🌈 个人主页: 小小、码农的 CSDN 博客
🔥 系列方向: linux系统
💪 学习宣言: 行动胜于空谈,实践出真知
一个可执行文件放在磁盘上时,还只是程序。真正运行它以后,内核不仅要把代码交给CPU,还要记录它是谁、能不能运行、优先级多高,以及被切走后怎样继续执行。
Linux把这些进程信息放在task_struct中。要看懂这个结构,先沿着两步把进程引出来:程序为什么必须进入内存,多个程序运行以后为什么必须由操作系统管理。
1 程序为什么要先加载到内存
1.1 冯诺依曼体系结构
现代计算机由输入设备、输出设备、存储器、运算器和控制器五大部分组成。其中,运算器和控制器共同组成CPU,这里的存储器主要指内存。

冯诺依曼体系结构最重要的思想是存储程序:程序的指令和数据都要先放入内存,CPU再从内存中取出指令,完成译码和执行。
编译生成的可执行程序最初保存在磁盘上。此时它只是一个文件,CPU不能直接执行它。运行程序时,操作系统要先把所需的代码和数据加载到内存,CPU才能开始工作。
所以,程序要运行,第一步就是从磁盘进入内存。
1.2 内存怎样减少CPU等待
内存解决的不只是"程序能不能运行",还影响"程序运行得快不快"。
CPU的速度很快,磁盘、键盘和网卡等外设却慢得多。如果CPU每处理一点数据都要等待外设,整个系统的速度就会被慢速外设拖住。
内存位于CPU和外设之间,作用就是先把CPU接下来要处理的代码和数据准备好。
从磁盘读取文件时,系统可以先把一批数据放入内存。CPU之后访问这些数据时,直接读取内存,不必每次都重新等待磁盘。
输出也是一样。CPU可以先把结果写入内存中的缓冲区,再由显示器、磁盘或网卡按自己的速度处理。
所以,内存提高效率的关键并不是让外设变快,而是减少CPU等待外设的时间。
1.3 没有内存行不行
如果完全取消内存,让CPU直接等待磁盘等慢速设备,计算机在理论上仍能工作,但效率会很低。于是问题变成了:既然主存不能少,为什么不把它做得和高速缓存一样快?
理想的存储器应该同时满足三个条件:
text
速度快、容量大、价格低
但速度、容量和价格无法同时做到最优。
寄存器和高速缓存使用的存储介质速度很快,但每字节成本高,容量不可能做得特别大;磁盘价格低、容量大,但速度又远慢于CPU。内存使用的DRAM在速度、容量和价格之间做了折中。
text
越靠近CPU:速度越快,容量越小,每字节价格越高
越远离CPU:速度越慢,容量越大,每字节价格越低

理论上可以用更快的存储器代替内存,现实中却会遇到成本和容量问题。DRAM之所以成为主存,正是因为它在速度、容量和价格之间更均衡。
2 多个程序运行以后,为什么需要操作系统
程序加载到内存后,还不能随意使用CPU、内存和外设。
系统中同时存在很多程序。CPU先交给谁,内存分配多少,网卡收到的数据交给哪个程序,都需要一个统一的管理者。这个管理者就是操作系统。

设备驱动本身就是内核的一部分。用户程序通过库函数和系统调用向内核提出请求,内核再通过驱动操作具体硬件。
驱动负责硬件怎么用,操作系统负责什么时候用、允许谁用。程序访问文件、申请内存或创建进程,都要经过操作系统。
这里还要区分库函数和系统调用。系统调用是内核提供的受控服务入口,库函数则是用户态库提供的函数接口。有些库函数完全在用户态完成工作,有些则会在内部继续调用系统调用。
例如,printf会先在用户态完成格式化和缓冲,真正需要把数据写入文件描述符时,底层才可能进入内核。因此,"调用了库函数"和"执行了系统调用"不能简单画等号。后文的fork、getpid和getppid都需要内核参与,理解这层关系以后,它们的出现就不会显得突然。
2.1 操作系统的管理方法:先描述,再组织
操作系统管理一个对象时,首先要知道这个对象有哪些属性。
例如,管理一个文件需要知道它的名称、大小、权限和存储位置;管理一个进程需要知道它是谁、当前处于什么状态、占用了哪些资源。
这些属性会被保存在结构体中,这一步叫作描述。
系统中同类对象的数量很多,只有一个个独立的结构体仍然不好管理。操作系统还需要使用链表、队列、树或哈希表等数据结构把它们连接起来,这一步叫作组织。
text
被管理对象 → 使用结构体描述 → 使用数据结构组织
因此,操作系统的管理方法可以概括成六个字:
text
先描述,再组织
这样一来,操作系统对对象的管理,就变成了对相应数据结构的增删查改。进程也是操作系统管理的对象,自然也要使用这套方法。
3 Linux怎样描述和组织进程
3.1 程序和进程不是一回事
程序是磁盘上的可执行文件,是静态的。进程是程序运行起来后的执行实例,是动态的。
程序加载到内存后,操作系统还要记录它的标识符、状态、优先级、内存和文件等信息。只有代码和数据,没有这些管理信息,操作系统就无法调度和控制它。
从操作系统的角度看:
text
进程 = 程序的代码和数据 + 描述进程的内核数据结构
3.2 用PCB描述进程
操作系统用来描述进程的数据结构叫作进程控制块,也就是PCB(Process Control Block)。
每创建一个进程,操作系统都要建立对应的PCB。内核调度、查询或回收进程时,操作的就是这些PCB。
PCB是操作系统中的通用名称。具体到Linux,描述任务的结构体叫作task_struct。
本文先讨论单线程进程,可以暂时把一个进程理解为对应一个task_struct。学习多线程后会看到,每个线程也有自己的task_struct。
c
struct task_struct {
/* 进程的各种属性 */
};
task_struct非常庞大,没有必要一上来研究所有成员。本文只选能够构成进程概念主线的几组信息。有些属性直接保存在task_struct中,有些则要通过它关联到其他结构:
| 信息类别 | 代表性字段或关联 | 后文回答的问题 |
|---|---|---|
| 标识与父子关系 | pid、tgid、real_parent |
这个任务是谁,由谁创建 |
| 状态 | __state、exit_state |
现在能不能运行,是否正在退出 |
| 优先级与调度 | prio、static_prio、normal_prio、policy以及调度实体 |
多个任务都想运行时,CPU怎样选择 |
| 上下文 | stack以及体系结构相关的thread_struct |
被切走后怎样从原位置继续 |
| 文件系统信息 | fs、files |
当前目录在哪里,打开了哪些文件 |
| 地址空间 | mm指向的mm_struct |
能看到哪些虚拟地址,代码和数据实际放在哪里 |
命令行参数和环境变量也不是直接平铺在task_struct中。它们最终位于进程的用户地址空间,内核可以沿着task_struct -> mm -> mm_struct找到相应地址范围。
这就是后面的主线。在逐项研究这些信息之前,先看内核怎样把大量task_struct组织起来。
3.3 用内嵌链表组织task_struct
系统中存在很多进程,内核也就需要管理很多task_struct。把它们接入全局任务双链表,是Linux组织这些结构体的一种方式。
我们以前实现双向链表,通常把前后指针直接放进节点:
c
struct Node {
int data;
struct Node *next;
struct Node *prev;
};
next的类型是struct Node *,所以它直接指向下一个节点的起始地址。
Linux内核采用的是内嵌式链表。它先定义一个只包含前后指针的结构体:
c
struct list_head {
struct list_head *next;
struct list_head *prev;
};
再把它作为成员放入外层结构体。真实内核中的全局任务链表成员常叫tasks,为了便于说明,下面统一把它简化为links:
c
struct task_struct {
/* 进程的其他属性 */
struct list_head links;
/* 进程的其他属性 */
};
此时,links.next指向的不是下一个task_struct的起始地址,而是下一个task_struct内部links成员的地址。
text
普通双向链表:
next ─────→ 下一个Node的起始地址
内嵌式双向链表:
links.next ─────→ 下一个task_struct中的links成员

