简介:合工大计算机学院操作系统实验五的完整实验报告,面向操作系统课程学习者与Linux内核入门者,围绕进程创建这一核心主题,系统讲解创建子进程和加载执行新程序的实现路径,并深入对比fork与execve两类系统调用的功能差异。报告按实验报告体例编排,从实验目的、任务要求、实验原理到操作步骤依次展开,重点还原在Linux 0.11环境中用fork创建子进程、通过gdb调试设置断点观察进程表变化、检查新分配物理页基址,以及编写新程序后用execve替换当前进程映像、观察进程标识符保持不变等关键环节,同时对运行结果给出合理解释。资料包仅含1个PDF文件,大小约621KB,内容即该实验报告全文,适合操作系统课程实验的预习、复现和报告撰写参考。已有548人学习下载,对理解进程生命周期、系统调用执行细节以及Bochs模拟器下的内核调试方法有直接的参考价值。
1. 实验五“进程的创建”在讲什么:fork 一次调用、两个返回值
第一次写 fork 实验的同学,常常会对着终端里多出来的那行输出发愣:程序明明只运行了一次,printf 却打了两遍。再往后,有人在循环里 fork 了三次,屏幕上冒出八行、甚至十几行输出,才意识到这个实验真正要建立的是“进程”这个抽象。某高校计算机学院操作系统实验五“进程的创建”,核心就是用 fork 系列系统调用生成子进程,配合 wait、exit 控制进程生命周期。它解决的不只是“创建一个进程”,而是让你亲眼看到 PCB 如何被分配、程序计数器如何复制、父子进程如何并行、退出时又如何被回收。适合正在做操作系统实验、需要理解 fork 语义,以及想彻底搞懂多进程调试的读者。
2. 进程创建背后的机制:从 fork 到写时拷贝,实验前必须搞清的四个概念
2.1 fork 的系统调用语义:返回值为什么是进程号而不是布尔值
进程创建实验的第一个认知门槛,是 fork 的返回值设计。它不返回“成功/失败”这样的布尔值,而是返回进程号,并且父进程和子进程拿到的值不一样:父进程里拿到的是新创建子进程的 PID,子进程里拿到的是 0,失败时返回 -1。这个设计的直接后果是,一段 fork 之后的代码,会被两个进程各自执行一遍,但同一行代码里的变量值可能完全不同。
我用一个最朴素的方式理解它:fork 之前,代码只有一个执行流;fork 那一刻,内核把当前进程的 PCB、页表、文件描述符表完整复制一份,两个执行流从 fork 返回的那条指令继续往下走。由于返回值不同,用 if (pid == 0) 就能把父子分支切开。子进程走 pid == 0 的分支,父进程走 else 分支,这是整个实验所有代码的基本盘。
常见误区是有人把 return 0 当成“子进程执行成功的标志”,于是用 return 0 判断子进程创建成功,逻辑上就完全错位了。0 只是区分父子进程的哨兵值,真正的成功与否要看 fork 是否返回 -1。这个语义理解不到位,后面写多进程协作代码会处处别扭。
2.2 进程四要素与 PCB:实验里看不见但必须理解的对象
操作系统教材里常把进程描述成“程序的一次执行”,但实验代码里真正要感受的是进程的四个要素:程序代码、数据集合、进程控制块(PCB)、以及执行上下文。fork 创建子进程时,并不是把父进程的代码段复制一份,而是父子共享同一份代码段,各自维护独立的数据段、栈和寄存器现场,PCB 里记录的状态也各自独立。
PCB 是操作系统管理进程的核心数据结构,包含 PID、PPID、进程状态、优先级、打开的文件表、信号处理信息等。实验代码里看不到 PCB 本身,但 ps 命令能把它的一部分内容暴露出来。我一般会让学生在 fork 程序运行期间,另开一个终端敲这样一条命令:
ps -o pid,ppid,stat,comm --forest代码逻辑说明:-o 指定要显示的列,pid 是进程号,ppid 是父进程号,stat 是进程状态,comm 是命令名;--forest 让父子关系以树状结构显示,能直观看到当前 shell 下面挂着一个父进程,父进程下面又挂着子进程。
这条命令的实际意义在于,它把教材里的 PCB 概念具象化:你会看到父进程退出前,子进程的 PPID 字段先是指向父进程 PID,等父进程退出后,PPID 会变成 1(被某个系统进程接管)。这就是 PCB 里父子关系字段的真实变化,比背十遍“进程控制块包含哪些字段”都管用。
2.3 写时拷贝与 fork 的执行效率:为什么 fork 不复制全部内存
一个绕不开的问题:fork 如果要把父进程的整个地址空间复制一份,创建几十个进程,内存不是瞬间爆炸?实际 Linux 系统上,fork 采用的是写时拷贝(Copy-on-Write, COW)策略。fork 时并不真正复制物理内存,只是把父进程的页表复制一份,并把这些页标记为只读;父子进程共享同一片物理内存页。
真正触发复制的时刻,是父子任意一方往共享页写入数据。这时候内核才分配新物理页,把旧页内容拷贝过去,再更新对应进程的页表。所以 fork 本身的开销主要是复制页表、创建 PCB,和进程地址空间的大小关系不大;真正费内存的是 fork 之后写操作的频率和规模。
这个机制的实验意义在哪儿?它解释了为什么 fork 之后,父子进程里的某个整型变量看起来是“各改各的”。因为对变量的写入触发了 COW,子进程改的是自己那份拷贝,父进程的值不受影响。不理解 COW,就会误以为 fork 创建的是线程那种共享内存的实体,进而写出靠全局变量传递数据的错误代码。
2.4 fork、vfork、clone 的选型差异:实验该用哪个
Linux 上创建进程的接口不止 fork 一个,还有 vfork 和 clone。vfork 是早期为了提升效率搞出来的,它不复制页表,子进程直接共享父进程地址空间,并且父进程会阻塞直到子进程 exec 或 exit。这个接口有几个陷阱,比如子进程里修改变量会影响父进程,一旦子进程在 exec 之前改了栈上的数据,父进程会跟着遭殃,所以现在实验里基本不建议碰 vfork。
clone 是更底层的接口,Linux 内核的 fork 和 pthread_create 底层都调它。clone 可以按位指定要共享哪些资源,比如共享文件描述符表、共享虚拟地址空间、共享信号处理器,灵活度高但复杂度也高,不适合本科实验第一课。实验五的核心就是 fork,配合 wait 和 exit 完成最基本的进程生命周期管理。
我给学生的选型建议很简单:实验要求用 fork 就用 fork,不要炫技换 vfork。vfork 的写时拷贝缺失会导致各种诡异行为,调试成本远高于它省下的那点时间。真要在生产代码里创建进程,优先考虑 posix_spawn,它的语义更安全,但这些都属于实验之外的扩展知识,不在实验五范围内。
3. 把实验五跑通:最小 fork 程序、wait 回收与参数逐行拆解
3.1 最小代码骨架:fork 之后子进程到底执行哪一行
实验五通常要求创建多个进程并验证父子关系,但最核心的骨架就是一个 fork 加一个 if/else 分支。下面这段代码是我在实验课上让学生先跑通的最小版本:
#include <stdio.h> #include <unistd.h> #include <sys/wait.h> int main(void) { pid_t pid = fork(); if (pid < 0) { perror("fork error"); return 1; } if (pid == 0) { printf("子进程: pid=%d, ppid=%d\n", getpid(), getppid()); } else { wait(NULL); printf("父进程: pid=%d, 子进程 pid=%d\n", getpid(), pid); } return 0; }这段代码的逻辑说明:fork() 调用后,内核创建子进程,父进程中返回子进程 PID,子进程中返回 0。pid < 0 表示创建失败,直接打印错误并退出。pid == 0 是子进程分支,这里打印自己的 PID 和父进程 PID。else 分支是父进程,wait(NULL) 等待子进程结束,再打印自己的 PID 和刚才 fork 返回的子进程 PID。
参数说明:printf 里的 %d 对应 pid_t 类型,在 Linux 上 pid_t 本质是 int,直接用 %d 没问题,不需要强制转换。wait(NULL) 的参数是 int* 类型,传 NULL 表示不关心子进程的退出状态。这段代码的输出顺序有个陷阱:子进程先打印还是父进程先打印,完全看调度器脸色,不要指望输出顺序固定。
另一个学生常问的问题是:fork 之后,子进程是从哪一行开始执行的?答案是从 fork 调用后的那一条指令继续,不是从 main 开头重新跑。所以上面代码里,子进程不会再次执行 fork(),它直接进入 pid == 0 的分支。理解这条,后面写循环 fork 才不会踩坑。
3.2 wait 与 exit 的配合:僵尸进程是实验必考项
实验报告里几乎必问的问题:子进程退出了,父进程不回收,会怎样?答案是子进程变成僵尸进程,PCB 不能释放,直到父进程调用 wait 把它回收。wait 的作用就是阻塞父进程,直到某个子进程退出,然后收集它的退出状态,释放其 PCB。
看下面这个演示僵尸进程的代码:
#include <stdio.h> #include <unistd.h> #include <sys/wait.h> int main(void) { pid_t pid = fork(); if (pid == 0) { printf("子进程退出\n"); return 0; } sleep(10); printf("父进程等待结束\n"); wait(NULL); return 0; }逻辑说明:子进程里 printf 之后 return 0,子进程退出,但因为父进程在 sleep(10) 期间没有调用 wait,子进程的退出状态没人收集,它会进入僵尸状态。父进程 sleep 期间,在另一个终端执行 ps -o pid,ppid,stat,comm,能看到 stat 列是 Z,这就是僵尸进程的标记。
参数说明:sleep(10) 给观察留出时间窗口,实验时改成 sleep(30) 更从容,但要注意实验环境可能会限制单次运行时长。wait(NULL) 放在最后还是会被调用,所以僵尸状态只存在于 sleep 的这段时间里。这正好说明:僵尸进程不是父进程永远不回收,而是回收动作来得太晚。
很多人会忽略 exit 的配合。子进程里 return 0 和 exit(0) 在这个实验中效果相同,都触发退出流程,但 exit 会多做一步:刷新标准 IO 缓冲区。如果子进程用了 printf 且没换行符,return 0 可能会导致输出丢失,因为缓冲区没被冲刷。实验里 printf 都带 \n,所以没区别,但你要知道这个差异存在。
3.3 getpid/getppid 与进程树:验证父子关系的三个打印点
验证进程创建是否成功,最直接的手段是打印 PID 和 PPID。getpid() 返回当前进程 PID,getppid() 返回父进程 PID。父子进程里分别调用这两个函数,能构造出一组对照关系:父进程打印的 getppid(),应该是 shell 的 PID;子进程打印的 getppid(),应该是父进程的 PID。
我惯用的验证代码是这样组织的:
#include <stdio.h> #include <unistd.h> #include <sys/wait.h> int main(void) { pid_t pid = fork(); if (pid == 0) { printf("子进程: my pid=%d, my ppid=%d\n", getpid(), getppid()); sleep(2); printf("子进程睡醒后: my ppid=%d\n", getppid()); } else { printf("父进程: my pid=%d, child pid=%d\n", getpid(), pid); wait(NULL); } return 0; }逻辑说明:子进程第一次打印 ppid 时,父进程还活着,所以 ppid 就是父进程的 PID。子进程 sleep(2) 期间,父进程的 wait(NULL) 已经拿到子进程退出信息,然后父进程退出,此时子进程变成孤儿进程,被系统的 init 进程收养,它的 ppid 变成 1。第二次打印的 ppid 就能看到这个变化。
参数说明:sleep(2) 是给父进程留出退出时间,太短可能父进程还没退出就打印第二次,太长影响实验节奏。2 秒在多数机器上足够。这段代码同时演示了三个关键点:子进程能拿到自己的 PID、子进程能拿到父进程 PID、父进程退出后子进程的 PPID 被系统接管。
顺带一提,有同学会问:父进程打印的 child pid 和子进程打印的 my pid 为什么不一致?它们应该一致,因为 fork 返回值就是子进程 PID,getpid() 也是同一个值。如果对不上,说明代码里 fork 了不止一次,或者打印时机出了岔子,优先检查逻辑分支。
3.4 编译运行与参数调整:for 循环创建多个子进程时注意什么
实验五进阶版往往要求创建多个子进程,比如连续 fork 三个子进程。最典型的错误写法是:
#include <stdio.h> #include <unistd.h> #include <sys/wait.h> int main(void) { for (int i = 0; i < 3; i++) { fork(); printf("进程 pid=%d\n", getpid()); } return 0; }这段代码的问题:fork 之后,子进程会继续执行循环体里的下一次 fork。第一次循环结束时有 2 个进程,第二次变成 4 个,第三次变成 8 个,printf 会被执行 8 次而不是 3 次。这是 fork 实验最容易翻车的地方。
正确的多进程创建方式是子进程 fork 成功后立即退出循环:
#include <stdio.h> #include <unistd.h> #include <sys/wait.h> int main(void) { for (int i = 0; i < 3; i++) { pid_t pid = fork(); if (pid < 0) { perror("fork error"); return 1; } if (pid == 0) { printf("子进程 %d: pid=%d\n", i, getpid()); exit(0); } } for (int i = 0; i < 3; i++) { wait(NULL); } printf("父进程所有子进程已回收\n"); return 0; }逻辑说明:父进程每 fork 出一个子进程,子进程打印后立即 exit(0),不会继续执行下一次 fork。父进程继续循环,再 fork 下一个。这样循环三次,恰好产生三个子进程,每个子进程只属于一次 fork。第二个 for 循环调用三次 wait(NULL),确保三个子进程都被回收。
参数说明:exit(0) 在这里是必须的,少了它子进程会进入下一个循环继续 fork,进程数量爆炸。wait(NULL) 的调用次数要和 fork 成功次数严格对应,多等一次会阻塞,少等一次可能留下僵尸进程。实验里如果把 3 改成 n,这个模式依然成立,可以写成宏定义控制子进程数量。
编译命令没什么悬念:gcc -o fork_demo fork_demo.c,然后 ./fork_demo 运行。如果输出顺序和你想的不一样,正常;进程调度不保证顺序,这是实验的一部分,不是玄学。
4. 实验五避坑指南:fork 实验翻车的五个常见问题
4.1 现象:printf 输出重复或顺序错乱
实验里最常见的问题是输出行数远超预期,或者在循环 fork 后出现几倍于子进程数量的输出。原因通常是子进程没有在打印后退出循环,继续执行了后续的 fork,导致进程数量指数级增长。另一个原因是 printf 缓冲区被拷贝到了子进程,父进程里尚未刷新的缓冲区内容被复制,子进程退出时把残留内容也刷了出来。
解决:循环内 fork 后,子进程分支里一定要 exit(0) 或 break。关于缓冲区,printf 的字符串要带换行符 \n,这样每次调用都能即时刷新;或者在 fork 之前调用 fflush(stdout) 清空缓冲区,避免父进程的残留内容被复制到子进程。判断输出重复是逻辑问题还是缓冲区问题,最简单的办法是看重复的内容是否来自同一段 printf。
4.2 现象:进程卡住,wait 迟迟不返回
父进程调用 wait(NULL) 后终端一直无响应,像是死锁了。常见原因是 fork 失败了,但代码没有检查返回值,父进程在等待一个根本不存在的子进程;或者代码里 wait 调用次数多于实际子进程数,父进程在等一个永远等不到的退出事件。
解决:先确认 fork 返回值,pid < 0 时直接打印错误并退出。再数一下 fork 成功次数和 wait 调用次数是否一致。如果子进程里还有嵌套 fork,情况会更复杂,建议在关键路径上加上 printf 打印 fork 返回值和当前 PID,定位卡在哪个 wait 上。Linux 下还可以用 ps -o stat 查看进程状态,如果父进程状态是 S+(可中断睡眠),通常就是在等子进程。
4.3 现象:fork 返回 -1,子进程没创建成功
fork 失败的原因集中在两个方向:一是系统进程数达到上限,常见于单用户同时开的进程太多,或者实验循环里 fork 数量失控;二是内存不足,fork 需要复制页表,虽然写时拷贝省了物理内存,但页表本身还是要分配的。实验环境如果是虚拟机,默认内存设得小,多 fork 几次就可能失败。
解决:先看 errno,用 perror("fork") 打印错误信息,明确是 EAGAIN(资源不足)还是 ENOMEM(内存不足)。EAGAIN 的话,用 ulimit -u 查看用户进程数限制,临时调大:ulimit -u 4096。ENOMEM 的话,给虚拟机增加内存,或者检查是否有人在循环里 fork 失控,先修复代码再考虑扩容。
4.4 现象:终端里出现一堆僵尸进程
ps 里看到 stat 为 Z 的进程,数量还不少。原因是子进程先于父进程退出,父进程没有及时调用 wait 回收,或者父进程自己就退出了,子进程变成孤儿进程后被接管,但接管方没有立即回收。实验里最常见的是:父进程在 fork 之后直接 return 0,根本没调用 wait,子进程全部变僵尸。
解决:父进程在退出前用 wait 或 waitpid 回收所有子进程。如果子进程数量不固定,用 while (wait(NULL) > 0); 循环回收,直到 wait 返回 -1(表示没有子进程可回收)。另外要理解,僵尸进程占用的资源不多,但数量多了会占满进程表条目,新进程创建不了,所以实验结束前必须排查干净。
4.5 现象:子进程改了变量,父进程却看不到变化
很多人以为 fork 是创建线程那种共享内存的并发实体,于是在子进程里给全局变量赋值,指望父进程读到相同结果,结果父进程里变量的值纹丝不动。原因是写时拷贝机制:fork 之后父子进程共享物理页,但一旦子进程写入变量,内核会分配新页面并复制内容,从此父子各拥有一份独立数据。
解决:明确区分进程和线程。进程是资源分配的基本单位,地址空间彼此隔离;多进程通信要走独立通道,比如管道、文件、共享内存、信号。实验五不要求实现这些通信手段,你只需要记住:fork 之后父子进程的数据是“看起来相同、改起来独立”。如果实验要求父进程获取子进程的计算结果,最常见的做法是子进程用 exit(code) 传递退出状态,父进程用 wait(&status) 提取状态值。
5. 进阶验证:用 strace 观察 fork 调用,把实验从“能跑”做到“看得见”
实验五做完后,很多同学只知道 fork 能用,但不清楚它到底走了什么系统调用路径。Linux 上有个工具叫 strace,可以追踪程序执行过程中的所有系统调用。用它对最小 fork 程序跑一遍,能直观看到 fork 背后的真相。
strace -f -e trace=process ./fork_demo参数说明:-f 表示追踪子进程,fork 产生的新进程也会被追踪;-e trace=process 只过滤进程相关的系统调用,包括 clone、execve、exit 等。fork_demo 是前面编译出来的可执行文件。
输出里你会看到一行关键信息:clone(child_stack=NULL, flags=CLONE_CHILD_USE_TID|CLONE_CHILD_SETTID|SIGCHLD)。这说明在 Linux 上,库函数 fork 底层实际调用的是 clone,SIGCHLD 标志表示子进程退出时会给父进程发信号。这是教材没说透的一层:fork 是封装,clone 是内幕。
我的习惯是让学生把 strace 输出和代码对照着看:父进程的 clone 调用返回一个 PID,紧接着子进程的 getpid 返回相同数值;然后两边各有一次 write 系统调用,那就是 printf 真正写入终端的时刻。这个视角能把“一次调用、两个返回”从口头概念变成可见事实。
再进一步,可以用 strace -e trace=wait4 单独看父进程的等待行为。你会看到 wait4 调用返回子进程 PID 和退出状态。这个技巧在调试多进程程序时特别有用——当不确定是哪里卡住时,strace 会告诉你系统调用停在哪一行。
我带学生做这个实验时,一定会让他们至少跑一次 strace,看到那个一次调用、两个返回的现场,比任何口诀都管用。实验的意义不在把 demo 跑通,而在搞懂操作系统在这一瞬间到底做了什么。希望帮你把这个实验吃透。
本文还有配套的精品资源,点击获取