☰
从fork到waitpid:彻底理解Linux进程创建与回收
2026/10/18 15:14:02 网站建设 项目流程

简介:合肥工业大学计算机学院操作系统课程的实验报告《实验五-进程的创建》,围绕Linux 0.11内核环境,面向正在学习进程管理、系统调用与应用程序调试的本科学生。报告首先阐述实验目的,要求掌握fork()创建子进程和execve()加载新程序的方法,理解两者区别;随后以Bochs模拟器为平台,结合gdb调试工具,详细演示了为main.c设置断点、单步跟踪fork调用进入copy_process函数、观察进程表变化和内存物理页分配等过程,并解析了execve调用如何释放旧内存、加载新程序并保持PID不变等关键机制。压缩包内含1个PDF文档,大小621KB,内容为完整实验报告,附有实验步骤截图和代码示例。已有548人学习该资源,说明其内容对同类实验具有参考价值,读者可借此掌握进程创建与程序加载的调试分析技能。

1. 进程的创建实验:从调用 fork 到真正理解进程

某高校计算机学院操作系统课程的实验五“进程的创建”,是进程管理模块里第一道需要动手写代码的硬关卡。这个实验表面上是让学生调用一次 fork 系统调用,然后观察“调用一次、返回两次”的现象,但实际考察的是进程控制块、内存地址空间、调度队列和资源回收这一整套机制。很多同学做完实验只记住了一句“fork 一下打印两行”,却说不出内核在 fork 调用里为子进程分配了哪些资源、父子进程的返回路径为什么会不同、子进程退出后为什么会变成僵尸进程。这篇笔记把该实验从原理到落地完整拆一遍,带读者写出可运行、可验证、可扩展的多进程程序,并给出参数设置和踩坑排查,适合正在做实验的新手,也适合想把这几个实验串成完整进程管理知识链的熟手。

2. 动手前需要先理清 fork 的内核行为与调度路径

2.1 为什么进程创建实验以 fork 为切口

操作系统课程里创建进程的方式不止一种,但实验五几乎毫无例外地选择 fork 作为切入点。原因在于 fork 是理解进程概念的最小操作:它不涉及加载可执行文件、不涉及命令行传参、不涉及环境变量继承,只做一件事——基于当前进程复制出一个几乎一模一样的子进程。这个“复制”动作把进程控制块、页表、文件描述符表、信号处理方式全都牵扯进来,是观察操作系统进程管理内部机制的最佳窗口。

实验要求里通常还会提及进程创建的同时观察并发执行。fork 之后的父子进程谁先运行,是由调度器决定的,不保证父进程先跑。这一点在实验现象上表现为打印顺序不稳定,很多初学者会误以为是程序写错了。实际上这正是操作系统课程想传达的核心概念之一:进程是并发调度的基本单位,创建进程之后就有了两个独立的调度实体。

2.2 fork 返回两次的原理与区分父子进程的方式

fork 调用到底做了什么,可以用一句话概括:内核复制当前进程的进程控制块和地址空间,然后把这个新进程放入就绪队列。但这里有一个关键细节,fork 调用在父进程中返回子进程的 PID,在子进程中返回 0,如果创建失败返回 -1。同一个函数调用在“调用前”和“调用后”是两个不同的进程,而每个进程各自从 fork 调用点拿到一个返回值,这就是“调用一次、返回两次”的本质。

基于这个返回值的差异,程序可以在 fork 之后用 if 分支把父子进程的执行路径分开。最常见写法是pid == 0走子进程逻辑,pid > 0走父进程逻辑,pid < 0报错退出。这个分支不是语法糖,而是基于内核为 fork 设计的返回值约定。实验报告里如果能把这个约定写成运行路径分析,通常能拿到不错的分数。

另外,fork 之后的两个进程虽然代码段相同,但内存地址空间相互独立。子进程后续修改自己的变量不会影响父进程的对应变量,因为内核用了写时复制机制:刚 fork 完时父子进程共享物理内存页,直到某一方写入才触发页表复制。这一点可以用一个全局变量的实验来验证,第 4 章会给出完整步骤。

2.3 进程控制块中哪些资源被继承、哪些被重建

把进程控制块展开看,能更清楚地理解 fork 的继承边界。父进程打开的文件描述符表会被子进程完整继承,所以 fork 之后父子进程可以同时读写同一个文件描述符,但“同时写”会导致输出内容交错,这是实验里常见的现象。父进程设置的环境变量、信号处理函数、当前工作目录也会被继承。而进程 PID、父进程 PID、进程运行时间统计、锁状态这类信息是重新分配的,不会继承。

