Rust OS 入门:ELF 与程序内存布局——从文件里的段到运行时的堆栈

编译出来的可执行文件里到底长什么样?它和程序运行时的内存(Stack、Heap、.bss……)又是什么关系?

本文是《Writing an OS in Rust》(https://os.phil-opp.com/)入门系列的概念补充篇,展开上一篇《从按下电源键到内核接管》里”bootimage 背后到底发生了什么”一节点到为止的部分——ELF 文件内部长什么样、bootloader 怎么把它的各段加载进内存;同时接上《从 main 到 _start》里的链接器分工。核心是下面这张经常被画错的内存布局图。

📚 基本概念速读

名称 定义 省流
ELF Executable and Linkable Format,Linux 可执行/目标文件格式 文件里的代码+数据容器
Section ELF 文件里的逻辑段(.text/.data/.bss…),链接视角 链接时按内容分类
Segment 加载视角的分组,由 Program Header 描述 加载时按权限打包
Program Header 描述哪些字节该加载到哪个地址的表 loader 的施工图
链接器(linker) 把多个 .o 合并成可执行文件并分配地址 文件建筑师
加载器(loader) 按 Program Header 把文件搬进内存、跳转入口 文件搬运工
.bss 未初始化数据段,文件里不占空间,加载时清零 零初始化
VMA Virtual Memory Address,段要被加载到的虚拟地址 段的目标门牌号
Stack / Heap 运行时才存在的内存区域 不在 ELF 文件里

🧩 ELF:文件与内存之间的翻译层

先建立一个关键直觉:

可执行文件不是内存的镜像,而是一份待翻译的图纸。 它描述的是一种中间状态——既不等于磁盘上的字节流,也不等于运行时的内存。

ELF(Executable and Linkable Format,可执行与可链接格式) 就是 Linux 世界里这份图纸的标准格式。你的 Rust 程序、C 程序、内核,只要跑在 Linux 上,编译产物几乎都是 ELF。

同一个 ELF 格式,按用途分三种类型:

e_type 名称 例子 谁用
ET_REL 可重定位文件 main.o 链接器
ET_EXEC / ET_DYN 可执行文件 blog_os、/bin/ls 加载器
ET_DYN 共享库 libc.so 加载器/动态链接器

(e_type 是 ELF header 里的一个字段,后面会看到。)

🏭 从源码到 ELF:两步走

以 cargo build 为例,背后其实有两次翻译:

1
2
3
4
5
6
7
8
9
main.rs / lib.rs
│
│ ① 编译器(rustc)
▼
main.o ← 一个 ET_REL 文件
│
│ ② 链接器(ld / rust-lld)
▼
rust_os ← 可执行 ELF(或 kernel ELF)

第一步编译器只负责把单个源文件变成目标文件(.o),里面还留着大量待填空的符号引用;第二步链接器(linker) 把多个 .o 和库合并起来,解析符号、分配地址,最终产出可执行文件。这就是上一篇《从 main 到 _start》里”链接器解析符号、确定 _start 地址”那一幕的完整背景。

🧱 ELF 文件内部:header 与 sections

一个可执行 ELF 文件(用 readelf -h 可以看)最前面是 ELF header,相当于文件的身份证+目录:

1
2
3
4
5
6
7
8
ELF header
├── 魔数 0x7F 'E' 'L' 'F' ← 让 loader 认出这是 ELF
├── class(32/64 位)
├── e_type(ET_REL/ET_EXEC/ET_DYN)
├── e_machine(x86_64 / aarch64 ...)
├── e_entry ← 入口点地址,_start 在这
├── e_phoff ← Program Header Table 的位置
└── e_shoff ← Section Header Table 的位置

文件主体是一系列 sections(节),按内容分类存放:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
可执行 ELF(文件视角)
┌────────────────────────────────┐
│ ELF header │ ← 魔数/架构/入口点/段表位置
├────────────────────────────────┤
│ .text │ ← 机器码
├────────────────────────────────┤
│ .rodata │ ← 只读常量
├────────────────────────────────┤
│ .data │ ← 已初始化变量
├────────────────────────────────┤
│ .bss │ ← 未初始化变量
├────────────────────────────────┤
│ .symtab / .strtab / .debug │ ← 符号表、字符串表、调试信息
├────────────────────────────────┤
│ Section Header Table │ ← 各 section 的名字/地址/大小
└────────────────────────────────┘
section 全称/含义 装什么 例
.text 代码 编译出的指令 _start 的函数体
.rodata read-only data 只读常量 字符串字面量
.data 已初始化数据 有初值的全局变量 static X: u32 = 42;
.bss block started by symbol 无初值的全局变量 static mut Y: u64;

为什么 .bss 特殊? 因为它的内容全是零——既然全是零,就没必要真的在文件里存几千字节的 0,文件里只记一个大小。加载时 loader 负责把这块内存清零。这也是零初始化(zero initialized)的真正含义:不是编译期填零,而是加载期清零。

🔄 Sections vs Segments:链接视角 vs 加载视角

这是理解 ELF 最绕、也最关键的一点:

同一个文件里有两张表描述它自己:Section Header Table 按内容类型切(链接器用);Program Header Table 按加载方式切(loader 用)。前者叫 sections,后者叫 segments(段)。

链接器关心 .text 里有哪些符号、.data 里有哪些变量,所以它按 section 工作;loader 不关心符号,只关心哪些字节、以什么权限、加载到哪个地址,所以它按 segment 工作。

多个相邻 section 往往被合并成一个 segment——例如把 .text 和 .rodata 合并成只读可执行段、把 .data 和 .bss 合并成可读写段。合并的规则由链接器脚本(linker script) 决定,比如我们的 kernel 可以把各段放到指定地址。

Program Header Table 里每条记录(PT_LOAD 类型)大致长这样:

1
2
3
4
5
6
Program Header(PT_LOAD)
├── offset ← 段内容在文件里的偏移
├── vaddr ← 要加载到的虚拟地址(VMA)
├── filesz ← 文件里占多少字节
├── memsz ← 内存里占多少字节(≥ filesz,多出的部分清零)
└── flags ← 权限 R / W / X

filesz 和 memsz 可以不一样——这正是 .bss 的体现:文件里不占空间,内存里却要预留。

🚀 运行时:loader 的施工过程

程序被执行时,加载器(loader) 出场。对 Linux 用户程序,loader 是内核的 execve;对你的 kernel,loader 是 bootloader。两者动作几乎一样:

1
2
3
4
5
6
7
8
① 读 ELF header,确认格式、找到 Program Header Table
② 遍历 Program Header:
对每条 PT_LOAD:
├── 分配内存(按 vaddr)
├── 把文件里的 filesz 字节复制过去
└── memsz - filesz 剩余部分清零(.bss 在这里被清零)
③ 建立栈(分配内存 + 设置 RSP)
④ 跳转到 e_entry(也就是 _start)

对比一下《从按下电源键到内核接管》里 bootloader 的那张流程图——“解析 kernel ELF 的 Program Header → 把各段复制到对应内存地址 → 创建栈、设置 RSP → 读取入口点 _start”——每一步都是上面这条通用流程的具体化。

🥞 Stack 与 Heap:运行时的产物,不在文件里

现在回到开头的图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
High Address
+----------------+
| Stack | ← ❌ 并不在 ELF 文件里
+----------------+
| Heap | ← ❌ 并不在 ELF 文件里
+----------------+
| .bss |
+----------------+
| .data |
+----------------+
| .rodata |
+----------------+
| .text |
+----------------+
Low Address

Stack 和 Heap 是 loader 完成后、进程运行时才存在的东西,由操作系统(或 bootloader)在加载阶段创建,而不是编译产物。把文件里有什么和运行时有什么对照起来看:

内容 文件里(ELF) 运行时(进程地址空间)
.text 代码 低地址(loader 搬入)
.rodata 只读常量 低地址
.data 已初始化变量 低地址
.bss 未初始化,只记大小 低地址,分配对应内存,初始值全部为 0
Stack 不存在 高地址,loader 创建、向下增长
Heap 不存在 运行时创建、向上增长

中间的 loader 做的事:解析 Program Header,把左侧的 PT_LOAD 段搬进右侧对应位置 → .bss 在内存里被清零 → 创建 Stack / Heap。

要点:

  • Stack(栈):由 loader/OS 创建并设置好栈指针(x86_64 上地址在高处、向下增长)。上一篇讲过:栈不是程序自己分配的,是启动环境给的。
  • Heap(堆):给动态内存分配(malloc、Box)用。用户程序靠 OS 提供(brk/mmap);裸机内核没有 OS 帮忙,只能自己写分配器(后面会学到)。
  • 文件里的 .bss 只是一段待清零的空间承诺,真正的零内存是 loader 现场清零的。

🎯 回到 Rust OS

把上面所有概念套到我们正在写的内核上,链路是:

1
2
3
4
5
6
7
8
9
你的 Rust 代码
↓ cargo build(编译器 + rust-lld 链接)
kernel ELF(.text/.rodata/.data/.bss,入口 _start)
↓ cargo bootimage 拼到 bootloader 末尾
bootimage.bin
↓ BIOS 启动,执行 bootloader
bootloader 解析 Program Header → 各段复制到目标地址 → .bss 清零
↓ 创建栈、设置 RSP
跳转 e_entry(= _start),内核开始运行

几个和前面文章对得上的细节:

  1. 入口是地址不是符号:loader 跳的是 e_entry 里的地址,_start 只是链接器把它解析成地址的那个符号(呼应《从 main 到 _start》)。
  2. 内核的栈是 bootloader 给的:_start 里能直接用栈,是因为 bootloader 已经设好了 RSP(呼应电源键篇的 RSP 章节)。
  3. 内核没有 Heap:#![no_std] 内核里 Box/Vec 全都用不了,直到我们实现自己的堆分配器——那是后面的事。
  4. bootloader 与 kernel 靠接口通信:跑起来之后你会看到 BootInfo、entry_point!() 这些 API——bootloader 把内存布局等机器信息封装成 BootInfo,通过 entry_point!() 宏声明的入口交给内核,这就是两个程序之间的通信接口。

✅ 总结

ELF 是文件与内存之间的图纸:链接器按 sections 把它搭出来,加载器按 segments(Program Header)把它搬进内存,e_entry 决定从哪开始执行;Stack 和 Heap 不是文件的一部分,而是加载完成后的运行时产物。

一条建议的记忆链:

1
2
3
4
5
编译/链接 → ELF 文件(sections,含 .bss 的"空间承诺")
↓ loader 按 Program Header 加载
运行时地址空间(.text/.data/.bss + 新建的 Stack/Heap)
↓ 跳 e_entry
执行

想亲眼看文件结构,可以对自己编译出的 bootimage 前的内核二进制执行:

1
2
3
4
file target/x86_64-unknown-none/debug/rust_os    # 看文件类型
readelf -h target/x86_64-unknown-none/debug/rust_os # 看 ELF header
readelf -l target/x86_64-unknown-none/debug/rust_os # 看 Program Header(segments)
readelf -S target/x86_64-unknown-none/debug/rust_os # 看 Section Header(sections)

Happy Hacking! 🎉