10、目标文件格式:ELF文件结构深度剖析,节区与段
说到目标文件,你肯定天天都在跟它打交道。每次编译完,那些 .o 文件、可执行文件,甚至共享库,本质上都是 ELF 格式。我刚开始学的时候,觉得这东西就是个黑盒子——编译器吐出来的,链接器吃进去的,中间发生了什么?完全不知道。
后来做嵌入式开发,有一次调试一个诡异的段错误,折腾了两天。最后发现是链接脚本里节区对齐出了问题。从那以后,我下定决心把 ELF 结构啃了一遍。嗯,今天咱们就来聊聊这个。
ELF 文件长什么样?
ELF 的全称是 Executable and Linkable Format。说白了,它就是一种文件格式的规范。不管是可执行文件、目标文件(.o),还是共享库(.so),都遵循这套规则。
一个 ELF 文件,从宏观上看,分为三个部分:
- ELF 头(ELF Header):文件的总目录,告诉你这是个啥文件,架构是啥,入口在哪。
- 节区(Sections):存放代码、数据、符号表等具体内容。这是链接器眼中的世界。
- 段(Segments):运行时需要的映射信息。这是加载器(loader)眼中的世界。
你想想看,链接器关心的是怎么把各个 .o 文件拼起来,所以它看节区。而操作系统加载程序时,关心的是哪些数据要加载到内存、哪些要只读、哪些可执行,所以它看段。
核心概念:节区(Section)是链接视图,段(Segment)是执行视图。同一个 ELF 文件,从不同角度看,结构不同。
ELF 头:文件的总指挥
每个 ELF 文件的开头,都是一个固定大小的头结构。你可以用 readelf -h 命令把它 dump 出来看看。
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Entry point address: 0x400440
Start of program headers: 64 (bytes into file)
Start of section headers: 6648 (bytes into file)
Number of section headers: 31
Section header string table index: 30
我个人习惯先看 Magic 数字。前四个字节是 7f 45 4c 46,也就是 \x7fELF。如果这个不对,那文件肯定坏了。我在项目里遇到过有人手改二进制文件,结果 Magic 写错了,加载器直接拒绝识别。
头里还有两个关键偏移:Start of program headers 和 Start of section headers。前者指向段表,后者指向节区表。这两个表就是 ELF 文件的「索引目录」。
节区:链接器眼中的世界
节区是 ELF 文件里最核心的部分。每个节区都有一个名字、一个类型、一些标志位,以及实际的数据。链接器就是靠这些节区来完成符号解析和重定位的。
常见的节区有这些:
| 节区名 | 类型 | 作用 |
|---|---|---|
.text |
PROGBITS | 存放可执行机器码 |
.data |
PROGBITS | 已初始化的全局变量和静态变量 |
.bss |
NOBITS | 未初始化的全局变量,不占文件空间 |
.rodata |
PROGBITS | 只读数据,比如字符串常量 |
.symtab |
SYMTAB | 符号表,记录函数和变量名 |
.strtab |
STRTAB | 字符串表,存放符号名等字符串 |
.rela.text |
RELA | .text 节的重定位信息 |
小技巧:用 readelf -S 可以列出所有节区。用 objdump -d 可以反汇编 .text 节。这两个命令我几乎每天都要用。
节区头表:每个节区的身份证
节区头表是一个数组,每个元素描述一个节区。结构大致如下:
typedef struct {
uint32_t sh_name; // 节区名在字符串表中的索引
uint32_t sh_type; // 节区类型
uint64_t sh_flags; // 标志位(可写?可执行?)
uint64_t sh_addr; // 虚拟地址(如果加载到内存)
uint64_t sh_offset; // 在文件中的偏移
uint64_t sh_size; // 节区大小
uint32_t sh_link; // 关联的其他节区索引
uint32_t sh_info; // 附加信息
uint64_t sh_addralign; // 对齐要求
uint64_t sh_entsize; // 如果节区是表,每项的大小
} Elf64_Shdr;
这里有个细节:sh_name 不是字符串,而是一个索引。真正的名字存在 .shstrtab 节区里。所以节区名其实是个间接引用。我刚开始看的时候觉得多此一举,后来才明白,这样设计是为了节省空间——毕竟节区名就那么几个,没必要每个头里都存一遍。
段:加载器眼中的世界
段是给操作系统加载器看的。一个段可能包含一个或多个节区。比如,.text 和 .rodata 经常被合并到一个只读可执行的段里。
段的信息存在程序头表(Program Header Table)里。每个段头结构如下:
typedef struct {
uint32_t p_type; // 段类型(PT_LOAD, PT_DYNAMIC 等)
uint32_t p_flags; // 标志位(可读?可写?可执行?)
uint64_t p_offset; // 在文件中的偏移
uint64_t p_vaddr; // 虚拟地址
uint64_t p_paddr; // 物理地址(通常不用)
uint64_t p_filesz; // 在文件中的大小
uint64_t p_memsz; // 在内存中的大小
uint64_t p_align; // 对齐要求
} Elf64_Phdr;
注意:p_filesz 和 p_memsz 可能不同。比如 .bss 节在文件中不占空间(p_filesz 为 0),但在内存中要分配空间(p_memsz 非 0)。我曾经在写一个自定义加载器时忘了处理这个差异,结果程序跑起来后全局变量全是乱的。
节区与段的映射关系
一个典型的可执行文件,节区和段的映射关系是这样的:
| 段 | 包含的节区 | 权限 |
|---|---|---|
| PT_LOAD (1) | .text, .rodata |
读 + 执行 |
| PT_LOAD (2) | .data, .bss |
读 + 写 |
| PT_GNU_STACK | 无(仅标记栈属性) | 读 + 写(可能不可执行) |
| PT_DYNAMIC | .dynamic |
读 + 写 |
你想想看,为什么 .text 和 .rodata 要放一起?因为它们都是只读的。操作系统在加载时,可以把它们映射到同一页,设置相同的权限,省时省力。
实战:用 readelf 解剖一个 .o 文件
光说不练假把式。咱们写个简单的 C 文件,然后看看它的 ELF 结构。
// test.c
int global_var = 42;
static int static_var = 10;
int add(int a, int b) {
return a + b;
}
编译成目标文件:gcc -c test.c -o test.o
然后执行 readelf -S test.o,你会看到类似这样的输出:
There are 11 section headers, starting at offset 0x268:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000014 0000000000000000 AX 0 0 1
[ 2] .data PROGBITS 0000000000000000 00000054
0000000000000008 0000000000000000 WA 0 0 4
[ 3] .bss NOBITS 0000000000000000 0000005c
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .comment PROGBITS 0000000000000000 0000005c
000000000000002c 0000000000000001 MS 0 0 1
...
注意看 .text 节的 Flags 是 AX,表示可分配(Alloc)且可执行(Execute)。.data 是 WA,可分配且可写。.bss 的 Size 是 0,因为它不占文件空间。
关键点:目标文件(.o)的节区地址通常为 0,因为还没经过链接。链接器会重新分配地址,把各个 .o 的节区合并到最终的地址空间里。
避坑指南:节区对齐
我曾经在做一个固件项目时,链接脚本里忘了给 .data 节设置对齐。结果 .data 和 .text 的地址没对齐到页边界,加载到内存后,部分变量访问出错。查了两天才发现是 sh_addralign 的问题。
所以,写链接脚本时,一定要关注每个节区的对齐要求。尤其是 .data 和 .bss,它们通常要求 4 字节或 8 字节对齐。如果对齐不对,轻则性能下降,重则段错误。
总结一下
ELF 文件的结构,说白了就是一套「元数据 + 数据」的组织方式。头是目录,节区是内容,段是运行时视图。理解这三者的关系,你就能看懂链接器在干什么,加载器在干什么。
下次遇到链接错误或者段错误,别急着瞎猜。先 readelf -S 看看节区,再 readelf -l 看看段,问题往往一目了然。
推荐练习:找一个你平时写的 C 程序,编译成 .o 文件,然后用 readelf -a 把所有信息 dump 出来,一行一行看。看不懂的字段就查手册。这样过一遍,比你读十篇文章都管用。