给你讲个昨天刚发生的现场。同事在 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 下是.so,macOS 下是.dylib,Windows 下是.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:.a,Windows:.lib | Linux:.so,macOS:.dylib,Windows:.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里配环境变量。更规范的做法是在编译期设置 RPATH:
set_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_DIRECTORY,DLL 属于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 需要加
-pthread,Windows 下某些 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_PATH:export 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_DEBUG=files,symbols ./demo_shared它会打印所有装载的库文件以及对每个符号的解析过程,能看到foo在哪个库里被绑定。治本的方法是控制动态库的符号导出范围,不光是 API 级的可见性,还可以用--exclude-libs或链接器脚本控制版本节点。Windows 上则是老生常谈的导出问题:DLL 里没有符号、外部自然找不到。小项目图省事可以用WINDOWS_EXPORT_ALL_SYMBOLS,正式项目老老实实写__declspec(dllexport)或者.def文件。
5.4 让 Release 模式也生成 PDB
Windows 上做崩溃分析离不了 PDB。默认 CMake 在 Debug 配置下会给 MSVC 生成 PDB,Release 配置为了优化体积和速度,不会生成。但线上问题往往只出现在 Release 版本,事后连一个 PDB 都没有,抓破头也找不到崩溃点。
要做的是在 CMake 里显式打开 Release 的调试信息。用目标属性MSVC_DEBUG_INFORMATION_FORMAT控制编译生成的调试信息格式:
set_property(TARGET demo_shared PROPERTY MSVC_DEBUG_INFORMATION_FORMAT ProgramDatabase)这样 CMake 会给编译器加/Zi,生成 PDB。如果还需要链接器生成完整符号,要在 Release 链接参数中加上/DEBUG:
set(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 里的这些行为差异搞清楚,再按自己的场景做取舍。