第7章:目标与属性——构建系统的核心拼图

说实话,刚接触CMake那会儿,我总觉得add_library()add_executable()就是“声明一下我要编译什么”。后来踩的坑多了才明白——这几个命令,其实是整个构建系统的骨架。你定义的目标(target),就是后续所有属性、依赖、编译选项的锚点。

这一章,咱们就把这几个核心命令掰开揉碎了讲。我会结合我在嵌入式项目里的实际经验,告诉你每个命令该怎么用、什么时候用、以及最容易在哪翻车。

7.1 add_library():创建你的代码模块

这个命令说白了就是“把一堆源文件打包成一个库”。嵌入式项目里,我习惯把驱动、中间件、算法分别做成独立的库。这样管理起来清爽,复用也方便。

# 创建一个静态库
add_library(driver
    STATIC
    src/gpio.c
    src/uart.c
    src/spi.c
)

# 创建一个动态库(嵌入式里用得少,但Linux应用层会用到)
add_library(algorithm
    SHARED
    src/filter.c
    src/fft.c
)

这里有个细节:STATICSHARED不写的话,默认是STATIC。我在做STM32项目时,所有外设驱动都编译成静态库,最后链接到固件里。为什么?因为静态库直接嵌入可执行文件,省去了动态加载的麻烦——你想想看,嵌入式设备上哪给你搞动态链接器去?

重要提醒:库的名字不要和源文件重名。我曾经犯过这个错——建了一个叫uart的库,结果和uart.c冲突,CMake报了一堆莫名其妙的错。

7.2 add_executable():生成最终固件

这个命令更直接——告诉CMake“我要生成一个可执行文件”。嵌入式里,这个可执行文件就是你的固件(.elf、.hex、.bin)。

add_executable(firmware
    src/main.c
    src/startup.c
    src/interrupts.c
)

嗯,这里要注意:源文件列表里不要包含头文件。头文件通过target_include_directories()来管理,后面会讲。我见过有人把.h文件也塞进add_executable()里,虽然CMake不会报错,但会让项目结构变得混乱。

我的习惯:add_executable()add_library()放在项目根目录的CMakeLists.txt里,子目录只负责添加源文件。这样顶层结构一目了然。

7.3 target_include_directories():告诉编译器头文件在哪

这个命令,说白了就是给编译器传递-I参数。嵌入式项目里,头文件路径管理不好,编译报错能让你怀疑人生。

target_include_directories(firmware
    PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR}/inc
    ${CMAKE_CURRENT_SOURCE_DIR}/drivers/include
)

这里的关键是PRIVATEPUBLICINTERFACE这三个关键字。我刚开始用的时候也搞混过,后来总结了一个规律:

关键字 对当前目标 对依赖当前目标的目标
PRIVATE 生效 不生效
PUBLIC 生效 生效
INTERFACE 不生效 生效

举个例子:你写了一个driver库,它内部用了inc/internal里的头文件。这些头文件对外部不可见,那就用PRIVATE。如果driver暴露了inc/public里的接口,那依赖driverfirmware也需要看到这些头文件,这时候用PUBLIC

我曾经踩过的坑:把所有头文件路径都设成PUBLIC,结果一个库的内部实现细节暴露给了所有依赖它的目标。后来重构时发现,改一个内部头文件,整个项目都要重新编译——那叫一个酸爽。

7.4 target_compile_definitions():控制编译时的宏开关

嵌入式项目里,宏定义用得特别多。芯片型号、外设配置、调试开关……全得靠宏来控制。target_compile_definitions()就是干这个的。

target_compile_definitions(firmware
    PRIVATE
    STM32F407xx
    USE_HAL_DRIVER
    DEBUG_ENABLED=1
)

这个命令同样支持PRIVATE/PUBLIC/INTERFACE。我一般把芯片相关的宏定义放在PRIVATE里,因为不同芯片的宏不应该泄露给其他模块。

你可能会问:为什么不用add_definitions()?嗯,add_definitions()是全局的,会影响所有目标。而target_compile_definitions()只影响指定的目标,粒度更细。我在做多芯片支持的项目时,这个特性帮了大忙——不同固件可以有不同的宏定义,互不干扰。

小技巧:调试版本和发布版本可以用不同的宏。比如调试时打开DEBUG_ENABLED,发布时关闭。配合CMAKE_BUILD_TYPE使用,效果更佳。

7.5 target_link_libraries():把模块串起来

这个命令是构建系统的“胶水”。它告诉链接器:把哪些库链接到当前目标里。

target_link_libraries(firmware
    PRIVATE
    driver
    algorithm
    freertos
)

链接的顺序很重要。我记得有一次,链接器报了一堆“undefined reference”错误。排查了半天,发现是库的链接顺序反了——依赖库要放在被依赖库的前面。

这里同样有PRIVATE/PUBLIC/INTERFACE的讲究:

  • PRIVATE:当前目标用这些库,但依赖当前目标的目标不需要知道
  • PUBLIC:当前目标用这些库,依赖当前目标的目标也会自动链接它们
  • INTERFACE:当前目标本身不用,但依赖当前目标的目标需要链接它们

举个例子:你写了一个sensor库,它内部用了i2c库。如果sensor的接口不暴露i2c相关的东西,那就用PRIVATE。如果sensor的API里包含了i2c的结构体,那依赖sensor的目标也需要链接i2c,这时候用PUBLIC

核心原则:最小暴露原则。能用PRIVATE就别用PUBLIC,能用PUBLIC就别用INTERFACE。这样依赖关系最清晰,编译也最快。

7.6 知识体系总览

下面这张图,把本章的核心逻辑串起来了。你可以看到,从源文件到最终固件,每一步都有对应的CMake命令在起作用。

CMake目标与属性核心逻辑 源文件 (.c/.s) 头文件 (.h) 宏定义 add_library() add_executable() target_include_directories() 库文件 (.a/.so) 可执行文件 (.elf) 输入 → 处理 → 输出

7.7 实战中的组合用法

在实际项目里,这几个命令通常是组合使用的。我给你看一个典型的嵌入式项目结构:

# 1. 创建驱动库
add_library(driver STATIC
    src/gpio.c
    src/uart.c
)

# 2. 指定驱动库的头文件路径
target_include_directories(driver
    PUBLIC inc
    PRIVATE inc/internal
)

# 3. 给驱动库添加宏定义
target_compile_definitions(driver
    PRIVATE STM32F407xx
)

# 4. 创建固件
add_executable(firmware
    src/main.c
    src/startup.c
)

# 5. 链接驱动库到固件
target_link_libraries(firmware
    PRIVATE driver
)

# 6. 给固件添加头文件路径和宏定义
target_include_directories(firmware
    PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/config
)
target_compile_definitions(firmware
    PRIVATE DEBUG_ENABLED=1
)

你看,每个目标(target)都有自己的属性。驱动库的PUBLIC头文件路径会透传给固件,但PRIVATE的不会。这就是CMake目标属性设计的精妙之处——封装性。

我的建议:刚开始用CMake时,先别追求花哨的语法。把add_library()add_executable()target_include_directories()target_compile_definitions()target_link_libraries()这五个命令用好,80%的项目需求都能搞定。

好了,这一章的内容就到这。记住:目标是骨架,属性是血肉。骨架搭好了,血肉才能长对地方。

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