这样写以后,链表代码只需要认识list_head,不用关心外层究竟是task_struct还是其他结构体。
但它也带来了一个问题:沿着next找到下一个links后,怎样得到下一个task_struct的起始地址?
3.4 从links找到task_struct的首地址
假设node指向当前task_struct中的links成员,那么node->next拿到的是下一个links的地址,还不是外层task_struct的首地址。
想回到结构体开头,只要减去links距离task_struct开头的字节数。这个距离可以通过下面的式子得到:
c
&(((struct task_struct *)0)->links)
先把0转换成struct task_struct *,相当于暂时假设有一个task_struct从地址0开始;再通过->links找到其中的links成员,最后用&取出成员地址。因为结构体从0开始,所以这个地址值刚好就是links的偏移量。
注意: 这里的0只是为了计算成员位置,并不是真的要访问地址0中的数据。
得到偏移量后,用下一个links的地址减去它,就能回到外层结构体:
c
unsigned long offset =
(unsigned long)&(((struct task_struct *)0)->links);
struct task_struct *next_task =
(struct task_struct *)((char *)node->next - offset);
把node->next转换成char *,是为了按照字节减去偏移量。整个过程只有三步:
text
找到下一个links → 减去links的固定偏移量 → 得到task_struct首地址
这种内嵌方式不只用于全局任务链表。一个task_struct可以带有多个节点,分别进入不同的管理结构。后面讲运行队列时还会用到这一点。
进程已经被描述和组织起来。下面先看怎样在Linux中查看进程,再沿着task_struct中的信息继续向下。
4 在Linux中查看进程
4.1 /proc/PID记录了什么
Linux通过/proc目录向用户提供进程信息:
bash
ls /proc

其中,以数字命名的目录对应当前仍然存在的进程,目录名就是进程的PID。进程存在,对应的目录就存在;进程退出并被回收后,对应的目录也会消失。
下面以PID为104的进程为例。实际操作时,要换成当前存在的PID:
bash
ls -l /proc/104

这里记录的就是104号进程的信息。除了图中重点观察的cwd和exe,/proc/PID下面还有许多常用入口:
| 路径 | 能看到什么 |
|---|---|
/proc/PID/status |
名称、状态、PID、PPID、内存等摘要信息 |
/proc/PID/stat |
适合程序读取的进程统计字段 |
/proc/PID/cmdline |
程序启动时收到的命令行参数 |
/proc/PID/environ |
进程环境中的字符串 |
/proc/PID/maps |
虚拟地址空间中的映射区域 |
/proc/PID/fd/ |
已经打开的文件描述符 |
/proc/PID/cwd |
当前工作目录 |
/proc/PID/exe |
当前执行文件 |
现在不需要逐个研究。它们先说明一件事:状态、参数、环境、地址空间和文件,原本就是内核管理一个进程时需要描述的信息。
图中cwd和exe开头的l表示它们都是软链接,->后面是它们指向的位置。软链接后面会单独介绍,这里先看这两个链接能告诉我们什么。
cwd:相对路径从哪个目录开始
图中cwd -> /表示:这个进程当前的工作目录是根目录/。
cwd最大的作用,就是确定相对路径从哪里开始。
例如,程序使用fopen创建文件:
c
fopen("log.txt", "w");
log.txt不是绝对路径,内核会以进程的cwd为起点解析它。学习时可以简单理解为在文件名前面拼上cwd。如果cwd是/home/dragon,实际创建的就是:
text
/home/dragon/log.txt
而且cwd不是固定不变的,进程可以通过chdir修改它:
c
chdir("/tmp");
fopen("log.txt", "w");
调用chdir之后,进程的cwd变成了/tmp。此时再创建log.txt,文件不会出现在程序启动时所在的目录,而会被创建到/tmp/log.txt。
所以,只要程序使用的是相对路径,文件最终出现在哪里,看的是调用fopen时进程的cwd。
从内核关系上看,可以先把它理解成:
text
task_struct → fs → 当前工作目录
因此,cwd不是Shell独有的字符串,而是每个进程文件系统视图的一部分。
exe:可执行文件被删除后为什么还能运行
图中exe -> /usr/bin/dash表示:这个进程当前执行的程序是/usr/bin/dash,也就是说,104号进程是一个dash进程。
假设一个测试程序正在运行,此时用rm删除它原来的可执行文件路径,进程会立刻退出吗?
不会。
程序启动后,运行所需的内容已经被加载或映射到进程的地址空间中,内核也仍然保留着对该文件的引用。rm删除的是目录中的文件名,并不会直接杀死已经运行的进程。
这时再查看/proc/PID/exe,原路径后面会出现(deleted),但进程仍然可以继续运行。等内核中的最后一个引用也被释放后,文件数据才会真正回收;由于原路径已经不存在,也无法再通过它启动新进程。

这里也能重新看到task_struct的入口作用:内核可以从任务关联的内存描述结构找到当前可执行文件的映射和引用,而不是把完整路径直接写死在PCB中。
4.2 用ps按名称查找进程
通过/proc/PID查看进程有一个前提:我们已经知道它的PID。如果想查看当前有哪些进程,或者根据进程名查找PID,就要使用ps。
bash
ps axj | head -1 && ps axj | grep proc | grep -v grep
这里的proc是目标进程名,使用时换成自己的程序名。
这条命令可以拆开理解:
ps axj显示进程的PID、PPID、状态和命令等信息;|把左侧命令的输出交给右侧命令;head -1保留第一行表头;grep proc筛选包含proc的行;grep -v grep排除grep命令本身;&&表示前一条命令成功后,再执行后一条命令。
管道的实现原理后面再介绍,这里先把它理解为给命令结果增加筛选条件。

提示:本文截图中的程序名是
proc。在VS Code终端中运行可能匹配出大量无关结果,建议换成更有辨识度的程序名。
下面先看task_struct中的第一类信息:进程标识符。
5 PID和PPID:进程是谁
刚才无论查看/proc还是使用ps,都绕不开PID。它是task_struct中最先要看的标识信息。
PID和PPID是最常见的两个进程标识符:
- PID(Process ID):当前进程的编号;
- PPID(Parent Process ID):当前父进程的PID。
在内核中,父子关系不只是保存一个孤立的PPID数字。task_struct还通过real_parent等指针维护任务关系,对外再表现为PPID。本文讨论单线程进程时,pid与线程组标识tgid通常相同;学习线程后再区分二者。

