前言:从“平坦二进制”到“结构化可执行文件”
在前面的章节中,我们的sys_exec只能加载简单的平坦二进制(flat binary):把整个文件读入内存,跳转到入口地址。这种方式要求代码、数据、BSS必须按固定顺序紧密排列,没有段保护,不支持符号调试,更无法兼容GCC/Clang的标准输出。现代操作系统与用户程序的契约是ELF(Executable and Linkable Format)。
ELF不是一坨字节流,而是一个自描述的容器:它告诉内核哪些区域是可执行代码、哪些是只读数据、哪些需要零初始化、入口点在哪里、栈如何设置。本章我们将实现一个完整的ELF32加载器,让你的OS首次能够运行标准工具链编译的程序,并为未来的动态链接和用户态调试打下基石。
本章里程碑:
- ✅ 解析ELF Header与Program Header Table
- ✅ 按PT_LOAD段描述符精确映射内存(含权限设置)
- ✅ 正确处理BSS段零填充与页对齐
- ✅ 提取入口点并构建初始用户栈(argc/argv/envp)
- ✅ 重构sys_exec为完整ELF加载流程
- ✅ 验证:运行GCC编译的带多段、BSS、命令行参数的程序
核心概念:ELF的“声明式加载”哲学
ELF不是指令序列,而是“内存布局蓝图”
平坦二进制的加载逻辑是隐式的:“文件内容 = 内存内容”。ELF的加载逻辑是显式的:文件内容与内存内容可以完全不同。一个PT_LOAD段可能:
- 文件中占100字节,内存中占4KB(剩余部分为零填充的BSS)
- 文件偏移0x1000映射到虚拟地址0x08048000
- 标记为RX(可读可执行),而另一个段标记为RW(可读可写)
⚠️关键洞察:ELF加载器的本质是一个内存布局解释器。它不关心文件里存的是什么机器码,只关心“这段数据应该放在哪个虚拟地址、占多大空间、有什么权限”。这种解耦使得同一份ELF可以在不同基址加载(ASLR)、可以按需分页调入(demand paging)、可以共享只读段(shared libraries)。
Program Header vs Section Header
这是初学者最常混淆的概念:
- Program Header (PHDR):面向运行时。描述加载到内存的段(PT_LOAD, PT_DYNAMIC等)。内核只看这个。
- Section Header (SHDR):面向链接时/调试时。描述编译产物中的节(.text, .data, .symtab等)。加载器完全忽略它。
你的ELF加载器不应该读取或依赖任何Section Header。一个被strip过的ELF没有SHDR但仍然可以完美运行。如果你的加载代码引用了.text或.bss节名,那就是设计错误。
BSS的特殊性:文件中不存在,内存中必须存在
BSS(Block Started by Symbol)存储未初始化的全局变量。它在ELF文件中不占空间(否则1MB的零数组会让文件膨胀1MB),但加载后必须在内存中占据对应大小的全零区域。这通过PT_LOAD段的p_filesz < p_memsz来表达:
[p_offset ... p_offset+p_filesz) → 从文件拷贝 [p_offset+p_filesz ... p_offset+p_memsz) → memset(0)忘记零填充BSS是最常见的ELF加载bug,表现为全局变量读到随机垃圾值。
实战代码
ELF32结构体定义
// include/elf.h #define EI_NIDENT 16 #define ET_EXEC 2 #define PT_LOAD 1 #define PF_X 0x1 #define PF_W 0x2 #define PF_R 0x4 typedef struct __attribute__((packed)) { uint8_t e_ident[EI_NIDENT]; uint16_t e_type; // ET_EXEC=2 uint16_t e_machine; // EM_386=3 uint32_t e_version; uint32_t e_entry; // ★ 入口点虚拟地址 uint32_t e_phoff; // Program Header表文件偏移 uint32_t e_shoff; // Section Header表偏移(加载器忽略) uint32_t e_flags; uint16_t e_ehsize; uint16_t e_phentsize; // 单个PHDR大小 uint16_t e_phnum; // ★ PHDR数量 uint16_t e_shentsize; uint16_t e_shnum; uint16_t e_shstrndx; } Elf32_Ehdr; typedef struct __attribute__((packed)) { uint32_t p_type; // PT_LOAD=1 uint32_t p_offset; // 文件偏移 uint32_t p_vaddr; // ★ 目标虚拟地址 uint32_t p_paddr; // 物理地址(忽略) uint32_t p_filesz; // 文件中大小 uint32_t p_memsz; // ★ 内存中大小(>=filesz) uint32_t p_flags; // PF_R/PF_W/PF_X uint32_t p_align; // 对齐要求 } Elf32_Phdr; // 验证ELF魔数与基本兼容性 static inline int elf_check_header(const Elf32_Ehdr *ehdr) { return ehdr->e_ident[0] == 0x7F && ehdr->e_ident[1] == 'E' && ehdr->e_ident[2] == 'L' && ehdr->e_ident[3] == 'F' && ehdr->e_ident[4] == 1 && // 32-bit ehdr->e_ident[5] == 1 && // Little-endian ehdr->e_type == ET_EXEC && ehdr->e_machine == 3; // x86 }ELF加载核心逻辑
// kernel/exec.c #include "elf.h" #include "memory.h" #include "vfs.h" #include "process.h" // ★ 将单个PT_LOAD段加载到当前进程地址空间 static int load_segment(int fd, const Elf32_Phdr *phdr) { if (phdr->p_memsz == 0) return 0; // 空段跳过 uint32_t vaddr_start = phdr->p_vaddr; uint32_t vaddr_end = phdr->p_vaddr + phdr->p_memsz; // ★ 页对齐:段可能不从页边界开始 uint32_t map_start = PAGE_ALIGN_DOWN(vaddr_start); uint32_t map_end = PAGE_ALIGN_UP(vaddr_end); uint32_t map_size = map_end - map_start; // 分配用户态页面(简化:一次性分配整个段覆盖的页范围) for (uint32_t va = map_start; va < map_end; va += PAGE_SIZE) { if (!map_user_page(va, PTE_USER | PTE_PRESENT | ((phdr->p_flags & PF_W) ? PTE_WRITE : 0))) { return -1; // ENOMEM } } // ★ 先清零整个映射区域(确保BSS和页内padding为零) memset((void *)map_start, 0, map_size); // ★ 从文件拷贝有效内容 if (phdr->p_filesz > 0) { vfs_lseek(fd, phdr->p_offset, SEEK_SET); ssize_t n = vfs_read(fd, (void *)vaddr_start, phdr->p_filesz); if (n != (ssize_t)phdr->p_filesz) return -1; } // p_filesz之后的区域已由memset清零 → BSS自动满足 return 0; } // ★ 完整ELF加载入口 int elf_load(const char *path, uint32_t *out_entry) { int fd = vfs_open(path, O_RDONLY); if (fd < 0) return -1; // 1. 读取并验证ELF Header Elf32_Ehdr ehdr; if (vfs_read(fd, &ehdr, sizeof(ehdr)) != sizeof(ehdr) || !elf_check_header(&ehdr)) { vfs_close(fd); return -1; } // 2. 读取Program Header Table uint32_t phdr_total = ehdr.e_phnum * ehdr.e_phentsize; Elf32_Phdr *phdrs = kmalloc(phdr_total); if (!phdrs) { vfs_close(fd); return -1; } vfs_lseek(fd, ehdr.e_phoff, SEEK_SET); if (vfs_read(fd, phdrs, phdr_total) != (ssize_t)phdr_total) { kfree(phdrs); vfs_close(fd); return -1; } // 3. ★ 释放旧地址空间,加载所有PT_LOAD段 process_destroy_address_space(current_process); for (uint16_t i = 0; i < ehdr.e_phnum; i++) { if (phdrs[i].p_type == PT_LOAD) { if (load_segment(fd, &phdrs[i]) < 0) { kprintf("[ELF] Failed to load segment %d\n", i); kfree(phdrs); vfs_close(fd); return -1; } } } kfree(phdrs); vfs_close(fd); *out_entry = ehdr.e_entry; return 0; }用户栈构建与sys_exec重构
// kernel/exec.c - 栈初始化 // ★ 按照System V i386 ABI构建初始栈 // 高地址 ┌─────────────┐ // │ env strings │ // │ argv strings│ // │ padding │ // │ envp[n]=NULL│ // │ ... │ // │ envp[0] │ // │ argv[argc]=NULL│ // │ ... │ // │ argv[0] │ // │ argc │ ← ESP // 低地址 └─────────────┘ static uint32_t setup_user_stack(int argc, char **argv, char **envp) { uint32_t sp = USER_STACK_TOP; // 例如 0xBFFFF000 // 1. 拷贝字符串到栈顶下方(从高到低) // 简化:假设总字符串长度<4KB char *str_area = (char *)(sp - 4096); uint32_t str_offset = 0; uint32_t argv_ptrs[64], envp_ptrs[64]; for (int i = 0; i < argc && argv[i]; i++) { int len = strlen(argv[i]) + 1; memcpy(str_area + str_offset, argv[i], len); argv_ptrs[i] = (uint32_t)(str_area + str_offset); str_offset += len; } argv_ptrs[argc] = 0; // NULL terminator int envc = 0; if (envp) { while (envp[envc]) { int len = strlen(envp[envc]) + 1; memcpy(str_area + str_offset, envp[envc], len); envp_ptrs[envc] = (uint32_t)(str_area + str_offset); str_offset += len; envc++; } } envp_ptrs[envc] = 0; // 2. 压入指针数组和argc(向低地址增长) uint32_t *stack = (uint32_t *)((uint32_t)str_area & ~0xF); // 16字节对齐 stack -= (envc + 1); // envp pointers + NULL memcpy(stack, envp_ptrs, (envc + 1) * sizeof(uint32_t)); uint32_t *envp_base = stack; stack -= (argc + 1); // argv pointers + NULL memcpy(stack, argv_ptrs, (argc + 1) * sizeof(uint32_t)); uint32_t *argv_base = stack; stack -= 3; // envp, argv, argc stack[0] = argc; stack[1] = (uint32_t)argv_base; stack[2] = (uint32_t)envp_base; return (uint32_t)stack; } // ★ 重构后的sys_exec int sys_exec(interrupt_frame_t *frame) { const char *path = (const char *)frame->ebx; char **argv = (char **)frame->ecx; char **envp = (char **)frame->edx; // 计算argc int argc = 0; if (argv) while (argv[argc]) argc++; uint32_t entry; if (elf_load(path, &entry) < 0) return -1; uint32_t new_sp = setup_user_stack(argc, argv, envp); // ★ 修改trap frame:iret后跳转到ELF入口点 frame->eip = entry; frame->esp = new_sp; frame->cs = USER_CS | 3; frame->ss = USER_DS | 3; frame->eflags = 0x202; // IF enabled return 0; // 实际不会返回 }关键细节解析
1. 为什么必须先清零再拷贝,而非只拷贝filesz部分?
因为p_vaddr不一定页对齐。如果段的vaddr=0x08048120、filesz=0x80,而你只分配了0x08048000开始的页并拷贝0x80字节,那么0x08048000~0x0804811F之间的120字节是上一段残留或随机内存。先memset整个页对齐范围为零,再拷贝有效内容,保证了:①段前padding为零;②BSS为零;③段后padding为零。这是安全性的基本要求,也是很多自制OS出现诡异crash的根源。
2. 为什么加载器要销毁旧地址空间?
exec的语义是“用新程序完全替换当前进程”。如果保留旧映射,新程序的段可能与旧段重叠,导致数据污染或权限提升漏洞。正确的exec必须是原子的:要么完全成功(旧空间已销毁、新空间已建立),要么完全失败(旧空间不变)。本章简化为非原子实现,生产级内核会在临时空间中加载成功后才切换页表。
3. 为什么栈要16字节对齐且参数以特定顺序排列?
这是System V i386 ABI的硬性规定。GCC生成的代码假设ESP在函数入口处满足(ESP+4) % 16 == 0,SSE指令(movaps等)要求16字节对齐操作数。argc/argv/envp的布局是C运行时启动代码(crt0)解析的唯一格式。偏离ABI意味着你的OS只能运行自己手写的汇编程序,无法兼容任何标准编译器输出。
调试Checklist:ELF加载器排查
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| elf_check_header失败 | 文件未正确读取/struct未packed/64位ELF误用32位头 | hex dump文件前16字节确认magic;用readelf -h prog验证e_ident字段;确认__attribute__((packed)) |
| 段加载后#PF | 页未映射/权限不足/vaddr超出用户空间 | kprintf每个PT_LOAD的vaddr/memsz/flags;确认map_user_page覆盖了PAGE_ALIGN_DOWN~UP范围;检查PTE_USER位 |
| 全局变量值异常 | BSS未零填充/filesz拷贝覆盖了BSS区域 | 对比p_filesz与p_memsz;确认memset在memcpy之前执行;用GDB attach检查BSS地址内容 |
| 程序启动即崩溃 | 栈未对齐/argc/argv布局错误/入口点错误 | 用readelf -h prog确认e_entry;在crt0入口设断点dump ESP/EAX;手动构造最小测试程序(无libc)验证栈布局 |
| 部分段未加载 | p_type判断遗漏/phdr读取偏移错误 | dump所有phdr的p_type值;确认e_phoff和e_phentsize正确;注意PT_INTERP等非LOAD段应跳过而非报错 |
| exec后旧内存仍可访问 | 地址空间未销毁/页表未刷新 | 确认process_destroy_address_space调用了tlb_flush();检查CR3是否切换到新页目录 |
🔧黄金法则:ELF调试的终极武器是宿主机的readelf/objdump。对你的测试程序执行
readelf -l prog # 查看Program Header:vaddr, filesz, memsz, flags readelf -S prog # 查看Section Header(仅用于理解,加载器不用) objdump -d prog # 反汇编确认入口点代码 hexdump -C prog | head # 验证文件头部原始字节永远不要凭感觉猜测ELF内部结构。当你的加载器行为异常时,先用readelf确认文件的真实布局,再对照你的代码逐步验证。推荐创建一个极简测试程序(
int main(){return 42;},静态链接,strip),排除libc和动态链接的干扰。
本章小结与下一步
今天我们完成了操作系统与用户程序之间最重要的桥梁:
- ✅ 实现了完整的ELF32解析与PT_LOAD段加载
- ✅ 正确处理了BSS零填充、页对齐、权限映射
- ✅ 按System V ABI构建了初始用户栈
- ✅ sys_exec现在能够运行GCC编译的标准ELF可执行文件
从此,你的操作系统拥有了与现代工具链对话的能力。当你第一次用gcc -static -nostdlib hello.c -o hello编译出ELF,然后在自制Shell中执行它看到输出时,你见证的是OS与编译器之间数十年协同演化的协议在你的手中重现。
下一章预告:《用户态C运行时:从裸ELF到printf》
当前的用户程序只能用内联汇编syscall。下一章将实现mini libc:封装syscall为C函数、提供malloc/free、实现printf格式化输出,让你能用纯C编写功能完整的用户程序,彻底告别内联汇编时代。
参考资料
- System V Application Binary Interface (i386): https://refspecs.linuxfoundation.org/elf/abi386-4.pdf
- ELF Specification: System V Application Binary Interface - DRAFT
- Linux Kernel:
fs/binfmt_elf.c - xv6 Source:
kernel/exec.c - 本系列完整代码:[你的GitHub仓库链接](Commit:
e1l2f3l)
📝作者注:这是《从零手写操作系统》系列的第20篇。ELF加载器是整个教程中规范依赖性最强的章节。你的代码是否正确,不取决于逻辑是否优美,而取决于是否精确符合ABI文档的每一个字节。建议先加载一个只有单个PT_LOAD段的极简ELF,确认基本流程跑通后再处理多段、BSS和对齐边界情况。ELF的正确性是用readelf逐字段验证出来的,不是用试错猜出来的。下一章,我们让用户程序真正“像C程序一样编程”!