11. 多库管理:多个 add_library()、库之间的依赖关系、target_link_libraries() 详解、库的可见性

好,咱们今天聊点实在的。当你一个项目里只有一两个 .so 库的时候,CMakeLists.txt 怎么写都行。但一旦项目膨胀起来——比如你同时要编译一个核心算法库、一个网络协议库、一个日志工具库,再加上第三方的预编译库——这时候,多库管理的能力就成了分水岭。

我见过不少团队,一开始图省事,把所有源文件塞进一个 add_library() 里。结果呢?编译慢、耦合高、想单独替换某个模块都费劲。说白了,这就是欠了技术债。今天咱们就把这笔债还上。

11.1 多个 add_library() 的正确姿势

先看一个最简单的多库场景。假设你有三个模块:core、network、utils。每个模块都有自己的源文件。

# CMakeLists.txt
add_library(core STATIC
    src/core/engine.cpp
    src/core/manager.cpp
)

add_library(network STATIC
    src/network/http_client.cpp
    src/network/websocket.cpp
)

add_library(utils STATIC
    src/utils/logger.cpp
    src/utils/string_helpers.cpp
)

每个库独立编译,互不干扰。这有什么好处?我举个例子。有一次我在项目中需要单独替换网络层,因为要适配一个新的 IoT 协议。如果所有代码都在一个库里,我得小心翼翼地改,生怕影响其他模块。但拆成多个库之后,我只需要重新编译 network 库,core 和 utils 纹丝不动。编译时间从 3 分钟降到了 30 秒。

我的习惯:每个逻辑模块一个库。哪怕这个库只有两个源文件,也值得单独拆出来。后期维护时你会感谢自己的。

11.2 库之间的依赖关系

库拆开了,问题也来了:它们之间怎么互相调用?比如 network 库要用 core 库里的数据结构,utils 库里的日志函数。

这时候就需要 target_link_libraries() 登场了。它用来声明库之间的依赖关系。

target_link_libraries(network PUBLIC core)
target_link_libraries(network PRIVATE utils)

这两行代码,就是告诉 CMake:

  • network 依赖于 core(PUBLIC)
  • network 依赖于 utils(PRIVATE)

你可能会问:PUBLIC 和 PRIVATE 到底有什么区别?别急,咱们下一节细讲。这里先记住一个原则:依赖关系要明确,不要偷懒写成 PUBLIC 一把梭

我曾经踩过的坑:有一次我把所有依赖都写成 PUBLIC,结果上层应用莫名其妙地链接了一堆它根本不需要的库。链接器报了一堆重复符号错误,排查了整整一个下午。从那以后,我对可见性关键字再也不敢马虎了。

11.3 target_link_libraries() 详解

这个函数是 CMake 里最常用、也最容易用错的函数之一。它的完整签名是这样的:

target_link_libraries(<target> 
    <PRIVATE|PUBLIC|INTERFACE> <item>...
    [<PRIVATE|PUBLIC|INTERFACE> <item>...]
)

