前言:从“硬编码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不是创建新进程,而是替换当前进程的地址空间。其完整流程为:

  1. 读取并校验ELF头
  2. 销毁当前进程的所有用户态页表映射
  3. 为每个LOAD段分配新物理页、建立新映射、拷贝内容
  4. 重置用户栈、EIP、寄存器状态
  5. 返回用户态执行新程序

        在本章简化实现中,我们先实现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输出的预期内容
加载过程中OOMPMM耗尽 / 未释放旧映射检查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支持。下一章,我们让内核学会“读文件”!

Logo

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

更多推荐