动态链接与 ld.so

本页目录

动态链接将部分地址选择推迟到加载或调用时。程序记录依赖与符号需求,加载器找到对象、建立映射并完成重定位;这样库可以在保持 ABI 的前提下独立更新。同一个库文件的干净代码页可被多个进程共享,私有数据与写时复制页则不能一概算作共享。

排查失败时应依次区分:库文件是否被找到,目标符号是否在正确作用域内,符号版本是否匹配,最后才是调用时的参数与对象布局是否兼容。文件名相同不等于 ABI 相同。

找库、找符号、写地址是三步

DT_NEEDED 记录依赖名。找到库后,加载器根据动态表定位符号、字符串、版本和重定位信息。重定位告诉加载器“哪处需要怎样计算和写入”,而符号查找决定某个名字在当前作用域中对应哪个定义。

所以加载了 libfoo.so 不等于所有 foo 引用必然绑定到它;可插入的全局符号可能被其他对象的定义满足。符号也不只是函数,数据、TLS 与 IFUNC 等有各自的处理要求。ELF 动态链接

PLT/GOT 为什么支持延迟绑定

经典 PLT 延迟绑定:首次解析,后续使用缓存地址 调用 foo@PLT 调用点保持稳定 读取 GOT 槽位 间接跳转目标 首次:解析器 按符号作用域查找 foo 写回 GOT 槽位 保存已解析地址 执行 foo 后续通常直达此处 已解析 示意机制以传统 ELF PLT 为例;不同架构、链接选项和 CET 会改变桩布局。

传统 x86-64 PLT 桩先通过 GOT 槽位跳转。未解析时它引向加载器的解析路径,解析器确定函数地址并通常更新槽位;以后调用复用这个结果。具体保留槽位数量、指令形式与重定位索引编码是架构实现细节,不应当作所有 ELF 的通用布局。

延迟绑定主要针对适用的函数调用重定位,数据引用不能任意等到第一次读取才补地址。-z now 或 LD_BIND_NOW 请求提前绑定;-fno-plt 等编译选项也可改变调用形式。因此“动态链接的代价就是每个函数第一次慢一点”并不完整。

GOT 也不只是“全局变量地址数组”。不同目标和重定位类型会把函数地址、数据地址及运行支持信息放在相应位置。errno 又是线程局部对象,不能拿 extern int errno 作为普通全局变量的教学例子。

第一次调用改变了哪个槽位?

先连续调用 foo,再调用 bar:第二次 foo 复用自己的解析结果,bar 的第一次调用仍可能进入解析器。画面把“当前槽位”与“最近一次调用路径”分开,避免把缓存状态误读成每次调用都会重复执行的流程。

正在呈现知识画面
第一次调用改变了哪个槽位?

延迟绑定按适用的重定位槽位解析。第一次调用更新对应目标,其余未调用符号可以仍未解析;即时绑定提前完成这些工作。

构建一个真正可运行的共享库实验

libfoo.c:

int foo(int x) { return x + 1; }

main.c:

#include <stdio.h>
extern int foo(int);
int main(void)
{
    printf("%d\n", foo(41));
    return 0;
}
cc -fPIC -shared libfoo.c -Wl,-soname,libfoo.so.1 -o libfoo.so.1
ln -sfn libfoo.so.1 libfoo.so
cc main.c -L. -lfoo -Wl,--enable-new-dtags,-rpath,'$ORIGIN' -Wl,-z,lazy -o dyn-demo
./dyn-demo
readelf -dW dyn-demo
objdump -R dyn-demo
LD_DEBUG=libs,bindings ./dyn-demo
LD_BIND_NOW=1 LD_DEBUG=bindings ./dyn-demo

实验应打印 42。链接时 -lfoo 找到 libfoo.so,库的 SONAME 使可执行文件通常记录 libfoo.so.1;RUNPATH 中的 $ORIGIN 让加载器在主程序所在目录找到它。单引号防止 shell 提前展开 $ORIGIN。LD_DEBUG 中还会出现 libc 等绑定,需要按 foo 定位自己的调用。

搜索路径:RUNPATH 不自动传给孙依赖

下面限定为 glibc 动态加载器。对于不含斜杠的依赖名,概括的搜索顺序是:

  1. 适用的 DT_RPATH,前提是没有对应的 DT_RUNPATH。
  2. LD_LIBRARY_PATH,安全执行模式下会被忽略。
  3. 发起依赖对象的 DT_RUNPATH,用于其直接 DT_NEEDED 依赖。
  4. /etc/ld.so.cache,包含系统缓存与相关硬件能力目录选择。
  5. 默认系统库目录,路径和是否跳过受架构及链接选项影响。

包含斜杠的依赖按路径处理,不走同样的名字搜索。RPATH 与 RUNPATH 的关键差别之一是传播范围:主程序的 RUNPATH 能找到 libA,不代表也能找到 libA 所需的 libB;libA 通常需要自己的合适搜索配置。$ORIGIN 按包含该标记的对象位置展开,不是当前工作目录。ld.so(8)

