news 2026/9/30 6:16:01

动态链接核心机制解析:PIC、GOT/PLT与延迟绑定实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
动态链接核心机制解析:PIC、GOT/PLT与延迟绑定实战

动态链接这事儿,说大不大说小不小。我见过不少人,编译个 C 程序报undefined reference会解决,一到了运行时报error while loading shared libraries就抓瞎,更别提让他解释PLT、GOT、延迟绑定这些词到底在干什么。其实动态链接就是程序从源码变成进程的最后一个关键环节,搞懂它,很多莫名其妙的运行时报错你一眼就能看穿。

这篇文章我打算从“为什么需要动态链接”开始讲,把地址无关代码、GOT/PLT、动态链接器的工作流程、搜索路径全部拆开揉碎,最后带你自己动手做一遍实验,亲自观察一次动态链接的完整过程。适合 C/C++ 开发者、对系统底层好奇的运维同学,以及所有被链接错误折磨过的人。我尽量说人话,但底层机制该讲透的地方绝不绕弯子。

1. 动态链接到底解决什么问题

1.1 从静态链接说起

要理解动态链接,必须先知道静态链接是怎么回事。早期程序开发基本就是静态链接:你写了一个main.c,调用了一个printf,编译链接的时候,链接器会把printf的机器码从libc.a这个静态库里直接拷贝一份,塞进你的可执行文件里。最后生成的可执行文件是自包含的,拿到任何一台同架构的 Linux 机器上都能跑,这是它最大的优点。

但缺点很致命:假如系统里有 100 个程序都调用了printf,那么磁盘上就有 100 份printf的机器码副本。这还只是磁盘占用的问题,更麻烦的是内存。每个程序运行的时候,这 100 份printf会被各自加载到内存里,物理内存中就存了 100 份一模一样的代码。按现在动辄几百 MB 的库来算,这种浪费是难以接受的。

另外还有一个维护性的问题。如果printf内部发现了一个安全漏洞需要修复,静态链接的世界里,你没有任何办法——除非把系统里所有依赖这个函数的程序全部重新编译一遍,再重新分发。这在一个大型系统里几乎是不可能完成的任务。

1.2 动态链接的设计思路

动态链接的思路完全反过来了。可执行文件里不再存放库函数的机器码,只存放一个“引用说明”——告诉系统,我这个程序需要用到哪个共享库里的哪个符号。真正的代码留在.so文件里,程序运行之前,由系统里一个叫做动态链接器的程序去找到这些.so,把它们加载进内存,再把可执行文件里的引用和库里的实际地址关联起来。

这个方案带来的好处是肉眼可见的。第一,磁盘和内存都省了,100 个程序共享一份libc.so,物理内存里只放一份.text段,大家通过页表映射共享它,内存占用从 100 份变成 1 份加一些私有的数据段。第二,升级变得无比简单,修复了libc.so的漏洞,直接替换这个.so文件,重启依赖它的程序就生效了,不需要重新编译任何业务代码。

不过别高兴太早,动态链接不是免费的午餐。它引入了新的运行时开销——程序启动时动态链接器要干活,符号的解析也需要时间;它引入了新的失败模式——运行时报“找不到 .so”、报“undefined symbol”,这些都是静态链接时代不存在的错误。你写代码的时候编译通过了,不代表程序就能跑起来,这就是动态链接给所有开发者的“惊喜”。

1.3 两种链接方式的对比

维度静态链接动态链接
可执行文件大小大,包含全部依赖代码小,只包含引用信息
内存占用每个进程各存一份共享库物理内存共用一份
启动速度快,直接运行慢,需要动态链接器解析
升级维护需要重新编译所有依赖者替换 .so 即可
兼容性风险低,自包含高,依赖系统库版本
部署便利性拷贝即用需要带上所有依赖库

从这张表能看出来,动态链接不是在所有场景都优于静态链接。做嵌入式开发、做容器镜像的朋友经常会有意识地使用静态链接,就是为了避免动态库依赖带来的部署噩梦。理解了这个背景,再看后面的机制就顺理成章了。

2. 动态链接的三大核心机制

2.1 地址无关代码(PIC)

动态链接面临一个根本性的问题:.so文件在被加载进内存的时候,加载地址是不确定的。同一个.so,在进程 A 里可能被加载到0x7f0000000000,在进程 B 里可能被加载到0x7f1111111000。如果代码里面直接写了绝对地址,那这个.so只能被加载到固定位置,否则一运行就崩。

