Linux 进程底层拆解:从 “先描述再组织” 到 O (1) 调度,程序员必备 OS 知识

2025博客之星年度评选已开启 10w+人浏览 2.3k人参与


个人主页

🎬 个人主页Vect个人主页

🎬 GitHubVect的代码仓库

🔥 个人专栏: 《数据结构与算法》《C++学习之旅》《Linux

⛺️Per aspera ad astra.


文章目录

1. 基本概念和基本操作

内核观点:进程是担当分配系统资源(CPU时间、内存)的实体

本质:进程=内核数据机构+程序的代码和数据

程序是静态的文件,进程是动态的执行过程,内核数据结构是进程的“身份证+状态记录”

如何进行进程管理呢?—>先描述再组织

image-20251218124346545

  • 先描述:
    • code.c:静态的源代码文件,只包含程序的代码逻辑
    • myexe: 编译后的二进制可执行文件,静态的,存在磁盘中,包含程序初始数据和二进制代码
    • 磁盘: 持久化存储介质,存放未运行的静态程序文件
    • 内存: 临时运行介质,执行./myexe时,myexe的代码和数据会从磁盘加载到内存中,同时内核会为它创建PCB(process control block)
    • PCB(struct task_struct): 进程的属性,每个PCB对应一个进程
    • CPU: 执行硬件 ,通过调度机制,从内存中的PCB链表中调度进程,执行对应的代码和数据
  • 再组织:
    1. 静态的code.c被编译成myexe,以文件形式加载到磁盘中
    2. 执行myexe时,代码和数据加载到内存
    3. 内核为内存中的myexe创建PCB,并将多个进程的PCB组织成链表
    4. CPU通过调度,从PCB链表中选择合适的进程,执行代码和数据

所以,进程会被OS根据task_struct属性进行调度和执行


2. PCB内部详细结构(task_struct详解)

PCB(Process Control Block,进程控制块)是内核用于描述进程的核心数据结构,Linux 下具体实现为task_struct,加载在内存中,包含进程所有属性

核心属性分类

  • 标识符(pid ppid):描述本进程的唯一id,用于区别其他进程
  • 状态:任务状态、退出代码、退出信号等
  • 优先级:相对于其他进程,执行的先后顺序
  • 程序计数器:程序中即将被执行的下一条指令的地址
  • 内存指针:包括程序代码和进程相关数据的指针、其他进程共享的内存块的指针
  • 上下文数据:进程执行时寄存器中恢复的数据
  • IO状态信息
  • 记账信息

这些核心属性都会在下文一一讲解

PCB的组织方式

所有运行在系统里的进程都以task_struct双链表的形式存在内核里,且task_struct包含进程所有属性(标识符、状态、优先级等)

image-20251218144810233

内核通过遍历该链表管理所有进程,如查找、调度、终止进程


3. 如何查看进程?

/proc文件系统(内核直接暴露的进程信息)

我们一般是用户级别访问,而有些文件可能会访问受限

  • 原理:Linux把每个进程的详细信息以目录形式存储在/proc/[PID]

    image-20251218145359830

  • 示例:

    查看PID=1的信息:ls /proc/1

    image-20251218145707070

ps指令(用户级进程查看工具)

  • ps aux: 查看是同所有进程(a->所有终端,u->用户中心格式,x->无控制终端进程)

  • ps -l:查看当前终端进程,详细显示优先级

  • ps ajx | grep 程序名

    image-20251218151222260

    image-20251218151322997

观察一下这个现象:

12月18日

为什么删除了可执行文件,进程还能运行?

我们删除的是磁盘中的文件,而正在执行的文件已经被加载到了内存,二者不相互影响!如果进程终止,下次就无法继续运行了!

image-20251218152713791

top指令(动态监控进程)

实时刷新进程状态(默认3秒一次),支持修改优先级

操作:

  • 进入top:输入top
  • 调整优先级:按r->输入进程pid->输入新的nice值
  • 退出:按q

pid和nice值后续会详细讲


4. 如何创建进程(fork函数详解)

核心功能:

  • 系统调用:pid_t fork(void);头文件:<sys/types.h> <unistd.h>
  • 功能:创建一个新进程(子进程),父进程和子进程从fork之后的代码开始执行

两个返回值:->确保进程之间的独立性

image-20251218182110119

  • 父进程:返回子进程的pid->用于识别子进程
  • 子进程:返回0,->表示自身是子进程
  • 错误返回:-1->表示资源不足或无法创建子进程

通过文档我们可以知道,一个父进程可以有多个子进程,但是一个子进程只能有一个父进程,这个很好理解,所以创建子进程成功,用0表示成功即可,而对于父进程,需要子进程的pid进行管理

