从零编写操作系统 23 - 用户态 Shell:从磁盘加载并执行命令
第 22 篇已经把 FAT16 文件和终端收进同一套文件描述符接口,但当时的用户程序仍然是内核里的字节数组。键盘能向 fd 0 交付字符,文件能从 fd 3 往后读取,这两条路径还没有被一个用户态程序连起来。
本篇加入一个常驻的 CPL3 Shell。它从终端读取一行,把命令名转换为 FAT16 8.3 文件名,调用 spawn_file 从磁盘加载 ELF,再用 wait 等待指定子进程。cat 和 echo 也运行在用户态,只依赖上一篇的 fd 系统调用。
实验使用 QEMU HMP sendkey 把按键送进模拟 i8042,然后经过 IRQ1、扫描码解析、终端队列和 read(0, ...) 到达 Shell。正常命令、未知命令、损坏 ELF、子进程页故障与解析边界都通过同一个交互镜像验证。
Shell 只负责编排用户态能力
命令解释器很容易被写成内核里的一个大 switch:内核直接识别 cat、读 FAT、往 VGA 打印。这样虽然能得到命令行,却绕过了前面已经建立的进程、系统调用和句柄边界。
当前的 Shell 只做四件事:读取一行、分割参数、创建子进程、等待子进程。程序文件、用户页和内核栈的所有权仍由内核管理;cat 和 echo 不知道 ATA 端口、FAT 几何或 VGA 地址。
1 | |
这条边界还有一个后续用途:第 29 篇把字符终端换成窗口终端时,Shell 不需改成内核任务。只要 fd 0/1/2 能映射到新终端对象,进程代码可以保持不变。
ELF loader 不应绑定字节数组
第 18 篇的 elf_load(image, size, out) 默认整个 ELF 已在内存里。从 FAT 启动程序时,最直接的做法是先把文件整体读入一块大缓冲区,但这会多出一份文件大小的临时所有权,也让受限内核必须先承诺一块连续内存。
本篇把 loader 改成只依赖一个有界 byte source:
1 | |
内存版 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 | |
argc 必须在 1–8 之间,每个字符串必须在 32 字节检查窗口中找到 NUL。指针数组的地址加法也先检查 32 位溢出。从这一刻起,后续 loader 和调度操作只使用内核副本。
子进程初始栈从 0x80000000 向下构造:
1 | |
字符串、指针向量和 argc 全部必须落在一页用户栈中。任何一项越界都会拒绝创建,不留下可调度的半成品。
spawn_file 是一次延迟发布的交易
一次启动会临时持有三类资源:父进程的 0/1/2 fd 引用、可执行文件对象、新地址空间的页。如果任务在这些资源准备完成前就变成 RUNNABLE,调度器可能进入一个没有完整栈或 fd 的进程。
实现把发布放在最后:
1 | |
加载完成后可以立即关闭程序文件,因为当前的所有 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 |
行过长后继续消费到换行,而不是把剩余字节当成下一条命令。一条输入要么完整执行,要么整条拒绝,不会执行静默截断后的另一个含义。
cat 和 echo 是普通子进程
FAT16 镜像中的 SHELL.EXE、CAT.EXE、ECHO.EXE、ARGTEST.EXE 和 FAULT.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 | |
nosuch 在 FAT 查找阶段失败;badmagic 在同一 ELF loader 中被拒绝;fault 在 CPL3 写入内核低地址时发生页故障。三条路径的资源交接点不同,但都不会终止 Shell 或 panic 内核。
提示符是这个实验的资源检查点。除首次外,每次输出 # 都断言系统只剩 Shell 进程,两个终端 open-file 对象共有三个引用,空闲页数回到 16043。输入 exit 后,PID 0 回收 Shell,终端对象和用户页全部释放:
1 | |
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 | |
解压后执行:
1 | |
附件包含第 01–23 篇的累计工程和打包前已形成的 docs/day23-* 证据,不含 build/ 产物。它会在新的空目录中从零重建,并复跑专项检查和九项累计回归。独立复验结果见ZIP 复验记录,该记录验证的是已冻结附件,因此不反向放入 ZIP 自身。
练习
- 把
argv字符串正向复制到用户栈,保持其他逻辑不变,观察指针向量和栈下界检查需要怎样调整。 - 把过长行改成读到上限就立即返回提示符,用同一组输入证明剩余字节会被误解为新命令。
- 把
file_pread换成改动 open-file offset 的file_read,然后设计一个含多个 LOAD 段的 ELF,检查 loader 为什么不应依赖调用顺序推导文件位置。
上一篇:22 - 文件描述符。
下一篇:24 - 管道。
参考资料
- Xinuos ELF Object File Format 4.3 DRAFT:Program Loading:用于核对
PT_LOAD、文件区间、内存区间和段对齐;本篇继续使用第 18 篇的受限 loader。 - Intel386 psABI 1.2,提交 20ec676cd56d41f6141037edfbeeb2b0681c6fb4:用于对照 i386 调用与栈对齐;本项目的
argc/argv入口仍是自定义约定。 - xv6-riscv
user/sh.c:固定提交的用户态 Shell 对照。xv6 使用fork/exec/dup,本项目使用spawn_file和显式 fd 映射,不直接移植其控制流。

