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(可分配 + 可执行),.data 是 WA(可写 + 可分配),.bss 是 WA 但大小为 0(不占文件空间)。
小技巧:用 objdump -d 可以反汇编 .text 节区的内容。我曾经用它逆向分析过一个第三方库,发现里面有个函数没导出,但通过符号表找到了它的地址。
SVG 图:ELF 文件结构全景
这张图把 ELF 的层次结构说清楚了。从上到下依次是文件头、程序头表、节区、节区头表。注意程序头表在节区之前,因为加载器需要先读程序头表才知道怎么映射内存,然后才去读节区头表(如果有需要的话)。
总结
ELF 和 PE 虽然细节不同,但核心思想一致:用结构化的方式描述代码和数据,让链接器和加载器各取所需。节区是给链接器看的,段是给加载器看的。理解了这个,你就掌握了目标文件格式的精髓。
我个人建议你多动手,用 readelf 和 objdump 分析几个真实的文件。看得多了,这些概念就变成直觉了。下次遇到链接错误,你一眼就能看出是节区对齐问题还是符号找不到问题。