6、目标文件格式:ELF(linux)与PE(windows)格式详解,节区(section)与段(segment)

说到编译链接,绕不开的就是目标文件格式。说白了,编译器辛辛苦苦翻译出来的东西,总得有个地方存着吧?这个「存」的格式,就是目标文件格式。Linux 下是 ELF,Windows 下是 PE。我当年刚入行时,觉得这东西就是个文件头,没啥好看的。直到有一次调试一个链接器报错,折腾了两天,最后发现是节区对齐出了问题……嗯,从那以后我再也不敢小看它了。

ELF 文件格式概览

ELF 全称 Executable and Linkable Format,可执行可链接格式。它既能描述可执行文件,也能描述目标文件(.o)和共享库(.so)。我个人习惯把 ELF 文件看成三部分:文件头、节区头表、节区本身。

先看文件头,它位于文件最开头,用 Elf64_Ehdr 结构体描述。里面记录了魔数、架构、入口地址、节区头表偏移等信息。魔数就是开头的 4 个字节:0x7f 'E' 'L' 'F'。你想想看,操作系统加载一个文件时,第一件事就是检查这个魔数,不对就直接拒了。

typedef struct {
    unsigned char e_ident[16];  // 魔数、类别、数据编码等
    uint16_t      e_type;       // 目标文件类型
    uint16_t      e_machine;    // 架构类型
    uint32_t      e_version;    // 版本
    uint64_t      e_entry;      // 入口地址
    uint64_t      e_phoff;      // 程序头表偏移
    uint64_t      e_shoff;      // 节区头表偏移
    uint32_t      e_flags;      // 标志
    uint16_t      e_ehsize;     // 文件头大小
    uint16_t      e_phentsize;  // 程序头表项大小
    uint16_t      e_phnum;      // 程序头表项数量
    uint16_t      e_shentsize;  // 节区头表项大小
    uint16_t      e_shnum;      // 节区头表项数量
    uint16_t      e_shstrndx;   // 节区名字符串表索引
} Elf64_Ehdr;

这里有个细节:e_shstrndx 指向一个特殊的节区,里面存着所有节区的名字。没有它,你就不知道 .text.data 这些节区谁是谁。

节区(Section)—— 链接时的视角

节区是链接器眼中的世界。每个节区存放一类特定的数据。常见的节区有:

节区名 用途 我踩过的坑
.text 代码段,存放可执行指令 对齐问题导致链接失败
.data 已初始化的全局变量和静态变量 大数组未初始化放到了 .bss
.bss 未初始化的全局变量和静态变量 不占文件空间,但占内存
.rodata 只读数据,如字符串常量 修改它会导致段错误
.symtab 符号表,记录函数和变量名 strip 后符号就没了
.strtab 字符串表,存符号名字符串 和 .shstrtab 不是同一个
.rela.text 代码段的重定位信息 链接器靠它修正地址

每个节区由 Elf64_Shdr 描述,里面记录了节区的类型、标志、地址、偏移、大小等信息。标志位里有个 SHF_ALLOC,表示这个节区在运行时需要占用内存。没有这个标志的节区(比如符号表),加载时会被丢弃。

重点:链接器合并多个目标文件时,会把同名的节区合并到一起。比如所有 .o 的 .text 节区合并成一个大的 .text 节区。这就是链接的核心操作之一。

段(Segment)—— 加载时的视角

段是操作系统加载器眼中的世界。加载器不关心你有多少个节区,它只关心哪些数据要加载到内存,哪些权限(读、写、执行)。段由程序头表(Program Header Table)描述,每个段对应一个 Elf64_Phdr

