10、CMake 进阶:库的构建与链接、find_package 使用、自定义编译选项、安装与打包

好,咱们继续往下走。上一章我们把 CMake 的基础语法和项目结构捋了一遍,算是入了门。但说实话,真正在工程里用 CMake,光会写个 add_executable 是远远不够的。你想想看,一个稍微像样点的嵌入式项目,少说也得有三五个模块:驱动层、中间件、应用层。这些模块怎么组织?怎么让别人(或者未来的自己)能轻松复用?

这一章,我就带你把这些「进阶操作」啃下来。说白了,就是解决三个核心问题:怎么造库、怎么找库、怎么把库装好打包带走

本章核心脉络:从构建静态/动态库,到用 find_package 自动定位依赖,再到通过自定义选项控制编译行为,最后完成安装与打包。这是一条完整的「模块化交付」链路。

CMake 进阶知识体系 库的构建与链接 add_library / target_link find_package 自动定位依赖库 自定义编译选项 option / cache 变量 安装与打包 install / CPack STATIC / SHARED OBJECT 库 Config / Find 模式 option() 命令 install(TARGETS...) 目标:实现模块的独立构建、自动依赖解析、可配置编译、一键安装打包

10.1 库的构建与链接:从 add_library 说起

先聊聊怎么造一个库。CMake 里用 add_library 命令,语法和 add_executable 几乎一样。区别在于,你得告诉它你要生成哪种类型的库。

# 生成静态库(.a 或 .lib)
add_library(my_driver STATIC driver.c gpio.c spi.c)

# 生成动态库(.so 或 .dll)
add_library(my_protocol SHARED protocol.c crc.c)

# 生成对象库(不打包,只编译成 .o 文件)
add_library(my_objects OBJECT utils.c queue.c)

我个人习惯,在嵌入式项目里优先用静态库。为什么?因为嵌入式设备资源有限,动态库的加载和链接开销有时候吃不消。而且静态库最终会直接嵌入到可执行文件里,部署起来也省心——你想想看,现场给客户升级,扔一个 .bin 文件总比扔一堆 .so 要靠谱吧?

小技巧:对象库(OBJECT)是个好东西。它不打包,只生成 .o 文件,然后你可以用 target_sources 把这些 .o 加到其他目标里。我在做 bootloader + app 双分区项目时,就用对象库共享了底层驱动代码,既避免了重复编译,又不用真的生成一个库文件。

链接库的时候,用 target_link_libraries。注意顺序:先写目标,再写要链接的库。

add_executable(firmware main.c)
target_link_libraries(firmware PRIVATE my_driver my_protocol)

这里 PRIVATE 表示链接关系只对当前目标可见。还有 PUBLICINTERFACE,区别在于传递性。嗯,这块容易搞混,我简单解释一下:

  • PRIVATE:库的依赖不对外暴露。比如你的驱动库内部用了某个算法库,外部使用者不需要知道。
  • PUBLIC:库的依赖会传递出去。比如你的协议库依赖了 CRC 库,那么链接协议库的人也会自动链接 CRC 库。
  • INTERFACE:库本身不链接,但要求使用者链接。常用于纯头文件库。

我曾经踩过的坑:在一个多层嵌套的模块里,我把所有链接关系都写成了 PRIVATE,结果上层模块死活找不到符号。后来排查了半天,才发现某个中间层库需要把头文件路径通过 PUBLIC 传递出去。记住:头文件搜索路径和链接库的传递性,是两个独立的概念,但经常一起出问题。

10.2 find_package:让 CMake 自己去找依赖

好,库造好了,也链接上了。但问题来了:如果你的项目依赖了第三方库,比如 FreeRTOS、LwIP 或者某个内部中间件,难道每次都要手动指定路径吗?

当然不。这时候 find_package 就派上用场了。它的作用很简单:自动找到某个库的头文件路径和库文件路径

find_package(Freertos REQUIRED)
# 如果找到了,就可以直接用 Freertos::Freertos 这个目标
target_link_libraries(my_app PRIVATE Freertos::Freertos)

find_package 有两种工作模式:Find 模式Config 模式

模式 查找方式 典型文件 适用场景
Find 模式 执行 FindXXX.cmake 脚本 FindFreertos.cmake 系统预装库,或没有提供 CMake 配置的库
Config 模式 查找 XXXConfig.cmake 文件 FreertosConfig.cmake 库本身用 CMake 构建,并安装了配置文件

我个人更推荐 Config 模式。为什么?因为库的作者已经帮你把路径、编译选项、依赖关系都写好了,你只需要 find_package 一下,然后直接用目标名链接就行。干净利落。

但现实是,很多嵌入式第三方库并没有提供 CMake 配置文件。这时候你就得自己写 Find 脚本了。嗯,写 Find 脚本确实有点麻烦,但也不是不能忍。我一般会在项目里建一个 cmake/ 目录,把自定义的 Find 脚本放进去,然后用 list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake") 告诉 CMake 去哪里找。

核心要点find_package 找到的库,通常会暴露一个 IMPORTED 目标(比如 Freertos::Freertos)。直接用这个目标名链接,比手动设置 include_directorieslink_directories 要优雅得多,而且能自动处理传递依赖。

10.3 自定义编译选项:让用户决定怎么编译

你有没有遇到过这种情况:同一个模块,在开发板上要开启调试日志,在产品上要关闭;在 A 芯片上要用硬件 SPI,在 B 芯片上要用软件模拟 SPI。

这种需求,用 option 命令就能搞定。

option(ENABLE_DEBUG_LOG "Enable debug logging" OFF)
option(USE_HARDWARE_SPI "Use hardware SPI driver" ON)

if(ENABLE_DEBUG_LOG)
  target_compile_definitions(my_driver PRIVATE DEBUG_LOG_ENABLED)
endif()

if(USE_HARDWARE_SPI)
  target_compile_definitions(my_driver PRIVATE USE_HW_SPI)
else()
  target_compile_definitions(my_driver PRIVATE USE_SW_SPI)
endif()

用户在配置项目时,可以用 -D 参数来开关这些选项:

cmake -B build -DENABLE_DEBUG_LOG=ON -DUSE_HARDWARE_SPI=OFF

我个人习惯,把所有可配置的选项都集中放在项目根目录的 CMakeLists.txt 里,或者单独抽一个 options.cmake 文件。这样后来的人一看就知道这个项目有哪些「旋钮」可以调。

避坑指南option 的默认值只在第一次运行 CMake 时生效。如果你改了默认值,但 build 目录已经存在,CMake 会沿用缓存里的旧值。我曾经因为这个原因,明明把默认值从 OFF 改成了 ON,但编译出来的代码还是没有调试日志。排查了半天才发现是缓存作祟。解决办法:要么删掉 build 目录重新配置,要么用 cmake -B build -D选项名=新值 强制覆盖。

除了 option,你还可以用 set 配合 CACHE 关键字来定义更复杂的缓存变量:

set(BAUD_RATE 115200 CACHE STRING "UART baud rate")
set_property(CACHE BAUD_RATE PROPERTY STRINGS 9600 19200 115200 921600)

这样用户在 cmake-gui 或者 ccmake 里就能看到一个下拉列表,而不是手动输入。嗯,细节决定体验。

10.4 安装与打包:把成果交付出去

代码写完了,库也编好了,怎么交付?总不能让人家去你的 build 目录里翻 .a 文件和头文件吧?

CMake 提供了 install 命令,用来定义安装规则。

# 安装可执行文件
install(TARGETS firmware DESTINATION bin)

# 安装库文件
install(TARGETS my_driver my_protocol
  LIBRARY DESTINATION lib
  ARCHIVE DESTINATION lib
  PUBLIC_HEADER DESTINATION include
)

# 安装头文件
install(FILES driver.h protocol.h DESTINATION include)

然后用户只需要运行:

cmake --install build --prefix /usr/local

就能把所有东西安装到指定目录。注意 PUBLIC_HEADER 这个属性,你得先在 add_library 里设置好:

add_library(my_driver STATIC driver.c)
set_target_properties(my_driver PROPERTIES
  PUBLIC_HEADER "driver.h;gpio.h"
)

说到打包,CMake 自带的 CPack 工具可以一键生成各种格式的安装包:tar.gz、deb、rpm、NSIS 安装包等等。

# 在 CMakeLists.txt 末尾添加
include(CPack)

# 设置包信息
set(CPACK_PACKAGE_NAME "my-firmware")
set(CPACK_PACKAGE_VERSION "1.2.3")
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Embedded firmware for STM32")

然后运行:

cpack -G TGZ   # 生成 tar.gz
cpack -G DEB   # 生成 deb 包(Linux)
cpack -G NSIS  # 生成 Windows 安装包

我的经验:在嵌入式团队里,我习惯把每个模块的安装规则写清楚,然后用 CPack 生成一个「SDK 包」。里面包含库文件、头文件、以及一个简单的 CMake 配置文件(XXXConfig.cmake)。这样其他团队用 find_package 就能直接集成,不需要关心内部实现。这才是真正的模块化交付。

10.5 实战组合:一个完整的例子

光说不练假把式。咱们把这一章的知识点串起来,写一个完整的 CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
project(my_embedded_sdk VERSION 1.0.0 LANGUAGES C)

# 自定义编译选项
option(ENABLE_DEBUG "Enable debug output" OFF)
option(USE_FREERTOS "Use FreeRTOS" ON)

# 构建驱动库
add_library(driver STATIC
  src/driver/gpio.c
  src/driver/uart.c
  src/driver/spi.c
)
target_include_directories(driver PUBLIC include/driver)
set_target_properties(driver PROPERTIES PUBLIC_HEADER "include/driver/gpio.h;include/driver/uart.h;include/driver/spi.h")

if(ENABLE_DEBUG)
  target_compile_definitions(driver PRIVATE DEBUG_ENABLED)
endif()

# 查找 FreeRTOS
if(USE_FREERTOS)
  find_package(Freertos REQUIRED)
  target_link_libraries(driver PUBLIC Freertos::Freertos)
endif()

# 构建应用
add_executable(firmware src/main.c)
target_link_libraries(firmware PRIVATE driver)

# 安装规则
install(TARGETS driver
  ARCHIVE DESTINATION lib
  PUBLIC_HEADER DESTINATION include/driver
)
install(TARGETS firmware RUNTIME DESTINATION bin)
install(FILES LICENSE README.md DESTINATION share/doc/${PROJECT_NAME})

# 打包
include(CPack)
set(CPACK_PACKAGE_NAME ${PROJECT_NAME})
set(CPACK_PACKAGE_VERSION ${PROJECT_VERSION})

这个例子虽然简单,但涵盖了库构建、条件编译、依赖查找、安装打包的全流程。你想想看,如果每个模块都按这个套路来,整个项目的可维护性和可复用性会提升多少?

最后提醒一句find_packageREQUIRED 关键字要慎用。如果你确定这个依赖是必须的,那就加上,CMake 会在找不到时报错退出。但如果你只是「建议使用」,不加 REQUIRED 会更灵活——CMake 会设置一个 XXX_FOUND 变量,你可以根据它来做条件编译。我在做跨平台项目时经常用这个技巧。

好,这一章的内容就到这里。库的构建与链接、find_package、自定义选项、安装打包——这四个技能点,是你在模块化编程路上绕不开的坎。多写几个项目,多踩几次坑,你自然就熟练了。


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