做C/C++项目,迟早会被一个问题找上门:代码放哪里?全塞进可执行文件,编译慢、复用差;拆成库,又得面对静态库和动态库这一堆命名差异。CMake 在这里几乎是标准答案,尤其是你想用一套构建脚本同时产出.a、.so、.lib、.dll这种东西的时候,add_library一行命令就能搞定,但背后的门道不少。这篇我用一个能直接跑起来的极小示例,一步一步带你生成静态库、动态库,并解释每一步为什么这样写、跨平台时哪些地方最容易翻车。适合刚开始学 CMake、想把库这件事彻底搞明白的开发者。你不需要多深的 CMake 基础,能看懂一点 C++,能敲命令行,就够了。
1. 先把“库”这一关想明白:到底在解决什么问题
1.1 从“一堆源码”到“一个干净的产物”
很多新手在最初写程序时,习惯把所有.cpp文件直接扔进可执行目标里。比如一个项目里有 10 个源文件,直接往add_executable里写 10 个文件,编译能过,运行也没问题。但项目一旦大一点,弊端立刻显现。
首先是编译速度。每次改动一个文件,整个可执行文件都要重新链接,哪怕只是改了一个小函数,链接器也得把所有目标文件重新整理一遍。接着是复用问题。假设你写了一个字符串处理模块,另一个项目也想要,直接把源码拷贝过去,之后两边同时维护,想想就头疼。库就是为这两件事存在的:把稳定的、通用的实现打包成一个二进制产物,通过头文件对外暴露接口,链接时再把它接进程序里。
静态库和动态库是两种最常见的形式。静态库简单理解就是把一堆.o目标文件打包成一个归档文件;动态库则更进一步,把实现代码独立成一个二进制模块,程序运行时再加载它。CMake 在这里做的是统一抽象:无论你是在 Linux、macOS 还是 Windows 上,add_library会替你生成对应平台上的构建规则,不需要你手动敲ar或者gcc -shared。
1.2 静态库与动态库,本质区别是什么
要理解 CMake 里的STATIC和SHARED,先要理解这两种库在链接阶段和运行阶段的不同行为。
静态库在链接时发挥作用。链接器会把可执行文件依赖的静态库中的目标文件提取出来,复制进最终的可执行文件。所以程序运行的时候,不再需要那个.a或.lib文件。代价是:如果两个可执行程序都链接同一个静态库,内存里会存在两份相同的机器码;而每次库的代码更新,所有依赖它的程序都得重新链接一遍。
动态库在编译期只提供符号表,程序运行时才由操作系统加载动态库。代码机器码在内存中只有一份,可以被多个进程共享;库文件更新版本时,只要接口不变,通常不需要重新链接使用方。但要处理运行时路径问题,不然程序启动时找不到.so或.dll。
两者的差异总结成表格会更清楚:
| 对比项 | 静态库 | 动态库 |
|---|---|---|
| 链接时机 | 生成可执行文件时 | 程序启动或运行时 |
| 运行时依赖 | 不依赖外部库文件 | 需要对应的 .so/.dll/.dylib 文件 |
| 可执行文件大小 | 较大,代码被复制进来 | 较小,函数代码留在库中 |
| 更新部署 | 需要重新链接所有使用方 | 替换库文件,接口不变即可 |
| 内存占用 | 多个进程重复加载 | 可被多个进程共享 |
| 常见扩展名 | .a/.lib | .so/.dll/.dylib |
这里要注意,Windows 下静态库和动态库的扩展名容易把人绕晕。静态库是.lib,动态库的导入库也叫.lib,但真正的运行时文件是.dll。Linux 下则比较直观:静态库是libxxx.a,动态库是libxxx.so。
1.3 为什么要交给 CMake 来做
有些人可能会问,我不学 CMake,直接手写gcc -c和ar rcs,不也能生成库吗?短期可以,但一旦面对多平台、多配置、多个依赖之间需要传递头文件路径和编译选项,手写命令很快就变成一坨不可维护的脚本。
CMake 的价值在于目标导向。你会定义math_utils这个库目标,然后用target_include_directories给它挂上头文件路径,用target_link_libraries让其他目标链接它。所有配置都跟着目标走,跨平台编译选项、符号导出、安装规则都能在一份 CMakeLists.txt 里表达。后续你只需要在命令行执行两条简洁的命令,CMake 就会在你当前的平台上生成对应的原生构建系统。
2. 开工前的项目设计与目录结构
2.1 先设计一个能跑起来的示例
理论说得再多,不如动手跑一个示例。我准备做一个极小的数学工具库,提供两个函数:整数加法和平方。这个库本身没有太多实用价值,但作为教学示例刚好合适,代码短,逻辑清晰,能把重点集中在 CMake 的配置上。
先建立如下目录结构:
demo_math/ ├── CMakeLists.txt ├── include/ │ └── math_utils.h └── src/ └── math_utils.cppinclude目录存放对外公开的头文件,src目录存放实现。头文件要带有头文件守卫,或者直接用#pragma once:
#pragma once int add(int a, int b); int square(int x);对应的实现文件:
#include "math_utils.h" int add(int a, int b) { return a + b; } int square(int x) { return x * x; }这个结构不复杂,但已经是一个微型库的雏形。真正项目里的库结构也基本类似,无非是增加更细致的模块划分、测试代码和文档。
2.2 为什么要单独放 include 目录,头文件路径怎么管理
很多新手会用相对路径#include "../src/math_utils.h",一时能编译通过,但库一旦被其他目录引用,路径立刻就断掉了。正确做法是通过 CMake 的target_include_directories把头文件目录传给目标,使用者只需要包含头文件名即可,不需要关心路径细节。
在库的 CMakeLists.txt 里:
target_include_directories(math_utils PUBLIC include)这里的关键是PUBLIC。它的意思是:这个头文件目录不仅编译库本身要使用,任何链接math_utils的目标也会自动获得这个头文件路径。这样一来,可执行文件里只需要写#include "math_utils.h",不需要在可执行目标的配置里再手动添加include路径。
如果你不想让这些头文件对使用者可见,可以用PRIVATE,表示目录只在编译库自己时使用。对于公开接口的头文件,通常都是PUBLIC。还有第三档INTERFACE,专门给不需要编译源文件的接口库用,比如纯头文件库。这三个关键字是 CMake 里比较常见的细节,建议从一开始就养成正确使用的习惯。
2.3 选择 CMake 版本与构建流程
在项目根目录创建最基础的CMakeLists.txt:
cmake_minimum_required(VERSION 3.16) project(MathUtils VERSION 1.0.0 LANGUAGES CXX) add_library(math_utils STATIC src/math_utils.cpp) target_include_directories(math_utils PUBLIC include)cmake_minimum_required用来声明最低 CMake 版本。3.16 是一个比较保守的版本,能覆盖绝大多数环境。project里声明了项目名和版本号,LANGUAGES CXX告诉 CMake 只需要 C++ 编译器,这比默认的 C 和 CXX 都启用更干净。
构建时我强烈推荐“源码目录和构建目录分离”的方式,不要在源码目录里生成一堆中间文件。在项目根目录执行:
cmake -S . -B build cmake --build build-S .指定源码目录,-B build指定构建目录。第一次执行cmake -S . -B build会生成配置,第二次执行cmake --build build才会实际编译。如果是在 Windows 上使用多配置生成器,比如 Visual Studio,还需要加--config Debug或--config Release指定配置类型,否则默认可能生成的是 Debug 版本。
3. 生成静态库:从 add_library 开始
3.1 第一版 CMakeLists.txt 怎么看
上面那两行add_library和target_include_directories,就已经完成了一个静态库的配置。add_library的第一个参数是目标名,第二个参数是库类型,第三个参数是源文件。这里STATIC表示我们要创建静态库。
构建完成后,去build目录下看一下产物。Linux 下你会看到:
build/libmath_utils.a为什么前面带lib,后缀是.a?这是 Linux 平台的命名惯例。Windows 下会生成math_utils.lib,没有lib前缀,后缀变成.lib。macOS 也是libmath_utils.a。这些差异不用死记,CMake 自动帮你处理。如果你手动写命令,就得为每个平台单独处理一次,想想就烦。
静态库本质上是一个目标文件的集合。你可以用ar t build/libmath_utils.a查看里面的内容,里面会有math_utils.cpp.o这个目标文件。虽然静态库不是一个可执行程序,但它内部的组织方式,决定了链接时只会提取被使用到的符号,后面第 6 节会专门讲链接顺序的坑。
3.2 构建并确认产物
假设你已经在项目根目录建好了CMakeLists.txt和源码目录,执行:
cmake -S . -B build cmake --build build然后列出构建目录下的文件:
ls build/libmath_utils.a如果顺利,你会看到这个静态库文件。还可以用file命令看一下:
file build/libmath_utils.a输出里可能包含current ar archive之类的内容,说明这就是一个静态库归档。这一步的意义在于,你亲眼确认了 CMake 帮你从源码生成了产物,而你自己没写任何ar或复杂的编译参数。
3.3 把静态库链接给可执行程序用
库生成之后,孤零零放在那里没有意义,得有人用它。我再加一个简单的可执行程序,放到app子目录下。先建立app/main.cpp:
#include <iostream> #include "math_utils.h" int main() { std::cout << "add(20, 22) = " << add(20, 22) << std::endl; std::cout << "square(7) = " << square(7) << std::endl; return 0; }然后在根目录的CMakeLists.txt中追加:
add_executable(demo app/main.cpp) target_link_libraries(demo PRIVATE math_utils)target_link_libraries的作用是让demo目标链接到math_utils静态库。PRIVATE的意思是,math_utils的头文件路径只需要对demo内部可见,当其他目标再链接demo时,不必传递这些路径。
再次构建:
cmake -S . -B build cmake --build build ./build/demo输出会是:
add(20, 22) = 42 square(7) = 49到这里,你已经完整走了一遍“生成静态库 -> 链接静态库 -> 运行可执行程序”的流程。后面所有复杂库的玩法,本质上都是在这个基础之上扩展。
4. 生成动态库:一次把跨平台细节摸透
4.1 只需改一个关键字?
生成动态库最直接的方式,是把add_library里的STATIC改成SHARED:
add_library(math_utils SHARED src/math_utils.cpp) target_include_directories(math_utils PUBLIC include)重新配置并构建:
cmake -S . -B build cmake --build buildLinux 下会在build目录里看到libmath_utils.so。Windows 下会生成math_utils.dll和一个配套的导入库math_utils.lib。macOS 下是libmath_utils.dylib。
看起来很简单,但动态库是跨平台最容易出幺蛾子的地方。尤其是 Windows,如果只改一个关键字,后面十有八九会在链接可执行程序时报一堆LNK2019错误。这不是 CMake 的配置问题,而是编译器对符号导出的默认策略不同。
4.2 Windows 下最容易出的岔子:符号导出
Linux 和 macOS 上,编译器默认会把动态库里的所有非局部符号都导出,外部程序可以直接链接。Windows 上则完全是另一套规则:DLL 只导出你显式声明的符号,没有声明导出的函数,外部程序根本连符号都看不到。
解决办法是加一个导出宏。先新建一个可复用的头文件,或者直接改math_utils.h:
#pragma once #if defined(_WIN32) && defined(MATH_UTILS_SHARED) # ifdef MATH_UTILS_BUILD # define MATH_UTILS_API __declspec(dllexport) # else # define MATH_UTILS_API __declspec(dllimport) # endif #else # define MATH_UTILS_API #endif MATH_UTILS_API int add(int a, int b); MATH_UTILS_API int square(int x);这段宏的逻辑有点绕,我拆开讲。_WIN32表示当前在 Windows 平台,MATH_UTILS_SHARED是我们自己定义的一个开关,用来标记这个头文件是为动态库准备的。MATH_UTILS_BUILD只在编译库本身时定义,表示“我自己正在生成 DLL”,所以需要dllexport;当外部程序使用这个头文件时,没有MATH_UTILS_BUILD宏,走的是dllimport分支,告诉编译器这些函数来自外部 DLL。
然后在 CMake 里给库目标添加编译定义:
target_compile_definitions(math_utils PRIVATE MATH_UTILS_BUILD)不要用PUBLIC,因为编译用户代码时不应该带着MATH_UTILS_BUILD。至于MATH_UTILS_SHARED,我习惯在头文件里通过 CMake 的target_compile_definitions同时传给使用者,或者干脆让用户在构建动态库时手动声明。更稳妥的做法,是让 CMake 生成一个math_utils_export.h,不过那不是现阶段必须掌握的。
4.3 动态库的版本号与 SONAME
动态库不像静态库,它可能同时存在多个兼容版本。Linux 下的共享库通常带版本号,比如libmath_utils.so.1.0.0、软链接libmath_utils.so.1和无版本号的libmath_utils.so。CMake 里可以通过set_target_properties配置:
set_target_properties(math_utils PROPERTIES VERSION 1.0.0 SOVERSION 1 )配置之后,构建目录里会生成带完整版本号的库文件,同时还有 SOVERSION 对应的软链接。可执行程序运行时依赖的是 SONAME,也就是libmath_utils.so.1,而不是某一个具体的 patch 版本。未来你修复 bug 发布了1.0.1,只要 SOVERSION 不变,旧的可执行程序不需要重新链接也能正常运行。
这个机制在 Linux 和 macOS 上比较重要,Windows 上并不完全等价。Windows DLL 没有内嵌 SONAME 的概念,运行时靠文件名字匹配,版本信息更多是作为文件属性写入。所以如果你主要在 Windows 上开发,对SOVERSION不敏感也很正常。
4.4 运行时怎么找到动态库
链接完动态库,执行可执行程序时经常会遇到一个陌生的报错:error while loading shared libraries: libmath_utils.so: cannot open shared object file: No such file or directory。这是新手最容易懵的地方——程序明明编译链接都没问题,怎么运行就找不到了?
原因很简单:静态库的代码已经被复制进可执行文件,运行时不需要额外文件;动态库则要求操作系统在程序启动时能找到对应的库文件。Linux 下默认搜索路径不包含你的build目录,所以你需要临时指定:
LD_LIBRARY_PATH=build ./build/demoWindows 下的报错形式是“找不到 math_utils.dll”。系统和 Linux 类似,会按照可执行文件所在目录、系统目录、PATH环境变量等顺序查找。最简单的开发期处理方式,是构建后把 DLL 复制到demo.exe所在的目录。可以用 CMake 的add_custom_command实现:
add_custom_command(TARGET demo POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different $<TARGET_FILE:math_utils> $<TARGET_FILE_DIR:demo> )$<TARGET_FILE:math_utils>是 CMake 生成器表达式,会自动展开成动态库文件的完整路径。这样每次构建完,DLL 都会自动被扔到可执行程序旁边,开发期运行非常省心。
5. 同一套代码同时产出静态库和动态库:常见姿势
5.1 一个目标名不够用
有时候一个项目既需要静态库又需要动态库。比如第三方希望静态链接进自己的安装包,另外一些场景又希望运行时可升级动态库。这时你会发现,CMake 不允许同一个目标名创建两次。如果你写:
add_library(math_utils STATIC src/math_utils.cpp) add_library(math_utils SHARED src/math_utils.cpp)CMake 会直接报错,告诉你目标重名。这是很常见的一个认知卡点。
解决思路是给两个库起不同的目标名。我习惯这样命名:目标名内部带类型后缀,但最终输出的文件名保持一致。比如内部目标叫math_utils_static和math_utils_shared,通过OUTPUT_NAME属性让它们最终都输出为math_utils。外部使用者在看文件名时,感知不到内部目标的区别。
5.2 用两个目标,并控制输出文件名
一个比较直观的写法:
add_library(math_utils_static STATIC src/math_utils.cpp) target_include_directories(math_utils_static PUBLIC include) add_library(math_utils_shared SHARED src/math_utils.cpp) target_include_directories(math_utils_shared PUBLIC include) target_compile_definitions(math_utils_shared PRIVATE MATH_UTILS_BUILD) set_target_properties(math_utils_static PROPERTIES OUTPUT_NAME math_utils) set_target_properties(math_utils_shared PROPERTIES OUTPUT_NAME math_utils VERSION 1.0.0 SOVERSION 1 )这样在 Unix 类系统上,你会得到libmath_utils.a和libmath_utils.so两个库文件,并存于同一目录。可执行目标链接哪一种,取决于你target_link_libraries里写的是哪个目标名。
但这里有个 Windows 特有的坑:动态库的导入库和静态库可能都叫math_utils.lib。如果你把两个库放在同一个输出目录下,文件会发生覆盖或冲突。解决方式是把它们的输出目录分开,或者给两个目标设置不同的OUTPUT_NAME。我在 Windows 下更推荐用不同的OUTPUT_NAME,比如动态库输出math_utils_shared、静态库输出math_utils_static,避免生成时文件互相顶掉。
5.3 用 BUILD_SHARED_LIBS 开关统一切换
如果你只需要在两种模式之间切换,不必同时生成两个库,有更简单的做法。CMake 提供了一个全局变量BUILD_SHARED_LIBS,它会影响未指定类型的add_library。于是可以这样:
option(BUILD_SHARED_LIBS "Build shared libraries" ON) add_library(math_utils src/math_utils.cpp) target_include_directories(math_utils PUBLIC include) target_compile_definitions(math_utils PRIVATE MATH_UTILS_BUILD)当BUILD_SHARED_LIBS为ON,math_utils就是动态库;为OFF时,就是静态库。在命令行里可以通过-DBUILD_SHARED_LIBS=OFF切换。这种方式适合快速做配置切换,控制很直接,但对于“同时产出两种库”的场景不适用。
5.4 安装与导出
库最终是要给别人用的,构建目录里的产物不能永远手动拷贝。CMake 的install规则可以把头文件和库文件安装到指定目录:
install(TARGETS math_utils_static math_utils_shared LIBRARY DESTINATION lib ARCHIVE DESTINATION lib RUNTIME DESTINATION bin ) install(FILES include/math_utils.h DESTINATION include)ARCHIVE覆盖静态库以及 Windows 下的导入库,LIBRARY覆盖 Linux/macOS 下的共享库,RUNTIME覆盖 Windows 下的 DLL。执行cmake --install build --prefix 安装目录后,你就能得到一个相对完整的分发目录。
再进一步,如果你想产生一个可被find_package使用的包,还需要export(EXPORT ...)。那部分内容可以等比熟练掌握基础后再说,现阶段先把安装规则写明白,已经能在很多小型项目里直接用了。
6. 新手最容易踩的坑:问题排查实录
6.1 常见问题速查表
我在不同项目里反复遇到下面这几类问题,整理成表格,方便你遇到报错时快速对号入座:
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 编译时报找不到头文件 | include 路径没有正确传递 | 检查target_include_directories是否是PUBLIC |
| 链接时报未定义的引用 | 没有链接库,或静态库链接顺序不对 | 确认target_link_libraries;库依赖按顺序排列 |
| Windows 下 LNK2019 | 动态库符号未导出 | 添加__declspec(dllexport)宏 |
运行时报找不到.so/.dll | 动态库不在搜索路径 | 设置LD_LIBRARY_PATH,或把 DLL 复制到 exe 旁 |
| 重新配置后产物还是旧的 | 构建目录缓存了旧配置 | 删除build目录,重新执行cmake -S . -B build |
| 静态库在上、依赖库在下导致链接失败 | 静态库不会自动反向解析 | 使用 CMake 目标管理链接顺序,避免手动-l |
6.2 从一次真实报错开始排查
接触过一个小项目,某开发者在 Windows 上生成动态库后,链接可执行程序时报了一堆LNK2019,大意是“无法解析的外部符号”,明明头文件也包含了,代码也能“看到”函数,为什么链接器就是找不到?问题就出在符号没有导出。他看了头文件,但没有给函数加导出宏。
当时的处理方法是把导出宏加到函数声明前,再在动态库目标上添加MATH_UTILS_BUILD编译定义,重新构建后问题解决。这类问题几乎每个用 CMake 生成 Windows 动态库的人都会遇到一次,所以我在 4.2 节花了大量篇幅讲导出宏,它不是可有可无的装饰,而是能否在 Windows 上成功链接的关键。
6.3 静态库的链接顺序为什么重要
链接静态库时,链接器是从左往右扫描的,每个静态库只提取当前还缺失的符号。如果库 A 依赖库 B,而你在命令行上先写 B 再写 A,链接器可能先处理 B 时发现没有需要的东西,于是跳过;处理 A 时发现了对 B 的依赖,但这时已经扫过 B 了,最终报未定义引用。
这个怪问题在 CMake 里基本不会出现,因为你只要用target_link_libraries管理依赖,CMake 会自动对库进行排序。但如果你是手动拼链接命令,或者用-l参数一个个加,就会踩到。我的建议是:始终通过 CMake 目标来链接,不要手动处理库顺序。手动指定时如果需要临时修复,可以加链接选项-Wl,--start-group,但那只是绕过了问题,不是正确做法。
6.4 改了类型构建产物不更新怎么办
有时候你原来生成的是静态库,后来把STATIC改成SHARED,重新构建却发现目录下依然躺着一个旧的.a,新.so也生成了,程序链接的却还是旧文件。这类问题多半是构建系统缓存没有完全刷新。
cmake --build build --target clean能清理一部分产物,但无法覆盖所有情况。最可靠的方法是删掉整个build目录再重新配置一次:
rm -rf build cmake -S . -B build代价是重新构建所有内容,但能保证绝对干净。我在开发中如果遇到 CMake 行为不符合预期,第一反应不是去查复杂配置,而是先删缓存。很多时候问题就是这么简单。
6.5 同一目录下同名 .lib 覆盖问题
Windows 上同时生成静态库和动态库时,动态库的导入库和静态库很可能同名。如果它们输出到同一个目录,后者会覆盖前者,而你还不容易察觉。排查这类问题,需要看清楚每个文件的时间戳,或者分目录输出。
我一般这样处理:在库的输出目录上做区分,比如静态库输出到lib-static,动态库输出到lib-shared,再通过ARCHIVE_OUTPUT_DIRECTORY和LIBRARY_OUTPUT_DIRECTORY属性精确控制。这样既能保持目标名语义清晰,又不会出现文件打架。
7. 我的几个实操习惯
生成库这件事,代码不多,但细节不少。我在实际项目里踩过一轮坑之后,慢慢形成了一些固定习惯,在这里分享一下。
第一,库的头文件路径永远用target_include_directories传,不手动拷贝或靠 IDE 设置。这样每个目标自包含,换机器、换队友都不会因为路径问题翻车。第二,Windows 下做动态库,导出宏这件事最好在一开始就加到接口头文件里,不要等链接报错了再补。第三,开发阶段给目标加上正确的构建后复制命令,把 DLL 自动放到可执行程序旁边,能极大减少运行时的“找不到 DLL”问题。第四,遇到 CMake 行为反常,先删build目录,重新配置,通常比胡思乱想更有效。第五,尽量让每个库目标只做一件事,静态库和动态库分开命名,避免在同一目录下产出同名产物。
我后来在项目里配合install规则和简单的CTest测试,把库的构建、使用和验证串成一条龙。每次改完库的接口,直接跑一遍测试,基本能保证对外分发的库是可用的。这个工作流不算复杂,但比“手动拷贝文件到别处再写个小 demo 验证”要顺手很多。
如果你刚接触 CMake,建议先把这篇文章里的示例代码在本地完整敲一遍,不要只看不做。同一个示例,在 Linux 和 Windows 上各编译一次,很多之前听不明白的细节,比如lib前缀、dllexport、运行时路径,都会立刻变得具体起来。后面再去看官方文档里的其他特性,例如OBJECT库、生成器表达式、find_package,就会发现底子已经打牢了。