在实验中理解这个继承边界能避免不少翻车场景。比如有同学在 fork 之后直接调用 printf 打印调试信息,发现输出重复出现了很多次,就以为是缓冲区问题。实际上原因在标准输出缓冲区的复制:fork 时缓冲区内容被原样带到子进程里,如果父进程在 fork 之前已经往缓冲区写了数据但没 flush,这些数据会被复制一次,之后父子进程各自 flush 一遍,就出现了重复输出。这不是进程控制块继承的问题,但却是实验现象里最容易误解的一点。

资源类型fork 后的行为实验中的表现
文件描述符表完整继承父子进程可操作同一文件描述符
内存地址空间逻辑独立,物理页写时复制子进程修改变量不影响父进程
环境变量继承子进程可直接读取父进程设置的变量
PID / PPID重新分配用 getpid / getppid 可验证
标准 IO 缓冲区内容被复制未 flush 的内容可能重复输出
信号处理方式继承自定义信号处理函数对子进程同样生效

3. 搭建最小实验环境并跑通第一个 fork 程序

3.1 实验环境搭建与最小验证命令

进程创建实验需要的是 Linux 环境,因为 fork 是 POSIX 标准接口,Windows 原生 API 并不提供 fork。实验环境的主流做法是在本机装一个 Linux 虚拟机,或者使用装有 WSL 的 Windows 系统,也有部分学校直接在实验室的 Linux 服务器上完成。我一般建议把编译和运行都放在同一个 Linux 环境里,避免因环境不一致导致实验结果不可复现。

环境准备好之后,先确认编译器和头文件是否齐全。实验只需要 gcc 和 Linux 内核头文件,绝大多数发行版默认都带了。可以用一段极简代码验证环境是否可用:

#include <stdio.h> #include <unistd.h> int main(void) { printf("current pid: %d\n", getpid()); return 0; }

编译命令是gcc envcheck.c -o envcheck,然后运行./envcheck。如果终端能打印出当前进程的 PID,说明编译链和头文件路径都没问题。这个最小验证的价值在于把“环境问题”和“实验代码问题”分开,后续写 fork 程序时如果编译报错,就能快速定位是头文件缺失还是代码本身写错。

3.2 第一个 fork 程序与双返回值的观察

环境验证通过后,就可以写第一个真正调用 fork 的程序。实验五的最小代码不需要做任何复杂的逻辑,只需要调用 fork、打印返回值、区分父子分支,然后让子进程退出。下面是完整代码:

#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> int main(void) { pid_t pid; pid = fork(); // 系统调用,创建子进程 if (pid < 0) { perror("fork failed"); exit(1); // 创建失败,直接结束 } else if (pid == 0) { // 这段代码只在子进程中执行 printf("child: pid=%d, ppid=%d, fork returned %d\n", getpid(), getppid(), pid); } else { // 这段代码只在父进程中执行 printf("parent: pid=%d, child pid=%d, fork returned %d\n", getpid(), pid, pid); } return 0; }

编译运行的方式是gcc forkdemo.c -o forkdemo && ./forkdemo。程序的关键点在 if-else 分支:pid == 0的分支只有子进程会进入,pid > 0的分支只有父进程会进入。getpid()拿到的是当前进程自身的 PID,getppid()拿到的是当前进程父进程的 PID。子进程打印出来的 ppid 应该和父进程打印出来的 pid 一致,这就是父子关系的最直接验证。

运行程序后能看到类似下面的输出,但注意两行打印的先后顺序不固定:

parent: pid=5210, child pid=5211, fork returned 5211 child: pid=5211, ppid=5210, fork returned 0

子进程打印的 ppid 等于父进程打印的 pid,说明 fork 建立起了正确的父子关系。如果先看到 child 行再看到 parent 行,也是正常现象。子进程和父进程在 fork 返回后都处于就绪状态,由调度器决定谁先拿到 CPU,实验报告里可以明确写出这一点来展示对并发调度的理解。

4. 让父子进程“分道扬镳”:验证路径区分与数据隔离

4.1 用 getpid 与 getppid 追踪父子身份的完整实验

基础 fork 程序只验证了返回值,但还不能充分证明两个进程确实是独立运行的。为了把进程创建的语义看清楚,我通常会写一个稍完整的实验程序:让父进程写自己的 PID 和子进程 PID,让子进程写出自己的 PID 和父进程 PID,并且在各自分支里执行不同的循环,模拟父子“各自干活”的场景。这个实验能同时验证三个点:分支确实分开了、每个进程拿到的是正确的身份信息、两个进程在并发轮流执行。

#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> int main(void) { pid_t pid = fork(); if (pid < 0) { perror("fork failed"); exit(1); } if (pid == 0) { // 子进程路径 printf("[child] pid=%d, ppid=%d\n", getpid(), getppid()); for (int i = 0; i < 3; i++) { printf("[child] working... count=%d\n", i); sleep(1); } printf("[child] done\n"); } else { // 父进程路径 printf("[parent] pid=%d, child_pid=%d\n", getpid(), pid); for (int i = 0; i < 3; i++) { printf("[parent] working... count=%d\n", i); sleep(1); } printf("[parent] done\n"); } return 0; }

运行这个程序会看到父子进程交替打印working...信息。交替顺序每次都可能不同,这是正常现象:sleep(1) 只是让当前进程主动放弃 CPU,并不知道下一个被调度的是父进程还是子进程。如果把 sleep(1) 去掉,打印节奏可能变得更快,但交替特点依然存在。这里要特别注意,程序的输出行可能交错在一起,不要把这个现象当成 bug。

配合ps -l命令可以观察进程状态。在父子进程的循环体里插入一个长 sleep,然后另开终端执行ps -l,可以看到两个进程的 PID、PPID 以及状态列。父子进程的 PPID 分别指向了正确的父进程,状态列里的 S 表示睡眠等待,睡眠结束后会变回 R(就绪或运行)。这是用外部工具反向验证进程创建的正确方法。

4.2 写时复制验证:子进程修改变量不影响父进程

进程创建实验另一个值得做的验证是写时复制。实验思路不复杂:在 fork 之前定义一个局部变量并赋初值,fork 之后在父进程分支里修改这个变量,在子进程分支里读取这个变量,然后让两边分别打印该变量的值。如果内存空间是独立的,父进程修改变量不会影响子进程看到的变量值。

#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(void) { int shared_value = 100; pid_t pid = fork(); if (pid < 0) { perror("fork failed"); exit(1); } if (pid == 0) { // 子进程先读取,再修改 printf("[child] before modify: shared_value=%d\n", shared_value); shared_value = 200; printf("[child] after modify: shared_value=%d\n", shared_value); exit(0); } else { // 父进程等子进程运行结束,再读取 sleep(2); printf("[parent] read after child modified: shared_value=%d\n", shared_value); } return 0; }

运行结果中父进程看到的 shared_value 应该始终是 100,即使子进程把它改成了 200,也影响不到父进程的变量。原因是 fork 之后父子进程的页表指向不同的物理页面,子进程的修改只作用于自己那部分的物理内存。sleep(2) 在这里特意让父进程等待,确保子进程的修改真正发生过了。如果父进程打印出来的值还是 100,就说明内存隔离生效了。

有同学会问:既然 fork 把整个地址空间复制一份,那为什么子进程和父进程的共享变量地址打印出来是一样的地址值?这涉及虚拟地址空间的概念,两个进程的虚拟地址虽然相同,但映射到的物理页已经不是同一页了。实验报告里如果能附带这个解释,说明对操作系统内存管理的理解已经超过平均水准。

4.3 观察父进程等待子进程退出的必要性

上面的写时复制验证里用了 sleep(2) 让父进程等子进程结束。实际实验中更规范的做法是用 wait 或 waitpid 等待子进程退出,而不是靠 sleep 碰运气。这是因为如果父进程先退出,子进程就会变成孤儿进程,被某进程收养,且子进程的调度和执行不再受父进程控制,实验结果可能变得不可控。

标准写法是把父进程分支里 sleep 的部分换成 wait(NULL),表示父进程阻塞等待任意子进程退出,之后才继续执行。wait 与 sleep 的差异在于:wait 能拿到子进程退出状态,也知道子进程确实已经结束;sleep 只是盲等固定时间,如果子进程运行的时间超过 sleep 时长,父进程会在子进程退出之前就打印数据,导致实验结果出现误差。从实验严谨性的角度,进程创建实验建议从一开始就用 wait 系列函数做同步,这对后续做进程通信实验也是一个好习惯。