typedef struct {
    uint32_t   p_type;    // 段类型
    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;

常见的段类型:

  • PT_LOAD:可加载段,加载器把它映射到内存
  • PT_DYNAMIC:动态链接信息
  • PT_INTERP:解释器路径(比如 /lib/ld-linux.so.2)
  • PT_NOTE:辅助信息

一个段可以包含多个节区。比如 PT_LOAD 段可能同时包含 .text.rodata,因为它们都是只读可执行的。而 .data.bss 通常放在另一个 PT_LOAD 段,因为它们是可读可写的。

小技巧:readelf -l 可以查看 ELF 文件的段信息。我曾经用它排查过一个内存映射错误,发现 .bss 段的大小算错了,导致程序启动时崩溃。

PE 文件格式概览

PE(Portable Executable)是 Windows 下的格式。它脱胎于 COFF 格式,所以很多概念和 ELF 类似,但细节差异很大。PE 文件的核心结构包括 DOS 头、PE 头、节区头表和节区。

DOS 头以 MZ 开头,这是为了兼容 DOS 时代的老程序。PE 头从偏移 0x3C 处指向的位置开始,包含 IMAGE_NT_HEADERS 结构。

typedef struct _IMAGE_NT_HEADERS64 {
    DWORD                   Signature;        // "PE\0\0"
    IMAGE_FILE_HEADER       FileHeader;
    IMAGE_OPTIONAL_HEADER64 OptionalHeader;
} IMAGE_NT_HEADERS64;

PE 的节区概念和 ELF 类似,但名字不同。常见的节区有 .text(代码)、.data(数据)、.rdata(只读数据)、.idata(导入表)、.edata(导出表)、.reloc(重定位表)。

PE 没有 ELF 那种「段」的概念,而是用节区头表中的 Characteristics 字段来表示权限。比如 IMAGE_SCN_MEM_EXECUTE 表示可执行,IMAGE_SCN_MEM_READ 表示可读,IMAGE_SCN_MEM_WRITE 表示可写。

注意:PE 的节区对齐和文件对齐是两个不同的概念。节区对齐是内存中的对齐(通常 4KB),文件对齐是磁盘上的对齐(通常 512 字节)。两者不一致时,加载器会做填充。我曾经因为没搞清楚这个,写了个解析器读出来的数据全是错的。

ELF 与 PE 的核心差异

特性 ELF PE
魔数 0x7f 'E' 'L' 'F' 'M' 'Z' + 'P' 'E'
节区描述 节区头表(Section Header Table) 节区头表(Section Table)
加载描述 程序头表(Program Header Table) 节区头表中的 Characteristics
重定位 独立的重定位节区(.rela.text) 重定位表(.reloc)
动态链接 .dynamic 节区 + PT_DYNAMIC 段 .idata 导入表 + .edata 导出表
调试信息 .debug_* 节区(DWARF 格式) .debug$S 等(CodeView 格式)

为什么会这样?说白了,两个系统的设计哲学不同。ELF 更强调「分离关注点」,链接时看节区,加载时看段,各司其职。PE 则更「实用主义」,把权限直接写在节区头里,省掉了一层抽象。

节区与段的映射关系

这是理解 ELF 的关键。一个典型的 ELF 可执行文件,节区和段的映射关系如下:

文件布局:
+-------------------+
| ELF 文件头         |
+-------------------+
| 程序头表           |
| (描述段)          |
+-------------------+
| 节区头表           |
| (描述节区)        |
+-------------------+
| .text 节区         |  ← 映射到 PT_LOAD 段1(读+执行)
| .rodata 节区       |  ← 映射到 PT_LOAD 段1(读+执行)
+-------------------+
| .data 节区         |  ← 映射到 PT_LOAD 段2(读+写)
| .bss 节区          |  ← 映射到 PT_LOAD 段2(读+写,不占文件空间)
+-------------------+
| .symtab 节区       |  ← 不映射到任何段(加载时丢弃)
| .strtab 节区       |  ← 不映射到任何段
+-------------------+

注意看,.symtab.strtab 没有对应的段。这意味着它们只在链接时需要,运行时根本用不到。所以 strip 命令可以安全地删掉它们,减小文件体积。

核心理解:节区是链接器的视角,段是加载器的视角。链接器合并节区,加载器映射段。两者通过节区头表中的 SHF_ALLOC 标志和程序头表中的 p_offset/p_vaddr 建立联系。

实战:用 readelf 查看 ELF 文件

光说不练假把式。我建议你找个 .o 文件,亲自跑一下命令看看:

# 查看 ELF 文件头
readelf -h main.o

# 查看节区头表
readelf -S main.o

# 查看程序头表(可执行文件才有)
readelf -l a.out

# 查看符号表
readelf -s main.o

# 查看重定位信息
readelf -r main.o

输出里你会看到每个节区的地址、偏移、大小、标志。比如 .text 节区的标志通常是 AX(可分配 + 可执行),.dataWA(可写 + 可分配),.bssWA 但大小为 0(不占文件空间)。

小技巧:objdump -d 可以反汇编 .text 节区的内容。我曾经用它逆向分析过一个第三方库,发现里面有个函数没导出,但通过符号表找到了它的地址。

SVG 图:ELF 文件结构全景

ELF 目标文件结构全景 ELF 文件头 (Elf64_Ehdr) 魔数 | 类型 | 架构 | 入口地址 | 程序头表偏移 | 节区头表偏移 程序头表 (Program Header Table) 描述段 (Segment):PT_LOAD | PT_DYNAMIC | PT_INTERP | PT_NOTE 节区 (Section) 区域 .text (代码) .rodata (只读数据) .data (已初始化数据) .bss (未初始化) .symtab (符号表) .strtab (字符串表) .rela.text (重定位) .debug_* (调试) 节区头表 (Section Header Table) — 描述每个节区的类型、偏移、大小、标志 ← 映射到 PT_LOAD 段 ← 不映射到段(链接时使用)

这张图把 ELF 的层次结构说清楚了。从上到下依次是文件头、程序头表、节区、节区头表。注意程序头表在节区之前,因为加载器需要先读程序头表才知道怎么映射内存,然后才去读节区头表(如果有需要的话)。

总结

ELF 和 PE 虽然细节不同,但核心思想一致:用结构化的方式描述代码和数据,让链接器和加载器各取所需。节区是给链接器看的,段是给加载器看的。理解了这个,你就掌握了目标文件格式的精髓。

我个人建议你多动手,用 readelf 和 objdump 分析几个真实的文件。看得多了,这些概念就变成直觉了。下次遇到链接错误,你一眼就能看出是节区对齐问题还是符号找不到问题。

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