22、CMake与外部依赖:FetchContent、ExternalProject_Add、add_subdirectory

做嵌入式开发,最头疼的事之一就是管理外部依赖。你想想看,项目里用了lwIP、FreeRTOS、mbedTLS这些库,总不能把源码全拷进自己仓库吧?版本怎么管?更新怎么办?

CMake给了我们三种主流方案:add_subdirectoryFetchContentExternalProject_Add。这三种方式各有各的脾气,用错了地方,够你喝一壶的。今天我就把它们的区别、适用场景和坑,一次性给你讲透。

22.1 三种方式的核心区别

先看一张总览图,心里有个谱:

CMake外部依赖管理三种方式对比 add_subdirectory • 源码已在本地 • 构建时直接编译 • 共享全局变量 • 适合小项目/内部库 ⚠ 污染父作用域 ⚠ 无法自动下载 FetchContent • 自动下载+编译 • 配置阶段执行 • 支持Git/SVN/URL • 可指定版本/标签 ⚠ 需网络连接 ⚠ 首次配置慢 ExternalProject_Add • 构建阶段执行 • 完全独立构建 • 支持任意构建系统 • 可打补丁/定制 ⚠ 无法直接target_link ⚠ 调试不方便 简单依赖 → add_subdirectory | 中等复杂 → FetchContent | 复杂/异构 → ExternalProject_Add

22.2 add_subdirectory:最直接,但最"脏"

这是最原始的方式。你把第三方库的源码放在项目里,然后用add_subdirectory把它加进来。说白了,就是把别人的CMakeLists.txt拉到你的构建流程里一起跑。

优点:简单粗暴,直接就能用target_link_libraries链接。

缺点:外部库的变量、宏、target会污染你的作用域。我遇到过好几次,某个库偷偷定义了DEBUG宏,结果把我的调试信息全搞乱了。

# 最简单的用法
add_subdirectory(third_party/lwIP)
target_link_libraries(my_app PRIVATE lwIP)

# 但如果你要控制变量传播
add_subdirectory(third_party/lwIP EXCLUDE_FROM_ALL)
# EXCLUDE_FROM_ALL 可以防止外部库的target被默认构建

注意:我曾经在一个项目里,用add_subdirectory加了个老版本的FreeRTOS。结果它内部定义了一个叫configUSE_TICKLESS_IDLE的变量,跟我自己的配置冲突了。查了两天才找到原因。所以,用这种方式前,一定要检查外部库的CMakeLists.txt里有没有全局变量。

22.3 FetchContent:现代CMake的"下载即用"

这是CMake 3.11引入的功能,3.14以后更稳定。我个人非常喜欢这种方式——它能在配置阶段自动下载依赖,然后像add_subdirectory一样直接使用。

为什么会推荐它?因为你在CMakeLists.txt里写清楚依赖的Git仓库和版本,CMake会自动帮你拉代码、切分支、然后编译。团队里其他人只需要cmake ..,一切自动完成。

# 引入FetchContent模块
include(FetchContent)

# 声明依赖
FetchContent_Declare(
  mbedtls
  GIT_REPOSITORY https://github.com/Mbed-TLS/mbedtls.git
  GIT_TAG        v3.4.0
  GIT_SHALLOW    TRUE  # 只拉最新commit,省时间
)

# 让CMake去下载并添加
FetchContent_MakeAvailable(mbedtls)

# 然后就可以直接链接了
target_link_libraries(my_app PRIVATE mbedtls::mbedtls)

我的习惯:在嵌入式项目里,我会配合GIT_SHALLOW TRUEGIT_PROGRESS TRUE,这样下载快,而且能看到进度。另外,如果依赖的库比较大(比如OpenCV),我会在第一次配置时泡杯咖啡——确实要等一会儿。

22.3.1 处理依赖的依赖

FetchContent最强大的地方在于,它能自动处理传递依赖。比如你依赖的库A又依赖库B,只要A的CMakeLists.txt里也用了FetchContent,CMake会自动去拉B。

# 但要注意:如果多个依赖都用了同一个子依赖
# 可以用 OVERRIDE_FIND_PACKAGE 来避免重复下载
FetchContent_Declare(
  zlib
  GIT_REPOSITORY https://github.com/madler/zlib.git
  GIT_TAG        v1.2.13
  OVERRIDE_FIND_PACKAGE  # 告诉CMake:别去找系统的zlib了,用我指定的
)

避坑指南:我曾经在一个项目里,同时依赖了libA和libB,它们都依赖libC但版本不同。FetchContent默认会使用先声明的那一个版本。如果你需要强制指定版本,可以用FetchContent_SetPopulated或者FetchContent_GetProperties来手动控制。

22.4 ExternalProject_Add:终极武器,但别滥用

ExternalProject_Add是CMake里最"重"的依赖管理方式。它在构建阶段(而不是配置阶段)去下载和编译外部项目。这意味着,你的主项目配置完了,外部项目才开始动工。

