3、CMake基础入门:掌握CMake的核心语法、变量、函数、目标(target)与构建流程
当我们决定将STM32工程从Keil MDK迁移到CMake构建体系时,首先需要理解CMake并不是一种编程语言,而是一种构建脚本生成器。它不直接编译代码,而是根据我们编写的 CMakeLists.txt 文件,生成对应平台的原生构建文件(如Makefile、Ninja文件或Visual Studio工程)。对于嵌入式开发而言,CMake的核心价值在于:它能够将编译选项、宏定义、头文件路径、源文件列表等工程配置以声明式的方式组织起来,并通过“目标(Target)”这一抽象概念实现模块化复用。本章将深入剖析CMake的核心语法、变量体系、函数定义以及目标机制,并梳理一次完整的构建流程,为后续将Keil工程迁移到CMake打下坚实基础。
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会自动将列表拆分为多个元素。
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),只需调用一次函数并传入不同的源文件和链接脚本即可。这大大提高了构建脚本的可维护性。
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 命令可以查看固件的内存占用情况。
file(GLOB_RECURSE) 自动收集源文件,避免了手动维护长列表的繁琐;通过 add_custom_command 在构建后自动生成烧录文件。相比Keil,CMake方案的优势在于:所有配置都是文本文件,可以纳入版本控制;支持命令行构建,便于CI/CD集成;增量编译效率更高,尤其在大型项目中优势明显。
3.7 常见误区与总结
在学习和使用CMake的过程中,初学者容易陷入以下几个误区:
- 误区一:将CMake当作编程语言。CMake是构建脚本生成器,不应在CMakeLists.txt中编写复杂的业务逻辑。如果发现脚本过于复杂,应该考虑拆分模块或使用函数封装。
- 误区二:忽略目标传递级别。错误使用
PRIVATE和PUBLIC会导致头文件路径或宏定义无法正确传递,产生“找不到头文件”或“未定义宏”的编译错误。 - 误区三:在配置阶段期望看到编译输出。配置阶段只生成构建规则,编译输出在构建阶段产生。如果配置成功但构建失败,需要检查编译选项、源文件路径等。
- 误区四:不清理缓存导致配置混乱。修改工具链或芯片型号后,必须删除
build目录或使用cmake --fresh重新配置,否则旧的缓存变量会干扰新配置。