5.1 在程序中获取PID和PPID
Linux提供了getpid()和getppid():
c
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("PID: %d\n", getpid());
printf("PPID: %d\n", getppid());
sleep(30);
return 0;
}
编译并运行:
bash
gcc proc.c -o proc
./proc
程序暂停30秒,是为了方便我们打开另一个终端进行观察:
bash
ps axj | head -1 && ps axj | grep proc | grep -v grep
程序打印的PID、PPID应该与ps看到的结果一致。


再根据PPID查看父进程,可以看到它是Bash:

5.2 为什么PPID会指向Bash
Bash本身也是一个进程。我们在命令行中输入./proc时,Bash不能把自己直接替换掉,否则程序一结束,命令行也就没了。
因此,Bash通常会创建一个子进程,让子进程去执行proc,自己则等待程序结束,随后继续接收命令。这也解释了刚才的现象:proc的PPID,正好就是Bash的PID。
那么,Bash是如何创建子进程的?Linux提供了一个系统调用:fork。
6 fork:创建子进程
6.1 fork之后为什么出现两个进程
c
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("hello: %d\n", getpid());
fork();
printf("I'm a proc: %d\n", getpid());
sleep(1);
return 0;
}

hello只打印了一次,说明执行到fork之前只有父进程。
I'm a proc却打印了两次,而且PID不同,说明执行完fork以后,系统中已经有了父、子两个进程,它们各自执行了一次后面的printf。
这也说明,子进程不会从main函数开头重新执行,而是从fork返回的位置开始,和父进程一起继续向后运行。
至于谁先打印,并不确定。父子进程都是独立的执行流,谁先被CPU调度,要看操作系统。
现在又有一个问题:父子进程执行的是同一份代码,怎样让它们分别去做不同的事情?
6.2 用返回值区分父子进程
fork的返回值可以用来区分父子进程:
text
ret < 0:创建失败,此时没有子进程
ret = 0:当前是子进程
ret > 0:当前是父进程,ret就是子进程的PID
根据返回值使用if进行分流:
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
pid_t ret = fork();
if (ret < 0)
{
perror("fork");
return 1;
}
else if (ret == 0)
{
printf("child: pid=%d, ppid=%d, ret=%d, &ret=%p\n",
getpid(), getppid(), ret, &ret);
}
else
{
printf("parent: pid=%d, child_pid=%d, ret=%d, &ret=%p\n",
getpid(), ret, ret, &ret);
}
return 0;
}

父进程为什么要拿到子进程的PID?因为子进程是父进程创建的,后面父进程还要等待、回收或者控制它。想管理一个进程,首先得知道它是谁,所以fork把子进程的PID返回给父进程。
子进程不需要fork返回自己的PID,它调用getpid()就能拿到。给它返回0,只需要表示"你是子进程"即可,而且正常进程的PID不会是0,判断起来也很方便。
从运行结果中还可以看到,子进程的PPID等于父进程的PID,这正是父子关系在进程标识符上的体现。
6.3 fork只调用一次,为什么会返回两次
调用fork时,进入内核的确实只有父进程。
fork返回前,内核已经创建了子进程的task_struct,并让子进程也从fork返回处继续执行。于是,父子两个进程都会从这次调用中返回:
text
调用fork时:父进程
│
内核创建子进程
┌─────┴─────┐
│ │
父进程从fork返回 子进程从fork返回
ret = 子进程PID ret = 0
系统调用的返回值通常先放在CPU寄存器中。内核分别设置父子进程的返回值寄存器:父进程得到子进程PID,子进程得到0。
所以,fork并不是在同一个进程中执行了两遍。它只被父进程调用了一次,但调用结束时已经有了两个进程,父子进程各自返回一次。
两个进程接着都会给变量ret赋值,却能得到不同的结果。这就涉及父子进程的数据如何存放。
6.4 父子进程为什么能看到相同地址、不同值
fork之后,父子进程可以执行同一份代码,却是两个独立的进程。父进程修改变量,不应该影响子进程,反过来也一样。
奇怪的是,父子进程打印同一个变量时,经常会得到相同的地址、不同的值。如果这真是同一块物理内存,结果显然说不通。
这个问题不能只用一句"父子进程数据独立"带过。它背后连着虚拟地址、页表和写时拷贝,本文最后会用一个完整章节专门解释。
这里先明确一件事:fork的两个返回值由内核分别写入父子进程的寄存器,并不是修改同一份内存得到的。
内存问题先留到最后。父子进程创建完成后,内核首先要判断它们当前是否具备运行条件,这就要看进程状态。
7 进程状态:现在能不能运行
PID和PPID说明进程是谁,进程状态则说明它现在能不能运行。
先不急着记R、S、D这些字母,我们从进程为什么会改变状态说起。
7.1 运行、阻塞与挂起
运行:正在使用CPU,或者正在等CPU
CPU资源有限,已经具备运行条件的进程要先进入运行队列runqueue,再由调度器选择一个交给CPU。
全局进程链表和运行队列使用task_struct中的不同成员,彼此互不冲突。因此,同一个进程既能留在全局进程链表中,也能进入运行队列。
不同管理结构使用的节点类型可以不同,但最终都能找到所属的task_struct。
因此,Linux所说的运行状态包含两种情况:
text
运行状态 = 正在CPU上执行 + 已经就绪,正在运行队列中等待
阻塞:运行条件还没有满足
以scanf为例。如果标准输入暂时没有数据,进程即使拿到CPU也无法继续执行。内核会让它离开运行队列,转而等待键盘资源就绪,这就是阻塞。
等待资源的进程可能不止一个。内核仍然按照"先描述,再组织"的方式,把这些进程放入对应硬件的等待队列wait_queue。
输入到达后,内核唤醒相应进程,再把它放回运行队列:
text
运行队列 --等待输入--> wait_queue --输入就绪--> 运行队列
回到运行队列,只说明它又具备了运行条件。什么时候真正获得CPU,还要由调度器决定。
教材中的挂起:进程暂时不在内存中
阻塞和挂起回答的是两个不同的问题:
- 阻塞问的是:进程等待的资源就绪了吗?
- 挂起问的是:进程还在内存中吗?
因此,二者可以同时存在。资源还没就绪,进程又被移出了内存,叫作阻塞挂起 ;资源已经就绪,但进程还没有回到内存,叫作就绪挂起。
例如,阻塞挂起的进程等到资源就绪后,会变成就绪挂起。它还要先回到内存,才能进入运行队列。
不过,这是教材为了说明状态转换建立的模型。现代Linux通常按页回收或换出内存,并不会简单地把整个进程搬走,所以ps中没有哪个字母专门表示挂起。后面看到R、S等状态时,不要强行和教材中的挂起状态一一对应。
7.2 Linux如何表示进程状态
Linux内核使用状态位记录任务状态,ps再将其整理成字母显示。现代内核把"任务能否运行"和"任务是否正在退出"放在不同字段中,可以先记住下面这层关系:
text
task_struct
├── __state:主要描述可运行、休眠、停止等状态
└── exit_state:描述僵尸、彻底退出等退出阶段
因此,task_struct中并不是直接保存字符'R'或'S'。ps显示的字母是面向用户的归纳结果,不同内核版本的内部定义也可能变化。下面只看最常见的R、S、D、T、t、Z和X。
常见状态如下:
| 状态 | 含义 |
|---|---|
R |
正在运行,或者正在运行队列中等待 |
S |
可中断睡眠,正在等待某个事件 |
D |
不可中断睡眠,最常见于等待I/O |
T |
被信号停止 |
t |
被调试器跟踪并停止 |
X |
进程正在被彻底删除 |
Z |
进程已经退出,等待父进程回收 |
R:可以运行
前面所说的运行状态,在ps中用R表示:正在使用CPU和在运行队列中等待都算R。
这里只看STAT列:程序持续进行计算时,状态显示为R。