构建期的 -L 不会自动写成运行期路径。部署时复制了库却出现找不到文件,先检查 NEEDED、RUNPATH 和实际文件名,再考虑符号问题。

RUNPATH 跟随声明它的对象,只解决直接依赖 bin/app NEEDED: libA.so.1 lib/libA.so.1 NEEDED: libB.so.1 lib/libB.so.1 提供 b() app 的 RUNPATH $ORIGIN/../lib → 能找到 A A 自己的 RUNPATH $ORIGIN → 才为 A 的 B 依赖提供此目录 假定无环境路径或系统缓存命中;app 的目录配置不会自动替 A 继续搜索。

用两级依赖复现“主程序能找 A,却找不到 B”

在一个新的实验目录中建立 bin 与 lib。这里刻意让两个库位于同一目录,但第一版 A 不设置 RUNPATH。三个源文件分别如下。

chain-b.c:

int b(void) { return 40; }

chain-a.c:

extern int b(void);
int a(void) { return b() + 2; }

chain-main.c:

#include <stdio.h>
extern int a(void);
int main(void) { printf("%d\n", a()); return 0; }
mkdir -p bin lib
cc -fPIC -shared chain-b.c -Wl,-soname,libB.so.1 -o lib/libB.so.1
cc -fPIC -shared chain-a.c -Llib -l:libB.so.1 \
  -Wl,-soname,libA.so.1 -o lib/libA.so.1
cc chain-main.c -Llib -l:libA.so.1 -Wl,-rpath-link,lib \
  -Wl,--enable-new-dtags,-rpath,'$ORIGIN/../lib' -o bin/app
readelf -dW bin/app
readelf -dW lib/libA.so.1
./bin/app

在没有额外 LD_LIBRARY_PATH、预加载或系统同名库命中的干净环境中,最后一步应报告找不到 libB.so.1。app 的 RUNPATH 已经让它找到 A,但 B 是 A 的直接依赖。链接时的 -rpath-link,lib 只是帮助链接器找到间接依赖,不会把这个目录写成 A 的运行路径。GNU ld 路径选项

只重建 A,为它添加自己的 RUNPATH:

cc -fPIC -shared chain-a.c -Llib -l:libB.so.1 \
  -Wl,-soname,libA.so.1,--enable-new-dtags,-rpath,'$ORIGIN' \
  -o lib/libA.so.1
./bin/app

此时应输出 42。app 不必重新链接,因为它记录的仍是 libA.so.1。A 内的 $ORIGIN 展开为 lib 所在目录;即使从其他工作目录执行 app,这个相对对象位置的关系仍然成立。相对路径 dlopen("./plugin.so", ...) 则由当前工作目录解释,不能与 $ORIGIN 混用概念。

检查位置第一版修复版
app 的 NEEDEDlibA.so.1不变
app 的 RUNPATH$ORIGIN/../lib不变
A 的 NEEDEDlibB.so.1不变
A 的 RUNPATH没有$ORIGIN
执行结果B 找不到时启动失败本例打印 42

把目录统一塞入 LD_LIBRARY_PATH 也可能让本例成功,但那会改变进程的搜索环境,不能据此说明 A 已具备独立部署时需要的路径信息。排查时把环境值与对象内的动态标签分别记录。

谁需要这个库,谁提供搜索路径?

搜索路径附着在发起依赖的对象上。图中两条边分别由 app 和 A 发起,勾选 A 的路径才能补上第二条边;把工作目录改到其他位置并不会改变这里的 $ORIGIN 展开。

正在呈现知识画面
谁需要这个库,谁提供搜索路径?

RUNPATH 只用于该对象的直接依赖。主程序找到 A 不等于 A 能找到 B;$ORIGIN 以持有标签的对象目录展开。

LD_PRELOAD 的作用是符号插入

创建 override.c:

int foo(int x) { return x + 100; }
cc -fPIC -shared override.c -o override.so
LD_PRELOAD="$PWD/override.so" ./dyn-demo

在这个默认可插入符号的例子中应打印 141。它不意味着能覆盖任意代码:静态绑定、隐藏可见性、库内直接调用、编译器内联或某些链接优化,都可能让调用不经过可插入的动态查找。安全执行模式也会限制预加载。

用 LD_PRELOAD 包装 open 可以做观测或适配,但不能据此实现完整隔离:程序可能调用其他接口或直接执行系统调用。理解这里的边界有助于判断“预加载没生效”究竟是搜索问题还是调用路径问题。

dlopen:把对象生命周期也纳入接口

下面继续加载刚才的 libfoo.so.1。POSIX 的 dlsym 结果可用于相应函数指针;这是 POSIX 环境约定,不能推广为任意 ISO C 平台的对象/函数指针转换规则。

