3. CMake基础入门

当我们决定将STM32工程从Keil MDK迁移到CMake构建体系时,首先需要理解CMake并不是一种编程语言,而是一种构建脚本生成器。它不直接编译代码,而是根据我们编写的 CMakeLists.txt 文件,生成对应平台的原生构建文件(如Makefile、Ninja文件或Visual Studio工程)。对于嵌入式开发而言,CMake的核心价值在于:它能够将编译选项、宏定义、头文件路径、源文件列表等工程配置以声明式的方式组织起来,并通过“目标(Target)”这一抽象概念实现模块化复用。本章将深入剖析CMake的核心语法、变量体系、函数定义以及目标机制,并梳理一次完整的构建流程,为后续将Keil工程迁移到CMake打下坚实基础。

CMake 核心概念与构建流程 CMakeLists.txt 源文件列表 编译选项/宏定义 配置阶段 cmake -S . -B build 生成构建文件 构建阶段 cmake --build build 调用编译器/链接器 产物 .elf/.bin .hex/.map 核心语法要素 变量 set(SOURCES main.c) ${SOURCES} 函数/宏 function(add_stm32_lib) endfunction() 目标(Target) add_executable(fw main.c) target_include_directories() 控制流 if(CMAKE_BUILD_TYPE) foreach() / while() 目标依赖关系与构建流程 app.elf core.a drivers.a startup.o linker.ld 1. 配置阶段:解析CMakeLists.txt,生成构建规则 2. 构建阶段:根据依赖关系,先编译静态库,再链接生成最终固件 3. 增量构建:只重新编译修改过的文件,大幅提升效率

3.1 CMake核心语法:命令、注释与变量展开

CMake的语法非常简洁,本质上是由命令(Command)、参数(Argument)和变量(Variable)组成的脚本。命令不区分大小写,但参数区分。每一行以命令名开始,后跟括号包裹的参数列表,参数之间用空格分隔。例如 target_include_directories(app PRIVATE "Core/Inc") 就是一个典型的命令调用。注释使用 # 开头,支持行注释和块注释(#[[ ... ]])。

变量是CMake中传递信息的核心载体。使用 set() 命令定义变量,使用 ${变量名} 进行引用。变量名区分大小写,且作用域分为目录作用域、函数作用域和缓存作用域。在STM32工程中,我们通常用变量来保存源文件列表、编译选项和链接脚本路径。

# 定义源文件列表变量
set(STM32_SOURCES
    Core/Src/main.c
    Core/Src/stm32f4xx_it.c
    Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal.c
)

# 定义编译选项变量
set(STM32_C_FLAGS
    -mcpu=cortex-m4
    -mthumb
    -mfpu=fpv4-sp-d16
    -mfloat-abi=hard
)

# 引用变量
message(STATUS "源文件数量: ${STM32_SOURCES}")

这里需要特别注意引号规则:如果参数中包含空格或特殊字符,必须使用双引号包裹。例如 set(TOOLCHAIN_PREFIX "arm-none-eabi-")。此外,CMake还支持列表(List),列表本质上是用分号分隔的字符串。当我们使用 foreach() 遍历源文件时,CMake会自动将列表拆分为多个元素。

经验提醒:在编写CMakeLists.txt时,建议始终使用 set(变量名 值...) 的规范格式,并避免在变量值中混用分号,因为分号是列表的分隔符。对于路径,统一使用正斜杠 /,CMake在Windows下也能正确识别。

3.2 变量体系:普通变量、缓存变量与环境变量

理解CMake的变量体系是编写可维护构建脚本的关键。CMake中存在三类主要变量:普通变量(Normal Variable)、缓存变量(Cache Variable)和环境变量(Environment Variable)。普通变量在CMakeLists.txt执行过程中定义,作用域限于当前目录及其子目录(除非使用 PARENT_SCOPE)。缓存变量存储在 CMakeCache.txt 文件中,用于跨多次构建保留用户配置,例如 CMAKE_BUILD_TYPE、CMAKE_TOOLCHAIN_FILE 等。