S和D:都在等待,区别是能否被信号打断
S和D都表示进程正在等待,区别在于等待能否被信号打断。
S是可中断睡眠。进程调用sleep,或者等待终端、网络等输入时,通常会处于S状态。它可以被信号唤醒或终止。
下面只观察STAT列:进程调用sleep以后,状态显示为S。


D是不可中断睡眠,最常见于等待I/O。普通信号通常不能立刻打断,必须等这次不可中断等待结束后再处理。进程长期处于D状态,通常说明某项I/O或内核等待迟迟没有完成。
T和t:信号停止与跟踪停止
T表示进程被信号暂停。发送SIGSTOP可以让进程停止,发送SIGCONT可以让它继续运行:
bash
kill -19 进程PID
kill -18 进程PID
小写t表示跟踪停止,通常出现在调试过程中。例如程序被gdb跟踪,并在断点处停下时,就可能显示为t。
截图中仍然只看STAT列,确认发送SIGSTOP后进程进入T状态。

Z和X:退出到彻底回收
Z表示进程已经退出,但父进程还没有读取退出信息;X表示内核正在删除剩余的进程记录。X持续时间极短,几乎无法通过ps看到。
如果STAT显示为S+或R+,第一个字母才是进程状态。后面的+表示该进程属于前台进程。
7.3 僵尸进程:子进程退出了为什么还在
子进程完成任务后,父进程需要知道它是否正常结束、退出码是多少。在默认情况下,子进程退出时,内核会先释放代码和数据等资源,暂时保留PID、退出码等信息。
此时子进程已经不会再运行,但它的进程记录仍然存在,状态就是Z。处于Z状态的进程称为僵尸进程。
text
子进程已经退出 + 父进程还没有读取退出信息 = 僵尸进程
下面的代码中,子进程直接退出,父进程休眠30秒,不读取子进程的退出信息:
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
pid_t id = fork();
if (id < 0)
{
perror("fork");
return 1;
}
else if (id == 0)
{
printf("child exits: pid=%d, ppid=%d\n", getpid(), getppid());
return 7;
}
else
{
printf("parent sleeps: pid=%d, child=%d\n", getpid(), id);
sleep(30);
}
return 0;
}
编译并运行:
bash
gcc zombie.c -o mytest
./mytest
在父进程休眠期间,打开另一个终端查看:
bash
while :; do ps axj | head -1 && ps axj | grep mytest | grep -v grep; sleep 1; done
子进程的状态会显示为Z,命令后面通常还会出现<defunct>。

僵尸进程不会继续占用原来的代码和数据,但会占用PID和进程表项。如果父进程不断创建子进程却从不回收,最终可能导致系统无法继续创建新进程。
本篇先回答"僵尸为什么会出现"。父进程怎样取得子进程的退出结果,并让这条记录彻底消失,留到下一节课的进程等待中再展开。
7.4 孤儿进程:父进程退出了怎么办
如果父进程先退出,而子进程仍在运行,这个子进程就称为孤儿进程。
text
父进程已经退出 + 子进程仍在运行 = 孤儿进程
下面的程序中,父进程1秒后退出,子进程继续运行,并再次查看自己的PPID:
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
pid_t id = fork();
if (id < 0)
{
perror("fork");
return 1;
}
else if (id == 0)
{
printf("child begins: pid=%d, ppid=%d\n", getpid(), getppid());
sleep(5);
printf("child continues: pid=%d, new ppid=%d\n", getpid(), getppid());
sleep(5);
}
else
{
printf("parent exits: pid=%d, child=%d\n", getpid(), id);
sleep(1);
}
return 0;
}
编译并运行:
bash
gcc orphan.c -o mytest
./mytest
父进程退出后,子进程不会跟着退出。内核会为它重新指定父进程,通常是所在PID命名空间中的PID 1;如果系统设置了专门负责回收子进程的进程,也可能由其他进程接管。
因此,在WSL、容器等环境中,孤儿进程的新PPID不一定是1。


僵尸进程和孤儿进程的区别很直接:
| 类型 | 父进程的情况 | 子进程的情况 |
|---|---|---|
| 僵尸进程 | 仍在运行,但没有读取退出信息 | 已经退出 |
| 孤儿进程 | 已经退出 | 仍在运行 |
状态先筛出能够运行的进程。运行队列中还可能同时存在多个R状态的进程,优先级是调度器区分它们时参考的信息之一。
8 进程优先级:调度器会更偏向谁
运行队列中可能同时存在多个R状态的进程。它们都可以运行,但CPU先交给谁,还要参考进程的优先级。
优先级和权限不要混淆:
text
权限:能不能获得某种资源
优先级:大家都能获得资源时,谁先获得
Linux会在task_struct中记录调度所需的优先级信息,ps可以将其中一部分显示为PRI和NI。
内核中的优先级并不是只有一个字段。初学时可以先认识几个角色:static_prio与普通任务的nice值相关,normal_prio表示根据调度策略得到的正常优先级,prio表示调度时使用的有效优先级之一,policy则说明任务采用哪种调度策略。它们的具体计算会随调度类别和内核实现变化,本文只理解分工,不背源码公式。
8.1 PRI和NI分别表示什么
查看进程的优先级信息:
bash
ps -al
PRI:ps显示的进程优先级,数值越小,优先级越高;NI:nice值,用来调整普通进程的调度权重,数值越小,权重通常越高。
对于普通、非实时进程,在本文使用的ps输出中,可以这样理解二者的显示关系:
text
PRI = 80 + NI
nice值的范围是-20 ~ 19:
| NI | PRI | 优先级 |
|---|---|---|
-20 |
60 |
普通进程中较高 |
0 |
80 |
默认 |
19 |
99 |
普通进程中较低 |
nice有"友好"的意思。NI越大,进程对其他进程越"友好",也就是越愿意少占一些CPU。
nice值影响的是普通进程获得CPU的权重,并不保证某个进程一定立即先运行。