同步方式是否能感知子进程退出适用场景
sleep(固定时长)不能只做演示,不严谨
wait(NULL)能子进程状态不重要,只需等待
waitpid(pid, &status, 0)能需要检查子进程退出码
waitpid(pid, &status, WNOHANG)非阻塞,查询状态需要周期性检查子进程状态

5. 进程创建实验的高频踩坑与排查记录

5.1 现象:fork 返回 -1,perror 显示资源不足

做实验时偶发 fork 调用返回 -1,perror 打出的信息是内存不足或资源暂时不可用。对于实验环境来说,最常见的原因是进程数超过了用户进程上限。默认情况下普通用户的进程数上限可能是 1024 或更小,如果之前运行的程序留有大量未回收的僵尸进程,又或者循环 fork 但子进程没有及时退出,就会触顶。

解决方法是先查看当前进程数。执行ulimit -u看用户进程上限,如果用ps -ef | grep 用户名能看到大量自己的进程残留,说明是环境里堆积了太多未清理的进程。此时用kill清理无用进程即可。如果确实是上限过低,可以用ulimit -u 4096提升当前 shell 的进程数限制,但这个操作只影响当前终端,不影响全局配置。实验代码里也应该在 fork 失败时做好错误处理,而不是直接让程序崩溃。

5.2 现象:子进程的打印先于父进程出现

进程创建实验里最容易被误判的现象就是打印顺序。很多初学者认为父进程在代码里写在前面就一定会先执行,于是看到子进程的打印行先出现时,第一反应是程序写错了。实际上 fork 之后两个进程的调度顺序完全由内核调度器决定,父进程并没有天然优先权。

解决方法是把对顺序的执念放下,改为验证父子进程确实同时存在于系统里。可以在父进程分支里调用wait(NULL),这样父进程会等待子进程退出后再继续,打印顺序就固定为子进程先打印、父进程后打印。如果实验要求就是观察并发调度,那么保留随机打印顺序并在实验报告中解释原因即可。这个现象本身不是 bug,而是操作系统的正常调度表现。

5.3 现象:printf 的输出内容重复出现不止一次

一个很经典的翻车现场是用 fork 配合 printf 打印,结果同一行内容出现了三到四次,而且顺序毫无规律。初次遇到时很容易让人怀疑 fork 是不是创建了多个子进程,实际上问题在于 printf 的缓冲区被复制了。printf 默认是行缓冲或全缓冲,当输出目标是终端时通常是行缓冲,遇到末尾没有换行的文本时内容会留在进程的用户空间缓冲区里,fork 时这个缓冲区被原样复制给子进程,之后每个进程各自在退出时 flush,同一份内容就被打印了多次。

解决方案是在 fork 之前fflush(stdout)清空缓冲区,或者确保每次 printf 都以换行符结尾。实验代码里最容易出现这个问题的位置是在 fork 之前打印提示信息时忘记加换行。这也说明了一个代码习惯:fork 之前不要留下未刷新的标准 IO 缓冲区。

5.4 现象:子进程退出后变成僵尸进程,且无法直接杀死

实验里如果父进程在任务结束后没有调用 wait 就退出,子进程虽然已经执行完,但其进程控制块仍然残留在内核的进程表里,状态标记为僵尸。如果父进程一直在运行不回收子进程,僵尸进程会持续占用一个进程表项。再执行 fork 时可能因为进程表项不足而失败,返回 -1。

解决方法是让父进程调用 wait 或 waitpid 回收子进程。需要注意的是,僵尸进程不能被 kill 命令强制解除,从外部杀掉僵尸进程本身是无效的,只能让父进程回收,或者杀掉父进程之后由某进程收养并回收。做实验时养成“每个 fork 出来的子进程都必须被 wait”的习惯,既避免僵尸进程堆积,也为后续进程间通信和进程同步实验扫清障碍。

5.5 现象:多进程并发写同一个文件,内容互相覆盖

实验扩展阶段有同学尝试让父子进程分别向同一个文件写入不同的文本,结果发现日志信息互相覆盖,或者文件内容混乱。这个现象的原因很好理解:父子进程共享同一个文件偏移量和文件描述符,写入操作是并发的,后写入的数据可能覆盖先前的内容,两个进程的文件偏移也可能互相干扰。

