第 17 篇的用户程序仍是一段写在内核工程里的汇编字节。代码地址、数据地址和入口都由内核事先知道,这种方式适合验证 ring 3 和系统调用,却不能装载独立编译的程序。

本篇让 GCC 和 GNU ld 生成一个真正的 ELF32/i386 可执行文件。内核不再假定入口和段地址,而是解析 ELF header 与 program header,把 PT_LOAD 映射进新的用户地址空间,再从文件声明的入口执行。用户程序会检查 .data、跨页 BSS 和 C 栈,最后复用第 17 篇的 write 输出结果。

加载器的输入不能因为“由自己的构建系统生成”就跳过检查。实验会在客体内构造 34 类错误 ELF,并把内存分配失败依次放在 7 张地址空间页和第 8 张任务内核栈上。任何失败都必须恢复资源,任务只有在所有步骤成功后才能变成 RUNNABLE。

从固定字节到可执行文件

第 16、17 篇的地址空间布局固定为一页代码、一页数据和一页栈。任务入口固定为 0x40000000,内核还会在用户 IRQ 返回前写入固定的数据页。这些探针证明了权限切换,却把“测试程序长什么样”和“地址空间怎样创建”绑在了一起。

ELF 把这两个问题分开。链接器决定文件中有哪些可装载段、每段应放到哪个虚拟地址、文件字节有多少、内存中还要补多少零,以及入口在哪里;加载器负责验证这些声明并落实映射。

本篇只实现一个受限子集:

  • ELF32、小端、i386、静态 ET_EXEC
  • 只接受 PT_LOADPT_NULL,最多 8 个 program header;
  • LOAD 必须落在 [0x40000000, 0x40400000),总计不超过 8 页;
  • 不允许不同 LOAD 覆盖同一页;
  • 不支持解释器、动态链接、重定位、TLS、共享对象和标准进程初始栈。

这不是“ELF 只能这样”,而是加载器明确承诺支持的输入集合。限制越窄,失败语义和资源上界越容易在当前教学内核里验证。

链接脚本先生成加载器能理解的文件

用户程序使用独立链接脚本显式生成两个 LOAD:

1
2
3
4
5
6
7
8
9
10
11
12
PHDRS {
code PT_LOAD FILEHDR PHDRS FLAGS(5);
data PT_LOAD FLAGS(6);
}

SECTIONS {
. = 0x40000000 + SIZEOF_HEADERS;
.text : { *(.text*) *(.rodata*) } :code
. = ALIGN(0x1000);
.data : { KEEP(*(.day18_result)) *(.data*) } :data
.bss (NOLOAD) : { *(.bss*) *(COMMON) } :data
}

FILEHDR PHDRS 把 ELF 头和 program header 放入代码 LOAD。数据段的文件内容只有 8 字节,内存大小却是 0x13a8,差额就是需要清零的 BSS。实际剥离后的文件为 4,332 字节,入口 0x40000080

LOAD 虚拟地址 文件大小 内存大小 权限
code 0x40000000 0x149 0x149 R E
data/BSS 0x40001000 0x8 0x13a8 RW

加载依据是 program header,不是 section header。section table 便于链接、调试和检查,但进程映像由 PT_LOAD 描述;加载器即使忽略 section table,也能得到完整的运行内存。

先验证整份计划,再分配一张页

把 ELF 结构体直接覆盖到输入指针上,会同时引入未对齐访问、越界读取和整数回绕问题。当前实现先确认剩余长度,再用有界 memcpy 把 header 复制到局部结构。

program header table 不能先计算 phoff + phnum * phentsize 再比较,因为恶意的 32 位字段可能先回绕。检查改写成减法和除法:

1
2
3
if (header.phoff > size ||
header.phnum > (size - header.phoff) / header.phentsize)
return ELF_ERR_PHDR_TABLE;

每个 LOAD 随后按以下顺序验证:

  1. 段类型和权限位属于支持集合。
  2. filesz <= memsz,文件区间没有越过输入末尾。
  3. p_align 为 0、1 或二次幂,并满足地址与文件偏移同余。
  4. 虚拟地址位于 LOAD 窗口,长度不会回绕或越过上界。
  5. LOAD 按虚拟地址升序,页覆盖不与前段相交,总页数不超过 8。
  6. 入口落在某个带 PF_X 的 LOAD 的真实内存范围内。

虚拟范围检查有一个容易漏掉的上界条件:

1
2
3
if (ph.vaddr < USER_CODE || ph.vaddr >= USER_LOAD_END ||
ph.memsz > USER_LOAD_END - ph.vaddr)
return ELF_ERR_VADDR;