8.2 调整进程的nice值
启动程序时增加nice值:
bash
nice -n 10 ./proc
这里的10会加在当前继承的nice值上。要把一个已经运行的进程设置为指定nice值,可以使用renice:
bash
renice 10 -p 进程PID
也可以使用top动态调整:
text
运行top → 按r → 输入PID → 输入新的nice值
普通用户通常只能调整自己的进程,并通过增大NI来降低优先级。想把NI改小,尤其是改成负数来提高优先级,通常需要sudo权限。
优先级解释了调度器更倾向于谁,却没有回答两个问题:什么时候重新选择进程,被换下来的进程以后怎样继续运行。先解决第二个问题,也就是上下文。
9 进程切换:为什么要保存上下文
9.1 时间片:CPU为什么要切换进程
CPU还要运行其他进程,不能一直被一个普通进程占用。内核会让多个可运行进程轮流执行,可以先把一次连续运行的时间理解为时间片。
时间片用完或进程等待资源时,CPU可能交给另一个进程。被换下来的进程通常还没有执行完,以后再次获得CPU时必须从原来的位置接着运行,因此内核要先保存它当时的运行现场。这份现场就是上下文。
9.2 上下文就是进程的运行现场
进程运行时,CPU寄存器中保存着当前执行所需的数据,例如:
- 程序计数器:记录接下来要执行的指令地址;
- 栈指针:记录当前栈顶的位置;
- 通用寄存器:保存运算结果、函数参数和临时数据;
- 状态寄存器:保存条件码、控制位等CPU状态。
本文所说的上下文,主要指切换时需要保存的这组CPU寄存器数据。
寄存器是CPU中的硬件;上下文是进程被切走前,寄存器中的那组值。
先只看一个逻辑CPU。同一时刻,寄存器中保存的是当前执行流的现场。进程A被切走后,进程B会使用这组寄存器;如果不先保存A的寄存器内容,它的运行现场就会被B覆盖。
9.3 上下文保存在哪里
上下文不是直接平铺在task_struct中,但内核能从task_struct找到它。
task_struct中包含体系结构相关的thread_struct,并通过stack关联进程的内核栈。切换所需的上下文会保存在这些位置。
text
task_struct
├── thread_struct:保存体系结构相关的切换信息
└── stack:指向进程的内核栈,其中也会保存寄存器现场
具体保存在哪个位置,与CPU架构和进入内核的方式有关。这里不需要死记字段,只要记住:每个进程都有自己的上下文,内核能够通过task_struct找到并恢复它。
9.4 从进程A切换到进程B
CPU从进程A切换到进程B,大致要完成下面几步:
text
1. 保存进程A的寄存器现场
2. 找到进程B之前保存的上下文
3. 将进程B的上下文恢复到CPU寄存器
4. CPU从进程B上次停下的位置继续执行
程序计数器恢复后,CPU知道下一条指令在哪里;栈指针和其他寄存器恢复后,函数调用和临时数据也能接着使用。因此,进程再次获得CPU时,不需要从main函数重新开始。
9.5 current:内核怎样找到当前进程
内核中可以通过current获取当前CPU正在运行的进程所对应的task_struct。
c
current->pid
current只在内核中使用,而且不能简单理解为所有CPU共享的普通全局指针。每个逻辑CPU都有自己的当前进程。
从进程A切换到进程B后,当前进程也随之变成B。内核需要访问当前进程的属性时,可以直接从B的task_struct开始。
9.6 竞争、独立、并发和并行
系统中的进程数量通常多于CPU核心数量,进程之间首先具有竞争关系;进程又有各自的地址空间和运行现场,具备相对独立性。在此基础上,进程切换可以实现并发,多核CPU还能让多个进程真正同时执行,也就是并行。
| 概念 | 含义 |
|---|---|
| 竞争 | 多个进程都希望获得有限的CPU、内存或I/O资源 |
| 独立 | 不同进程拥有相互隔离的地址空间和运行现场,正常情况下互不干扰 |
| 并发 | 一个逻辑CPU在多个进程之间切换,使它们在一段时间内都向前推进 |
| 并行 | 多个CPU核心在同一时刻分别执行不同进程 |
单核系统能够并发,却不能让两个普通指令流在同一个核心上同一时刻执行。多核系统既有并发,也能够真正并行。无论采用哪种方式,每个进程都保存自己的上下文,彼此不会混用。
至此,我们知道CPU怎样从进程A换到进程B。为什么偏偏选择B,是调度要回答的问题。
10 进程调度:CPU接下来运行谁
进程调度,就是内核从运行队列中的可运行进程中选出下一个使用CPU的进程。
10.1 调度器从谁里面选
一个逻辑CPU同一时刻只能执行一个进程,处于R状态的进程却可能有很多。这些可运行任务由相应CPU的运行队列runqueue管理,其中一个正在CPU上执行,其余等待调度。
text
运行队列中的进程:已经准备好,可以参与调度
等待队列中的进程:还在等待资源,暂时不能参与调度
所以,一个进程已经存在,不等于它现在就有资格竞争CPU。阻塞进程要先等资源就绪,重新进入运行队列,才能成为候选者。
现代Linux中,每个逻辑CPU都有自己的运行队列。task_struct中可以内嵌多个管理节点,因此同一个进程既能留在全局任务链表中,也能进入运行队列或等待队列。
多核系统还要考虑任务迁移与负载均衡。如果一个CPU的运行队列很长,另一个CPU却比较空闲,内核会在满足CPU亲和性等约束的前提下尝试重新分配任务。具体算法很复杂,这里只需要知道:每CPU运行队列解决局部调度,负载均衡负责协调多个CPU之间的工作量。
10.2 什么时候需要重新调度
当前进程无法继续运行,或者不该继续占用CPU时,内核就要重新选择。常见情况有:
- 进程等待输入、磁盘或网络等资源;
- 进程执行完毕并退出;
- 进程主动让出CPU;
- 当前进程这一次获得的运行时间结束;
- 按照当前调度规则,更应该运行的进程已经就绪。
把一次调度过程连起来看:
text
进程A离开CPU
├── 仍能运行:回到运行队列
└── 等待资源:进入等待队列
↓
调度器从运行队列中选出进程B
↓
内核保存A的上下文,恢复B的上下文
现在,"调度"和"切换"的区别就清楚了:
text
调度负责选出进程B
切换负责把CPU从进程A交给进程B
10.3 调度器按什么规则选
Linux并不是把所有进程的nice值放在一起比较。普通任务、实时任务和期限任务使用不同的调度规则:
- 普通任务使用日常最常见的公平调度;
- 实时任务可以使用
SCHED_FIFO、SCHED_RR等策略; - 期限任务可以使用
SCHED_DEADLINE。
内核使用相应的调度类实现这些规则。本文只继续讨论普通任务,实时调度和期限调度知道它们不和普通任务使用同一套规则即可。
第8节介绍的nice值,主要影响普通任务的调度权重。nice值越小,权重通常越大,进程长期分到的CPU时间往往越多。
但nice值不能指定"下一个必须运行谁"。进程不在运行队列中,nice值再小也不会被选中。
10.4 O(1)调度器
很多Linux课程会使用早期的O(1)调度器讲解调度。它已经不是当前Linux的普通任务调度器,但结构直观,适合用来理解调度器怎样从大量进程中快速选出一个。
O(1)调度器为每个CPU维护两套优先级数组:
text
active:本轮仍可继续运行的进程
expired:本轮运行时间已经用完的普通进程
每套数组中都有140个队列:
text
0~99:实时优先级
100~139:普通优先级
这些数字是该历史模型在内核中使用的优先级编号,不能直接当成ps输出中的PRI。
同一优先级可以有多个进程,因此数组中的每个元素都是一个队列的表头:
text
queue[优先级] → 进程A → 进程B → 进程C
为了避免每次都检查140个队列,O(1)调度器还准备了一张位图bitmap。哪个优先级队列不为空,对应的二进制位就置为1。
调度时只做两步:
text
通过bitmap找到优先级最高的非空队列
↓
从这个队列的队首取出一个进程
普通进程的本轮运行时间用完后,会进入expired。当active为空时,交换active和expired两个指针,下一轮就可以继续,不需要重新搬动所有进程。
选择过程不会随着进程数量增加而变长,因此称为O(1)调度。

