《从零手写操作系统 (10):ELF加载器与exec——让内核学会“运行程序”》
前言:从“硬编码Blob”到“通用可执行文件”
在上一章中,我们成功实现了用户态隔离和系统调用,但用户程序是以原始二进制blob的形式硬编码嵌入内核镜像的。这意味着每添加或修改一个用户程序,都必须重新编译整个内核。这显然不是一个通用操作系统应有的形态。
本章我们将实现ELF(Executable and Linkable Format)解析器。ELF是Unix/Linux世界的标准可执行文件格式,包含了代码段、数据段、符号表等完整的元信息。通过解析ELF头部,内核可以动态地将程序加载到正确的虚拟地址、设置正确的内存权限,并最终跳转到入口点执行。这是让你的OS能够运行gcc编译出的标准程序的必经之路。
本章里程碑:
- ✅ 理解ELF32文件结构与Program Header语义
- ✅ 实现ELF头部校验与段加载逻辑
- ✅ 为每个LOAD段分配物理页并建立用户态映射
- ✅ 实现
sys_exec系统调用:替换当前进程映像 - ✅ 加载并运行外部编译的标准ELF用户程序
- ✅ 验证不同段的内存权限(R/W/X)正确生效
核心概念:ELF结构与按需加载
ELF文件的解剖学
一个ELF可执行文件由以下关键部分组成:
| 结构 | 作用 | 关键字段 |
|---|---|---|
| ELF Header | 文件魔数、类型、入口点、PH表位置 | e_entry, e_phoff, e_phnum, e_phentsize |
| Program Header Table | 描述运行时内存布局的段列表 | p_type, p_vaddr, p_filesz, p_memsz, p_flags |
| LOAD Segment | 需要加载到内存的代码/数据段 | PT_LOAD=1, PF_R/PF_W/PF_X |
| Section Header Table | 链接/调试用的节区信息(运行时不需要) | 本章忽略 |
⚠️ 关键区分:
p_filesz是文件中实际占用的字节数,p_memsz是加载到内存后应占用的大小。当p_memsz > p_filesz时,多出的部分必须用零填充(典型场景:.bss段)。忘记清零BSS是最常见的ELF加载bug。
exec的语义:替换而非创建
exec不是创建新进程,而是替换当前进程的地址空间。其完整流程为:
- 读取并校验ELF头
- 销毁当前进程的所有用户态页表映射
- 为每个LOAD段分配新物理页、建立新映射、拷贝内容
- 重置用户栈、EIP、寄存器状态
- 返回用户态执行新程序
在本章简化实现中,我们先实现load_elf()作为独立函数供fork_user_process使用,下一章再实现完整的sys_exec语义。
实战代码
ELF数据结构定义
// elf.h
#ifndef ELF_H
#define ELF_H
#include <stdint.h>
#define ELF_MAGIC 0x464C457F // "\x7FELF"
#define ET_EXEC 2 // 可执行文件
#define EM_386 3 // x86架构
#define PT_LOAD 1
#define PF_X 0x1
#define PF_W 0x2
#define PF_R 0x4
typedef struct {
uint32_t e_magic;
uint8_t e_class; // 1=32bit
uint8_t e_data; // 1=little-endian
uint8_t e_version;
uint8_t e_osabi;
uint8_t e_abiversion;
uint8_t e_pad[7];
uint16_t e_type;
uint16_t e_machine;
uint32_t e_version2;
uint32_t e_entry; // ★ 入口点虚拟地址
uint32_t e_phoff; // ★ Program Header表文件偏移
uint32_t e_shoff;
uint32_t e_flags;
uint16_t e_ehsize;
uint16_t e_phentsize; // ★ 单个PH条目大小
uint16_t e_phnum; // ★ PH条目数量
uint16_t e_shentsize;
uint16_t e_shnum;
uint16_t e_shstrndx;
} __attribute__((packed)) elf_header_t;
typedef struct {
uint32_t p_type;
uint32_t p_offset; // 段在文件中的偏移
uint32_t p_vaddr; // ★ 目标虚拟地址
uint32_t p_paddr; // 忽略(用于嵌入式)
uint32_t p_filesz; // ★ 文件中段大小
uint32_t p_memsz; // ★ 内存中段大小
uint32_t p_flags; // ★ R/W/X权限
uint32_t p_align;
} __attribute__((packed)) elf_program_header_t;
// 返回值:成功返回入口点VA,失败返回0
uint32_t elf_load(const uint8_t *elf_data, uint32_t elf_size);
#endif
ELF加载器核心实现
// elf.c
#include "elf.h"
#include "paging.h"
#include "memory.h"
#include "serial.h"
static uint32_t flags_to_page(uint32_t pflags) {
uint32_t flags = PTE_PRESENT | PTE_USER;
if (pflags & PF_W) flags |= PTE_WRITABLE;
// 注意:x86分页无NX位(需PAE),此处仅控制R/W
return flags;
}
uint32_t elf_load(const uint8_t *elf_data, uint32_t elf_size) {
// === 1. 校验ELF头 ===
if (elf_size < sizeof(elf_header_t)) {
kprintf("[ELF] File too small\n");
return 0;
}
const elf_header_t *ehdr = (const elf_header_t *)elf_data;
if (ehdr->e_magic != ELF_MAGIC) {
kprintf("[ELF] Bad magic: 0x%x\n", ehdr->e_magic);
return 0;
}
if (ehdr->e_class != 1 || ehdr->e_data != 1 || ehdr->e_type != ET_EXEC) {
kprintf("[ELF] Not a 32-bit LE executable\n");
return 0;
}
if (ehdr->e_machine != EM_386) {
kprintf("[ELF] Not x86 ELF\n");
return 0;
}
// 校验PH表在文件范围内
uint32_t ph_end = ehdr->e_phoff + ehdr->e_phnum * ehdr->e_phentsize;
if (ph_end > elf_size) {
kprintf("[ELF] PH table out of bounds\n");
return 0;
}
kprintf("[ELF] Loading entry=0x%x phnum=%d\n", ehdr->e_entry, ehdr->e_phnum);
// === 2. 遍历并加载每个LOAD段 ===
for (uint32_t i = 0; i < ehdr->e_phnum; i++) {
const elf_program_header_t *phdr =
(const elf_program_header_t *)(elf_data + ehdr->e_phoff + i * ehdr->e_phentsize);
if (phdr->p_type != PT_LOAD) continue;
// 边界检查
if (phdr->p_offset + phdr->p_filesz > elf_size) {
kprintf("[ELF] Segment %d exceeds file\n", i);
return 0;
}
if (phdr->p_vaddr >= 0xC0000000) {
kprintf("[ELF] Segment %d in kernel space!\n", i);
return 0;
}
uint32_t page_flags = flags_to_page(phdr->p_flags);
uint32_t vaddr_start = phdr->p_vaddr & ~0xFFF;
uint32_t vaddr_end = (phdr->p_vaddr + phdr->p_memsz + 0xFFF) & ~0xFFF;
kprintf("[ELF] LOAD: VA=0x%x filesz=%d memsz=%d flags=%c%c%c\n",
phdr->p_vaddr, phdr->p_filesz, phdr->p_memsz,
(phdr->p_flags & PF_R) ? 'R' : '-',
(phdr->p_flags & PF_W) ? 'W' : '-',
(phdr->p_flags & PF_X) ? 'X' : '-');
// 逐页映射并填充
for (uint32_t va = vaddr_start; va < vaddr_end; va += PAGE_SIZE) {
uint32_t phys = pmm_alloc_page();
if (!phys) {
kprintf("[ELF] OOM at VA=0x%x\n", va);
return 0;
}
// 先在内核地址空间临时映射以便写入内容
// (高半核下可直接用线性映射,此处假设已有恒等或递归映射)
uint8_t *kva = (uint8_t *)phys; // 简化:利用恒等映射窗口
memset(kva, 0, PAGE_SIZE); // ★ 清零整页(含BSS)
// 计算本页需要从文件拷贝的范围
uint32_t seg_offset_in_page = 0;
uint32_t copy_src = 0;
uint32_t copy_len = 0;
if (va < phdr->p_vaddr + phdr->p_filesz) {
uint32_t offset_in_seg = va - phdr->p_vaddr;
if ((int32_t)offset_in_seg < 0) {
// 页起始在段vaddr之前(首段未对齐情况)
seg_offset_in_page = phdr->p_vaddr - va;
copy_src = phdr->p_offset;
copy_len = MIN(PAGE_SIZE - seg_offset_in_page, phdr->p_filesz);
} else {
seg_offset_in_page = 0;
copy_src = phdr->p_offset + offset_in_seg;
copy_len = MIN(PAGE_SIZE, phdr->p_filesz - offset_in_seg);
}
memcpy(kva + seg_offset_in_page, elf_data + copy_src, copy_len);
}
// 超出filesz的部分已被memset清零 → BSS自动处理 ✓
paging_map_page(va, phys, page_flags);
}
}
kprintf("[ELF] Load complete. Entry=0x%x\n", ehdr->e_entry);
return ehdr->e_entry;
}
集成到用户进程创建
// 修改 user_process.c
process_t *fork_user_process_from_elf(const uint8_t *elf_data, uint32_t elf_size, const char *name) {
process_t *proc = process_create(NULL, name);
if (!proc) return NULL;
// 加载ELF到新进程地址空间
// TODO: 切换CR3到新进程页表后再调用elf_load()
// 简化版:先用当前页表加载,后续章节实现地址空间隔离
uint32_t entry = elf_load(elf_data, elf_size);
if (!entry) {
kprintf("[USER] ELF load failed for %s\n", name);
proc->state = PROC_DEAD;
return NULL;
}
// 构造iret栈帧(同第09章,EIP改为ELF入口点)
uint32_t *kstack = (uint32_t*)&proc->kernel_stack[KERNEL_STACK_SIZE];
*(--kstack) = USER_DS;
*(--kstack) = USER_STACK_TOP;
*(--kstack) = 0x202;
*(--kstack) = USER_CS;
*(--kstack) = entry; // ★ ELF入口点
proc->ctx.esp = (uint32_t)kstack;
proc->ctx.eip = (uint32_t)do_iret_to_user;
tss_set_kernel_stack((uint32_t)&proc->kernel_stack[KERNEL_STACK_SIZE]);
return proc;
}
用户程序编译规范
# user/Makefile
CC = gcc
LD = ld
CFLAGS = -m32 -nostdlib -static -fno-pie -no-pie
LDFLAGS = -T user/linker.ld -m elf_i386
user/linker.ld:
@echo 'ENTRY(_start)' > $@
@echo 'SECTIONS {' >> $@
@echo ' . = 0x08000000;' >> $@
@echo ' .text : { *(.text*) }' >> $@
@echo ' .rodata : { *(.rodata*) }' >> $@
@echo ' .data : { *(.data*) }' >> $@
@echo ' .bss : { *(.bss*) *(COMMON) }' >> $@
@echo '}' >> $@
%.elf: %.c user/linker.ld
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
📌 为什么必须
-no-pie? 现代gcc默认生成PIE(Position Independent Executable),其类型为ET_DYN而非ET_EXEC,且入口点是相对偏移。我们的简易加载器只支持ET_EXEC的绝对地址。生产级OS应同时支持PIE+ASLR,但那是后续章节的内容。
关键细节解析
1. 为什么BSS必须显式清零?
ELF文件中.bss段的p_filesz=0但p_memsz>0。文件中不包含任何BSS数据,加载器必须保证这段内存在运行时全为零。我们的实现通过对每个分配的物理页执行memset(0)来统一处理——无论该页包含代码尾部、纯BSS还是两者混合,清零操作都是安全的。
2. 为什么加载时需要临时内核映射?
在新进程的用户态页表尚未激活时(CR3仍指向内核页表),我们无法直接通过用户VA写入数据。解决方案有三种:
- 方案A:利用恒等映射窗口(本章采用,最简单)
- 方案B:切换到新页表,在内核空间中保留递归映射
- 方案C:先在内核堆上组装完整镜像,再逐页拷贝
生产级内核通常采用方案B,但方案A在教学阶段足以验证ELF加载逻辑的正确性。
3. 段对齐与跨页边界的处理
ELF段的p_vaddr不一定4KB对齐。一个段可能跨越多个页面,且首个页面的有效数据不从页首开始。代码中的seg_offset_in_page计算正是处理这种情况:当va < p_vaddr时,说明当前页的前部不属于该段,拷贝应从页内偏移处开始。
调试Checklist:ELF加载崩溃排查
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| ELF校验失败但文件确实有效 | 结构体未packed / 字段偏移错误 | sizeof(elf_header_t)应为52;用xxd对比文件头与结构体dump |
| 用户程序#PF在入口点 | 入口点所在页未映射 / 权限缺X位 | QEMU info mem确认VA范围已映射;检查PTE标志 |
| 全局变量值异常 | BSS未清零 / 段拷贝偏移计算错误 | 在加载后dump目标VA内存;对比objdump -s输出的预期内容 |
| 加载过程中OOM | PMM耗尽 / 未释放旧映射 | 检查PMM剩余页数;确认每次alloc都有对应free路径 |
| 用户程序行为随机错乱 | 使用了PIE二进制 / 重定位未处理 | file xxx.elf确认类型为"executable"而非"shared object";确保编译加了-no-pie |
🔧 黄金法则:ELF加载器的最佳调试工具是
readelf -l program.elf。在写加载代码之前,先用readelf打印出所有LOAD段的精确vaddr/filesz/memsz/flags,手算出每个段覆盖的页面范围和拷贝参数,再对照代码逻辑验证。不要靠猜。
本章小结与下一步
今天我们让内核学会了“读懂”标准可执行文件:
- ✅ 理解了ELF32格式的完整结构与Program Header语义
- ✅ 实现了健壮的段加载、BSS清零和权限设置
- ✅ 将外部编译的用户程序成功加载到用户地址空间
- ✅ 建立了从源码到运行的完整工具链闭环
从此,你的操作系统不再依赖硬编码的二进制blob,而是能够加载任意符合ELF标准的可执行文件。这是迈向通用操作系统的决定性一步——你的OS现在拥有了自己的“应用程序生态”入口。
下一章预告:《文件系统初探:initrd与VFS抽象层》
目前ELF数据仍是嵌入内核镜像的数组。下一章将实现初始内存盘(initrd)、构建虚拟文件系统(VFS)抽象层、实现open/read/close系统调用,让你的OS第一次拥有真正的“文件”概念。
参考资料
- System V ABI i386 Supplement - ELF Specification
- OSDev Wiki - ELF Tutorial / Loading an ELF Binary
- Linux Kernel:
fs/binfmt_elf.c(load_elf_binary) - xv6 Source:
exec.c - 本系列完整代码:[你的GitHub仓库链接](Commit:
e1l0f0l)
📝 作者注:这是《从零手写操作系统》系列的第10篇。ELF加载器是第一个让你同时面对“文件格式规范”和“内存管理”双重复杂性的章节。如果你被段对齐和跨页拷贝折磨得痛苦不堪,请记住:Linux内核的
load_elf_binary有超过800行代码来处理各种边界情况,你现在的简化版已经是了不起的成就。建议先用一个只有.text段的极简ELF验证主流程,再逐步加入.data/.bss支持。下一章,我们让内核学会“读文件”!


openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)