从零编写操作系统 17 - 系统调用:把用户参数安全地带进内核
第 16 篇已经把任务降到 ring 3,并用页表阻止用户程序改写内核。隔离建立以后,用户程序也失去了直接输出、访问端口和控制调度器的能力。
本篇增加一个 DPL3 的 int 0x80 门和两项自定义系统调用:write 把用户字节写到控制台,yield 请求调度。难点集中在边界状态:CPU 怎样换到当前任务的内核栈,内核怎样检查一段可能跨页、回绕或指向 supervisor 页的地址,以及失败后怎样回到同一用户程序继续执行。
实验会故意传入第二页缺失的缓冲区。预置在第一页末尾的 LEAKFAIL 不能出现在串口里;否则说明输出在完整校验之前已经发生。
系统调用是受控的权限切换
用户态不能通过普通 call 跳进内核。call 只改变 EIP 和栈上的返回地址,不会把 CPL3 变成 CPL0,也不会从 TSS 取出内核栈。
int 0x80 走 IDT 门。处理器检查这个门是否存在、门的 DPL 是否允许软件调用,再完成权限切换和现场保存。第 16 篇建立的 TSS.ESP0 因而不只是 IRQ 的入口栈,也成为系统调用入口栈。
1 | |
其他异常和 IRQ 门继续使用属性 0x8e,DPL 为 0。只有系统调用门写成 0xee,将 DPL 设置为 3:
1 | |
门的 DPL 检查针对软件执行的 INT n。硬件 IRQ 和处理器检测到的异常不靠把门开放给 CPL3 才能进入。若把所有门一起改成 DPL3,用户程序就能主动伪造本应由处理器或设备产生的入口。
本篇用 int 0x20 做失败对照。IRQ0 的门仍为 DPL0,CPL3 执行它得到 #GP,实际错误码为 0x102。错误码指向 IDT 的 32 号门;系统没有把这次权限失败当成一次时钟中断。
这个规律可以迁移到任何跨权限入口:只开放需要公开的入口,同时让其余内部入口继续受原权限约束。入口号相邻,不表示权限也应相同。
软件 INT 复用 76 字节现场
INT n 不会因为向量号碰巧对应某种异常,就自动获得那种异常的错误码。vector 128 的汇编 stub 人工压入零和向量号,再进入第 07 篇建立的公共入口:
1 | |
CPL3 到 CPL0 的硬件现场尾部仍有用户 ESP 和 SS,公共 trap_frame 前部仍为 68 字节,总计 76 字节。interrupt gate 在保存 EFLAGS 后清 IF;iretd 返回时从保存值恢复 IF、DF、CF、用户栈和代码位置。
GDB 在真实 syscall_dispatch 入口读出 19 个双字。保存的 CS/SS 为 0x1b/0x23,用户 ESP 为 0x80000000;处理中 CS/SS 已是 0x08/0x10,IF 为零。保存 EIP 等于 user_syscall_return,也就是两字节 INT 指令之后的位置。
用户测试在调用前为 EBX、ECX、EDX、ESI、EDI、EBP 写入哨兵,并同时设置 IF、DF、CF。返回后除了约定的 EAX,其他通用寄存器和三个位都保持原值。汇编入口进入 C 前执行 CLD,内核 C 代码不会继承用户 DF;返回路径仍把用户原来的 DF 恢复。
这种现场验证比“系统调用打印出一行文字”多回答了两个问题:入口确实来自 CPL3,返回确实回到原调用点。缺少任一项,都可能把一次偶然跳转误判成完整的调用协议。
本项目 ABI 只有两个调用
调用约定固定为:
| 寄存器 | 含义 |
|---|---|
| EAX | 调用号;返回时保存结果或负错误码 |
| EBX | 第一个参数,write 的用户地址 |
| ECX | 第二个参数,write 的字节数 |
| EDX | 预留的第三个参数 |
调用号 1 是 write,调用号 2 是 yield。write 成功返回实际长度,yield 返回 0,未知调用返回 -ENOSYS。地址无效返回 -EFAULT,超过 256 字节返回 -EINVAL。
这些编号和寄存器分配属于教学内核自己的 ABI,不是 Linux ABI。共享 int 0x80 这个入口号,不会自动获得 Linux 的调用号、进程模型或兼容性。
分派器只写回 frame 中的 EAX:
1 | |
系统调用返回负数时,任务仍然存活。坏参数属于可预期的接口错误;来自 CPL3 的非法指令或页面访问则进入异常隔离路径。内核自身在已经校验的复制过程中发生页故障,仍应视为内核缺陷,而不能悄悄改写成用户错误。
一段用户地址必须逐页成立
检查起点是否合法还不够。一个缓冲区可以从合法页的最后八字节开始,余下部分落到缺失页、supervisor 页或用户窗口之外。
当前用户窗口从 USER_CODE=0x40000000 开始,到 USER_STACK=0x80000000 之前结束。非零长度先检查起点,再用减法约束长度:
1 | |
先比较 length > USER_STACK - address,才能在计算 address + length - 1 之前排除无符号回绕。0xfffffff0 + 32 会绕回低地址;若先算末地址,它可能看起来重新落入一个较小范围。
合法范围随后从首地址所在页走到末地址所在页。每一页都要检查 PDE 和 PTE 的 present、user 位。内核向用户写数据时,两级还必须同时具备 writable:
1 | |
只检查叶子 PTE 会漏掉上级 PDE 的权限收紧。只检查第一和最后一页,在更长范围里又可能漏掉中间的空洞。本篇缓冲区最多 256 字节,循环仍按一般的逐页规则实现,避免把大小限制误当成页面权限保证。
零长度是单独的接口语义:返回 0,不读取地址,也不要求地址有效。因此地址 0xffffffff 配合长度 0 成功,并不说明该地址可访问。
地址范围验证的可迁移模式是“先证明整个对象,再发生外部效果”。对象可以是用户缓冲区、磁盘区间或文件片段;只验证开头,就无法保证后半段不会跨越另一种权限或所有权边界。
输入复制和输出复制使用不同权限
write 从用户页读取数据,所以只读代码页可以作为合法输入。若错误地统一要求 W 位,write 会拒绝本来完全有效的字符串常量。
copy_from_user 先验证完整范围,再按页取得物理地址并复制:
1 | |
物理页能被内核直接访问,是因为第 11 篇保留了低 64 MiB supervisor 恒等映射。这里没有把用户虚拟指针直接传给 console,也没有对它调用 strlen。
copy_to_user 使用同一遍历,但把 write_access 设为 1。虽然本篇尚无一个需要向用户缓冲区写结果的系统调用,这条基础路径仍做了四项客体内动态检查:
- 跨两张 writable 用户页写入 16 字节,首尾内容都正确。
- 第二页 PTE 去掉 W 后拒绝,第一页的 guard 不变。
- PDE 去掉 W 后拒绝。
- 零长度配无效地址不发生访问。
第二项同时验证失败前不产生部分写。检查器读取 syscall_copy_checks == 4,不是仅凭代码存在推断它可用。
当前复制协议依赖一个重要前提:每个进程只有一个线程,没有 unmap,IRQ 也不修改当前地址空间。若以后允许另一执行流在“检查完成”和“复制开始”之间撤销映射,就需要锁页、固定映射或可恢复的复制故障路径。
先复制全部内容,再产生输出
write 使用内核栈上的 256 字节缓冲:
1 | |
这段顺序给失败路径一个明确边界。测试把 LEAKFAIL 放在第一页最后八字节,系统调用长度要求继续读取第二页;第二页没有映射,因此整次调用返回 -EFAULT,串口中不能出现这八个字符。
若改成“检查一页、输出一页”,调用者会看到失败,但外部世界已经得到部分结果。对控制台只是多出几个字符;对文件、网络包或结构化消息,同类错误可能留下无法撤销的半次操作。
正常实验还覆盖 19 字节非 NUL 文本和 %。输出按显式长度逐字节处理,既不会越界寻找结尾,也不会把用户内容当作 kprintf 格式串。
yield 在统一 trap 出口兑现
系统调用进入后当前任务的 trap_depth 为 1。现有调度器要求只在最外层 trap 退出或普通任务调用边界切换,不能从 syscall_dispatch 内直接调用需要 trap_depth == 0 的切换函数。
SYS_YIELD 因而只调用 task_request_reschedule()。公共 trap 尾部把深度减到零,再选择另一个 RUNNABLE 任务。测试记录调用者和目标任务,确认目标计数在 yield 期间增长;这组固定槽位只属于实验探针,系统调用实现本身不依赖某个任务编号。
write 完成用户复制后短暂执行 STI,让真实 PIT IRQ 可以进入。外层系统调用 frame 仍属于当前任务,嵌套 IRQ 只递增 tick 并提出调度请求;console 输出结束后 CLI,切换仍在最外层退出点发生。
这个实现允许短系统调用期间响应中断,但不构成可抢占内核。console 仍有全局光标状态,也没有锁。把“IRQ 可以进入”和“内核可以在任意位置换任务”混为一谈,会破坏栈和共享状态的所有权。
正常路径、坏参数和坏入口一起验收
本次 QEMU 8.2.2 与 GDB 15.1 实测得到以下返回值。十六进制负数按 32 位补码显示:
1 | |
其中 0x13/0x10/0x0f 分别是 19、16、15。0xffffffda 是 -38,0xffffffea 是 -22,0xfffffff2 是 -14。
故障实验随后让另一个用户任务执行 int 0x20。该任务收到 #GP(0x102) 并退出,配对计数任务在随后四个 tick 中继续增长。两组共四个任务全部回收,每个任务的六张地址空间页和一张内核栈页均归还,空闲页回到实验开始值。
同一次启动保留 Day 03–16 的累计内建断言,最后到达原来的键盘阻塞消费者。GDB 给出三条通过记录:
1 | |
完整 串口记录、GDB 记录 和 QEMU 记录 与截图放在同名素材目录。旧 check-user.sh 在当前 GDB 15.1 下复跑时出现 target is running 控制错误,因此本轮不声称该旧脚本退出 0;正常镜像仍完整执行 Day 16 内建断言并输出 USER OK。
容量边界没有扩大
新增代码曾使全局 -O0 构建超过既有运行内存边界。最终没有修改 stage2、镜像头或 64 KiB 上限,而是只对增长集中的 task.c 使用 -Os -fno-inline,对 user.c 和 syscall.c 使用 -Os。可供 GDB 断点使用的标记函数仍然保留。
最终 kernel.bin 为 40820 字节。镜像头记录 memory size 为 64832 字节,低于 65536 字节,还剩 704 字节。这个余量已经很小,但“接近上限”仍不等于可以只改一个常量扩容。后续若真正超界,需要同时核对镜像头生产端、实模式分段读取、保护模式搬运、链接布局和过大镜像故障实验。
下载与复现
下载第 17 篇完整源码。附件 SHA-256:
1 | |
附件把完整项目放在 os-day-17/,不依赖累计源码目录中的 build/。解压后运行:
1 | |
冻结附件已经在独立临时目录解压,从空 build/ 重新编译并启动 QEMU,check-syscall 退出 0。重建的 kernel.bin 与 mbr.img SHA-256 分别为 6dc48b32623242660bf17b3e83128ea040da5aacb9d78aeeb1cd35061fd81cb4 和 6cea2d081032144809db26189c9d53c137cc9651b15d424dbac00a4589cc022c。
附件保留第 01 篇的 Podman 工具链入口。当前云端没有该镜像,改用临时解压的 Ubuntu 24.04 工具包完成复验;具体版本、命令、日志哈希和未验证边界见源码内 docs/day17-evidence.md。
练习
- 画出 CPL3 执行
int 0x80后的 76 字节现场,指出 CPU 保存的五项跨级返回状态和汇编人工补入的两个字段。 - 分别计算
0x40001ff8 + 16、0x7ffffff8 + 16和0xfffffff0 + 32,说明为什么必须在加法前用减法检查长度。 - 把跨页失败测试改成“逐页复制后立即输出”,观察
LEAKFAIL为什么会先于-EFAULT泄漏到串口。
上一篇:16 - 进入用户态。
下一篇:18 - ELF 加载器。
参考资料
- Intel SDM Vol. 3A:IA-32 分页权限、IDT 门、跨特权级中断栈、软件 INT 与 IRET 规则;本文核对版本为 Order 253668-085US,2024-10。
- Intel Software Developer Manuals:Intel 手册入口与当前版本索引。
- QEMU v7.2.22
seg_helper.c:固定源码提交中软件 INT 的 DPL 检查、TSS 换栈、保存 EIP 与 interrupt gate 清 IF;它用于交叉检查规范,不冒充本次 QEMU 8.2.2 的二进制源码对应证明。 - Intel386 System V psABI 1.0:内核 C 调用所依赖的 i386 寄存器与栈约定;用户系统调用 ABI 为本项目自定义。