解决方法是让每个进程各自打开一次同一个文件路径以获得独立的文件描述符和偏移量,或使用O_APPEND标志让每次写入都从文件末尾追加,也可以简单地在实验里避免并发写同一文件,改为每个进程写各自不同的文件。如果想进一步控制并发写同一文件的顺序,就得引入进程间同步机制,比如文件锁或信号量,这个内容通常属于后续实验的范围。

6. 用 waitpid 和退出码把进程创建实验收口

6.1 通过 waitpid 拿到子进程退出状态

进程创建实验的最后一个环节是资源回收与状态检查。wait 函数可以回收子进程,但拿不到子进程的退出状态细节;waitpid 提供了更精确的控制,可以指定等待某个具体的子进程,并通过状态指针检查退出方式。下面这段代码展示了如何用 waitpid 检查子进程是正常退出还是被信号终止:

#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/wait.h> int main(void) { pid_t pid = fork(); if (pid < 0) { perror("fork failed"); exit(1); } if (pid == 0) { // 子进程执行完退出 printf("[child] exiting with code 42\n"); exit(42); } int status; pid_t ret = waitpid(pid, &status, 0); // 等待指定子进程 if (ret == pid) { if (WIFEXITED(status)) { // 正常退出,用 WEXITSTATUS 提取退出码 printf("[parent] child exited normally, code=%d\n", WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { // 被信号终止,用 WTERMSIG 提取信号编号 printf("[parent] child killed by signal %d\n", WTERMSIG(status)); } } return 0; }

代码里的宏是实验报告里需要解释清楚的重点。WIFEXITED 判断子进程是否正常退出,WEXITSTATUS 提取退出码的低 8 位,WIFSIGNALED 判断是否被信号杀死,WTERMSIG 返回具体信号编号。退出码 42 会被内核截断为低 8 位,也就是说超过 255 的退出码实际取模 256,设计退出码时要注意这个边界。父进程最终 return 0,整个实验的收尾工作就完成了。

6.2 扩展验证:连续创建多个子进程并跟踪执行顺序

如果还想在实验基础上往前一步,可以尝试循环创建多个子进程,再逆序回收它们。这个扩展能训练对 PID 分配规律的感知,也能加深对 waitpid 指定回收和 wait 随机回收这两种行为的区分。我做这类扩展时常用的方案是让父进程创建 3 个子进程,每个子进程打印自己的 PID 后退出,然后父进程用 wait 循环回收并打印回收到的子进程 PID。

#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/wait.h> int main(void) { pid_t children[3]; for (int i = 0; i < 3; i++) { pid_t pid = fork(); if (pid < 0) { perror("fork failed"); exit(1); } if (pid == 0) { // 子进程打印自己的 PID printf("[child %d] pid=%d, ppid=%d\n", i, getpid(), getppid()); exit(i); // 退出码 i } children[i] = pid; } int status; pid_t done; while ((done = wait(&status)) > 0) { printf("[parent] reaped pid=%d, code=%d\n", done, WEXITSTATUS(status)); } return 0; }

运行这个程序时会发现一个值得记录的现象:三个子进程打印的 PID 不一定按创建顺序递增排列。PID 分配虽然通常递增,但内核可能复用已经释放的 PID 编号,同时三次 fork 之间如果存在其他进程创建销毁,编号顺序就会被打断。wait 回收子进程的顺序也不一定是创建顺序,取决于哪个子进程先退出。把这个观察写进实验报告,能体现出对进程生命周期和异步调度机制的真实理解。

进程创建实验做到这里就形成了一个完整的闭环:从 fork 创建进程、区分父子执行路径、验证写时复制,到 wait 回收资源、检查退出状态、扩展观察调度顺序。整套动词串下来,进程管理章节的核心操作就都过了手。我做过很多次这个实验的辅导,最大的体会是别把实验结果当成真理,要把实验现象当成理解操作系统机制的线索。遇到打印顺序和自己预期不符时,先查调度和缓冲区的原理,不要急着改代码去“修正”正常现象。希望这篇笔记能帮你把实验五做透,也把进程这个概念真正装进脑子里。

如果后续实验涉及进程通信,建议回头再看一遍 fork 时继承的文件描述符表和进程间的关系,因为管道、共享内存这些机制都建立在 fork 创建的进程关系之上。实验里写过的 wait、等待回收、退出状态检查这些代码,在后续的实验中只会更加常用,现在把它们练熟能省下很多补课时间。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询