代码共享,数据私有:->验证了进程间有独立性

  • 代码段:父子进程共享一份程序代码(fork后执行相同的后续代码)

  • 数据段(全局变量、局部变量):父子进程独立享有数据

  • 代码示例:

    #include <stdio.h>
    #include <unistd.h>
    #include <sys/types.h>
    
    // 全局变量(数据段)
    int global_val = 100;
    
    int main(){
      // 局部变量(栈段)
      int local_val = 10;
    
      // fork前:打印初始值,验证代码执行流程
      printf("fork前,全局变量: %d, 局部变量:%d\n",global_val, local_val);
    
      // 创建子进程
      pid_t ret = fork();
      if(ret < 0){
        perror("fork err");
      }else if(ret == 0){
        // 子进程分支,代码共享,执行相同的代码逻辑
        printf("\n子进程pid:%d, ppid:%d\n",getpid(),getppid());
        // 子进程修改前
        printf("子进程修改前:全局变量:%d, 局部变量:%d\n",global_val,local_val);
    
        // 子进程修改变量
        global_val = 500;
        local_val = 200;
    
        // 子进程修改后
        printf("子进程修改后:全局变量:%d, 局部变量:%d\n",global_val,local_val);
        sleep(10);
      }else{
        // 父进程分支
        // 父进程sleep休眠 保证子进程完成变量修改
        sleep(2);
    
        printf("\n父进程pid:%d, ppid: %d\n",getpid(),getppid());
    
        printf("父进程-子进程修改后:全局变量:%d, 局部变量:%d\n",global_val,local_val);
        sleep(10);
      }
      return 0;
    }
    
    

    输出结果:

    fork前,全局变量: 100, 局部变量:10
    
    子进程pid:14567, ppid:14566
    子进程修改前:全局变量:100, 局部变量:10
    子进程修改后:全局变量:500, 局部变量:200
    
    父进程pid:14566, ppid: 28859
    父进程-子进程修改后:全局变量:100, 局部变量:10
    
    

    分析:

    • pid_t ret = fork();这行代码的行为:
      1. 基于当前的父进程,创建子进程,形成子进程的PCB
      2. 父子进程从fork的下一行代码开始,各自独立执行代码-> 父子进程进入不同的逻辑判断
      3. 数据层面:初始父子进程同值,修改后各自独立

    这里再讲一下分支问题:为什么else ifelse这两个逻辑同时执行了?

    不是同一个进程同时执行两个分支,而是fork创建了两个独立的进程,各自执行不同的分支!!!

    1. fork 调用成功后,内核复制父进程的``task_struct`,创建子进程的 PCB。
    2. 父进程和子进程同时从fork()之后的代码开始运行,但ret值不同:
      • 父进程的ret是子进程 PID(正数)→ 进入else分支。
      • 子进程的ret是 0 → 进入else if (ret == 0)分支。
    3. 两个进程是独立的执行流,因此看起来 “同时” 进入了不同的分支(本质是并发执行)。

补充知识点:详细理解cwd

先来看一段代码:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main(){
  FILE* fptr = fopen("new.txt","w");
  if(fptr == NULL){
    perror("fopen err");
  }

  pid_t id = getpid();
  while(1){
    printf("我是一个进程,我的pid: %d\n",id);
    sleep(1);
  }

  return 0;
}

如果文件不存在,则会在当前工作目录创建新文件:

image-20251218193155629

查看cwd:ls -l /proc/PID/cwd

image-20251218193823959

这是代码执行的逻辑:

image-20251218194349413

exe文件指向进程的详细路径->源可执行文件路径,而每个进程都会记录自己的exe路径和cwd

想要修改cwd,可以利用chdir函数,这里不做演示

父进程的父进程是谁?

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main(){
  pid_t id = fork();

  if(id == 0){
    printf("我是子进程,pid: %d, ppid: %d\n",getpid(),getppid());
  }else{
    printf("我是父进程,pid: %d, ppid: %d\n",getpid(),getppid());
  }

  sleep(1000000);
  return 0;
}

image-20251218200034002

命令行中,执行命令/执行程序,本质时bash的进程创建的子进程,最终由子进程执行我们的代码

创建多个进程

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main(){
  for(int i = 0; i < 10; i++){
    pid_t id = fork();
    if(id == 0){
      printf("子进程PID: %d,PPID: %d, 序号:%d\n",getpid(),getppid(),i + 1);
      sleep(2);
      return 0; // 子进程退出
    }
  }
    sleep(5); // 让子进程跑完
    printf("父进程:%d,所有子进程创建完毕\n",getpid());

    return 0;
}


5.进程状态

补充知识:

  • **并发:**多个进程在一个CPU下执行,CPU给每一个进程预分配一个时间片,基于时间片,进行调度轮转执行进程

  • **并行:**多个进程在多个CPU下同时执行

  • 时间片: 在Linux/Windows民用级别的OS时分时操作系统,调度任务追求公平

    与之对应实时OS,高优先级,比如车载OS,刹车操作时高优先级执行的

操作系统学科视角下的进程状态

image-20251218211956245

  • 创建态:进程刚被创建,内核正在分配PCB、内存资源,山我给进入就绪队列
  • 就绪态:进程已具备执行条件,等待CPU调度(未占用CPU,但随时能跑,在就绪队列排队)
  • 运行态:进程正在CPU上执行指令(单核CPU同一时间只有1个进程处于这个状态)
  • 阻塞态:进程因为等待某事件(IO完成、资源可用、信号),无法执行,主动放弃CPU(不占用CPU,在等待队列休眠)
  • 终止态:进程执行完毕/被终止,内核正在回收资源,资源回收完成后彻底消失

阻塞(等待)的本质:

阻塞(等待)是进程主动放弃CPU使用权的核心机制:

当进程需要的事件未就绪:

  • 读磁盘但数据为到
  • 等待用户输入
  • 等待子进程退出

等等情况,继续占用CPU只会浪费CPU资源,阻碍其他进程正常运行

因此,进程从运行/就绪态进入阻塞态,被内核放入对应事件的等待队列,直到事件完成之后,内核将其唤醒至就绪态(不能直接到运行态,如果有其他进程在运行态,直接抢占吗?),重新排队等候CPU调度

Linux视角下的进程状态

状态缩写全程对应OS理论核心特征
运行/就绪RRunning/Runnable运行态+就绪态要么CPU执行,要么在“运行队列”等待调度(Linux不区分运行/就绪,统一为R)
可中断等待(睡眠)SInterruptible Sleep阻塞态(可中断)等待事件(sleep、键盘输入、wait),可被信号(killctrl+c)唤醒
不可中断等待(睡眠)DUninterruptible Sleep阻塞态(不可中断)等待关键资源(磁盘IO),不能被信号唤醒 -> 避免数据损坏、丢失
暂停态TStopped特殊阻塞状态被信号(ctrl+zkill -19)暂停,可通过fg/bg恢复
僵尸态ZZombie终止态(未回收)进程已退出,代码和数据被清理,但PCB仍保留,等待父进程回收处理
死亡态XDead终止态(已回收)进程资源彻底释放,瞬间状态,ps无法捕获

进程状态查看

ps aux / ps axj

  • a:显示一个终端所有的进程,包括其他用户的进程
  • x:显示没有控制终端的进程,例如后台运行的进程
  • j:显示进程归属的进程组ID、会话ID、父进程ID以及与作业控制相关的信息
  • u:以用户为中心的格式显示进程信息,提供用户、CPU和内存使用情况等详细信息

代码演示各种状态

R+S态

#include <stdio.h>
#include <unistd.h>

int main(){
  pid_t pid = getpid();
  printf("进程ID: %d\n",pid);
  printf("阶段: R态(死循环,占用CPU)\n");
  /*while(1){
    // 死循环 持续占用CPU
  }
  */
  // 以下代码需要注释掉while循环,演示S态
  
   printf("阶段:S态(sleep 10秒,可中断等待\n");
   sleep(10);
   printf("sleep结束,回到R态\n");

  return 0;

}

image-20251218221545751

image-20251218222034112

S态可悲信号唤醒/终止

+代表进程在前台执行

T态(暂停)

#include <stdio.h>
#include <unistd.h>

int main(){
  pid_t pid = getpid();
  printf("进程PID:%d\n",pid);
  printf("按ctrl+z暂停进程,变为T态,用fg恢复变为R态\n");

  while(1){
  }

  return 0;
}

image-20251219092650912

在我们debug时,进程追踪到断点处停下,此时进程就是T状态

Z态

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main(){
  pid_t child_pid = fork();
  if(child_pid == 0){
    // 子进程:立刻退出,成为僵尸进程
    printf("子进程PID:%d,即将退出\n",getpid());
    return 1;
  }else{
    // 父进程 无限循环 不回收子进程
    printf("父进程PID:%d, 子进程PID:%d\n",getpid(),child_pid);
    printf("子进程成为Z态,请查看状态\n");
    while(1){  }
  }

  return 0;
}

image-20251219094511140

对于进程退出:代码会不会执行,释放对应的代码和数据,保留退出信息保存在task_struct,方便用户未来管理

而对于僵尸状态:子进程的task_struct由内核维护,方便未来父进程读取退出状态;若是一直没有父进程处理,会一直处于Z态,task_struct就会一直消耗内存,可能导致内存泄漏!这就是僵尸进程

孤儿进程

父进程先退出,子进程成为孤儿进程,此时谁来管理子进程?->被系统的1号进程领养

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main(){
  pid_t ret = fork();

  if(ret == 0){
    printf("子进程PID:%d,PPID:%d\n",getpid(),getppid());
    printf("子进程不退出\n");
    sleep(6);
    while(1){ sleep(15);  }
  }else{
    printf("父进程:%d,父进程即将退出\n",getpid());
    sleep(5);
    return 1;
  }

  return 0;
}

输出:

父进程:4598,父进程即将退出
子进程PID:4599,PPID:4598
子进程不退出
父进程退出
[vect@VM-0-11-centos process]$ 子进程PID:4599,新的PPID:1

1号进程的信息:

[vect@VM-0-11-centos process]$ ps -p 1 -f
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 Nov26 ?        00:04:27 /usr/lib/systemd/systemd --switched-root

6. 进程优先级

优先级的作用

  • 决定进程竞争CPU的先后顺序:优先级越高,越先被调度器选中执行
  • 解决竞争性问题:多进程并发时,确保重要的进程优先获得CPU资源

PRI和nice值

  • PRI(Priority):静态优先级(调度取决于PRI值,越小优先级越高)
  • nice:优先级修正值,范围[-20,19]
  • 计算公式: P R I ( n e w ) = P R I ( 默认 ) + n i c e PRI(new) = PRI(默认) + nice PRI(new)=PRI(默认)+nice 默认值为80

优先级查看与调整

查看优先级

ps -l

[vect@VM-0-11-centos ~]$ ps -l
F S   UID   PID  PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
0 S  1002 17266 17265  0  80   0 - 29251 do_wai pts/2    00:00:00 bash
0 R  1002 17371 17266  0  80   0 - 38332 -      pts/2    00:00:00 ps

  • UID:执行者的身份,由谁启动的进程

    文件会记录下拥有者、所属组,而所有的操作都是进程操作,进程会记录谁启动的自己,这便对应上了权限控制

  • PID:进程的编号

  • PPID:父进程的编号

  • PRI:优先级,越小越先执行

  • NI:nice值,修正PRI

修正优先级(不建议修改)

  • nice命令(创建进程时设置NI)

    nice -n [ni值] 程序,例如:nice -n -5 ./test → 启动 test 进程,NI=-5,PRI=80-5=75

  • renice命令(调整已经运行的进程NI)

    renice [NI值] -p [PID],例如:renice 10 -p 3239 → 把 PID=3239 的进程 NI 设为 10,PRI=80+10=90

  • top命令(动态调整)

​ top → 按r → 输入 PID → 输入新 NI 值


7. 进程切换

切换的核心原因

  • 时间片耗尽:每个进程都有固定的时间片,用完后必须让出CPU
  • 高优先级抢占:新创建的高优先级进程会抢占低优先级进程的CPU
  • 进程主动放弃CPU:如进程调用sleep进入S态、等待IO

注意:一个进程时间片耗尽时,并不一定执行完,这时也必须退出CPU,等待下一次调度,所以一定要保存上下文数据!!!不然下次接着运行发现没数据就尴尬了

切换的本质:进程上下文数据的保存和恢复

  • 上下文: 进程执行时CPU寄存器中的数据
  • 切换流程:
    1. 保存当前进程的上下文(将寄存器里的数据存入进程的task_struct
    2. 从就绪队列中选择下一个要执行的进程
    3. 加载下一个进程的上下文(从下一个进程的task_struct读取数据,写入寄存器)
    4. 跳转到下一个进程的程序计数器指向的指令,开始执行

切换的目的是实现并发:单核CPU下,通过快速切换,让多个进程看起来同时执行,人是察觉不到的

image-20251219114339940

8. Linux2.6内核进程 O ( 1 ) O(1) O(1)调度队列

image-20251219123540589

数据结构功能

  1. struct queue 优先级管理单元

    O ( 1 ) O(1) O(1)调度的最小功能端元,负责管理同一状态(活跃/过期)下的所有优先级进程,三个成员的功能:

    • nr_active:记录当前queue里的进程总数

    • bitmap[5]优先级标记位图,每个比特位对应一个进程优先级[0,139],其中[100,139]是用户级别的普通优先级

      为什么设计大小为5? 32 × 5 = 160 > = 140 32 \times 5 = 160 >= 140 32×5=160>=140,刚好能覆盖

    • queue[140]140个优先级的进程哈希桶

  2. struct queue array[2] 双队列容器

    这是一个 存 2 个struct queue的数组,专门用来放活跃队列过期队列

    • array[0]:默认作为活跃队列(存还有剩余时间片的进程)
    • array[1]:默认作为过期队列(存时间片已经耗尽的进程)
  3. struct runqueue CPU就绪队列总管理

    每个CPU都有一个独立的runqueue,它是 调度器的总入口,通过两个指针管理双队列:

    • struct queue* active:指向当前的活跃队列(调度器只从这个队列里选进程运行);
    • struct queue* expired:指向当前的过期队列(暂时不参与调度的进程容器)

算法逻辑

1. 初始化就绪队列:哈希桶与映射关系的初始化

runqueue完成初始化(本质是初始化哈希表的容器与映射规则):

  • 初始化哈希桶数组:清空array[0](活跃队列)和array[1](过期队列)的queue[140]
  • 初始化键的标记位:清空bitmap(用于标记 “哪些优先级键对应的哈希桶有进程”)
  • 初始化哈希表状态:清空nr_active(记录每个哈希表内的进程总数)
  • 绑定哈希表指针:让active指向array[0](活跃哈希表)、expired指向array[1](过期哈希表)

2. 新进程入队:哈希表的 “键→桶” 插入操作

当进程被创建 / 唤醒时(本质是向活跃哈希表插入元素):

  1. 确定哈希键:获取进程的内核优先级(比如优先级 40,这是哈希的键值)
  2. 执行哈希映射:通过直接地址哈希函数 f ( 键 ) = 键 f(键)=键 f()=,映射到active哈希表的哈希桶——queue[40]链表
  3. 插入哈希桶:将进程节点插入到queue[40]链表(哈希桶)的尾部
  4. 标记有效键:将active哈希表的bitmap中 “对应键(优先级 40)的比特位” 设为 1(标记该键对应的哈希桶有元素)
  5. 更新哈希表状态:active哈希表的nr_active++

3. 调度器选进程运行:哈希表的 “有效键→桶” 查找操作

调度器从active哈希表选进程(本质是查找哈希表中 “有效键” 对应的桶,取头部元素):

  1. 有效键:遍历active哈希表的bitmap(最多遍历 5 次,对应图里的bitmap[5]),找到第一个为 1 的比特位—— 这是当前有进程的 “最高优先级键”
  2. 执行哈希映射:通过 f ( 有效键 ) = 有效键 f(有效键)=有效键 f(有效键)=有效键,映射到对应的哈希桶(比如有效键是 40,对应queue[40]链表)
  3. 取桶内元素:从该哈希桶的链表头部取出进程,让 CPU 切换到这个进程运行

4. 进程时间片耗尽→移到过期队列:哈希表间的 “键→桶” 迁移操作

进程时间片耗尽时(本质是从活跃哈希表删除元素,插入到过期哈希表):

  1. 从原哈希桶删除:通过进程的 “优先级键”,映射到active哈希表的对应桶,将进程从该桶的链表中移除
  2. 标记键失效:若该桶的链表为空,将active哈希表bitmap中对应键的比特位设为 0
  3. 更新原哈希表状态:active哈希表的nr_active--
  4. 插入新哈希桶:通过同一个 “优先级键”,映射到expired哈希表的对应桶(比如键是 40→expired->queue[40]链表),将进程插入该桶
  5. 标记新键有效:将expired哈希表bitmap中对应键的比特位设为 1,同时expired哈希表的nr_active++

5. 活跃队列空了→交换两队列指针:哈希表的批量复用

active哈希表的nr_active=0(所有元素都迁移到过期哈希表):交换activeexpired的指针(本质是复用哈希桶数组,保持 “键→桶” 的映射关系不变):

  • 原来的active(指向array[0])现在指向array[1]
  • 原来的expired(指向array[1])现在指向array[0]

整个流程的核心是:用 “进程优先级” 作为键,通过直接地址映射到固定的哈希桶(queue[优先级]链表),从而让插入、查找、迁移操作都保持 O (1) 时间复杂度


9. 命令行参数和环境变量

命令行参数:用户显式传递的启动指令

底层结构:argc+argv数组

程序启动时,OS会将命令行参数组织成两个核心参数,传递给main函数

  • int argc:参数个数,至少为1,默认参数是程序自身的路径
  • char* argv[]:参数数组,每个元素是指向参数字符串的指针,最后一个元素为NULL

核心原理:程序启动时的“数据注入”

在Linux系统里,当用户执行一个程序(如ls -l -a -n ...)时,OS会做三件事:

  1. 创建进程(分配内核数据结构)
  2. 将程序的代码和数据加载到内存中
  3. 命令行参数环境变量组成数据结构,如下图

对于ls -l -a -n ...这个命令,可以理解为这样的结构

image-20251220184120313

代码1:

#include <stdio.h>

int main(int argc, char* argv[]){
  printf("===命令行参数===\n");

  printf("argc: %d\n",argc);

  for(int i = 0; argv[i]; i++){
    printf("argv[%d] = %s\n",i,argv[i]);
  }

  return 0;
}

输出结果:

[vect@VM-0-11-centos environ]$ gcc -std=c99 -o demand demand.c
[vect@VM-0-11-centos environ]$ ./demand
===命令行参数===
argc: 1
argv[0] = ./demand

代码2:

#include <stdio.h>
#include <string.h>

int main(int argc, char* argv[]){
    // 第一步:检查参数个数,至少需要1个选项参数(argv[1])
    if (argc < 2) { // 仅输入程序名,未传任何选项
        printf("错误:未传入选项!\n");
        printf("有效选项:-opt1 或 -opt2\n");
        printf("使用示例:./a.out -opt1\n");
        return 1; // 非0返回值表示程序异常退出
    }

    // 第二步:判断argv[1](唯一的选项参数位置)
    if (strcmp(argv[1], "-opt1") == 0) {
        printf("程序选项1\n");
    } else if (strcmp(argv[1], "-opt2") == 0) {
        printf("程序选项2\n");
    } else {
        printf("错误:无效选项「%s」!\n", argv[1]);
        printf("有效选项:-opt1 或 -opt2\n");
        return 1;
    }

    return 0;
}

输出结果:

[vect@VM-0-11-centos environ]$ gcc -o demand demand.c
[vect@VM-0-11-centos environ]$ ./demand
错误:未传入选项!
有效选项:-opt1 或 -opt2
使用示例:./a.out -opt1
[vect@VM-0-11-centos environ]$ ./demand -opt1
程序选项1
[vect@VM-0-11-centos environ]$ ./demand -opt2
程序选项2
[vect@VM-0-11-centos environ]$ ./demand -op3
错误:无效选项「-op3」!
有效选项:-opt1 或 -opt2

为什么要设计命令行参数?

解决程序静态性和运行动态性的矛盾->让同一个程序,可以根据命令行参数,指定不同的选项,实现不同的功能。就像我们熟悉的ls是程序,-a -l是不同选项,同一个程序用不同的选项实现不同的功能

  1. 动态传递配置,无需修改代码

    命令行参数让程序无需重新编译,通过不同参数实现不同功能

  2. 标准化进程启动接口

    所有程序都通过argc/argc接收外部配置,形成统一规范,OS只用按照固定的方式传递参数,不用关心程序内部逻辑->做到OS管理资源,程序专注业务的解耦

  3. 支持自动化脚本调用

    命令行参数可被Shell脚本批量传递

再来一段代码:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>

int main(int argc, char* argv[]){
  // 无效参数提示
  if(argc < 2){
    printf("当前指令错误!用法:\n");
    printf("选项: -add\r -sub\r -mul\r -div\n");
    printf("示例: ./calculate -add 10 20 -->计算10+20\n");
    return 1;
  }

  if(argc != 4){
    printf("参数个数错误!每个运算需要传入两个数字!\n");
    return 1;
  }

  int num1 = atoi(argv[2]);
  int num2 = atoi(argv[3]);

      if (strcmp(argv[1], "-add") == 0) {
        // 加法:num1 + num2
    } else if (strcmp(argv[1], "-sub") == 0) {
        printf("%d - %d = %d\n", num1, num2, num1 - num2);
    } else if (strcmp(argv[1], "-mul") == 0) {
        printf("%d × %d = %d\n", num1, num2, num1 * num2);
    } else if (strcmp(argv[1], "-div") == 0) {
        if (num2 == 0) {
            printf("错误:除数不能为0!\n");
            return 1;
        }
        printf("浮点数结果:%d ÷ %d = %.2f\n", num1, num2, (float)num1 / num2);
    } else {
        // 无效选项提示
        printf("无效选项「%s」!\n", argv[1]);
        printf("支持的选项:-add  -sub  -mul  -div\n");
        return 1;
    }

    return 0;

}

输入输出示例:

[vect@VM-0-11-centos environ]$ ./calculate
当前指令错误!用法:
 -div -add
示例: ./calculate -add 10 20 -->计算10+20
[vect@VM-0-11-centos environ]$ ./calculate -mul 19 21
19 × 21 = 399
[vect@VM-0-11-centos environ]$ ./calculate -div 19 12
浮点数结果:19 ÷ 12 = 1.58

环境变量

什么是环境变量

环境变量是OS用来指定运行环境的参数,有两个核心特性:

  • 全局性: 可以被子进程继承(OS在创建子进程时,会自动拷贝父进程的环境表)
  • 配置统一: 帮助程序解决“找不到依赖”的问题(如编译器找动态库、Shell找命令路径),避免每个进程重复匹配

举个例子:

写的一个C程序编译生成a.out,运行时需要链接glibc库。不用告诉程序glibc在哪里——OS 会通过环境变量提前配置好库的搜索路径,程序启动时OS会自动读取环境变量,帮程序找到依赖库

常见的环境变量

环境变量作用例子
PATH指定「命令搜索路径」:OS 执行命令时,会遍历PATH中的目录找可执行文件为什么ls能直接运行?因为/bin/lsPATH中;而自己写的a.out需要./a.out(带路径),因为它不在PATH
HOME指定用户家目录:OS 为不同用户(root / 普通用户)分配独立工作空间,统一默认路径root 用户echo $HOME输出/root,普通用户输出/home/xxxcd ~本质是进入HOME指定的目录
SHELL指定当前终端的解释器:OS 为用户提供统一的命令交互接口通常值为/bin/bash,表示 OS 使用 bash 作为默认 Shell,所有命令输入都由 bash 解析后交给 OS 执行

环境变量的使用:OS提供的操作接口

OS提供了命令行工具和系统调用,让用户/程序能直接操作它

用户级接口

  • echo $NAME:查看某个环境变量值

    [vect@VM-0-11-centos ~]$ echo $PATH
    /usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/vect/.local/bin:/home/vect/bin
    
  • export NAME=value: 新增/修改环境变量

    image-20251220215341215

    解析一下:hello所在的当前目录./不在PATH中,OS找不到,添加之后,OS便能在PATH中找到hello->这就是ls直接能运行的原理!

  • env:查看所有环境变量

    image-20251220215859743

    截取了几个关键的环境变量,这个显式格式是KEY=VALUE键值对模型,所以环境变量是以键值对形式存储的

  • unset NAME:清除环境变量

  • set:查看本地Shell变量+环境变量:包含env的输出并多了Shell的本地临时变量

我们再来看两个例子:

#include <stdio.h>
#include <stdlib.h>

int main(){
  char* env = getenv("MYENV");

  if(env == NULL){
    printf("未找到\n");
  }else{
    printf("成功读取MYENV:%s\n",env);
  }

  return 0;
}
[vect@VM-0-11-centos environ]$ MYENV="hello"
[vect@VM-0-11-centos environ]$ vim find_myenv.c
[vect@VM-0-11-centos environ]$ gcc -o find_myenv find_myenv.c
[vect@VM-0-11-centos environ]$ ./find_myenv
未找到

分析:

  1. MYENV="hello":OS不会将MYENV写入Shell的环境表中(OS只把export的变量写入环境表),MYENV属于本地变量,存储在Shell进程的私有内存中
  2. 运行./find_myenv:OS创建子进程执行程序,OS复制Shell的环境表给子进程(没有MYENV),程序调用getenv("MYENV"),遍历子进程的环境表,找不到对应数据
[vect@VM-0-11-centos environ]$ export MYENV="hello"
[vect@VM-0-11-centos environ]$ ./find_myenv
成功读取MYENV:hello

分析:

  1. export MYENV="hello":OS将MYENV写入环境变量表中
  2. 运行./find_myenv:OS创建子进程执行程序,OS复制Shell的环境表给子进程(有MYENV),程序调用getenv("MYENV"),遍历子进程的环境表,找到对应数据

总结:环境变量是进程的全局配置,OS 在创建子进程时,会复制父进程的 PCB、内存空间,同时也复制环境表—— 保证子进程继承父进程的运行环境,无需重新配置

操作环境变量:OS提供的系统调用接口

命令行第三个参数env[]

OS在启动进程时,会将环境变量表作为第三个参数传给main

#include <stdio.h>
#include <stdlib.h> 

int main(int argc, char* argv[], char* env[]){
  for(int i = 0; env[i]; i++){
    printf("%s\n",env[i]);
  }
  return 0;
}

全局变量environ

OS在libc中提供了全局变量environ,直接指向环境变量表(不用传递参数)

#include <stdio.h>

extern char** environ;    // 不在头文件中,需要提前声明
int main(){
  for(int i = 0; environ[i]; i++){
    printf("%s\n",environ[i]);
  }
  return 0;
}

系统调用getenv/putenv
int main(){
  char* path = getenv("PATH");
  printf("PATH:%s\n",path);

  putenv("MYENV=hhhh"); // 设置环境变量
  
  return 0;
}


10. 进程地址空间

程序地址空间

先来看一个有趣的现象:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int global = 10;
int main(){
  pid_t ret = fork();
  if(ret < 0){
    perror("fork err\n");
    return 1;
  }else if(ret == 0){
    // 子进程
    while(1){
      printf("I am child process,pid:%d,ppid:%d,global:%d,global pos:%p\n",getpid(),getppid(),global, &global);
      ++global;
      sleep(1);
    }
  }else{
    // 父进程
    while(1){
       printf("I am father process,pid:%d,ppid:%d,global:%d,global pos:%p\n",getpid(),getppid(),global, &global);
       sleep(5);
    } 
  }
  return 0;
}

输出:image-20251221165111755

父进程和子进程的数据是各自独立,代码是共享的,这个我们已经了解,但是为什么global的地址一直没有变化?

我们根据输出内容可以推断出:

  • 输出的内容不一样,所以父子进程输出的变量绝对不是同一个变量!
  • 不同的变量却有相同的地址,说明这个地址一定是虚拟的!

其实,用户层面看不到真实的物理地址,我们能看到的只有虚拟地址!!!

实际的物理地址是由OS统一管理的,OS系统把虚拟地址转化成物理地址

image-20251221183939848

再来回忆一下以前的程序内存布局图:

image-20251221171247319

代码验证:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int global_val = 10;
int g_val;
int main(int argc, char* argv[], char* env[]){
  const char* str = "hello";
  printf("code addr: %p\n",main);
  printf("init global_val addr: %p\n",&global_val);
  printf("uninit g_val addr: %p\n",&g_val);
   
  static int test_val = 1;
  char* heap_mem1 = (char*)malloc(10);  
  char* heap_mem2 = (char*)malloc(10);
  char* heap_mem3 = (char*)malloc(10);
  printf("haep addr: %p\n",heap_mem1);
  printf("haep addr: %p\n",heap_mem2);
  printf("haep addr: %p\n",heap_mem3);

  printf("test_val static addr: %p\n",&test_val);

  printf("stack addr: %p\n",&heap_mem1);
  printf("stack addr: %p\n",&heap_mem2);
  printf("stack addr: %p\n",&heap_mem3);

  printf("read only string add: %p\n",str);
  for(int i = 0; i < argc; i++){
    printf("argv[%d]: %p\n",i,argv[i]);
  }
  for(int i = 0; env[i]; i++){
    printf("env[%d]: %p\n",i, env[i]);
  }

  return 0;
}

image-20251221173549702

进程地址空间

所以以前所说的程序的地址空间是有些问题的,确切地说是进程地址空间,那张内存布局图应该叫进程地址空间

进程空间地址本质是内核数据结构对象(类似PCB),在Linux中由结构体mm_struct实现

如何理解进程地址空间呢?

进程地址空间类似与一把尺子,尺子的刻度由0x000000000xffffffff,按照刻度被划分为各个区域->代码区、堆区、栈区等等

mm_struct中,记录了各个边界刻度,如下图所示:

image-20251221185044287

怎么完成区域划分呢?类比”38线“->一条线划分出两个区间,我们知道区间的起始值和结束值,便可划分出一个区域!

mm_struct中,每个刻度都代表一个虚拟地址,这些虚拟地址通过页表映射与物理内存建立联系。由于地址是从0x000000000xffffffff线性增长的,所以虚拟地址又叫做线性地址

所以:

  • 堆向上增长以及栈向下增长实际就是改变mm_struct中栈和堆的边界数值->好比改变“38线”的位置,两个人的所占空间就改变!
  • 我们生成的可执行程序实际上也被划分为了各个区域,例如初始化区域,未初始化区域、代码段、只读数据段。当可执行程序运行起来,OS则将对应的数据加载到对应内存中即可,大大提高了OS的操作效率,而进行可执行程序的分区操作,实际上是编译器的操作,我们所说的代码优化级别实际上是编译器掌握的

每个进程被创建是,对应的task_structmm_struct也会被创建。OS可以通过进程的task_struct找到mm_struct,因为task_struct中有一个结构体指针存的是mm_struct的地址

image-20251221190327662

例如:父进程有自己的task_structmm_struct,该父进程创建的子进程也有自己的task_structmm_struct,父子进程的mm_struct当中的各个虚拟地址分别通过页表映射到物理空间的具体位置,如下图:

image-20251221193910354

当子进程被创建时,子进程和父进程的,代码和数据是共享的,即父子进程的代码和数据通过页表映射到物理内存的同一块空间。只有当父进程或子进程需要修改数据的时候,才将进程的数据在内存中拷贝一份,然后进行修改

例如,子进程修改global_val的值,那么此时就在内存中的某处存储global_val的新值,并且改变子进程中的global_val的虚拟地址,这个过程通过页表映射即可完成。

image-20251221193736132

这种方式叫做写时拷贝

为什么要写时拷贝?

进程间具有独立性。多进程运行,需要独享资源,确保多进程运行期间互不干扰,子进程的修改不能干扰父进程

为什么不在创建子进程时就拷贝数据?

子进程不一定会使用父进程的所有数据,并且子进程不对数据写入的情况下,没有必要对数据进行拷贝,按需分配即可,这样可以高效利用空间

总结几个问题

程序加载到内存中的过程

  1. OS初始化进程的虚拟地址空间->创建mm_struct,调整区域划分(在编译时,代码段,未初始化字段、初始化字段、只读字段就已经划分好了)
  2. 加载进程(OS创建堆区、栈区、命令行参数和环境变量),申请物理空间
  3. 将虚拟地址和物理地址通过页表映射

为什么需要虚拟地址和页表?

  1. 虚拟地址和页表能保护内存->页表还有个rwx的标记位,可以判定原有内存数据的权限

    什么是野指针?

    指针指向被释放或未知的地址->物理内存被释放了->对应的页表虚拟地址和物理地址数据要删除,映射关系没了->此时要访问这个空间,查询页表会失败,OS直接杀掉进程->所以,野指针行为可能导致程序崩溃!

    为什么char* str = "hh"; *str = "hhhhhh";能编译通过,但运行时崩了?

    字符串常量所在的虚拟内存页被页表标记为只读,写操作被硬件和操作系统拦截->所以引入const关键字,在编译层面先做一层保护!

  2. 让进程管理和内存管理在OS层面解耦合

    进程管理只关心虚拟地址,完全屏蔽物理内存

    ->创建进程,只需初始化mm_struct、进程调度,秩序切换页表寄存器、进程销毁,秩序释放页表和mm_struct->完美做到进程隔离,每个进程一份独立页表,保证进程间独立性

    内存管理只关心物理地址,完全屏蔽进程身份

    ->分配物理内存,只需按页表分配,更新页表映射关系、回收物理内存,只需检查页表引用位/脏位判断是否被使用、内存共享,只需让多个进程的页表映射到同一物理页

全局变量、字符常量为什么有常性?

在虚拟地址空间中,全局变量存在未初始化区、字符常量存在只读字段区,随着进程一直存在,他们的地址能被所有类和函数看到

申请堆空间,是开辟物理空间吗?

不是!本质是把虚拟空间扩大,改变堆区的起始值和结束值就能做到,在使用时才开辟物理空间

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值