21、函数与动态链接:共享库中的函数,dlsym动态加载函数,PLT/GOT的工作原理
动态链接这个话题,说实话,我当年刚入行时也觉得挺玄乎的。那时候写单片机程序,所有代码都编译成一个孤零零的二进制文件,哪有什么共享库的概念。后来转到Linux平台做嵌入式开发,第一次碰到动态库加载失败,程序直接崩溃,我才意识到——嗯,这里面的水挺深的。
今天我们就来聊聊共享库里的函数是怎么被调用的,dlsym怎么做到运行时加载函数,以及那个让无数人头疼的PLT和GOT到底在干什么。
共享库中的函数:静态链接 vs 动态链接
先想一个问题:你写了一个printf("hello"),这个printf函数到底在哪?
静态链接时,链接器会把printf的机器码直接拷贝到你的可执行文件里。好处是部署简单,坏处是文件体积大,而且如果libc更新了修复了bug,你得重新编译你的程序。
动态链接就不一样了。你的程序里只保留一个“指向printf的引用”,真正的printf代码留在libc.so里。程序启动时,动态链接器(ld-linux.so)负责把这些引用“补上”。
核心区别一句话:静态链接是“复制代码”,动态链接是“引用代码”。
我个人习惯在嵌入式Linux项目里尽量用动态链接,除非是那种极端资源受限的场景。为什么呢?因为动态库可以独立更新,修复安全漏洞时只需要替换一个.so文件,不用重新烧写整个固件。我在一个IoT网关项目里就吃过这个甜头——发现libcurl有漏洞,替换一个so文件就搞定了,省了大把时间。
dlsym:运行时动态加载函数
dlsym这个函数,说白了就是让你在程序跑起来之后,按名字去找一个函数。这有点像“函数版的反射”。
用法很简单:
#include <dlfcn.h>
void *handle = dlopen("libmymodule.so", RTLD_LAZY);
if (!handle) {
fprintf(stderr, "dlopen failed: %s\n", dlerror());
return -1;
}
// 定义一个函数指针类型
typedef int (*math_func_t)(int, int);
// 用dlsym查找函数
math_func_t add = (math_func_t)dlsym(handle, "add");
if (!add) {
fprintf(stderr, "dlsym failed: %s\n", dlerror());
dlclose(handle);
return -1;
}
// 调用动态加载的函数
int result = add(3, 4);
printf("result = %d\n", result);
dlclose(handle);
你想想看,这有什么用?插件系统!我做过一个数据采集平台,不同的传感器需要不同的解析算法。每个传感器驱动编译成一个.so文件,主程序启动时扫描目录,用dlopen加载,用dlsym找到解析函数。加一个新传感器?扔一个.so文件进去就行,主程序一行代码都不用改。
避坑指南:我曾经在dlsym返回的指针上直接调用函数,结果段错误。后来发现是函数签名对不上——我声明的是int (*)(int, int),但库里实际导出的是long (*)(long, long)。参数大小不匹配,栈就乱了。记住:dlsym不检查类型,全靠你自己保证一致。
PLT和GOT:延迟绑定的艺术
好,现在进入重头戏。PLT(Procedure Linkage Table)和GOT(Global Offset Table)是动态链接的核心机制。很多初学者觉得这两个东西很抽象,我换个角度来讲。
假设你的程序调用了printf。编译时,编译器并不知道printf在内存的哪个地址——因为libc.so还没加载呢。那怎么办?
答案是:编译器生成一段“跳板代码”,放在PLT里。每次调用printf,实际上先跳到PLT中对应的条目,再由PLT跳转到真正的printf地址。
但真正的printf地址在哪?在GOT里。GOT是一张表,每个动态函数对应一个条目,里面存放着函数的实际地址。
那GOT里的地址什么时候填进去的?有两种策略:
| 策略 | 时机 | 优点 | 缺点 |
|---|---|---|---|
| 立即绑定(BIND_NOW) | 程序启动时,所有函数地址解析完成 | 调用时无延迟,地址固定 | 启动慢,加载所有符号 |
| 延迟绑定(BIND_LAZY) | 第一次调用函数时才解析地址 | 启动快,只解析用到的函数 | 第一次调用有额外开销 |
延迟绑定是怎么实现的?第一次调用时,PLT跳转到GOT,但GOT里存的还不是printf的地址,而是指向PLT中下一段代码——这段代码会调用动态链接器去查找printf,找到后把地址写回GOT。第二次调用时,GOT里已经是真实地址了,直接跳过去,不再经过动态链接器。
这个过程,业内叫“蹦床机制”(trampoline)。名字挺形象,你跳过去,弹回来,再跳过去。
注意:如果你在信号处理函数或者中断上下文里调用动态链接的函数,一定要小心。因为延迟绑定可能触发动态链接器的代码执行,而动态链接器不是可重入的。我曾经在SIGALRM处理函数里调用了printf,结果死锁了——printf触发了PLT解析,PLT解析需要拿锁,而主线程刚好也拿着那把锁。嗯,血的教训。
PLT/GOT的工作流程图
下面这张图展示了延迟绑定下,第一次调用和后续调用的完整流程:
这张图里,红色虚线表示第一次调用的路径,黑色实线表示后续调用的路径。你看,第一次调用绕了一大圈,后续调用就是直线跳转。这就是延迟绑定的精髓——把启动时的开销分摊到第一次调用时。
实战中的PLT/GOT
你可以用objdump -d或者readelf -r来查看你的程序里PLT和GOT的真实面貌。我习惯这样操作:
# 查看PLT条目
objdump -d /usr/bin/ls | grep -A5 'printf@plt'
# 查看GOT重定位
readelf -r /usr/bin/ls | grep printf
你会看到类似这样的输出:
0000000000005b20 <printf@plt>:
5b20: ff 25 f2 34 20 00 jmp *0x2034f2(%rip) # 跳转到GOT
5b26: 68 00 00 00 00 push $0x0
5b2b: e9 e0 ff ff ff jmp 0x5b10 # 跳转到PLT解析器
第一行jmp *0x2034f2(%rip)就是跳转到GOT条目。如果GOT里还没填真实地址,就跳到第二行push $0x0,然后跳到PLT的公共解析代码去调用动态链接器。
一个小技巧:如果你在调试时想强制所有符号立即解析(避免延迟绑定带来的不确定性),可以设置环境变量LD_BIND_NOW=1。我在做性能分析时经常这么干,确保测量的是真实调用开销,而不是第一次调用的解析开销。
总结一下
动态链接的核心就是“间接跳转”。PLT是跳板,GOT是地址表,dlsym是运行时查找工具。这三者配合,让我们的程序可以灵活地加载和调用共享库中的函数。
你想想看,没有动态链接,Linux上那么多程序共享一份libc,每个程序都拷贝一份printf的代码,那得浪费多少内存?PLT/GOT虽然增加了调用链路的复杂度,但换来了内存节省和更新灵活性,这笔账是划算的。
嗯,关于动态链接的底层原理,今天就聊到这里。下次你遇到undefined symbol或者segfault in dynamic linker的时候,至少知道该往哪个方向排查了。