
.
个人主页: 晓风飞
专栏: 数据结构|Linux|C语言
路漫漫其修远兮,吾将上下而求索
文章目录
- 同一个地址怎么能存两个不同的值?
-
- 前置:虚拟地址空间是什么
- 一、fork:不只是复制
- 二、进程终止:两个数字讲一个故事
-
- 退出码:你考了多少分
- 信号:你是正常交卷还是作弊被抓
- 两个数字,一张表
- [exit vs _exit:缓冲区到底是谁的](#exit vs _exit:缓冲区到底是谁的)
- 三、进程等待:父亲必须善后
-
- 为什么要等:僵尸与问责
- wait:堵在门口,等你咽气
- waitpid:指名道姓地等
- [为什么 status 是 256](#为什么 status 是 256)
- 一个反直觉的问题

同一个地址怎么能存两个不同的值?
先看一段代码。
c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 100;
int main()
{
pid_t id = fork();
if (id == 0) {
printf("子进程: g_val = %d, &g_val = %p\n", g_val, &g_val);
g_val = 200;
printf("子进程修改后: g_val = %d, &g_val = %p\n", g_val, &g_val);
exit(0);
} else {
sleep(1);
printf("父进程: g_val = %d, &g_val = %p\n", g_val, &g_val);
}
return 0;
}
运行它:
子进程: g_val = 100, &g_val = 0x60104c
子进程修改后: g_val = 200, &g_val = 0x60104c
父进程: g_val = 100, &g_val = 0x60104c
同一个地址 0x60104c,子进程读出来是 200,父进程读出来是 100。
同一个地址,两个不同的值。这怎么可能?
答案只有一种:你在 C/C++ 里用到的地址,根本就不是物理地址。它是虚拟地址。
这件事一旦接受,一系列问题就涌上来了:虚拟地址是怎么工作的?fork 到底干了什么?一个进程从出生到死亡,操作系统在背后做了哪些事?
前置:虚拟地址空间是什么
在进入 fork 之前,得先把虚拟地址空间这件事说清楚。不需要讲完------那得讲一个学期------但至少把骨架立起来。
画三八线
虚拟地址空间,本质上是操作系统给每个进程画的一张"大饼"。它告诉每个进程:"你有 4GB(32位)或 256TB(64位)的内存,随便用。"
画饼容易,管饼难。每个进程一张饼,操作系统就得管一堆饼。管理的套路是"先描述,再组织"------在内核里,每个进程挂着一个 mm_struct 结构体,这就是它的虚拟地址空间。
那这个结构体怎么做到"给你划出代码区、数据区、堆区、栈区"呢?
靠的是画三八线。
三八线的本质是区域划分。在结构体里设一堆 start 和 end 字段------code_start、code_end、data_start、data_end、heap_start、heap_end------线一画,区域就分好了。线内的虚拟地址,进程可以直接用。
但三八线有个毛病:它只能画连续的区域。如果将来要加载动态库、要扩展堆区,需要一堆离散的小块,三八线就不好使了。所以在 mm_struct 里还挂了一个 vm_area_struct 链表,每个节点有自己的 vm_start 和 vm_end,专门管理这些离散的内存块。
所以现在有了两张图:
mm_struct:宏观画饼,管整个空间vm_area_struct链表:微观切块,管每一小片区域的起止
记住这两张图就够了。至于页表怎么做虚拟到物理的映射------那是另一个故事。
为什么要费这个劲
直接让进程访问物理内存不好吗?三个理由。
第一,拦截。从虚拟地址到物理地址,中间隔了一层页表映射。多一层就多一道关卡。你试图写一块只读的内存,映射直接失败,操作系统在转化阶段就把你拦住了------根本到不了物理内存。这比事后补救干净得多。
第二,整理。没有虚拟空间的时候,进程的代码和数据被加载到物理内存的任意位置,七零八落。有了虚拟空间和页表映射之后,每个进程看到的空间排布都是整齐划一的------代码区在下面,堆区往上,栈区在顶上。物理上可以乱七八糟,逻辑上整整齐齐。
第三,惰性。你申请内存,操作系统不再立刻给你分配物理内存。它只在虚拟空间里把地址范围划好。等你真的访问那块地址时,操作系统发现映射还没建立,当场给你补上。这就是写时拷贝和缺页中断的底层逻辑------用时间换空间利用率。
好了,前置知识够了。下面进入正题。
一、fork:不只是复制
一个函数,两个返回值
c
#include <unistd.h>
pid_t fork(void);
// 返回值:子进程返回 0,父进程返回子进程 pid,出错返回 -1
三个问题。
为什么子进程返回 0,父进程返回子进程 pid?
因为父进程可能有很多子进程。给父进程返回子进程的 pid,父进程才知道"我多了哪个孩子"。而子进程只需要知道"我是孩子"------返回 0 就够了。子进程想找自己的 pid 直接调 getpid(),想找父进程调 getppid()。
为什么一个函数能返回两次?
fork 是系统调用。调用之后,执行权从用户态陷入内核。内核做的事情:
- 分配新的内存块和内核数据结构给子进程(新的
task_struct、新的mm_struct、新的页表) - 把父进程的部分数据结构内容拷贝给子进程
- 把子进程加入调度队列
- fork 返回------这时候调度器手里有两个进程了,各自返回一次
所以 return 被执行了两次。不是返回值有两个,是返回这个动作发生了两次。
为什么一个 id 变量既等于 0 又大于 0?
id 是父进程在栈上创建的变量。fork 之后,子进程继承了父进程的页表------父子页表指向同一块物理内存。所以一开始,父子看到的 id 在同一块物理内存上,虚拟地址也一样。
但一旦有人往 id 里写返回值,写操作触发写时拷贝:操作系统给写的人单独分配一块物理内存,把原来的内容拷过去,然后修改页表映射。父子从此看的是两块不同的物理内存,值当然可以不一样。但虚拟地址没变------所以"同一个地址,两个不同的值"。
写时拷贝:故意设成只读,等操作系统来救场
这是整个 fork 机制里最精妙的设计。
fork 之后,父子进程的代码和数据默认是共享的。代码本来就是只读的,共享没问题。数据是读写的,共享就有风险------任何一个进程修改数据,另一个进程就会看到不该看到的变化。
解决办法不是 fork 的时候立刻拷贝一份数据------那太浪费了。万一子进程 fork 完立刻 exec 换了个新程序呢?那白拷了。
解决办法是:故意把数据段的页表权限改成只读,然后等。
操作系统在 fork 时,把父子共享的所有数据页的页表条目权限位改成只读。改完之后再拷贝页表给子进程。现在两个进程看同一块物理内存,但谁都不能写------一写就触发 MMU 转换失败。
转换失败不是崩溃。它是操作系统的介入信号。MMU 说:"有人想在只读的页上写东西,我转化不了。"操作系统说:"让我看看怎么回事。"
操作系统介入之后,它会判断:这个进程访问的地址落在数据段范围内,它有权写。之所以触发只读保护,是因为刚才 fork 的时候故意把权限改了。而且这块物理内存的引用计数是 2------证明有父子两个进程共享它。
好,操作系统就知道了:有人要对共享数据做写入,需要写时拷贝。
操作系统做的事:
- 在物理内存中申请一块新空间
- 把原来的数据拷贝过去
- 修改写入方的页表,把映射指向新物理地址,权限改回读写
- 引用计数减 1
从那以后,父子各写各的,互不影响。
整个过程的核心触发机制就一句话:操作系统通过修改页表权限来故意制造"非法访问",从而在写入时拦截并触发拷贝。
为什么要拷贝?不能直接给一块新空间让人从头写吗?
因为"写入"不等于"彻底覆盖"。大多数时候你写的是
count++------你需要在旧值上加一,不是直接写个新值。你得知道旧值是什么。拷贝就是在保留旧值的基础上让你修改。
写时拷贝本质上是一种延迟申请技术,就像一个礼拜之后才需要用钱的朋友------你现在不给他,这一个礼拜之内这 100 块钱可以借给另外三个朋友应急,只要保证在约定时间之前他们还回来就行。钱还是那笔钱,但利用率高了。内存也是同样的道理。这个类比到这里就不太对了------你舍友确实不会在你写数据的时候跳出来给你分配新内存------但延迟申请的思想是一样的:不在一开始就分配全部资源,等到真正需要的时候再给。腾出来的资源在等待期间可以被别人用。
一个现实用法:无痛备份
因为写时拷贝的存在,fork 有一个非常实用的场景:内存快照备份。
假设你有一个程序,每隔 5 秒钟修改一批数据,修改完要做一次备份。你可以在修改完后立刻 fork,让子进程拿着 fork 那一瞬间的数据快照去做备份,父进程继续回去干活。
c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <time.h>
#define NUM 10
int date[NUM];
int main()
{
srand(time(NULL));
while (1) {
// 父进程修改数据
for (int i = 0; i < NUM; i++) {
date[i] = rand() % 100;
}
printf("\n===== 原始数据 =====\n");
for (int i = 0; i < NUM; i++) {
printf("%d ", date[i]);
}
printf("\n=====================\n");
pid_t id = fork();
if (id == 0) {
// 子进程:备份数据(故意慢一点)
printf("\n[子进程 %d] 开始备份...\n", getpid());
sleep(10); // 模拟备份耗时
printf("[子进程 %d] 备份数据: ", getpid());
for (int i = 0; i < NUM; i++) {
printf("%d ", date[i]);
}
printf("\n[子进程 %d] 备份完成\n", getpid());
exit(0);
}
// 父进程:休眠后继续下一轮修改
sleep(5);
}
return 0;
}
关键点在于:子进程 sleep 10 秒模拟备份期间,父进程睡 5 秒之后就继续修改数据了。也就是说,子进程在备份的时候,父进程可能已经改了好几轮数据。
怕吗?完全不怕。
因为 fork 成功的那一刻,子进程就拿到了当时数据的快照。后续父进程再怎么改,触发的是写时拷贝------父进程改的是父进程自己的拷贝,子进程读的还是 fork 时刻的那份数据。
这种技术在 Redis 之类的内存数据库里是标准做法:定期 fork 一个子进程,子进程拿着内存快照往磁盘写 RDB 文件,父进程继续服务客户端请求。互不干扰。
二、进程终止:两个数字讲一个故事
一个进程怎么死的?宏观上只有两种情况:正常跑完,或者中间出异常。
细分一下:
- 代码跑完了,结果正确
- 代码跑完了,结果不对
- 代码没跑完,中途崩溃了
前两种是"正常终止",最后一种是"异常终止"。
退出码:你考了多少分
你在 main 函数里写的 return 0,你以为只是"函数返回一个整数"?
它是进程的退出码。
c
int main()
{
printf("pid: %d\n", getpid());
return 88; // 故意设一个奇怪的数字
}
编译运行,然后:
bash
$ ./a.out
pid: 15840
$ echo $?
88
$? 是 shell 里保存"最近一个退出进程的退出码"的变量。88 就是刚才那个进程留给 bash 的遗言。
0 表示成功,非 0 表示失败。失败的原因可以用不同的数字区分------1 代表什么、2 代表什么,约定好就行。
但人是读不懂数字的。所以需要把数字转化成字符串描述。Linux 内置了一个 strerror 函数干这件事:
c
#include <stdio.h>
#include <string.h>
int main()
{
for (int i = 0; i < 250; i++) {
printf("%d: %s\n", i, strerror(i));
}
return 0;
}
输出:
0: Success
1: Operation not permitted
2: No such file or directory
3: No such process
...
所以当你执行 ls 一个不存在的文件 时:
bash
$ ls hello.txt
ls: cannot access 'hello.txt': No such file or directory
$ echo $?
2
退出码 2,对应的描述是 "No such file or directory"。ls 用退出码 2 告诉 bash:我失败了,原因是没有这个文件。
退出码是给父进程的。父进程创建子进程是为了让子进程干活。活干得怎么样?看退出码。
你派张三去取快递。张三回来把快递放你桌上------退出码 0。张三回来说快递没取到,因为快递站关门了------退出码 1。张三根本没回来,人也联系不上------这就是异常退出了,我们马上说。
信号:你是正常交卷还是作弊被抓
异常终止不是 return 或 exit() 能控制的------代码跑到一半,除零了、野指针了,进程直接崩溃。这时退出码还有意义吗?
没有。
一个学生去考试。正常考完,99 分------成绩有意义。正常考完,30 分------成绩也有意义。但作弊被抓,中途被赶出考场------学校既公布"该生作弊"又公布"该生考了 99 分"吗?不会。作弊那一刻,成绩作废。
进程也一样。一旦异常退出,退出码没有任何参考价值。
那怎么衡量"异常退出"?答案是:信号。
bash
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL
5) SIGTRAP 6) SIGABRT 7) SIGBUS 8) SIGFPE
9) SIGKILL 10) SIGUSR1 11) SIGSEGV 12) SIGUSR2
13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGSTKFLT
...
1 到 31 号是普通信号。验证一下。写一个除零的程序:
c
int main()
{
int a = 10;
int b = 0;
int c = a / b; // 故意除零
printf("%d\n", c);
while (1) sleep(1);
return 0;
}
运行:
bash
$ ./a.out
Floating point exception (core dumped)
Floating point exception,浮点异常。对照信号表------8 号信号,SIGFPE。FPE 就是 Floating Point Exception。
如果你不信"崩溃的本质是收到信号",做个对照实验:对一个正常运行的进程手动发 8 号信号。
bash
$ ./a.out & # 后台运行一个死循环进程
[1] 18240
$ kill -8 18240
Floating point exception (core dumped)
一模一样的输出。除零崩溃和收到 8 号信号,效果完全一致。
所以结论:进程异常退出,本质是进程收到了某个信号。 除零收到 8 号(SIGFPE),野指针收到 11 号(SIGSEGV),Ctrl+C 收到 2 号(SIGINT)。信号编号从 1 开始,没有 0 号信号。
两个数字,一张表
现在可以总结了。一个进程的终止状态,用两个数字就能完整描述:
| 信号编号(signal) | 退出码(exit code) | 含义 |
|---|---|---|
| 0 | 0 | 代码跑完,结果正确 |
| 0 | 非 0 | 代码跑完,结果不对(具体原因看退出码) |
| 非 0 | 无意义 | 代码没跑完,被信号杀死了 |
信号为 0 = 没有收到信号 = 正常跑完。退出码为 0 = 结果正确。信号非 0 = 中间出异常了,退出码作废。
这两个数字存在哪?在进程的 PCB(task_struct)里。 进程退出时,代码和数据可以释放,页表可以释放,但 PCB 不能立刻释放------因为退出信息还在 PCB 里,父进程还没来读。这时候进程处于僵尸状态------已经死了,但还没被收尸。
exit vs _exit:缓冲区到底是谁的
让进程正常退出,有三种方式:
- main 函数 return(本质上等价于调 exit)
- 调
exit(status) - 调
_exit(status)
exit 和 _exit 的区别,看一段代码就清楚了。
c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
printf("hello world"); // 注意:没有 \n
sleep(2);
exit(14);
}
$ ./a.out
hello world$ # 字符串被刷出来了
把 exit 换成 _exit:
c
int main()
{
printf("hello world"); // 同样没有 \n
sleep(2);
_exit(14);
}
$ ./a.out
$ # 什么都没打印
printf 肯定是执行了的。字符串去哪了?在输出缓冲区里。exit 在终止进程前会主动冲刷缓冲区,而 _exit 不会------它直接杀进程,不看缓冲区。
这个差异暴露了一个更深的事实:缓冲区不在操作系统内核里。
_exit 是系统调用,直接跟内核打交道。如果缓冲区是内核提供的,_exit 一定能看到它、刷新它。既然 _exit 看不到,说明缓冲区在更上层------在 C 语言库(glibc)那里。exit 是库函数,它在调用 _exit 之前,先把库层面的事情清理干净(刷缓冲区、关文件流、调 atexit 注册的清理函数),然后再进内核杀进程。
"库函数封装系统调用"的经典模型:上一层在调下一层之前,把自己的烂摊子收拾好。
三、进程等待:父亲必须善后
为什么要等:僵尸与问责
两个理由。
第一个是刚需 :父进程如果不等待子进程,子进程退出后会变成僵尸。僵尸进程杀不掉------kill -9 对一个已经死掉的进程毫无作用。它占着 PCB、占着退出信息,这就是内存泄漏。
第二个是按需:父进程创建子进程是为了让子进程干活。干得怎么样?得知道。要不要看这份总结是父进程的自由,但操作系统必须提供"能看"的机制。
wait:堵在门口,等你咽气
c
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
// 成功返回被等待进程的 pid,失败返回 -1
// status 是输出型参数。设为 NULL 表示不关心退出信息。
wait 是阻塞的。如果子进程还没死,父进程就卡在 wait 这里,直到子进程退出。
c
int main()
{
pid_t id = fork();
if (id == 0) {
// 子进程:跑 5 秒后退出
int count = 5;
while (count--) {
printf("子进程 %d 还在运行... count=%d\n", getpid(), count);
sleep(1);
}
printf("子进程退出\n");
exit(10);
}
// 父进程
printf("父进程 %d 开始等待...\n", getpid());
pid_t rid = wait(NULL); // 不关心退出信息,只回收
printf("父进程等待成功,退出的子进程 pid=%d\n", rid);
sleep(5);
return 0;
}
跑起来,同时在另一个终端每秒钟查一次进程状态:
bash
$ while :; do ps axj | head -1; ps axj | grep a.out | grep -v grep; sleep 1; done
你会看到:
- 前 5 秒:父子两个进程都在,状态都是 S(睡眠/运行)
- 子进程退出后:子进程变成 Z(僵尸),父进程还在 sleep
- 父进程 wait 返回后:僵尸消失,只剩父进程
- 父进程退出后:什么都没了
wait 解决了僵尸问题。但它不挑------有多个子进程时,谁先死它先收谁。
waitpid:指名道姓地等
c
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
参数 pid:
> 0:只等这个 pid 对应的子进程-1:等任意子进程,行为等同于 wait
参数 status:输出型参数,拿到子进程的退出信息。
参数 options:设为 0 表示阻塞等待(默认)。还有一个 WNOHANG 选项可以做非阻塞轮询。
wait 本质上是 waitpid(-1, status, 0)。所以最佳实践是直接用 waitpid------功能更全,一个接口覆盖所有场景。
为什么 status 是 256
换 waitpid 重写上面的代码,这次把退出码拿出来看看:
c
int main()
{
pid_t id = fork();
if (id == 0) {
int count = 5;
while (count--) {
printf("子进程 %d 还在运行... count=%d\n", getpid(), count);
sleep(1);
}
printf("子进程退出\n");
exit(1); // 退出码设为 1
}
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0) {
printf("父进程等待成功,status=%d\n", status);
}
return 0;
}
输出:
父进程等待成功,status=256
退出码明明是 1,status 却是 256。为什么?
因为 status 这个整数不只是一个数字------它是一个位图。
32 个比特位,只用低 16 位。低 16 位又分成两部分:
-
次低 8 位 (bit 8-15):退出码。正常终止时,这里是
return或exit()传的那个数字。 -
低 7 位(bit 0-6):终止信号。正常终止时为 0,被信号杀死时存信号编号。
-
bit 7:core dump 标志位。
status = 256 = 0b0000_0000_0000_0000_0000_0001_0000_0000
│ │ │ │
│ │ │ └── bit 0-6: 信号,全 0 = 正常终止
│ │ └── bit 7: core dump,0
│ └── bit 8-15: 退出码 = 0000_0001 = 1
└── bit 16-31: 不用
退出码 1 存在了次低 8 位。1 左移 8 位 = 1 × 2⁸ = 256。
想提取退出码,右移 8 位再跟 0xFF 按位与:
c
int exit_code = (status >> 8) & 0xFF; // = 1
int exit_signal = status & 0x7F; // = 0,正常终止
系统提供了更优雅的宏来做这件事:
c
if (WIFEXITED(status)) { // 是否正常终止
printf("退出码: %d\n", WEXITSTATUS(status)); // 提取退出码
}
if (WIFSIGNALED(status)) { // 是否被信号杀死
printf("终止信号: %d\n", WTERMSIG(status));
}
一个反直觉的问题
有人会问:为什么要把退出码放在 status 里让父进程通过 waitpid 来拿?我定义一个全局变量,子进程退出前置个值,父进程直接读不行吗?
c
int exit_code = 0;
// 子进程
exit_code = 11;
exit(11);
// 父进程
wait(NULL);
printf("%d\n", exit_code); // 能拿到 11 吗?
不行。因为写时拷贝。
子进程 fork 出来之后,修改全局变量 exit_code 触发写时拷贝------子进程改的是自己那份拷贝。父进程读的还是自己那份,永远是 0。
父子进程之间的通信,不能靠普通变量。退出信息只能通过内核中转------子进程写到自己的 PCB 里,父进程通过 waitpid 系统调用从 PCB 里读。这是进程独立性的直接后果。
事情就是这样。
你 fork 出来的进程,它有自己的页表、自己的地址空间、自己的一整套内核数据结构。写时拷贝让父子在数据上最终分离------修改的一方才获得独立的物理内存,不修改的一方继续共享。退出码和信号让父进程知道子进程怎么死的。wait 让父进程给子进程收尸、释放最后的 PCB。
进程的一生,从 fork 开始,到 exit 结束,最后被 wait 收走。每一步背后,操作系统都在默默更新数据结构------创建时分配,运行时调度,死亡时释放。
回到开头的那个问题:同一个地址,两个不同的值。现在你知道答案了------那不是同一个地址,那是两个地址,指向两块不同的物理内存,只是虚拟地址长得一模一样。因为子进程的页表,是照着父进程的页表抄出来的。
抄作业,连名字都抄了。