10、CMake 进阶:库的构建与链接、find_package 使用、自定义编译选项、安装与打包
好,咱们继续往下走。上一章我们把 CMake 的基础语法和项目结构捋了一遍,算是入了门。但说实话,真正在工程里用 CMake,光会写个 add_executable 是远远不够的。你想想看,一个稍微像样点的嵌入式项目,少说也得有三五个模块:驱动层、中间件、应用层。这些模块怎么组织?怎么让别人(或者未来的自己)能轻松复用?
这一章,我就带你把这些「进阶操作」啃下来。说白了,就是解决三个核心问题:怎么造库、怎么找库、怎么把库装好打包带走。
本章核心脉络:从构建静态/动态库,到用 find_package 自动定位依赖,再到通过自定义选项控制编译行为,最后完成安装与打包。这是一条完整的「模块化交付」链路。
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 表示链接关系只对当前目标可见。还有 PUBLIC 和 INTERFACE,区别在于传递性。嗯,这块容易搞混,我简单解释一下:
- 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_directories 和 link_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_package 的 REQUIRED 关键字要慎用。如果你确定这个依赖是必须的,那就加上,CMake 会在找不到时报错退出。但如果你只是「建议使用」,不加 REQUIRED 会更灵活——CMake 会设置一个 XXX_FOUND 变量,你可以根据它来做条件编译。我在做跨平台项目时经常用这个技巧。
好,这一章的内容就到这里。库的构建与链接、find_package、自定义选项、安装打包——这四个技能点,是你在模块化编程路上绕不开的坎。多写几个项目,多踩几次坑,你自然就熟练了。
公众号:蓝海资料掘金营,微信deep3321