你以为 Linux 运行程序,是把文件搬进内存,然后从 main() 开始?
实际上,文件中有些内容根本不需要加载;有些运行时需要的空间,在文件里并不保存对应的数据;而在 main() 之前,启动代码通常已经做了一轮准备。
前面讲过进程如何创建。这一次接着往下看:Linux 怎样知道哪些内容该加载、放到哪里,又该从哪里开始执行?
答案不是“猜文件内容”,而是读取文件里的描述信息。这套组织约定,就是 ELF。
这篇只抓一条主线: 代码编译成什么 → 图中的表和内容是什么关系 → 节为什么还要组成段 → 加载器怎样照着它们建立内存映像。
01 · ELF:让工具和系统读懂二进制文件
ELF 全称 Executable and Linkable Format,即“可执行与可链接格式”。除了机器指令和数据,文件里还记录了类型、架构、位置、大小和权限等信息。
只给你一串字节,你没法直接判断哪部分应该执行、哪部分应该可写。ELF 把这些内容及其描述按规则组织起来,链接器和加载器才能各取所需。
hello.c → 编译、汇编 → hello.o → 链接 → hello
源代码 ELF 目标文件 ELF 可执行文件
普通 .o 文件已有机器代码,但可能还留着待解析的符号引用。链接器把目标文件等输入组合起来,处理符号和重定位,形成可执行文件或共享库。所以,ELF 不等于“可以直接执行”:
| 类型 |
常见用途 |
| ET_REL |
.o 目标文件,供继续链接 |
| ET_EXEC |
非 PIE 可执行文件 |
| ET_DYN |
共享库,也用于 PIE 可执行文件 |
| ET_CORE |
核心转储,用于分析进程现场 |
PIE 是位置无关可执行文件,便于运行时选择加载位置。看到 DYN 不能直接认定“这是动态库”,更不能只靠文件扩展名判断。
02 · 对着这张图,先分清“内容”和“索引”

