Skip to content

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 中,整个过程如丝般顺滑:

bash
# 1. 初始化一个空项目 (自动生成一个叫 package.json 的配置文件)
npm init -y

# 2. 一键下载并安装别人写好的彩色字体库!
npm install chalk

就这么两行命令,npm(Node的包管理器)就会自动去云端服务器把 chalk 的源码下载下来,放在一个叫 node_modules 的隐藏角落里。

然后我们只需要写两行代码 index.js

javascript
import chalk from 'chalk';
console.log(chalk.blue.bgRed.bold('Hello RoboMaster!'));

运行 node index.js,华丽的彩色字体就出来了!你完全不需要关心 chalk 内部是怎么实现的,也不需要手动配置它的路径。

2. 现代系统级语言的新星:Rust 与 Cargo

C/C++ 的老对手 Rust 更是把这种体验做到了极致。Rust 自带了一个神级构建工具叫 Cargo

你想新建一个项目?不要自己建文件夹和文件,直接敲:

bash
cargo new hello_rust

Cargo 会瞬间帮你建好一个极其标准的工程目录,连 git 仓库都顺手帮你初始化了。里面有一个 Cargo.toml 文件,这就是 Rust 的“项目图纸”。

如果你想引入一个随机数生成库 rand,只需要在图纸里加一行:

toml
[dependencies]
rand = "0.8.5"

接下来,见证奇迹的时刻,你只需要敲一个命令:

bash
cargo run

Cargo 会自动:去网上下库 -> 编译随机数库 -> 编译你自己的代码 -> 将它们链接在一起 -> 直接运行! 整个过程一气呵成。

为什么我们还要学 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 去敲击命令翻译这上百个文件:

  1. 手会断掉:每次修改一行代码,都要敲几百行编译命令。

  2. 效率极低:我只改了 main.c 里的一行,难道要把所有 HAL 库文件都重新翻译一遍?

为了解决这个问题,前辈们发明了 Make 工具。Make 是一个智能化的“包工头”。

我们需要写一个叫 Makefile 的“施工图纸”,告诉它文件之间的依赖关系。

当你点击构建时,Make 会:

  1. 检查图纸:看看哪些文件被你修改过了。

  2. 精准施工把被修改过的文件交给 GCC 重新翻译,没改过的直接用之前的成果。

  3. 最终组装:把所有翻译好的小零件(.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所需的最低版本
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,我们最常用到这三个操作:

  1. 链接库(Linking Libraries):使用 target_link_libraries() 把 PID 库(假设叫 pid_lib)链接到机器人目标:

    cmake
    target_link_libraries(RoboMaster_Robot PRIVATE pid_lib)
  2. 添加头文件目录(Including Directories):使用 target_include_directories() 告诉目标到哪里查找 my_sensor.h(假设位于 Core/Inc):

    cmake
    target_include_directories(RoboMaster_Robot PRIVATE Core/Inc)
  3. 添加编译宏(Compile Definitions):使用 target_compile_definitions() 控制 #ifdef DEBUG 等条件编译:

cmake
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

plaintext
MySTM32Project/
├── CMakeLists.txt         <-- 顶层总控制台
├── Core/
│   └── Src/main.c         <-- 主程序
└── user_code/             <-- 我们新建的文件夹
    ├── CMakeLists.txt     <-- user_code的总管
    └── motor/
        ├── CMakeLists.txt <-- motor模块的配置
        ├── motor.h
        └── motor.c

1. 在子目录里造“引擎”:add_library()

进入 user_code/motor/CMakeLists.txt,我们不想生成一个单独跑的程序,我们只想把 motor.c 打包成一个“电机引擎库”,给主程序调用。

cmake
# 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,我们要把刚造好的库接到主程序上。

cmake
# 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,你会发现它虽然长,但逻辑和我们讲的完全一致!

核心模块解析:

  1. 查找编译器与工具链设置:

    cmake
    set(CMAKE_C_COMPILER arm-none-eabi-gcc)
    set(CMAKE_CXX_COMPILER arm-none-eabi-g++)

    这就指定了我们之前说的“翻译官”。如果是电脑程序一般是 gcc,STM32 必须是 arm-none-eabi-* 系列。

  2. 收集所有的源文件: CubeMX 会把所有的 HAL 库源文件和 main.c 用变量存起来:

    cmake
    set(sources
        Core/Src/main.c
        Core/Src/stm32f1xx_it.c
        Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal_gpio.c
        # ...省略几十个HAL库文件...
    )
  3. 创建最终的单片机目标:

    cmake
    add_executable(${CMAKE_PROJECT_NAME} ${sources} ${project_ext_sources})

    这里的 ${CMAKE_PROJECT_NAME} 就是你在 CubeMX 里填的项目名。

  4. 附加包含目录和宏定义:

    cmake
    target_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 库的头文件,并定义了你的芯片型号。

  5. 链接脚本 (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 里该怎么写:

cmake
# 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 里,直接敲下:

c
#include "printf.h" // 直接就能包含!CMake 已经自动帮你把头文件路径配好了

然后,你只需要在代码里实现一个最底层的单字符发送函数(比如调用 HAL 库的 UART 轮询发送一个字节 putchar_),这个强大的 printf_() 函数就能在你的 STM32 上欢快地跑起来了,不用去处理任何恶心的系统依赖和库重定向问题!

c
/**
 * 实现 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 仓库和参考资料:

现代CMake教程(进阶必看)

咱们电控组的一些开源/内部仓库 (可以学学里面的CMake是怎么写的)

业界优秀的 CMake 开源项目

最近更新