第 22 篇已经把 FAT16 文件和终端收进同一套文件描述符接口,但当时的用户程序仍然是内核里的字节数组。键盘能向 fd 0 交付字符,文件能从 fd 3 往后读取,这两条路径还没有被一个用户态程序连起来。

本篇加入一个常驻的 CPL3 Shell。它从终端读取一行,把命令名转换为 FAT16 8.3 文件名,调用 spawn_file 从磁盘加载 ELF,再用 wait 等待指定子进程。catecho 也运行在用户态,只依赖上一篇的 fd 系统调用。

实验使用 QEMU HMP sendkey 把按键送进模拟 i8042,然后经过 IRQ1、扫描码解析、终端队列和 read(0, ...) 到达 Shell。正常命令、未知命令、损坏 ELF、子进程页故障与解析边界都通过同一个交互镜像验证。

Shell 只负责编排用户态能力

命令解释器很容易被写成内核里的一个大 switch:内核直接识别 cat、读 FAT、往 VGA 打印。这样虽然能得到命令行,却绕过了前面已经建立的进程、系统调用和句柄边界。

当前的 Shell 只做四件事:读取一行、分割参数、创建子进程、等待子进程。程序文件、用户页和内核栈的所有权仍由内核管理;catecho 不知道 ATA 端口、FAT 几何或 VGA 地址。

1
2
3
4
5
6
7
PS/2 按键
↓ IRQ1 / 终端输入队列
Shell: read(0) → 分词 → spawn_file(path, argv, fd-map)

FAT16 → ELF loader → CPL3 子进程

exit status → wait(pid) → 下一个提示符

这条边界还有一个后续用途:第 29 篇把字符终端换成窗口终端时,Shell 不需改成内核任务。只要 fd 0/1/2 能映射到新终端对象,进程代码可以保持不变。

ELF loader 不应绑定字节数组

第 18 篇的 elf_load(image, size, out) 默认整个 ELF 已在内存里。从 FAT 启动程序时,最直接的做法是先把文件整体读入一块大缓冲区,但这会多出一份文件大小的临时所有权,也让受限内核必须先承诺一块连续内存。

本篇把 loader 改成只依赖一个有界 byte source:

1
2
3
4
5
6
struct elf_source {
uint32_t size;
void *context;
int (*read_at)(void *context, uint32_t offset,
void *buffer, uint32_t length);
};

内存版 provider 继续服务旧回归;磁盘版 provider 用 file_pread() 在指定 offset 读取,不改动 open-file 的顺序读游标。校验 ELF 头、program header、段范围、权限和入口的代码仍然只有一份。

Day 23 的用户 ELF 只有一个 PT_LOAD。真实装载过程产生三次 source 读取:52 字节 ELF header、32 字节 program header,以及 2381 字节 LOAD 段。GDB 在进程发布前观察到 reads=3 和最后访问 LBA 2203。

可迁移的部分是“让解析器依赖有界随机读,而不是依赖数据住在哪里”。ELF 可以来自内存、只读文件或未来的其他块设备,但同一套边界检查不应随数据来源复制。

用户指针只在调用边界上存活

SYS_SPAWN_FILE=10 收到的程序名、argv 指针数组和字符串都来自 CPL3。内核不能在切换地址空间后继续保留这些指针,也不能用第一个 NUL 之前的内容掩盖后续页不可读。

系统调用首先逐项 copy_from_user,再将结果收敛到固定大小的内核结构:

1
2
3
4
5
6
7
#define PROCESS_ARG_MAX 8u
#define PROCESS_ARG_LEN 31u

struct process_args {
unsigned argc;
char values[PROCESS_ARG_MAX][PROCESS_ARG_LEN + 1u];
};

argc 必须在 1–8 之间,每个字符串必须在 32 字节检查窗口中找到 NUL。指针数组的地址加法也先检查 32 位溢出。从这一刻起,后续 loader 和调度操作只使用内核副本。

子进程初始栈从 0x80000000 向下构造:

1
2
3
4
5
6
7
高地址  0x80000000
├─ 参数字符串,逆序复制
├─ 对齐填充
├─ argv[0] ... argv[argc - 1]
├─ NULL
ESP ──────► argc (ESP % 16 == 0)
低地址 0x7ffff000

字符串、指针向量和 argc 全部必须落在一页用户栈中。任何一项越界都会拒绝创建,不留下可调度的半成品。

spawn_file 是一次延迟发布的交易

一次启动会临时持有三类资源:父进程的 0/1/2 fd 引用、可执行文件对象、新地址空间的页。如果任务在这些资源准备完成前就变成 RUNNABLE,调度器可能进入一个没有完整栈或 fd 的进程。

实现把发布放在最后:

1
2
3
4
5
6
暂存 fd 引用
→ 打开程序文件
→ 从 file_pread 加载未发布地址空间
→ 关闭程序文件
→ 构造 argc/argv 用户栈
→ 分配内核栈并发布 RUNNABLE

加载完成后可以立即关闭程序文件,因为当前的所有 LOAD 页都已立即分配并填充,没有 demand paging。地址空间加载失败时,loader 释放已分配页;进程发布失败时,task_spawn_file 释放地址空间和暂存 fd 引用。

可迁移的规则是:对外可见的对象只在不变式全部成立后发布。关键字是“创建中失败”和“其他执行流可以立即看见”;同类问题应先列出暂存资源,再设计逆序回滚和唯一发布点。

语法边界必须是可观察行为