变量类型 定义方式 作用域 典型用途
普通变量 set(VAR value) 当前目录及子目录 源文件列表、局部编译选项
缓存变量 set(VAR value CACHE STRING "描述") 全局(存储在CMakeCache.txt) 工具链路径、构建类型、芯片型号
环境变量 set(ENV{VAR} value) 进程级 读取系统PATH、编译器路径

在STM32项目中,我们经常需要根据芯片型号选择不同的启动文件和链接脚本。这时可以使用缓存变量让用户在命令行通过 -D 参数指定芯片型号:

# 定义芯片型号缓存变量,默认值为STM32F407VG
set(STM32_CHIP "STM32F407VG" CACHE STRING "目标芯片型号")

# 根据芯片型号选择启动文件
if(STM32_CHIP STREQUAL "STM32F407VG")
    set(STM32_STARTUP_FILE "startup_stm32f407xx.s")
elseif(STM32_CHIP STREQUAL "STM32F103C8")
    set(STM32_STARTUP_FILE "startup_stm32f103xb.s")
else()
    message(FATAL_ERROR "不支持的芯片型号: ${STM32_CHIP}")
endif()

这里使用了 if() 条件判断和 message(FATAL_ERROR) 来终止配置过程。这种模式在构建多型号固件时非常实用,用户只需在配置时执行 cmake -DSTM32_CHIP=STM32F103C8 .. 即可切换目标。

风险提示:缓存变量一旦设置,会在 CMakeCache.txt 中持久化。如果修改了默认值,需要删除 build 目录下的缓存文件或使用 cmake -U 命令清除,否则新的默认值不会生效。

3.3 函数与宏:封装可复用的构建逻辑

当多个目标需要重复使用相同的编译选项、头文件路径或链接脚本时,函数(function)和宏(macro)是避免代码重复的最佳工具。函数和宏的区别在于:函数有自己的变量作用域,函数内定义的变量不会影响外部;而宏在调用处展开,共享调用者的作用域。在嵌入式项目中,我们通常用函数来封装“创建一个STM32可执行目标”的逻辑。

# 定义一个函数,用于创建STM32固件目标
function(add_stm32_firmware TARGET_NAME)
    # 参数解析
    set(options)
    set(oneValueArgs LINKER_SCRIPT)
    set(multiValueArgs SOURCES INCLUDES)
    cmake_parse_arguments(ARG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN})

    # 创建可执行目标
    add_executable(${TARGET_NAME} ${ARG_SOURCES})

    # 设置编译选项
    target_compile_options(${TARGET_NAME} PRIVATE
        -mcpu=cortex-m4
        -mthumb
        -Wall
        -fdata-sections
        -ffunction-sections
    )

    # 设置头文件路径
    target_include_directories(${TARGET_NAME} PRIVATE ${ARG_INCLUDES})

    # 设置链接脚本
    target_link_options(${TARGET_NAME} PRIVATE
        -T ${ARG_LINKER_SCRIPT}
        -Wl,--gc-sections
    )

    # 生成hex和bin文件
    add_custom_command(TARGET ${TARGET_NAME} POST_BUILD
        COMMAND ${CMAKE_OBJCOPY} -O ihex ${TARGET_NAME} ${TARGET_NAME}.hex
        COMMAND ${CMAKE_OBJCOPY} -O binary ${TARGET_NAME} ${TARGET_NAME}.bin
        COMMENT "生成HEX和BIN文件"
    )
endfunction()

# 使用函数
add_stm32_firmware(app
    SOURCES
        Core/Src/main.c
        Core/Src/stm32f4xx_it.c
        Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal.c
    INCLUDES
        Core/Inc
        Drivers/STM32F4xx_HAL_Driver/Inc
    LINKER_SCRIPT
        STM32F407VGTx_FLASH.ld
)

上述代码中,cmake_parse_arguments() 是解析函数参数的标准方式。通过定义函数,我们可以在项目中创建多个固件目标(如bootloader、app),只需调用一次函数并传入不同的源文件和链接脚本即可。这大大提高了构建脚本的可维护性。

核心原理:函数封装的核心价值在于“约定优于配置”。通过将编译选项、链接参数等细节封装在函数内部,上层调用者只需关注“构建什么”,而无需关心“如何构建”。这与Keil中通过图形界面配置编译选项的方式形成鲜明对比,CMake将配置过程代码化、版本化,便于团队协作和持续集成。

