C++ 动态库:链接期依赖与运行期加载
09 动态库为什么要同时考虑链接期和运行期
第 8 章讲静态库时,模型很清楚:链接器从 .a 里按需抽取目标文件,把需要的代码合进最终可执行文件。程序运行时,那个 .a 文件已经不参与了。动态库的模型不一样。程序在链接期会用到动态库,运行期还要再次找到动态库。一次成功的链接,只说明链接器当时找到了库;它不能保证用户机器启动程序时,系统装载器也能找到同一个库。
这个差异会制造一个很典型的现象:编译命令成功了,app 文件也生成了,但执行 ./app 时立刻报错,说找不到 libmath.dylib 或 libmath.so。问题不在编译器,也不在函数定义缺失,而在程序启动时的动态库搜索失败。动态库把"找库"这件事拆成了两次:构建时找一次,运行时再找一次。
用一个小例子建立模型。头文件 math.h 只放声明:
// math.h
#ifndef MATH_H_
#define MATH_H_
int Add(int left, int right);
#endif
实现文件 math.cc 放定义:
// math.cc
#include "math.h"
int Add(int left, int right) {
return left + right;
}
主程序调用 Add:
// main.cc
#include "math.h"
#include <iostream>
int main() {
std::cout << "dynamic result: " << Add(20, 22) << "\n";
return 0;
}
在 macOS 上,可以用下面的命令生成动态库并链接主程序:
clang++ -std=c++20 -Wall -dynamiclib math.cc -o libmath.dylib
clang++ -std=c++20 -Wall main.cc -L. -lmath -o app
./app
第一条命令把 math.cc 编译成一个动态库文件 libmath.dylib。第二条命令编译 main.cc,并告诉链接器:到当前目录找库,链接名字叫 math 的库。-L. 表示把当前目录加入链接期库搜索路径,-lmath 表示寻找 libmath.dylib 或类似命名的库文件。Linux 上常见命令是把 math.cc 编译成 libmath.so,主程序同样通过 -L. 和 -lmath 链接。
链接成功后,app 并没有把 libmath.dylib 里的全部代码复制进来。可执行文件里记录的是一个依赖:这个程序启动时需要一个名为 libmath.dylib 的动态库,并且 Add 这个符号要从那个库里解析。用第 6 章的目标文件模型看,动态库像一个在运行时才会被装入进程的目标文件集合;用第 7 章的符号模型看,链接器在构建阶段确认符号能对上,但把真正的代码装入推迟到了程序启动阶段。
这条依赖记录是动态库模型的核心。静态库链接后,可执行文件里留下的是代码和数据本身;动态库链接后,可执行文件里留下的是"我需要哪个库"以及"这些外部符号将来从哪里解析"。所以可执行文件虽然已经能被操作系统识别,但它并不是完全自给自足的文件。启动时,装载器要先把依赖的动态库映射进进程地址空间,再把可执行文件里对外部函数和全局对象的引用接到动态库中的实际地址上。
这个过程对开发者通常是透明的。你写 Add(20, 22),源代码里看不到任何"运行时再找库"的动作;链接命令成功后,你也会自然以为程序已经完整。但从二进制角度看,app 只是带着一张动态依赖清单。清单里的库文件缺失、版本不对、路径不对,都会在启动阶段暴露。理解这点之后,动态库错误就不再显得神秘:构建阶段和启动阶段各有一套检查,任何一套失败都会让程序无法交付。
动态库还会影响符号绑定时机。静态链接时,链接器把最终地址基本都确定下来;动态链接时,某些地址要到程序装载甚至第一次调用时才完成绑定。现代系统会用延迟绑定、重定位表、导入表等机制优化启动成本。本章不展开这些内部细节,只保留对工程最有用的判断:动态库让一部分解析工作从构建期延后到了运行期,所以排错时必须把这两个阶段分开。
动态库的关键收益也来自这里。多个程序可以共享同一份库代码。假设 app1、app2、app3 都依赖 libmath.dylib,它们的可执行文件里不需要各自带一份完整实现。操作系统可以把同一个动态库文件映射到多个进程的地址空间中,代码段在物理内存层面有机会共享。库升级时,只要 ABI 兼容,把动态库文件替换成新版本,依赖它的程序可以在下次启动时使用新实现,而不必重新链接每个可执行文件。
这也是系统库、图形库、插件框架常用动态库的原因。系统级 API 如果都静态复制到每个程序里,磁盘和内存浪费很大;插件如果要在程序启动后按需加载,静态库也不合适。动态库把"库代码在哪里"从可执行文件内部挪到外部文件,使共享、更新和延迟加载成为可能。
代价是发布复杂度上升。静态链接后的可执行文件只要自己存在就能运行;动态链接后的可执行文件还依赖外部动态库文件。这个外部文件在开发机上存在,不代表在用户机器上也存在;在链接时的当前目录中存在,不代表运行时的搜索路径能找到它。很多动态库问题,本质上都是把链接期路径误当成运行期路径。
链接期路径由链接器使用。-L 参数告诉链接器去哪些目录查找库文件,-l 参数告诉链接器找哪个库名。比如 clang++ main.cc -L. -lmath -o app 的含义是:编译 main.cc,链接时在当前目录搜索名为 math 的库。链接器找到了库,读取它的符号表,确认 Add 的定义存在,于是链接成功。这个阶段关心的是"构建这台机器上,链接器能不能看见库"。
运行期路径由系统装载器使用。程序启动时,操作系统不会重新读取你的编译命令,也不会记得你当时写过 -L.。它只会读取可执行文件内部记录的动态库依赖,然后按系统规则搜索那些库。macOS 上这个工作由 dyld 完成,Linux 上由动态链接器完成。搜索规则会涉及库的安装名、可执行文件内部的运行路径记录、系统默认目录和环境变量。不同平台细节不同,但共同点很直接:运行时装载器有自己的搜索规则,它不继承链接器的 -L 参数。
所以一个程序可能出现这样的状态:链接时没问题,运行时报错。macOS 上常见错误是 dyld: Library not loaded: libmath.dylib,Linux 上常见错误是 error while loading shared libraries: libmath.so。这类错误的关键字不是 undefined reference,也不是发生在 #include 上的 file not found。它来自程序启动阶段的装载器。排查方向应该转向动态库部署位置和运行时搜索路径。
动态库在链接期仍然需要符号表。主程序调用 Add,编译 main.cc 得到的目标文件中仍然会有一个未定义符号需求。链接器处理动态库时,会检查 libmath.dylib 或 libmath.so 是否导出了 Add 的定义。只要能确认导出符号存在,链接器就可以在可执行文件里写入对这个动态库的依赖记录。这个过程和静态库有相似之处:都要用符号表完成供需匹配。区别在于静态库会把被抽取目标文件的代码合入可执行文件,动态库会把依赖记录保留下来。
这也解释了动态库的两个错误阶段。链接期如果找不到库文件,命令会直接失败,通常会看到类似 library not found for -lmath 或 cannot find -lmath 的提示。链接器连库都找不到,自然无法确认 Add 的定义。链接期如果找到了库,但库里没有导出 Add,就会报未定义符号。运行期如果可执行文件已经生成,但启动时找不到动态库文件,就会报装载器错误。三种错误都像"缺了什么",但缺的东西不同:链接器缺库文件,链接器缺符号定义,装载器缺运行时库文件。
工程上通常用运行路径记录来把动态库位置写进可执行文件。Linux 里常见概念是 rpath 或 runpath,macOS 里常见概念是 install name 和 @rpath。这些机制的目标都是让可执行文件携带一条运行时找库线索,让程序启动时能从相对稳定的位置找到动态库。比如应用程序可以约定把动态库放在可执行文件旁边的 lib 目录里,然后在构建时把这个相对关系写入可执行文件。这样部署时只要保持目录结构,用户不需要手动设置环境变量。
环境变量也能影响运行时搜索路径。Linux 上常见 LD_LIBRARY_PATH,macOS 上常见 DYLD_LIBRARY_PATH。它们适合本地实验和临时验证,不适合作为正式发布的唯一依赖。原因很简单:环境变量属于用户运行环境,不属于程序本身。你在终端里设置了变量,IDE、服务进程、桌面双击启动时不一定带着同样的环境。真正稳定的发布方式应该让程序和动态库的相对位置、安装路径、运行路径记录保持一致。
一个可靠的发布布局通常会同时约束三件事。第一,头文件来自哪个版本,保证编译时看到的声明和库实现匹配。第二,链接期库文件来自哪个目录,保证构建机上使用的动态库就是预期版本。第三,运行期库文件放在哪里,保证用户启动程序时装载器也能找到同一组二进制。只管第二件事,程序可能在开发机上链接成功,换一台机器就启动失败;只管第三件事,构建时可能已经链接到了另一个版本的库。动态库项目的稳定性,来自这三件事同时一致。
开发阶段还有一个常见误区:把当前目录里的库文件当成天然可见。链接命令里的 -L. 只服务链接器。你在同一个目录里执行 ./app 时,有些平台和配置可能碰巧能找到当前目录的动态库,有些平台不会。不要把这种偶然成功当成规则。课程示例为了演示方便,会把库和程序放在同一个目录;正式工程应该明确写入运行路径,或按平台推荐的目录结构安装动态库。
动态库文件名本身也带着平台约定。Linux 常见 libxxx.so,有时还会带版本号;macOS 常见 libxxx.dylib,内部还有 install name;Windows 常见 xxx.dll,链接时还常配一个 import library。构建脚本如果只根据后缀硬编码,很容易把平台差异揉成一团。更稳妥的做法是让构建系统负责生成平台正确的库名和安装规则,业务代码只表达"我要链接 math 这个目标"。本章讨论的是底层模型,后续讲构建系统时会把这套模型映射到 CMake 的目标和安装规则上。
Windows 的模型又有一层差异。Windows 常见动态库文件是 .dll,链接时还可能使用 import library,也就是 .lib 文件。这个 .lib 不等于第 8 章讲的静态库,它更像一个链接期导入说明,用来告诉链接器 DLL 里有哪些可用符号。程序运行时仍然需要对应的 .dll 文件。虽然文件后缀和工具不同,心智模型仍然相同:链接期有一套输入,运行期还要让装载器找到真正的动态库。
把动态库和静态库放在一起对比,差异会更清楚。静态库在链接时被打开,链接器从里面抽取需要的目标文件成员,最终可执行文件包含被抽取代码。动态库在链接时也被打开,但链接器主要确认符号和记录依赖,最终可执行文件保留对动态库的引用。静态库的问题主要集中在链接期,比如库顺序、重复定义、未定义引用。动态库的问题跨越链接期和运行期,路径配置和发布布局也进入了问题域。
动态库的更新能力也有边界。只要导出的函数签名、对象布局、调用约定等 ABI 兼容,替换动态库通常可以让旧程序继续工作。如果新库删掉了旧程序需要的符号,修改了结构体布局,改变了异常或内存分配边界,程序可能在启动时报缺符号,也可能在运行中崩溃。源码层面的 API 兼容不一定等于二进制层面的 ABI 兼容。C++ 因为名字修饰、模板、类布局、异常模型都比较复杂,跨版本动态库发布尤其需要控制 ABI。
这也是很多工程会把动态库导出接口设计得更克制的原因。对外导出的接口越多,未来兼容压力越大;接口里暴露的 C++ 类型越复杂,ABI 风险越高。插件边界和系统库边界常常会选择 C 风格接口或稳定的抽象句柄,原因是它的二进制边界更容易控制。动态库给了工程更新和共享的能力,也要求工程对边界负责。
排查动态库错误时,先判断它发生在哪个阶段。命令执行 clang++ main.cc -L. -lmath -o app 时失败,说明链接期没有通过,检查 -L 目录、库文件名、导出符号和链接命令顺序。app 已经生成,执行 ./app 才失败,说明链接期已通过,问题在运行时装载,检查动态库是否存在、程序内部记录的库名是什么、运行时搜索路径是否包含库所在目录。
可以用工具把这两类信息分开看。nm 能查看动态库是否导出某个符号,otool -L app 能在 macOS 上查看可执行文件记录了哪些动态库依赖,Linux 上常用 ldd app 或 readelf 查看依赖信息。看到 Add 没导出,解决的是库实现或导出设置问题;看到依赖记录指向一个运行时不存在的位置,解决的是安装路径或运行路径问题。这比反复改 include 路径有效得多,因为 include 路径只影响编译器能不能看到声明,不影响装载器能不能找到动态库。
还可以从输出文件的变化判断自己到底做成了什么。静态链接某个库后,可执行文件体积通常会增加,因为代码被合入了程序;动态链接后,可执行文件体积可能只增加少量元数据,因为主要代码仍在外部动态库里。用依赖查看工具能看到动态库名字,用符号查看工具能看到未解析符号最终会交给动态装载流程处理。这些现象都在提醒你:动态库不是"更高级的静态库",它改变了程序交付物的边界。
发布时要把这个边界写进交付清单。交付一个动态链接程序时,交付对象至少包括可执行文件、动态库文件、版本匹配的头文件或 SDK、运行路径或安装说明。内部项目可以用统一的部署脚本保证这些文件一起移动;开源项目可以用包管理器、安装脚本或构建系统安装规则保证它们落到正确位置。只把 app 单独发出去,等用户报 Library not loaded,本质上是交付清单漏掉了运行时依赖。
本地调试也要按这个思路做。不要只在源码目录里运行一次成功就结束验证,可以把可执行文件和动态库复制到一个临时发布目录,用接近真实交付的目录结构启动程序。这样能提前发现依赖记录写成了开发机绝对路径、动态库没有被复制、环境变量依赖没有进入启动脚本等问题。动态库项目的最后一关是离开源码目录后仍然能启动。
当团队里多人协作时,还要把动态库版本写清楚。构建机上链接的是哪个版本,测试机上加载的是哪个版本,线上机器实际部署的是哪个版本,这三者需要能追踪。否则很容易出现开发者说"我这里能跑",测试机却加载了另一份旧库。版本记录、发布目录和运行时依赖检查,都是动态库工程的一部分。
动态库的核心模型可以收成一句工程判断:链接成功只是第一关,启动成功才说明运行时依赖也成立。第 10 章会把本系列所有错误放进一张诊断图里:头文件找不到是预处理问题,声明看不见是编译问题,定义对不上是链接问题,动态库启动失败是运行时装载问题。判断阶段,是排查 C++ 构建错误的第一步。
阅读导航