O(1)调度器只作为历史模型理解。位图的实际长度取决于机器字长,不需要记某个固定数组长度。
10.5 当前Linux已经不再使用O(1)调度器
O(1)调度器后来被CFS取代。CFS记录经过调度权重修正的虚拟运行时间,并倾向于选择虚拟运行时间较小的任务。
从Linux 6.6开始,普通任务调度又逐步转向EEVDF。它会先判断哪些任务应该获得CPU,再结合虚拟截止时间完成选择。
这里不用继续学习CFS和EEVDF的算法,只要明确一件事:前面的O(1)结构是为了帮助我们理解调度过程,不是当前Linux的实现。
无论具体算法怎样变化,进程调度的主线都不会变:
text
进程具备运行条件
↓
进入相应CPU的运行队列
↓
调度规则和优先级信息参与选择
↓
调度器选出下一个进程
↓
内核完成上下文切换
一个已经存在的进程怎样排队、被选中和切换,现在清楚了。接下来回到启动它的地方,看看Shell还会把哪些信息交给新程序。
11 环境变量与命令行参数
先从一条最普通的命令开始:
bash
ls -l /home
Shell要执行这条命令,至少要解决两个问题:
text
运行谁? ls
怎样运行? -l /home
第一个问题由PATH解决,第二个问题由命令行参数解决。Shell找到程序后,还会把环境变量一起交给它。顺着这条线往下看,argc、argv和envp就不会显得突然。
11.1 PATH告诉Shell应该运行谁
直接写ls就能运行,自己编译的proc却通常要写成./proc。两者都是可执行程序,区别只在于有没有明确给出路径。
ls没有写出具体路径仍能被找到,是因为Shell的环境中保存着PATH。
环境变量是name=value形式的字符串,用来记录程序运行时需要的配置信息。例如:
text
PATH=/usr/local/bin:/usr/bin:/bin
HOME=/home/lenovo
LANG=zh_CN.UTF-8
常见的环境变量有:
| 环境变量 | 作用 |
|---|---|
PATH |
Shell搜索外部程序时使用的目录 |
HOME |
用户家目录的路径 |
PWD |
Shell记录的当前工作目录 |
SHELL |
用户设置的默认Shell,不一定是眼前正在运行的Shell |
LANG |
语言、字符编码等区域设置 |
TERM |
当前终端类型 |
USER、LOGNAME |
当前用户的登录名信息 |
查看某一个环境变量:
bash
echo "$PATH"
printenv HOME
查看全部环境变量:
bash
env
set还会显示当前Shell中的本地变量和其他Shell状态,输出通常比env更多。这个差别正好对应后文要区分的Shell变量与环境变量。
PATH中的多个目录使用:分隔。当Shell需要执行外部程序,并且命令名中不含/时,会按照这些目录从左到右查找:
bash
type -P ls
echo "$PATH"

ls位于/usr/bin,而/usr/bin在PATH中,所以只写ls也能运行。
自己编译的proc位于当前目录,而当前目录通常不在PATH中,直接输入proc就会查找失败。写成./proc时,路径已经明确,Shell便不再搜索PATH。
到这里,PATH解决的问题已经很清楚了:让Shell根据程序名找到可执行文件。
11.2 添加环境变量时要分清覆盖和追加
先创建一个自己的变量:
bash
MY_ENV=hello
echo "$MY_ENV"
printenv MY_ENV

echo能够输出hello,printenv却没有结果。
MY_ENV=hello只是在当前Bash中创建了一个Shell变量。当前Shell能够使用它,但随后启动的新程序还拿不到它。
执行export后再看:
bash
export MY_ENV
printenv MY_ENV

这一次可以看到hello。export表示当前Shell以后启动新程序时,要把这个变量放进新程序的环境中。
export必须修改当前Shell自己的环境。如果把它当成普通外部程序放进子进程中执行,子进程只能修改自己的环境,无法反向改变父进程。因此,export和cd这类必须改变当前Shell状态的命令,通常由Shell作为内建命令直接执行。
环境信息主要沿父进程到子进程的方向传递。子进程得到自己的环境以后,即使修改其中的变量,也不会自动反向修改父进程的环境。这一点与前面讲到的进程独立性是一致的。
创建和导出也可以合成一步:
bash
export MY_ENV=hello
删除变量使用:
bash
unset MY_ENV
修改已有变量时,有两种完全不同的写法。
第一种是覆盖原值:
bash
export PATH=/home/lenovo/code # 会覆盖原值,不要直接在当前终端测试
这会把原来的PATH整个替换掉。/usr/bin、/bin等目录一旦丢失,ls之类的命令就可能无法通过名字找到。
第二种是保留原值,再追加新内容:
bash
export PATH="$PATH:$(pwd)"
Shell会先用原来的PATH替换$PATH,再把当前目录追加到末尾。此时直接输入proc,Shell也能找到当前目录中的程序。
如果希望新目录优先被搜索,可以把它放在前面:
bash
export PATH="$(pwd):$PATH"
text
PATH="$PATH:新目录" 追加到末尾,最后搜索
PATH="新目录:$PATH" 添加到开头,优先搜索
终端中执行的export只对当前Shell会话及其以后启动的程序生效。想让配置在重新打开终端后仍然存在,可以把export命令写入~/.bashrc,再执行:
bash
source ~/.bashrc
11.3 命令名后面的内容就是命令行参数
Shell通过PATH找到ls以后,还要把-l和/home交给它:
bash
ls -l /home
text
ls 程序名
-l 第一个命令行参数
/home 第二个命令行参数
这些参数具体表示什么,由ls自己解析。我们现在不关心它怎样解析,只关心Shell怎样把这些字符串交给程序。
从C程序的角度看,这些信息最终会出现在main函数的参数中:
c
int main(int argc, char *argv[], char *envp[])
这里要补充一个准确性边界:标准C主要规定无参数形式和argc/argv形式,第三个参数envp是类Unix系统中的常见扩展。本文在Linux环境中使用它来观察环境表没有问题,但不能把三参数形式说成所有C实现都必须支持的标准写法。
先看前两个参数:
text
argc 命令行参数的个数
argv 保存每个命令行参数的字符串指针数组
下面的程序把收到的argv打印出来,同时顺便打印envp中的前5项:
c
#include <stdio.h>
int main(int argc, char *argv[], char *envp[])
{
for (int i = 0; i < argc; i++)
{
printf("argv[%d] = %s\n", i, argv[i]);
}
puts("\nenvp中的前5项:");
for (int i = 0; envp[i] != NULL && i < 5; i++)
{
printf("envp[%d] = %s\n", i, envp[i]);
}
return 0;
}
编译并运行:
bash
gcc info.c -o info
./info linux "process concept"