3.4 目标(Target):构建系统的核心抽象

目标是CMake构建系统的核心概念。一个目标(Target)代表一个可执行文件、静态库或动态库。目标之间可以存在依赖关系,CMake会根据依赖关系自动确定编译顺序。在STM32工程中,我们通常将HAL库、中间件(如FreeRTOS)编译为静态库目标,将应用代码编译为可执行文件目标。

目标的关键操作包括:add_executable() 创建可执行目标,add_library() 创建库目标,target_include_directories() 添加头文件路径,target_compile_definitions() 添加宏定义,target_link_libraries() 链接其他目标。这些命令都支持 PRIVATE、PUBLIC 和 INTERFACE 三种传递级别:

  • PRIVATE:仅对当前目标生效,不传递给依赖它的目标。
  • PUBLIC:既对当前目标生效,也传递给依赖它的目标。
  • INTERFACE:仅传递给依赖它的目标,当前目标自身不使用。

在STM32项目中,一个典型的目标依赖关系如下:

# HAL库作为静态库目标
add_library(stm32_hal STATIC
    Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal.c
    Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_cortex.c
)
target_include_directories(stm32_hal PUBLIC Drivers/STM32F4xx_HAL_Driver/Inc)

# 应用固件目标
add_executable(app Core/Src/main.c Core/Src/stm32f4xx_it.c)
target_link_libraries(app PRIVATE stm32_hal)

这里 stm32_hal 库的 PUBLIC 头文件路径会自动传递给 app 目标,因此 app 的源文件可以直接 #include "stm32f4xx_hal.h" 而无需额外配置。这种传递机制是CMake管理复杂依赖关系的核心优势。

3.5 构建流程:配置、生成与编译

CMake的构建流程分为两个阶段:配置阶段(Configure)和构建阶段(Build)。配置阶段执行 cmake -S . -B build 命令,CMake会读取 CMakeLists.txt,检查编译器、工具链、依赖项,并生成构建文件(如Makefile或Ninja文件)。构建阶段执行 cmake --build build 命令,CMake会调用底层的构建工具(如make或ninja)执行实际的编译和链接。

对于STM32交叉编译,配置阶段还需要指定工具链文件(Toolchain File)。工具链文件定义了编译器路径、目标架构、系统类型等关键信息。以下是一个典型的ARM GCC工具链文件:

# arm-none-eabi-toolchain.cmake
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR arm)

set(TOOLCHAIN_PREFIX arm-none-eabi-)
set(CMAKE_C_COMPILER ${TOOLCHAIN_PREFIX}gcc)
set(CMAKE_CXX_COMPILER ${TOOLCHAIN_PREFIX}g++)
set(CMAKE_ASM_COMPILER ${TOOLCHAIN_PREFIX}gcc)
set(CMAKE_OBJCOPY ${TOOLCHAIN_PREFIX}objcopy)
set(CMAKE_OBJDUMP ${TOOLCHAIN_PREFIX}objdump)
set(CMAKE_SIZE ${TOOLCHAIN_PREFIX}size)

# 不进行编译器测试
set(CMAKE_C_COMPILER_WORKS 1)
set(CMAKE_CXX_COMPILER_WORKS 1)

# 设置链接器
set(CMAKE_EXE_LINKER_FLAGS_INIT "--specs=nano.specs --specs=nosys.specs")

配置命令为:cmake -B build -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi-toolchain.cmake。配置完成后,构建系统会生成 build/Makefile,执行 cmake --build build 即可编译固件。

常见误区:很多初学者在配置阶段就期望看到编译输出,这是错误的。配置阶段只生成构建规则,不产生任何目标文件。如果配置阶段报错(如找不到编译器),需要先解决配置问题,再执行构建。另外,CMAKE_BUILD_TYPE 需要在配置阶段指定,例如 -DCMAKE_BUILD_TYPE=Release,它会影响优化级别和调试信息的生成。

3.6 实战案例:从零构建一个最小STM32工程

为了将上述概念串联起来,我们以一个最小化的STM32F407工程为例,演示完整的CMake构建流程。假设工程目录结构如下:

