ELF 与动态库进阶:链接的真相、GOT 表与动态链接器
库文件系列第四篇(2026-07-21)。上一篇把「节 → 段 → mm_struct」的主线打通了,这一篇往底层再钻两件事:
① 链接到底做了什么?为什么
.o叫「可重定位文件」?② 运行期的函数调用是怎么找到动态库代码的?------
.got、重定位与ld-linux.so
一、从「代码变成二进制」说起
先回答一个朴素的问题:为什么我们需要编译?
每一条 C 语句编译后,实际是这样一条记录:
[ 指令长度 | 操作码 | 数据 ]
- 代码段里的指令都是单字节标识 的,所以对齐、类型都不影响,每条指令紧密相连;
- 只要 PC 拿到了起始虚拟地址,就可以顺着「读长度 → 解析操作码 → 取数据」的节奏,递归式地一路读下去。
而 CPU 本质上"很傻":它出厂时就被刻录了固定的一套指令集,只会执行指令集里存在的东西。
二进制 ⇄ 指令集 是一一映射的。
所以:
- 同样一份二进制,换台机器为什么跑不起来? 因为不同厂商 CPU 刻录的指令集不同;
- 软件的可移植性:硬件层面取决于指令集,软件层面取决于编译器 + 操作系统。
二、链接的本质:一堆 ELF 合并成一个 ELF
2.1 两个反直觉的事实
- 程序的入口不是
main,而是_start!
用readelf -h xxx.exe看Entry point address,指向的是运行时启动代码_start,由它准备好环境后再调用main。 .o里的地址全是 0。
反汇编一个.o你会发现call后面的地址尚未确定------因为还没链接,地址还没分配 。这就是「可重定位文件 」这个名字的由来:地址待重定位。
2.2 链接时先做「符号表检查」
链接器怎么知道某个函数有没有定义?答案是 ELF 里有一个节专门存放符号表(.symtab) ,而符号表在文件中的字节偏移量记录在 ELF Header 里。
链接的第一件事就是遍历所有 .o 的符号表:
- 遍历完所有
.o都找不到定义 → 链接失败(undefined reference); - 同一个符号被定义了多次 → 重定义错误(multiple definition)。
检查通过后,再谈合并。
2.3 合并规则
| 内容 | 合并方式 |
|---|---|
| 代码(节) | 按 .o 的顺序直接拼接 |
| 数据(节) | 按先后顺序 + 类型 + 对齐要求排布 |
于是三件事一次完成:① 各节的偏移量 ② 整体文件的偏移量 ③ 静态链接所需的偏移量。有了偏移量就有了虚拟地址,映射关系随之可以建立。
所以静态链接的结论很硬核:静态库不需要在运行期加载到内存------因为整个库的代码早已被复制进可执行文件,在磁盘上就完成了全部链接工作。
补充一个容易误解的点:
.a本身不是标准 ELF 文件,它只是ar归档包;但包里面每一个.o都是标准 ELF。
三、动态链接:链接的是「链接」,不是「运行」
3.1 静态链接的代价
静态链接把节全部合并、重新以新磁盘偏移构建虚拟地址,一切 OK......除了体积:
全部重载会让可执行文件体积膨胀近 100 倍。
内存再优秀也救不了磁盘空间问题。而且内存就那么大,代码和数据必须进内存 CPU 才能用------可执行文件太肥,就是灾难。
3.2 动态链接的路线
① 找到库:库路径 → 库文件的 inode number → 读 inode → 拿块号
② 把 .so 映射到进程地址空间的【共享区】(mm_struct 里属于全局共享的那段编号)
③ 运行期调用库函数 = 对共享区虚拟地址的调用
共享库(Shared Object)的本质:
- 一份
.so被映射到共享区后,代码和数据只需在物理内存中存在一份,可以被多个进程共同映射使用; exec创建子进程时,内存里已经载入的库并不会销毁,只是虚拟地址映射消失,子进程再自行按需建立映射即可(常驻共享);- 问题来了:内存里有些库被 1 个进程映射、有些被 10 个映射、有些已销毁......操作系统必须用一个数据结构来专门管理这些「已载入内存的 .so」 。这就是动态库管理的由来(内核里对应
struct file/共享映射的引用计数那一套)。
3.3 地址空间布局的「对偶」
低地址 → 0 号起始(与磁盘块组相似):.text / 数据段 → 堆 → 【共享区:.so 库】 → 栈 ← 高地址
有意思的对称:
.so落在磁盘的高地址侧,而进程地址空间里共享区也位于上部------这套布局与我们学过的磁盘分区、块组划分是同一种「先分区再放东西」的思路。
四、.o / .exe / .so:谁有 Program Header Table?
这是本篇最值得记住的一张表:
| 文件 | 是否标准 ELF | 有 Section Header Table | 有 Program Header Table |
|---|---|---|---|
.o 可重定位目标文件 |
是 | ✅ | ❌ 没有 |
.exe 可执行文件 |
是 | ✅ | ✅ |
.so 共享库 |
是 | ✅ | ✅ |
为什么 .o 没有 Program Header Table? 因为 Program Header Table 是链接器 ld 的产物:
ld遍历所有节 ,按类型 + 权限把相关的节归拢、排好相对位置;- 属性相近的节(比如都是已初始化数据)块号紧密排在一起,遇到数据截断就知道要排到下一段;
- 排完后写入 Program Header Table------记录每一段的相对字节位置。
关键理解:
Program Header Table 不是「全局虚拟地址表」,而是「段的架构蓝图」 :它提供的是段的相对布局。
最终虚拟地址 = 这份蓝图(ld 的 exe Program Header)+ 系统按段类型分配的基地址。
于是完整的地址落地链条是:
虚拟地址 → ① 依据段类型确定它属于哪个段
→ ② 拿 Program Header 里的偏移量(虚拟地址本身就是 4KB 倍块号 + 段内偏移)
→ ③ 加上系统分配的基地址 = 最终虚拟地址
→ ④ 缺页时:用最终虚拟地址查页表(hash,O(1))→ 找到对应的磁盘 4KB 块
→ ⑤ 在内存里找空闲页,把这一块整体载入 → 反向登记映射
这条链之所以能成立,全靠一个巧合(其实是设计):虚拟地址是 4KB×n,物理内存页是 4KB×n,磁盘上的数据存储也是 4KB×n ------三者完美对齐,于是「映射」退化成一块 4KB 整体挪移。
五、重定位与 .got:动态库函数是怎么被调用的
5.1 难题:call 后面的地址是段里的,改不了
动态库在链接期还不知道自己会被加载到哪个虚拟地址(位置无关 )。可是 call 指令的目标地址写在代码段 里,而代码段是只读的,运行期无法修改。
解法很巧妙:
在
.data(数据段)里专门划出一块区域,用来存放「函数跳转地址」------这就是.got(Global Offset Table,全局偏移表)。
5.2 一趟完整的解析过程
【编译/链接期】
ld 遍历用到的动态库的符号表,在 .got 里为每个库函数留一个条目
→ 调用 100 个库函数,.got 至少 100 项
→ 此时条目里通常是「符号名 + 占位」(如 libc.c@0x4044)
代码里写的是「间接调用」:call .got[n]
【运行期 · 动态库加载时】
① ld-linux.so.2(动态链接器)把 .so 映射进共享区,建立映射
② 走一遍重定位:修改 .data 里的 .got 条目,写入真实的最终虚拟地址(基地址 + 偏移)
③ 此后 CPU 执行到 call .got[n],硬件自动查表拿到目标地址并跳转
重点区分两个"链接器":
| 静态链接器 | 动态链接器 | |
|---|---|---|
| 程序名 | ld |
ld-linux.so.2 |
| 工作时机 | 链接期(编译时) | 运行期(程序启动 / 库载入时) |
| 干什么 | 合并节、生成 Program Header Table、填 .got 占位 |
载入库、建映射、重定位修改 .got 条目 |
顺带回答「为什么静态链接也存在动态链接的身影」:即使采用动态库,程序里仍然有大量静态完成的结构(节、段布局、
.got框架),只是具体库函数地址的填写被推迟到了运行期。这就是「链接的链接,不是运行的链接」这句话的真正含义。
而 -fPIC(位置无关代码)之所以必要,也正是因为动态库的虚拟基地址未知 :只有全部代码采用相对寻址 + 通过 .got 间接跳转,库才能被加载到任意位置而不出错。
六、小结
源码 ──编译──▶ .o(ELF,地址全 0,可重定位,无 PHT)
.o 们 ──ld──▶ 合并节 + 符号检查 + 生成 PHT ──▶ 可执行 ELF(有 PHT)
├─ 静态:库代码已复制进来,运行期零依赖
└─ 动态:只记库名,运行期 ld-linux.so.2 载入库 → 重定位 .got
五条最值钱的结论:
- 入口是
_start不是main;.o的地址全 0,所以叫"可重定位"; - 链接先查符号表(未定义/重定义都过不了),再按「代码拼接 + 数据对齐」合并;
- 节是磁盘的、段是内存的 ;只有被
ld处理过的文件(.exe/.so)才有 Program Header Table; - 最终虚拟地址 = 段蓝图(PHT)+ 系统分配的基地址;三端都是 4KB 对齐,所以映射等于整块 4KB 搬运;
- 代码段只读不可改 → 用
.got间接跳转 ;.got的填写是运行期由动态链接器完成的重定位工作。
本文基于 2026-07-21《ELF 动态库》笔记整理。后续可继续深入:动态库为什么天然共享(7-28),以及 mm_struct 视角下的动态库加载细节。