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 延迟绑定工作流程 主程序 call printf@PLT PLT 跳转到 GOT[printf] GOT(第一次) 指向 PLT 解析代码 动态链接器 查找 printf 地址 libc.so printf 函数本体 GOT(后续调用) 直接指向 printf ③ 第一次调用时触发 ④ 找到地址 ⑤ 写回真实地址 ⑥ 后续调用直接跳转 主程序 PLT GOT(初始) 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的时候,至少知道该往哪个方向排查了。


公众号:蓝海资料掘金营,微信 deep3321