解决办法就是编译时加-fPIC。PIC 是 Position Independent Code 的缩写,它的核心思想是:代码段里不直接引用绝对地址,所有对全局变量和函数的访问都通过一个间接层——全局偏移表(GOT)来完成。代码里存的只是一个“偏移量”,程序加载后,动态链接器算出.so的真实基地址,再把这个基地址加上偏移量填充进 GOT,代码通过 GOT 间接访问数据,就和加载地址无关了。

你会问,为什么链接器不直接修改代码里的地址?因为代码段在进程间是共享的,如果每个进程都要修改代码段里的地址,就破坏了共享性,必须为每个进程复制一份私有的代码段副本。而 GOT 是数据段,每个进程可以有自己的一份,修改 GOT 不影响共享的代码段。这就是“地址无关”的精髓——共享代码,私有数据。

2.2 GOT 与 PLT 的分工

很多人把 GOT 和 PLT 混在一起讲,其实它们分工完全不同。GOT 是全局偏移表,它存放的是地址数据:全局变量的地址、函数的真实地址。而 PLT 是过程链接表,它存放的是一小段跳转指令,是给函数调用准备的“跳板”。

简单梳理一下函数调用的路径。你写的代码调用add(1, 2),编译后生成的指令是call add@plt。add@plt是 PLT 里的一项,它的内容大致是:先跳转到 GOT 里记录的地址,如果 GOT 里还没填上add的真实地址,就触发动态链接器去解析,解析完再填回 GOT;如果已经填过了,就直接跳到目标函数。这个设计精妙在它把“调用函数”这个行为统一变成了“查 GOT”,而 GOT 的内容是运行时才确定的,完美配合 PIC 方案。

为什么要把全局变量和函数分开处理?因为两者的访问模式不同。全局变量是直接读数据,编译器会把对全局变量的访问编译成通过 GOT 间接寻址的指令,比如mov rax, [rip + GOT_offset]。函数则是通过 PLT 跳板加一层间接跳转。实际调试的时候,你用objdump -d看一个动态链接的可执行文件,会看到大量@plt结尾的调用指令,那就是在走 PLT 这条路径。

2.3 延迟绑定

如果程序一启动就把所有依赖库里的所有函数都解析好,那启动速度会慢得吓人。实际上,很多程序用到的库函数只占库导出函数的一小部分,比如一个程序可能只用了libc.so里的 20 个函数,而 libc 导出了几千个符号。全部解析明显不划算。

于是出现了延迟绑定,也叫懒绑定。它的思路是:函数第一次被调用的时候才去解析真实地址,后面再调用就直接命中。实现方式就是 PLT 和 GOT 配合——GOT 里初始存放的是 PLT 下一行指令的地址,而不是真实函数的地址。第一次调用的时候,程序会跳到 PLT,PLT 跳到 GOT,发现 GOT 里存的是 PLT 下一条指令,于是相当于跳到了 PLT 里的解析代码,由它把真实地址解析出来并写回 GOT,然后再跳转到真实函数。第二次调用,GOT 里已经是真实地址了,一条指令就直接过去了。

你可以用环境变量LD_BIND_NOW=1来关闭延迟绑定,让动态链接器在程序启动的时候就把所有符号解析完。这通常用于安全敏感的场景,因为延迟绑定意味着 GOT 在运行期会被写入,如果程序有漏洞,攻击者可能利用 GOT 覆写来劫持控制流。后面的实验部分我会带你亲眼观察这个由“未解析”到“已解析”的变化。

3. 动态链接器的工作流程与搜索路径

3.1 编译阶段写入了什么

动态链接不是运行时的独角戏,它从编译阶段就开始准备了。当你执行gcc -fPIC -shared -o libfoo.so foo.c的时候,编译器和链接器会生成一个特殊的 ELF 文件,里面包含一个动态段,记录了各种元信息。

你可以用readelf -d查看这个动态段。里面会有NEEDED条目,标明这个.so依赖哪些其他库;有SONAME条目,相当于这个库的“身份证名字”,程序记录依赖时记的是 SONAME 而不是文件名,这样文件名改了,只要 SONAME 不变,程序照样能找到它;可执行文件里还会有RPATH或RUNPATH,预先指定搜索库的路径。

还有一个容易被忽略的东西叫作.interp段。它记录了动态链接器本身的路径,在 Linux 上一般是/lib64/ld-linux-x86-64.so.2。内核加载可执行文件的时候,会先读这个段,找到动态链接器,把它也加载进进程,然后把控制权交给它,而不是直接交给程序的入口函数。这也就是说,动态链接的程序真正意义上的“第一个执行者”是动态链接器。

