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。嗯,这就是教训。

注意:如果你在Windows上开发,动态库的导出符号需要特殊处理。通常要用__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下还有符号可见性的问题。默认情况下,动态库的所有符号都是可见的。这会导致两个问题:

  1. 符号冲突:两个库导出了同名函数,链接时可能出问题。
  2. 性能下降:动态链接器需要处理更多符号。

解决办法是设置隐藏符号

# 在CMake中设置
set(CMAKE_CXX_VISIBILITY_PRESET hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN ON)

# 然后在需要导出的函数上显式标记
__attribute__((visibility("default")))
void public_function();

我记得第一次遇到符号冲突是在一个嵌入式项目里。两个第三方库都定义了一个叫log()的函数,结果链接时直接报错。排查了整整一天才找到原因。从那以后,我所有动态库都默认隐藏符号,只导出必要的接口。

小技巧:如果你用GCC/Clang,可以加上-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库管理知识体系 add_library() STATIC | SHARED | MODULE 创建静态库 / 动态库 / 模块库 target_link_libraries() PRIVATE | PUBLIC | INTERFACE 控制依赖传递与可见性 可见性控制 EXCLUDE_FROM_ALL 符号隐藏 / 构建排除 静态库特点 • 编译时嵌入,文件较大 • 部署简单,无外部依赖 • 更新需重新编译 动态库特点 • 运行时加载,文件较小 • 需携带.so/.dll文件 • 更新只需替换库文件 可见性策略 • 默认隐藏内部符号 • 显式导出公共接口 • 避免符号冲突 核心原则:封装与隔离 每个库只暴露必要接口,内部实现细节完全隐藏 PRIVATE < PUBLIC < INTERFACE 逐级控制依赖传递

这张图把今天的内容串起来了。从左到右,从创建到链接再到可见性控制,每一步都有对应的CMake命令和策略。你写CMakeLists.txt的时候,就按这个思路来,基本不会出错。

好了,库管理这块就讲到这里。记住一句话:库的封装程度,决定了项目的可维护性。别图省事一股脑全PUBLIC,该藏的就藏起来,该暴露的才暴露。这样你的项目才能经得起时间的考验。