第 18 篇已经能把独立链接的 ELF 装进私有地址空间,但程序仍由内核演示代码直接创建,结束后也由演示代码强制清理。用户程序不能启动另一个程序,不能主动退出,更拿不到子进程的退出原因。

本篇在第 17 篇的私有系统调用 ABI 上增加 spawnexitwait。创建成功的进程得到单调递增的 PID;正常退出和用户故障都会留下状态;父进程取得状态后才最终释放子进程的地址空间与内核栈。父进程先退出时,PID 0 接管并回收孤儿。

实验不只验证“创建后能运行”。正常镜像会执行两轮指定等待、十次坏状态指针、槽位复用和孤儿回收;失败镜像会依次切断 ELF 的 7 个分配点和第 8 个内核栈分配点。两条路径最后都必须恢复到实验开始时的空闲页数。

任务能切换,不等于进程能结束

第 13 篇建立的任务只有 RUNNABLERUNNINGBLOCKEDDEAD。教学演示知道每个任务何时结束,也知道应该释放哪两张栈页,所以任务退出后直接进入 DEAD,bootstrap 随后清理即可。

用户进程多了两个问题。父进程需要知道子进程是正常 exit(7),还是因为页故障被内核终止;正在退出的进程又不能释放自己仍在使用的内核栈和页目录。若 exit 立即把任务槽清空,状态会消失;若一直保留而没有回收者,固定任务表和物理页都会泄漏。

因此任务状态增加 ZOMBIE:

1
2
3
4
5
6
7
8
9
10
11
空槽
│ spawn 全部准备成功

RUNNABLE ⇄ RUNNING ── wait 条件未满足 ──> BLOCKED
│ │
│ exit / 用户故障 │ 子退出后唤醒
▼ └──────────┐
ZOMBIE <──────────────────────────────────┘
│ wait 成功或 PID 0 回收

空槽

ZOMBIE 不可调度,但仍保存 PID、父 PID、退出状态、地址空间和内核栈。最终释放由另一个执行上下文完成:通常是父进程的 wait,孤儿则由运行在内核 CR3 和 bootstrap 栈上的 PID 0 回收。

这里选择保留完整地址空间到 wait,是固定四槽教学内核的简化,不是 POSIX 的强制要求。以后加入文件描述符和管道时,FD 引用必须在 exit 阶段关闭;如果僵尸仍持有管道写端,读端永远等不到 EOF。

三个系统调用只承诺最小语义

第 17 篇已经占用 write=1yield=2。本篇继续分配:

EAX 调用 EBX ECX 成功结果
3 spawn NUL 结尾的程序名 io_map=0 新 PID
4 exit 退出码 未使用 不返回
5 wait 正的直接子 PID 可写 uint32_t *status 被回收的 PID

这不是 Linux ABI,也不是完整 POSIX waitpidspawn 只识别内嵌白名单;wait 不支持 wait-any,也不允许空状态指针;正常退出只保留低 8 位,用户异常编码为 0x10000 | vector。错误仍沿用负整数,例如坏指针为 -EFAULT,非子进程为 -ECHILD,槽满为 -EAGAIN

程序名不能先交给 strlen。用户地址可能在字符串中途跨到未映射页,内核必须逐字节复制并设置上限:

1
2
3
4
5
6
7
8
9
10
11
static int32_t syscall_spawn(uintptr_t address, unsigned io_map)
{
char name[16];
for (unsigned i = 0; i < sizeof(name); ++i) {
if (!copy_from_user(&name[i], address + i, 1))
return -EFAULT;
if (!name[i])
return task_spawn_process(name, io_map);
}
return -ENAMETOOLONG;
}

每次只验证即将读取的一个字节。这样 \"COUNT\\0\" 可以位于页尾,而真正跨入缺页的名称会在访问该字节前返回错误。16 字节内没有 NUL 则报告名称过长,不会在用户地址空间里无界扫描。

创建事务只有最后一步对调度器可见

第 18 篇的 ELF 加载器把页目录、两张页表、LOAD 页和用户栈登记在局部 user_space 中。Day 19 的 ELF 实际使用 7 张页;进程对象还需要一张内核栈页。

创建顺序保持单一所有权:

1
2
3
4
5
6
7
复制并识别名称
-> ELF 完整验证
-> 分配并登记 7 张地址空间页
-> 找空任务槽并分配内核栈
-> 建立首次运行现场、PID 和父关系
-> 转移 user_space 所有权
-> 发布 RUNNABLE

发布之前,调度器看不到半成品。若 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 后,才在关中断状态下执行终止发布:

  1. 把直接子进程的 parent_pid 改为 0;
  2. 把当前进程改为 ZOMBIE;
  3. 唤醒等待该进程的父进程;
  4. 切换到另一个 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
2
3
4
5
6
7
static void schedule_locked_depth(unsigned allowed_depth)
{
KASSERT(interrupts_are_disabled());
KASSERT(tasks[current].trap_depth == allowed_depth);
KASSERT(tasks[current].preempt_count == 0);
/* 选择 RUNNABLE 任务并切换保存栈。 */
}

普通调度仍调用 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.2qemu32、TCG、单 CPU、64 MiB。正常镜像最终输出:

1
PROCESS OK mode=normal reaped=8 wait=4 blocks=2 faults=10 rollback=0 survivor+=3 pages=16057

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
2
D19 FAIL rollback and recovery OK
PROCESS OK mode=failure reaped=3 wait=2 blocks=1 faults=0 rollback=8 survivor+=1 pages=16057

完整 正常串口记录正常 GDB 记录失败串口记录失败 GDB 记录readelf 输出 位于同名素材目录。

同一正常镜像从 BIOS 连续运行 Day 10–18 的已有演示,实际输出 MEMORY OKPAGING OKHEAP OKTASKS OKPREEMPT OKWAIT OKUSER OKSYSCALL OKELF 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
a219823cb69661c0c23b5a2491fe3bf070ea34fe30892d575eac75ebd24f8258

解压后执行:

1
2
3
4
5
6
7
8
unzip os-day-19.zip
cd os-day-19
make build
make check-process
make check-syscall
make check-elf
make check-memory
make check-runtime-limit

附件包含从第 01 篇累计至本篇的完整工程、检查脚本和 docs/day19-evidence.md。它已在空目录独立解压,从空 build/ 重新执行 make buildcheck-processcheck-syscallcheck-elfcheck-memorycheck-runtime-limit,六项均退出 0;重建的 kernel.binmbr.img 和用户 ELF 哈希与主工作区一致。旧 check-user 在 GDB 15.1 下仍复现控制错误;串口已输出 USER OK,但该目标不记为通过。源码版本以附件 SHA-256 和完成后的真实 Git 提交为准,不使用虚构标签。

练习

  1. wait 中“复制状态”和“回收任务”的次序交换,使用只读状态指针说明为什么合法重试会丢失退出结果。
  2. 让父进程同时拥有两个仍运行的子进程,分别等待后创建和先创建的 PID,检查退出队列为什么必须属于目标子进程。
  3. 为进程增加一个引用计数文件对象,比较它在 exit 与 wait 阶段释放对管道 EOF 的不同影响。

上一篇:18 - ELF 加载器

下一篇:20 - ATA PIO 块设备

参考资料