前 29 篇已经分别证明了引导、内存、调度、用户态、文件系统、Shell、管道和窗口终端。单项实验通过仍留下两个问题:这些机制能否在同一次启动中连续工作,系统重启后能否得到同样的结果?

最终实验把答案压进一条固定操作链:列出 FAT16 根目录,读取文件,通过管道统计字节,从磁盘加载 DEMO.EXE,并发运行两个计数进程和一个越界进程。越界进程被终止后,两个计数进程必须继续增长;所有子进程退出后,Shell 还要执行下一条命令并完整释放资源。相同场景随后经历一次同进程 hard reset 和一次新 QEMU 进程复跑。

补齐最终场景缺少的两个入口

Day 29 的 Shell 能打开已知文件,却不能询问卷上有哪些文件;Day 19 的 DEMO 则以内嵌 ELF 运行,尚未走 FAT16 装载路径。Day 30 只补这两个缺口,不重写已经通过的机制。

最终镜像增加 syscall 12:

1
2
3
4
5
6
7
8
#define SYS_READDIR 12u

struct fat_dirent {
char name[13];
uint8_t attributes;
uint16_t reserved;
uint32_t size;
};

用户态传入从零开始的索引。返回 1 表示得到一项,0 表示目录结束,负数表示失败。定宽结构只包含 ls 当前需要的名字、属性和长度,不暴露 FAT 目录项中的簇号、高低字、时间戳等内部布局。

fat_readdir 仍遵守 Day 21 的只读边界:只扫描固定根目录,跳过已删除项、长文件名项、卷标和子目录,把 ASCII 8.3 名称转换为带点号的显示形式。用户态 LS.EXE 只是 Day 23 多调用程序新增的一个入口:

1
2
3
4
5
6
7
8
9
for (uint32_t index = 0;; ++index) {
int32_t result = read_directory(index, &entry);
if (result < 0) return 2;
if (!result) return 0;
write_all(1, entry.name, text_length(entry.name));
write_all(1, " ", 1);
write_unsigned(1, entry.size);
write_all(1, "\n", 1);
}

最终 FAT 卷共有 13 个可见文件,其中包括独立编译的 DEMO.EXE

1
2
3
4
5
6
7
# ls
README.TXT 1479
SHELL.EXE 4028
...
LS.EXE 4028
...
DEMO.EXE 1212

目录枚举和程序装载使用同一只读卷,但用途不同。ls 证明用户程序可以遍历文件名;demo 仍通过 open → read_at → ELF loader → process_create 执行,不能拿目录输出代替可执行文件的装载证据。

磁盘 DEMO 只负责组织已有进程

DEMO.EXE 不包含计数器和故障指令本身。它调用既有 spawn,依次创建两个 COUNT 和一个 BADPTR:

1
2
3
4
5
6
7
int32_t first = spawn("COUNT");
int32_t second = spawn("COUNT");
int32_t bad = spawn("BADPTR");

wait_pid(bad, &status);
wait_pid(first, &status);
wait_pid(second, &status);

COUNT 与 BADPTR 仍来自 Day 19 的用户程序。COUNT 在自己的用户页递增进度值,等待内核写入释放标志,再以状态 7 退出。BADPTR 访问用户地址空间中未映射的 0x00100000,产生 vector 14,由进程故障路径转成退出状态。内核错误仍会 panic;这里只隔离 CPL3 进程自身的页故障。

这个场景同时存在 Shell、DEMO、COUNT×2 和 BADPTR,需要五个用户进程槽,加上 PID 0 共六槽。直接把全局上限从四改成六会破坏旧实验对 EAGAIN 和槽位耗尽的精确边界,因此代码分开了容量和当前限制:

1
2
3
4
#define TASK_SLOTS 6u
#define PROCESS_SLOTS 4u

task_limit = final_demo ? TASK_SLOTS : PROCESS_SLOTS;

只有最终镜像启用六槽。Day 19 进程实验、Day 22 fd 实验和原 Shell 镜像继续使用四槽,历史失败路径没有被扩容掩盖。

“另一个进程还活着”需要先后证据

BADPTR 被终止、最终看到 COUNT 退出,并不能证明 COUNT 在故障之后继续运行。它可能早已完成全部工作,只是稍晚才被回收。最终实验在 BADPTR 退出发布前读取两个 COUNT 的进度:

1
2
process_release_snapshots[slot] = *progress;
day30_survivor_before[n] = *progress;

之后每次从最外层 syscall trap 返回时,内核重新读取两个值。只有两者都严格大于各自快照,才设置两个进程的 release 标志:

1
2
3
4
if (now == process_release_snapshots[i]) return;

for (each surviving COUNT)
*release = 1;

事件日志另带独立单调序号:

1
2
D30 BADPTR seq=1 pid=22 vector=14 cr2=100000 tick=703 survivors=2
D30 SURVIVORS seq=2 count=2 growth=1,1 tick=704 after-fault=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
2
3
4
5
6
ls
cat readme.txt
cat readme.txt | wc
demo
echo after-reset
exit

这几条命令分别压住不同的连接点:

