前言:从"平坦二进制"到"结构化可执行文件"
在前面的章节中,我们的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程序一样编程"!