从零编写操作系统 19 - 创建与回收进程:让退出状态有地方可去
第 18 篇已经能把独立链接的 ELF 装进私有地址空间,但程序仍由内核演示代码直接创建,结束后也由演示代码强制清理。用户程序不能启动另一个程序,不能主动退出,更拿不到子进程的退出原因。
本篇在第 17 篇的私有系统调用 ABI 上增加 spawn、exit 和 wait。创建成功的进程得到单调递增的 PID;正常退出和用户故障都会留下状态;父进程取得状态后才最终释放子进程的地址空间与内核栈。父进程先退出时,PID 0 接管并回收孤儿。
实验不只验证“创建后能运行”。正常镜像会执行两轮指定等待、十次坏状态指针、槽位复用和孤儿回收;失败镜像会依次切断 ELF 的 7 个分配点和第 8 个内核栈分配点。两条路径最后都必须恢复到实验开始时的空闲页数。
任务能切换,不等于进程能结束
第 13 篇建立的任务只有 RUNNABLE、RUNNING、BLOCKED 和 DEAD。教学演示知道每个任务何时结束,也知道应该释放哪两张栈页,所以任务退出后直接进入 DEAD,bootstrap 随后清理即可。
用户进程多了两个问题。父进程需要知道子进程是正常 exit(7),还是因为页故障被内核终止;正在退出的进程又不能释放自己仍在使用的内核栈和页目录。若 exit 立即把任务槽清空,状态会消失;若一直保留而没有回收者,固定任务表和物理页都会泄漏。
因此任务状态增加 ZOMBIE:
1 | |
ZOMBIE 不可调度,但仍保存 PID、父 PID、退出状态、地址空间和内核栈。最终释放由另一个执行上下文完成:通常是父进程的 wait,孤儿则由运行在内核 CR3 和 bootstrap 栈上的 PID 0 回收。
这里选择保留完整地址空间到 wait,是固定四槽教学内核的简化,不是 POSIX 的强制要求。以后加入文件描述符和管道时,FD 引用必须在 exit 阶段关闭;如果僵尸仍持有管道写端,读端永远等不到 EOF。
三个系统调用只承诺最小语义
第 17 篇已经占用 write=1 和 yield=2。本篇继续分配:
| EAX | 调用 | EBX | ECX | 成功结果 |
|---|---|---|---|---|
| 3 | spawn |
NUL 结尾的程序名 | io_map=0 |
新 PID |
| 4 | exit |
退出码 | 未使用 | 不返回 |
| 5 | wait |
正的直接子 PID | 可写 uint32_t *status |
被回收的 PID |
这不是 Linux ABI,也不是完整 POSIX waitpid。spawn 只识别内嵌白名单;wait 不支持 wait-any,也不允许空状态指针;正常退出只保留低 8 位,用户异常编码为 0x10000 | vector。错误仍沿用负整数,例如坏指针为 -EFAULT,非子进程为 -ECHILD,槽满为 -EAGAIN。
程序名不能先交给 strlen。用户地址可能在字符串中途跨到未映射页,内核必须逐字节复制并设置上限:
1 | |
每次只验证即将读取的一个字节。这样 \"COUNT\\0\" 可以位于页尾,而真正跨入缺页的名称会在访问该字节前返回错误。16 字节内没有 NUL 则报告名称过长,不会在用户地址空间里无界扫描。
创建事务只有最后一步对调度器可见
第 18 篇的 ELF 加载器把页目录、两张页表、LOAD 页和用户栈登记在局部 user_space 中。Day 19 的 ELF 实际使用 7 张页;进程对象还需要一张内核栈页。
创建顺序保持单一所有权:
1 | |
发布之前,调度器看不到半成品。若 ELF 分配失败,加载器销毁局部地址空间;若第 8 张内核栈分配失败,调用者仍持有 user_space,可以完整销毁。只有任务槽写完后,局部结构才清零并把所有权转给任务。
PID 与槽位分开。槽位回收后可以复用,PID 从 1 单调增加且不回退;创建失败允许留下 PID 空洞。到 INT32_MAX 后拒绝新建而不回绕,旧 PID 因此不会在槽位复用后指向另一个进程。
任务数组从三个槽扩为四个,才能同时容纳 bootstrap、DEMO、COUNT 和 BADPTR。旧实验没有跟着扩大活动范围:Day 13–18 仍只使用三个槽,第 16 篇的双元素观测数组也只接受无 PID 的旧任务。容量和旧接口边界被分开,避免“只改数组长度”造成越界。
exit 只发布终止,不负责自我销毁
exit 从 CPL3 通过 int 0x80 进入时,当前任务的 trap_depth 为 1。此刻 trap frame、系统调用 C 栈和返回链都在该任务的内核栈上。直接释放内核栈等于拆掉当前正在执行的地板;直接销毁当前 CR3 也会破坏后续取指和返回路径。
系统调用因此只记录 code & 0xff 并设置退出请求。公共 trap 尾部把深度降为 0 后,才在关中断状态下执行终止发布:
- 把直接子进程的
parent_pid改为 0; - 把当前进程改为 ZOMBIE;
- 唤醒等待该进程的父进程;
- 切换到另一个 RUNNABLE 任务,不再返回当前用户态。
用户页故障走同一条尾部路径,只是状态提前写成 0x10000 | vector。BADPTR 对低地址写入触发 vector 14,父进程最终取得 0x1000e。内核故障仍然 panic,没有被进程退出机制吞掉。
Intel SDM 对从外层特权级进入中断门的换栈顺序给出了硬件基础:处理器从 TSS 取得 ring 0 栈,在新栈上保存用户 SS、ESP 和返回现场。第 16 篇已经在任务切换时更新 TSS.esp0,所以每个用户进程的系统调用续体都留在自己的内核栈。本篇的 ZOMBIE 规则负责保证这张栈在退出发布前仍然存在。
wait 要把“检查”和“睡下”连成一个动作
父进程等待一个仍在运行的子进程时,最危险的窗口是:刚检查完“还没退出”,子进程便退出并发出唤醒,而父进程随后才登记为 BLOCKED。这个唤醒已经过去,父进程可能永远睡眠。
单 CPU 内核用 IF=0 临界区封住窗口。wait 在同一个区间内完成 PID 和父子关系检查、ZOMBIE 判断、退出队列登记以及 BLOCKED 发布;子进程发布 ZOMBIE 和唤醒也在 IF=0 下完成。父被唤醒后重新查找 PID 和状态,不把一次唤醒直接当成条件成立。
第 15 篇的阻塞调用发生在普通任务上下文,要求 trap_depth==0。同步系统调用不同:父进程正处于最外层 int 0x80,深度应当是 1。实现没有临时伪造 depth=0,也没有放宽所有调度入口,而是给底层切换器传入明确的允许深度:
1 | |
普通调度仍调用 schedule_locked_depth(0);wait 是唯一调用 depth 1 的路径。父进程被唤醒后,switch_context 回到原来的内核栈续点,wait 循环继续,最终由原 trap frame 通过 IRET 返回 CPL3。
这条路径只适用于当前单 CPU、不可重入的最外层系统调用。它不是允许任意 IRQ handler 阻塞的通用规则。
坏状态指针不能消费退出结果
子进程已经成为 ZOMBIE 时,回收顺序仍不能反。正确次序是先在父 CR3 下验证 4 字节目标范围,再复制状态,最后释放子资源并清空任务槽。
copy_to_user 检查地址加法、用户窗口,以及每一页 PDE/PTE 的 present、user 和 writable 位。完整范围在写入前验证,所以跨页指针的第二页无效时不会先修改第一页末尾。
实验让同一个 BADPTR zombie 连续遭遇五类状态指针:低地址、只读代码页、跨到缺页、高地址溢出和未映射地址。五次均返回 -EFAULT,zombie 仍保存 PID、状态、7 张地址空间页和内核栈;合法指针随后取得 0x1000e 并只回收一次。两轮实验累计十次失败。
这套顺序可以迁移到所有“输出参数同时触发资源消费”的接口:先证明并完成用户可见写入,再提交不可逆的内核状态变化。否则一次坏指针就可能让调用者永久丢失结果。
PID 0 收走父进程留下的资源
ORPHAN 程序创建 COUNT 和 BADPTR,主动让出一次 CPU 后直接退出。此时一个子进程仍在运行,另一个可能已经是 ZOMBIE。父退出路径扫描直接子进程,把两者的 parent_pid 都改为 0。
bootstrap 每次恢复后扫描 PID 0 名下的 ZOMBIE。已有 zombie 可以立即回收;COUNT 继续运行,后来退出后再由 bootstrap 回收。回收发生在内核页目录和 bootstrap 栈上,满足“不能销毁当前 CR3、不能释放当前栈”的约束。
当前实现没有子链表,固定四槽下直接扫描足够清楚。若以后任务数增长,扫描成本才值得换成显式子进程关系结构。
一份 ELF 承担五种实验角色
若分别嵌入 DEMO、COUNT、BADPTR、ORPHAN 和 FAILTEST 五份 ELF,重复的启动代码和只读字符串会迅速吃掉 64 KiB 内核文件上限。本篇没有借机扩大 loader。
链接器只生成一份 ELF。每次 elf_load 都创建独立物理页,内核在该副本的可写数据页写入 mode,用户 _start 再分派到对应程序逻辑。不同进程仍有独立 CR3、数据页和栈;共享的是磁盘镜像中的模板字节,不是运行时可写内存。
最终 kernel.bin 为 61,408 字节,镜像头的运行内存为 85,872 字节。文件仍低于 65,536 字节,运行内存仍低于第 18 篇已经验证的 131,072 字节,所以实模式暂存、校验和与 128 个保留扇区都不需要改变。
正常镜像和失败镜像的实测
环境为 QEMU 8.2.2、SeaBIOS、pc-i440fx-7.2、qemu32、TCG、单 CPU、64 MiB。正常镜像最终输出:
1 | |
GDB 在 BADPTR 已经 ZOMBIE、COUNT 仍运行时确认父进程处于 BLOCKED,trap_depth==1,保存 ESP 位于父内核栈,等待队列只包含父槽。COUNT 的进度在 BADPTR 退出后继续增加,随后以 exit(0x107) 结束,父进程读到状态 7。
正常路径还覆盖未知和过长程序名、合法与失败的跨页名称、非零 io_map、槽满、非子 PID、负 PID、重复 wait、旧 PID、十次坏状态指针,以及父退出时对运行子和 zombie 子的重新归父。总共回收 8 个进程,最后三个用户槽清空,空闲页恢复为 16057。
失败镜像把分配失败点从 0 递增到 7。前七次分别命中 ELF 地址空间页,第八次命中任务内核栈。每次 spawn 都返回 -ENOMEM,没有 RUNNABLE 半成品;关闭注入后再次创建成功:
1 | |
完整 正常串口记录、正常 GDB 记录、失败串口记录、失败 GDB 记录 和 readelf 输出 位于同名素材目录。
同一正常镜像从 BIOS 连续运行 Day 10–18 的已有演示,实际输出 MEMORY OK、PAGING OK、HEAP OK、TASKS OK、PREEMPT OK、WAIT OK、USER OK、SYSCALL OK 和 ELF OK,最后回到原键盘阻塞消费者。
单独重跑旧 check-tasks.sh 时,GDB 15.1 在自由运行客体上设置后续硬件断点存在控制竞争,串口已经完成全部累计演示,但 GDB 错过旧断点后超时。这个结果属于旧检查脚本控制失败,不算专项退出 0,也没有被写成内核通过证据。Day 19 的两套 check-process 场景均退出 0。
当前边界
本篇没有 fork/exec、wait-any、信号、线程、优先级、标准初始栈或磁盘程序。spawn 在系统调用中同步加载一份很小的内嵌 ELF,期间会延迟时钟 IRQ;第 20 篇之后不能把慢速磁盘读取原样塞进这段临界路径。
实验保存当前 next_pid,把它临时设为 INT32_MAX+1,确认 spawn 拒绝创建且页数不变,再恢复原值。这个注入验证了耗尽分支,但没有通过数十亿次自然创建验证长期计数过程。进程表固定四槽,ZOMBIE 暂时保留完整地址空间,适合教学验证,不适合大量进程。所有运行结论只覆盖上述 QEMU/SeaBIOS 环境和单 CPU。
本篇形成的可迁移规则可以压缩为四项:对象完整后才发布;退出者不自毁当前执行资源;等待条件检查与阻塞发布必须原子;输出参数成功写回后才消费结果。第 20–24 篇增加磁盘、文件对象和管道时,这四条仍是资源回滚与 EOF 语义的基础。
下载与复现
下载第 19 篇完整源码。附件 SHA-256:
1 | |
解压后执行:
1 | |
附件包含从第 01 篇累计至本篇的完整工程、检查脚本和 docs/day19-evidence.md。它已在空目录独立解压,从空 build/ 重新执行 make build、check-process、check-syscall、check-elf、check-memory 和 check-runtime-limit,六项均退出 0;重建的 kernel.bin、mbr.img 和用户 ELF 哈希与主工作区一致。旧 check-user 在 GDB 15.1 下仍复现控制错误;串口已输出 USER OK,但该目标不记为通过。源码版本以附件 SHA-256 和完成后的真实 Git 提交为准,不使用虚构标签。
练习
- 把
wait中“复制状态”和“回收任务”的次序交换,使用只读状态指针说明为什么合法重试会丢失退出结果。 - 让父进程同时拥有两个仍运行的子进程,分别等待后创建和先创建的 PID,检查退出队列为什么必须属于目标子进程。
- 为进程增加一个引用计数文件对象,比较它在 exit 与 wait 阶段释放对管道 EOF 的不同影响。
上一篇:18 - ELF 加载器。
下一篇:20 - ATA PIO 块设备。
参考资料
- Intel SDM Vol. 3A,253668-092US,2026-06:§7.8.1、§7.12.1–7.12.1.3、§10.2.1,特权级换栈、中断门和返回现场。
- POSIX.1-2024
wait()/waitpid():指定子进程、阻塞、状态取得与ECHILD。本项目只实现收窄接口。 - POSIX.1-2024
_Exit()/_exit():终止调用不返回;本项目的资源集合和状态编码不是 POSIX ABI。 - xv6 x86
proc.c,固定提交eeb7b415dbcb12cc362d0783e41c3d1f44066b17:创建发布、ZOMBIE、wait 回收、孤儿转交和 sleep/wakeup 原子性。 - xv6 book rev11,第 5 章:x86 进程调度、sleep/wakeup 与 lost wakeup。
- OSTEP v1.10:Process API,第 5 章:创建、程序替换与等待的接口分工。

