南京大学 操作系统 (JYY) 学习笔记:进程、系统调用与状态机管理

发布时间:2026/7/27 4:46:23

南京大学 操作系统 (JYY) 学习笔记:进程、系统调用与状态机管理 写在前面这是本系列的第五篇。经过前几讲的底层探索本次课我们正式进入“每一讲都实现一点什么”的模式。我们将学习程序和进程管理以及进程管理的 API。操作系统通过虚拟化Virtualization技术将物理计算机抽象为虚拟计算机让每个程序都产生了一种“独占整个计算机运行”的幻觉。今天我们就来解剖这种幻觉的底层逻辑。程序 v.s. 进程程序 (Program)是状态机的静态描述。它静静地躺在硬盘上描述了所有可能的状态但本身没有任何运行时的状态。就像在 vim 里写代码如果没有 IDE 的检查你根本不知道犯了什么错直到你把它跑起来。进程 (Process)是动态的是运行起来的程序是状态机随时间的演进。操作系统上的进程除了程序本身的状态内存、寄存器操作系统还会为进程保存一些额外的只读状态PID、打开的文件、权限等。AI Prompt 探索进程信息我们可以用系统调用 API 获取当前进程的元数据。基本信息getpid()获取进程 IDgetppid()获取父进程 IDgetpgid()获取进程组 IDgetsid()获取会话 ID。状态信息可以读取/proc/[pid]/stat获取运行/睡眠状态使用nice()获取优先级。资源信息读取/proc/[pid]/status获取VmSize(虚拟内存大小)、VmRSS(常驻内存) 等。Linux 哲学的极致体现“Everything is a file”万物皆文件操作系统不仅提供了getpid这样的 API还允许我们通过文件系统/proc直接读取状态机的信息。看看下面这段通过读取/proc/self/目录探测自身状态的演示rootLAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/processes# ./procpid:1230ppid:888--- Additional Process Information --- User ID:0Group ID:0Effective User ID:0Effective Group ID:0Maxopenfiledescriptors:1024Process priority:0--- Process Status(/proc/self/status)--- Name: proc State: R(running)PPid:888Uid:0000VmSize:2680kB --- Command Line(/proc/self/cmdline)--- Command line: ./proc --- Current Directory(/proc/self/cwd)--- Current directory: /mnt/d/CSLab/osCourse/lec5/processes编程素养机器永远是对的未测代码永远是错的啊…我们终于开始编程了在这个阶段代码质量是重中之重。必须重视所有的架构设计函数名、变量名、参数设计。不重视设计的后果就是程序只能删掉重写。程序既是“人类”的需要可读性也是“反人类”的无情在机器上执行。永远遵守两个规则机器永远是对的。未测代码永远是错的。你需要一个自动化测试框架 (Unit Test)不要再用满屏的printf来人肉调试了主流的 C 语言测试框架有 Unity、CppUTest、Google Test 等。课程为大家准备了一个好用的TestKit库。借助 AI你可以轻松掌握如何编写单元测试用 AI 读 test lib 的代码。不会写 Test Example让 AI 帮你写。目的养成写 Unit Test 的习惯。当你为原程序加上断言和环境变量测试时它不会对程序的业务逻辑产生任何影响但能极大降低重构时的心理负担。rootLAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/testkit# makegcc-Wall-g-I. main.c testkit.c-omain argv[0]./main TestKit -[PASS]test_simple(main.c:23)-[FAIL]test_fail(main.c:27)- Assertion fail Assertion failed:(1145140x114514)In __tk_test_fail_27 of main.c:28 Assertion violated -[FAIL]test_timeout(main.c:31)- Timeout... -3/8testcases passed.进程状态机管理 API操作系统是状态机的管理者那么进程管理本质上就是状态机管理。一个直观的想法是如果我想执行一个新程序操作系统应该提供一个像spawn(path, argv)这样的 API。我传给它路径和参数它帮我拉起一个新的状态机。这也是 Windows 操作系统的设计思路微软的匈牙利命名法如CreateProcess。但 UNIX 给出了一个极其反直觉的答案它没有提供spawn而是把“新建”和“加载”拆成了两步复制状态机:fork()复位状态机:execve()创建状态机Unix 的fork()pid_tfork(void);我们现在已经有“一个运行中的状态机”了。UNIX 的fork()做法是做一份当前状态机完整的复制完整的拷贝包括寄存器现场、每一字节的内存。注意那些没有被直接复制的状态 (Caveat)进程在 OS 里还有 PID、ppid、信号等这些元数据不会被简单复制而是有特定的规则。如何区分真假美猴王父与子在新创建的子进程中fork()返回0。在执行fork()的父进程中返回子进程的PID。如果复制失败返回-1并设置errno。进程树与孤儿、僵尸进程进程的这种“复制”关系自然而然地形成了一棵进程树 (Process Tree)。你可以用pstree命令查看。问题如果 $ A \rightarrow B \rightarrow C $如果中间的 $ B $ 终止了$ C $ 的 ppid 会变成什么直觉往上提一层认 $ A $ 做父进程实际的复杂性如果直接往上提发错人了怎么办$ A $ 可能根本没准备好管理 $ C $ 这份额外的资源。进程之间可能通过 ppid 进行通信一旦乱认父亲通信就会错乱。AI 科普时间孤儿进程 (Orphan) 和 僵尸进程 (Zombie)孤儿进程当父进程先于子进程终止时子进程就成了“孤儿”。Linux 会自动将孤儿进程过继给init进程现代系统通常是systemdPID 为 1。僵尸进程子进程先结束了但父进程太忙一直没有调用waitpid()去收割它。此时子进程的资源已被释放但它的 PID 和退出状态依然保留在系统任务表中变成了一具“僵尸”。我们写一段create-tree的代码通过重定向 test.md把进程树的生成结果用 Mermaid 画出来警惕 Fork Bomb (分叉炸弹)如果在死循环里无条件调用fork()1 变 22 变 4…… 指数级增长的进程会瞬间耗尽系统的 PID 和内存资源导致死机。这是早期 CS 极客最爱搞的恶作剧。理解fork()的硬核习题与“缓冲区陷阱”习题一到底创建了几个进程pid_t xfork();pid_t yfork();printf(%d %d\n,x,y);Line 11 变 2。Line 22 变 4。最终会有四个执行路径4 个状态机。父进程的x和y都是大于 0 的子进程 PID而最底层的孙子进程打印出的将是0 0。JYY 的偷懒方法遇到这种题不用拿笔画直接用上节课讲的mosaic(Model Checker) 去穷举所有可能的状态路径习题二震惊加入管道后结果变了for(inti0;i2;i){fork();printf(Hello\n);}按照状态机理论1 变 22 变 4。循环两次应该一共有 4 个进程每个进程打印一些Hello。我们在终端直接运行打印出了6 行 HellorootLAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/fork-demo# ./demo-2Hello Hello Hello Hello Hello Hello但是当我们加入管道| wc -l或者重定向到文件 output.txt时rootLAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/fork-demo# ./demo-2 | wc -l8竟然变成了8 行为什么极客揭秘printf** 的行缓冲陷阱**计算机系统里没有魔法机器永远是对的。当直接输出到终端屏幕时printf是行缓冲 (Line Buffered)的。遇到\n它就立刻把数据清空推给屏幕。但是当你重定向到管道|或文件 output.txt时printf会变成全缓冲 (Fully Buffered)遇到\n时数据依然憋在内存缓冲区里。此时执行fork()子进程不仅复制了代码还把父进程憋在内存里的“那一坨Hello缓冲区”也完完整整地复制了一份最终退出时父子进程各自把缓冲区刷到文件里导致结果翻倍。防坑指南在调用fork()之前一定要养成调用fflush(stdout)的好习惯复位状态机execve()intexecve(constchar*filename,char*constargv[],char*constenvp[]);UNIX 没有直接新建程序的 API它要求你先用fork()克隆一个自己然后用execve()把这个克隆体复位洗脑成一个全新的可执行文件它将当前进程重置成目标可执行文件描述的初始状态代码、数据、PC 指针重置。操作系统维护的状态不变进程号 PID、当前工作目录、以及打开的文件描述符全部保留。这解释了为什么我们需要O_CLOEXEC标志防止泄漏多余的文件句柄给新程序。execve** 是 UNIX 中唯一能够“执行程序”的系统调用** 它是一张单程票一旦调用成功去而不返。execve()设置了进程的初始状态argc** argv(命令行参数)* 困扰多年的疑问得到解答C 语言的main函数参数就是被execve从这里硬塞进去的envp** (环境变量)*使用env命令查看PATH, PWD, HOME 等。环境变量本质上就是一张全局的“配置表”所有的神奇报错中文/英文、路径寻找大都在这里。rootLAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/execve-demo# ./demoPWD/mnt/d/CSLab/osCourse/lec5/execve-demoHELLOWORLDSHLVL0_/usr/bin/envPATH环境变量的奥秘当你在终端敲下gcc或者python时系统凭什么知道去哪里找它们全靠PATH环境变量操作系统会严格按照PATH里面以冒号:分隔的目录顺序挨个去寻找同名的可执行文件。还记得用strace抓取的寻径过程吗没有魔法只有无情的字符串匹配和尝试。摧毁状态机_exit()void_exit(intstatus);这个系统调用没有争议。立即摧毁当前状态机并允许抛出一个返回值状态码status供父进程通过wait()或waitpid()捕获。(注C 标准库里有exit()函数而底层的系统调用是_exit()。早期的命名冲突导致了这种加下划线的无奈设计。)UNIX 进程的完整生命周期总结在 UNIX 的世界里实现“创建一个新程序跑起来”的终极范式是Spawn fork execve waitpidintpidfork();if(pid-1){// 错误处理克隆失败可能系统资源耗尽perror(fork);gotofail;}elseif(pid0){// 子进程逻辑被洗脑执行全新程序execve(目标程序路径,argv,envp);// 如果执行到这里说明 execve 失败了因为成功的话它不返回perror(execve);_exit(EXIT_FAILURE);}else{// 父进程逻辑耐心等待子进程汇报结果intstatus;waitpid(pid,status,0);}这就是支撑起整个现代计算机操作系统的基石逻辑。

相关新闻