#include <dlfcn.h>
#include <stdio.h>

int main(void)
{
    void *handle = dlopen("./libfoo.so.1", RTLD_NOW | RTLD_LOCAL);
    if (handle == NULL) {
        fprintf(stderr, "%s\n", dlerror());
        return 1;
    }
    dlerror();
    int (*foo)(int) = (int (*)(int))dlsym(handle, "foo");
    const char *error = dlerror();
    if (error != NULL) {
        fprintf(stderr, "%s\n", error);
        dlclose(handle);
        return 1;
    }
    /* 本例已知 foo 是可调用函数;一般符号的 NULL 值未必表示查找错误。 */
    printf("%d\n", foo(41));
    if (dlclose(handle) != 0) {
        fprintf(stderr, "%s\n", dlerror());
        return 1;
    }
    return 0;
}

编译:cc -Wall -Wextra plugin-host.c -ldl -o plugin-host。运行应打印 42。

RTLD_NOW 提前解析需要的未定义符号,RTLD_LAZY 仅延迟适用的函数绑定;调用者必须选择相应模式,不能笼统说 LAZY 是所有调用的默认值。RTLD_LOCAL 限制符号进入全局作用域,不表示只有这一个 handle 才能看到对象。

先清除旧 dlerror,再在 dlsym 后检查新的错误。仅检查返回指针是否为 NULL 不足以覆盖所有符号情况。dlclose 会减少引用,但不保证立刻卸载;无论是否实际卸载,都不能在生命周期失去保障后继续使用缓存函数指针。dlopen(3)、dlsym(3)

卸载前需要收回的不只有一个函数指针

假设插件创建了一个对象并返回给宿主;对象的析构函数、虚函数表或回调仍指向插件代码。即便宿主已经不调用最初的 foo,析构这个对象时仍可能跳回插件。因此接口最好成对提供 create/destroy,让分配与释放责任明确,并要求对象在关闭最后一个有效库引用前销毁。

可将关闭过程理解为有前置条件的状态变化:

状态允许的操作进入下一状态的条件
接受工作创建对象、提交调用、注册回调停止入口接收新工作
排空等待已有调用和任务结束插件不再运行,异步回调已撤销且不在执行
回收调用插件自己的销毁接口所有需要插件代码的数据与回调已释放
关闭 handledlclose,移除宿主缓存入口后续没有任何路径再进入插件

这些步骤需要宿主自己的同步协议;RTLD_LOCAL 只影响查找作用域,不会替宿主等待工作线程,也不构成内存隔离。若回调撤销 API 只保证“不再开始新的回调”,宿主还必须等待已经开始的回调返回。为对象保留有效库引用是一种常见设计,但仍要处理对象之间的环和插件退出时的任务收敛。

符号版本与 IFUNC

GNU 符号版本允许同一个名字关联不同版本;foo@@VERS_2.0 通常表示默认版本,foo@VERS_1.0 表示非默认旧版本。调用者记录的版本需求必须被提供,改一个 SONAME 或建软链接不会修复参数、结构布局或版本不兼容。

IFUNC 让解析器选择实际实现,例如根据可用 CPU 特性选择函数。解析时机可能很早,也可能与具体绑定路径相关;解析器不能假定普通应用初始化已经完成。阅读启动异常时,IFUNC 是需要检查的早期执行路径之一。

ABI 兼容需要比“能找到符号”多检查一层

设插件入口仍叫 process,但旧版接收两个整数,新版接收一个结构体指针。加载器可以成功找到这个名字,调用却不符合约定;动态符号表通常不会替你验证 C 函数的完整参数类型。

层次例子能通过这一层仍不能证明什么
文件身份SONAME 满足 libfoo.so.1内容实现了原 ABI
名字与版本foo、所需版本节点存在参数、返回值和对象布局符合宿主预期
调用契约固定宽度字段、尺寸、调用约定所有权和并发使用正确
生命周期契约create/destroy 配对、关闭前排空新旧实现业务语义完全相同

自定义插件 ABI 可在入口返回的接口表中包含版本号和表大小,以便宿主在读取可选尾部字段前检查是否存在。这个设计要求双方先约定稳定的初始布局和兼容策略;随意给旧结构中间插字段,再提高版本号,并不能让旧宿主自动安全运行。

快速定位

现象首先检查
cannot open shared object fileNEEDED、SONAME、路径、目标架构
undefined symbol动态符号导出、依赖关系、查找作用域
version not foundreadelf -V 输出及部署库版本
preload 无效果可见性、直接调用、内联、安全执行模式
dlclose 后偶发崩溃对象、函数指针与工作线程的生命周期

readelf -dW、readelf --dyn-syms -W 和 readelf -rW 分别回答依赖、动态符号与重定位问题。普通 nm 依赖的符号表可能已被移除,查看动态符号可用 nm -D。本文实验以 glibc 为准,musl 的加载器实现和诊断选项应另按其文档核对。