13、CMake目标与属性:目标(target)的概念,target_include_directories, target_link_libraries, target_compile_definitions的使用

说实话,我刚接触CMake那会儿,最头疼的就是搞不清「目标」到底是个什么东西。那时候我习惯把CMake当成一个高级的Makefile生成器,写一堆全局变量,然后set来set去。结果项目一复杂,依赖关系乱成一锅粥,改一个头文件路径,整个工程都得重新编译。

后来我才明白,CMake真正的精髓,就是「目标」这个概念。你想想看,一个嵌入式项目里,有固件、有测试程序、有引导加载器、有静态库……这些东西之间怎么组织?靠全局变量?那迟早要翻车。

核心观点:CMake中的target(目标)是一个带有属性的对象。你可以给目标添加头文件路径、链接库、编译宏定义。这些属性会沿着依赖链自动传递。这才是现代构建系统的正确打开方式。

13.1 什么是目标(target)?

目标,说白了就是你要构建的东西。它可以是:

  • 可执行文件:用 add_executable() 创建
  • 库文件:用 add_library() 创建,可以是静态库或动态库
  • 自定义目标:用 add_custom_target() 创建,用于执行脚本或生成文件

每个目标都有自己的「小本本」,记录着它需要哪些头文件路径、链接哪些库、定义哪些宏。这些小本本还可以传给依赖它的其他目标。嗯,这就是所谓的「传递性」。

我个人习惯把目标想象成一个「积木块」。每个积木块有自己的接口(头文件、宏定义),也有自己的内部实现(源文件、私有依赖)。你搭积木的时候,只需要关心积木块的接口,不用管它内部怎么实现的。这不就是模块化设计吗?

CMake目标与属性传递关系 目标A(库) 源文件: a.c, a.h PUBLIC: include/ 目标B(库) 源文件: b.c, b.h PUBLIC: include/ 目标C(可执行) 源文件: main.c 链接: A, B 属性传递规则 • A的PUBLIC属性 → 传递给C(头文件路径、宏定义) • B的PUBLIC属性 → 传递给C(头文件路径、宏定义) • C只需要链接A和B,不需要关心A和B的内部依赖 • 如果A依赖B,C只需要链接A,CMake会自动拉入B

13.2 target_include_directories:给目标喂头文件路径

这个命令,说白了就是告诉编译器:「喂,编译这个目标的时候,去这些目录里找头文件。」

它的语法是:

target_include_directories(<target> <INTERFACE|PUBLIC|PRIVATE> <dir1> [<dir2> ...])

这里有个关键点:访问限定符。我刚开始用的时候,经常搞混这三个词。后来我总结了一个口诀:

  • PRIVATE:这个目录只给我自己用。别人链接我,也看不到这个目录。
  • INTERFACE:这个目录是给别人用的。我自己编译时不需要,但别人链接我时必须加上。
  • PUBLIC:这个目录我自己用,别人链接我也要用。说白了就是PRIVATE + INTERFACE。

我的经验:在嵌入式项目中,我通常把芯片厂商提供的HAL库头文件设为PRIVATE。因为这些头文件是库内部的实现细节,上层应用不应该直接包含它们。而把库的公共API头文件设为PUBLIC,这样应用层只需要包含一个头文件就能用。

举个例子,假设你有一个电机控制库:

add_library(motor_control motor.c motor.h)

# 私有头文件:芯片寄存器定义,只有库内部用
target_include_directories(motor_control PRIVATE 
    ${CMAKE_CURRENT_SOURCE_DIR}/chip_regs
)

# 公共头文件:电机控制API,外部也要用
target_include_directories(motor_control PUBLIC 
    ${CMAKE_CURRENT_SOURCE_DIR}/include
)

这样,任何链接了 motor_control 的目标,都会自动加上 include/ 目录,但不会看到 chip_regs/ 目录。封装性一下子就出来了。

13.3 target_link_libraries:把积木块拼起来

这个命令,就是用来建立目标之间的依赖关系的。语法很简单:

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

同样有访问限定符。这里我再说说我的理解:

  • PRIVATE:这个库是我内部实现需要的。别人链接我,不需要知道这个库的存在。
  • INTERFACE:这个库是我接口需要的。别人链接我,必须也链接这个库。
  • PUBLIC:我内部需要,别人也需要。相当于PRIVATE + INTERFACE。

我曾经在一个项目中踩过坑。当时我写了一个通信库,内部用了某个加密算法库。我把加密库设成了PRIVATE链接。结果上层应用在头文件里间接引用了加密库的类型,编译直接报错「找不到符号」。后来我把加密库改成PUBLIC,问题就解决了。

注意:如果你在头文件里包含了某个库的头文件,或者使用了某个库的类型,那么这个库必须是PUBLIC或INTERFACE。否则别人链接你的库时,编译会报错。

来看一个完整的例子:

# 底层:硬件抽象层
add_library(hal hal.c hal.h)
target_include_directories(hal PUBLIC hal_include)

# 中间层:传感器驱动,依赖HAL
add_library(sensor_driver sensor.c sensor.h)
target_link_libraries(sensor_driver PUBLIC hal)
target_include_directories(sensor_driver PUBLIC sensor_include)