图:ELF 布局与节、段关系示意。布局和段归属以实际文件为准。
图中间是同一个 ELF 文件。先别急着记字段,从上往下分清四部分:
| 图中部分 |
它的作用 |
| ELF 文件头 |
说明文件是什么,并告诉你两张表在哪里 |
| 程序头表 |
一组描述条目,告诉加载器如何建立运行时映像 |
| 各节的内容 |
保存代码、数据、符号等;.bss 等有特殊规则 |
| 节头表 |
逐节记录类型、位置、大小、属性等信息 |
两张“头表”是描述信息,不是代码的副本。它们从不同角度描述文件:节头表按内容用途看,程序头表按运行时装载看。
文件头固定在文件开头,其他部分不能靠“上下紧挨着”定位,而要按文件头给出的偏移查找。不同 ELF 的布局和表是否存在,也可能不同。
03 · 节:把代码和数据按用途放好
先看一个小程序。无需逐行研究语法,只关注函数、字符串和两个全局变量最终放在哪里:
#include <stdio.h>
int initialized = 42;
int zeroed[1024];
int main(void)
{
printf("ELF demo: %d, %d\n", initialized, zeroed[0]);
return 0;
}
| 示例中的内容 |
常见所在节 |
为什么这样放 |
| main 等函数的机器指令 |
.text |
作为代码执行 |
| printf 使用的格式字符串 |
.rodata |
运行时读取,通常不需修改 |
| initialized 的初始值 42 |
.data |
需要把初始值保存在文件里 |
| zeroed 数组的空间 |
.bss |
初始值全为零,无需逐字节保存 |
这些是常见工具链布局,具体会受优化和链接方式影响。ELF 还可能有符号表、字符串表、重定位信息等节,帮助工具识别名称、解析引用;并不是所有节都需要进入进程内存。
节头表告诉工具各节的类型、文件偏移、大小和属性。节名本身通常保存在专门的字符串表中,节头通过索引找到名称。
04 · 段:加载器不需要逐节搬运
既然已经有节,为什么还要有段(Segment)?因为“链接时怎么组织内容”和“运行时怎么映射内存”,是两种需求。
链接器需要区分代码、数据、符号等内容;加载器则更关心哪段连续文件区域要映射到哪里,以及能不能读、写、执行。它不必逐个识别 .text、.data 这些名字。
图右侧把 .init、.plt、.text 等代码相关节放进一个可加载段,给它读、执行权限。其他可加载段可以承载只读数据,或可写数据。
节和段不是两份内容。一个段可以覆盖多个节;节头表与程序头表,是对同一文件的两种描述。
段的分组通常会考虑权限,但也受链接脚本、对齐等影响,不能简单认定“相同权限的节一定属于同一个段”。这也是参考图中需要按示意理解的地方。
程序头表每项描述一个段或相关运行信息。其中 PT_LOAD 指定可加载段;PT_INTERP 则指定解释器路径,通常指向动态链接器。并非每一种程序头条目都代表要映射的一块普通代码或数据。
节布局与映射可参考 GNU ld 的节布局说明;加载条目见 ELF 程序加载规范。
05 · 读懂 LOAD,就能解释 .bss
对于一个 LOAD 条目,先看这几个字段:
| 字段 |
含义 |
| p_offset |
对应内容在文件中的起始偏移 |
| p_vaddr |
对应虚拟地址;PIE 等需结合加载基址理解 |
| p_filesz |
这个段在文件中的字节数 |
| p_memsz |
这个段在内存中的字节数 |
| p_flags |
读 R、写 W、执行 X 权限 |
假设某个段的 p_filesz = 4 KB、p_memsz = 12 KB。它要求前 4 KB 内容来自文件,后面多出的 8 KB 内存在运行时清零。
这就解释了 .bss:它描述零初始化对象所需的空间,不在文件中占用对应大小的数据字节。 因此,加一个很大的未初始化全局数组,通常会增加运行时内存需求,却不会让可执行文件按相同大小增长。
注意,.bss 的节头描述仍需要元数据空间;“不占文件数据字节”不等于文件里完全没有它的信息。
06 · 用 readelf,把图与真实文件对起来
将上面的程序保存为 hello.c,在 Linux 中执行。需要 GCC、file 和 binutils;这里显式生成 PIE,避免依赖工具链的默认设置:
gcc -O0 -fPIE -pie hello.c -o /tmp/elf_hello
file /tmp/elf_hello
readelf -h /tmp/elf_hello
readelf -W -S /tmp/elf_hello
readelf -W -l /tmp/elf_hello
先看 -h:认清文件身份
主要看 Magic(ELF 以 7f 45 4c 46 开头)、Class(ELF32 / ELF64)、Machine(目标架构)、Type,以及 Entry。再看两张头表的偏移和条目数量,就知道图中的索引从哪里开始。
再看 -S:找到代码与变量
找到 .text、.rodata、.data 和 .bss。示例中,.bss 的类型是 NOBITS,这正对应“需要空间,但没有相同大小的文件内容”。
最后看 -l:观察加载范围
下面摘取本机 x86-64 构建结果中的一个可写 LOAD 条目,只保留关键字段。数值为十六进制,不同环境可能不同:
Type Offset VirtAddr FileSiz MemSiz Flags
LOAD 0x2db8 0x3db8 0x25c 0x1288 RW
逐项读:文件内容从偏移 0x2db8 开始;对应虚拟地址为加载基址加 0x3db8;文件提供 0x25c 字节,但内存需要 0x1288 字节,超出的尾部要清零;RW 表示读写。
继续看输出末尾的 Section to Segment mapping。本机这个段覆盖 .init_array、.fini_array、.dynamic、.got、.data 和 .bss,能直接看到“一个段包含多个节”。差值还涉及其他对象及对齐,不应直接当作示例数组的大小。
想对比目标文件,可以执行 gcc -c hello.c -o /tmp/elf_hello.o,再运行 readelf -h 和 readelf -l 查看它:类型是 REL,普通 .o 通常没有程序头表,因为它还不是最终装载映像。
07 · 从 ELF 到 main,中间还有什么?
运行程序时,execve() 替换当前进程的程序映像。对 ELF 的典型处理,可以分成四步理解:
- 检查文件头: 确认格式、架构等信息,并定位程序头表。
- 建立内存映射: 按 LOAD 条目准备地址空间、权限和零填充区域;Linux 通常通过映射和按需调页提供文件内容,并非一次性把整个文件读入物理内存。
- 准备启动环境: 设置用户栈、参数、环境变量和辅助向量等。
- 交出控制权: 若有 PT_INTERP,先进入动态链接器;由它完成所需依赖加载、重定位等工作,再转入程序入口。没有解释器时则进入程序自身入口。
程序入口常见为 _start,启动代码经运行库初始化后才调用 main()。所以,main 是你写业务代码的入口,却通常不是进程开始执行的第一条指令。
文件头的 e_entry 描述程序入口;带解释器时,内核最先交出控制的位置可以是解释器入口,两者要区分。
加载与解释器行为见 execve(2);Linux ELF 定义见 elf(5)。
写在最后
回头看这张图,就不必把 ELF 当成一堆难记的字段:
- 文件头: 它是什么,索引在哪里?
- 节与节头表: 内容各有什么用途,方便链接和分析。
- 段与程序头表: 运行时如何装载,加载器据此建立内存映像。
前面的 task_struct 描述“谁在运行”,ELF 则为“运行什么、怎样装载”提供信息。下次看 readelf 输出,先辨认文件,再找节,最后对照 LOAD 和节到段的映射,整条链就能串起来。如果你也在折腾 Linux 系统编程,可以到 云栈社区 一起交流。
参考:ELF 文件头规范、节规范、GNU readelf 文档、GCC 编译阶段说明。