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 Header(ELF 头) 节区(Sections)— 链接视图 .text(代码节) .data(已初始化数据节) .bss(未初始化数据节) .symtab / .strtab / ... 段(Segments)— 执行视图 PT_LOAD(可加载段) PT_DYNAMIC(动态链接信息) PT_INTERP(解释器路径) PT_NOTE / PT_GNU_STACK / ... 程序头表 映射 链接器操作节区,加载器操作段。两者通过程序头表(Program Header Table)关联。

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 headersStart 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_fileszp_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)。.dataWA,可分配且可写。.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 出来,一行一行看。看不懂的字段就查手册。这样过一遍,比你读十篇文章都管用。

公众号:蓝海资料掘金营,微信 deep3321