上一章实现了 exec(),用户程序能跑起来了。但进程退出时只是把进程表项清掉,父进程拿不到退出码,也没有办法等待。这一章实现完整的 exit / wait 语义。

为什么进程退出后不能直接消失

直觉上,进程退出就应该立刻清掉进程表项。但这里有个问题:父进程怎么拿到子进程的退出码?

如果进程表项直接清掉,退出码就丢了。父进程调用 wait() 的时机可能比子进程 exit() 晚,也可能早——无论哪种情况,父进程都需要能读到那个退出码。

Linux 的解法是引入**僵尸(zombie)**状态:进程 exit() 后不立即消失,保留退出码,等父进程 wait() 读取后才彻底释放。

这就是为什么 ps 输出里偶尔会出现状态为 Z 的进程——它已经退出了,只是在等父进程来"收尸"。

进程状态机的变化

原来进程只有三个状态:UNUSED、READY、RUNNING。这一章加两个:

UNUSED → READY → RUNNING
                    ↓ exit()
                 ZOMBIE   ← 已退出,等父进程 wait
                    ↑ 唤醒
                 BLOCKED  ← 父进程 wait 时挂起

调度器只挑 READY 的进程运行,ZOMBIE 和 BLOCKED 自然就不会被调度到,不需要改调度器的逻辑。

exit 和 wait 的实现思路

exit 做三件事:

  1. 把退出码存到进程表项,状态改为 ZOMBIE
  2. 如果父进程正在 BLOCKED 等待,把它改回 READY(唤醒)
  3. 切回内核栈和内核页表,hlt 循环等待被回收

wait 做的事:

  1. 扫描进程表,找有没有自己的子进程且状态是 ZOMBIE
  2. 找到了:读退出码,把它设为 UNUSED,返回退出码
  3. 没找到但有活着的子进程:挂起等待(ring3)或返回 -2 让调用方重试(ring0)
  4. 没有子进程:返回 -1

parent_pid 需要在 exec / fork 时记下来,这样 exit 才知道该唤醒谁,wait 才知道哪些进程是自己的子进程。

一个隐蔽的 bug:ring0 进程不能用 BLOCKED

第一版实现里,proc_wait 找不到 ZOMBIE 时,不管什么进程都设成 PROC_BLOCKED。

运行结果是 child=0x0 exit_code=0x0——两个都是初始值 0,没有 reap 的日志,说明第二次 proc_wait 根本没找到 ZOMBIE。

排查发现,问题出在调度器保存上下文的条件上:

void sched_tick(int_regs_t *regs) {
    if (current_proc >= 0 && procs[current_proc].state == PROC_RUNNING) {
        procs[current_proc].ctx.rip = regs->rip;  // 只有 RUNNING 才保存
        // ...
    }
}

proc_wait 在返回 -2 之前,已经把 procs[0].state 改成了 PROC_BLOCKED。下次 timer 中断进来,调度器发现 pid=0 不是 RUNNING,不保存 rip,还用着上一次保存的旧地址。

子进程退出后把 pid=0 改回 READY,调度器恢复它——但恢复的是旧的 rip,不是 proc_wait 返回 -2 之后的位置。内核跳到了别处,完全跳过了第二次 proc_wait 调用,child_pidcode 当然还是 0。

修法是:ring0 进程只返回 -2,不改 state,由调用方自己循环重试:

do {
    code = proc_wait(&child_pid);
    if (code == -2) __asm__ volatile("hlt");  // 等一个 timer tick
} while (code == -2);

pid=0 一直保持 PROC_RUNNING,调度器每个 tick 都更新 ctx.rip。子进程退出后,下次 tick 时 pid=0 被调度,iretq 回到 hlt 之后,循环条件成立,再调一次 proc_wait,这次找到 ZOMBIE,正常回收。

ring3 进程(用户态通过 syscall 调用 wait)则可以用 BLOCKED——syscall 返回时走的是 sysret,不依赖 ctx.rip 被精确更新。

验证

[test] exec ok, pid=0x1
[wait] no zombie yet, pid=0x0
Hello from user!
[exit] pid=0x1 code=0x2a
[wait] reap pid=0x1 code=0x2a
[test] wait done, child=0x1 exit_code=0x2a

0x2a = 42,退出码正确传递,父进程等到子进程结束后才继续。

小结

僵尸状态不是"多余的东西",是为了解决退出码传递的时序问题——进程死了但数据还在,等父进程来取。BLOCKED 让父进程真正挂起而不是空转。这两个机制合在一起,才构成完整的进程生命周期管理。