L5 CMake 构建系统
本课目标
理解“源文件如何变成固件”,会用 Target 表达库和可执行文件的依赖,并能把用户驱动从 main.c 拆到独立目录。
目录
开胃菜:跳出C语言,看看外面的世界
CMake引入:点下“构建”按钮后发生了什么?
CMake实践:掌握“目标”的哲学
项目大了怎么办:告别庞大的main.c
STM32项目分析:解剖CubeMX生成的CMake (重点)
FetchContent实践:一键引入学长写的库
资料
开胃菜:跳出C语言,看看外面的世界
在大家正式头疼 C/C++ 的各种编译报错之前,我们先来点轻松的。
大家之前写单片机代码,可能习惯了这种模式:新建工程 -> 写代码 -> 发现需要一个 PID 算法 -> 去网上找一段 pid.c 和 pid.h -> 复制粘贴到自己的文件夹里 -> 在 IDE 里右键添加文件 -> 祈祷它能跑起来。
但如果你们去问问写前端网页的、写 Python 的、或者写现代系统编程语言(比如 Rust)的同学,他们是怎么写代码的?他们会觉得“手动复制粘贴别人的代码”简直是上个世纪的做法!
在现代编程语言中,有一个非常核心的概念叫做构建系统 (Build System) 与包管理器 (Package Manager)。为了让大家直观地感受一下,我们来玩两个其他语言的 “Hello World”。
1. 前端/Node.js 界的霸主:npm
假设我们要用 JavaScript 写一个程序,在终端里打印一句彩色的 "Hello RoboMaster!"。 我们不需要自己去研究终端的颜色控制符,我们可以直接用别人写好的神级变色库 chalk。
在 Node.js 中,整个过程如丝般顺滑:
# 1. 初始化一个空项目 (自动生成一个叫 package.json 的配置文件)
npm init -y
# 2. 一键下载并安装别人写好的彩色字体库!
npm install chalk就这么两行命令,npm(Node的包管理器)就会自动去云端服务器把 chalk 的源码下载下来,放在一个叫 node_modules 的隐藏角落里。
然后我们只需要写两行代码 index.js:
import chalk from 'chalk';
console.log(chalk.blue.bgRed.bold('Hello RoboMaster!'));运行 node index.js,华丽的彩色字体就出来了!你完全不需要关心 chalk 内部是怎么实现的,也不需要手动配置它的路径。
2. 现代系统级语言的新星:Rust 与 Cargo
C/C++ 的老对手 Rust 更是把这种体验做到了极致。Rust 自带了一个神级构建工具叫 Cargo。
你想新建一个项目?不要自己建文件夹和文件,直接敲:
cargo new hello_rustCargo 会瞬间帮你建好一个极其标准的工程目录,连 git 仓库都顺手帮你初始化了。里面有一个 Cargo.toml 文件,这就是 Rust 的“项目图纸”。
如果你想引入一个随机数生成库 rand,只需要在图纸里加一行:
[dependencies]
rand = "0.8.5"接下来,见证奇迹的时刻,你只需要敲一个命令:
cargo runCargo 会自动:去网上下库 -> 编译随机数库 -> 编译你自己的代码 -> 将它们链接在一起 -> 直接运行! 整个过程一气呵成。
为什么我们还要学 CMake?
看完 npm 和 cargo,你可能会觉得:哇,太爽了吧!那我们 C/C++ 有没有这么爽的工具?直接 c_npm install pid_controller 不行吗?
很遗憾,C/C++ 由于历史包袱太重,至今没有一个全行业统一的官方包管理器和构建工具。 加上 C/C++ 要跨越无数种硬件平台(从你的 Windows 电脑,到 Linux 服务器,再到我们车上那颗算力贫弱、甚至连操作系统都没有的 STM32 单片机),情况变得极其复杂。
但是,不要灰心!CMake 就是目前 C/C++ 领域里,最接近这种“现代爽快体验”的业界标准!
学习 CMake,就是为了让我们在写 STM32 代码时,也能拥有类似 Cargo 和 npm 那样优雅的项目管理体验:不需要手动敲编译命令,不需要在 IDE 里点来点去找文件路径,甚至还可以像 npm 一样一键从 GitHub 拉取学长写好的代码!
接下来,就让我们正式走进 CMake 的世界。
引入:点下“构建”按钮后发生了什么?
在前面的几次培训中,我们学会了用 STM32CubeMX 配置引脚、定时器、串口,然后生成代码,最后在 CLion 里点一下右上角的“绿色小锤子”(Build),代码就神奇地跑到了我们的 STM32 开发板上。
但是,这中间到底发生了什么?我们写的 C/C++ 代码,是用人类语言(英语+符号)写的,单片机那个小黑块只认识高低电平(0和1)。这个把“人类代码”变成“机器指令”的过程,我们称之为编译。
在我们正式学习如何写 CMake 之前,让我们先花一点时间,了解一下构建系统的底层逻辑。
从源代码到机器码:编译器的角色 (GCC)
我们需要一个“翻译官”——编译器。在我们的 STM32 开发环境(STM32CubeCLT)中,这个翻译官叫 arm-none-eabi-gcc(针对 ARM 嵌入式平台的 GCC 编译器)。
你可以把 GCC 想象成一个功能强大的翻译引擎。假设我们只有一个简单的 main.c,我们在命令行里输入一条简单的指令,GCC就能把它翻译成单片机能懂的二进制文件。
当项目变得复杂:构建的挑战 (Make)
然而,大家打开 CubeMX 生成的项目看看,里面光是 HAL 库的代码就有几十上百个 .c 文件!比如管串口的 stm32f1xx_hal_uart.c,管 GPIO 的 stm32fxx_hal_gpio.c。而且,A 文件可能需要 B 文件里的函数。
如果让我们手动用 GCC 去敲击命令翻译这上百个文件:
手会断掉:每次修改一行代码,都要敲几百行编译命令。
效率极低:我只改了
main.c里的一行,难道要把所有 HAL 库文件都重新翻译一遍?
为了解决这个问题,前辈们发明了 Make 工具。Make 是一个智能化的“包工头”。
我们需要写一个叫 Makefile 的“施工图纸”,告诉它文件之间的依赖关系。
当你点击构建时,Make 会:
检查图纸:看看哪些文件被你修改过了。
精准施工:只把被修改过的文件交给 GCC 重新翻译,没改过的直接用之前的成果。
最终组装:把所有翻译好的小零件(
.o文件)拼接(链接)成最终烧进板子的程序。
跨平台的“鸿沟”与 CMake 的诞生
有了 Make 和 Makefile 图纸,似乎很完美了。但问题又来了:图纸的语法太晦涩难懂了!而且,如果我们在 Windows 上用 MinGW,在 Linux 上用 GCC,他们需要的“图纸”格式还不一样。难道我们要学好几种图纸的画法吗?
于是,CMake 闪亮登场。
CMake 的核心思想是 “生成构建规则的工具”。我们用 CMakeLists.txt 描述项目、源码和目标之间的关系,再由 CMake 为当前工具链生成实际构建规则。
然后 CMake 就会根据你当前的操作系统和工具链(在我们的环境里就是 STM32CubeCLT),自动帮你写出极其复杂的 Makefile 施工图纸!
简单来说,整个流程是这样的:
CMakeLists.txt(项目描述)-> cmake(生成)-> Makefile/Ninja(构建规则)-> make/ninja(调用编译器)-> STM32 固件
掌握了 CMake,你就掌握了管理大型代码仓库的钥匙,不再是一个只会点 IDE 按钮的萌新,而是一个能掌控全局的软件架构师!
CMake实践:掌握“目标”的哲学
让我们亲手揭开 CMake 的神秘面纱。
当你在 CLion 里新建一个最基础的 C++ 项目时,生成的 CMakeLists.txt 极其精简:
# 设置CMake所需的最低版本
cmake_minimum_required(VERSION 3.20)
# 定义项目名称
project(HelloCMake)
# 设置C++标准
set(CMAKE_CXX_STANDARD 17)
# 添加一个可执行文件
add_executable(HelloCMake main.cpp)这里最关键的一行是 add_executable(HelloCMake main.cpp):它使用 main.cpp 创建名为 HelloCMake 的可执行目标。
CMake的核心理念:一切皆为“目标 (Target)”
在 CMake 的世界里,上面的 HelloCMake 不仅仅是一个程序名,它叫目标 (Target)。
你可以把“目标”想象成工厂里的一条流水线产品。这个产品可以是一台完整的车(可执行程序,add_executable),也可以是一个现成的汽车引擎库(库文件,add_library)。
我们后续所有的操作,都是在给这个“目标”加各种属性。
我们能对“目标”做什么?
假设我们建好了一个目标叫 RoboMaster_Robot,我们最常用到这三个操作:
链接库(Linking Libraries):使用
target_link_libraries()把 PID 库(假设叫pid_lib)链接到机器人目标:cmaketarget_link_libraries(RoboMaster_Robot PRIVATE pid_lib)添加头文件目录(Including Directories):使用
target_include_directories()告诉目标到哪里查找my_sensor.h(假设位于Core/Inc):cmaketarget_include_directories(RoboMaster_Robot PRIVATE Core/Inc)添加编译宏(Compile Definitions):使用
target_compile_definitions()控制#ifdef DEBUG等条件编译:
target_compile_definitions(RoboMaster_Robot PRIVATE DEBUG=1)PRIVATE, PUBLIC, INTERFACE 关键字(重点!)
注意到了上面的 PRIVATE 吗?这是控制“谁能用这些属性”的关键字:
PRIVATE(私有): 只有我自己(当前目标)用这个头文件或库。
INTERFACE(接口): 我自己不用,但谁如果要用我(链接我),谁就能顺带获得这些头文件路径。
PUBLIC(公开): 我自己用,谁用我也能跟着用。(相当于 PRIVATE + INTERFACE)。
项目大了怎么办:使用 add_subdirectory 告别庞大的main.c
到目前为止,我们一直在单文件里玩。但请想象一下我们电控组的实际项目:有底盘解算代码、有云台 PID 代码、有串口协议解析代码……如果把所有这些 .c 文件全堆在 Core/Src 里,找个 Bug 眼睛都要瞎了。
我们需要把项目模块化,放到不同的子文件夹里。这就需要用到 add_subdirectory() 命令。
规划我们的项目结构
假设我们在 STM32 项目根目录下新建了一个 user_code 文件夹,专门放我们自己写的模块,比如一个电机驱动模块 motor:
MySTM32Project/
├── CMakeLists.txt <-- 顶层总控制台
├── Core/
│ └── Src/main.c <-- 主程序
└── user_code/ <-- 我们新建的文件夹
├── CMakeLists.txt <-- user_code的总管
└── motor/
├── CMakeLists.txt <-- motor模块的配置
├── motor.h
└── motor.c1. 在子目录里造“引擎”:add_library()
进入 user_code/motor/CMakeLists.txt,我们不想生成一个单独跑的程序,我们只想把 motor.c 打包成一个“电机引擎库”,给主程序调用。
# user_code/motor/CMakeLists.txt
# 将 motor.c 编译成一个名为 "motor_lib" 的静态库
add_library(motor_lib motor.c)
# 设置 PUBLIC 属性:告诉全世界,谁链接了 motor_lib,
# 谁就可以直接 #include "motor.h",不需要再手动配置路径了!
target_include_directories(motor_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})2. 在顶层指挥全局
回到项目根目录的顶层 CMakeLists.txt,我们要把刚造好的库接到主程序上。
# 1. 告诉CMake,去处理 user_code 文件夹里的配置
add_subdirectory(user_code)
# (假设主程序目标叫 MySTM32Project)
# 2. 将我们的主程序和 motor_lib 链接起来!
target_link_libraries(MySTM32Project PRIVATE motor_lib)因为 motor_lib 配置了 PUBLIC 的头文件路径,主程序一链接它,主程序的 main.c 里就可以合法地直接 #include "motor.h" 了!这就是现代 CMake 的优雅之处。
STM32项目分析:解剖CubeMX生成的CMake
现在,让我们结合大家手里实际的 STM32 机器人项目,看看 CubeMX 帮我们写了多长的一份 CMakeLists.txt。
如果你用 CubeMX 生成代码时选择了生成 CMake 项目,打开根目录的 CMakeLists.txt,你会发现它虽然长,但逻辑和我们讲的完全一致!
核心模块解析:
查找编译器与工具链设置:
cmakeset(CMAKE_C_COMPILER arm-none-eabi-gcc) set(CMAKE_CXX_COMPILER arm-none-eabi-g++)这就指定了我们之前说的“翻译官”。如果是电脑程序一般是
gcc,STM32 必须是arm-none-eabi-*系列。收集所有的源文件: CubeMX 会把所有的 HAL 库源文件和
main.c用变量存起来:cmakeset(sources Core/Src/main.c Core/Src/stm32f1xx_it.c Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_gpio.c # ...省略几十个HAL库文件... )创建最终的单片机目标:
cmakeadd_executable(${CMAKE_PROJECT_NAME} ${sources} ${project_ext_sources})这里的
${CMAKE_PROJECT_NAME}就是你在 CubeMX 里填的项目名。附加包含目录和宏定义:
cmaketarget_include_directories(${CMAKE_PROJECT_NAME} PRIVATE Core/Inc Drivers/STM32F1xx_HAL_Driver/Inc ) target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE USE_HAL_DRIVER STM32F103xx )这正是我们在前面学的给目标加属性!告诉编译器去哪找 HAL 库的头文件,并定义了你的芯片型号。
链接脚本 (Linker Script) 和生成 .hex/.bin: STM32 和电脑程序最大的区别在于,STM32 需要一个
.ld文件来告诉它“我的 Flash 在哪,RAM 在哪”。文件最后还会有特殊的命令(add_custom_command),把生成的.elf文件转换成 Ozone 烧录软件常用的.hex或.bin文件。
避免被 CubeMX 重新生成覆盖
不要把自建 .c 文件直接塞进 CubeMX 自动维护的 sources 列表。更稳妥的方式是用 add_subdirectory() 引入 user_code,再通过 target_link_libraries() 链接到主目标,并把自定义配置放在当前 CubeMX 版本明确保留的位置。
FetchContent实战:一键引入开源库 (以轻量级 printf 为例)
随着大家在电控组越来越深入,你们一定会遇到一个痛点:想在 STM32 上用串口打印变量和调试信息。
大家可能在网上搜过教程,常规做法是去重定向标准的 printf(也就是重写 fputc 或 _write 函数)。但这套标准库最初是为电脑操作系统设计的,在单片机上不仅极其消耗宝贵的 Flash 和 RAM 资源,而且配置繁琐,有时候在中断里打印甚至会导致程序莫名其妙卡死。
为了解决这个问题,开源界有很多专为嵌入式裸机(没有任何 Linux 等操作系统环境、资源极其有限的系统)打造的纯 C 语言轻量级 printf 库。其中非常著名的就是 eyalroz/printf。它不依赖庞大的系统标准库,极度精简,简直是为单片机量身定制的。
以前,你想用这种神仙库,必须经历痛苦的“刀耕火种”:去 GitHub 下载源码压缩包,解压,把里面的 .c 和 .h 文件手动复制粘贴到你的项目文件夹里,再在 IDE 里小心翼翼地配置各种头文件路径。不仅麻烦,日后原作者修复了 Bug,你也根本没法无缝更新。
现代 CMake(3.11 及以上)提供 FetchContent,可以在配置阶段从 Git 仓库获取并集成依赖。
让我们看看在顶层 CMakeLists.txt 里该怎么写:
# 1. 引入 FetchContent 模块 (这是CMake自带的魔法,不用额外安装)
include(FetchContent)
# 2. 声明我们要去哪里进货:给它起个代号叫 tiny_printf
FetchContent_Declare(
tiny_printf
GIT_REPOSITORY https://github.com/eyalroz/printf.git
GIT_TAG master # 可以是具体的版本号 (如 v4.0.0),实战中为了稳定通常填具体版本
)
# 3. 执行下载,并让这个库自己的 CMake 配置文件融入我们的项目
FetchContent_MakeAvailable(tiny_printf)
# ... (假设前面 CubeMX 已经帮我们生成了 add_executable(RoboMaster_Robot ...) ) ...
# 4. 把刚刚下载好的库,直接链接到我们的主程序目标上!
# 注意:在 eyalroz/printf 自己的 CMakeLists.txt 里,它的“目标”名字被作者定义为了 printf
target_link_libraries(RoboMaster_Robot PRIVATE printf)当你写完这几行代码,并点击 CLion 弹出的“Reload CMake Project”时,背后发生了什么?
CMake 会在后台静悄悄地去 GitHub 把源码拉取下来,并把它的编译规则和你现在的项目缝合在一起。
等下方进度条跑完,你在你自己的 main.c 里,直接敲下:
#include "printf.h" // 直接就能包含!CMake 已经自动帮你把头文件路径配好了然后,你只需要在代码里实现一个最底层的单字符发送函数(比如调用 HAL 库的 UART 轮询发送一个字节 putchar_),这个强大的 printf_() 函数就能在你的 STM32 上欢快地跑起来了,不用去处理任何恶心的系统依赖和库重定向问题!
/**
* 实现 printf 库要求的底层字符输出函数
* @param c 要发送的字符
*/
void putchar_(char c) {
// 使用 STM32 HAL 库通过串口发送字符
// 参数:串口句柄,数据指针,长度,超时时间
HAL_UART_Transmit(&huart1, (uint8_t*)&c, 1, HAL_MAX_DELAY);
}
printf_("Hello STM32! Value: %d, PI: %.2f\n", val, pi);这就是现代化 CMake 带来的优雅:告别手动复制粘贴,一行代码按需集成全球的开源结晶。 将来如果想用学长写好的 CAN 解析库、电机控制库,也都是一样的配方,一样的味道!
资料
下面是一些电控组常用的现代化 CMake 仓库和参考资料: