不瞒大家说,我真正把 CMake 用熟,不是从编译单个可执行文件开始的,而是从“必须同时给项目产出动态库和静态库”这件事开始的。在那之前,我整理库文件基本靠手动敲 gcc、ar、cp 一条条命令,跨平台时再被虐一轮。后来把生成动态库和静态库的整套逻辑交给 CMake 之后,才体会到什么叫“一次配置,处处复用”。这篇文章就把我整理过的 CMake 生成动态库和静态库的完整步骤、背后的设计逻辑,以及我在真实工程里踩过的坑,一步一步写给同样被库工程绕晕的朋友。
这篇内容适合三种人:第一种是刚学 CMake 不久、已经会写简单 CMakeLists.txt 但还没认真做过库工程的人;第二种是已经在用 CMake 维护项目,但每次生成库都靠网上拼凑代码、出了链接错误只能瞎试的人;第三种是想要搞懂静态库和动态库本质区别、想把“为什么 CMake 要这样写”彻底搞清楚的人。我先说结论:生成库的 CMake 代码本身不难,难的是理解库的类型、链接时机、运行期查找机制这三件事。
1. 为什么我把库文件生成从手动编译切到了 CMake
1.1 手动编译库文件到底有多痛
先回忆一下不借助构建工具时,生成一个静态库需要做什么。假设我有一个简单的数学模块,源码是 calculator.cpp,头文件是 calculator.h,命令行大概是:
g++ -c calculator.cpp -o calculator.o ar rcs libcalc.a calculator.o这只是单个源文件。如果工程里有十几个源文件,你得先写一堆g++ -c的编译命令,再把生成的 .o 文件全部列到ar rcs后面。动态库又是另一套:
g++ -fPIC -shared calculator.cpp -o libcalc.soWindows 上的动态库还会牵扯到__declspec(dllexport)、导入库.lib这类问题。手动命令一旦变量多起来,立刻失控。比如某个源文件改了名字、新增了一个依赖目录、需要切换 debug 和 release 配置、要处理跨平台编译宏,手动命令就要同步维护很多处,漏掉任何一处就是一堆诡异的未定义符号。
这些痛苦说到底是一个问题:库工程需要的是“声明式管理”,而不是“命令式堆叠”。你真正想告诉编译器的是“这个目标是一个库,它由哪些源文件构成,需要哪些头文件目录,对外暴露什么接口”,而不是“先编译这个再打包那个”。CMake 的价值正是把后者的繁琐细节封装成前者的简单声明。
1.2 CMake 处理库生成的核心思路
CMake 里生成库的核心命令只有一个:
add_library(<目标名> <STATIC|SHARED|MODULE> <源文件...>)这个目标名在 CMake 体系里非常重要——它不是最终的库文件名,而是一个“逻辑名字”。后续的target_link_libraries、target_include_directories等命令都通过这个名字来给这个库附加属性。CMake 会根据平台自动推导最终生成的库文件全名,比如在 Linux 上叫libcalc.a或libcalc.so,在 Windows 上叫calc.lib或calc.dll。
从这短短一行命令出发,可以展开出一整套知识:静态库和动态库的本质差异决定了参数怎么选;头文件目录的传递规则决定了使用方怎么继承依赖;输出目录的设置决定了构建产物落在哪里;版本号属性决定了动态库的 SONAME 机制如何工作。下面我按工程推进的顺序一步步展开。
2. 静态库和动态库的底层差异,决定 CMake 怎么写
2.1 静态库本质上是一个“对象文件的压缩包”
静态库在 Linux 下是.a文件,在 Windows 下是.lib文件。它本质上是多个编译好的目标文件(.o/.obj)用 ar 工具打包到一起的集合,本身不参与链接,也不决定运行时行为。链接可执行文件时,链接器会从静态库里挑出“被引用的那些目标文件”,把它们的机器码直接复制进最终的可执行文件。
这个过程有点像你整理行李箱:你把所有衣服预先打包成一个收纳袋,出门时把这个收纳袋整个塞进箱子。好处是程序运行时不需要这个.a文件在旁边,坏处是如果两个可执行文件都用了同一个静态库,那么每份可执行文件里都存了一份拷贝,磁盘和内存的浪费就这么来了。另外,一旦静态库里的实现需要更新,使用它的所有可执行文件都必须重新链接一遍。
2.2 动态库是“编译好的独立模块”,在运行时才接入
动态库在 Linux 下是.so,在 macOS 下是.dylib,在 Windows 下是.dll。链接可执行文件时,链接器并不把动态库的代码复制进可执行文件,它只记录一条“这个程序需要用到名为 libcalc.so 的东西,并且里面必须有 calc_add 这个符号”之类的信息。程序真正启动后,由操作系统里的动态加载器找到对应的动态库文件,再把它加载到内存里,完成调用关系。
对应行李箱的类比:动态库更像是一个“共享衣橱”,每个箱子本身不存衣服,箱子上只写了一个标签说明要到哪个衣橱去拿哪几件衣服。程序运行时系统负责把衣橱门打开、把衣服递出来。好处是多个程序可以共享同一份库的内存拷贝,升级库文件后程序不用重新链接即可拿到新实现;坏处是运行环境里必须能找得到这个库文件,否则程序会直接拒绝启动。
2.3 静态库和动态库的选择依据
我这里直接给一张对比表,把关键差异列出来,后面选型可以直接对照:
| 对比维度 | 静态库 | 动态库 |
|---|---|---|
| 链接时机 | 程序构建期 | 程序运行期(加载时或调用时) |
| 产物形态 | Linux 下.a,Windows 下.lib | Linux 下.so,macOS 下.dylib,Windows 下.dll |
| 运行依赖 | 不需要库文件在场 | 需要库文件在场且能被系统找到 |
| 升级成本 | 需重新链接所有使用方 | 替换库文件即可(需接口兼容) |
| 内存占用 | 各进程各留一份拷贝 | 可多进程共享内存页 |
| 典型用途 | 工具库、静态发布、嵌入式场景 | 插件系统、公共基础库、热更新场景 |
实际工程里这两种库往往会同时产出,因为有些下游客户喜欢全部静态链接省去部署麻烦,有些下游更愿意用动态库方便升级。CMake 能同时管理这两种库而互不冲突,这也是它作为构建系统的优势之一。
3. 从零搭建库工程:一个 CMakeLists.txt 同时产出两种库
3.1 先准备一个最小的计算器库项目
为了不空谈概念,我造一个非常小的示例工程,核心是一个计算器模块,提供加法和减法两个对外接口。目录结构如下:
calc_demo/ ├── CMakeLists.txt ├── calculator.h ├── calculator.cpp └── main.cppcalculator.h 的内容:
#pragma once // 跨平台导出/导入宏 #ifdef _WIN32 #ifdef CALC_EXPORTS #define CALC_API __declspec(dllexport) #else #define CALC_API __declspec(dllimport) #endif #else #define CALC_API #endif CALC_API double calc_add(double a, double b); CALC_API double calc_subtract(double a, double b);calculator.cpp 的内容:
#include "calculator.h" double calc_add(double a, double b) { return a + b; } double calc_subtract(double a, double b) { return a - b; }之所以要在 Windows 上加CALC_API宏,是因为 Windows 生成 DLL 时,如果导出函数没有显式标记__declspec(dllexport),外部程序很难正确导入该符号。Linux 和 macOS 上这个宏是空的,不影响编译。工程里限制只能有一个输出文件名,解决方案是给动态库目标单独指定 OUTPUT_NAME,或让一个目标直接使用 STATIC、另一个使用 SHARED 但赋予不同目标名。最常用的是后者。
工程中我还不需要立刻写 main.cpp,先把库本身构建出来。
3.2 编写基础 CMakeLists.txt 并生成两种库
工程根目录下创建 CMakeLists.txt:
cmake_minimum_required(VERSION 3.16) project(calc_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(calc STATIC calculator.cpp) add_library(calc_shared SHARED calculator.cpp)这里我创建了两个目标:calc是静态库,calc_shared是动态库。两者都从同一个 calculator.cpp 编译。此时直接在 build 目录执行:
cmake -S . -B build cmake --build build查看 build 目录,Linux 下会看到libcalc.a和libcalc.so两个文件。CMake 会自动根据平台添加lib前缀和.a或.so后缀。
提示:add_library 的目标名(target name)不等于最终生成的库文件名。这是新手最容易搞混的地方。你把目标名定义为
calc,生成的文件可能是libcalc.a,但你在 CMake 体系里引用它的方式永远是calc,CMake 会自动完成名字转换。
3.3 把库产物统一输出到指定目录
默认情况下库文件会落在 build 目录下和 CMakeLists.txt 层级结构对应的位置。工程一复杂,这些库文件会分散得很难收拾。我习惯在一开始就统一指定输出目录:
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)三条变量的语义分别是:
CMAKE_ARCHIVE_OUTPUT_DIRECTORY:归档文件输出目录,对应静态库.a/.libCMAKE_LIBRARY_OUTPUT_DIRECTORY:动态库输出目录,对应.so/.dylib/.dll(部分平台下 DLL 会被视为 RUNTIME)CMAKE_RUNTIME_OUTPUT_DIRECTORY:可执行文件输出目录,也包含 Windows 下的 DLL
这样配置之后,Linux 下构建结果就是build/lib/libcalc.a和build/lib/libcalc.so,可执行文件统一去build/bin找。
3.4 头文件目录的传递:PUBLIC、PRIVATE 怎么选
库生成之后,最核心的一步是把头文件目录告诉使用方。很多人直接用target_include_directories(calc PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})把这层关系声明出来。这里 PUBLIC、PRIVATE 的选择很多人不理解,我展开说:
- PRIVATE:当前目标自己编译时需要这个目录,但使用当前目标的其他人不需要。比如某个源文件 include 了只有你自己才有的内部头文件。
- PUBLIC:既影响当前目标编译,也会传递给链接当前目标的使用方。对外部使用者来说,他们需要你的头文件目录来找到接口声明。
- INTERFACE:当前目标本身的源文件不需要,但使用方需要。典型场景是“header-only”库,或者纯模板库。
对于计算器库这种最简单场景,头文件对内部编译和外部使用都需要,所以应该写成 PUBLIC。如果写成了 PRIVATE,从命令本身能跑通,但 main.cpp 一旦 include "calculator.h" 就报找不到头文件,因为 include 路径根本没传过去。这一步是很多新手的第一个拦路虎。
4. 让 main 程序真正跑通:链接、运行时识别与验证
4.1 写一个调用库函数的可执行程序
库工程最终还是需要被调用方使用,所以我现在写一个 main.cpp:
#include <cstdio> #include "calculator.h" int main() { double sum = calc_add(2.0, 3.0); double diff = calc_subtract(5.0, 1.0); std::printf("add: %.1f\n", sum); std::printf("subtract: %.1f\n", diff); return 0; }在 CMakeLists.txt 中追加:
add_executable(app main.cpp) target_link_libraries(app PRIVATE calc)到这里先编译一把,跑一下程序看看能不能调用静态库:
cmake -S . -B build cmake --build build ./build/bin/app正常输出:
add: 3.0 subtract: 4.0静态链接的方案验证通过,因为静态库的代码已经被搬进可执行文件,所以运行时并不需要libcalc.a在系统里存在。
4.2 动态库的运行时查找机制:ldd、RPATH、LD_LIBRARY_PATH
接下来把链接对象换成动态库试一下。修改 CMakeLists.txt 里target_link_libraries(app PRIVATE calc)为target_link_libraries(app PRIVATE calc_shared)或干脆把可执行文件改名重新建一个目标:
add_executable(app_shared main.cpp) target_link_libraries(app_shared PRIVATE calc_shared)重新构建后再运行:
./build/bin/app_shared大概率会出现这样的结果:
./build/bin/app_shared: error while loading shared libraries: libcalc_shared.so: cannot open shared object file: No such file or directory这个报错是动态库新手必见的经典问题。理解它的关键是“链接期”和“运行期”是两件完全不同的事:链接器在生成可执行文件时只需要找到libcalc_shared.so确认符号存在,然后记下它的名字;程序运行加载时,动态加载器会按固定规则去系统目录、缓存的库路径里搜索这个名字,而build/lib显然不在默认搜索列表里。
排查动态库依赖关系最常用的工具是ldd:
ldd ./build/bin/app_shared输出里会有一行类似:
libcalc_shared.so => not found解决方案无非几种:把库文件复制到系统库目录、设置LD_LIBRARY_PATH环境变量、或者在编译时写入 RPATH。开发阶段我一般用环境变量快速验证:
export LD_LIBRARY_PATH=/path/to/build/lib:$LD_LIBRARY_PATH ./build/bin/app_shared在 CMake 层面,也可以给可执行文件加 RPATH 以便开发期找到同工程产出的动态库:
set_target_properties(app_shared PROPERTIES BUILD_RPATH ${CMAKE_BINARY_DIR}/lib INSTALL_RPATH ${CMAKE_INSTALL_PREFIX}/lib )BUILD_RPATH是构建产物里嵌入的搜索路径,INSTALL_RPATH是安装之后换用的路径。这个方案对开发调试非常省心,部署阶段再按实际安装路径切换。
注意:如果你同时链接了静态库和动态库,且两个库中有同名模块(比如都是我示例里的 calculator.cpp 编译出来的),可能会遇到符号冲突。实际工程中静态库和动态库同时生成但只会让使用方明确链接其中一个,而不是两个都链接进来,这是我在多个项目里验证过的做法。
4.3 跨平台验证结果对比
实践中的 CMake 好处在于同一份 CMakeLists.txt 在三个主流系统上基本不用改就能构建:
| 平台 | 静态库文件 | 动态库文件 | 可执行运行依赖 |
|---|---|---|---|
| Linux | libcalc.a | libcalc.so | 需处理 RPATH / LD_LIBRARY_PATH |
| macOS | libcalc.a | libcalc.dylib | 需处理 DYLD_LIBRARY_PATH / install_name |
| Windows | calc.lib | calc.dll | 需把 DLL 放在 exe 同目录或 PATH |
Windows 上构建 DLL 时,CMake 默认还会生成一个导入库.lib,这是链接器在构建期使用的,不要和静态库的.lib混淆。二者文件扩展名相同但作用完全不同,这也是一个高频混淆点。
5. 版本号、命名规则和安装导出,进阶配置一次说清
5.1 给动态库加上 VERSION 和 SOVERSION
生产级的动态库一般都要带版本信息。Linux 下成熟的动态库命名规则是libxxx.so.主版本.次版本.修订号,比如libcalc.so.1.2.3,同时还有一个不带数字的软链接libcalc.so用于链接器查找。CMake 里通过两个属性控制:
set_target_properties(calc_shared PROPERTIES VERSION 1.2.3 SOVERSION 1 )VERSION是完整的库版本号,会体现在真实文件名里SOVERSION是 ABI 兼容版本号,Linux 下作为 SONAME 的主要部分
设置之后,build/lib下会生成:
libcalc_shared.so -> libcalc_shared.so.1 libcalc_shared.so.1 -> libcalc_shared.so.1.2.3 libcalc_shared.so.1.2.3为什么需要 SONAME?这涉及到动态库兼容性管理。当你的动态库接口发生了不兼容的修改时,应该提升 SOVERSION。系统通过 SONAME 记录可执行文件依赖的是哪个 ABI 版本,这样即使磁盘上有多个版本的动态库,程序也能根据 SONAME 找到自己编译期对应的那个版本,避免因误加载新版本而崩溃。SOVERSION是库作者对下游的承诺,修改它务必谨慎。
5.2 控制输出文件名:OUTPUT_NAME、PREFIX、SUFFIX
有时候目标名和你期望的最终库文件名不一致。假设技术上目标名叫calc_shared,但我希望生成的文件叫libcalc.so而不是libcalc_shared.so,可以这样:
set_target_properties(calc_shared PROPERTIES OUTPUT_NAME calc)这时最终动态库文件名就是libcalc.so。静态库目标可以同理设置。但要注意:如果静态库目标也叫calc,动态库输出名也叫calc,它们会互相覆盖输出文件,所以两个目标同时在一个 CMakeLists.txt 中且都想输出同名文件时,必须让其中一个改名或放到不同子目录。
PREFIX和SUFFIX属性分别控制库文件名的前后缀。绝大多数场景不需要动,因为 CMake 的平台默认行为已经很合理。
5.3 安装规则:install 命令的正确用法
需要把库和头文件装到系统目录或指定前缀目录时,使用 install 命令:
install(TARGETS calc calc_shared ARCHIVE DESTINATION lib LIBRARY DESTINATION lib RUNTIME DESTINATION bin ) install(FILES calculator.h DESTINATION include)执行:
cmake --install build --prefix /your/install/prefix之后前缀目录下会出现lib和include两个干净目录。这种安装方式配合 CPack 可以做出一套规范的发布包。我建议从一开始就把安装规则写上,哪怕暂时不装,将来做 CI 产物归档或给下游输出 SDK 时都能直接受益。
5.4 隐藏未导出的符号,让动态库接口更干净
动态库默认会导出所有非局部符号,容易造成符号污染和二进制体积膨胀。Linux 平台可以用CXX_VISIBILITY_PRESET hidden把默认可见性改为隐藏,只显式导出的函数对外可见:
set_target_properties(calc_shared PROPERTIES CXX_VISIBILITY_PRESET hidden VISIBILITY_INLINES_HIDDEN ON )配合我示例代码里的CALC_API宏,Windows 和 Linux 上行为就统一了:只有标了CALC_API的接口会被导出。这属于品质提升项,等前面基础链路跑通以后再上不迟。
6. 库工程实战中的高频报错与完整排查思路
6.1 常见的三个“未定义符号”错误
我把自己这些年见过的库相关报错按出现频率排了个序,第一位是链接可执行文件时提示 undefined reference:
undefined reference to `calc_add(double, double)'这类报错最常见的原因不是代码写错了,而是链接顺序问题。Linux 下的链接器解析静态库时是从前往后匹配符号的:如果静态库出现在引用它的源文件之前,符号不会被我链接进来。简单说,target_link_libraries(app PRIVATE calc)这种写法已经把库放到正确位置了,但如果有人在命令行手工把库放在目标文件前就会踩坑。
CMake 下更隐蔽的原因可能是:你链接的目标是calc,但calc实际是动态库目标,而你系统上找不到这个动态库的运行时依赖。这时候你应该用ldd看可执行文件的依赖表,而不是纠结源码。
6.2 经典的 -fPIC 与重定位错误
动态库在 Linux 上要求编译出的目标文件是位置无关代码(Position Independent Code),否则链接时会报:
re-linking with -fPICCMake 对SHARED目标默认会加-fPIC,所以正常情况下你不需要自己处理。但很多人会踩另一个相关坑:当你把一个已经编译成静态库的目标文件再打包成动态库时,如果那个静态库当初没开-fPIC,就会复现这个报错。解决方式有两种:给目标加POSITION_INDEPENDENT_CODE ON属性:
set_target_properties(calc PROPERTIES POSITION_INDEPENDENT_CODE ON)或者在全局开:
set(CMAKE_POSITION_INDEPENDENT_CODE ON)给静态库开 PIC 会让它的性能略微下降,但能保证它后续可以被链接进动态库。我的建议是:如果你明确知道某个静态库将来可能被放进 .so,提前开,否则不要滥用。
6.3 动态库“生成了”却运行时报找不到
这是本文第 4 节提过的运行时问题。完整的排查链路应该是:
- 确认动态库文件确实存在:
ls build/lib看有没有对应.so - 用
ldd(或 macOS 的otool -L)看可执行文件依赖了什么库名、能否被找到 - 若 not found,先检查环境变量是不是干扰:
echo $LD_LIBRARY_PATH,能不用就不用,避免污染其他程序 - 临时统一解决方案:设置 RPATH 或把库放到系统能找到的位置
- 确认 SONAME 是否和文件名匹配:
readelf -d build/lib/libcalc_shared.so | grep SONAME
这套链路基本能覆盖 95% 的动态库运行问题,比瞎猜要快得多。
6.4 静态库和动态库同名冲突
当同一个 CMakeLists.txt 里生产了libcalc.a和libcalc.so,然后你既有一个程序链接静态版,又有一个程序链接动态版时,需要仔细检查每个目标的OUTPUT_NAME。如果两个目标的输出名恰好一致,后构建的会覆盖先构建的,导致其中一个链接器拿到错误的库类型。遇到这类问题,优先把两个库的最终输出名区分开,或放到不同子目录,再给使用方分别指定具体路径。
6.5 用 nm 检查库中导出的符号
写一个库,最终要验证接口是否被正确导出。Linux 下我会直接用 nm:
nm -C build/lib/libcalc.a nm -D build/lib/libcalc_shared.so-D表示查看动态符号表。如果我在nm -D的输出里看不到calc_add,说明导出配置有问题,多半是可见性设置太激进了,比如全局隐藏符号但忘了给接口加导出宏。Windows 下可以用 dumpbin 工具做类似检查。学会快速查看符号表,排错效率会比反复改代码重新编译高一截。
提示:排查的所有步骤里,我建议每次都只改一个变量,重新构建并记录结果。很多人在库工程排错时同时改了好几个地方,最后成功了也不知道是哪一步起效,下次遇到问题依然措手不及。这个习惯值得从现在开始养成。
从动态库的命名规则到符号可见性,再到运行时搜索路径,这些内容其实都绕着一个核心主线:库的生成不只是“把代码编成二进制文件”,而是整套 ABI 管理的开端。CMake 把这些机制用声明式的属性暴露给我们,看似只是几行配置,背后却是操作系统在加载、链接、版本兼容方面的多年积累。希望这篇一步步梳理的文章能帮你把“生成动态库和静态库”这件事想得更通透,而不是止步于“照抄成功了”。实测中我最深的体会是,真正搞懂这层机制之后,再去看其他构建系统或者手写链接脚本,都会觉得豁然开朗。