如果只写最后一项,而没有先排除 vaddr >= USER_LOAD_ENDUSER_LOAD_END - vaddr 会发生无符号下溢。一个明显位于窗口上方的段可能因此通过检查,随后用巨大的页表下标写坏内核。这也是本篇恶意头实验要覆盖地址上界和 32 位回绕的原因。

完整验证阶段只生成最多 8 项的加载计划,不分配物理页。格式错误、范围错误和非法入口因此不会产生需要回收的半成品。

PT_LOAD 是文件区间到内存区间的映射

验证通过后,加载器先为页目录和两张页表分配页面,再逐页落实 LOAD。每张物理页先清零,然后只复制该页与文件区间相交的部分:

1
2
3
4
5
6
segment memory: [p_vaddr, p_vaddr + p_memsz)
segment file: [p_vaddr, p_vaddr + p_filesz)

for each covered page:
clear the whole physical page
copy intersection(page, segment file)

这个顺序同时处理三类区域:文件中存在的代码或初始化数据、p_memsz - p_filesz 指定的零填充区,以及段首尾页没有被该段占用的字节。实现不要求段从页边界开始,但要求 p_vaddrp_offset 的页内偏移相同,复制关系才不会在映射后错位。

p_paddr 没有被当成物理页号。System V 应用 ELF 的这一字段不替内核分配内存;物理页必须来自当前页分配器,并登记到地址空间的所有权数组里。

页表只能落实部分 ELF 权限

两个 LOAD 都位于同一个 4 MiB PDE 范围。该 PDE 必须为 RWU,代码和数据的写权限差异由叶子 PTE 表达:代码 PTE 为 present+user,数据 PTE 再增加 writable。

这不等于完整实现了 PF_R/PF_W/PF_X。本项目使用 IA-32 非 PAE 32 位分页,没有 NX 位:数据页虽然没有 PF_X,硬件仍允许从中取指;present 的用户页也至少可读。PF_X 只用于软件检查入口,PF_R 无法独立关闭。p_flags=0 因而直接拒绝,因为当前页表不能把一张 present 用户页表达成完全不可访问。

入口必须位于带 PF_X 的 LOAD,是本加载器的收窄策略,不是 ELF 规范对所有系统的通则。检查使用段的真实 [p_vaddr, p_vaddr+p_memsz),而不是页对齐后扩大的范围,避免跳进段外的填充页。

自制 _start 建立本项目的 C 入口

-ffreestanding 不会自动提供进程入口、栈或退出协议。本项目也没有构造 System V 规定的 argc、argv、envp 和 auxiliary vector,因此不能直接复用假定标准初始栈的宿主 _start

用户程序提供最小入口:

1
2
3
4
5
6
7
8
day18_start:
cld
xor ebp, ebp
call day18_main
.idle:
mov eax, 2
int 0x80
jmp .idle

内核把初始 ESP 设为 0x80000000call 压入返回地址后,day18_main 入口满足 i386 C 调用约定的 (ESP+4)%16==0cld 保证 C 代码不会继承未知方向标志。第 19 篇还没有实现 exit,所以 main 返回后只反复调用现有 yield,由内核的实验控制路径结束任务。

用户 C 程序检查初始化变量、5,000 字节 BSS 和八项局部栈数据,再通过第 17 篇的私有 syscall ABI 输出。它不是 Linux 用户程序,也不具备 libc。

地址空间创建是一个事务

地址空间的所有权槽从 6 个扩为 12 个。旧布局的 pages[0..5] 语义保留,保证第 16、17 篇测试继续成立;ELF 额外 LOAD 页使用剩余槽位。地址空间同时保存动态 entrystack_top 和种类,旧 IRQ 数据标记只对固定 blob 类型执行。

创建分成三个阶段:

1
2
3
4
5
验证所有 ELF 字段
-> 建立未发布的地址空间并登记每张物理页
-> 分配任务内核栈
-> 写入完整 task 字段
-> 最后设置 RUNNABLE

elf_load 要求输出结构为空。如果调用者把仍持有页面的结构传进来,函数返回 ELF_ERR_OUTPUT,不会用零结构覆盖旧所有权。分配失败则调用统一的 user_space_destroy,按已登记槽位释放页面并清空动态入口。

任务创建同样延迟发布。内核栈分配失败时,任务槽仍为 DEAD,没有内核栈,也没有复制进去的用户地址空间。调用者仍拥有未发布的地址空间,可以完整销毁它。

这套顺序的通用形式是:先证明输入,随后把资源放进单一所有权容器,最后发布可见状态。只要半成品还没有进入调度器,失败回滚就不需要和并发执行的任务竞争。

34 类坏输入和 8 个分配失败点

客体内的负例不是 host 脚本猜测返回值,而是正常内核启动时真正调用同一个 elf_load。34 项覆盖头截断、错误 ident、类型和机器、PHDR 表越界、未知段、零权限、文件与内存范围、对齐、LOAD 乱序和页覆盖、页数上限、虚拟地址回绕以及非法入口。