每个 <item> 可以是:

  • 另一个 CMake 目标(比如你定义的库名)
  • 一个库文件的绝对路径
  • 一个库文件名(如 log
  • 一个 -l 开头的链接器标志

咱们重点说说最常见的用法——链接另一个 CMake 目标。这时候,CMake 会自动处理头文件搜索路径、编译定义、链接库路径等所有传递性依赖。

# 假设 app 是一个可执行文件或共享库
target_link_libraries(app 
    PRIVATE core
    PUBLIC  network
    INTERFACE utils
)

嗯,这里要注意:INTERFACE 比较特殊。它表示这个依赖只对使用 app 的目标可见,app 自己并不链接它。这个用法在纯头文件库或者接口库中很常见。

11.4 库的可见性:PUBLIC / PRIVATE / INTERFACE

这是多库管理的核心,也是很多人搞不清楚的地方。我尽量用大白话讲明白。

想象一下,你有一个库 A,它依赖了库 B。现在有一个库 C,它要链接 A。

  • PRIVATE:A 依赖 B,但 C 不需要知道 B 的存在。B 是 A 的内部实现细节。
  • PUBLIC:A 依赖 B,而且 C 也需要用到 B 的东西(比如头文件、符号)。B 是 A 的接口的一部分。
  • INTERFACE:A 自己不链接 B,但任何链接 A 的目标都必须链接 B。这通常用于纯头文件库或者接口定义库。

咱们用代码说话:

# 库 A 的实现用到了 B
add_library(A STATIC a.cpp)
target_link_libraries(A PRIVATE B)
# 库 C 链接 A,但 C 不需要知道 B
add_library(C STATIC c.cpp)
target_link_libraries(C PRIVATE A)
# 这里 C 不会自动链接 B,因为 B 是 A 的 PRIVATE 依赖

再看 PUBLIC 的例子:

# 库 A 的头文件里引用了 B 的类型
add_library(A STATIC a.cpp)
target_link_libraries(A PUBLIC B)
# 库 C 链接 A,C 的源文件里可以直接 #include <B/header.h>
add_library(C STATIC c.cpp)
target_link_libraries(C PRIVATE A)
# 这里 C 会自动获得 B 的头文件搜索路径和链接库

最后是 INTERFACE:

# 一个纯头文件库,没有源文件
add_library(header_only INTERFACE)
target_include_directories(header_only INTERFACE include/)
target_link_libraries(header_only INTERFACE core)

# 任何链接 header_only 的目标,都会自动获得 include/ 目录和 core 库
add_library(app SHARED main.cpp)
target_link_libraries(app PRIVATE header_only)
核心原则:能用 PRIVATE 就用 PRIVATE。只有当下游确实需要知道这个依赖时,才升级为 PUBLIC。INTERFACE 只用于纯接口场景。

11.5 实战:一个典型的多库项目结构

咱们把前面讲的知识点串起来,看一个完整的例子。假设你的 Android NDK 项目结构如下:

app/
├── CMakeLists.txt
├── src/
│   ├── core/
│   │   ├── engine.cpp
│   │   └── engine.h
│   ├── network/
│   │   ├── http_client.cpp
│   │   └── http_client.h
│   └── utils/
│       ├── logger.cpp
│       └── logger.h
└── libs/
    └── libssl.so  (第三方预编译库)

对应的 CMakeLists.txt:

cmake_minimum_required(VERSION 3.18)
project(MyApp)

# 1. 定义三个内部库
add_library(core STATIC
    src/core/engine.cpp
)

add_library(utils STATIC
    src/utils/logger.cpp
)

add_library(network STATIC
    src/network/http_client.cpp
)

# 2. 声明依赖关系
target_link_libraries(network 
    PUBLIC  core      # network 的头文件暴露了 core 的类型
    PRIVATE utils     # network 内部用 utils 打日志
)

# 3. 导入第三方库
add_library(ssl SHARED IMPORTED)
set_target_properties(ssl PROPERTIES
    IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/libssl.so
)

# 4. 最终的目标:共享库
add_library(myapp SHARED
    src/main.cpp
)

target_link_libraries(myapp
    PRIVATE network   # 只依赖 network,间接获得 core
    PRIVATE ssl       # 直接依赖第三方库
)

这个结构清晰吗?我觉得挺清晰的。每个库的职责单一,依赖关系明确。如果将来要替换网络层,只需要改 network 库和它的依赖声明,其他代码完全不受影响。

11.6 知识体系总览

为了让你对本章内容有个整体把握,我画了一张图。它展示了多库管理的核心要素和它们之间的关系。

多库管理知识体系 多库管理 多个 add_library() 库之间的依赖关系 可见性控制 独立编译 模块解耦 传递依赖 链接传播 PRIVATE PUBLIC INTERFACE 核心原则:最小可见性 + 明确依赖 能 PRIVATE 不 PUBLIC,INTERFACE 只用于纯接口

11.7 避坑指南

最后,分享几个我这些年积累的经验,希望能帮你少走弯路。

  • 循环依赖是噩梦:库 A 依赖 B,B 又依赖 A。CMake 会直接报错。解决办法是重新设计模块边界,或者把公共部分抽成第三个库。
  • 小心 STATIC 库的重复链接:如果多个共享库都链接了同一个静态库,每个共享库都会有一份静态库的副本。这会导致代码膨胀和全局变量重复。我的建议是:静态库只用于内部模块,最终产物用共享库。
  • INTERFACE 库没有源文件:它只是一个“标签”,用来传递编译选项和依赖。别想着往里面加 .cpp 文件,CMake 会忽略它们。
  • Android NDK 的特殊性:在 Android 平台上,系统库(如 log、z)需要用 find_library() 找到路径,再用 target_link_libraries() 链接。不要直接写 -llog,那样不够跨平台。

好了,多库管理这块就聊到这儿。记住一句话:好的依赖管理,是项目长期健康的基石。别图一时省事,给未来的自己挖坑。

公众号:蓝海资料掘金营,微信 deep3321