这个 Shell 只按 ASCII 空格分割一行,连续空格被折叠,空行直接回到提示符。命令名不区分 ASCII 大小写;没有扩展名时补 .EXE,名称最终必须落在当前 FAT 8.3 子集。

无法解释的语法不会被当成普通文件名继续执行。|、引号、反斜杠、tab、;&<> 一律返回 shell: syntax is not supported。这个拒绝很重要:若 Day 23 先悄悄吞掉管道符,Day 24 就无法用运行行为证明管道真的被实现。

三个限制都有精确成功与失败样本:

限制 成功边界 拒绝边界
整行 95 字节 第 96 字节起不执行该行
参数数 8 个,含 argv[0] 第 9 个报 too many arguments
单参数 31 字节 32 字节报 argument too long

行过长后继续消费到换行,而不是把剩余字节当成下一条命令。一条输入要么完整执行,要么整条拒绝,不会执行静默截断后的另一个含义。

catecho 是普通子进程

FAT16 镜像中的 SHELL.EXECAT.EXEECHO.EXEARGTEST.EXEFAULT.EXE 暂时是同一份 2381 字节 ELF 的五个目录项。入口根据 argv[0] 选择行为。这是为了在还没有完整 libc 和独立链接配置时减少重复代码,不改变“每次命令都创建新地址空间”的进程语义。

echo 遍历 argv[1..],用空格连接后写入 fd 1。cat 打开用户指定的文件,每次读取最多 256 字节,并用 write_all 处理短写,直到 read 返回 EOF。它读出的 README.TXT 为 1479 字节,SHA-256 是 25fc6f4ce8493714e7056d2896c47e6a9f4c4db91a4b860dd474035542804798,宿主检查脚本进行了逐字节比较。

Shell 在 spawn_file 成功后立即 wait(pid, &status)。因此它是前台 Shell,不支持后台任务。子进程返回非零状态时,Shell 输出 child failed,然后重新读取输入。

错误只终止当前命令

交互序列刻意在每个错误后追加一条有效命令:

1
2
3
4
5
6
7
8
9
10
11
12
# nosuch
shell: command not found
# echo recovered
recovered
# badmagic
shell: invalid executable
# echo afterbad
afterbad
# fault
shell: child failed
# echo afterfault
afterfault

nosuch 在 FAT 查找阶段失败;badmagic 在同一 ELF loader 中被拒绝;fault 在 CPL3 写入内核低地址时发生页故障。三条路径的资源交接点不同,但都不会终止 Shell 或 panic 内核。

提示符是这个实验的资源检查点。除首次外,每次输出 # 都断言系统只剩 Shell 进程,两个终端 open-file 对象共有三个引用,空闲页数回到 16043。输入 exit 后,PID 0 回收 Shell,终端对象和用户页全部释放:

1
SHELL OK prompts=20 pages=16049

Shell 的错误恢复与边界检查

Shell 完整串口记录GDB 装载现场键盘注入序列保留了实际输入和观察值。专项检查后又复跑文件描述符、FAT、块设备、进程、系统调用、ELF、内存、容量边界和键盘检查,九个目标全部退出 0,见累计回归记录

当前边界

本篇的 Shell 不是 POSIX shell。它没有 PATH、当前目录、环境变量、引号、转义、通配符、重定向、后台任务或脚本。FAT16 仍然只读,文件查找限于固定根目录和 ASCII 8.3 子集。

终端仍是逐字符输入。退格由 Shell 本身修改当前行,没有 canonical mode、Ctrl-D 或多 reader 公平性。测试使用 QEMU 8.2.2、SeaBIOS 1.16.3、pc-i440fx-7.2、TCG、qemu32、1 CPU 和 64 MiB;HMP 只是可重复的按键注入方式,不代表已验证真实键盘或其他虚拟机。

echo x | wc 在当前版本中明确报语法不支持。第 24 篇将把这个失败用例变成两段管道,重点是端点引用、缓冲区满空阻塞、EOF 传播和 Shell 的关闭顺序。

机制速查

遇到的问题 可迁移做法 本篇落点
同一解析器要读不同介质 依赖有界读回调 elf_source(size, read_at)
输入指针的生命周期不可控 在边界内验证并复制 process_args
创建过程可在中途失败 延迟发布,失败时逆序回滚 task_spawn_file
解析器尚不支持某个语法 整条拒绝并保留会话 `
子任务失败不应终止控制者 回收子资源后回到稳定检查点 下一个 #

下载与复现

下载第 23 篇完整源码。附件 SHA-256:

1
5f4af72799f0d876218d62029fca483a47b2284f255ffa37c4f7f1c323ce1d3f

解压后执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
unzip os-day-23.zip
cd os-day-23
make build
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

附件包含第 01–23 篇的累计工程和打包前已形成的 docs/day23-* 证据,不含 build/ 产物。它会在新的空目录中从零重建,并复跑专项检查和九项累计回归。独立复验结果见ZIP 复验记录,该记录验证的是已冻结附件,因此不反向放入 ZIP 自身。

练习

  1. argv 字符串正向复制到用户栈,保持其他逻辑不变,观察指针向量和栈下界检查需要怎样调整。
  2. 把过长行改成读到上限就立即返回提示符,用同一组输入证明剩余字节会被误解为新命令。
  3. file_pread 换成改动 open-file offset 的 file_read,然后设计一个含多个 LOAD 段的 ELF,检查 loader 为什么不应依赖调用顺序推导文件位置。

上一篇:22 - 文件描述符

下一篇:24 - 管道

参考资料