写在前面:这是本系列的第五篇。
经过前几讲的底层探索,本次课我们正式进入"每一讲都实现一点什么"的模式。我们将学习程序和进程管理 以及进程管理的 API。
操作系统通过虚拟化(Virtualization)技术,将物理计算机抽象为虚拟计算机,让每个程序都产生了一种"独占整个计算机运行"的幻觉。今天,我们就来解剖这种幻觉的底层逻辑。

程序 v.s. 进程
- 程序 (Program): 是状态机的静态描述。它静静地躺在硬盘上,描述了所有可能的状态,但本身没有任何运行时的状态。(就像在 vim 里写代码,如果没有 IDE 的检查,你根本不知道犯了什么错,直到你把它跑起来。)
- 进程 (Process): 是动态的,是运行起来的程序,是状态机随时间的演进。
操作系统上的进程
除了程序本身的状态(内存、寄存器),操作系统还会为进程保存一些额外的(只读)状态:PID、打开的文件、权限等。
AI Prompt 探索进程信息: 我们可以用系统调用 API 获取当前进程的元数据。
- 基本信息:
getpid()获取进程 ID,getppid()获取父进程 ID,getpgid()获取进程组 ID,getsid()获取会话 ID。- 状态信息: 可以读取
/proc/[pid]/stat获取运行/睡眠状态,使用nice()获取优先级。- 资源信息: 读取
/proc/[pid]/status获取VmSize(虚拟内存大小)、VmRSS(常驻内存) 等。
Linux 哲学的极致体现:"Everything is a file"(万物皆文件)
操作系统不仅提供了 getpid 这样的 API,还允许我们通过文件系统 /proc 直接读取状态机的信息。看看下面这段通过读取 /proc/self/ 目录探测自身状态的演示:
bash
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/processes# ./proc
pid: 1230
ppid: 888
--- Additional Process Information ---
User ID: 0
Group ID: 0
Effective User ID: 0
Effective Group ID: 0
Max open file descriptors: 1024
Process priority: 0
--- Process Status (/proc/self/status) ---
Name: proc
State: R (running)
PPid: 888
Uid: 0 0 0 0
VmSize: 2680 kB
--- Command Line (/proc/self/cmdline) ---
Command line: ./proc
--- Current Directory (/proc/self/cwd) ---
Current directory: /mnt/d/CSLab/osCourse/lec5/processes
编程素养:机器永远是对的,未测代码永远是错的
啊...我们终于开始编程了!在这个阶段,代码质量是重中之重。
- 必须重视所有的架构设计(函数名、变量名、参数设计)。不重视设计的后果就是:程序只能删掉重写。
- 程序既是"人类"的(需要可读性),也是"反人类"的(无情在机器上执行)。
- 永远遵守两个规则:
- 机器永远是对的。
- 未测代码永远是错的。
你需要一个自动化测试框架 (Unit Test)
不要再用满屏的 printf 来人肉调试了!主流的 C 语言测试框架有 Unity、CppUTest、Google Test 等。
课程为大家准备了一个好用的 TestKit 库。借助 AI,你可以轻松掌握如何编写单元测试:
- 用 AI 读 test lib 的代码。
- 不会写 Test Example?让 AI 帮你写。
- 目的: 养成写 Unit Test 的习惯。当你为原程序加上断言和环境变量测试时,它不会对程序的业务逻辑产生任何影响,但能极大降低重构时的心理负担。
bash
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/testkit# make
gcc -Wall -g -I. main.c testkit.c -o main
argv[0] = ./main
TestKit
- [PASS] test_simple (main.c:23)
- [FAIL] test_fail (main.c:27) - Assertion fail
Assertion failed: (114514 == 0x114514)
In __tk_test_fail_27 of main.c:28
Assertion violated
- [FAIL] test_timeout (main.c:31) - Timeout
...
- 3/8 test cases passed.
进程(状态机)管理 API
操作系统是状态机的管理者,那么进程管理本质上就是状态机管理。
一个直观的想法是:如果我想执行一个新程序,操作系统应该提供一个像 spawn(path, argv) 这样的 API。我传给它路径和参数,它帮我拉起一个新的状态机。这也是 Windows 操作系统的设计思路(微软的匈牙利命名法,如 CreateProcess)。
但 UNIX 给出了一个极其反直觉的答案: 它没有提供 spawn,而是把"新建"和"加载"拆成了两步!
- 复制状态机:
fork() - 复位状态机:
execve()
创建状态机:Unix 的 fork()
cpp
pid_t fork(void);
我们现在已经有"一个运行中的状态机"了。UNIX 的 fork() 做法是:做一份当前状态机完整的复制!
- 完整的拷贝: 包括寄存器现场、每一字节的内存。
- 注意那些没有被直接复制的状态 (Caveat): 进程在 OS 里还有 PID、ppid、信号等,这些元数据不会被简单复制,而是有特定的规则。
- 如何区分真假美猴王(父与子)?
- 在新创建的子进程中,
fork()返回0。 - 在执行
fork()的父进程中,返回子进程的PID。 - 如果复制失败,返回
-1,并设置errno。
进程树与孤儿、僵尸进程
进程的这种"复制"关系,自然而然地形成了一棵进程树 (Process Tree) 。你可以用 pstree 命令查看。
- 问题: 如果 A \\rightarrow B \\rightarrow C ,如果中间的 B 终止了, C 的 ppid 会变成什么?
- 直觉: 往上提一层,认 A 做父进程?
- 实际的复杂性:
- 如果直接往上提,发错人了怎么办? A 可能根本没准备好管理 C 这份额外的资源。
- 进程之间可能通过 ppid 进行通信,一旦乱认父亲,通信就会错乱。
AI 科普时间:孤儿进程 (Orphan) 和 僵尸进程 (Zombie)
- 孤儿进程: 当父进程先于子进程终止时,子进程就成了"孤儿"。Linux 会自动将孤儿进程过继给
init进程(现代系统通常是systemd,PID 为 1)。- 僵尸进程: 子进程先结束了,但父进程太忙,一直没有调用
waitpid()去收割它。此时子进程的资源已被释放,但它的 PID 和退出状态依然保留在系统任务表中,变成了一具"僵尸"。
我们写一段 create-tree 的代码,通过重定向 > test.md,把进程树的生成结果用 Mermaid 画出来:
警惕 Fork Bomb (分叉炸弹):
如果在死循环里无条件调用 fork(),1 变 2,2 变 4...... 指数级增长的进程会瞬间耗尽系统的 PID 和内存资源,导致死机。这是早期 CS 极客最爱搞的恶作剧。
理解 fork() 的硬核习题与"缓冲区陷阱"
习题一:到底创建了几个进程?
cpp
pid_t x = fork();
pid_t y = fork();
printf("%d %d\n", x, y);
- Line 1:1 变 2。
- Line 2:2 变 4。
- 最终会有四个执行路径(4 个状态机)。父进程的
x和y都是大于 0 的子进程 PID,而最底层的孙子进程打印出的将是0 0。 - JYY 的偷懒方法: 遇到这种题,不用拿笔画,直接用上节课讲的
mosaic(Model Checker) 去穷举所有可能的状态路径!
习题二:震惊!加入管道后结果变了?
cpp
for (int i = 0; i < 2; i++) {
fork();
printf("Hello\n");
}
按照状态机理论,1 变 2,2 变 4。循环两次应该一共有 4 个进程,每个进程打印一些 Hello。
我们在终端直接运行,打印出了 6 行 Hello:
bash
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/fork-demo# ./demo-2
Hello
Hello
Hello
Hello
Hello
Hello
但是,当我们加入管道 | wc -l 或者重定向到文件 > output.txt 时:
bash
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/fork-demo# ./demo-2 | wc -l
8
竟然变成了 8 行!为什么?!
极客揭秘:
printf** 的行缓冲陷阱**计算机系统里没有魔法,机器永远是对的。
当直接输出到终端屏幕时,
printf是行缓冲 (Line Buffered) 的。遇到\n,它就立刻把数据清空推给屏幕。但是,当你重定向到管道
|或文件> output.txt时,printf会变成全缓冲 (Fully Buffered) !遇到\n时数据依然憋在内存缓冲区里。此时执行
fork(),子进程不仅复制了代码,还把父进程憋在内存里的"那一坨Hello缓冲区"也完完整整地复制了一份! 最终退出时,父子进程各自把缓冲区刷到文件里,导致结果翻倍。防坑指南: 在调用
fork()之前,一定要养成调用fflush(stdout)的好习惯!
复位状态机:execve()
cpp
int execve(const char *filename, char * const argv[], char * const envp[]);
UNIX 没有直接新建程序的 API,它要求你先用 fork() 克隆一个自己,然后用 execve() 把这个克隆体复位(洗脑)成一个全新的可执行文件!
- 它将当前进程重置成目标可执行文件描述的初始状态(代码、数据、PC 指针重置)。
- 操作系统维护的状态不变: 进程号 PID、当前工作目录、以及打开的文件描述符全部保留。(这解释了为什么我们需要
O_CLOEXEC标志,防止泄漏多余的文件句柄给新程序)。 execve** 是 UNIX 中唯一能够"执行程序"的系统调用!** 它是一张单程票,一旦调用成功,去而不返。
execve() 设置了进程的初始状态
argc** &argv(命令行参数)😗* 困扰多年的疑问得到解答,C 语言的main函数参数,就是被execve从这里硬塞进去的!envp** (环境变量)😗*- 使用
env命令查看(PATH, PWD, HOME 等)。 - 环境变量本质上就是一张全局的"配置表",所有的神奇报错(中文/英文)、路径寻找,大都在这里。
bash
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/execve-demo# ./demo
PWD=/mnt/d/CSLab/osCourse/lec5/execve-demo
HELLO=WORLD
SHLVL=0
_=/usr/bin/env
PATH 环境变量的奥秘
当你在终端敲下 gcc 或者 python 时,系统凭什么知道去哪里找它们?
全靠 PATH 环境变量!操作系统会严格按照 PATH 里面以冒号 : 分隔的目录顺序,挨个去寻找同名的可执行文件。还记得用 strace 抓取的寻径过程吗?没有魔法,只有无情的字符串匹配和尝试。
摧毁状态机:_exit()
cpp
void _exit(int status);
这个系统调用没有争议。立即摧毁当前状态机,并允许抛出一个返回值(状态码 status),供父进程通过 wait() 或 waitpid() 捕获。
(注:C 标准库里有 exit() 函数,而底层的系统调用是 _exit()。早期的命名冲突导致了这种加下划线的无奈设计。)
UNIX 进程的完整生命周期总结
在 UNIX 的世界里,实现"创建一个新程序跑起来"的终极范式是:
Spawn = fork + execve + waitpid
cpp
int pid = fork();
if (pid == -1) {
// 错误处理:克隆失败(可能系统资源耗尽)
perror("fork"); goto fail;
} else if (pid == 0) {
// 子进程逻辑:被洗脑,执行全新程序
execve("目标程序路径", argv, envp);
// 如果执行到这里,说明 execve 失败了(因为成功的话它不返回!)
perror("execve");
_exit(EXIT_FAILURE);
} else {
// 父进程逻辑:耐心等待子进程汇报结果
int status;
waitpid(pid, &status, 0);
}
这就是支撑起整个现代计算机操作系统的基石逻辑。