Linux系统篇(二十二)——文件(六):目标文件与 ELF 深度解析:从编译到加载的全景揭秘
从目标文件出发,逐层解剖ELF头部、节头表与程序头表,讲透静态链接重定位、动态链接GOT/PLT以及程序从编译到加载的完整过程。
项目背景
上一篇讲完了动静态库的原理与用法,但很多人的疑问并没有结束:gcc -c 产出的 .o 文件到底是什么?它和 .so、a.out 有什么区别?ELF 为什么被称作"统一一切的二进制格式"?链接器凭什么能修正函数地址?
这些问题直接关系到"源码 → 编译 → 链接 → 加载 → 运行"的完整链路。本文从目标文件讲起,逐层解剖 ELF 的四大组成、两个视图,并用 readelf、objdump、size 等工具实操演示,最后对比静态链接与动态链接的本质差异。
技术方案
目标文件:编译的"半成品"
假设项目有几十个源文件,只改动了其中一个。如果没有中间产物,每次都要全量重编,效率极低。引入目标文件后,改动 hello.c 只需重新编译 hello.c,再重新链接即可——这就是增量编译思想:编译是单个文件的事,链接才是全局的事。
# hello.c 与 code.c 各自编译,互不链接
gcc -c hello.c # → hello.o
gcc -c code.c # → code.o
gcc -c 的含义是"只编译(compile),不链接(no link)"。用 file 命令查看 hello.o:
file hello.o
输出会显示 ELF ... relocatable。relocatable(可重定位) 是目标文件的灵魂属性:其中的地址尚未固定,等待链接时重新定位。
ELF:统一一切的二进制格式
ELF(Executable and Linkable Format,可执行与可链接格式)是 Linux 下可执行文件、目标文件、共享库、核心转储的统一格式标准。以下四类文件都是 ELF:
| 文件类型 | 扩展名/名称 | 说明 |
|---|---|---|
| 可重定位文件 | .o | 适合与其他目标文件链接,进而生成可执行文件或共享库 |
| 可执行文件 | 无扩展名 / a.out | 经过完整链接,可直接加载运行 |
| 共享目标文件 | .so | 动态链接库,由运行时动态链接器加载 |
| 核心转储 | core | 进程崩溃时的执行上下文,供事后调试 |
核心思想:无论 .o、.so 还是 a.out,骨子里都是 ELF。
ELF 的四大组成
一个 ELF 文件由四部分构成:
- ELF 头(ELF Header):位于文件最开头,描述文件主要特性,用于定位其他部分;
- 程序头表(Program Header Table):列举所有有效段(Segment)及属性,记录每个段的偏移、长度与权限——操作系统加载器看的就是它;
- 节头表(Section Header Table):描述所有节(Section)——编译器与链接器看的就是它;
- 节(Section):ELF 的基本组成单位,存放特定类型数据。一个 Segment 通常由若干 Section 合并而成。
常见节一览:
| 节名称 | 作用 |
|---|---|
| .text | 代码节,保存机器指令 |
| .data | 已初始化的全局变量与静态变量 |
| .rodata | 只读数据,如字符串常量 |
| .bss | 未初始化数据,加载时清零,不占磁盘空间 |
| .symtab | 符号表,函数名/变量名与地址的对应关系 |
| .strtab | 符号名称字符串表 |
| .got / .got.plt | 全局偏移表,提供共享库函数的访问入口 |
系统架构
ELF 头:文件的身份证
readelf -h hello.o # 查看目标文件的 ELF 头
readelf -h a.out # 对比可执行文件的 ELF 头
关键字段对比:
| 字段 | hello.o | a.out |
|---|---|---|
| Type | REL(可重定位文件) | EXEC(可执行文件) |
| Entry point address | 0x0(无入口) | 0x401040(有效入口) |
| Program headers | 0 个(没有程序头表) | 11 个(11 个段) |
| Section headers | 13 个 | 30 个 |
.o 文件是 REL 类型,没有程序头表、入口地址为 0,只提供节信息供链接器使用;a.out 是 EXEC 类型,同时具备节与段信息,可被操作系统直接加载。ELF 头中最核心的信息是:文件类型 + 入口点地址 + 程序头表与节头表的位置。
两个视图:链接器看节,加载器看段
| 视图 | 对应表 | 使用场景 | 粒度 | 作用 |
|---|---|---|---|---|
| 链接视图 | 节头表(Section) | 编译/链接时 | 细 | 按功能模块划分,链接器分析符号依赖 |
| 执行视图 | 程序头表(Segment) | 加载/运行时 | 粗 | 告诉 OS 如何加载、设置权限 |
查看命令三件套:
readelf -h 文件名 # 查看 ELF 头部(基础元信息)
readelf -S 文件名 # 大写 S:查看节头表 Section(链接器视角)
readelf -l 文件名 # 小写 l:查看程序头表 Segment(加载器视角)
注意 readelf -s(小写)是查看符号表,-S(大写)是查看节头表。
Segment 的合并:为什么需要段
合并原则:相同属性合并(可读、可写、可执行、是否需要申请空间)。原因有二:
| 原因 | 说明 |
|---|---|
| 减少内存碎片 | 页大小 4096 字节,.text 为 4097 字节、.init 为 512 字节时,不合并用 3 页,合并后只用 2 页 |
| 统一权限控制 | 所有只读/可执行节合并为 RE,所有可读写节合并为 RW,简化内存管理与权限管控 |
合并规则在链接时确定,记录在程序头表的 Section to Segment mapping 中,可用 readelf -l a.out 查看:
Program Headers:
Type Offset VirtAddr ... Flags Align
INTERP ... [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD ... R 0x1000 # 可读段
LOAD ... R E 0x1000 # 代码段:.init .plt .text .fini
LOAD ... R 0x1000 # 只读常量段:.rodata
LOAD ... RW 0x1000 # 数据段:.data .bss(MemSiz > FileSiz 对应 .bss)
DYNAMIC ... RW 0x8 # 动态链接信息
GNU_STACK... RW 0x10 # 栈不可执行(防缓冲区溢出)
GNU_RELRO ... R 0x1 # 重定位完成后只读(安全防护)
实施过程
从源代码到可执行文件:两步走
- 编译:把多份 C/C++ 源码翻译成目标
.o文件(以及动静态库,均为 ELF); - 链接:把多份
.o的 Section 合并并重定位,生成可执行文件。
静态链接的全过程
第一步,目标文件"互不相识"。对 hello.o 反汇编:
objdump -d hello.o
可以看到 call 指令指向的 printf、run 地址全是 0x00000000——编译器编译 hello.c 时,根本不知道这些函数在哪,甚至没见过它们的代码。
第二步,符号表标记未定义符号:
readelf -s hello.o # 小写 s:查看符号表
hello.o 中的 run 标记为 UND(undefine,未定义);而 code.o 中的 run 是已定义符号。链接器在链接时会把二者匹配起来。
第三步,链接时重定位。链接完成后再看 readelf -s a.out 与 objdump -d a.out:
| 符号 | 链接前(hello.o) | 链接后(a.out) |
|---|---|---|
| run | 00 00 00 00 | 0x401145 |
| printf | 00 00 00 00 | 0x401130(puts@plt) |
| Section 编号 | 各自的 .text(Nr 1) | 合并后的 .text(Nr 13) |
静态链接的本质 = 合并所有 .o 的 Section + 地址修正。链接器依据重定位表,找到需要重定位的函数与全局变量并修正其地址。
虚拟地址:链接时就已编好址
一个反直觉的事实:ELF 程序在加载进内存之前就已经有地址了。readelf -h a.out 显示的入口地址(如 0x1060)是链接时确定的虚拟地址,而非物理地址。现代 CPU 工作于平坦模式,ELF 在链接阶段就对自己的代码和数据统一编址,反汇编最左侧一列即为虚拟地址(逻辑地址 = 起始地址 + 偏移量)。
进程地址空间的数据从哪来?答案是从 ELF 的各 Segment 来。每个 Segment 有自己的起始地址与长度,操作系统据此初始化内核结构中的 [start, end] 范围,并用更细的地址填充页表。所以说:虚拟地址机制不仅 OS 要支持,编译器也要支持。
动态链接:运行时才见真章
静态链接会产生巨大的可执行文件,多个程序若都包含同一份库代码(如 printf),内存里会有大量副本。动态链接把符号解析与地址重定位从编译时推迟到程序加载运行时,是性价比极高的取舍。
程序真正的启动流程并不是从 main 开始:
| 阶段 | 说明 |
|---|---|
| _start | glibc/链接器提供的特殊函数,程序的真正入口 |
| 动态链接器 | ld-linux.so,解析程序依赖的所有动态库并加载进内存 |
| __libc_start_main | glibc 提供,完成额外初始化后调用 main |
| main | 程序员编写的入口函数 |
PIC 原理:动态库可能被映射到任意进程的任意地址,必须采用相对寻址——所有地址都是相对当前指令指针的偏移,这就是为什么编译动态库必须加 -fPIC。
GOT(全局偏移表):动态链接面临一个核心矛盾——代码段只读不能改,但函数地址又需要在加载时修正。解法是在可读写的 .data 区预留一块区域存放函数跳转地址,即 GOT。要点:
- 代码段只读不能直接修改,GOT 位于可读写段,运行时可更新;
- 单个
.so内 GOT 与.text相对位置固定,CPU 可用相对寻址找到 GOT; - 调用函数时先查 GOT,按表中地址跳转;
- 这套机制就是 PIC = 相对编址 + GOT。
PLT(过程链接表)与延迟绑定:如果程序启动时对所有库函数都做重定位,启动会非常慢。PLT 把重定位推迟到函数第一次被调用时——因为大多数动态库函数在程序运行期间可能一次都用不到。首次调用时通过 PLT 触发动态链接器解析真实地址并回填 GOT,后续调用直接命中。
应用价值
| 维度 | 静态链接 | 动态链接 |
|---|---|---|
| 链接时机 | 编译时 | 运行时 |
| 可执行文件大小 | 大(含全部库代码) | 小(仅含引用信息) |
| 内存占用 | 每程序独享一份库代码 | 物理内存一份,多进程共享 |
| 运行时依赖 | 无,独立运行 | 依赖系统存在对应 .so |
| 更新维护 | 替换库需重新链接 | 替换 .so 即可,无需重编 |
| 地址修正 | 编译重定位 | 加载重定位 |
| 性能 | 无额外加载开销 | 启动需动态链接器,有额外开销 |
| 核心机制 | 重定位表 + Section 合并 | GOT + PLT + 相对编址(PIC) |
无论是 .o 目标文件、.a 静态库、.so 动态库,还是 a.out 可执行文件——它们全部都是 ELF 文件。同一份 ELF 格式,通过链接视图(节头表)服务编译器和链接器,通过执行视图(程序头表)服务操作系统加载器。掌握了 ELF,就掌握了 Linux 下程序从源码到运行的全景链路,对排查链接错误、分析崩溃转储、理解逆向工程都大有裨益。
SEO关键词
Linux,ELF文件,目标文件,可重定位,程序头表,节头表,静态链接,动态链接,重定位,GOT,PLT,位置无关码,readelf
