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 | main.rs / lib.rs |
第一步编译器只负责把单个源文件变成目标文件(.o),里面还留着大量待填空的符号引用;第二步链接器(linker)
把多个 .o
和库合并起来,解析符号、分配地址,最终产出可执行文件。这就是上一篇《从
main 到 _start》里”链接器解析符号、确定 _start
地址”那一幕的完整背景。
🧱 ELF 文件内部:header 与 sections
一个可执行 ELF 文件(用 readelf -h 可以看)最前面是
ELF header,相当于文件的身份证+目录:
1 | ELF header |
文件主体是一系列 sections(节),按内容分类存放:
1 | 可执行 ELF(文件视角) |
| 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 | Program Header(PT_LOAD) |
filesz 和 memsz 可以不一样——这正是
.bss的体现:文件里不占空间,内存里却要预留。
🚀 运行时:loader 的施工过程
程序被执行时,加载器(loader) 出场。对 Linux
用户程序,loader 是内核的 execve;对你的 kernel,loader 是
bootloader。两者动作几乎一样:
1 | ① 读 ELF header,确认格式、找到 Program Header Table |
对比一下《从按下电源键到内核接管》里 bootloader 的那张流程图——“解析
kernel ELF 的 Program Header → 把各段复制到对应内存地址 → 创建栈、设置
RSP → 读取入口点
_start”——每一步都是上面这条通用流程的具体化。
🥞 Stack 与 Heap:运行时的产物,不在文件里
现在回到开头的图:
1 | High 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 | 你的 Rust 代码 |
几个和前面文章对得上的细节:
- 入口是地址不是符号:loader 跳的是
e_entry里的地址,_start只是链接器把它解析成地址的那个符号(呼应《从 main 到 _start》)。 - 内核的栈是 bootloader 给的:
_start里能直接用栈,是因为 bootloader 已经设好了 RSP(呼应电源键篇的 RSP 章节)。 - 内核没有 Heap:
#![no_std]内核里Box/Vec全都用不了,直到我们实现自己的堆分配器——那是后面的事。 - bootloader 与 kernel 靠接口通信:跑起来之后你会看到
BootInfo、entry_point!()这些 API——bootloader 把内存布局等机器信息封装成BootInfo,通过entry_point!()宏声明的入口交给内核,这就是两个程序之间的通信接口。
✅ 总结
ELF 是文件与内存之间的图纸:链接器按 sections 把它搭出来,加载器按 segments(Program Header)把它搬进内存,
e_entry决定从哪开始执行;Stack 和 Heap 不是文件的一部分,而是加载完成后的运行时产物。
一条建议的记忆链:
1 | 编译/链接 → ELF 文件(sections,含 .bss 的"空间承诺") |
想亲眼看文件结构,可以对自己编译出的 bootimage
前的内核二进制执行:
1 | file target/x86_64-unknown-none/debug/rust_os # 看文件类型 |
Happy Hacking! 🎉