Appearance
静态链接与动态链接
概念
lang/22 的链接器有个隐含前提:它在"链接期"就把所有地址填死。
于是产生一个后果:库的代码被复制进了每一个可执行文件。
这带来三个躲不开的代价:
| 代价 | 说明 |
|---|---|
| ① 体积重复 | 20 个程序都用同一个 2 MiB 的库 → 磁盘上就存了 20 份 |
| ② 内存不共享 | 同时跑 20 个进程,物理内存里就有 20 份库代码 |
| ③ 升级要重编 | 库修了一个 bug,所有用户程序都得重新链接、重新发布 |
动态链接(dynamic linking)走另一条路:
它把 lang/22 的重定位"推迟"了:
| 静态链接 | 动态链接 | |
|---|---|---|
| 谁做重定位 | 链接器 ld(链接期) | 动态加载器 ld.so / ld-linux-x86-64.so.2(加载期) |
| 什么时候定地址 | 生成可执行文件时 | 程序启动时(甚至第一次调用时) |
| 库代码在哪 | 在可执行文件里 | 在 .so 文件里,被 mmap 进来 |
本篇在主线上的位置:
lang/22讲"链接期怎么填地址",本篇讲"能不能不填、改到加载期填"——以及为了做到这件事,代码和库必须遵守哪些新规矩(PIC / GOT / PLT)。
原理
一、动态链接的两种,与三方角色
"动态"其实有两层含义:
| 类型 | 什么时候接上 | 典型用法 |
|---|---|---|
| ① 加载时动态链接 | execve 启动程序时,由 ld.so 接上 | 普通程序用 libc.so(绝大多数情况) |
| ② 运行期动态链接 | 程序跑起来之后,自己调用 dlopen() 加载 | 插件系统、驱动、可选功能(dlopen / dlsym / dlclose) |
一个动态链接程序的生命周期里有三个角色:
| 阶段 | 谁在场 | 做什么 |
|---|---|---|
| 编译期 | 编译器 + 链接器 | 只检查"有没有 libfoo.so",并把"我依赖 libfoo.so.1"写进 .dynamic 节(不复制代码) |
| 加载期 | ld.so(动态加载器) | mmap 库文件 → 遍历重定位表 → 填 GOT → 处理依赖顺序 |
| 运行期 | 程序自己 | 通过 PLT/GOT 调用库函数(首次调用可能触发"延迟解析") |
查看一个程序依赖谁:
bash
ldd ./app # 列出所有共享库与它们的实际路径
readelf -d ./app # 看 .dynamic 节:NEEDED 项就是依赖清单
objdump -T ./libfoo.so # 看动态符号表(.dynsym)二、核心难题:共享库会被映射到"未知地址"
为什么不能简单地把库也"重定位一遍"了事? ——因为共享库必须能被"任意程序、加载到任意地址",理由有三条:
| 理由 | 说明 |
|---|---|
| ① 多个程序共用一个库 | 不同程序的地址空间布局不同,库不可能固定在同一个位置(除非每次都碰巧空着) |
| ② ASLR(地址空间随机化) | 出于安全,系统故意每次把库映射到不同地址(防"猜地址"的攻击) |
| ③ 一个进程里可能有多个库 | 哪个地址空着要看运行时情况 |
结论:库代码不能依赖"我的地址是多少"——这就是 PIC(Position-Independent Code,位置无关代码)。
PIC 的两条技术路线:
| 要引用什么 | 怎么做 | 为什么 |
|---|---|---|
| 同一个模块内部的代码/数据 | PC 相对寻址(S + A - P,lang/22 的公式直接可用) | 距离固定,与基址无关 |
| 别的模块里的符号(外部函数、外部全局变量) | PC 相对找到"表",再从表里取绝对地址 | 距离虽然固定,但"绝对地址"要等加载时才定 |
这两条路线分别对应:
注意一个关键细节:"同模块内部的数据也可能走 GOT"——因为全局变量默认是"可被插入(interposable)"的(别的模块可以定义同名全局变量并覆盖它,见本节的第五小节)。
三、GOT 与 PLT:一张表 + 一小段跳板
先把两个东西的分工说清:
| GOT(Global Offset Table) | PLT(Procedure Linkage Table) | |
|---|---|---|
| 是什么 | 一张"地址表"(数据) | 一小段"跳板代码"(指令) |
| 在哪 | .got / .got.plt(可写数据段) | .plt(代码段,只读可执行) |
| 谁写 | ld.so 在加载/首次调用时填 | 编译期由链接器生成,运行期不改 |
| 表项内容 | 被引用对象的真实地址 | "跳到 GOT 表项指向的地方"的指令 |
| 为什么要它 | 代码是只读的,不能让加载器改代码;所以把"要改的东西"放进可写数据 | 为了让代码段保持只读、且只占一份 |
PLT 表项的形状(x86-64,每个表项 16 字节):
PLT[0](公共入口,负责触发解析器)
push GOT[1] ; 把 link_map 压栈
jmp *GOT[2] ; 跳到 _dl_runtime_resolve
PLT[printf](每个外部函数一个表项;IDX = 它的重定位索引,printf 的 IDX = 0)
jmp *GOT[IDX+3] ; 若已解析 → 直接到真函数
push IDX ; 若未解析 → 压入重定位索引
jmp PLT[0] ; 交给解析器GOT 的前三项被"征用"了(这是"GOT[3] 起才是函数地址"的原因):
| 索引 | 内容 |
|---|---|
| GOT[0] | .dynamic 节的地址("我是谁"的元信息入口) |
| GOT[1] | link_map(已加载模块的链表,供解析器查找符号) |
| GOT[2] | _dl_runtime_resolve(解析函数的地址) |
| GOT[3]、GOT[4] … | 各外部符号的真实地址(由 ld.so 填) |
四、延迟绑定:首次慢、之后零成本
"解析一个符号"是件不便宜的活:要在多个库的符号表里按名字查找、比较版本、处理插入规则(量级是微秒,而一次函数调用是纳秒)——差了三到四个数量级。
如果启动时把程序用到的所有外部符号都解析一遍:
而一个程序通常只用到其中一小部分——于是就诞生了延迟绑定(lazy binding):
| 时刻 | 发生了什么 |
|---|---|
| 第一次调用某函数 | PLT[IDX] 发现 GOT[IDX+3] 还指向"PLT[IDX] 里的 push 指令" → 触发 _dl_runtime_resolve → 解析出真地址 → 写回 GOT[IDX+3] → 跳到真函数 |
| 之后每次调用 | PLT[IDX] 的 jmp *GOT[IDX+3] 直接跳到真函数——只多一次内存间接跳转 |
开关:
| 选项 | 效果 |
|---|---|
默认(-z lazy) | 延迟绑定(首次调用慢一次) |
-Wl,-z,now / LD_BIND_NOW=1 | 启动时全部解析(启动慢一点,但运行期无"首次抖动";安全加固常用) |
-Wl,-z,relro | 把 .got(除 .got.plt)设为只读(防止攻击者改 GOT) |
"延迟绑定"的代价是"不可预测性":第一次调用某个函数会突然慢一点点(实时系统/游戏会介意)——所以在性能敏感场景会显式关掉它。
五、"插入(interposition)":动态链接独有的能力
动态链接还带来一个静态链接不可能有的特性:符号插入。
规则:ld.so 在全局符号表里按"加载顺序 + 搜索顺序"找第一个同名定义——靠前的定义会"盖住"靠后的。
两件实际用途:
| 用途 | 手段 |
|---|---|
| 不改代码就替换库函数(打补丁 / 调试 / hock) | LD_PRELOAD=./mymalloc.so ./app——先加载的 mymalloc.so 里的 malloc 覆盖 libc 的 |
| 让自定义实现被优先选中 | 把符号定义在自己的可执行文件里(可执行文件参与全局搜索,且常常排在最前) |
LD_PRELOAD 的核心机制:被预加载的库在"全局符号搜索顺序"里排到了 libc 之前。
⚠️ 由此也带来一个易错点:因为全局变量默认可被插入,所以"访问本模块的全局变量"在 PIC 下也可能绕道 GOT(除非加
-fno-semantic-interposition或改可见性)——这就是"动态链接的程序有时比静态的慢一点点"的隐藏原因之一。
六、版本与 ABI:SONAME 与符号版本
共享库要在"不重编程序"的前提下升级,就必须有一套"我只保证兼容到这个程度"的约定。
| 概念 | 例子 | 含义 |
|---|---|---|
SONAME(DT_SONAME) | libfoo.so.1 | 库的"接口名"——程序里记的是这个,不是文件名 |
| 真实文件名 | libfoo.so.1.2.3 | 实现版本(升级只换这个,SONAME 不变) |
| 开发用软链 | libfoo.so → libfoo.so.1 | 链接时用(-lfoo 找它) |
| 符号版本 | printf@GLIBC_2.2.5 | 同一个名字可以有多个版本,供不同年代的 ABI 共存 |
DT_NEEDED | libc.so.6 | 依赖清单(readelf -d 可见) |
这就是"为什么 libfoo.so 和 libfoo.so.1 是两个文件":
- 链接时:
-lfoo找libfoo.so(软链); - 运行时:程序里记的是
DT_NEEDED:libfoo.so.1——所以升级到1.2.4不用重编程序; - 不兼容时:升
SONAME到libfoo.so.2——老程序照样能找到 1,新程序用 2,两者并存。
"依赖地狱"就来自这里:A 要 libc.so.6 的某个符号版本、B 要另一个——版本约定一旦破,就得靠容器/虚拟环境把不同的库分开装。
七、静态 vs 动态:一张对照表
| 维度 | 静态链接 | 动态链接 |
|---|---|---|
| 链接时机 | 链接期(生成可执行文件时) | 加载期 / 首次调用时 |
| 库代码位置 | 在可执行文件里 | 在 .so 里,运行时 mmap |
| 可执行文件体积 | 大(含所有用到的库代码) | 小(只有薄壳 + 依赖清单) |
| 磁盘重复 | 每个程序一份 | 全系统一份 |
| 内存共享 | 不共享(每个进程私有物理页) | 共享(同一份只读代码页) |
| 启动速度 | 快(没有解析、没有 mmap 额外文件) | 稍慢(要跑 ld.so + 解析依赖) |
| 运行速度 | 略快(直接调用) | 略慢(GOT 间接跳转;首次调用还要解析) |
| 升级库 | 必须重编所有程序 | 换 .so 即可(SONAME 兼容时) |
| 部署 | 单文件,拷走就能跑 | 要保证目标机有对应版本的库 |
| 符号覆盖 | 做不到(代码已复制进来) | 可以(LD_PRELOAD / 插入) |
| 典型文件 | .a(归档)、-static | .so(Linux)/ .dll(Windows)/ .dylib(macOS) |
| PIC 要求 | 不要求 | 库必须 -fPIC 编译 |
.a与.so的本质差别:.a是"一堆.o的归档",链接时按需抽取(lang/22);.so是一整个 ELF(ET_DYN),加载时整块映射。
一个补充事实:现代可执行文件自己通常也是 PIC 的(叫 PIE,Position-Independent Executable)——好处是配合 ASLR 让"程序本身的基址"也随机化(ET_DYN 而非 ET_EXEC 的由来,见 lang/21)。
示例
例 1:静态与动态的体积/内存账
任务:把"20 个程序共用同一个库"的两种做法算成账。
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
N = 20 # 程序个数
LIB = 2 * 1024 * 1024 # 共享库 2 MiB
SHELL = 16 * 1024 # 每个程序自己的代码 16 KiB
def human(b):
if b >= 1024 * 1024:
return "%.2f MiB" % (b / 1024 / 1024)
return "%.0f KiB" % (b / 1024)
print("=== ① 磁盘占用 ===")
print(" 参数:程序 %d 个,每个自己的代码 %s,共用库 %s" % (N, human(SHELL), human(LIB)))
static_disk = N * (LIB + SHELL)
dyn_disk = N * SHELL + LIB
print(" 静态:每个程序都含一份库 → %d × (%s + %s) = %s"
% (N, human(LIB), human(SHELL), human(static_disk)))
print(" 动态:库只有一份,程序只有薄壳 → %d × %s + %s = %s"
% (N, human(SHELL), human(LIB), human(dyn_disk)))
print(" 磁盘节省 = %s;静态总量是动态的 %.2f 倍"
% (human(static_disk - dyn_disk), static_disk / dyn_disk))
print()
print("=== ② 同时运行时的物理内存 ===")
print(" 假设 %d 个进程同时跑,库的代码段是只读的(可共享)" % N)
static_mem = N * LIB
dyn_mem_code = LIB # 共享库的只读代码页只占一份
dyn_mem_private = N * SHELL # 每个进程自己的代码与数据
print(" 静态:每个进程私有 %s 的库代码 → 合计 %s" % (human(LIB), human(static_mem)))
print(" 动态:库代码共享 1 份 = %s;每进程私有 %s → 合计 %s"
% (human(dyn_mem_code), human(SHELL), human(dyn_mem_code + dyn_mem_private)))
print(" 内存节省 = %s;静态总量是动态的 %.2f 倍"
% (human(static_mem - (dyn_mem_code + dyn_mem_private)),
static_mem / (dyn_mem_code + dyn_mem_private)))
print(" ★ 关键是“只读代码页可被多进程共享”这一条(写时复制也不用复制)")
print()
print("=== ③ 但动态链接不是白拿的 ===")
COST = [
("多一次 ld.so 启动与依赖解析", "启动期,毫秒级"),
("每次外部调用多一次 GOT 间接跳转", "运行期,约 1 个时钟周期量级"),
("首次调用要解析符号", "运行期,微秒级(可关掉)"),
("需要目标机有匹配版本的库", "部署期,依赖地狱的来源"),
]
print(" " + pad("代价", 34) + "出现在")
for a, b in COST:
print(" " + pad(a, 34) + b)
print()
print(" 结论:省的是“磁盘 + 内存 + 升级成本”,付的是“启动 + 一点运行开销 + 部署复杂度”")预期输出:
=== ① 磁盘占用 ===
参数:程序 20 个,每个自己的代码 16 KiB,共用库 2.00 MiB
静态:每个程序都含一份库 → 20 × (2.00 MiB + 16 KiB) = 40.31 MiB
动态:库只有一份,程序只有薄壳 → 20 × 16 KiB + 2.00 MiB = 2.31 MiB
磁盘节省 = 38.00 MiB;静态总量是动态的 17.43 倍
=== ② 同时运行时的物理内存 ===
假设 20 个进程同时跑,库的代码段是只读的(可共享)
静态:每个进程私有 2.00 MiB 的库代码 → 合计 40.00 MiB
动态:库代码共享 1 份 = 2.00 MiB;每进程私有 16 KiB → 合计 2.31 MiB
内存节省 = 37.69 MiB;静态总量是动态的 17.30 倍
★ 关键是“只读代码页可被多进程共享”这一条(写时复制也不用复制)
=== ③ 但动态链接不是白拿的 ===
代价 出现在
多一次 ld.so 启动与依赖解析 启动期,毫秒级
每次外部调用多一次 GOT 间接跳转 运行期,约 1 个时钟周期量级
首次调用要解析符号 运行期,微秒级(可关掉)
需要目标机有匹配版本的库 部署期,依赖地狱的来源
结论:省的是“磁盘 + 内存 + 升级成本”,付的是“启动 + 一点运行开销 + 部署复杂度”三条结论:
| 结论 | 说明 |
|---|---|
| 动态链接省的是"重复" | 磁盘上库只有一份,内存里只读代码页也只占一份 |
| 内存共享的前提是"只读" | 可写数据(.data/.bss)每个进程一份——所以库的状态不能靠全局变量传递 |
| 代价落在启动期和部署期 | 运行期的额外开销其实很小(一次间接跳转),大头是启动解析与"目标机有没有这个库" |
例 2:PLT 表项的字节账与"首次调用三次跳转"
任务:把 call printf@plt 的地址算清楚,并对比"首次"与"后续"两条路径。
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
PLT0 = 0x401030 # PLT[0] 公共入口
PLT_PR = 0x401040 # PLT[printf] 表项起点
GOT0 = 0x404000 # GOT 起始
IDX = 0 # printf 是第 0 个需要 PLT 的外部函数
GOT_SLOT = GOT0 + 8 * (IDX + 3) # GOT[0..2] 被征用,函数从 GOT[3] 起
print("=== ① PLT 表项的字节布局(x86-64) ===")
print(" PLT[printf] @ 0x%06x" % PLT_PR)
parts = [
("jmp *GOT[%d]" % (IDX + 3), "ff 25 <disp32>", 6, PLT_PR + 0),
("push $%d" % IDX, "6a 00", 2, PLT_PR + 6),
("jmp PLT[0]", "e9 <rel32>", 5, PLT_PR + 8),
]
print(" " + pad("偏移", 8) + pad("指令", 20) + pad("编码", 17) + pad("长度", 6) + "地址范围")
for name, enc, size, addr in parts:
print(" " + pad("+0x%x" % (addr - PLT_PR), 8) + pad(name, 20) + pad(enc, 17)
+ pad(str(size), 6) + "0x%06x ~ 0x%06x" % (addr, addr + size - 1))
used = sum(p[2] for p in parts)
print(" 实际用掉 %d 字节,但表项按 16 字节对齐 → 浪费 %d 字节(%.1f%%)"
% (used, 16 - used, 100.0 * (16 - used) / 16))
print(" 下一个 PLT 表项从 0x%06x 开始(+5 个表项 → +80 字节)" % (PLT_PR + 16))
print(" ★ GOT 索引从 %d 起:前 3 项被 .dynamic / link_map / _dl_runtime_resolve 占用" % (IDX + 3))
print(" printf 的地址槽 = GOT 起始 0x%06x + 8 × %d = 0x%06x" % (GOT0, IDX + 3, GOT_SLOT))
print()
print("=== ② 第一次调用:三次跳转 + 一次解析 ===")
first = [
("call printf@plt", "0x401012", "跳到 PLT[printf] = 0x%06x" % PLT_PR),
("jmp *GOT[%d]" % (IDX + 3), "0x%06x" % PLT_PR,
"此刻 GOT[%d] 指向 PLT 表项内部(0x%06x)→ 等于没跳走" % (IDX + 3, PLT_PR + 6)),
("push $%d ; jmp PLT[0]" % IDX, "0x%06x" % (PLT_PR + 6),
"把“第 %d 号重定位”压栈,交给 PLT[0]" % IDX),
("PLT[0]: push GOT[1] ; jmp *GOT[2]", "0x%06x" % PLT0,
"跳到 _dl_runtime_resolve:查符号表,找到 printf 真地址"),
("_dl_runtime_resolve 收尾", "(库代码)",
"把真地址写回 GOT[%d],然后跳到 printf" % (IDX + 3)),
]
print(" " + pad("步骤", 34) + pad("位置", 12) + "做什么")
for a, b, c in first:
print(" " + pad(a, 34) + pad(b, 12) + c)
print()
printf_addr = 0x7F2A1C401000
print(" 解析前的 GOT[%d] = 0x%06x → 解析后 = 0x%06x(printf 真地址)"
% (IDX + 3, PLT_PR + 6, printf_addr))
print()
print("=== ③ 之后每次调用:只剩一次跳转 ===")
print(" call printf@plt → jmp *GOT[%d] → 直接进 printf" % (IDX + 3))
print(" 与“直接调用”相比,只多一次内存间接跳转(GOT 表项通常在 Cache 里)")
print()
print("=== ④ 延迟绑定的收益(量级示意) ===")
TOTAL_SYMS = 500 # 程序引用到的外部符号总数
USED_SYMS = 30 # 实际会执行到的
PER_COST_US = 3.0 # 单次解析约 3 微秒
print(" 全部立即绑定:%d × %.1f μs = %.0f μs = %.2f ms"
% (TOTAL_SYMS, PER_COST_US, TOTAL_SYMS * PER_COST_US,
TOTAL_SYMS * PER_COST_US / 1000))
print(" 延迟绑定 :%d × %.1f μs = %.0f μs = %.2f ms"
% (USED_SYMS, PER_COST_US, USED_SYMS * PER_COST_US,
USED_SYMS * PER_COST_US / 1000))
saved = 100.0 * (TOTAL_SYMS - USED_SYMS) / TOTAL_SYMS
print(" 启动期解析量减少 %.1f%%" % saved)
print(" → 这就是为什么默认是 lazy;而实时/安全敏感场景会改用 -z now 换确定性")预期输出:
=== ① PLT 表项的字节布局(x86-64) ===
PLT[printf] @ 0x401040
偏移 指令 编码 长度 地址范围
+0x0 jmp *GOT[3] ff 25 <disp32> 6 0x401040 ~ 0x401045
+0x6 push $0 6a 00 2 0x401046 ~ 0x401047
+0x8 jmp PLT[0] e9 <rel32> 5 0x401048 ~ 0x40104c
实际用掉 13 字节,但表项按 16 字节对齐 → 浪费 3 字节(18.8%)
下一个 PLT 表项从 0x401050 开始(+5 个表项 → +80 字节)
★ GOT 索引从 3 起:前 3 项被 .dynamic / link_map / _dl_runtime_resolve 占用
printf 的地址槽 = GOT 起始 0x404000 + 8 × 3 = 0x404018
=== ② 第一次调用:三次跳转 + 一次解析 ===
步骤 位置 做什么
call printf@plt 0x401012 跳到 PLT[printf] = 0x401040
jmp *GOT[3] 0x401040 此刻 GOT[3] 指向 PLT 表项内部(0x401046)→ 等于没跳走
push $0 ; jmp PLT[0] 0x401046 把“第 0 号重定位”压栈,交给 PLT[0]
PLT[0]: push GOT[1] ; jmp *GOT[2] 0x401030 跳到 _dl_runtime_resolve:查符号表,找到 printf 真地址
_dl_runtime_resolve 收尾 (库代码) 把真地址写回 GOT[3],然后跳到 printf
解析前的 GOT[3] = 0x401046 → 解析后 = 0x7f2a1c401000(printf 真地址)
=== ③ 之后每次调用:只剩一次跳转 ===
call printf@plt → jmp *GOT[3] → 直接进 printf
与“直接调用”相比,只多一次内存间接跳转(GOT 表项通常在 Cache 里)
=== ④ 延迟绑定的收益(量级示意) ===
全部立即绑定:500 × 3.0 μs = 1500 μs = 1.50 ms
延迟绑定 :30 × 3.0 μs = 90 μs = 0.09 ms
启动期解析量减少 94.0%
→ 这就是为什么默认是 lazy;而实时/安全敏感场景会改用 -z now 换确定性三条结论:
| 结论 | 说明 |
|---|---|
| PLT 表项是"13 字节代码 + 3 字节填充" | 按 16 对齐是为了快速寻址(一条 call rel32 直接算出表项地址) |
| GOT 前 3 项被系统征用 | 所以第 0 个外部函数的地址槽是 GOT[3]——这个偏移是硬规定的 |
| 首次调用是"三次跳转 + 一次解析" | 之后只剩一次间接跳转——这就是"延迟绑定:首次慢、之后免费"的准确含义 |
例 3:动态依赖与符号覆盖的实战形态
任务:看运行期加载(dlopen)的用法与"符号覆盖"的判断顺序。
c
/* dyn_demo.c —— 运行期动态加载:连“依赖谁”都可以推迟到运行时决定 */
#include <stdio.h>
#include <dlfcn.h>
int main(void) {
void *h = dlopen("libm.so.6", RTLD_NOW); /* 加载期动态链接之外的第二条路 */
if (!h) {
fprintf(stderr, "%s\n", dlerror());
return 1;
}
/* 注意:函数指针的转换要按真实签名来写 */
double (*my_sqrt)(double) = (double (*)(double))dlsym(h, "sqrt");
if (!my_sqrt) {
fprintf(stderr, "%s\n", dlerror());
dlclose(h);
return 1;
}
printf("sqrt(2) = %.6f\n", my_sqrt(2.0));
dlclose(h);
return 0;
}编译与运行示意(本机无 C 编译器,这里只给命令形态):
bash
cc dyn_demo.c -o dyn_demo -ldl # -ldl 是因为 dlopen/dlsym 在 libdl 里
./dyn_demo # 打印 sqrt(2) = 1.414214用 Python 模拟"符号搜索顺序":
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
print("=== ld.so 的全局符号搜索顺序(谁在前谁赢) ===")
ORDER = [
("① 可执行文件本身", ["main", "my_malloc"], "程序自己定义的符号优先"),
("② LD_PRELOAD 的库", ["malloc", "free"], "预加载库可以“盖住”后面的同名符号"),
("③ 直接依赖(libc.so.6)", ["printf", "malloc", "free", "open"], "程序自己要求的库"),
("④ 依赖的依赖(递归)", ["memcpy", "strlen"], "深度优先展开依赖树"),
]
print(" " + pad("顺序", 26) + pad("提供什么符号", 36) + "说明")
for a, syms, note in ORDER:
print(" " + pad(a, 26) + pad("、".join(syms), 36) + note)
print()
print("=== 同名的 malloc 会取哪一个? ===")
defs = [layer for layer, syms, _ in ORDER if "malloc" in syms]
print(" 定义了 malloc 的层:" + ("、".join(defs) if defs else "无"))
print(" 规则:按上面的顺序取第一个 → %s" % defs[0])
print(" 于是 `LD_PRELOAD=./mymalloc.so ./app` 就能在不改 app 的前提下替换 malloc")
print()
print(" 前提条件:替换版必须提供兼容的函数签名(否则 ABI 不匹配,行为未定义)")
print()
print("=== dlopen 的两个关键标志 ===")
FLAGS = [
("RTLD_LAZY", "懒解析:用到才解析(默认行为)"),
("RTLD_NOW", "立刻解析全部符号:加载时就把错误暴露出来,推荐"),
("RTLD_GLOBAL", "把这个库的符号加入全局搜索表(后续加载的库也能用)"),
("RTLD_LOCAL", "只给本句柄用(默认)——插件之间不互相污染"),
]
print(" " + pad("标志", 14) + "含义")
for a, b in FLAGS:
print(" " + pad(a, 14) + b)
print()
print(" ★ 插件系统常用 RTLD_NOW | RTLD_LOCAL:早失败、不污染")预期输出:
=== ld.so 的全局符号搜索顺序(谁在前谁赢) ===
顺序 提供什么符号 说明
① 可执行文件本身 main、my_malloc 程序自己定义的符号优先
② LD_PRELOAD 的库 malloc、free 预加载库可以“盖住”后面的同名符号
③ 直接依赖(libc.so.6) printf、malloc、free、open 程序自己要求的库
④ 依赖的依赖(递归) memcpy、strlen 深度优先展开依赖树
=== 同名的 malloc 会取哪一个? ===
定义了 malloc 的层:② LD_PRELOAD 的库、③ 直接依赖(libc.so.6)
规则:按上面的顺序取第一个 → ② LD_PRELOAD 的库
于是 `LD_PRELOAD=./mymalloc.so ./app` 就能在不改 app 的前提下替换 malloc
前提条件:替换版必须提供兼容的函数签名(否则 ABI 不匹配,行为未定义)
=== dlopen 的两个关键标志 ===
标志 含义
RTLD_LAZY 懒解析:用到才解析(默认行为)
RTLD_NOW 立刻解析全部符号:加载时就把错误暴露出来,推荐
RTLD_GLOBAL 把这个库的符号加入全局搜索表(后续加载的库也能用)
RTLD_LOCAL 只给本句柄用(默认)——插件之间不互相污染
★ 插件系统常用 RTLD_NOW | RTLD_LOCAL:早失败、不污染三条结论:
| 结论 | 说明 |
|---|---|
| 符号搜索顺序决定"谁被选中" | 可执行文件 → LD_PRELOAD → 直接依赖 → 间接依赖;靠前者赢 |
LD_PRELOAD 是"不改程序就替换函数"的官方通道 | 代价是要求 ABI 兼容,且只对动态符号有效 |
dlopen 把"依赖谁"也推迟到运行时 | 插件/可选功能的实现路径;RTLD_NOW 能避免"跑到一半才发现符号缺失" |
考点
考点
1. 静态与动态的核心区别(必背)
- 静态:链接期定地址,库代码复制进可执行文件——体积大、内存不共享、升级要重编,但单文件、跑哪都能跑;
- 动态:加载期定地址,库代码在
.so里被mmap——体积小、内存共享、换库即升级,但启动稍慢、依赖环境; - 谁做重定位:静态是
ld(链接期);动态是ld.so(加载期)。
2. 为什么必须 PIC
- 共享库要被"任意程序、加载到任意地址"(多程序复用 + ASLR + 布局不定);
- PIC 的两条路线:模块内用 PC 相对;跨模块经 GOT 取绝对地址;
- 库必须用
-fPIC编译——否则会在链接期报relocation R_X86_64_32 ... can not be used when making a shared object; recompile with -fPIC; - PIE:可执行文件自己也可 PIC(
ET_DYN),配合 ASLR。
3. GOT 与 PLT 的分工
| GOT | PLT | |
|---|---|---|
| 形态 | 数据(地址表) | 代码(跳板) |
| 位置 | .got / .got.plt(可写) | .plt(只读可执行) |
| 谁填 | ld.so | 链接器生成,不改 |
- 为什么要分开:代码段要只读且共享,所以"要改的地址"必须放在可写数据段里;
GOT[0]=.dynamic、GOT[1]=link_map、GOT[2]=_dl_runtime_resolve——所以函数地址从GOT[3]起。
4. 延迟绑定(lazy binding)
- 含义:GOT 表项初始指向"PLT 表项里的
push指令",首次调用触发解析,之后直接跳真地址; - 首发路径:
PLT[IDX]→push IDX→jmp PLT[0]→_dl_runtime_resolve→ 回填 GOT → 真函数(三次跳转 + 一次解析); - 后续路径:
PLT[IDX]→jmp *GOT[IDX+3]→ 真函数(一次间接跳转); - 开关:
-z lazy(默认) 与-z now/LD_BIND_NOW=1(立即全部解析); - 为什么默认 lazy:程序用到的外部符号通常只是其引用的一小部分(例 2 的量级:500 → 30)。
5. 符号插入与 LD_PRELOAD
- 搜索顺序:可执行文件 →
LD_PRELOAD→ 直接DT_NEEDED→ 间接依赖; - 靠前者胜,所以
LD_PRELOAD可以在不改程序的情况下替换库函数(打补丁、调试、拦截); - 前提是 ABI 兼容;只对"动态符号表
.dynsym里的符号"有效; - 这也是"动态链接运行略慢"的隐藏原因:全局变量/函数默认可被插入,编译器不敢随意优化,必要时走 GOT。
6. SONAME、真实文件名与符号版本
- 程序里记的是
DT_NEEDED(如libfoo.so.1),不是libfoo.so; libfoo.so只是"链接期用的软链";libfoo.so.1.2.3是实现版本;- 兼容升级:只换
.1.2.3,SONAME不变;不兼容就升SONAME到.2(新老并存); - 符号版本:
printf@GLIBC_2.2.5——同名不同版本共存,是老程序还能跑的原因。
7. .a 与 .so 的机制差别(易混)
.a 静态库 | .so 共享库 | |
|---|---|---|
| 本质 | .o 的归档(ar 打包) | 一个完整的 ELF(ET_DYN) |
| 链接时 | 按需抽取成员(lang/22) | 只记依赖(DT_NEEDED) |
| 加载时 | 不适用(代码已在程序里) | 整块 mmap |
| 顺序敏感 | 敏感(库必须在引用之后) | 不敏感(记的是依赖名) |
| PIC | 不需要 | 必须(-fPIC) |
8. 高频易错点
- "动态链接的程序一定更慢"是错的:额外开销只是启动解析(可延迟)与一次间接跳转;好处是共享内存与可升级;
- 库里的全局变量不能被"多进程共享":代码页只读才共享;数据段每个进程一份——所以库不能拿全局变量当跨进程通信;
-static完全静态链接在 glibc 下常有坑(NSS/getaddrinfo等需要动态加载模块的功能会失效或告警)——需要"绝对自包含"时通常改用 musl;LD_LIBRARY_PATH会改搜索路径:调试方便,但也是"同一个程序在两台机器上行为不同"的经典来源;gcc -lfoo找到的可能是.so也可是.a:要指定顺序可用-Wl,-Bstatic/-Wl,-Bdynamic或写全路径;.dynsym与.symtab不是一回事:前者是运行时必需(strip要保留),后者可被剥掉。
小结
- 静态链接:链接期定地址、库代码进程序——代价是体积重复、内存不共享、升级要重编。
- 动态链接:把重定位推迟到加载期——由
ld.so在启动时mmap库并填 GOT。 - PIC 是前提:共享库会被映射到未知地址(多程序复用 + ASLR),所以模块内用 PC 相对、跨模块走 GOT。
- GOT 存地址(数据、可写)、PLT 做跳板(代码、只读);
GOT[0..2]被系统征用,函数地址从GOT[3]起。 - 延迟绑定:首次调用三次跳转 + 一次解析,之后一次间接跳转;默认
lazy,可用-z now换确定性。 - 符号插入:搜索顺序是"可执行文件 →
LD_PRELOAD→ 直接依赖 → 间接依赖"——LD_PRELOAD就是"不改程序换函数"的通道。 SONAME与符号版本:程序记DT_NEEDED(libfoo.so.1),升级换实现文件即可;不兼容就升SONAME,新老并存。.a是归档(按需抽取、顺序敏感);.so是完整 ELF(整块映射、必须-fPIC)。- 一句话记住动态链接的价值:把"重复的库代码"变成"共享的一份",代价是把"确定"换成了"运行时才知道"。
回到主线:lang/22 让"名字接上、地址填上"发生在链接期;本篇让它发生在加载期——这正是操作系统要参与的地方。
于是最后一个问题浮出水面:一个可执行文件被"加载"时,操作系统到底做了什么?
.text、.data、.bss、堆、栈分别被摆在虚拟地址空间的哪里?argc/argv是谁写进栈的?堆是从哪儿开始长的?——24-image.md会把这张"进程内存映像"完整画出来,然后交给 L4【操作系统】。
评论(0)
当前浏览器不允许本地存储,评论无法保存。
还没有评论,来说两句。