3.2 加载阶段动态链接器做了什么

动态链接器的工作可以分成几步。第一步,它要先把自己“安顿好”——因为动态链接器本身也依赖一些库,这有个鸡生蛋的问题,动态链接器在编译时使用了特殊技巧,它内部的依赖在加载的第一步就要手工处理好,这部分非常底层,一般开发者不用深究。

第二步,它读取可执行文件的动态段,找出所有NEEDED条目,逐一把依赖的共享库加载进内存。库本身又有自己的依赖,所以这是一个递归的过程,最终构建出一张完整的依赖图。加载库的过程就涉及路径搜索。

第三步就是重定位和符号解析。动态链接器遍历每个库和可执行文件的重定位表,根据符号表找到每个符号的真实地址,然后填充 GOT 和其他需要修正的位置。这一步是最耗时的。做完这些之后,它才调用每个库的初始化函数,最后跳转到程序的入口_start,程序正式开始执行。

3.3 搜索路径的顺序

动态链接器查找共享库的路径有一套明确的顺序,搞懂这个顺序,大部分“找不到库”的问题都能自己解决。我按优先级从高到低列出来:

  1. DT_RPATH:可执行文件动态段里记录的绝对路径,已废弃不推荐使用。
  2. LD_LIBRARY_PATH环境变量指定的路径列表。
  3. DT_RUNPATH:可执行文件动态段里记录的路径,优先级比LD_LIBRARY_PATH低。
  4. /etc/ld.so.cache:这是ldconfig生成的缓存文件,记录了系统已知的共享库位置。
  5. 默认目录,一般是/lib、/usr/lib,64 位系统还有/lib64、/usr/lib64。

有一个很关键且容易被忽略的规则:DT_RPATH只在它自身存在时生效,而且它的优先级高于LD_LIBRARY_PATH;而DT_RUNPATH是在链接时用-Wl,-rpath加的新机制,它只影响它所在这个库或可执行文件的依赖查找,不影响库的依赖。所以如果你在可执行文件上设了DT_RUNPATH,它的依赖库再去查找自己的依赖时,DT_RUNPATH不会传递给下一级,这时候还是得靠LD_LIBRARY_PATH或者系统缓存。

注意:LD_LIBRARY_PATH在 setuid 和 setgid 的程序上会被内核和动态链接器直接忽略,因为环境变量太容易被利用了。遇到 suid 程序报找不到库,别想着靠环境变量解决,要么装到系统目录,要么重新编译指定 RUNPATH。

4. 实操:亲手观察一次动态链接

4.1 准备实验文件

理论讲再多,不如自己跑一遍。我这里准备两个文件,一个共享库,一个可执行程序:

// libmath.c #include <stdio.h> int add(int a, int b) { int result = a + b; printf("add called, result = %d\n", result); return result; }
// main.c #include <stdio.h> extern int add(int a, int b); int main() { int sum = add(3, 4); printf("sum = %d\n", sum); return 0; }

这个例子足够简单,但完整覆盖了函数调用、全局变量访问打印、标准库依赖这些动态链接的核心场景。先编译共享库,再编译可执行程序:

gcc -fPIC -shared -o libmath.so libmath.c gcc -o app main.c -L. -lmath

注意第二行命令里的-L.是告诉编译期的链接器,在当前目录找libmath.so;-lmath是链接libmath.so的缩写形式。不加-L.的话,库在默认搜索路径里找不到,编译就会报错。

4.2 读取 ELF 信息

先用readelf看看可执行文件的动态段:

readelf -d app | grep -E 'NEEDED|RPATH|RUNPATH'

正常情况下你会看到类似这样的输出:

0x0000000000000001 (NEEDED) 共享库:[libmath.so] 0x0000000000000001 (NEEDED) 共享库:[libc.so.6]

这说明app的动态段里只是记录了依赖关系,并没有包含add函数的任何代码。再看一下.interp段:

readelf -l app | grep INTERP

输出会显示动态链接器的路径,比如/lib64/ld-linux-x86-64.so.2。这个路径就是内核加载完app之后,要去加载的动态链接器的位置。

4.3 观察 PLT 和 GOT

用objdump -d反汇编app,重点看main函数和 PLT 段:

objdump -d app | grep -A 20 '<main>:'

