cmake之旅(4)
同系列文章:
cmake之旅(1):构建的过程
cmake之旅(2):CMakeLists.txt 核心语法
cmake之旅(3):多目录项目管理
cmake之旅(4):静态库与动态库
cmake之旅(5):函数、宏与 .cmake 模块
cmake之旅(6):查找和使用第三方库
静态库与动态库
上一篇我们学会了用 add_subdirectory 管理多目录项目,并且在子目录中用 add_library 把源文件编译成了库。但当时我们只是简单地用了一下,并没有深入思考:编译出来的到底是什么类型的库?静态库和动态库有什么区别?什么时候该用哪一种?
这一篇我们就来彻底搞清楚这些问题。
1 什么是库?
在正式开始之前,我们先理解一下"库"这个概念。
库(Library)就是一组已经编译好的代码,打包在一起供别人使用。 你可以把它想象成一个"工具箱"——别人不需要知道工具箱里的工具是怎么造出来的,只需要知道怎么用就行。
回忆一下我们之前的 add 模块:
// add.h —— 告诉别人"我有什么工具"
int add(int a, int b);
// add.cpp —— 工具的具体实现
int add(int a, int b)
{
return a + b;
}
如果把 add.cpp 编译成库,别人只需要拿到 add.h(知道怎么用)和编译好的库文件(工具本身),就能使用 add 功能了,完全不需要拿到 add.cpp 的源码。
库分为两种:静态库(Static Library) 和 动态库(Dynamic Library / Shared Library)。
2 静态库与动态库的区别
在讲 CMake 的用法之前,我们先搞清楚这两种库的本质区别。
2.1 静态库
静态库在链接阶段会被完整地"复制"到可执行文件中。
打个比方:你要写一篇论文,引用了一本参考书中的一个章节。静态库的做法是——你把那个章节的内容完整地抄写到你的论文里面。这样你的论文"自包含"了所有内容,不依赖外部任何东西,但论文的篇幅也变大了。
在不同的操作系统上,静态库的文件格式不同:
| 系统 | 静态库文件 | 示例 |
|---|---|---|
| Linux / macOS | .a |
libadd.a |
| Windows | .lib |
add.lib |
2.2 动态库
动态库在链接阶段只是做了一个"标记",真正的库代码在程序运行时才被加载。
还是论文的比方:这次你不抄写内容了,只在论文中写了一个注释——“请参见《XXX》第 3 章”。读者(操作系统)在阅读你的论文时,会自己去找那本书、翻到第 3 章来看。这样你的论文变短了,但前提是读者手边必须有那本参考书。
| 系统 | 动态库文件 | 示例 |
|---|---|---|
| Linux | .so |
libadd.so |
| macOS | .dylib |
libadd.dylib |
| Windows | .dll + .lib |
add.dll + add.lib |
2.3 对比总结
| 对比项 | 静态库 | 动态库 |
|---|---|---|
| 链接方式 | 编译时复制进可执行文件 | 运行时动态加载 |
| 可执行文件大小 | 较大(包含了库的代码) | 较小(只包含引用信息) |
| 运行时依赖 | 无,独立运行 | 需要库文件存在于系统中 |
| 更新库 | 需要重新编译可执行文件 | 替换库文件即可,无需重新编译 |
| 内存占用 | 每个程序各自一份副本 | 多个程序可共享同一份 |
| 部署难度 | 简单,只需要一个可执行文件 | 需要一起分发库文件 |
一句话总结:静态库"打包带走",动态库"现场借用"。
3 在 CMake 中构建静态库
我们继续使用上一篇的项目结构:
├── CMakeLists.txt
├── include
│ └── calc
│ ├── add.h
│ └── de.h
└── src
├── CMakeLists.txt
├── add
│ ├── CMakeLists.txt
│ └── add.cpp
├── de
│ ├── CMakeLists.txt
│ └── de.cpp
└── main.cpp
源文件和上一篇完全一样,这里不再重复列出。
3.1 显式指定 STATIC
在上一篇中,我们的子模块 CMakeLists.txt 是这样写的:
add_library(add_lib add.cpp)
这里有一个细节:我们没有指定库的类型。 当不指定类型时,CMake 会根据 BUILD_SHARED_LIBS 变量来决定——如果这个变量为 True,就构建动态库;否则构建静态库。默认情况下 BUILD_SHARED_LIBS 未定义,所以默认构建的是静态库。
但是"依赖默认值"不是好习惯。推荐显式指定库的类型:
src/add/CMakeLists.txt:
# 显式指定为静态库(STATIC)
add_library(add_lib STATIC add.cpp)
# 设置头文件路径
target_include_directories(add_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)
src/ de /CMakeLists.txt:
# 显式指定为静态库(STATIC)
add_library(de_lib STATIC de.cpp)
# 设置头文件路径
target_include_directories(de_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)
3.2 构建并观察
mkdir build && cd build
cmake ..
make
构建完成后,在 build 目录下你可以找到生成的静态库文件:
find . -name "*.a"
你会看到类似这样的输出:
./src/add/libadd_lib.a
./src/de/libde_lib.a
注意:CMake 自动给库名加上了 lib 前缀和 .a 后缀。也就是说,你在 CMakeLists.txt 中写的目标名是 add_lib,生成的实际文件名是 libadd_lib.a。这是 Linux 下的命名约定,CMake 会自动处理。
4 在 CMake 中构建动态库
把 STATIC 换成 SHARED 就行了。
src/add/CMakeLists.txt:
# 指定为动态库(SHARED)
add_library(add_lib SHARED add.cpp)
# 设置头文件路径
target_include_directories(add_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)
src/de/CMakeLists.txt:
# 指定为动态库(SHARED)
add_library(de_lib SHARED de.cpp)
# 设置头文件路径
target_include_directories(de_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)
重新构建后查看:
find . -name "*.so"
输出:
./src/add/libadd_lib.so
./src/de/libde_lib.so
可执行文件也能正常运行。但这里有一个关键区别——可执行文件现在依赖于这些 .so 文件。如果你把可执行文件拷贝到另一个目录单独运行,可能会报错:
error while loading shared libraries: libadd_lib.so: cannot open shared object file
这就是动态库的特点:运行时必须能找到库文件。
5 让用户选择库类型
有时候你希望把选择权交给使用者——让他自己决定构建静态库还是动态库。CMake 提供了一个内置变量 BUILD_SHARED_LIBS 来实现这个需求。
src/add/CMakeLists.txt:
# 不指定 STATIC 或 SHARED,由 BUILD_SHARED_LIBS 决定
add_library(add_lib add.cpp)
# 设置头文件路径
target_include_directories(add_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)
使用者可以在构建时通过命令行参数来控制:
# 构建静态库(默认)
cmake ..
# 构建动态库
cmake -DBUILD_SHARED_LIBS=ON ..
这种方式在开源项目中很常见,让使用者根据自己的需求灵活选择。
6 同时构建静态库和动态库
有些项目希望同时提供静态库和动态库,让使用者按需取用。做法是定义两个不同的目标:
src/add/CMakeLists.txt:
# 静态库
add_library(add_static STATIC add.cpp)
target_include_directories(add_static PUBLIC ${PROJECT_SOURCE_DIR}/include)
# 动态库
add_library(add_shared SHARED add.cpp)
target_include_directories(add_shared PUBLIC ${PROJECT_SOURCE_DIR}/include)
这样构建后会同时生成 libadd_static.a 和 libadd_shared.so。
但这样写有个问题: 动态库和静态库的目标名不同(add_static vs add_shared),生成的文件名也不同。如果我们希望它们生成的文件名相同(都叫 libadd),可以用 set_target_properties 来修改输出名称:
# 静态库
add_library(add_static STATIC add.cpp)
target_include_directories(add_static PUBLIC ${PROJECT_SOURCE_DIR}/include)
set_target_properties(add_static PROPERTIES OUTPUT_NAME "add")
# 动态库
add_library(add_shared SHARED add.cpp)
target_include_directories(add_shared PUBLIC ${PROJECT_SOURCE_DIR}/include)
set_target_properties(add_shared PROPERTIES OUTPUT_NAME "add")
这样静态库会生成 libadd.a,动态库会生成 libadd.so,文件名统一,不会冲突(因为后缀不同)。
7 控制库的输出路径
默认情况下,CMake 会把生成的库文件放在与源文件对应的构建子目录中。比如 src/add/CMakeLists.txt 生成的库会出现在 build/src/add/ 下。
但在实际项目中,我们通常希望把所有的库和可执行文件集中输出到统一的目录中,比如 build/lib/ 和 build/bin/。
在顶层 CMakeLists.txt 中添加:
# 设定可执行文件的输出目录
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
# 设定静态库的输出目录
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
# 设定动态库的输出目录
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
构建后的目录结构就会变成:
build/
├── bin
│ └── Calculator # 可执行文件
└── lib
├── libadd_lib.a # 静态库(或 .so)
└── libde_lib.a # 静态库(或 .so)
这三个变量分别控制不同类型文件的输出位置:
| 变量 | 控制的文件类型 | 示例 |
|---|---|---|
CMAKE_RUNTIME_OUTPUT_DIRECTORY |
可执行文件(.exe) | bin/Calculator |
CMAKE_ARCHIVE_OUTPUT_DIRECTORY |
静态库(.a / .lib) | lib/libadd.a |
CMAKE_LIBRARY_OUTPUT_DIRECTORY |
动态库(.so / .dylib) | lib/libadd.so |
注意: 在 Windows 上,.dll 文件被视为 RUNTIME(而不是 LIBRARY),所以 Windows 的动态库会输出到 CMAKE_RUNTIME_OUTPUT_DIRECTORY 指定的目录中。这是一个容易踩的坑。
8 设置库的版本号
对于动态库,设置版本号是一个好习惯。版本号可以帮助使用者区分不同版本的库,也有利于系统的库管理。
add_library(add_lib SHARED add.cpp)
# 设置版本号
set_target_properties(add_lib PROPERTIES
VERSION 1.2.3 # 库的完整版本号(主版本.次版本.修订号)
SOVERSION 1 # SO 版本号(通常等于主版本号,用于 ABI 兼容)
)
构建后,Linux 下会生成以下文件:
libadd_lib.so -> libadd_lib.so.1 # 符号链接,指向 SO 版本
libadd_lib.so.1 -> libadd_lib.so.1.2.3 # 符号链接,指向完整版本
libadd_lib.so.1.2.3 # 真实的库文件
为什么要这样设计?
- 程序链接时使用
libadd_lib.so(不带版本号),所以升级库时程序不需要重新编译 - 运行时加载
libadd_lib.so.1(SO 版本),只要主版本号不变,程序就能兼容 - 真实文件
libadd_lib.so.1.2.3包含完整版本信息,方便管理多个版本共存
这套机制是 Linux 动态库版本管理的标准做法,CMake 通过 VERSION 和 SOVERSION 两个属性就能自动帮你生成。
9 OBJECT 库 —— 第三种选择
除了 STATIC 和 SHARED,CMake 还提供了一种特殊的库类型——OBJECT 库。
add_library(add_obj OBJECT add.cpp)
OBJECT 库不会生成 .a 或 .so 文件,它只是把源文件编译成目标文件(.o),然后让其他目标直接引用这些目标文件。
什么时候用 OBJECT 库? 最典型的场景就是"同时构建静态库和动态库"。回想一下第 6 节,我们为了同时生成两种库,不得不写两次 add_library,源文件也被编译了两次。用 OBJECT 库可以避免这个问题:
# 源文件只编译一次,生成目标文件
add_library(add_obj OBJECT add.cpp)
target_include_directories(add_obj PUBLIC ${PROJECT_SOURCE_DIR}/include)
# 静态库和动态库都复用同一份目标文件
add_library(add_static STATIC $<TARGET_OBJECTS:add_obj>)
add_library(add_shared SHARED $<TARGET_OBJECTS:add_obj>)
$<TARGET_OBJECTS:add_obj> 是一个"生成器表达式"(Generator Expression),它的值是 add_obj 编译产生的所有 .o 文件。我们会在后续的文章中详细讲解生成器表达式,这里了解用法即可。
不过要注意: 动态库编译时通常需要 -fPIC 选项(Position Independent Code,位置无关代码)。如果你的 OBJECT 库要同时被静态库和动态库使用,需要加上这个属性:
add_library(add_obj OBJECT add.cpp)
# 启用位置无关代码,这样生成的目标文件既能用于静态库也能用于动态库
set_target_properties(add_obj PROPERTIES POSITION_INDEPENDENT_CODE ON)
10 完整示例
我们把这一篇的知识汇总,写一个完整的示例,项目结构如下:
├── CMakeLists.txt
├── include
│ └── calc
│ ├── add.h
│ └── de.h
└── src
├── CMakeLists.txt
├── add
│ ├── CMakeLists.txt
│ └── add.cpp
├── de
│ ├── CMakeLists.txt
│ └── de.cpp
└── main.cpp
源文件与上一篇相同,这里只列出 CMakeLists.txt。
顶层 CMakeLists.txt:
# ============================================================
# 项目:Calculator
# 描述:cmake之旅(4)完整示例 —— 静态库与动态库
# ============================================================
# 设定 CMake 最低版本
cmake_minimum_required(VERSION 3.10)
# 定义项目信息
project(Calculator VERSION 1.0.0 LANGUAGES CXX)
# 设定 C++ 标准
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED True)
# 统一输出目录
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
# 提供选项:让使用者选择构建静态库还是动态库
option(CALC_BUILD_SHARED "构建动态库" OFF)
# 打印构建信息
if(CALC_BUILD_SHARED)
message(STATUS "库类型: 动态库 (SHARED)")
else()
message(STATUS "库类型: 静态库 (STATIC)")
endif()
# 添加 src 子目录
add_subdirectory(src)
这里用了 option 命令定义了一个开关 CALC_BUILD_SHARED,使用者可以通过 -DCALC_BUILD_SHARED=ON 来选择构建动态库。option 和 set(... CACHE BOOL ...) 类似,但语法更简洁,专门用于布尔类型的选项。
src/add/CMakeLists.txt:
# 根据选项决定库类型
if(CALC_BUILD_SHARED)
add_library(add_lib SHARED add.cpp)
else()
add_library(add_lib STATIC add.cpp)
endif()
# 设置头文件路径
target_include_directories(add_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)
# 如果是动态库,设置版本号
if(CALC_BUILD_SHARED)
set_target_properties(add_lib PROPERTIES
VERSION ${PROJECT_VERSION}
SOVERSION 1
)
endif()
src/de/CMakeLists.txt:
# 根据选项决定库类型
if(CALC_BUILD_SHARED)
add_library(de_lib SHARED de.cpp)
else()
add_library(de_lib STATIC de.cpp)
endif()
# 设置头文件路径
target_include_directories(de_lib PUBLIC ${PROJECT_SOURCE_DIR}/include)
# 如果是动态库,设置版本号
if(CALC_BUILD_SHARED)
set_target_properties(de_lib PROPERTIES
VERSION ${PROJECT_VERSION}
SOVERSION 1
)
endif()
src/CMakeLists.txt:
# 添加各模块子目录
add_subdirectory(add)
add_subdirectory(de)
# 生成可执行文件
add_executable(${PROJECT_NAME} main.cpp)
# 链接所有模块
target_link_libraries(${PROJECT_NAME} PRIVATE add_lib de_lib)
构建和运行:
mkdir build && cd build
# 构建静态库版本(默认)
cmake ..
make
./bin/Calculator
# 或者构建动态库版本
cmake -DCALC_BUILD_SHARED=ON ..
make
./bin/Calculator
注意看,因为我们设置了 CMAKE_RUNTIME_OUTPUT_DIRECTORY,可执行文件现在在 bin/ 目录下,库文件在 lib/ 目录下,整齐多了。
11 什么时候用静态库,什么时候用动态库?
这个问题没有标准答案,取决于你的具体场景。以下是一些通用的建议:
优先选择静态库的场景:
部署环境不可控时静态库更可靠,因为所有代码都打包在可执行文件中,不怕目标机器上缺少库文件。嵌入式开发和跨平台发布工具通常优先使用静态库。对性能要求极高的场景也倾向静态库,因为它避免了运行时的动态链接开销(虽然这个开销通常很小)。
优先选择动态库的场景:
大型项目中多个程序共用同一个库时,动态库可以节省磁盘空间和内存。需要支持插件机制的程序必须使用动态库,因为插件需要在运行时加载。库本身更新频繁、但 API 不变的情况下,动态库可以单独替换而不需要重新编译所有依赖它的程序。
初学者建议:如果没有明确的需求,就用静态库。 它更简单、更不容易出问题。等到项目规模增大、有了明确的需求时,再考虑切换到动态库。
12 本篇命令速查表
| 命令 / 属性 | 作用 | 示例 |
|---|---|---|
add_library(name STATIC src) |
构建静态库 | add_library(mylib STATIC a.cpp) |
add_library(name SHARED src) |
构建动态库 | add_library(mylib SHARED a.cpp) |
add_library(name OBJECT src) |
构建 OBJECT 库 | add_library(myobj OBJECT a.cpp) |
option(name desc val) |
定义布尔选项 | option(BUILD_TESTS "构建测试" OFF) |
set_target_properties |
设置目标属性 | 见下方 |
VERSION |
库的完整版本号 | VERSION 1.2.3 |
SOVERSION |
SO 版本号(ABI 兼容版本) | SOVERSION 1 |
OUTPUT_NAME |
自定义输出文件名 | OUTPUT_NAME "add" |
POSITION_INDEPENDENT_CODE |
启用 -fPIC | POSITION_INDEPENDENT_CODE ON |
输出路径相关变量:
| 变量 | 控制的文件 |
|---|---|
CMAKE_RUNTIME_OUTPUT_DIRECTORY |
可执行文件、Windows .dll |
CMAKE_ARCHIVE_OUTPUT_DIRECTORY |
静态库 .a / .lib |
CMAKE_LIBRARY_OUTPUT_DIRECTORY |
动态库 .so / .dylib |
13 总结与下一篇预告
这一篇我们系统学习了静态库和动态库的区别、CMake 中如何构建它们、如何控制输出路径和版本号,还了解了 OBJECT 库这种特殊类型。
回头看一下我们目前写过的 CMakeLists.txt,你可能已经注意到一个问题:很多代码在重复。 比如 add 和 de 的 CMakeLists.txt 几乎一模一样,只是库名和源文件不同。如果有十个模块,就要写十份几乎相同的 CMakeLists.txt,改一个地方还要改十次。
有没有办法把这些重复的逻辑"封装"起来复用?就像 C++ 中的函数一样,定义一次,到处调用?
下一篇——cmake之旅(5):函数、宏与 .cmake 模块,我们来学习 CMake 的代码复用机制。

浙公网安备 33010602011771号