什么时候需要用?我总结了几种场景:

  • 外部项目使用非CMake构建系统(比如Makefile、Autotools)
  • 需要给外部项目打补丁
  • 外部项目有复杂的配置选项
  • 你想让外部项目完全独立构建,不污染主项目
include(ExternalProject)

ExternalProject_Add(
  libusb
  GIT_REPOSITORY https://github.com/libusb/libusb.git
  GIT_TAG        v1.0.26
  SOURCE_DIR     ${CMAKE_SOURCE_DIR}/third_party/libusb
  BINARY_DIR     ${CMAKE_BINARY_DIR}/third_party/libusb
  CONFIGURE_COMMAND ./configure --prefix=${CMAKE_BINARY_DIR}/install
  BUILD_COMMAND   make -j4
  INSTALL_COMMAND make install
  # 如果不需要安装,可以设为空
  # INSTALL_COMMAND ""
)

22.4.1 如何链接ExternalProject构建的库

这里有个大坑:ExternalProject_Add构建出来的target,不能直接用target_link_libraries链接。因为它在构建阶段才生成,配置阶段根本不知道它的存在。

解决办法是手动指定库文件路径:

# 先构建外部项目
ExternalProject_Add(my_third_party
  ...
  INSTALL_DIR ${CMAKE_BINARY_DIR}/install
)

# 手动创建导入的target
add_library(my_third_party::my_third_party STATIC IMPORTED GLOBAL)
set_target_properties(my_third_party::my_third_party PROPERTIES
  IMPORTED_LOCATION ${CMAKE_BINARY_DIR}/install/lib/libmy_third_party.a
)

# 添加头文件路径
target_include_directories(my_third_party::my_third_party INTERFACE
  ${CMAKE_BINARY_DIR}/install/include
)

# 最后,确保外部项目先构建
add_dependencies(my_app my_third_party)
target_link_libraries(my_app PRIVATE my_third_party::my_third_party)

我的经验:ExternalProject_Add最适合那些"一次构建,多次使用"的场景。比如交叉编译工具链里的某个库,你只需要构建一次,后面所有项目都能用。但如果你频繁修改外部库的代码,调试起来会很痛苦——每次都要重新构建整个外部项目。

22.5 三种方式的选择策略

说了这么多,到底该怎么选?我根据自己的项目经验,整理了一个决策表:

场景 推荐方式 原因
内部小库,源码已在本项目 add_subdirectory 最简单,直接链接
开源库,需要自动下载 FetchContent 配置阶段完成,使用方便
开源库,需要打补丁 FetchContent + PATCH_COMMAND 可以在下载后自动打补丁
非CMake项目 ExternalProject_Add 支持任意构建系统
交叉编译,需要独立构建 ExternalProject_Add 完全隔离,不污染主项目
大型依赖(如Qt、Boost) find_package + 系统安装 不要用CMake去构建它们

22.6 实战:一个完整的嵌入式项目依赖管理

最后,我给你看一个我实际项目中的例子。这个项目用到了FreeRTOS、lwIP和mbedTLS,我用FetchContent统一管理:

cmake_minimum_required(VERSION 3.16)
project(my_iot_device C ASM)

# 设置嵌入式交叉编译器
set(CMAKE_C_COMPILER arm-none-eabi-gcc)

# 引入FetchContent
include(FetchContent)

# ---- FreeRTOS ----
FetchContent_Declare(
  freertos
  GIT_REPOSITORY https://github.com/FreeRTOS/FreeRTOS.git
  GIT_TAG        V10.5.1
  GIT_SHALLOW    TRUE
  SOURCE_SUBDIR  FreeRTOS/Source  # 只取Source目录
)
FetchContent_MakeAvailable(freertos)

# ---- lwIP ----
FetchContent_Declare(
  lwip
  GIT_REPOSITORY https://github.com/lwip-tcpip/lwip.git
  GIT_TAG        STABLE-2_1_3_RELEASE
  GIT_SHALLOW    TRUE
)
FetchContent_MakeAvailable(lwip)

# ---- mbedTLS ----
FetchContent_Declare(
  mbedtls
  GIT_REPOSITORY https://github.com/Mbed-TLS/mbedtls.git
  GIT_TAG        v3.4.0
  GIT_SHALLOW    TRUE
)
FetchContent_MakeAvailable(mbedtls)

# ---- 我的应用 ----
add_executable(my_app
  main.c
  network.c
  crypto.c
)

target_link_libraries(my_app PRIVATE
  freertos::freertos
  lwIP::lwIP
  mbedtls::mbedtls
)

target_include_directories(my_app PRIVATE
  ${CMAKE_CURRENT_SOURCE_DIR}/config
)

小技巧:第一次配置时,CMake会把所有依赖下载到${CMAKE_BINARY_DIR}/_deps目录。如果你网络不好,可以手动下载后放到那里,CMake会跳过下载步骤。嗯,这个技巧在离线开发环境特别有用。

好了,关于CMake的外部依赖管理,核心就是这些。记住:add_subdirectory适合小项目,FetchContent适合大多数场景,ExternalProject_Add是最后的手段。选对了工具,你的构建系统会清爽很多。


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