简介合肥工业大学计算机学院操作系统课程的实验报告《实验五-进程的创建》围绕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()拿到的是当前进程自身的 PIDgetppid()拿到的是当前进程父进程的 PID。子进程打印出来的 ppid 应该和父进程打印出来的 pid 一致这就是父子关系的最直接验证。运行程序后能看到类似下面的输出但注意两行打印的先后顺序不固定parent: pid5210, child pid5211, fork returned 5211 child: pid5211, ppid5210, 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 返回 -1perror 显示资源不足做实验时偶发 fork 调用返回 -1perror 打出的信息是内存不足或资源暂时不可用。对于实验环境来说最常见的原因是进程数超过了用户进程上限。默认情况下普通用户的进程数上限可能是 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、等待回收、退出状态检查这些代码在后续的实验中只会更加常用现在把它们练熟能省下很多补课时间。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?