# 上层:应用主程序
add_executable(app main.c)
target_link_libraries(app PRIVATE sensor_driver)
# 注意:app 会自动获得 hal 和 sensor_driver 的 PUBLIC 头文件路径

你看,app只需要链接sensor_driver,CMake会自动把hal也拉进来。这就是传递性的威力。

13.4 target_compile_definitions:给代码喂宏定义

这个命令,用来给目标添加编译宏定义。语法:

target_compile_definitions(<target> <INTERFACE|PUBLIC|PRIVATE> <def1> [<def2> ...])

宏定义的格式有两种:

  • 无值宏USE_FEATURE_X,相当于 #define USE_FEATURE_X
  • 有值宏VERSION=3VERSION="3.2.1",相当于 #define VERSION 3

我在嵌入式项目中,经常用这个来配置不同硬件平台:

# 针对STM32F4平台
add_executable(firmware main.c)
target_compile_definitions(firmware PRIVATE
    STM32F407xx
    USE_HAL_DRIVER
    HSE_VALUE=8000000
)

# 针对STM32F1平台
add_executable(firmware_f1 main.c)
target_compile_definitions(firmware_f1 PRIVATE
    STM32F103xB
    USE_HAL_DRIVER
    HSE_VALUE=12000000
)

这样,同一个源文件里可以用 #ifdef 来区分不同平台,编译时自动选择正确的配置。

避坑指南:我曾经在项目里把某个宏定义设成了PUBLIC,结果这个宏被传递到了所有依赖的目标里。一个底层库的宏定义污染了整个项目,导致一些模块的行为被意外改变。从那以后,我养成了一个习惯:除非明确需要暴露给外部,否则一律用PRIVATE

13.5 三个命令的配合使用

这三个命令,说白了就是CMake目标属性的「三驾马车」。它们配合起来,可以构建出非常清晰的依赖关系。

我总结了一个表格,方便你对照:

命令 作用 PRIVATE INTERFACE PUBLIC
target_include_directories 添加头文件搜索路径 仅本目标 仅依赖者 本目标+依赖者
target_link_libraries 添加链接依赖 仅本目标 仅依赖者 本目标+依赖者
target_compile_definitions 添加编译宏定义 仅本目标 仅依赖者 本目标+依赖者

你看,三个命令的访问限定符语义是完全一致的。记住一个,就记住了三个。

13.6 实战:一个嵌入式项目的目标组织

最后,我拿一个真实的嵌入式项目来演示。这个项目是一个智能传感器节点,包含:

  • 芯片驱动库(HAL)
  • 传感器驱动库(sensor)
  • 无线通信库(wireless)
  • 主固件(firmware)
  • 单元测试(test)
# CMakeLists.txt

# 芯片驱动库 - 私有头文件不暴露
add_library(hal 
    hal/gpio.c
    hal/uart.c
    hal/timer.c
)
target_include_directories(hal 
    PRIVATE hal/regs          # 寄存器定义,内部用
    PUBLIC  hal/include       # 公共API
)

# 传感器驱动 - 依赖HAL
add_library(sensor 
    sensor/temperature.c
    sensor/humidity.c
)
target_link_libraries(sensor PUBLIC hal)
target_include_directories(sensor 
    PRIVATE sensor/internal   # 传感器校准算法,内部用
    PUBLIC  sensor/include    # 传感器API
)
target_compile_definitions(sensor PRIVATE SENSOR_DEBUG=0)

# 无线通信库 - 依赖HAL
add_library(wireless 
    wireless/lora.c
    wireless/spi.c
)
target_link_libraries(wireless PUBLIC hal)
target_include_directories(wireless PUBLIC wireless/include)
target_compile_definitions(wireless PRIVATE LORA_FREQ=868000000)

# 主固件 - 链接所有库
add_executable(firmware 
    app/main.c
    app/tasks.c
)
target_link_libraries(firmware PRIVATE sensor wireless)
target_compile_definitions(firmware PRIVATE 
    FW_VERSION="2.1.0"
    NODE_ID=42
)

# 单元测试 - 单独编译,不链接到固件
add_executable(test_sensor 
    test/test_sensor.c
)
target_link_libraries(test_sensor PRIVATE sensor)
target_compile_definitions(test_sensor PRIVATE UNIT_TEST)

这个结构的好处是:

  • firmware 只需要链接 sensor 和 wireless,不需要关心它们内部的HAL依赖
  • test_sensor 可以独立编译,只链接 sensor 库,不包含固件代码
  • 修改 HAL 的内部实现,只要接口不变,上层代码不需要重新编译
  • 每个目标的宏定义互不干扰,不会出现宏污染

嗯,这就是CMake目标的魅力。它让大型嵌入式项目的构建变得清晰、可维护。你想想看,如果没有这些机制,你不得不在每个源文件里手动添加 -I-D 参数,那得多痛苦?

好了,这一章的内容就到这里。记住:目标就是积木块,属性就是积木块的接口。用好这三个命令,你的CMake项目就能像乐高一样,搭得又快又稳。


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