给你讲个昨天刚发生的现场。同事在 CI 里把模块从静态库切到动态库本地跑得好好的测试机上一运行就报error while loading shared libraries。他在群里连发三条求助最后发现是构建时没处理好 RPATH。这种场景对经常用 CMake 的人应该不陌生——动态库和静态库在 CMake 里只差一个单词实际在编译、链接、运行三个阶段的行为却完全不同。这篇文章不谈官方文档已经写透的语法列表而是按我实际查问题的思路来先搞清楚两种库在底层到底差在哪再看 CMake 怎么配置然后拿一份最小工程把整个流程跑一遍最后把那些真正让人头疼的问题挨个拆开。适合刚接触 CMake 的新手也适合已经被动态库依赖地狱折磨过的老开发。1. 先讲原理动态库和静态库到底差在哪很多人知道.a和.so一个静态一个动态但你要问他链接静态库时链接器到底做了什么、动态库运行时又是怎么被加载的他未必能答清楚。这块底子不打好后面配置 CMake 很容易全凭感觉。1.1 静态库本质上是目标文件的打包箱静态库在 Linux 下后缀是.a在 Windows 下是.lib。它的本质非常朴素用ar工具把一堆.o目标文件打包成一个归档文件。你可以把ar想象成一个普通压缩包但它不是用来省空间的而是为了方便链接器一次性拿到一组目标模块。链接器在处理静态库时有个关键行为按需提取。它不会把.a里所有.o都塞进最终可执行文件而是先看当前已经把哪些未定义符号记在小本本上了然后只从归档里挑出能补全这些符号的目标文件。比如一个库里有加法模块和乘法模块程序只用到了加法乘法那个.o就不会出现在可执行文件里。这个机制带来两个非常重要的推论静态库的大小不等于最终程序增加的大小很多人一看.a文件几十 MB 就觉得程序会膨胀几十 MB其实链接器是按符号提取的通常只增加被用到的代码。链接顺序极其敏感。因为链接器遍历.a时是一次性的如果库放在前面、目标文件放在后面链接器在遍历库时还不知道要解析哪些符号就会把整个库跳过最后报一堆 undefined reference。静态库最大的优点就是部署简单可执行文件把代码都拷贝进去了运行时不依赖任何额外文件。缺点是每次库代码更新所有依赖它的程序都必须重新链接一遍而且如果十个进程都用了同一个静态库内存里就有十份完全相同代码的副本。1.2 动态库本质上是运行期的独立模块动态库在 Linux 下是.somacOS 下是.dylibWindows 下是.dll。它不是一个归档包而是一个独立编译出来的、可被多个程序共享的完整模块。动态库在编译时必须启用位置无关代码Position Independent Code-fPIC。因为动态库在运行时才被加载到进程地址空间具体加载到哪个地址是不确定的。代码里的函数调用、全局变量访问都不能写死绝对地址必须通过相对寻址或全局偏移表GOT完成重定位。CMake 在生成 SHARED 目标时会自动帮你加-fPIC但如果你手动用命令编共享库忘了这个参数就会遇到类似relocation R_X86_64_PC32 against symbol ... can not be used when making a shared object的报错。链接动态库时链接器并不会把库的代码拷贝进可执行文件而是在可执行文件的动态段里写下一条依赖记录Linux 下叫 DT_NEEDED记下这个库的名字或者路径。真正解析符号、映射代码到内存的工作留到了程序启动时由动态装载器ld.so完成。动态库的好处是多进程共享物理内存里的同一份代码页更新库文件时只要保持接口不变甚至不需要重新编译可执行文件。代价是运行环境的库查找路径必须正确否则程序根本起不来。1.3 一张表看懂两种库在三个阶段的差异对比项静态库动态库常见后缀Linux:.aWindows:.libLinux:.somacOS:.dylibWindows:.dll编译参数普通目标文件不需要特殊参数需要-fPIC位置无关代码链接期行为按需提取.o代码合并进可执行文件只登记依赖不复制代码运行期行为不依赖库文件直接运行启动时由装载器查找、加载并解析符号可执行文件体积通常更大包含库代码较小只包含自身代码部署复杂度单文件即可运行必须带上.so/.dll且查找路径要正确代码更新所有依赖方必须重新链接替换库文件即可接口不变则不用重编多进程内存占用每个进程各持一份代码段共享一份符号冲突风险所有符号都绑定进可执行文件相对可控同名动态符号可能互相覆盖排查更复杂我个人的体会是这两个库没有绝对的优劣选哪个取决于你的发布环境和迭代方式。后面配置 CMake 的时候目标类型不同行为差异会体现在很多细节上。2. CMake 建库目标add_library 的正确打开方式CMake 里声明一个库非常简单核心就一个命令add_library。但越是简单的命令背后藏着的坑越多。2.1 STATIC、SHARED、MODULE 三个类型怎么选add_library的经典用法是这样add_library(math_utils STATIC src/math_utils.cpp) add_library(math_utils_shared SHARED src/math_utils.cpp)第一个参数是目标名第二个参数是库类型第三个是源文件。如果不写类型直接add_library(math_utils src/math_utils.cpp)CMake 会根据一个全局变量BUILD_SHARED_LIBS来决定生成动态还是静态库。这个变量默认是 OFF也就是不写类型时生成静态库。这里有个很隐蔽的坑BUILD_SHARED_LIBS只在add_library没有显式指定类型时生效。如果你工程里某些地方写了add_library(name SHARED ...)那么即使你把BUILD_SHARED_LIBS设为 ON那些目标也依然是动态库。这个变量适合做全局一键切换但不能当作某个库一定会变成动态库的保证。除了 STATIC 和 SHARED还有一个 MODULE 类型。MODULE 库不会被链接到其他目标上它专门给dlopen这类运行时加载机制使用典型场景是插件系统。如果你调用方通过target_link_libraries去链一个 MODULE 库CMake 会直接报错。这个类型用的人少但理解它有助于区分运行时被系统装载和运行时主动 dlopen是两回事。还有一点容易被新手忽略静态库默认不要求-fPIC因为它最终会被合并进可执行文件地址在链接期就定下来了。但如果你要把一个静态库再打包进动态库里比如把libabc.a链到libfoo.so里那这个静态库就必须以 PIC 方式编译否则链接动态库时会报重定位错误。CMake 里可以单独开目标属性set_target_properties(math_utils PROPERTIES POSITION_INDEPENDENT_CODE ON)或者全局设置CMAKE_POSITION_INDEPENDENT_CODE更省事。2.2 输出路径、命名规则和版本管理必须搞清CMake 默认会把库产物放在构建目录下和源文件目录对应的位置。比如build/src/libmath_utils.a。这个默认行为在简单工程里没问题但工程一复杂你需要统一管输出目录就得靠三个变量set_target_properties(math_utils_static PROPERTIES ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin )三个属性的分工很容易搞混我踩过不止一次ARCHIVE_OUTPUT_DIRECTORY管的是静态库本身.a或.lib以及 Windows 上动态库配套的导入库.lib。LIBRARY_OUTPUT_DIRECTORY管的是 Linux/macOS 下的动态库.so、.dylib。RUNTIME_OUTPUT_DIRECTORY管的是可执行程序以及 Windows 下的.dll文件。没错同样是动态库Linux 下它属于LIBRARY输出Windows 下它属于RUNTIME输出。这个差异坑过很多人你在 Windows 上设了LIBRARY_OUTPUT_DIRECTORY发现 DLL 根本没过去以为是路径写错其实是属性名用错了。命名上Linux 的库文件默认带lib前缀比如目标名math_utils生成libmath_utils.a。Windows 的静态库不带前缀生成math_utils.lib。如果你希望静态库和动态库产物重名比如都叫math_utils就必须让两个目标使用不同的输出目录或者用OUTPUT_NAME错开命名。我之前就干过把两个目标都设成OUTPUT_NAME foo结果构建时后一个目标直接覆盖前一个产物的蠢事排查了半天才发现链接到的文件根本不是自己以为的那份。版本管理是动态库特有的需求。经常看到 Linux 系统里有libfoo.so.1.2.3、libfoo.so.1、libfoo.so三个名字这是链接器约定俗成的版本方案。CMake 里通过VERSION和SOVERSION控制set_target_properties(math_utils_shared PROPERTIES VERSION 1.2.3 SOVERSION 1 )SOVERSION决定 soname也就是libfoo.so.1这个名VERSION用于生成完整的libfoo.so.1.2.3同时 CMake 会自动创建libfoo.so这个不带版本号的软链接给人链接时用。生产环境往往只需要libfoo.so.1这个运行时依赖带软链接的开发包才是给编译用的。2.3 符号可见性和调试信息别等到发布才处理很多人写动态库从来不关心符号导出问题在 Linux 上默认所有非 static 的全局符号都会被导出所以小工程确实能跑。但工程大了有两个麻烦导出符号太多符号表膨胀程序启动时动态链接开销高更严重的是多个动态库都导出了同名符号运行时可能互相覆盖产生诡异 bug。行业惯例是编译动态库时把符号默认设为隐藏只对真正的 API 显式导出。CMake 里只需要两行set(CMAKE_CXX_VISIBILITY_PRESET hidden) set(CMAKE_VISIBILITY_INLINES_HIDDEN ON)然后在头文件里对导出函数加可见性标记。为了跨平台通常写一个宏#if defined(_WIN32) # if defined(MATH_UTILS_BUILD_SHARED) # define MATH_UTILS_API __declspec(dllexport) # else # define MATH_UTILS_API __declspec(dllimport) # endif #else # define MATH_UTILS_API __attribute__((visibility(default))) #endif MATH_UTILS_API int add(int a, int b);Windows 上 SDK 的库几乎都有这种API_EXPORT宏原因就是 DLL 必须用__declspec(dllexport)导出符号外部才能从导入库中看到这个函数。如果你的库没有导出任何符号链接时会直接报无法解析的外部符号。如果你不想在源码里搞一堆宏CMake 3.4 之后提供了WINDOWS_EXPORT_ALL_SYMBOLS属性设置为 ON 后 CMake 会扫描目标里的全局符号自动生成导出定义相当于把 dllexport 这个活给你干了set_target_properties(math_utils_shared PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON)这个属性对维护老代码、没有条件改头文件的工程很实用但正式项目还是建议显式导出一方面接口清晰另一方面也能避免导出一些内部实现细节。3. 一次完整实验用 CMake 同时产出静态库和动态库理论说再多不如动手跑一遍。下面这个最小工程我会一次性生成静态库、动态库再分别生成两个可执行文件去链接它们。整个工程我平时在 VSCode 里远程连虚拟机做 C 开发时就是这么用的体验很顺。3.1 最小工程结构和源码设计工程结构如下library_demo/ ├── CMakeLists.txt ├── include/ │ └── math_utils.h └── src/ ├── math_utils.cpp └── main.cppmath_utils.h里声明两个简单的数学函数#pragma once #if defined(_WIN32) # if defined(MATH_UTILS_BUILD_SHARED) # define MATH_UTILS_API __declspec(dllexport) # else # define MATH_UTILS_API __declspec(dllimport) # endif #else # define MATH_UTILS_API __attribute__((visibility(default))) #endif MATH_UTILS_API int add(int a, int b); MATH_UTILS_API int multiply(int a, int b);math_utils.cpp就是普通的函数实现#include math_utils.h int add(int a, int b) { return a b; } int multiply(int a, int b) { return a * b; }main.cpp编译成可执行程序同时链接库并调用函数#include cstdio #include math_utils.h int main() { printf(add(1, 2) %d\n, add(1, 2)); printf(multiply(3, 4) %d\n, multiply(3, 4)); return 0; }这个工程简单到不能再简单但已经足够暴露动态库和静态库在链接、运行阶段的大部分差异。3.2 写完 CMakeLists.txt静态和动态一次都出来在CMakeLists.txt里同时声明两个库目标名字和产物都要错开cmake_minimum_required(VERSION 3.16) project(library_demo CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 静态库 add_library(math_utils_static STATIC src/math_utils.cpp) target_include_directories(math_utils_static PUBLIC include) # 动态库导出宏里要用 MATH_UTILS_BUILD_SHARED 区分编译期行为 add_library(math_utils_shared SHARED src/math_utils.cpp) target_compile_definitions(math_utils_shared PRIVATE MATH_UTILS_BUILD_SHARED) target_include_directories(math_utils_shared PUBLIC include) set_target_properties(math_utils_shared PROPERTIES OUTPUT_NAME math_utils WINDOWS_EXPORT_ALL_SYMBOLS ON ) # 两个可执行文件分别链接不同版本的库 add_executable(demo_static src/main.cpp) target_link_libraries(demo_static PRIVATE math_utils_static) add_executable(demo_shared src/main.cpp) target_link_libraries(demo_shared PRIVATE math_utils_shared)这里有个细节我把动态库目标的OUTPUT_NAME设成了math_utils而静态库目标名是math_utils_static默认产物就是libmath_utils_static.a。这样两个库产物名字不冲突。如果两个目标都叫 math_utils必须另外区分输出路径。构建命令只需要两行cmake -S . -B build cmake --build build如果用的是 Ninja 或者 Makefile 生成器CMake 会自动选好编译器并完成编译。构建完成后build目录下会出现两个库文件、两个可执行文件。3.3 构建产物对比file、nm、ldd 一个都不能少构建完先看文件类型file build/libmath_utils_static.a file build/libmath_utils.so输出大致是libmath_utils_static.a: current ar archivelibmath_utils.so: ELF 64-bit LSB shared object, x86-64一个归档一个可重定位的共享目标性质完全不同。接着看符号表这是排查链接问题的基本技能nm -C build/libmath_utils_static.a nm -D --defined-only build/libmath_utils.so静态库的nm能看到目标文件里的详细符号包括add、multiply这些已定义全局符号动态库的nm -D列出的是导出符号区也能看到这两个函数。这个对比特别直观静态库的符号是躺在归档里等待提取动态库的符号是通过导出表向外部提供。再看两个可执行文件的动态依赖ldd build/demo_static ldd build/demo_shareddemo_static的动态依赖列表很干净通常只有libc和libstdc因为库代码已经合并进可执行文件了。而demo_shared会多出一行显示它依赖libmath_utils.so。如果此时 CMake 没有帮你搞定 RPATH这一行后面极可能带着not found运行demo_shared就直接崩。这正好引出了下一部分要展开的运行时查找机制。也可以顺手看下文件大小ls -lh build/demo_static build/demo_shareddemo_static通常会比demo_shared大一点因为追加了两个函数的代码。这个差距在真实项目里可能只有百分之一二但如果你用了巨大的静态库差异会非常明显。4. 链接期与运行期两个最容易出问题的阶段构建产物跑起来了只是第一步真正让工程师头疼的往往发生在链接和运行两个阶段。这两个阶段的行为差异决定了你在 CMake 里要怎么写、怎么部署。4.1 链接期静态库的顺序问题和动态库的登记机制静态库的顺序问题我在前面反复提到了因为它是链接阶段最经典的坑。链接器对.a的处理是边遍历边提取它从头到尾扫一遍库把能补全当前未定义符号的.o提出来如果遍历结束时某个符号还是没被解析就报 undefined reference。如果两个静态库互相依赖A 里的符号要由 B 提供B 里的符号又要由 A 提供那么简单的先后顺序就无解了。CMake 在生成链接命令时通常会根据target_link_libraries的依赖关系自动排列库顺序所以简单工程你感知不到这个问题。但遇到循环依赖、或者手动拼命令的场景还是要靠两种手段调整target_link_libraries里库的书写顺序把被依赖的库放后面。对 GCC/Clang 用-Wl,--start-group和-Wl,--end-group把互相依赖的库包起来让链接器反复扫描。CMake 3.24 之后也支持$LINK_GROUP:RESCAN,...生成器表达式。动态库的链接期行为则完全不同。链接器看到-lmath_utils会在搜索路径里找libmath_utils.so找到后做的事情是读取它的导出符号表把它加入 DP_NEEDED 列表然后继续处理下一个目标。代码本身不拷贝重定位信息也不写入可执行文件。这就是为什么链接动态库通常比链接一个庞大的静态库快很多。一个 Linux 特有的细节链接器允许动态库存在未解析符号允许这些符号在运行时从可执行文件里解析。这也是插件系统能工作的基础——插件库可以引用主程序导出的函数。但反过来如果动态库缺少某个系统库的符号通常还是要在链接时补全靠运行时去拼运气是不可取的。4.2 运行期动态库到底怎么被找到的程序启动后动态装载器ld.so会按照固定顺序查找依赖的.so文件可执行文件自身的 DT_RPATH已废弃但仍可能遇到。LD_LIBRARY_PATH环境变量指定的目录。系统的/etc/ld.so.cache缓存。/lib、/usr/lib等默认目录。这里最坑的是 DT_RPATH 和 DT_RUNPATH 的优先级差异。老式的 DT_RPATH 优先级最高排在LD_LIBRARY_PATH前面新 linker 默认生成的 DT_RUNPATH 优先级反而低于LD_LIBRARY_PATH。CMake 默认在构建目录时会写 RUNPATH所以有时候你执行export LD_LIBRARY_PATH/path/to/libs却发现程序走的还是构建目录里旧版本的库出现改了环境变量却不生效的错觉。推荐的做法是别依赖LD_LIBRARY_PATH它的好处是临时生效坏处是全局污染。尤其你给客户交付程序时总不能要求客户在每个用户的.bashrc里配环境变量。更规范的做法是在编译期设置 RPATHset_target_properties(demo_shared PROPERTIES BUILD_RPATH $ORIGIN/../lib INSTALL_RPATH $ORIGIN/../lib )$ORIGIN表示可执行文件所在的目录$ORIGIN/../lib就是可执行文件上一级目录里的 lib 目录。这样不管程序被拷到哪个目录只要保持可执行文件和 lib 目录的相对位置不变动态库就能被找到。实测下来这是跨机器交付最省心的方式。如果你的程序走上安装流程CMake 的install规则里还可以用INSTALL_RPATH_USE_LINK_PATH让 CMake 自动把目标链接过的库路径写进 RPATH减少手工维护。但要注意一点安装后的 RPATH 路径如果包含本机开发环境路径比如/home/user/lib那就没法带给客户了这时候必须显式覆盖成$ORIGIN这种相对路径。4.3 跨平台差异Windows 的 DLL 和导入库Windows 上的动态库和 Linux 有本质差别。Windows 下链接 DLL 时链接器并不直接拿.dll文件来解析符号而是需要一个对应的导入库.lib。这个.lib和静态库的.lib后缀一样容易造成混淆——静态库直接把代码放在.lib里导入库只是记录了符号和 DLL 的对应关系真正的代码在.dll里。所以 Windows 上发布一个动态库通常要同时给开发人员提供.dll和.lib导入库甚至还有头文件。这也是为什么很多 Windows 下的 C 库把.lib叫link library把.dll叫runtime library。运行期 DLL 的查找顺序也和 Linux 不同可执行文件所在目录。系统目录C:\Windows\System32等。PATH环境变量里的目录。这解释了为什么 Windows 下把 DLL 放到程序同目录最省事也解释了为什么乱设 PATH 会有安全风险。CMake 里生成 DLL 时导入库默认属于ARCHIVE_OUTPUT_DIRECTORYDLL 属于RUNTIME_OUTPUT_DIRECTORY前面已经说过这里就不再重复。还有一个 macOS 特有的概念动态库内部会记录自己的 install name相当于 macOS 版的 RPATH。修改 install name 用install_name_tool -change排查依赖用otool -L。跨平台项目如果用了dlopen/LoadLibrary这类动态加载 API差异会更多不过那就是另一个话题了。5. 常见问题与排查技巧实录这一部分我整理平时在群里被问得最多、自己也踩过的问题。每个问题都先说现象再给排查思路和解决方案。5.1 undefined reference从符号层面找原因undefined reference to xxx是链接期最常见的错误。按照我的经验按照以下顺序排查命中率最高先确认库文件确实生成了检查输出目录里的.a或.so是否存在。有时候构建失败但错误信息被日志淹没了你会看到一个幽灵链接错误。再确认库和源文件的链接顺序。CMake 大多能自动排好但如果你用了add_custom_command或直接操作链接命令顺序就容易错。用nm -C看符号名。如果符号后面带了一长串或者 mangle 后的名字说明 C 和 C 符号混用了头文件里需要加extern C。查库本身的符号是否被隐藏。设置了CMAKE_CXX_VISIBILITY_PRESET hidden又没显式导出符号时nm -D会看不到符号此时就需要补导出标记。确认是不是目标平台的差异。比如 Linux 下链接 pthread 需要加-pthreadWindows 下某些 WinAPI 库需要链接Ws2_32.lib。这里面最容易忽视的是静态库的依赖不传递。静态库里的目标文件被提取后它引用的外部符号比如数学库的sin、cos并不会自动把数学库拉进来需要最终链接可执行文件时显式加m。很多人在 CMake 里只写target_link_libraries(app PRIVATE libmath_utils.a)忘了依赖项就报 undefined reference。动态库反而没这么麻烦因为它自己已经链接了需要的依赖。5.2 运行时找不到动态库怎么办这是动态库最常见的运行期错误./demo_shared: error while loading shared libraries: libmath_utils.so: cannot open shared object file: No such file or directory按以下优先级处理确认库文件是否在检查输出目录里确实有.so。临时验证可以用LD_LIBRARY_PATHexport LD_LIBRARY_PATH/path/to/lib:$LD_LIBRARY_PATH ./demo_shared长期方案是在 CMake 里设置 RPATH前面已经给出示例。如果程序要安装到系统目录把.so放到/usr/local/lib或/usr/lib然后跑sudo ldconfig更新缓存。用ldd ./demo_shared查看当前解析结果看到not found说明还没找对路径。另外给个经验不要把一堆LD_LIBRARY_PATH塞进 CI 脚本或者系统服务里。环境变量会传染今天这个服务用得上明天另一个服务也被带偏。早早在 CMake 里把 RPATH 配好省得后面反复折腾。5.3 同名符号冲突与 Windows 导出问题动态库的符号冲突比静态库隐蔽得多。静态库里如果两个目标文件都定义了同名函数链接器通常会直接报多重定义错误错误信息明确。动态库却可能悄悄运行进程启动时装载器按依赖顺序把.so映射进来后加载的库如果导出了同名符号默认行为是第一个已经加载的生效。这时候你调用foo()编译器告诉你链接成功了但实际执行的可能不是你本意想要的函数这种 bug 最难定位。如果你怀疑符号被覆盖Linux 下有一个很实用的定位手段LD_DEBUGfiles,symbols ./demo_shared它会打印所有装载的库文件以及对每个符号的解析过程能看到foo在哪个库里被绑定。治本的方法是控制动态库的符号导出范围不光是 API 级的可见性还可以用--exclude-libs或链接器脚本控制版本节点。Windows 上则是老生常谈的导出问题DLL 里没有符号、外部自然找不到。小项目图省事可以用WINDOWS_EXPORT_ALL_SYMBOLS正式项目老老实实写__declspec(dllexport)或者.def文件。5.4 让 Release 模式也生成 PDBWindows 上做崩溃分析离不了 PDB。默认 CMake 在 Debug 配置下会给 MSVC 生成 PDBRelease 配置为了优化体积和速度不会生成。但线上问题往往只出现在 Release 版本事后连一个 PDB 都没有抓破头也找不到崩溃点。要做的是在 CMake 里显式打开 Release 的调试信息。用目标属性MSVC_DEBUG_INFORMATION_FORMAT控制编译生成的调试信息格式set_property(TARGET demo_shared PROPERTY MSVC_DEBUG_INFORMATION_FORMAT ProgramDatabase)这样 CMake 会给编译器加/Zi生成 PDB。如果还需要链接器生成完整符号要在 Release 链接参数中加上/DEBUGset(CMAKE_SHARED_LINKER_FLAGS_RELEASE ${CMAKE_SHARED_LINKER_FLAGS_RELEASE} /DEBUG)再把 PDB 输出路径挪到统一目录set_target_properties(demo_shared PROPERTIES PDB_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/pdb )有一点要注意PDB 文件包含详细的符号和源码路径信息发布给外部客户时一般不带 PDB需要保留的是你自己归档的那份用来对线上崩溃转储做符号还原。就算客户那边拿不到 PDB你手上有 PDB 就能解析 dmp这个习惯非常值得养。最后分享一个我个人的选择习惯内部研发阶段我倾向于把所有模块编成动态库迭代只需要编译发生变动的库链接速度快到发布会或者需要给现场交付的时候再把核心组件合并成静态库打进可执行文件动态库数量压到最少部署时只需带一两个运行时库。我也见过不少团队反过来做为了省一点重新编译的时间发布时被一堆.so依赖折腾得焦头烂额。库的类型选择没有绝对标准关键是先把 CMake 里的这些行为差异搞清楚再按自己的场景做取舍。