1
2
3
4
5
6
ls                    → readdir ABI → FAT16 根目录
cat README.TXT → FAT 文件 → fd → 有界窗口终端
cat ... | wc → 两个用户进程 → 64 字节管道 → 终端
demo → FAT ELF → spawn/wait → #PF 隔离 → 两个存活者
echo after-reset → DEMO 回收后 Shell 再次取得输入并创建子进程
exit → fd、终端、管道、地址空间、栈和 surface 反向释放

最终窗口保留了本轮的可见结果:

最终演示完成后的窗口终端

窗口里的文字只证明可见输出。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
2
FINAL first demo=1212/3 bad=22 seq=1<2 growth=1,1
dir=13/16 reaped=10 pages=16040 prompts=6

demo=1212/3 表示 FAT-loaded ELF 长度为 1212 字节,当前装载器读取了 ELF 头、program header 和 LOAD 段三次。这个数字是本文件布局的观测值,不是 ELF 的通用读取次数下限。dir=13/16 表示 13 个可见文件和 16 次根目录扇区读取;实现按索引从头扫描,语义明确但没有优化成目录游标。

hard reset 与新进程不是同一种复现

首轮完成后,脚本向同一个 QEMU 进程发送:

1
{"execute":"system_reset","id":"system_reset-warm"}

QMP 把它定义为 guest hard reset。虚拟 CPU 和设备重新进入复位状态,但 QEMU 宿主进程没有退出。第二轮重新经过 MBR、stage2 和内核初始化,再执行同一命令链。

随后脚本关闭这个实例,启动新的 QEMU 进程,从同一个 mbr-final.img 完成第三轮。三个入口的首个提示符状态一致:

1
2
3
READY first         cs=1b eip=400000c7 pages=16040 refs=3
READY warm-reset cs=1b eip=400000c7 pages=16040 refs=3
READY fresh-process cs=1b eip=400000c7 pages=16040 refs=3

三个终态也具有相同的语义计数:

1
2
3
first         seq=1<2 growth=1,1 dir=13/16 reaped=10 pages=16040
warm-reset seq=1<2 growth=1,1 dir=13/16 reaped=10 pages=16040
fresh-process seq=1<2 growth=1,1 dir=13/16 reaped=10 pages=16040

三张截图逐字节相同。这里的复现仍限定于同一镜像、同一 QEMU/SeaBIOS 版本和同一模拟机参数;它不等于物理断电重启,更不能外推到不同 BIOS 或真实设备。

资源基线是场景结束条件

Shell 退出后,PID 0 持续回收孤儿并排空终端输出。最终断言要求:

  • file_live_count()file_total_refs() 都为 0;
  • 管道创建数等于释放数;
  • 终端输出入队字节等于 UI 消费字节,两个端点均释放;
  • 本轮回收至少 10 个进程;
  • 两个 COUNT、BADPTR、DEMO 与 Shell 均不再占用任务槽;
  • 16 页 surface 释放后,空闲页回到 UI 初始化前的 16040 页。

串口最后输出:

1
2
D30 FINAL demo=1212/3 pid=19 reaped=10 dir=13/16 prompts=6 pages=16040
FINAL DEMO OK FAT ls, pipe, two survivors, cleanup

一次端点相等无法证明所有未覆盖路径都没有泄漏。三轮相同终态能排除本场景在 warm reset 和新进程复跑下的累计残留,结论只到这里为止。

专项检查与累计回归

专项结果首轮与 hard reset 串口新进程串口QMP transcriptHMP 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
9c4a841efc8b6a51f9509586cdcd8889d8276ec9d8bcde9d7cdc4363e6dfbf3a

解压后执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
unzip os-day-30.zip
cd os-day-30
make build
make check-final
make check-window
make check-mouse
make check-graphics-text
make check-graphics
make check-pm
make check-pipe
make check-shell
make check-fd
make check-fat
make check-block
make check-process
make check-syscall
make check-elf
make check-memory
make check-runtime-limit
make check-keyboard

附件不含 build/。冻结后已在新空目录重新构建并复跑专项与全部现行累计目标;独立附件复验是 ZIP 外的收据,不写回归档。

练习

  1. readdir(index) 改成持有目录游标的 fd,比较状态所有权、关闭语义和重复扫描次数;仍保持 FAT 内部目录项不直接暴露给用户态。
  2. 增加第三个 COUNT,再把任务槽总量和场景活动上限分开调整,检查旧四槽耗尽实验是否保持原结果。
  3. 让 BADPTR 在不同进度阶段故障,保存 before/after 和事件序号,确认存活证明不依赖固定 PID 或固定 tick。
  4. check-final 增加一个故意漏掉 COUNT 释放的镜像,要求测试在资源终态而不是超时文本处明确失败。
  5. 把同一镜像放到另一版 QEMU/SeaBIOS 中运行,先记录差异,再决定哪些断言属于体系结构契约,哪些只是固定模拟器观察。

上一篇:29 - 窗口终端与用户态 Shell

本篇完成 00–30 的既定主线。UEFI/x86-64、可写文件系统、网络和用户态图形服务属于新的扩展系列,需要重新制定实现顺序和验收边界。

参考资料