你会看到对add的调用不是call <add 的地址>,而是call 401030 <add@plt>。跳到了 PLT 段。再单独看 PLT:

objdump -d app | grep -A 10 '<add@plt>:'

输出大致是这样:

0000000000401030 <add@plt>: 401030: ff 25 e2 2f 00 00 jmp *0x2fe2(%rip) # 404018 <add@plt+0x18> 401036: 68 00 00 00 00 push $0x0 40103b: e9 e0 ff ff ff jmp 401020 <add@plt+0xfffffffffffffff0>

这段就是延迟绑定的入口。第一行跳转到 GOT 里记录的地址,注释里的404018就是这个函数的 GOT 条目地址。第一次执行的时候,GOT 里存的是下一条指令的地址401036,于是跳回来执行push $0x0——这个 0 是符号在重定位表里的序号——然后跳到 PLT 的第一条统一入口,动态链接器根据序号去解析add的真实地址,写回 GOT。

4.4 用 LD_DEBUG 观察运行时的解析过程

GLIBC 的动态链接器内置了一个调试开关LD_DEBUG,能打印出内部的每一个步骤。这是理解动态链接最直观的工具。

先看libs,查看库的加载顺序:

LD_DEBUG=libs ./app

输出会列出app依赖的libmath.so、libc.so.6等被依次加载的过程,包括它们最终被映射到的内存地址。再看bindings,观察符号是怎么绑定的:

LD_DEBUG=bindings ./app

你会看到add这个符号在第一次被调用时完成绑定。还有个reloc选项可以观察重定位的细节:

LD_DEBUG=reloc ./app

输出量很大,但你能看到动态链接器逐条处理重定位表项,把符号地址写入 GOT 的全过程。我建议你实际敲一遍这些命令,输出比任何文字都更有说服力。LD_DEBUG还支持help参数,列出所有可用选项,适合自己玩。

4.5 搜索路径实验

现在做一个小实验:把libmath.so从当前目录移走,再运行app:

mv libmath.so /tmp/ ./app

你会得到经典的报错:

./app: error while loading shared libraries: libmath.so: cannot open shared object file: No such file or directory

然后试试用环境变量救回来:

LD_LIBRARY_PATH=/tmp ./app

程序又能跑了。这就是LD_LIBRARY_PATH发挥作用的过程。但如果我重新编译,把路径写死进程序里:

gcc -o app main.c -L. -lmath -Wl,-rpath,/tmp ./app

再把/tmp/libmath.so删掉,程序照样报错。-Wl,-rpath路径虽然印在了 ELF 里,但库里没有就是没有,搜索路径再正确也找不到文件。如果把库放进默认搜索目录/usr/lib或者/usr/local/lib,然后运行ldconfig更新缓存,即使不设置环境变量程序也能找到,这就是生产环境最常见的部署方式。

5. 常见问题与排查技巧

5.1 找不到共享库

这是动态链接最常见的问题,报错长得都一样:

./app: error while loading shared libraries: libxxx.so: cannot open shared object file: No such file or directory

排查思路分三步走。第一步,用ldd app看所有依赖的库哪些找不到,输出里带“not found”字样的就是问题所在。第二步,确定这个库在系统里到底有没有,用find / -name "libxxx.so*"全局搜一下。第三步,如果库确实存在但没在默认搜索路径里,有三种解法:临时调试用LD_LIBRARY_PATH;长期使用用ldconfig+ 配置文件;打包部署用-Wl,-rpath把相对路径(比如$ORIGIN/lib)写进 ELF。

提示:ldd本身不是万能的,它会调用动态链接器来解析依赖。如果一个库是为不同架构编译的,ldd可能报错或者输出一些奇怪的提示,这时候用readelf -d配合readelf -h检查架构更可靠。

5.2 undefined symbol 的错误

运行时报undefined symbol: xxx比找不到库更难排查,因为它意味着库文件找到了,但某个符号在它的依赖项里找不到定义。常见原因有三个:

第一个是链接顺序问题,使用静态库时顺序不对会导致符号解析失败,这个是在链接期报错,但在动态库的场景里,常见于两个.so互相依赖,循环依赖没有处理干净。第二个是库版本不匹配,编译的时候链接的是高版本的库,运行的时候系统里只有低版本,低版本里没有这个符号。第三个是符号被隐藏了,比如用-fvisibility=hidden编译库但没有显式导出符号。

