一、CMake 的哲学:别重复造轮子,也别硬编码

我第一次接触 CMake 是在一个跨平台项目里。当时 Makefile 已经写了上百行,换了个编译器就全崩了。说实话,那感觉挺崩溃的。

CMake 的设计理念其实很朴素:你描述项目长什么样,它帮你生成对应的构建文件。你不需要告诉它「用 gcc 编译 main.c」,而是说「我有一个可执行文件,它由 main.c 构成」。至于用什么编译器、加什么 flag,那是平台的事,CMake 替你操心。

我个人理解,CMake 的哲学可以总结为三点:

  • 声明式而非命令式:你描述「是什么」,而不是「怎么做」
  • 平台无关:一份 CMakeLists.txt,Windows/Linux/Mac 都能用
  • 模块化:每个子目录可以有自己的 CMakeLists.txt,互不干扰

核心思想:CMake 不是构建工具,而是构建系统的生成器。它生成 Makefile、Ninja 文件、Visual Studio 工程等,然后由这些工具真正干活。

二、CMakeLists.txt 的基本结构

每个 CMake 项目都从 CMakeLists.txt 开始。这个文件说白了就是一份「项目说明书」。我习惯把它放在项目根目录,结构大概是这样:

project_root/
├── CMakeLists.txt          # 根目录的构建文件
├── src/
│   ├── CMakeLists.txt      # 子目录的构建文件(可选)
│   └── main.c
├── inc/
│   └── utils.h
└── build/                  # 构建目录,我一般在这里执行 cmake ..

一个最简的 CMakeLists.txt 只需要三行:

cmake_minimum_required(VERSION 3.10)
project(MyProject)
add_executable(myapp main.c)

嗯,就这么简单。但实际项目中,这三行背后藏着不少门道。我们一个一个说。

2.1 cmake_minimum_required:版本红线

这条命令告诉 CMake:「低于这个版本别跑」。为什么需要它?

我记得有一次,同事用 CMake 2.8 打开我写的 3.15 项目,结果一堆命令不认识。从那以后,我每条 CMakeLists.txt 第一行必定是版本声明。

写法很简单:

cmake_minimum_required(VERSION 3.10)

# 也可以指定范围
cmake_minimum_required(VERSION 3.10...3.20)

注意:版本号写太低,新特性用不了;写太高,用户环境可能不满足。我一般写 3.10,兼容性和功能都够用。

2.2 project:给项目起个名

project 命令不只是起名字。它还干几件事:

  • 设置 PROJECT_NAMEPROJECT_SOURCE_DIRPROJECT_BINARY_DIR 等变量
  • 可以指定语言:project(MyProject C CXX ASM)
  • 可以指定版本号:project(MyProject VERSION 1.0.0)

我常用的写法:

project(MyEmbeddedProject
    VERSION 1.2.0
    DESCRIPTION "一个嵌入式项目示例"
    LANGUAGES C CXX ASM
)

你想想看,如果项目里既有 C 又有汇编,不声明 ASM 语言,CMake 可能就不认识 .s 文件了。这个坑我踩过。

2.3 add_executable:生成可执行文件

这条命令告诉 CMake:「我要生成一个可执行文件,源文件是这些」。

add_executable(myapp main.c utils.c)

# 也可以先定义源文件列表,再引用
set(SOURCES main.c utils.c)
add_executable(myapp ${SOURCES})

对于嵌入式项目,我经常需要指定链接脚本、启动文件:

add_executable(firmware
    startup_stm32f4.s
    main.c
    system_stm32f4xx.c
)

target_link_options(firmware PRIVATE
    -T stm32f4_flash.ld
)

个人习惯:源文件列表单独用 set 定义,方便后续添加编译选项或条件编译。别把所有源文件都塞在 add_executable 里,那样改起来很痛苦。

三、一个完整的嵌入式 CMakeLists.txt 示例

下面是我给 STM32 项目写的典型 CMakeLists.txt。你可以直接拿来改:

cmake_minimum_required(VERSION 3.10)
project(STM32F4_Demo VERSION 1.0.0 LANGUAGES C CXX ASM)

# 设置芯片型号
set(MCU_FAMILY STM32F4)
set(MCU_MODEL STM32F407VG)

# 源文件列表
set(SOURCES
    src/main.c
    src/system_stm32f4xx.c
    src/stm32f4xx_hal.c
    startup/startup_stm32f407xx.s
)

# 头文件路径
set(INCLUDE_DIRS
    inc
    inc/hal
    CMSIS/Include
)

# 生成可执行文件
add_executable(${PROJECT_NAME} ${SOURCES})

# 指定头文件路径
target_include_directories(${PROJECT_NAME} PRIVATE ${INCLUDE_DIRS})

# 编译选项
target_compile_options(${PROJECT_NAME} PRIVATE
    -mcpu=cortex-m4
    -mthumb
    -mfloat-abi=hard
    -mfpu=fpv4-sp-d16
    -Os
    -Wall
)

# 链接选项
target_link_options(${PROJECT_NAME} PRIVATE
    -mcpu=cortex-m4
    -mthumb
    -mfloat-abi=hard
    -mfpu=fpv4-sp-d16
    -T ${CMAKE_SOURCE_DIR}/linker/stm32f4_flash.ld
    -Wl,--gc-sections
)

你看,整个构建逻辑一目了然。换芯片?改 MCU_MODEL 和链接脚本就行。换编译器?CMake 自动处理。

四、CMake 的核心工作流程

为了让你更直观地理解 CMake 怎么工作,我画了张图:

CMake 构建流程 ① 编写 CMakeLists.txt 描述项目结构、源文件、依赖 ② cmake .. 配置阶段 生成 Makefile / Ninja 文件 ③ make / ninja 编译 编译器真正干活,生成 .o 和 .elf ④ 生成可执行文件 / 库 myapp.elf / libmylib.a

这张图展示了 CMake 的完整工作流。注意看:CMake 本身不编译代码,它只负责生成构建规则。真正的编译是 make 或 ninja 干的。

五、常见问题与避坑指南

做嵌入式 CMake 项目这几年,我遇到过不少坑。挑几个典型的说说:

5.1 源文件路径写错了

我曾经把源文件路径写成绝对路径,结果项目换了个目录就编译不过。后来我统一用相对路径,或者用 ${CMAKE_SOURCE_DIR} 变量。

5.2 忘记声明 ASM 语言

嵌入式项目经常有 .s 启动文件。如果你在 project 里没写 LANGUAGES ASM,CMake 会直接忽略这些文件。链接的时候就会报 undefined reference。

5.3 链接脚本路径不对

target_link_options 里的 -T 参数,路径要是相对于构建目录的。我习惯用 ${CMAKE_SOURCE_DIR} 来定位链接脚本:

target_link_options(myapp PRIVATE
    -T ${CMAKE_SOURCE_DIR}/linker/stm32f4_flash.ld
)

总结一下:CMake 的核心就三个命令——cmake_minimum_required 定版本,project 定项目,add_executable 定输出。把这三点吃透,剩下的都是锦上添花。

我个人觉得,CMake 最大的价值不是省掉你写 Makefile 的时间,而是让构建逻辑变得清晰、可维护。你想想看,一个 500 行的 Makefile 和 50 行的 CMakeLists.txt,哪个更容易看懂?

好了,这一章就到这里。记住:CMake 是描述,不是指令。你告诉它「要什么」,它帮你想「怎么做」。


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