这次输入可以对应成:
text
argc = 3
argv[0] = "./info"
argv[1] = "linux"
argv[2] = "process concept"
argv[3] = NULL
argv[0]通常是程序的启动名称,真正跟在程序名后面的参数从argv[1]开始。
Shell会先处理引号,再构造argv。因此,"process concept"虽然包含空格,最终仍然只占一个参数。
我们不用研究Shell内部怎样构造这张表。只要知道,程序启动以后,可以通过argc和argv取得本次命令行中的每一个参数,然后按照自己的规则进行解析。
11.4 envp把环境变量交给程序
命令行中明明没有写HOME和LANG,前面的程序却能从envp中看到它们。
这是因为Shell启动程序时,不只会交给它argv,还会把已经导出的环境变量整理成另一张表。main的第三个参数envp就指向这张表:
text
argv 本次命令行中明确写出的参数
envp 程序启动时收到的环境变量
envp是一个字符串指针数组。每个指针指向一条name=value字符串,最后一个指针为NULL:

前面的info.c使用下面的循环打印了envp中的前5项:
c
for (int i = 0; envp[i] != NULL && i < 5; i++)
{
printf("envp[%d] = %s\n", i, envp[i]);
}
如果去掉i < 5,循环就会一直打印到环境变量表末尾。
除了envp,还可以通过全局指针environ访问当前进程的环境变量表:
c
extern char **environ;
for (int i = 0; environ[i] != NULL; i++)
{
puts(environ[i]);
}
envp和environ都适合遍历整张表。如果只想读取某一个变量,使用getenv更直接:
c
#include <stdio.h>
#include <stdlib.h>
int main()
{
const char *home = getenv("HOME");
if (home == NULL)
{
puts("没有找到HOME");
return 1;
}
printf("HOME=%s\n", home);
return 0;
}
执行结果如下:

getenv("HOME")找到变量时返回指向变量值字符串的指针,找不到时返回NULL。
程序还可以通过setenv()和unsetenv()修改自己的环境:
c
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
if (setenv("MY_ENV", "hello", 1) != 0)
{
perror("setenv");
return 1;
}
printf("MY_ENV=%s\n", getenv("MY_ENV"));
unsetenv("MY_ENV");
return 0;
}
这些修改首先作用于当前进程,以后由它创建的子进程可以继续继承更新后的环境;已经存在的父进程和其他进程不会被同步修改。
text
envp 通过main的第三个参数遍历环境变量表
environ 直接遍历当前进程的环境变量表
getenv 按名称查找某一个环境变量
11.5 从一条命令到main函数
现在重新看最开始的命令:
bash
ls -l /home
它从Shell进入程序的过程是:
text
Shell读到命令
↓
通过PATH找到ls的可执行文件
↓
把ls、-l和/home整理成argv
↓
把已经导出的环境变量整理成envp
↓
通常fork创建子进程
↓
子进程调用execve装入ls,并把argv和envp交给它
↓
ls从main函数取得这些信息,再按自己的规则解析参数
这里的execve只用来交代参数和环境怎样进入新程序,不在本篇展开。它为什么能够用新程序替换当前进程,将在下一节课的进程替换中继续学习。
因此,看到一条命令时,至少要能分清三件事:
text
PATH 解决运行谁
argv 记录这一次怎样运行
envp 记录程序运行时收到的环境
argv、envp以及它们指向的字符串,最终都要放在进程自己的内存中。父子进程最初可以拥有相同的数据,一方修改后却不会影响另一方。接下来要讨论的进程地址空间,正是为了解释这个问题。
12 进程地址空间:程序打印的地址是物理地址吗
上一节提到,命令行参数和环境变量最终都要放进进程的内存中。除了它们,进程里还有代码、全局数据、堆和栈。这些内容在内存中是怎样排列的?先从一张熟悉的图开始。

从低地址到高地址,依次可以看到代码区、数据区、堆、内存映射区(图中称为共享区)和栈。命令行参数与环境变量位于靠近栈顶的位置。堆通常向高地址增长,栈通常向低地址增长。
图中画的是经典的32位Linux地址空间,最上面的内核空间不能被用户代码直接访问。3G/1G也不是所有系统都固定采用的边界,我们现在使用的WSL通常运行64位Linux,实际地址会大得多。这里先看各区域的相对位置,不背具体数值。
12.1 先用程序验证地址分布
一张图还不够,我们把不同类型对象的地址打印出来:
c
#include <stdio.h>
#include <stdlib.h>
int g_unval;
int g_val = 100;
int main(int argc, char *argv[], char *envp[])
{
const char *str = "hello world";
int *p = malloc(10 * sizeof(int));
if (p == NULL)
{
perror("malloc");
return 1;
}
printf("code addr:%p\n", (void *)main); // 代码区
printf("read only addr:%p\n", (const void *)str); // 只读常量区
printf("init addr:%p\n", (void *)&g_val); // 初始化数据
printf("uninit addr:%p\n", (void *)&g_unval); // 未初始化数据
printf("heap addr:%p\n", (void *)p); // 堆区
printf("stack addr:%p\n", (void *)&str); // 栈区
printf("stack addr:%p\n", (void *)&p); // 栈区
for (int i = 0; i < argc; i++)
{
printf("args addr:%p\n", (void *)argv[i]);
}
for (int i = 0; envp[i] != NULL; i++)
{
printf("env addr:%p\n", (void *)envp[i]);
}
free(p);
return 0;
}
受64位地址、动态链接和地址空间随机化影响,每次运行得到的具体数值可能不同。不过比较地址的大小,仍能看出大致规律:代码和全局数据在低地址一侧,堆在其上方,栈以及argv、envp位于高地址一侧。

实验只能说明这些地址具有图中的相对关系,却不能证明这张图画的就是物理内存。
12.2 这张图画的真是物理内存吗
如果程序打印的是物理地址,那么相同地址就应该指向同一个物理位置,并读出相同数据。要验证这一点,可以回到fork留下的现象:父子进程打印相同的变量地址,一方修改后,双方读到的值却不同。
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int g_val = 100;
int main()
{
pid_t id = fork();
if (id < 0)
{
perror("fork");
return 1;
}
else if (id == 0)
{
g_val = 200;
printf("child : pid=%d, g_val=%d, &g_val=%p\n",
getpid(), g_val, (void *)&g_val);
return 0;
}
else
{
sleep(1); // 这里只为方便观察输出顺序,不是可靠的进程同步
printf("parent: pid=%d, g_val=%d, &g_val=%p\n",
getpid(), g_val, (void *)&g_val);
}
return 0;
}
运行后会看到一个很有意思的现象:父子进程打印的&g_val相同,但子进程读到200,父进程读到的仍然是100。

