从零编写操作系统 18 - ELF 加载器:把文件中的段变成用户地址空间
第 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_LOAD和PT_NULL,最多 8 个 program header; - LOAD 必须落在
[0x40000000, 0x40400000),总计不超过 8 页; - 不允许不同 LOAD 覆盖同一页;
- 不支持解释器、动态链接、重定位、TLS、共享对象和标准进程初始栈。
这不是“ELF 只能这样”,而是加载器明确承诺支持的输入集合。限制越窄,失败语义和资源上界越容易在当前教学内核里验证。
链接脚本先生成加载器能理解的文件
用户程序使用独立链接脚本显式生成两个 LOAD:
1 | |
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 | |
每个 LOAD 随后按以下顺序验证:
- 段类型和权限位属于支持集合。
filesz <= memsz,文件区间没有越过输入末尾。p_align为 0、1 或二次幂,并满足地址与文件偏移同余。- 虚拟地址位于 LOAD 窗口,长度不会回绕或越过上界。
- LOAD 按虚拟地址升序,页覆盖不与前段相交,总页数不超过 8。
- 入口落在某个带
PF_X的 LOAD 的真实内存范围内。
虚拟范围检查有一个容易漏掉的上界条件:
1 | |
如果只写最后一项,而没有先排除 vaddr >= USER_LOAD_END,USER_LOAD_END - vaddr 会发生无符号下溢。一个明显位于窗口上方的段可能因此通过检查,随后用巨大的页表下标写坏内核。这也是本篇恶意头实验要覆盖地址上界和 32 位回绕的原因。
完整验证阶段只生成最多 8 项的加载计划,不分配物理页。格式错误、范围错误和非法入口因此不会产生需要回收的半成品。
PT_LOAD 是文件区间到内存区间的映射
验证通过后,加载器先为页目录和两张页表分配页面,再逐页落实 LOAD。每张物理页先清零,然后只复制该页与文件区间相交的部分:
1 | |
这个顺序同时处理三类区域:文件中存在的代码或初始化数据、p_memsz - p_filesz 指定的零填充区,以及段首尾页没有被该段占用的字节。实现不要求段从页边界开始,但要求 p_vaddr 与 p_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 | |
内核把初始 ESP 设为 0x80000000。call 压入返回地址后,day18_main 入口满足 i386 C 调用约定的 (ESP+4)%16==0。cld 保证 C 代码不会继承未知方向标志。第 19 篇还没有实现 exit,所以 main 返回后只反复调用现有 yield,由内核的实验控制路径结束任务。
用户 C 程序检查初始化变量、5,000 字节 BSS 和八项局部栈数据,再通过第 17 篇的私有 syscall ABI 输出。它不是 Linux 用户程序,也不具备 libc。
地址空间创建是一个事务
地址空间的所有权槽从 6 个扩为 12 个。旧布局的 pages[0..5] 语义保留,保证第 16、17 篇测试继续成立;ELF 额外 LOAD 页使用剩余槽位。地址空间同时保存动态 entry、stack_top 和种类,旧 IRQ 数据标记只对固定 blob 类型执行。
创建分成三个阶段:
1 | |
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 | |
这次满足了既有扩容设计的启用条件。修改没有把 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 | |
串口的最终三行是:
1 | |
同一轮还重新执行 check-syscall 和 check-memory。系统调用专项继续通过;页分配器重新耗尽、写入并回收 16,077 张可用页。完整 ELF 串口记录、GDB 记录、readelf 输出、运行上限 GDB 记录、131,073 字节链接拒绝记录 和 stage2 拒绝记录 放在同名素材目录。
当前结论只覆盖上述固定环境和受限 ELF 输入集。它不证明动态 ELF、任意 BIOS 或物理机可用,也不证明数据页不可执行。第 23 篇从 FAT16 读取 ELF 时,还要重新处理可变输入在“验证完成”和“复制开始”之间发生变化的问题。
下载与复现
下载第 18 篇完整源码。附件 SHA-256:
1 | |
解压后执行:
1 | |
附件保留完整累计项目、检查脚本和 docs/day18-evidence.md。它已经在全新临时目录解压,从空 build/ 重新执行 check-elf 与 check-runtime-limit,两项均退出 0;重建的 kernel.bin、mbr.img 和用户 ELF 哈希与主工作区一致。工具链版本与云端额外路径见证据文件;源码版本使用附件 SHA-256 和完成后的真实提交 SHA,不使用虚构标签。
练习
- 构造
p_vaddr=USER_LOAD_END、p_memsz=1的 LOAD,说明若缺少显式上界比较,哪个无符号减法会下溢。 - 把数据段 PTE 的 W 位清掉但保持共享 PDE 为 RWU,观察写
.data时的页故障错误码;再反过来只清 PDE.W,比较结果。 - 让第二个 LOAD 与第一个段落在同一页但字节范围不相交,思考支持它需要怎样合并权限和所有权。
上一篇:17 - 系统调用。
下一篇:19 - 创建与回收进程。
参考资料
- Xinuos ELF Object File Format 4.3 DRAFT:ELF Header 与 Program Loading:ELF 头、program header、
PT_LOAD、零填充、排序、对齐和段权限。 - System V ABI Intel386 Architecture Processor Supplement, Fourth Edition:i386 ELF 与程序装载。
- Intel386 psABI 1.2,提交 20ec676cd56d41f6141037edfbeeb2b0681c6fb4:i386 函数调用、栈对齐、方向标志与标准进程初始栈。
- GNU ld 2.42:PHDRS 与 Entry Point:显式 program header 与入口控制。
- GCC 13.3:Language Standards Supported:freestanding 环境的边界。
- Intel SDM Vol. 3A,253668-085US:§5.6.1 分页权限合成,以及 32 位非 PAE 分页没有 XD 的边界。

