ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

操作系统实验:fork()进程创建原理与实战解析

操作系统实验:fork()进程创建原理与实战解析 简介合工大计算机学院操作系统实验五“进程的创建”实验报告面向正在学习操作系统、需要完成类Unix系统进程实验的学生。报告基于Linux 0.11内核详细阐述了fork与execve两个系统调用的运行机制记录实验目的、原理、完整操作步骤和调试结果。压缩包内仅有1个PDF文档大小约621KB报告配有多张截图展示使用VSCode、gcc、gdb及Bochs模拟器进行程序编译、断点设置、单步调试、进程信息查看的全过程特别对fork如何复制父进程内存、execve如何释放并替换当前进程映像等关键环节进行深入分析并对比了两种操作下进程ID、内存空间和代码段的变化规律。该资源已有547人学习内容系统完整、步骤可复现既可作为合工大学生完成同名实验的参考也能帮助其他读者理解进程创建、程序加载执行及操作系统底层调度机制具有较高实用价值。 大二的《操作系统》课实验五“进程的创建”是让我印象很深的一次。合工大计算机学院这门课的Lab安排前几个实验还在搭环境、看内核线程到了进程创建这里才真正感受到“我在操作系统的地盘里干活了”。我的实验环境是VMware里的Ubuntu 22.04全程用C语言调fork()、getpid()、wait()几个系统调用。这个实验如果不走心就是照着代码敲一遍然后对着输出发呆但琢磨透了你会发现它把“进程”“调度”“内存”几个大概念串起来了。这篇文章就把我当时从实验文档到最终报告的整个过程写下来包括踩坑和思考给还没做或者正在做这个实验的同学一个参考。1. 实验背景与整体设计思路1.1 为什么这个实验非做不可操作系统这门课里“进程”是所有概念的起点。进程是资源分配的基本单位也是CPU调度的基本单位你后面学的线程、同步、死锁、内存管理全都建立在进程的基础上。而进程的创建又是进程生命周期的第一步。你连进程怎么产生都没整明白后面的“进程调度”“进程通信”只能越学越虚。实验五就是把“进程创建”从课本概念变成可观察的现象。合工大这个实验表面上只是叫你调用几次fork()看输出结果实际上它逼着你搞明白三件事第一fork()调用一次为什么返回两次第二父子进程到底共享了什么、复制了什么第三多个进程同时存在时谁先运行谁后运行谁来收尸这三个问题恰恰是理解操作系统的关键。我当时看到实验文档里那句“分析输出结果解释原因”就意识到这不是一个写代码的实验是一个“写代码写思想”的实验。代码量不大分析才是大头。1.2 实验环境选型合工大的实验课推荐用Linux环境但当代大学生笔记本上大概率是Windows。我当时用VMware Workstation 17装了一个Ubuntu 22.04 LTS分配的配置是2核CPU、4GB内存、40GB磁盘跑实验完全够用。我在虚拟机里装好了gcc编译环境用以下命令把基础包装上sudo apt update sudo apt install build-essential vim这里我踩了一个小坑第一次登录Ubuntu后直接敲gcc报“Command not found”。原因是默认系统不一定装好了build-essential这个基础包里面才包含gcc、g、make这些工具。装上之后后面所有实验代码都用gcc编译一条命令搞定gcc -o fork_demo fork_demo.c除了VMware也有同学用WSLWindows Subsystem for Linux我的体会是WSL跑这种实验完全没问题甚至更方便因为可以直接在Windows文件系统里编辑代码。但如果后续实验涉及图形界面或者内核模块还是用虚拟机更稳。提示每次实验前先备份一份干净的虚拟机快照后面几周你会感谢这个习惯。尤其实验课连做几周环境改崩了随时回滚。2. fork() 的原理与机制拆解2.1 一个函数调用为什么返回两个值这是实验里几乎所有思考题的核心也是面试常问的八股题。fork()是Linux提供的系统调用它的作用是“从当前进程复制出一个子进程”。调用fork()的进程叫父进程复制出来的叫子进程。关键在于fork()执行成功后父子两个进程都会从fork()返回的地方继续往下执行而它们的返回值却不相同。在父进程中fork()返回子进程的PID在子进程中fork()返回0如果创建失败父进程返回-1。我实验时写的第一段代码是这样的#include stdio.h #include unistd.h #include sys/types.h int main() { printf(before fork, pid %d\n, getpid()); pid_t pid fork(); printf(after fork, pid %d, return %d\n, getpid(), pid); return 0; }编译运行后输出长这样每次运行PID会不一样before fork, pid 12345 after fork, pid 12345, return 12346 after fork, pid 12346, return 0注意“before fork”只打印了一次而“after fork”打印了两次。这说明fork()之后的代码被两个进程各执行了一遍而fork()之前的代码只属于父进程。理解了这一点就理解了“调用一次、返回两次”的表象。2.2 子进程到底复制了什么按照操作系统的经典模型fork()创建一个子进程时子进程会获得父进程的数据段、堆、栈、打开的文件描述符的副本。父子进程从fork()返回后各自拥有独立的内存空间互不影响。这一点最直观的验证方法是在fork()之后修改变量观察父子进程中该变量是否同步变化。我当时在实验里加了这样一段#include stdio.h #include unistd.h int main() { int x 10; pid_t pid fork(); if (pid 0) { x 20; printf(child: x %d, x %p\n, x, (void*)x); } else { printf(parent: x %d, x %p, child_pid %d\n, x, (void*)x, pid); } return 0; }输出示例parent: x 10, x 0x7ffc... child: x 20, x 0x7ffc...虽然打印出来的地址看起来一样但父子进程的x值互不影响。这是因为进程的虚拟地址空间是独立的同一个虚拟地址映射到的物理内存页在fork()那一刻是复制的更准确地说现代Linux用“写时复制”技术只有写入时才真正复制。这个实验现象很容易误解需要多解释几句。现代Linux的fork()并不是把父进程的所有内存立刻复制一遍而是利用“写时复制”Copy-on-WriteCOW技术刚创建子进程时父子进程共享同一批物理内存页并把它们标记为只读。只要双方都不修改这些页就不用真正复制节省了大量内存和时间。一旦某一方尝试写入就会触发缺页异常内核才在此时复制物理页并重新映射到对应进程的地址空间。我在实验报告里分析这段代码时专门点出了COW机制并验证了“父子进程的虚拟地址相同但物理地址不同”这一结论。这个知识点在期末考试的简答题里也出现了属于必拿分项。提示进程中任何对数据段的修改比如x 20都会触发写时复制让子进程拥有自己独立的一个物理页。这就是为什么地址看起来一样值却不同的根本原因。3. 实验核心操作与代码实现3.1 实验三连创建、查看、回收合工大实验五的官方要求通常是编写程序使用fork()创建一个或多个子进程观察进程的PID、父子关系并尝试使用wait()或waitpid()回收子进程。我的实现是分三步走的。第一步基础创建。直接调用fork()在父进程和子进程里分别打印getpid()和getppid()父进程PID。注意子进程里getppid()返回的是父进程PID而在父进程里getppid()返回的是Shell进程PID。第二步循环创建。这一步是最容易翻车的地方。如果我在循环里写for (int i 0; i 3; i) { fork(); }你以为创建了3个子进程实际上创建了7个。因为每次fork()之后当前的父子进程都会继续下一轮循环第二轮的fork()会被两个进程各自执行第三轮会被四个进程各自执行总进程数变成2^3 8个除去最初的那个父进程子进程是7个。这个“fork炸弹”式的增长是实验报告里经典的思考题。我后来用一棵进程树把它画出来挂在实验报告里老师评分时看着很清楚。第三步回收子进程。子进程结束后会进入僵尸状态Zombie它的进程描述符仍然留在内核里直到父进程调用wait()或waitpid()读取其退出状态才能真正释放资源。如果父进程一直不回收僵尸进程会大量堆积消耗内核资源。我在实验中专门做了两个版本对比一个不调用wait()用ps命令观察僵尸进程另一个调用wait()观察僵尸进程消失。对比结果让我对这个机制印象极其深刻。3.2 完整的实验核心代码参考下面是我最终提交的核心代码片段结合了创建、查看和回收#include stdio.h #include unistd.h #include sys/types.h #include sys/wait.h int main() { pid_t pid fork(); if (pid 0) { perror(fork error); return -1; } else if (pid 0) { // 子进程 printf([child] pid %d, parent pid %d\n, getpid(), getppid()); sleep(1); // 模拟子进程执行任务 printf([child] work done, exiting...\n); return 42; // 自定义退出码 } else { // 父进程 printf([parent] pid %d, created child pid %d\n, getpid(), pid); int status; waitpid(pid, status, 0); if (WIFEXITED(status)) { printf([parent] child exited with code %d\n, WEXITSTATUS(status)); } } return 0; }这段代码覆盖了几个核心知识点fork()返回值判断、pid_t类型、waitpid()的使用、退出状态宏WIFEXITED和WEXITSTATUS。我当时实验报告的“核心代码说明”部分就是围绕这几行展开的。我编译运行后输出类似[parent] pid 20001, created child pid 20002 [child] pid 20002, parent pid 20001 [child] work done, exiting... [parent] child exited with code 42注意父子进程的打印顺序不一定是“父先子后”。我跑了很多次有时候子进程先打印有时候父进程先打印。这说明操作系统调度是抢占式的谁先拿到CPU谁就先执行程序员不应该在代码里隐式假设执行顺序。3.3 用进程状态验证实验现象在运行实验的过程中我同时开了另一个终端用ps和top观察进程状态。这个过程非常直观强烈推荐你也这样做。我在父进程wait()之前加了sleep(10)让父进程等待期间有足够时间查看状态sleep(10); waitpid(pid, status, 0);这时候在另一个终端里执行ps -o pid,ppid,stat,comm -a | grep fork_demo能看到类似输出PID PPID STAT COMMAND 20001 1900 S fork_demo 20002 20001 Z fork_demoSTAT那一列里子进程是Z代表Zombie僵尸父进程是S代表睡眠。这个现象特别典型如果不调用wait()子进程结束后会一直是僵尸状态。而我一旦让父进程执行waitpid()子进程的Z状态就消失因为它被回收了。提示在实验报告里把ps的输出贴进去并配上僵尸进程形成原理的分析这个实验的深度一下就上来了。只贴一个费解的输出不如配合状态解释有说服力。4. 实验常见错误与排查技巧实录4.1 第一坑fork()返回值判断写反我一开始写代码时把pid 0当成父进程分支把pid 0当成子进程分支。运行后没有任何报错但逻辑全反了。这属于典型的“编译通过、运行崩”的错误。排查方法是在分支里分别加上调试打印printf(in branch, pid %d\n, getpid());然后对照PID大小。父进程的getpid()是原有PID子进程的getpid()是新PID。通过输出能立刻发现谁在执行哪个分支。4.2 第二坑printf缓冲导致输出重复这可能是实验中最让人迷惑的现象。我在fork()之前用了printf打印一行提示结果运行后发现这行提示被打印了两次。当时怎么回事呢原因是printf是带缓冲的而且当输出目标是终端时是行缓冲模式。但当你使用重定向把输出写入文件时缓冲模式会变成全缓冲。我的实验代码里有一段printf(before fork)没有换行符也没调用fflush()数据被留在缓冲区里。fork()复制进程时缓冲区也被复制了于是子进程把缓冲区里的内容又输出了一遍。我实验时把这个坑也记录了下来在fork()之前如果打算打印信息要么用带\n的printf要么显式调用fflush(stdout)要么使用stderr标准错误默认是不缓冲的。这个知识点很经典面试笔试偶尔会考。4.3 第三坑孤儿进程与父进程提前退出有时候我想让子进程多跑一会儿比如sleep(30)然后让父进程立即return 0。结果子进程变成孤儿进程它的PPID变成了1也就是init/systemd进程。我当时用ps查看了进程状态发现子进程的父进程PID变成了1同时stat是S。这个现象其实不是bug而是操作系统的一个机制如果父进程先于子进程退出内核会把子进程的父进程设置为init进程PID为1由init进程负责收养并最后回收。这也是wait()的另一种变形理解不是非要亲父进程收尸别的进程只要调用wait()也可以。我实验报告里把“僵尸进程”和“孤儿进程”做了一个对比表格这个小动作在报告里显得分析很完整对比项僵尸进程孤儿进程产生原因子进程退出、父进程未回收父进程先退出、子进程仍在运行进程状态ZZombieSSleeping或RRunning被谁回收父进程调用wait()/waitpid()被initPID 1收养后回收是否有害消耗内核进程表项本身无害但要注意语义上的孤儿4.4 第四坑waitpid()的参数总是用错waitpid()的第三个参数叫options我当时经常忘了传0直接传了个NULL进去然后编译报错。实际上第三个参数是int类型传0表示阻塞等待子进程结束。如果传WNOHANG则是非阻塞轮询模式配合循环使用可以做到“不阻塞地查看子进程状态”这在后面的进程通信实验里经常用到。我建议你把这几种waitpid()用法都写一遍demo确认效果后再进实验报告。提示wait()和waitpid()的区别在于wait()等待任意一个子进程结束waitpid()可以精确指定要等待哪个子进程。在多个子进程的场景下wait()可能等到的不是你想要的那个这时候waitpid()就派上用场了。5. 实验报告的思考题与进阶扩展5.1 必考的思考题不只是在考fork实验文档最后有几道思考题其中一道是“子进程与父进程有哪些资源是共享的哪些是独立复制的”。我当时的回答分了三类内存空间数据段、堆、栈是独立复制的打开的文件描述符表是复制但指向同一个文件表项进程PID、父子关系、信号处理器等是独立的。核心是文件描述符这一块很容易漏掉父进程打开一个文件fork()之后子进程可以继续读写同一个文件且共享文件偏移量这在后面学管道、重定向时非常重要。另外一道题是“fork()和exec()的区别”。实验五通常不要求exec()但思考题可以提前铺垫。execl、execvp等系统调用用于在一个已存在的进程中加载新的程序。我的总结是fork负责“复制”exec负责“替换”两者搭配使用才能实现“启动一个新程序”的效果。这也是Shell执行外部命令的底层原理。我把这两个思考题的完整分析都写进了报告实验报告最后还画了一个简单的进程状态迁移流程创建fork→就绪→运行→阻塞/退出→僵尸/回收。这个图帮我把整个生命周期串起来了。5.2 利用/proc文件系统加深理解我在实验完成后额外做了一件事在子进程休眠期间用Linux的/proc文件系统查看该进程的信息。例如在另一个终端里执行cat /proc/20002/status里面能看到Name、State、Pid、PPid、VmRSS等字段真实地反映了该进程的状态和内存占用。这一招虽然不是实验的硬性要求但在报告“进阶讨论”中写下“通过/proc查看进程PCB字段”绝对能让老师眼前一亮。5.3 小范围并发创建多个子进程如果觉得一个子进程不过瘾还可以写一个循环每次创建子进程后用不同方式让进程输出自己的创建顺序for (int i 0; i 3; i) { if (fork() 0) { printf(child %d created, pid%d, parent%d\n, i, getpid(), getppid()); return 0; } }跑几次你会发现子进程输出的顺序不一定是0、1、2乱的因为调度器不是按创建顺序分配CPU的。这里我用一个usleep(200)让子进程错开执行反而能看到更稳定的顺序。这种并发不确定性也就是操作系统课里反复强调的“调度不可预测性”。提示进阶玩法是写一个程序创建200个子进程观察系统PID分配规律。PID是递增的但中间可能会有空洞因为其他进程也在创建、退出。这个观察能帮你理解内核进程表的管理。6. 实验做完之后的一点体会整个实验五做完我最直观的感受是操作系统课本上的名词终于活过来了。僵尸进程、孤儿进程、写时复制、进程调度这些概念如果不亲手观察就只是背完就忘的考点。现在说起fork()我脑子里不是一行函数原型而是一幅画面一个进程像细胞分裂一样变成两个各自跑各自的最后还得有人收尸。如果你正卡在这个实验上我的建议是不要急着调代码先问问自己三个问题fork()之后有几条执行流每个进程的变量空间是独立还是共享父进程怎么确认子进程的状态想明白这三个代码怎么写都是顺的。要是代码能跑但报告写不满就按我上面的思路把wait()回收、僵尸进程、COW这几个点各写200字报告自然充实。最后再分享一个实际操作中很实用的小技巧在代码的关键执行点加打印当前PID的语句后面所有排查都会方便很多。我之前觉得printf(entering...)这种日志无所谓直到有一次调试进程顺序才发现没有PID标记的日志就像没有目录的书完全没法找线索。加一行getpid()整个实验的调试效率能提升一个档次。本文还有配套的精品资源点击获取
返回列表