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

写在前面:这是本系列的第五篇。

经过前几讲的底层探索,本次课我们正式进入"每一讲都实现一点什么"的模式。我们将学习程序和进程管理 以及进程管理的 API

操作系统通过虚拟化(Virtualization)技术,将物理计算机抽象为虚拟计算机,让每个程序都产生了一种"独占整个计算机运行"的幻觉。今天,我们就来解剖这种幻觉的底层逻辑。

程序 v.s. 进程

  • 程序 (Program): 是状态机的静态描述。它静静地躺在硬盘上,描述了所有可能的状态,但本身没有任何运行时的状态。(就像在 vim 里写代码,如果没有 IDE 的检查,你根本不知道犯了什么错,直到你把它跑起来。)
  • 进程 (Process): 是动态的,是运行起来的程序,是状态机随时间的演进。

操作系统上的进程

除了程序本身的状态(内存、寄存器),操作系统还会为进程保存一些额外的(只读)状态:PID、打开的文件、权限等。

AI Prompt 探索进程信息: 我们可以用系统调用 API 获取当前进程的元数据。

  • 基本信息: getpid() 获取进程 ID,getppid() 获取父进程 ID,getpgid() 获取进程组 ID,getsid() 获取会话 ID。
  • 状态信息: 可以读取 /proc/[pid]/stat 获取运行/睡眠状态,使用 nice() 获取优先级。
  • 资源信息: 读取 /proc/[pid]/status 获取 VmSize (虚拟内存大小)、VmRSS (常驻内存) 等。

Linux 哲学的极致体现:"Everything is a file"(万物皆文件)

操作系统不仅提供了 getpid 这样的 API,还允许我们通过文件系统 /proc 直接读取状态机的信息。看看下面这段通过读取 /proc/self/ 目录探测自身状态的演示:

bash 复制代码
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/processes# ./proc
pid: 1230
ppid: 888

--- Additional Process Information ---
User ID: 0
Group ID: 0
Effective User ID: 0
Effective Group ID: 0
Max open file descriptors: 1024
Process priority: 0

--- Process Status (/proc/self/status) ---
Name:   proc
State:  R (running)
PPid:   888
Uid:    0       0       0       0
VmSize:     2680 kB

--- Command Line (/proc/self/cmdline) ---
Command line: ./proc

--- Current Directory (/proc/self/cwd) ---
Current directory: /mnt/d/CSLab/osCourse/lec5/processes

编程素养:机器永远是对的,未测代码永远是错的

啊...我们终于开始编程了!在这个阶段,代码质量是重中之重。

  • 必须重视所有的架构设计(函数名、变量名、参数设计)。不重视设计的后果就是:程序只能删掉重写。
  • 程序既是"人类"的(需要可读性),也是"反人类"的(无情在机器上执行)。
  • 永远遵守两个规则:
  1. 机器永远是对的。
  2. 未测代码永远是错的。

你需要一个自动化测试框架 (Unit Test)

不要再用满屏的 printf 来人肉调试了!主流的 C 语言测试框架有 Unity、CppUTest、Google Test 等。

课程为大家准备了一个好用的 TestKit 库。借助 AI,你可以轻松掌握如何编写单元测试:

  • 用 AI 读 test lib 的代码。
  • 不会写 Test Example?让 AI 帮你写。
  • 目的: 养成写 Unit Test 的习惯。当你为原程序加上断言和环境变量测试时,它不会对程序的业务逻辑产生任何影响,但能极大降低重构时的心理负担。
bash 复制代码
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/testkit# make
gcc -Wall -g -I. main.c testkit.c -o main
argv[0] = ./main

TestKit
- [PASS] test_simple (main.c:23)
- [FAIL] test_fail (main.c:27) - Assertion fail
Assertion failed: (114514 == 0x114514)
    In __tk_test_fail_27 of main.c:28
    Assertion violated
