这一章做完,我们的系统就有了完整的用户程序执行链路:写一个独立的程序,编译成 ELF,放到磁盘上,Shell 里输入 run hello.elf,内核加载并执行它。
ELF 是什么
ELF(Executable and Linkable Format)是 Linux 可执行文件的格式。你编译出来的每个程序、/bin/ls、/usr/bin/python 都是 ELF 文件。
结构很简单:
ELF Header → 魔数、架构、入口地址
Program Headers → 每个段加载到内存哪里、从文件哪里读
.text → 代码
.data → 数据
.bss → 未初始化数据(文件里不占空间,加载时清零)
加载一个 ELF,本质上就是:按 Program Header 的指示,把文件里的数据复制到内存里,然后跳到入口地址。
先写用户程序
用户程序不能调内核函数,只能通过 syscall 和内核通信:
BITS 64
section .text
global _start
_start:
mov rax, 1 ; syscall 1 = print
lea rdi, [rel msg] ; RIP 相对寻址
syscall
mov rax, 2 ; syscall 2 = exit
syscall
section .data
msg: db "Hello from ELF!", 13, 10, 0
链接时指定加载地址 0x400000(避开内核占用的低地址区域):
ENTRY(_start)
SECTIONS {
. = 0x400000;
.text : { *(.text) }
.data : { *(.data) }
}
ELF 加载器
加载流程:
uint64_t elf_load(const char *filename) {
// 1. 读 ELF Header,验证魔数
// 2. 遍历 Program Headers
for each PT_LOAD segment:
// 3. 分配物理页,建立虚拟地址映射
for va in p_vaddr .. p_vaddr+p_memsz:
map_page(va, pmm_alloc(), 0x3)
// 4. 从文件读数据写到 p_vaddr
// 5. .bss 部分清零
// 6. 返回 e_entry
}
关键在第3步:用户程序的地址(0x400000)在内核页表里默认没有映射,直接写会 Page Fault。必须先用 pmm_alloc 分配物理页,再用 map_page 建立映射。
Shell 里执行:
uint64_t entry = elf_load(argv[1]);
void (*fn)(void) = (void (*)(void))entry;
fn(); // 跳转执行
跑起来
> ls
hello.elf
hello.txt
> run hello.elf
loading hello.elf...
Hello from ELF!
[kernel] process exited
>
从 ext2 读 ELF → 解析 Program Header → 分配物理页 → 写入内存 → 跳转 → syscall 打印 → 返回 Shell。整条链路通了。
两个坑
坑1:Page Fault,写 0x400000
ELF 加载地址是 0x400000,内核页表没有这个地址的映射,写入直接 fault。
修法:加载每个段之前,遍历 p_vaddr ~ p_vaddr+p_memsz,每 4KB 分配一个物理页并映射。
坑2:文件名被截断(h.elf 而不是 hello.elf)
串口缓冲区里有上次命令残留的字节,readline 开始时把它们读走,导致后续字符错位。
修法:readline 前调 serial_flush(),把缓冲区里已到达的字符清掉。
和真实 Linux 的差距
我们跑用户程序是在 Ring 0(内核态),共用内核页表。真实 Linux:
- 用户程序跑在 Ring 3,权限受限
- 每个进程有独立页表,进程间内存隔离
- ELF 支持动态链接(
.so),大部分程序不是静态链接的
但核心逻辑是一样的:读 Program Header,按指示加载,跳入口。
下一章
十六章走完,整个系统的主要组件都有了。下一章做整体回顾:从第1章到第16章,我们建了什么,每一层的作用是什么。