排查手段主要是用nm -D查看动态库的导出符号表,objdump -T也可以。先在报错的库里查一遍nm -D libxxx.so | grep symbol_name,如果找不到,就去它的依赖项里逐个查,直到定位到真正缺少符号的库。处理技巧上,如果是循环依赖,链接时用-Wl,--start-group和-Wl,--end-group包住库列表;如果是符号隐藏,检查代码里的导出宏是否设置正确。

5.3 版本冲突

动态链接的版本冲突是生产环境的高频故障,典型报错长这样:

/lib64/libc.so.6: version `GLIBC_2.34' not found (required by ./app)

GLIBC 有符号版本机制,新版本的库会带版本号导出符号,老版本库没有这些版本就无法满足要求。检查方法是用strings看看系统里实际 GLIBC 支持的版本:

strings /lib64/libc.so.6 | grep GLIBC_

对比一下报错里要求的版本是否在列表中。这类问题的坑在于,编译环境的 GLIBC 比运行环境新太多,解决的根本办法是让编译环境与运行环境匹配。临时缓解可以用patchelf修改 ELF 里的依赖版本记录,但这是下下策,容易引入更大的兼容性问题,不推荐。更靠谱的做法是使用容器或者旧系统镜像来编译,保证二进制和运行环境一致。

5.4 符号被“截胡”

还有一种隐蔽的坑叫符号插桩,也叫全局符号覆盖。动态链接器解析符号的时候有一个规则:如果一个符号在可执行文件本身里有定义,那么共享库里的同名全局符号会被“忽略”,可执行文件里的定义会胜出,哪怕它后面才被加载。这就是为什么有人自定义了一个malloc函数链接进程序,能“覆盖”掉 libc 的malloc。这可以当作一种调试或干预手段,但也会带来莫名其妙的问题。

常见的坑是:你在程序里定义了一个通用名字的全局函数,比如debug,恰好某个.so里也有一个debug函数且没有声明static,那么.so内部对debug的调用会被劫持到你的函数,导致完全无法预料的运行结果。排查这类问题很费劲,因为代码看起来完全正常。

解决办法是编译库的时候使用-fvisibility=hidden加上显式导出宏,只暴露你真正想公开的符号;链接可执行文件时用-Wl,-Bsymbolic让共享库优先解析自己的内部符号。平时写库代码,养成给内部函数加static的习惯,也是规避这个问题的基本功。

5.5 排查工具速查表

场景首选工具示例命令
查看依赖列表lddldd app
查看动态段元信息readelfreadelf -d app
查看符号表nm / objdumpnm -D libxxx.so
查看重定位表readelfreadelf -r libxxx.so
跟踪链接过程LD_DEBUGLD_DEBUG=all ./app
查看库缓存ldconfigldconfig -p
配置库搜索路径ldconfigecho "/opt/mylib" > /etc/ld.so.conf.d/mylib.conf && ldconfig

把这几个命令用熟,动态链接相关的问题基本都能用它们定位到自行解决。我自己排查线上问题的时候,LD_DEBUG是压箱底的杀手锏,虽然输出量大,但它能明确告诉你符号是从哪里解析的、路径是在哪一步断掉的,信息量和效率远超猜来猜去。

最后再分享一个实操层面的小技巧。很多人改完LD_LIBRARY_PATH发现不生效就一脸懵,先确认你是不是用sudo执行的——sudo默认会重置环境变量。再看有没有 setuid 标志。排除了这些之后,就用LD_DEBUG=libs跑一下,动态链接器会老老实实把每一步搜索路径打印出来,问题出在哪一目了然。搞懂动态链接不是让你背概念,而是让你在报错面前能冷静下来,顺着机制一层层拆解,最后找到解药。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 6:15:37

中央集中式域控制器量产实战:从EEA重构到落地踩坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:15:10

PyTorch预训练模型实现本地图像搜索流水线

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:15:07

计算机组成原理:十种数据寻址方式的硬件实现与微操作解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:15:02

Windows 终端工具推荐:7款cmd替代品横向对比

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:13:51

MiniMax-H3 8G 显存 AI 漫剧进阶实战|ComfyUI 命令行调参、角色锁定与 FFmpeg 批量脚本全解析

前言 本篇承接 MiniMax-H3 基础部署教程&#xff0c;主打可直接复制运行的代码、启动指令、调参命令、FFmpeg 批量处理脚本&#xff0c;解决 8G 显存本地部署最常见的角色闪烁、拼接断层、显存溢出、动作变形、音画不同步等生产问题。 全文基于 Windows 本地一键整合包、Int8 …

作者头像 李华