- [FAIL] test_timeout (main.c:31) - Timeout
...
- 3/8 test cases passed.

进程(状态机)管理 API

操作系统是状态机的管理者,那么进程管理本质上就是状态机管理

一个直观的想法是:如果我想执行一个新程序,操作系统应该提供一个像 spawn(path, argv) 这样的 API。我传给它路径和参数,它帮我拉起一个新的状态机。这也是 Windows 操作系统的设计思路(微软的匈牙利命名法,如 CreateProcess)。

但 UNIX 给出了一个极其反直觉的答案: 它没有提供 spawn,而是把"新建"和"加载"拆成了两步!

  • 复制状态机: fork()
  • 复位状态机: execve()

创建状态机:Unix 的 fork()

cpp 复制代码
pid_t fork(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 进程(现代系统通常是 systemd,PID 为 1)。
  • 僵尸进程: 子进程先结束了,但父进程太忙,一直没有调用 waitpid() 去收割它。此时子进程的资源已被释放,但它的 PID 和退出状态依然保留在系统任务表中,变成了一具"僵尸"。

我们写一段 create-tree 的代码,通过重定向 > test.md,把进程树的生成结果用 Mermaid 画出来:

警惕 Fork Bomb (分叉炸弹):

如果在死循环里无条件调用 fork(),1 变 2,2 变 4...... 指数级增长的进程会瞬间耗尽系统的 PID 和内存资源,导致死机。这是早期 CS 极客最爱搞的恶作剧。


理解 fork() 的硬核习题与"缓冲区陷阱"

习题一:到底创建了几个进程?

cpp 复制代码
pid_t x = fork();
pid_t y = fork();
printf("%d %d\n", x, y);
  • Line 1:1 变 2。
  • Line 2:2 变 4。
  • 最终会有四个执行路径(4 个状态机)。父进程的 xy 都是大于 0 的子进程 PID,而最底层的孙子进程打印出的将是 0 0
  • JYY 的偷懒方法: 遇到这种题,不用拿笔画,直接用上节课讲的 mosaic (Model Checker) 去穷举所有可能的状态路径!

习题二:震惊!加入管道后结果变了?

cpp 复制代码
for (int i = 0; i < 2; i++) {
    fork();
    printf("Hello\n");
}

按照状态机理论,1 变 2,2 变 4。循环两次应该一共有 4 个进程,每个进程打印一些 Hello

我们在终端直接运行,打印出了 6 行 Hello

bash 复制代码
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/fork-demo# ./demo-2
Hello
Hello
Hello
Hello
Hello
Hello

但是,当我们加入管道 | wc -l 或者重定向到文件 > output.txt 时:

bash 复制代码
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/fork-demo# ./demo-2 | wc -l
8

竟然变成了 8 行!为什么?!

极客揭秘: printf** 的行缓冲陷阱**

计算机系统里没有魔法,机器永远是对的。

当直接输出到终端屏幕时,printf行缓冲 (Line Buffered) 的。遇到 \n,它就立刻把数据清空推给屏幕。

但是,当你重定向到管道 | 或文件 > output.txt 时,printf 会变成全缓冲 (Fully Buffered) !遇到 \n 时数据依然憋在内存缓冲区里。

此时执行 fork()子进程不仅复制了代码,还把父进程憋在内存里的"那一坨 Hello 缓冲区"也完完整整地复制了一份! 最终退出时,父子进程各自把缓冲区刷到文件里,导致结果翻倍。

防坑指南: 在调用 fork() 之前,一定要养成调用 fflush(stdout) 的好习惯!


复位状态机:execve()

cpp 复制代码
int execve(const char *filename, char * const argv[], char * const envp[]);

UNIX 没有直接新建程序的 API,它要求你先用 fork() 克隆一个自己,然后用 execve() 把这个克隆体复位(洗脑)成一个全新的可执行文件!

  • 它将当前进程重置成目标可执行文件描述的初始状态(代码、数据、PC 指针重置)。
  • 操作系统维护的状态不变: 进程号 PID、当前工作目录、以及打开的文件描述符全部保留。(这解释了为什么我们需要 O_CLOEXEC 标志,防止泄漏多余的文件句柄给新程序)。
  • execve** 是 UNIX 中唯一能够"执行程序"的系统调用!** 它是一张单程票,一旦调用成功,去而不返。

execve() 设置了进程的初始状态

  • argc** & argv (命令行参数)😗* 困扰多年的疑问得到解答,C 语言的 main 函数参数,就是被 execve 从这里硬塞进去的!
  • envp** (环境变量)😗*
  • 使用 env 命令查看(PATH, PWD, HOME 等)。
  • 环境变量本质上就是一张全局的"配置表",所有的神奇报错(中文/英文)、路径寻找,大都在这里。
bash 复制代码
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec5/execve-demo# ./demo
PWD=/mnt/d/CSLab/osCourse/lec5/execve-demo
HELLO=WORLD
SHLVL=0
_=/usr/bin/env

PATH 环境变量的奥秘

当你在终端敲下 gcc 或者 python 时,系统凭什么知道去哪里找它们?

全靠 PATH 环境变量!操作系统会严格按照 PATH 里面以冒号 : 分隔的目录顺序,挨个去寻找同名的可执行文件。还记得用 strace 抓取的寻径过程吗?没有魔法,只有无情的字符串匹配和尝试。


摧毁状态机:_exit()

cpp 复制代码
void _exit(int status);

这个系统调用没有争议。立即摧毁当前状态机,并允许抛出一个返回值(状态码 status),供父进程通过 wait()waitpid() 捕获。

(注:C 标准库里有 exit() 函数,而底层的系统调用是 _exit()。早期的命名冲突导致了这种加下划线的无奈设计。)


UNIX 进程的完整生命周期总结

在 UNIX 的世界里,实现"创建一个新程序跑起来"的终极范式是:

Spawn = fork + execve + waitpid

cpp 复制代码
int pid = fork();
if (pid == -1) { 
    // 错误处理:克隆失败(可能系统资源耗尽)
    perror("fork"); goto fail;
} else if (pid == 0) { 
    // 子进程逻辑:被洗脑,执行全新程序
    execve("目标程序路径", argv, envp);
    // 如果执行到这里,说明 execve 失败了(因为成功的话它不返回!)
    perror("execve"); 
    _exit(EXIT_FAILURE);
} else { 
    // 父进程逻辑:耐心等待子进程汇报结果
    int status;
    waitpid(pid, &status, 0); 
}

这就是支撑起整个现代计算机操作系统的基石逻辑。

相关推荐
DeviceHub1 小时前
硬件工程师选型笔记:ALPS RK12L1230C0K 与 Tonevee RK12L1230C0K 电位器 PIN TO PIN 评估指南
笔记
whyTeaFo2 小时前
GAMES101: Lecture 13: Ray Tracing 1 (Whitted-Style Ray Tracing) ppt笔记
笔记
天国梦4 小时前
2026英语教学系统选型实战:AI如何让备课效率提升42%?天学网技术落地全解析
人工智能·学习
维克兜率天5 小时前
【维克】大数定律与中心极限定理:为什么长期均值终将回归?
经验分享·学习·金融·概率论
中微极客5 小时前
降维算法75倍加速:从PCA到稀疏字典学习的工程实践
人工智能·学习·算法
吃好睡好便好7 小时前
MATLAB中图像的读取、写入和显示
开发语言·图像处理·学习·计算机视觉·matlab
~kiss~7 小时前
大模型中的强化学习算法和对齐算法的区别
学习
MartinYeung57 小时前
[论文学习]InjecAgent:工具集成大语言模型智能体的间接提示注入基准测试
网络·学习·语言模型
XS0301067 小时前
SpringMVC核心知识点实操笔记
笔记