第 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CPL3 用户程序
EAX=调用号,EBX/ECX=参数
│ int 0x80

IDT[0x80]:present,DPL3,32 位 interrupt gate
│ TSS.SS0:ESP0

CPL0 当前任务内核栈
用户 SS / ESP / EFLAGS / CS / EIP
error=0 / vector=128
GPR / 段寄存器
│ syscall_dispatch

完整校验 → 内核副本 → 执行服务 → 只改返回 EAX
│ iretd

CPL3 的 int 后一条指令

其他异常和 IRQ 门继续使用属性 0x8e,DPL 为 0。只有系统调用门写成 0xee,将 DPL 设置为 3:

1
2
3
4
uint32_t address = (uint32_t)isr128;
idt[128] = (struct idt_gate){
address, 0x08, 0, 0xee, address >> 16
};

门的 DPL 检查针对软件执行的 INT n。硬件 IRQ 和处理器检测到的异常不靠把门开放给 CPL3 才能进入。若把所有门一起改成 DPL3,用户程序就能主动伪造本应由处理器或设备产生的入口。

本篇用 int 0x20 做失败对照。IRQ0 的门仍为 DPL0,CPL3 执行它得到 #GP,实际错误码为 0x102。错误码指向 IDT 的 32 号门;系统没有把这次权限失败当成一次时钟中断。

这个规律可以迁移到任何跨权限入口:只开放需要公开的入口,同时让其余内部入口继续受原权限约束。入口号相邻,不表示权限也应相同。

软件 INT 复用 76 字节现场

INT n 不会因为向量号碰巧对应某种异常,就自动获得那种异常的错误码。vector 128 的汇编 stub 人工压入零和向量号,再进入第 07 篇建立的公共入口:

1
2
3
4
5
global isr128
isr128:
push dword 0
push dword 128
jmp exception_common

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 是 yieldwrite 成功返回实际长度,yield 返回 0,未知调用返回 -ENOSYS。地址无效返回 -EFAULT,超过 256 字节返回 -EINVAL

这些编号和寄存器分配属于教学内核自己的 ABI,不是 Linux ABI。共享 int 0x80 这个入口号,不会自动获得 Linux 的调用号、进程模型或兼容性。

分派器只写回 frame 中的 EAX:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void syscall_dispatch(struct trap_frame *frame)
{
KASSERT((frame->cs & 3) == 3 &&
frame->vector == 128 && frame->error == 0);

int32_t result;
if (frame->eax == SYS_WRITE)
result = syscall_write(frame->ebx, frame->ecx);
else if (frame->eax == SYS_YIELD) {
task_request_reschedule();
result = 0;
} else
result = -ERR_ENOSYS;

frame->eax = (uint32_t)result;
}

系统调用返回负数时,任务仍然存活。坏参数属于可预期的接口错误;来自 CPL3 的非法指令或页面访问则进入异常隔离路径。内核自身在已经校验的复制过程中发生页故障,仍应视为内核缺陷,而不能悄悄改写成用户错误。

一段用户地址必须逐页成立

检查起点是否合法还不够。一个缓冲区可以从合法页的最后八字节开始,余下部分落到缺失页、supervisor 页或用户窗口之外。

当前用户窗口从 USER_CODE=0x40000000 开始,到 USER_STACK=0x80000000 之前结束。非零长度先检查起点,再用减法约束长度:

1
2
3
if (address < USER_CODE || address >= USER_STACK) return 0;
if (length > (size_t)(USER_STACK - address)) return 0;
uintptr_t last = address + length - 1;

先比较 length > USER_STACK - address,才能在计算 address + length - 1 之前排除无符号回绕。0xfffffff0 + 32 会绕回低地址;若先算末地址,它可能看起来重新落入一个较小范围。

合法范围随后从首地址所在页走到末地址所在页。每一页都要检查 PDE 和 PTE 的 present、user 位。内核向用户写数据时,两级还必须同时具备 writable:

1
2
3
4
5
6
7
8
uint32_t required = PAGE_PRESENT | PAGE_USER;
if ((pde & required) != required ||
(write_access && !(pde & PAGE_WRITE)))
return 0;

if ((pte & required) != required ||
(write_access && !(pte & PAGE_WRITE)))
return 0;

只检查叶子 PTE 会漏掉上级 PDE 的权限收紧。只检查第一和最后一页,在更长范围里又可能漏掉中间的空洞。本篇缓冲区最多 256 字节,循环仍按一般的逐页规则实现,避免把大小限制误当成页面权限保证。

零长度是单独的接口语义:返回 0,不读取地址,也不要求地址有效。因此地址 0xffffffff 配合长度 0 成功,并不说明该地址可访问。

