从零编写操作系统 30 - 综合演示与交付验证
前 29 篇已经分别证明了引导、内存、调度、用户态、文件系统、Shell、管道和窗口终端。单项实验通过仍留下两个问题:这些机制能否在同一次启动中连续工作,系统重启后能否得到同样的结果?
最终实验把答案压进一条固定操作链:列出 FAT16 根目录,读取文件,通过管道统计字节,从磁盘加载 DEMO.EXE,并发运行两个计数进程和一个越界进程。越界进程被终止后,两个计数进程必须继续增长;所有子进程退出后,Shell 还要执行下一条命令并完整释放资源。相同场景随后经历一次同进程 hard reset 和一次新 QEMU 进程复跑。
补齐最终场景缺少的两个入口
Day 29 的 Shell 能打开已知文件,却不能询问卷上有哪些文件;Day 19 的 DEMO 则以内嵌 ELF 运行,尚未走 FAT16 装载路径。Day 30 只补这两个缺口,不重写已经通过的机制。
最终镜像增加 syscall 12:
1 | |
用户态传入从零开始的索引。返回 1 表示得到一项,0 表示目录结束,负数表示失败。定宽结构只包含 ls 当前需要的名字、属性和长度,不暴露 FAT 目录项中的簇号、高低字、时间戳等内部布局。
fat_readdir 仍遵守 Day 21 的只读边界:只扫描固定根目录,跳过已删除项、长文件名项、卷标和子目录,把 ASCII 8.3 名称转换为带点号的显示形式。用户态 LS.EXE 只是 Day 23 多调用程序新增的一个入口:
1 | |
最终 FAT 卷共有 13 个可见文件,其中包括独立编译的 DEMO.EXE:
1 | |
目录枚举和程序装载使用同一只读卷,但用途不同。ls 证明用户程序可以遍历文件名;demo 仍通过 open → read_at → ELF loader → process_create 执行,不能拿目录输出代替可执行文件的装载证据。
磁盘 DEMO 只负责组织已有进程
DEMO.EXE 不包含计数器和故障指令本身。它调用既有 spawn,依次创建两个 COUNT 和一个 BADPTR:
1 | |
COUNT 与 BADPTR 仍来自 Day 19 的用户程序。COUNT 在自己的用户页递增进度值,等待内核写入释放标志,再以状态 7 退出。BADPTR 访问用户地址空间中未映射的 0x00100000,产生 vector 14,由进程故障路径转成退出状态。内核错误仍会 panic;这里只隔离 CPL3 进程自身的页故障。
这个场景同时存在 Shell、DEMO、COUNT×2 和 BADPTR,需要五个用户进程槽,加上 PID 0 共六槽。直接把全局上限从四改成六会破坏旧实验对 EAGAIN 和槽位耗尽的精确边界,因此代码分开了容量和当前限制:
1 | |
只有最终镜像启用六槽。Day 19 进程实验、Day 22 fd 实验和原 Shell 镜像继续使用四槽,历史失败路径没有被扩容掩盖。
“另一个进程还活着”需要先后证据
BADPTR 被终止、最终看到 COUNT 退出,并不能证明 COUNT 在故障之后继续运行。它可能早已完成全部工作,只是稍晚才被回收。最终实验在 BADPTR 退出发布前读取两个 COUNT 的进度:
1 | |
之后每次从最外层 syscall trap 返回时,内核重新读取两个值。只有两者都严格大于各自快照,才设置两个进程的 release 标志:
1 | |
事件日志另带独立单调序号:
1 | |
seq=1<2 给出严格顺序,两个 growth=1 给出故障后的实际进展。PIT 约为 100 Hz,两个事件可能在同一 tick 完成;第三轮确实记录了 after-fault=0。因此 tick 用来辅助定位,不能承担同一 tick 内的顺序证明。
进度检查放在 syscall trap 的最外层退出路径还有一个调度原因。图形模式下 PID 0 负责 UI,但两个始终 runnable 的 COUNT 可能使 idle 路径长期拿不到执行机会。COUNT 每次 yield 都会经过 syscall 返回边界,把检查放在那里才能由产生进度的执行路径直接驱动释放。
一次启动要走完完整操作链
专项脚本通过 QEMU HMP 注入以下命令:
1 | |
这几条命令分别压住不同的连接点:
1 | |
最终窗口保留了本轮的可见结果:
窗口里的文字只证明可见输出。check-final.py 还在 GDB 中读取保留字符流,确认命令及结果的顺序,并核对 Shell 保存现场仍为 CS=0x1b、EIP 位于用户 ELF。目录计数器、FAT 读取次数、故障地址、两个进度快照和资源终态来自客体变量;截图不替代这些检查。
三种证据各回答一个问题
最终验收同时保留截图、串口、QMP/HMP transcript 和 GDB 记录,因为它们的证明范围不同。
| 证据 | 可以证明 | 不能单独证明 |
|---|---|---|
| 截图 | 固定 framebuffer 上确实出现命令和结果 | CPL、故障原因、调度顺序、资源回收 |
| 串口 | 启动链、故障事件序号、最终内核摘要 | 屏幕像素、用户 ELF 的现场与所有对象计数 |
| QMP/HMP | QEMU 接受了 reset、截图与输入命令 | 客体一定正确解释了每个输入 |
| GDB | CS/EIP、进度前后值、目录计数、fd/页终态 | 人工操作手感或真实硬件行为 |
第一轮结束时,GDB 给出的终态为:
1 | |
demo=1212/3 表示 FAT-loaded ELF 长度为 1212 字节,当前装载器读取了 ELF 头、program header 和 LOAD 段三次。这个数字是本文件布局的观测值,不是 ELF 的通用读取次数下限。dir=13/16 表示 13 个可见文件和 16 次根目录扇区读取;实现按索引从头扫描,语义明确但没有优化成目录游标。
hard reset 与新进程不是同一种复现
首轮完成后,脚本向同一个 QEMU 进程发送:
1 | |
QMP 把它定义为 guest hard reset。虚拟 CPU 和设备重新进入复位状态,但 QEMU 宿主进程没有退出。第二轮重新经过 MBR、stage2 和内核初始化,再执行同一命令链。
随后脚本关闭这个实例,启动新的 QEMU 进程,从同一个 mbr-final.img 完成第三轮。三个入口的首个提示符状态一致:
1 | |
三个终态也具有相同的语义计数:
1 | |
三张截图逐字节相同。这里的复现仍限定于同一镜像、同一 QEMU/SeaBIOS 版本和同一模拟机参数;它不等于物理断电重启,更不能外推到不同 BIOS 或真实设备。
资源基线是场景结束条件
Shell 退出后,PID 0 持续回收孤儿并排空终端输出。最终断言要求:
file_live_count()和file_total_refs()都为 0;- 管道创建数等于释放数;
- 终端输出入队字节等于 UI 消费字节,两个端点均释放;
- 本轮回收至少 10 个进程;
- 两个 COUNT、BADPTR、DEMO 与 Shell 均不再占用任务槽;
- 16 页 surface 释放后,空闲页回到 UI 初始化前的 16040 页。
串口最后输出:
1 | |
一次端点相等无法证明所有未覆盖路径都没有泄漏。三轮相同终态能排除本场景在 warm reset 和新进程复跑下的累计残留,结论只到这里为止。
专项检查与累计回归
专项结果、首轮与 hard reset 串口、新进程串口、QMP transcript、HMP transcript和完整证据说明保存了本篇的运行材料。首轮、hard reset 与新进程的 GDB 文件也分别保留在素材目录。
专项之外,Day 28 窗口、Day 27 鼠标、图形文字、Mode 13h、保护模式、管道、原 Shell、文件描述符、FAT16、ATA、进程、系统调用、ELF、物理页、装载容量和 IRQ1 键盘共十六项现行检查全部退出 0,加上 check-final 共十七个目标。完整输出见累计回归日志。
旧仓库级 make check 没有被计入通过。它的 Day 7 check-exceptions 仍假设 vector 34 以后都 non-present,与后来加入的 IRQ12 vector 44 和 DPL3 syscall vector 128 冲突。最终验收保留这条已知边界,没有通过放宽新 IDT 来迎合旧断言。
当前边界
最终系统仍是 32 位 BIOS 教学内核,只在 pc-i440fx-7.2、TCG、qemu32、单 CPU、64 MiB、QEMU 8.2.2 与 SeaBIOS 1.16.3 下完成自动验收。没有验证真实 ATA、PS/2、VGA 硬件,也没有 SMP、APIC、USB 或现代 GPU。
文件系统是固定根目录的只读 FAT16。readdir 不支持子目录、长文件名、写入、缓存一致性和崩溃恢复。按索引枚举会重复扫描根目录,适合当前 13 个文件,不是通用 VFS 目录句柄。
六槽任务表只满足冻结场景,不提供动态扩容。图形服务仍在内核 PID 0 中,只有一个固定终端窗口;每次变化复制完整 64,000 字节,不支持多窗口客户端协议、脏矩形或垂直同步。
自动输入验证了从 QEMU 设备到客体逻辑的固定路径,没有验证人工拖动、键入的主观响应。三轮资源相等也不覆盖关闭中的阻塞 I/O、任意失败点或长时间压力运行。
机制速查
| 遇到的问题 | 可迁移做法 | 本篇落点 |
|---|---|---|
| 单项都通过,整条链仍可能断 | 用一个连续场景跨越所有接口边界 | ls → cat → pipe → demo → echo → exit |
| 最终结果不能证明故障后的进展 | 在故障点取快照,再要求幸存者严格增长 | 两个 COUNT 的 before/after |
| 粗粒度时钟无法区分同 tick 事件 | 增加独立单调事件序号 | seq=1<2,tick 只作定位 |
| 新场景需要更大容量 | 分离编译容量和历史实验的活动上限 | 六槽实现,旧实验仍限四槽 |
| reset 成功不等于完整复现 | reset 后重走场景,再增加新进程对照 | warm reset 与 fresh process |
| 截图、日志各有盲区 | 为每项主张选择能直接观察它的证据 | framebuffer、串口、transcript、GDB |
| 运行结束不等于资源已释放 | 把所有权计数回基线设为通过条件 | fd、pipe、task、terminal、page |
下载与复现
下载第 30 篇完整源码。附件 SHA-256:
1 | |
解压后执行:
1 | |
附件不含 build/。冻结后已在新空目录重新构建并复跑专项与全部现行累计目标;独立附件复验是 ZIP 外的收据,不写回归档。
练习
- 把
readdir(index)改成持有目录游标的 fd,比较状态所有权、关闭语义和重复扫描次数;仍保持 FAT 内部目录项不直接暴露给用户态。 - 增加第三个 COUNT,再把任务槽总量和场景活动上限分开调整,检查旧四槽耗尽实验是否保持原结果。
- 让 BADPTR 在不同进度阶段故障,保存 before/after 和事件序号,确认存活证明不依赖固定 PID 或固定 tick。
- 为
check-final增加一个故意漏掉 COUNT 释放的镜像,要求测试在资源终态而不是超时文本处明确失败。 - 把同一镜像放到另一版 QEMU/SeaBIOS 中运行,先记录差异,再决定哪些断言属于体系结构契约,哪些只是固定模拟器观察。
上一篇:29 - 窗口终端与用户态 Shell。
本篇完成 00–30 的既定主线。UEFI/x86-64、可写文件系统、网络和用户态图形服务属于新的扩展系列,需要重新制定实现顺序和验收边界。
参考资料
- QEMU QMP
system_reset:同一 QEMU 进程中的 guest hard reset 控制入口。 - QEMU QMP
screendump:从当前图形控制台保存截图;截图不替代客体状态检查。 - Microsoft FAT Specification:FAT 目录项、短文件名和属性位的格式依据。
- 19 - 创建与回收进程:COUNT、BADPTR、DEMO 和
spawn/exit/wait的来源。 - 21 - 只读 FAT16:最终根目录枚举所沿用的卷边界和损坏输入处理。
- 24 - 两段管道:最终连续场景中的有界管道、EOF 与引用释放。
- 29 - 窗口终端与用户态 Shell:最终演示复用的 FAT-backed Shell、终端队列和窗口 UI。