同一个物理位置不可能同时存放200和100。因此,程序打印出来的地址不是最终访问的物理地址,而是虚拟地址 。前面的布局图描述的也不是物理内存,而是进程看到的虚拟地址空间。
这里使用sleep(1)只是为了让父进程晚一点打印,便于观察现象,并不能充当可靠的进程同步。父进程怎样正确等待子进程,留到下一节课的进程等待中再讲。
至于父子进程怎样做到"地址相同,数据不同",先把问题留在这里。把虚拟地址怎样落到物理内存上讲清楚以后,答案自然会出现。
12.3 每个进程都有自己的虚拟地址空间
虚拟地址空间是一段由进程自己看到的地址范围。代码区、数据区、堆和栈都位于这段范围中,但这些地址本身并不直接保存数据,真正的数据仍在物理内存里。
每个进程都有自己的虚拟地址空间,所以不同进程可以使用相同的虚拟地址而不发生冲突。就像刚才的父子进程,它们都能看到g_val位于同一个地址,但这个地址最终可以对应不同的物理位置。
确认这是虚拟地址以后,接下来要回答的是:这片地址空间由谁描述?
12.4 mm_struct描述进程的虚拟地址空间
进程仍然满足"先描述,再组织"。task_struct负责描述进程,其中的mm指针可以找到该进程的内存描述结构mm_struct:
text
task_struct
└── mm → mm_struct
task_struct记录进程的PID、状态、优先级等管理信息,mm_struct则负责描述这个进程看到的虚拟地址空间。

可以把mm_struct理解成一把管理地址空间的尺子。代码区从哪里开始、堆的边界在哪里、栈位于哪一段,都需要由内核记录下来。命令行参数和环境变量所在范围,也可以通过arg_start、arg_end、env_start、env_end等边界信息描述。图中的结构体只是为了表达这种关系,不需要记忆所有字段,当前Linux内核的实际结构也比图中复杂得多。
仅有几个边界还不够。地址空间中还存在许多连续区间,它们的用途和权限各不相同。Linux使用VMA描述这些区间:代码区域通常可读、可执行,数据和堆通常可读、可写,栈也有自己的范围和权限。
因此,mm_struct和VMA回答的是:某段虚拟地址是否属于该进程,以及允许读、写还是执行。
但它们没有回答另一个问题:虚拟地址中的数据究竟放在物理内存的哪里?这就需要页表。
12.5 页表把虚拟地址映射到物理内存
内存按页管理,虚拟地址空间和物理内存都会被划分成大小固定的页。页表保存虚拟页与物理页之间的映射关系。
CPU执行指令时产生虚拟地址,MMU根据当前进程的页表完成转换,找到真正要访问的物理位置:
text
虚拟地址
↓
当前进程的页表
↓
物理地址
每个进程使用自己的页表,因此,相同的虚拟地址经过不同页表转换后,可以落到不同的物理页:

反过来,不同进程的页表也可以指向同一个物理页,这样便能共享代码、共享库或共享内存。
页表中还记录了页面是否存在,以及能否读、写、执行等权限。CPU发现映射不存在或本次访问不符合权限时,会触发缺页中断,再交给内核处理。写时拷贝正是利用了这套机制。

需要注意,拥有一段合法虚拟地址并不等于相应物理页已经全部准备好。程序申请内存或装入可执行文件时,内核可以先建立合法的虚拟地址区域;等程序第一次真正访问某一页,再通过缺页异常分配匿名页、读取文件页面或恢复被换出的页面,并更新页表。这种按需分配避免了提前准备大量暂时用不到的物理内存。
如果访问的地址根本不属于合法VMA,或者操作违反了读、写、执行权限,内核就不会把它当作正常的按需调页处理,进程可能因此收到SIGSEGV。所以,缺页异常本身不一定是程序错误,关键要看内核能否为这次访问建立合法映射。
到这里,"虚拟地址"和"物理数据"已经被分开了。现在可以回头处理fork留下的问题。
12.6 fork之后并不会立刻复制所有数据
fork创建子进程时,子进程会拥有自己的task_struct、mm_struct和页表。按照进程独立性的要求,父子进程也应该各自拥有一份私有数据。
但如果fork刚执行完就复制父进程的全部物理页,开销会很大。更何况,很多子进程创建后会立刻调用exec加载新程序,刚复制的内容马上又会被替换(进程替换后面会讲),这次复制完全是浪费。
因此,fork刚结束时,父子进程虽然有各自的地址空间和页表,但页表中的许多映射仍然指向同一批物理页:

只读页面可以一直共享。对于原本可写的私有页面,内核会暂时限制写入,为后面的写时拷贝做准备。
共享省掉了无用的复制,却带来了一个新问题:子进程修改共享页面时,为什么不会连父进程的数据一起改掉?
12.7 写时拷贝
当父进程或子进程尝试修改仍在共享的私有页面时,CPU会因为页表中的写权限限制触发缺页异常。内核识别出这是一次合法的私有写入后,才执行写时拷贝:
text
1. 为写入方申请新的物理页
2. 把原物理页的内容复制过去
3. 修改写入方的页表,使其指向新页
4. 恢复写权限,继续完成刚才的写操作
这个过程叫作写时拷贝(Copy On Write,COW)。前面的子进程执行g_val = 200后,父子进程的映射关系就变成了这样:

父进程的页表仍然指向原来的物理页,所以读到100;子进程中相同的虚拟地址已经映射到新页,所以读到200。现在,"地址相同,数据不同"就解释通了。
COW按页处理,写一个变量时复制的是它所在的物理页,不是只复制这一个变量,更不是复制整个地址空间。如果该物理页已经没有其他私有映射与它共享,内核也可能直接恢复写权限,不再重复复制。
再回答fork为什么一次调用能有两个返回值
调用fork的源代码确实只执行了一次。进入内核后,内核创建了子进程,并复制了父进程当时的执行现场。父子进程以后都会从fork系统调用返回的位置继续执行,所以看起来像是fork"返回了两次"。
更准确地说,不是同一个进程返回两次,而是两个进程各返回一次。内核把父进程寄存器中的返回值设为子进程PID,把子进程寄存器中的返回值设为0,父子进程便能进入不同的分支。
两个返回值最初位于父子进程各自的寄存器现场中,因此"返回值不同"本身不等于修改了共享内存,也不会必然触发COW。如果编译器随后把返回值写进仍由父子共享的栈页,这次内存写入才可能触发COW。判断标准始终只有一个:进程是否写入了仍以COW方式共享的物理页。
12.8 为什么不让程序直接使用物理地址
虚拟地址到物理地址之间多了一层转换,看起来更麻烦,却解决了直接使用物理地址时最棘手的问题:
- 保证进程独立性:每个进程只能访问自己的合法地址范围,一个野指针不能直接改坏其他进程的内存;
- 进程管理与内存管理解耦:程序只按自己的代码区、堆和栈工作,不必关心物理页实际位于哪里;
- 灵活使用物理内存:连续的虚拟地址可以映射到不连续的物理页,物理页也可以按需分配和回收;
- 方便共享并节省内存 :多个进程可以共享只读代码和共享库,
fork也能通过COW避免无意义的整份复制。
现在再看进程的内存部分,关系就很清楚了:
text
task_struct
↓ mm
mm_struct与VMA 描述进程有哪些虚拟地址区域
↓
页表 建立虚拟页到物理页的映射
↓
物理内存 真正保存代码和数据
task_struct告诉内核这是谁的进程,mm_struct和VMA描述它能看到哪些虚拟地址,页表再把这些地址映射到真正保存数据的物理内存。至此,PID、状态、优先级、上下文、环境和地址空间都重新落回了同一个入口:task_struct以及它关联的内核结构。
下一节课将继续研究进程控制:进程怎样退出,父进程怎样等待并取得子进程结果,以及现有进程怎样装入另一份程序。它们分别会继续用到本篇已经出现的退出状态、父子关系和mm_struct。