每项检查加载结果和空闲页数。因为坏格式在分配前拒绝,elf_last_allocations 必须为零。

随后对真实 ELF 做故障注入。前 7 次依次让页目录、两张页表、三张 LOAD 页和用户栈分配失败;第 8 次让任务内核栈失败。每轮后空闲页数恢复,任务栈失败还会检查没有 RUNNABLE 半成品。最后再正常加载一次,证明失败没有污染后续路径。

64 KiB 运行上限在这里真的撞到了

第 17 篇镜像头的运行内存为 64,832 字节,只剩 704 字节。加入 ELF 代码和内嵌文件后,用旧 65,536 字节上限链接当前对象,链接器真实返回:

1
i686-elf-ld: kernel memory exceeds loader bound

这次满足了既有扩容设计的启用条件。修改没有把 PAYLOAD_MAX_BYTES 一起放大:内核文件仍必须不超过 65,536 字节,因为 stage2 在实模式下从 0x10000 暂存并校验文件,磁盘也只为它保留 128 个扇区。

新增的 KERNEL_MEMORY_MAX_BYTES=131072 只约束保护模式目标区、镜像头的 memory 字段和链接器 __kernel_end。正常内核文件为 51,432 字节,memory 为 75,632 字节。

边界实验构造了精确 131,072 字节的运行镜像。GDB 在 _start 看见 stage2 把跨过旧边界的 BSS 全部预填为 0xa5,在 kernel_main 看见入口汇编已将它全部清零;镜像继续完成分配器、用户态、系统调用和 ELF 回归。131,073 字节在链接端被拒绝,另一个只篡改 memory 字段的镜像在 stage2 输出 HEADER FAIL

文件上限和运行内存上限代表不同约束。把同一个 64 KiB 常量机械替换为 128 KiB,会绕过实模式段访问和磁盘布局问题;分开命名与分开测试才能证明改动只发生在允许扩大的路径。

实测结果

QEMU 8.2.2、SeaBIOS、TCG、qemu32、单 CPU、64 MiB 下,GDB 在 ELF 入口和 C 入口取得以下结果:

1
2
3
4
5
6
7
PASS: dynamic ELF entry reached CPL3 with private CR3,
RO code and RW data
PASS: C entry stack alignment, clear DF, initialized data
and 5000-byte BSS verified
PASS: 34 malformed ELF cases, all allocation failures
and task-stack rollback recovered
PASS: ELF demo reaches original blocking keyboard consumer

串口的最终三行是:

1
2
3
D18 ELF32 entry=40000080 image=4332 alloc=7 invalid=34 rollback=8
D18 ELF32 data/BSS/stack OK
ELF OK data/BSS/stack; code=RO data=RW rollback=8 reaped=1

ELF 用户程序、失败回滚与累计回归输出

同一轮还重新执行 check-syscallcheck-memory。系统调用专项继续通过;页分配器重新耗尽、写入并回收 16,077 张可用页。完整 ELF 串口记录GDB 记录readelf 输出运行上限 GDB 记录131,073 字节链接拒绝记录stage2 拒绝记录 放在同名素材目录。

当前结论只覆盖上述固定环境和受限 ELF 输入集。它不证明动态 ELF、任意 BIOS 或物理机可用,也不证明数据页不可执行。第 23 篇从 FAT16 读取 ELF 时,还要重新处理可变输入在“验证完成”和“复制开始”之间发生变化的问题。

下载与复现

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

1
a29a899024cd8515aa90608666fe76f4f9fc7a47b047fccb3015c587d26989ba

解压后执行:

1
2
3
4
5
unzip os-day-18.zip
cd os-day-18
make build
make check-elf
make check-runtime-limit

附件保留完整累计项目、检查脚本和 docs/day18-evidence.md。它已经在全新临时目录解压,从空 build/ 重新执行 check-elfcheck-runtime-limit,两项均退出 0;重建的 kernel.binmbr.img 和用户 ELF 哈希与主工作区一致。工具链版本与云端额外路径见证据文件;源码版本使用附件 SHA-256 和完成后的真实提交 SHA,不使用虚构标签。

练习

  1. 构造 p_vaddr=USER_LOAD_ENDp_memsz=1 的 LOAD,说明若缺少显式上界比较,哪个无符号减法会下溢。
  2. 把数据段 PTE 的 W 位清掉但保持共享 PDE 为 RWU,观察写 .data 时的页故障错误码;再反过来只清 PDE.W,比较结果。
  3. 让第二个 LOAD 与第一个段落在同一页但字节范围不相交,思考支持它需要怎样合并权限和所有权。

上一篇:17 - 系统调用

下一篇:19 - 创建与回收进程

参考资料