资讯动态

操作系统原理实验2:进程控制实战与常见坑解析

发布时间:2026/10/14 9:02:06 来源:尧图企业网站定制
“操作系统原理 | 实验2-进程控制”是操作系统课程里第一次让人真正摸到“进程”这个概念的实验。前面几节课还在PPT上画PCB、状态转换图一到实验课fork一敲下去终端里突然冒出两个PID很多人才意识到原来一个程序跑起来之后系统里是真的有“分身”这回事。这个实验不复杂但特别容易踩坑尤其是对进程状态、父子关系、僵尸进程这些知识点的理解如果只是照着代码敲一遍很难形成肌肉记忆。下面这份整理是我基于自己的课程实验过程写出来的报告总结把代码怎么搭、知识点怎么串、坑怎么填都展开讲一遍希望能帮正在做这个实验的同学少走弯路。1. 实验整体设计与思路拆解1.1 这个实验到底想考察什么进程控制这一章的知识点看起来很散fork、exec、wait、exit、进程状态、孤儿进程、僵尸进程每一块单独拿出来都挺好理解但合在一起就非常容易混乱。操作系统课程的实验设计者把这个实验取名为“进程控制”核心目的不是让我们背诵API而是让我们通过代码观察三个关键现象进程如何被创建、进程如何被替换、进程如何被回收。先说创建。fork()是最典型的创建接口调用一次、返回两次这个行为本身就能说明很多问题子进程是父进程的一个副本但它拥有独立的地址空间、独立的PCB从fork返回之后两个进程各自往下执行。你可以在实验里打印PID和父子关系来验证这一点。再说替换。exec族函数做的事情是加载一个新的可执行程序到当前进程的地址空间替换掉原来的代码段、数据段、堆和栈本质上就是“换身”。很多同学会混淆fork和exec其实正确的组合方式是fork负责复制exec负责替换两者合起来才能启动一个全新的程序。最后是回收。进程退出不等于进程消失父进程需要用wait或waitpid来接收子进程的退出状态否则就会留下僵尸进程。这个环节在理论上只是几句话的事但实验里如果不写wait用ps一查就能看到一堆defunct那种视觉冲击力比任何PPT都来得直接。1.2 环境准备先把工具链配好这个实验对操作系统平台没有太多挑剔Windows上的虚拟机、本机Linux、WSL都可以但如果你用的是macOS个别细节会不太一样。我用的是Linux环境编译直接用gcc没有任何额外依赖因为实验涉及的接口全部来自POSIX标准不涉及图形界面也不涉及网络编程。建议动手之前先确认三件事系统里有没有gcc在终端敲 gcc -v如果没有先安装build-essentialUbuntu系或等价工具。是否有ps命令后面查僵尸进程要频繁使用 ps -l、ps -aux这是检查进程状态的必备工具。当前shell的PID环境变量echo $PPID 能帮你理解父进程是谁。另外强烈建议写代码时把每个系统调用的返回值都打印出来不要只打提示信息。这个习惯能在排查问题时省下大量时间。提示实验别急着打开编辑器写一大段代码再编译。先写一个10行的fork小测试确认环境没问题再逐步扩成完整的实验程序这样排错范围会小很多。2. 核心知识点解析把抽象概念落到代码里2.1 fork函数的“一个调用两次返回”fork()的返回值是理解整个实验的关键。它成功时在父进程中返回子进程的PID在子进程中返回0失败时返回-1。代码里最常见的长相是pid_t pid fork(); if (pid 0) { perror(fork failed); exit(1); } else if (pid 0) { // 子进程执行路径 } else { // 父进程执行路径 }这里有一个必须想清楚的问题子进程和执行父进程的代码在fork返回之后是同时执行的但谁先谁后不由程序决定而是由调度器决定。所以实验中你会看到父子进程输出的顺序每次可能都不一样这不是bug而是操作系统调度的真实表现。fork复制的不只是代码还包括父进程打开的文件描述符、环境变量、信号处理方式等。有一个常用的生活化类比fork就像是给了你一张系统的“高级复制券”把目前进程所有状态打包复制一份然后分成两队继续往下走。只不过这个复制不是物理上的全部拷贝而是用了写时复制技术只有真正写入数据的内存页才会复制这是性能层面的优化但逻辑上你还是可以把它当作完整复制来理解。写实验报告时这一部分建议配上具体的输出截图或者手动整理的输出表格清楚地标出两个进程的PID、父进程PID以及各自的输出顺序。2.2 进程状态与生命周期从创建到消亡课堂上讲的进程状态一般分为运行态、就绪态、阻塞态但实验里ps命令会展示更细致的状态码。Linux中常见的状态包括R可执行状态正在运行或在运行队列中等待S可中断睡眠等待某个事件发生D不可中断睡眠一般是在等待I/O完成Z僵尸状态进程已终止但未被父进程回收T停止状态被信号暂停实验里重点观察的是R和Z。你写一个循环打印输出的子进程它会表现为R或者S如果你不写wait子进程执行完exit(0)之后就会显示为Z也就是僵尸进程。僵尸进程没有占用CPU等执行资源但会占用一个进程表项如果大量堆积系统可能无法创建新进程这是生产环境中特别忌讳的事情。进程生命周期可以粗略理解为创建fork→ 就绪/运行 → 等待sleep/wait→ 终止exit/return→ 回收wait。其中终止和回收是两个环节中间隔着“僵尸”这个状态。很多同学以为进程执行完就自动从系统里消失这是最大的认知误区。2.3 exec族函数进程如何“改头换面”exec族一共有六个成员execl、execlp、execle、execv、execvp、execve。命名规则很简单l表示参数列表v表示参数数组p表示在PATH环境变量中搜索程序e表示可以自定义环境变量。实际实验中常用的是execlp和execvp因为带p的版本不用写完整路径系统会自己去PATH里找命令。一个典型的代码片段execlp(/bin/ls, ls, -l, NULL);要注意它与fork的本质区别exec成功之后exec之后的代码不会被执行因为当前进程的地址空间已经被新程序完全替换。如果你在某个分支里调用exec而它后面还有printf那这一行printf永远不会有输出除非exec调用失败。因此exec的返回值只在失败时才有意义。代码里通常写成execlp(ls, ls, -l, (char *)NULL); perror(exec failed); exit(1);如果exec成功perror根本不会执行如果执行到了说明exec肯定失败了这时把错误打出来再退出是标准姿势。实验报告里可以对比一下fork和exec的异同fork生成的新进程是父进程的副本exec让当前进程变成另一个程序fork用的场景是并发执行相似任务的场景exec往往和fork搭配用来启动一个完全不同的程序。比如shell执行我们敲入命令时就是先fork一个子进程再在子进程里exec对应程序。2.4 wait与waitpid给进程“收尸”wait函数的作用是让父进程阻塞等待任意一个子进程的终止并获得子进程的退出状态。waitpid则更精细可以指定等待哪一个子进程也可以设置非阻塞选项。wait的基本代码int status; pid_t wpid wait(status); if (WIFEXITED(status)) { printf(child %d exited with %d\n, wpid, WEXITSTATUS(status)); }常见误区是以为wait只能回收一个子进程。如果父进程创建了多个子进程wait需要多次调用才能回收所有子进程。写实验的时候可以验证fork两个子进程主循环调用两次wait你会发现系统必然会等两个都结束才继续往下走。waitpid有多种模式最常用的是下面几种默认阻塞同wait但能指定精确PID带WNOHANG的非阻塞模式可以用来实现“定期检查子进程状态”的效果WUNTRACED选项能捕获子进程的停止状态排查信号问题的时候有用。注意wait和waitpid只能回收直接子进程对孙进程是无能为力的。这一点在实验的扩展思考题里经常出现需要重点说明。3. 实操过程与核心实现细节3.1 实验一用fork创建子进程并观察特征这一部分的实验代码目标非常简单创建一个子进程然后分别在父进程和子进程中打印自己的PID、父进程PID。核心源码可以这样写#include stdio.h #include unistd.h #include sys/types.h #include sys/wait.h int main(void) { pid_t pid fork(); if (pid 0) { perror(fork error); return 1; } else if (pid 0) { printf(子进程PID%d父进程PPID%d\n, getpid(), getppid()); _exit(0); } else { printf(父进程PID%d子进程PID%d\n, getpid(), pid); wait(NULL); } return 0; }这段代码值得琢磨的点是子进程里的printf用到了getppid()这个函数返回的是当前进程的父进程PID也就是父进程的PID。而父进程里打印的子进程PID来自fork的返回值。两者交叉一对照就能确认父子关系。运行时应观察到父进程的PID通常比终端自身的进程大一点子进程的PID又比父进程大一点。注意父进程和子进程的调度顺序不确定所以输出先后会乱。如果你希望让父进程等待子进程先跑完可以调用sleep或者直接wait但wait的含义是想清楚了才加上去的。为了让现象更明显我们还可以让子进程进入一个循环每隔一秒打印一次“还在运行”然后父进程在wait之前打印“等待子进程结束”。这样可以看到子进程在R/S状态切换的过程。你也可以配合ps命令在另一个终端里查看当前进程的状态一般的命令是ps -l查出来的S、R、Z状态一清二楚。3.2 实验二通过exec替换进程镜像实验二可以设计成“父进程创建子进程子进程exec执行一个外部命令”。比如让子进程执行ls -l父进程只负责等待。#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; } else if (pid 0) { printf(子进程exec前PID%d\n, getpid()); execlp(ls, ls, -l, (char *)NULL); perror(exec error); _exit(1); } else { wait(NULL); printf(父进程子进程已被回收\n); } return 0; }运行结果中你会看到ls -l的输出直接出现在终端里而且子进程打印的“exec前”信息会在ls输出之前出现。这就证明exec是在fork之后、子进程运行期间执行的。exec之后的perror和“exec error”不会出现这恰恰说明exec成功执行了。如果你想对比exec成功和失败的区别可以在子进程中故意传一个不存在的命令比如execlp(not_exist_cmd, not_exist_cmd, NULL)你会看到perror打印出错误信息然后子进程用_exit(1)退出。还要注意实际应用中常用的是带完整路径的写法例如execlp(ls...)会依赖PATH环境变量而execl(/bin/ls, ...)则写死路径更适合脚本化场景。这里还要补充一个常用技巧如果想看进程替换前后PID是否变化可以在exec调用前打印getpid()然后让外部命令自己打印一个进程ID比如用ps你会发现PID完全没变。因为exec并不创建新进程它只做“换内容”这件事。3.3 实验三父进程回收子进程观察僵尸状态第三个实验到了本实验最有视觉冲击力的部分故意不调用wait让子进程退出然后观察它在进程表中的状态。#include stdio.h #include unistd.h int main(void) { pid_t pid fork(); if (pid 0) { perror(fork error); return 1; } else if (pid 0) { printf(子进程PID%d即将退出\n, getpid()); _exit(0); } else { printf(父进程PID%d不调用wait睡眠20秒\n, getpid()); sleep(20); } return 0; }运行后在另一个终端立即执行ps -l你会看到子进程一栏的状态码是Z命令行里还会带一个 标记意思是僵死进程。20秒后父进程退出这个僵尸进程会被系统的init进程领养并回收再查就不存在了。这个现象能解答很多人的困惑不是说僵尸进程无法被杀死吗为什么父进程退出后它就消失了因为父进程退出后子进程变成孤儿由系统的1号进程接管而1号进程会周期性调用wait来回收孤儿进程的所有子进程。完整的实验还应该验证父进程调用wait后子进程的状态不再是Z而是彻底消失。这里有个实操细节wait放在sleep之前就不会给你观察Z状态的窗口放在sleep之后你就能抓住中间那段僵尸时间。这种“代码顺序决定观察窗口”的思路是排查进程类问题的重要方法论。3.4 实验四用waitpid实现更精细的回收控制最后可以锦上添花用waitpid做一次带WNOHANG的轮询。父进程创建子进程后不断非阻塞检查子进程是否退出而不是一上来就死等。#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; } else if (pid 0) { printf(子进程PID%d\n, getpid()); sleep(2); printf(子进程运行结束\n); _exit(42); } else { int status; while (waitpid(pid, status, WNOHANG) 0) { printf(父进程子进程还在运行继续干自己的事...\n); sleep(1); } if (WIFEXITED(status)) { printf(父进程子进程退出状态%d\n, WEXITSTATUS(status)); } } return 0; }这里可以把子进程的退出码故意设成42父进程通过WEXITSTATUS能读到这个数字。对比一下之前实验中我们一直用_return和exit它们的本质是一样的都会触发退出状态返回给父进程。而_exit和exit的区别在于exit会刷新stdio缓冲区_exit则直接进入内核退出流程。printf输出顺序的诡异现象往往和缓冲区有关不是简单的代码逻辑问题。4. 常见问题与排查技巧实录4.1 问题一子进程输出丢失或者顺序错乱很多同学在实验中最先遇到的坑是子进程明明调用了printf却什么都没显示。这种问题的元凶多半是缓冲区而不是代码逻辑。当程序的标准输出连接终端时通常是行缓冲模式遇到换行符就会刷新但如果输出重定向到文件就会变成全缓冲数据攒在缓冲区里直到缓冲区满或进程正常退出时才刷新。如果你在子进程里用_exit(0)退出缓冲区内容就会被直接丢弃因而看不到输出。解决方法是测试时在printf字符串里加\n注意观察效果如果重定向到文件需要显式调用fflush(stdout)或者直接用fprintf(stderr, ...)绕过缓冲区。输出顺序错乱的问题更好解释父进程和子进程是并发调度的谁先运行取决于调度器和CPU时间片所以先打印的代码不一定先输出。实验报告里应明确说明这是正常现象不是环境问题。4.2 问题二父进程没有wait出现一堆僵尸进程如果你没有调用wait并且父进程是一个持续运行的长期进程比如守护进程或服务程序那么它的子进程每次退出都会留下一具尸体。多次fork之后坐标系里就会累积很多Z状态进程。生产环境中这是严重事故因为内核会按进程数上限来限制任务僵尸进程不释放进程号很快会导致新任务启动失败。排查步骤一般是这样用ps -l查看Z状态进程的PID和PPID找到PPID对应的父进程确认它是否在持续运行在父进程代码中检查是否有wait或waitpid以及是否对所有子进程都做了回收如果父进程本身已经退出而僵尸进程依然存在考虑是否被某个中间父进程持有。实验里经常有人只调用一次wait但创建了多个子进程导致后面的子进程还是僵尸。记住wait一次只能回收一个子进程需要循环调用直到返回-1或者精确配合waitpid。4.3 问题三fork之后进程进入死循环CPU占用飙升有些同学在测试时会在子进程里写一个不带sleep的while(1)死循环然后父进程也忘了wait结果整个系统CPU占用瞬间拉满终端卡住。这种情况的处理思路是打开另一个终端用ps找到PID再kill -9强制终止。如果连终端都卡到无法操作可以考虑重启虚拟机或者用系统自带的资源管理器强制结束相关进程。这个坑反映出fork一个重要特点子进程几乎复制了父进程的全部资源如果父进程处于某种循环里子进程也会带着同样的循环执行。所以设计实验时循环体里至少加一句sleep给其他进程留出调度空间也给自己留出观察时间。4.4 问题四waitpid返回-1errno含义不明确在复杂实验中waitpid调用失败主要有三种原因没有子进程可回收、指定PID不是当前进程的子进程、以及传入的信号中断了阻塞等待。排查时不要只看返回值还要配合perror或者查errno。常见的errno值包括ECHILD没有子进程和EINTR被信号打断。前者往往是代码里先wait完了所有子进程又重复调用后者多发生在信号处理场景解决办法是循环重试while (waitpid(pid, status, 0) 0) { if (errno EINTR) { continue; } perror(waitpid error); break; }这个写法在实际系统编程里很常见。实验报告里如果能补充这一段会让老师说你是带着工程意识在做实验而不是单纯照葫芦画瓢。4.5 问题五孤儿进程也有存在感跟僵尸进程相对的是孤儿进程。父进程提前退出子进程还没退出子进程就会被系统1号进程收养。很多资料会说“孤儿进程无害”但在实验中也能观察到它们的行为用sleep把父进程弄退出然后子进程继续打印你会看到子进程的PPID变成了1。这说明当一个进程的父进程消失后内核会自动指定一个父进程来接管它而1号进程是通常意义上的全局管家。实验报告里最好同时写出孤儿进程和僵尸进程的对比表一个是父亲死了儿子没人管一个是儿子死了父亲不埋。两者都是对进程生命周期中“父子关系维护”的逆向映射。5. 实验报告模板这样写能让老师和评审一眼看出你理解了5.1 报告结构建议如果你需要把实验整理成一份完整的报告建议按下面几个部分组织实验目的写明人话不要复制教材原文比如“通过编写和运行进程控制程序直观理解fork、exec、wait、exit的实际行为”实验环境写清操作系统版本、编译器版本、硬件平台但不用写宏大的描述实验内容与步骤每个实验点给出核心代码、运行命令、运行现象实验结果分析对照ps输出、PID关系、退出状态逐条解释现象背后的知识点遇到的问题与解决方法把上面那些细节填进去思考题部分这部分最值得花时间回答深入一点能让报告的整体质量上一个台阶。5.2 思考题高频考点操作系统课程的进程控制实验思考题基本都围绕着几个固定问题第一fork之后父子进程共享哪些资源不共享哪些资源。答案是文件描述符、环境变量、信号处理方式这些属于进程资源的会继承但地址空间、堆栈、全局变量虽然是副本却不共享写入因为写时复制让它们各自独立。你可以用两个进程修改同一个全局变量并打印来验证。第二exit和_exit有什么区别。单看exit是标准库函数会先执行清理工作比如刷新缓冲区、调用atexit注册的钩子函数然后才执行系统调用_exit。_exit更直接立即进入内核终止流程。实验中可以通过子进程里setbuf关闭缓冲或者不关闭缓冲来观察输出差异。第三什么是僵尸进程有什么危害怎么避免。结合实验里的现象解释比干巴巴背定义要好。第四编写一个程序要求父进程创建两个子进程让两个子进程分别执行不同程序并让父进程回收两个子进程。这个问题要写清楚wait循环和waitpid结合的办法很多同学的实现会出现只回收一个子进程的情况。5.3 一个可以复用的实验源码总集给你整理一份可以直接拿去扩展的完整代码框架它把上面的实验点合并成一个主程序通过命令行参数选择执行哪一个功能#include stdio.h #include stdlib.h #include unistd.h #include sys/types.h #include sys/wait.h void demo_fork(void) { ... } void demo_exec(void) { ... } void demo_zombie(void) { ... } void demo_waitpid(void) { ... } int main(int argc, char *argv[]) { if (argc 2) { fprintf(stderr, 用法: %s fork|exec|zombie|waitpid\n, argv[0]); exit(EXIT_FAILURE); } if (argv[1][0] f) demo_fork(); else if (argv[1][0] e) demo_exec(); else if (argv[1][0] z) demo_zombie(); else if (argv[1][0] w) demo_waitpid(); return 0; }每个demo函数里保留适当printf和sleep便于观察现象。这个结构有点像Linux系统命令的风格也方便后续扩展更多实验点。6. 实验心得与扩展建议进程控制实验做完之后最大的感受是系统调用的返回值不能只看成功与否关键要看它代表的语义。fork返回两次wait回收状态exec不返回这些看起来违反直觉的规律恰恰是操作系统向应用程序放出的接口信号。我自己的实操体会是不要急着追求一次把代码写对。把每个系统调用分开测试先跑fork再用ps分析再引入exec和wait这样知识链条会一截一截地打通。另外实验过程中多开一个终端专门跑ps -l能让你看到很多“意外惊喜”那些现象比教科书上的状态转换图真实得多。如果时间允许还可以试试在这个实验的基础上做一个迷你版shell的雏形大概只有二十几行循环打印提示符、读取用户输入、fork子进程、子进程exec命令、父进程wait。把整个实验的知识点串起来一下子就有了实际用途也顺便为后面的进程间通信实验做铺垫。最后再分享一个小技巧排查进程问题时不要只盯着自己的程序看优先确认父进程、子进程、系统进程这三角关系是否正常。很多问题只要把PID和PPID打印出来真相就自己浮出来了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