stm32_demo/
├── CMakeLists.txt
├── arm-none-eabi-toolchain.cmake
├── Core/
│   ├── Inc/
│   │   └── main.h
│   └── Src/
│       ├── main.c
│       └── stm32f4xx_it.c
├── Drivers/
│   └── STM32F4xx_HAL_Driver/
│       ├── Inc/
│       └── Src/
├── startup_stm32f407xx.s
└── STM32F407VGTx_FLASH.ld

根目录的 CMakeLists.txt 内容如下:

cmake_minimum_required(VERSION 3.20)
project(stm32_demo C ASM)

# 设置C标准
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)

# 收集源文件
file(GLOB_RECURSE HAL_SOURCES
    "Drivers/STM32F4xx_HAL_Driver/Src/*.c"
)
list(FILTER HAL_SOURCES EXCLUDE REGEX ".*template.*")

set(APP_SOURCES
    Core/Src/main.c
    Core/Src/stm32f4xx_it.c
)

# 创建HAL库目标
add_library(stm32_hal STATIC ${HAL_SOURCES})
target_include_directories(stm32_hal PUBLIC
    Drivers/STM32F4xx_HAL_Driver/Inc
    Drivers/CMSIS/Device/ST/STM32F4xx/Include
    Drivers/CMSIS/Include
)
target_compile_definitions(stm32_hal PUBLIC STM32F407xx)

# 创建可执行目标
add_executable(app
    ${APP_SOURCES}
    startup_stm32f407xx.s
)

# 设置编译选项
target_compile_options(app PRIVATE
    -mcpu=cortex-m4
    -mthumb
    -mfpu=fpv4-sp-d16
    -mfloat-abi=hard
    -Wall
    -fdata-sections
    -ffunction-sections
)

# 设置头文件路径
target_include_directories(app PRIVATE
    Core/Inc
)

# 链接HAL库
target_link_libraries(app PRIVATE stm32_hal)

# 设置链接选项
target_link_options(app PRIVATE
    -T STM32F407VGTx_FLASH.ld
    -Wl,--gc-sections
    --specs=nano.specs
    --specs=nosys.specs
)

# 生成hex和bin
add_custom_command(TARGET app POST_BUILD
    COMMAND ${CMAKE_OBJCOPY} -O ihex app app.hex
    COMMAND ${CMAKE_OBJCOPY} -O binary app app.bin
    COMMAND ${CMAKE_SIZE} app
    COMMENT "生成HEX/BIN并显示内存占用"
)

构建命令如下:

# 配置
cmake -B build -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi-toolchain.cmake
# 构建
cmake --build build

构建成功后,在 build 目录下会生成 app.elf、app.hex 和 app.bin 文件。通过 CMAKE_SIZE 命令可以查看固件的内存占用情况。

案例复盘:这个案例展示了CMake在嵌入式项目中的完整应用。通过将HAL库封装为静态库目标,实现了编译单元的解耦;通过 file(GLOB_RECURSE) 自动收集源文件,避免了手动维护长列表的繁琐;通过 add_custom_command 在构建后自动生成烧录文件。相比Keil,CMake方案的优势在于:所有配置都是文本文件,可以纳入版本控制;支持命令行构建,便于CI/CD集成;增量编译效率更高,尤其在大型项目中优势明显。

3.7 常见误区与总结

在学习和使用CMake的过程中,初学者容易陷入以下几个误区:

  • 误区一:将CMake当作编程语言。CMake是构建脚本生成器,不应在CMakeLists.txt中编写复杂的业务逻辑。如果发现脚本过于复杂,应该考虑拆分模块或使用函数封装。
  • 误区二:忽略目标传递级别。错误使用 PRIVATE 和 PUBLIC 会导致头文件路径或宏定义无法正确传递,产生“找不到头文件”或“未定义宏”的编译错误。
  • 误区三:在配置阶段期望看到编译输出。配置阶段只生成构建规则,编译输出在构建阶段产生。如果配置成功但构建失败,需要检查编译选项、源文件路径等。
  • 误区四:不清理缓存导致配置混乱。修改工具链或芯片型号后,必须删除 build 目录或使用 cmake --fresh 重新配置,否则旧的缓存变量会干扰新配置。