9、CMake中的库管理:add_library()创建静态库与动态库、target_link_libraries()链接库、库的可见性
说到CMake的库管理,这其实是我在实际项目中打交道最多的部分。你想想看,一个稍微像样点的C++项目,少说也得拆成三五个库。要是连库都不会管理,那项目基本就乱成一锅粥了。
今天咱们就把这块彻底讲透。我会从创建库开始,再到链接库,最后聊聊那个让很多人头疼的可见性(VISIBILITY)问题。
9.1 用add_library()创建库
创建库的入口函数就是 add_library()。它的基本用法很简单:
add_library(<name> [STATIC | SHARED | MODULE]
[EXCLUDE_FROM_ALL]
[<source>...])
这里的关键是第二个参数——库的类型。我习惯把它分成三类:
- STATIC:静态库,编译时直接嵌入到可执行文件中。Linux下是
.a文件,Windows下是.lib。 - SHARED:动态库,运行时加载。Linux下是
.so,Windows下是.dll(同时会生成一个.lib导入库)。 - MODULE:插件式动态库,不参与链接,只在运行时用
dlopen()加载。这个用得少,我至今只在插件系统里用过两次。
来看个实际例子:
# 创建一个静态库
add_library(my_math STATIC
math/add.cpp
math/sub.cpp
math/mul.cpp
)
# 创建一个动态库
add_library(my_net SHARED
net/tcp.cpp
net/udp.cpp
)
这里有个小细节:如果不指定STATIC或SHARED,CMake会通过BUILD_SHARED_LIBS变量来决定。我个人建议显式指定,不然别人看你的CMakeLists.txt会一头雾水。
option(BUILD_SHARED_LIBS "Build shared libraries" ON)
# 然后所有add_library都不指定类型,由全局变量控制
这样既灵活又统一。
9.2 静态库 vs 动态库:怎么选?
这个问题我经常被问到。说实话,没有绝对答案,但有几个原则可以参考:
| 对比维度 | 静态库 | 动态库 |
|---|---|---|
| 生成文件大小 | 最终可执行文件较大 | 可执行文件较小,库单独存在 |
| 部署复杂度 | 简单,一个文件搞定 | 需要带上.so/.dll文件 |
| 更新维护 | 更新库需要重新编译整个程序 | 替换库文件即可(需保持ABI兼容) |
| 内存占用 | 每个进程都有一份拷贝 | 多个进程共享同一份代码 |
| 启动速度 | 快,无需加载外部库 | 稍慢,需要动态链接 |
我在项目中遇到过这样一个坑:一个内部工具链,所有模块都编译成静态库。结果最后链接出来的可执行文件有200多MB,而且每个工具都这么大。后来改成动态库,整个部署包从1.2GB降到了300MB。嗯,这就是教训。
__declspec(dllexport)和__declspec(dllimport)。CMake里可以用GenerateExportHeader模块自动生成,省去手动维护的麻烦。
9.3 target_link_libraries():链接的艺术
库创建好了,接下来就是链接。核心函数是 target_link_libraries():
target_link_libraries(<target>
<PRIVATE|PUBLIC|INTERFACE> <item>...
[<PRIVATE|PUBLIC|INTERFACE> <item>...]
)
这里最关键的就是那三个关键字:PRIVATE、PUBLIC、INTERFACE。很多人一开始搞不清楚它们的区别,我当初也绕了好一阵子。
简单来说:
- PRIVATE:这个库自己用,不传给依赖它的人。
- PUBLIC:自己用,也传给依赖它的人。
- INTERFACE:自己不直接用,只传给依赖它的人(常见于纯头文件库)。
来看个例子:
# 库A依赖了json库,但A的接口不暴露json相关类型
add_library(A STATIC a.cpp)
target_link_libraries(A PRIVATE json)
# 库B依赖了Eigen,而且B的接口中使用了Eigen的类型
add_library(B STATIC b.cpp)
target_link_libraries(B PUBLIC Eigen)
# 库C是纯头文件库,只定义接口
add_library(C INTERFACE)
target_link_libraries(C INTERFACE fmt)
为什么会这样设计?说白了就是为了控制依赖传递。你想想看,如果所有依赖都默认PUBLIC,那最终用户链接你的库时,会莫名其妙链接上一大堆你内部用的库。我曾经接手过一个项目,一个简单的可执行文件链接了40多个库,其中一半都是不必要的——就是因为滥用PUBLIC。
- 能用PRIVATE就用PRIVATE
- 只有接口中确实暴露了依赖的类型,才用PUBLIC
- 纯头文件库或者接口定义库,用INTERFACE
9.4 库的可见性:不仅仅是链接
说到可见性,很多人只想到链接时的符号可见性。其实CMake里还有一层——构建目标的可见性。这涉及到add_library()的另一个参数:EXCLUDE_FROM_ALL。
什么意思呢?默认情况下,你定义的所有库都会被构建。但有时候你有一些内部工具库,不想让它们出现在默认构建中。这时候就可以用EXCLUDE_FROM_ALL:
add_library(internal_tool STATIC EXCLUDE_FROM_ALL
tool.cpp
)
# 这个库不会在make时自动构建
# 只有明确依赖它的目标才会触发构建
另外,在Linux下还有符号可见性的问题。默认情况下,动态库的所有符号都是可见的。这会导致两个问题:
- 符号冲突:两个库导出了同名函数,链接时可能出问题。
- 性能下降:动态链接器需要处理更多符号。
解决办法是设置隐藏符号:
# 在CMake中设置
set(CMAKE_CXX_VISIBILITY_PRESET hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN ON)
# 然后在需要导出的函数上显式标记
__attribute__((visibility("default")))
void public_function();
我记得第一次遇到符号冲突是在一个嵌入式项目里。两个第三方库都定义了一个叫log()的函数,结果链接时直接报错。排查了整整一天才找到原因。从那以后,我所有动态库都默认隐藏符号,只导出必要的接口。
-fvisibility=hidden编译选项。CMake里用target_compile_options()设置。Windows上默认就是隐藏的,只有显式__declspec(dllexport)的才会导出。
9.5 实战:一个完整的库管理示例
说了这么多,咱们来写一个完整的例子。假设我们要构建一个网络库,内部依赖日志库,对外提供HTTP接口:
# 内部日志库,不对外暴露
add_library(logger STATIC
logger/logger.cpp
)
target_include_directories(logger PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/logger)
# 核心网络库,对外提供接口
add_library(network SHARED
network/http.cpp
network/tcp.cpp
)
target_include_directories(network
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/network
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/internal
)
target_link_libraries(network
PUBLIC pthread
PRIVATE logger
)
# 可执行文件
add_executable(my_app main.cpp)
target_link_libraries(my_app PRIVATE network)
这个结构很清晰:
logger是内部库,network用PRIVATE链接它,外部完全感知不到logger的存在。network用PUBLIC链接pthread,因为它的接口中可能用到了线程相关类型。my_app只需要链接network,不需要关心它内部依赖了什么。
这就是CMake库管理的精髓——封装与隔离。每个库只暴露必要的信息,内部实现细节完全隐藏。
9.6 知识体系总览
最后,我用一张图来总结本章的核心内容。这张图展示了从创建库到链接库,再到可见性控制的完整流程:
这张图把今天的内容串起来了。从左到右,从创建到链接再到可见性控制,每一步都有对应的CMake命令和策略。你写CMakeLists.txt的时候,就按这个思路来,基本不会出错。
好了,库管理这块就讲到这里。记住一句话:库的封装程度,决定了项目的可维护性。别图省事一股脑全PUBLIC,该藏的就藏起来,该暴露的才暴露。这样你的项目才能经得起时间的考验。