地址范围验证的可迁移模式是“先证明整个对象,再发生外部效果”。对象可以是用户缓冲区、磁盘区间或文件片段;只验证开头,就无法保证后半段不会跨越另一种权限或所有权边界。

输入复制和输出复制使用不同权限

write 从用户页读取数据,所以只读代码页可以作为合法输入。若错误地统一要求 W 位,write 会拒绝本来完全有效的字符串常量。

copy_from_user 先验证完整范围,再按页取得物理地址并复制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int copy_from_user(void *destination, uintptr_t address, size_t length)
{
uint32_t cr3 = current_cr3();
if (!user_range_ok(cr3, address, length, 0)) return 0;

uint8_t *out = destination;
while (length) {
uint32_t physical;
KASSERT(user_page_ok(cr3, address, 0, &physical));
size_t chunk = PAGE_BYTES - (address & 0xfff);
if (chunk > length) chunk = length;
memcpy(out, (const void *)physical, chunk);
out += chunk;
address += chunk;
length -= chunk;
}
return 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
2
3
4
5
6
7
char bytes[SYSCALL_WRITE_MAX];
if (length > sizeof(bytes)) return -ERR_EINVAL;
if (!length) return 0;
if (!copy_from_user(bytes, address, length)) return -ERR_EFAULT;

for (size_t i = 0; i < length; ++i)
console_putc(bytes[i]);

这段顺序给失败路径一个明确边界。测试把 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
2
3
D17 returns write=13 yield=0 unknown=ffffffda zero=0
long=ffffffea low=fffffff2 wrap=fffffff2 upper=fffffff2
missing=fffffff2 supervisor=fffffff2 ro=10 cross=f regs=1

其中 0x13/0x10/0x0f 分别是 19、16、15。0xffffffda 是 -38,0xffffffea 是 -22,0xfffffff2 是 -14。

故障实验随后让另一个用户任务执行 int 0x20。该任务收到 #GP(0x102) 并退出,配对计数任务在随后四个 tick 中继续增长。两组共四个任务全部回收,每个任务的六张地址空间页和一张内核栈页均归还,空闲页回到实验开始值。

系统调用正常输出、错误返回、DPL 失败隔离与最终回收

同一次启动保留 Day 03–16 的累计内建断言,最后到达原来的键盘阻塞消费者。GDB 给出三条通过记录:

1
2
3
4
5
PASS: CPL3 int80 used DPL3 interrupt gate; 76-byte frame,
post-INT EIP, GPR and saved IF/DF/CF verified
PASS: write/yield, copy-to-user and all return paths verified;
bad DPL gate isolated; survivor and page recovery verified
PASS: syscall demo reaches original blocking keyboard consumer

完整 串口记录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.csyscall.c 使用 -Os。可供 GDB 断点使用的标记函数仍然保留。

最终 kernel.bin 为 40820 字节。镜像头记录 memory size 为 64832 字节,低于 65536 字节,还剩 704 字节。这个余量已经很小,但“接近上限”仍不等于可以只改一个常量扩容。后续若真正超界,需要同时核对镜像头生产端、实模式分段读取、保护模式搬运、链接布局和过大镜像故障实验。

下载与复现

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

1
cdd1b1653ff29c737e3a38a653580a2995d8725c9d13215a99236a8056f063e8

附件把完整项目放在 os-day-17/,不依赖累计源码目录中的 build/。解压后运行:

1
2
3
4
unzip os-day-17.zip
cd os-day-17
make build
make check-syscall

冻结附件已经在独立临时目录解压,从空 build/ 重新编译并启动 QEMU,check-syscall 退出 0。重建的 kernel.binmbr.img SHA-256 分别为 6dc48b32623242660bf17b3e83128ea040da5aacb9d78aeeb1cd35061fd81cb46cea2d081032144809db26189c9d53c137cc9651b15d424dbac00a4589cc022c

附件保留第 01 篇的 Podman 工具链入口。当前云端没有该镜像,改用临时解压的 Ubuntu 24.04 工具包完成复验;具体版本、命令、日志哈希和未验证边界见源码内 docs/day17-evidence.md

练习

  1. 画出 CPL3 执行 int 0x80 后的 76 字节现场,指出 CPU 保存的五项跨级返回状态和汇编人工补入的两个字段。
  2. 分别计算 0x40001ff8 + 160x7ffffff8 + 160xfffffff0 + 32,说明为什么必须在加法前用减法检查长度。
  3. 把跨页失败测试改成“逐页复制后立即输出”,观察 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 为本项目自定义。