博主介绍程序喵大人35 - 资深C/C/Rust/Android/iOS客户端开发10年大厂工作经验嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手《C20高级编程》《C23高级编程》等多本书籍著译者更多原创精品文章首发gzh见文末记得订阅专栏以防走丢C基础系列专栏C语言基础系列专栏C大佬养成攻略专栏C训练营个人网站好文推荐【AIAgent项目】从零构建一个代码PRAgent【C入门】编译链接模型 - 01 一个 C 程序是怎样变成可执行文件的【C入门】编译链接模型 - 02 预处理把头文件怎样塞进源文件【C入门】编译链接模型 - 03 翻译单元才是编译器真正看到的文件【C入门】编译链接模型 - 04 声明让编译器通过检查定义给链接器提供实体【C入门】编译链接模型 - 05 ODR 为什么会让重复定义变成工程炸点【C入门】编译链接模型 - 06 目标文件里到底有什么【C入门】编译链接模型 - 07 链接器怎样把符号对上号【C入门】编译链接模型 - 08 静态库为什么像一包目标文件第 8 章讲静态库时模型很清楚链接器从.a里按需抽取目标文件把需要的代码合进最终可执行文件。程序运行时那个.a文件已经不参与了。动态库的模型不一样。程序在链接期会用到动态库运行期还要再次找到动态库。一次成功的链接只说明链接器当时找到了库它不能保证用户机器启动程序时系统装载器也能找到同一个库。这个差异会制造一个很典型的现象编译命令成功了app文件也生成了但执行./app时立刻报错说找不到libmath.dylib或libmath.so。问题不在编译器也不在函数定义缺失而在程序启动时的动态库搜索失败。动态库把找库这件事拆成了两次构建时找一次运行时再找一次。用一个小例子建立模型。头文件math.h只放声明// math.h#ifndefMATH_H_#defineMATH_H_intAdd(intleft,intright);#endif实现文件math.cc放定义// math.cc#includemath.hintAdd(intleft,intright){returnleftright;}主程序调用Add// main.cc#includemath.h#includeiostreamintmain(){std::coutdynamic result: Add(20,22)\n;return0;}在 macOS 上可以用下面的命令生成动态库并链接主程序clang-stdc20-Wall-dynamiclibmath.cc-olibmath.dylib clang-stdc20-Wallmain.cc -L.-lmath-oapp ./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.dylibLinux 上常见错误是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 或 runpathmacOS 里常见概念是 install name 和rpath。这些机制的目标都是让可执行文件携带一条运行时找库线索让程序启动时能从相对稳定的位置找到动态库。比如应用程序可以约定把动态库放在可执行文件旁边的lib目录里然后在构建时把这个相对关系写入可执行文件。这样部署时只要保持目录结构用户不需要手动设置环境变量。环境变量也能影响运行时搜索路径。Linux 上常见LD_LIBRARY_PATHmacOS 上常见DYLD_LIBRARY_PATH。它们适合本地实验和临时验证不适合作为正式发布的唯一依赖。原因很简单环境变量属于用户运行环境不属于程序本身。你在终端里设置了变量IDE、服务进程、桌面双击启动时不一定带着同样的环境。真正稳定的发布方式应该让程序和动态库的相对位置、安装路径、运行路径记录保持一致。一个可靠的发布布局通常会同时约束三件事。第一头文件来自哪个版本保证编译时看到的声明和库实现匹配。第二链接期库文件来自哪个目录保证构建机上使用的动态库就是预期版本。第三运行期库文件放在哪里保证用户启动程序时装载器也能找到同一组二进制。只管第二件事程序可能在开发机上链接成功换一台机器就启动失败只管第三件事构建时可能已经链接到了另一个版本的库。动态库项目的稳定性来自这三件事同时一致。开发阶段还有一个常见误区把当前目录里的库文件当成天然可见。链接命令里的-L.只服务链接器。你在同一个目录里执行./app时有些平台和配置可能碰巧能找到当前目录的动态库有些平台不会。不要把这种偶然成功当成规则。课程示例为了演示方便会把库和程序放在同一个目录正式工程应该明确写入运行路径或按平台推荐的目录结构安装动态库。动态库文件名本身也带着平台约定。Linux 常见libxxx.so有时还会带版本号macOS 常见libxxx.dylib内部还有 install nameWindows 常见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 构建错误的第一步。码字不易欢迎大家点赞关注评论谢谢