1. 从那一行红色报错说起:cmake 到底是什么东西
第一次在 Windows 的 PowerShell 里敲下cmake这四个字母,回给你的大概率是这么一行红字:cmake : 无法将“cmake”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。这句话看着像在骂人,其实它只是很直白地告诉你:系统在当前 PATH 里翻遍了所有目录,没找到一个叫 cmake 的可执行程序。
这个报错几乎是我见过的新手第一坑,没有之一。它跟 CMake 本身难不难完全没关系,纯粹是环境没配对。所以在讲怎么写 CMakeLists.txt 之前,得先把“cmake 是个啥”说清楚,不然很多人连自己在装什么都没搞明白。
CMake 不是一个编译器,它不负责把 .c 或者 .cpp 变成机器码。它干的事情更像一个工程文件翻译官:你写一份跨平台的描述文件(CMakeLists.txt),告诉它“我这个项目有哪些源文件、依赖哪些库、要生成什么产物”,然后它根据你当前的操作系统和你指定要用的编译器,生成对应的底层构建文件。在 Windows 上它默认生成 Visual Studio 的 .sln/.vcxproj,也可以生成 MinGW 的 Makefile;在 Linux 上它生成 Makefile 或者 Ninja 的 build.ninja;在 macOS 上它还能生成 Xcode 工程。
所以 CMake 的定位是build system generator,构建系统生成器,不是构建系统本身。真正干活的是它生成出来的那套东西,以及后面的 make、ninja、MSBuild。
理解这一点非常关键,因为它直接解释了后面几个高频问题:
- 为什么
cmake .之后还要再make或者cmake --build .——因为第一步只是“生成”,第二步才是“构建”。 - 为什么同一个项目在 Windows 和 Linux 上都能编——因为描述文件是同一份,生成出来的工程文件是两套。
- 为什么新手总觉得它绕——因为多了一层间接,多了一层就要多一次理解成本。
我个人习惯把 CMake 类比成“装修图纸转换器”。你画的是一份标准图纸(CMakeLists.txt),转换器会根据施工队是江苏的还是广东的,输出成他们各自看得懂的施工单(Makefile / vcxproj)。图纸不用改,施工单随时可以重出。
1.1 为什么它值得花时间学
说句实在话,如果只写单文件的练习代码,直接gcc main.c -o main就够了,根本用不着 CMake。但只要项目超过三个源文件、跨两个目录、还要链接第三方库,手写编译命令就会迅速失控。这时候 CMake 的价值就出来了:源文件列表改一处,全平台同步生效;换个编译器,不用重写构建脚本;接 CI 的时候,流水线里三行命令就能跑起来。
嵌入式这行尤其明显。以前用 Keil 或者 IAR 的时候,工程文件是 IDE 私有的二进制格式,git diff 出来全是乱码,两个人同时改工程配置基本必然冲突。换成 CMake 之后,工程配置就是纯文本,谁改了什么一目了然。
1.2 版本号这件事比你想的重要
热词里出现了“ubuntu cmake 版本”,说明不少人在这上面栽过。CMake 的版本差异不是小修小补,cmake_minimum_required写 3.5 和写 3.20,能用的命令、能生效的策略(policy)是两回事。Ubuntu 20.04 自带的 apt 源里是 3.16,22.04 是 3.22,而某些新库张口就要 3.20 以上。
查版本很简单:
cmake --version输出第一行就是版本号。我一般建议在项目开头就把最低版本写清楚,别写cmake_minimum_required(VERSION 2.8)这种远古写法,那是十年前教程留下的遗毒,现在写它只会让 CMake 走一堆兼容旧行为的策略分支,反而更容易出怪问题。
提示:如果你在 Ubuntu 上用 apt 装完发现版本太老,别急着到处找 PPA。最省事的做法是去 CMake 官网下载对应的 Linux x86_64 二进制 tar.gz,解压到 /opt 下,再软链接到 /usr/local/bin,比折腾源干净得多。
2. 安装与第一个能跑起来的项目
先把环境弄干净,再谈写代码。我见过太多人一上来就闷头写 CMakeLists.txt,结果敲命令报错,分不清是文件写错了还是环境没装好,来回折腾一下午。
2.1 Windows:装完之后那三个必须确认的动作
Windows 上最推荐的装法是官网下载 64 位 msi 安装包。注意是 x86_64 那个,热词里“cmake wind10 64位”问的就是这个——别下成 32 位的,虽然大多数情况也能跑,但和你系统里的 64 位编译器混用时会出一些很迷的链接错误。
安装过程中有两个勾选特别关键:
- Add CMake to the system PATH,这个决定了你能不能直接在命令行敲 cmake。选“为所有用户添加”还是“为当前用户添加”都行,区别只在于写进的是系统 PATH 还是用户 PATH。
- 安装路径尽量别带空格和中文。
C:\Program Files\CMake是默认值,问题不大,但如果你手滑装到D:\我的工具\cmake这种路径,后面配工具链的时候大概率要骂人。
装完之后,一定要重新开一个终端。已经打开的 PowerShell 不会自动刷新环境变量,这就是为什么很多人明明装了、勾了 PATH,还是看到那行“无法将 cmake 项识别为 cmdlet”。另外 VS Code 里的集成终端也一样,装完 CMake 得把整个 VS Code 重启,或者按 Ctrl+Shift+P 执行一次重载窗口,否则它继承的还是旧环境。
验证三步走:
where.exe cmake cmake --version cmake --helpwhere.exe能列出所有叫 cmake 的路径,如果这里有输出但cmake --version还是失败,那基本就是 PATH 里有多个 cmake,前面那个损坏或者被删了。
如果确实没加进 PATH,手动加也行:打开“编辑系统环境变量”→ 环境变量 → 在用户变量里找到 Path → 编辑 → 新建一条,指向 CMake 的 bin 目录,比如C:\Program Files\CMake\bin。改完同样要重开终端。
除了 msi,还有几条路可以走,各有适用场景:
| 安装方式 | 命令 | 适合谁 |
|---|---|---|
| winget | winget install Kitware.CMake | Win10 1809 之后的系统,一条命令搞定 |
| scoop | scoop install cmake | 喜欢把工具装在用户目录、不污染系统的 |
| pip | pip install cmake | 已经有一套 Python 环境,想顺手装的 |
| zip 免安装 | 解压后手动加 PATH | 没有管理员权限的公司电脑 |
注意:pip 装出来的 cmake 在虚拟环境里是隔离的。如果你在项目 A 的 venv 里装的,切到别的终端就找不到,这点和 msi 全局安装完全不同,别装完就失忆。
2.2 Ubuntu:apt 装完之后的第一件事
Ubuntu 上简单:sudo apt update && sudo apt install cmake。但装完第一件事还是查版本。
sudo apt update sudo apt install -y cmake build-essential cmake --versionbuild-essential会把 gcc、g++、make 一次性带上,不然 CMake 生成完 Makefile 你会发现 make 也没装。
如果版本太老,两种补救方式。一是官方预编译包,这个最稳:
wget https://github.com/Kitware/CMake/releases/download/v3.29.6/cmake-3.29.6-linux-x86_64.tar.gz sudo tar -zxvf cmake-3.29.6-linux-x86_64.tar.gz -C /opt sudo ln -sf /opt/cmake-3.29.6-linux-x86_64/bin/cmake /usr/local/bin/cmake二是pip install cmake --upgrade,好处是快,坏处是它装在 Python 的 bin 目录里,多用户环境下别人不一定能用。
判断到底用的是哪个 cmake,用which -a cmake,能把路径全列出来。/usr/local/bin 一般排在 /usr/bin 前面,所以软链接过去之后会优先命中新版。
2.3 第一个最小工程:从零到可执行
建个目录,两个文件,先跑通再说。
# CMakeLists.txt cmake_minimum_required(VERSION 3.15) project(hello LANGUAGES C) add_executable(hello main.c)/* main.c */ #include <stdio.h> int main(void) { printf("hello cmake\n"); return 0; }然后执行:
cmake -S . -B build cmake --build build ./build/helloWindows 上最后一步换成.\build\Debug\hello.exe,因为 VS 生成器默认是 Debug 配置,会在 build 目录下再套一层配置名文件夹。
这里出现的-S . -B build是 CMake 3.13 之后推荐的写法,S 是 source,B 是 build。老教程里的mkdir build && cd build && cmake ..效果一样,只是多敲几行,而且一不小心就在源码目录里拉一地文件。
3. CMakeLists.txt 的骨架:三行命令背后的逻辑
能跑通一个文件之后,往下就要面对真实项目了。真实项目的 CMakeLists.txt 通常分四块:版本与工程声明、产物定义、依赖与链接、附加配置。搞清楚每一块的职责,写起来就不会乱。
3.1 cmake_minimum_required 到底是给谁看的
这行不是写给人类看的注释,它是写给 CMake 自己的。它告诉 CMake:用这个版本的行为模式来解释我这份文件。CMake 有很多策略(policy)在不同版本间行为不一样,比如 CMP0077 影响 option() 的处理方式,CMP0069 影响 IPO 支持。写一个足够高的最低版本,等于让 CMake 全部走新行为,省得它一边跑一边给你发一堆开发者警告。
那到底写多少合适?我的经验:
- 纯自用的小工具,写
3.16就行,这是 Ubuntu 20.04 的默认版本,覆盖面足够。 - 要用
target_link_options(3.13+)、FetchContent的完整功能(3.14+)、cmake_path(3.20+),就往上抬。 - 别写
VERSION 3.15...3.28这种范围写法除非你真的需要兼容旧行为,新手用不上。
3.2 project() 里不只有名字
project(hello)是最简写法,但它其实能带一堆参数:
project(mydemo VERSION 1.2.0 DESCRIPTION "a small demo" LANGUAGES C CXX ASM)几个值得注意的点:
- LANGUAGES 一定要写。不写的话 CMake 默认启用 C 和 CXX,会去探测 C++ 编译器。做纯 C 的嵌入式项目时会平白多一次编译器检查,配置阶段变慢,有时候还会因为找不到 C++ 编译器直接报错。
- VERSION 会生成宏,比如
MYDEMO_VERSION_MAJOR,头文件里可以直接用,做版本号输出的功能时很省事。 - project 之后,CMake 会定义
PROJECT_NAME、PROJECT_SOURCE_DIR这些变量,后面写路径全靠它们。
3.3 add_executable、target_include_directories、target_link_libraries 三件套
这是现代 CMake 的核心思路:一切围绕 target(目标)来配置,而不是围绕目录。
add_executable(demo src/main.c src/sensor.c) target_include_directories(demo PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) target_compile_options(demo PRIVATE -Wall -Wextra -O2) target_link_libraries(demo PRIVATE m)对比一下老写法:
# 不推荐 include_directories(include) add_executable(demo src/main.c src/sensor.c) link_libraries(m)两种写法在单目标项目里效果一样,但项目一大就分道扬镳了。include_directories是目录级的,加完之后这个目录以及所有子目录里定义的目标都受影响,而且顺序敏感——加到add_executable之前还是之后,作用范围不同。target_include_directories是目标级的,只影响指定的那个 target,谁需要谁加,清晰可控。
PRIVATE / PUBLIC / INTERFACE 这三个关键字也不是摆设:
| 关键字 | 自己编译时用 | 别人链接我时继承 |
|---|---|---|
| PRIVATE | 用 | 不继承 |
| PUBLIC | 用 | 继承 |
| INTERFACE | 不用 | 继承 |
举个例子,我做一个sensor静态库,它对外暴露的头文件里 include 了sensor_reg.h,那sensor_reg.h所在目录就得用 PUBLIC 加,因为用我这个库的人也得能找到它。而我只在 .c 内部用的sensor_debug.h,就 PRIVATE,别人不需要也不该看到。
一开始记不住没关系,先用 PRIVATE 跑起来,等遇到“链接我的库时报找不到头文件”的时候,再回来把它改成 PUBLIC,这个错误会教你一辈子。
4. build 目录、缓存和那堆让人心慌的文件
第一次跑完 cmake,build 目录里会冒出 CMakeCache.txt、CMakeFiles、cmake_install.cmake 一堆东西。很多人看着就怕,觉得污染了源码。其实这些都是正常产物,理解它们能帮你少走很多弯路。
4.1 为什么强烈建议 out-of-source build
所谓 out-of-source,就是构建产物和源码分开放。cmake -S . -B build这种写法天然就是 out-of-source。
好处有三个,都很实在:
- 清理彻底。哪天构建乱了,
rm -rf build一把梭,源码一根汗毛都不少。in-source 构建的话,你得挨个分辨哪个 .o 是生成的、哪个 .h 是自己写的。 - 多配置并存。
build-debug和build-release两个目录各自独立,切换配置就是切目录,不需要重新配置。做性能对比测试时这个太方便了。 - git 干净。.gitignore 里一句
build*/就够了。
我踩过的一个坑是:在同一个 build 目录里先配置了 MinGW 生成器,又想切到 VS 生成器。这时候直接重跑 cmake 会报“生成器不匹配”。正确做法是把 build 目录整个删掉重来,因为 CMakeCache.txt 里记着上一次的生成器和编译器路径,改不动。
4.2 CMakeCache.txt:既是福也是祸
CMakeCache.txt 存的是所有缓存变量:编译器路径、生成器、各种-D传进来的开关。它的价值在于,第二次配置时不用重新探测编译器,速度快很多。
但它也是最容易让人迷惑的东西。典型场景:你第一次用了-DUSE_SSL=OFF,后来想改成 ON,直接重跑 cmake 有时候不生效——因为缓存里那个变量的类型和值已经定死了。这时候有两个办法:
# 办法一:显式强制覆盖 cmake -S . -B build -DUSE_SSL=ON # 办法二:直接删掉缓存重配 rm -rf build && cmake -S . -B build一般正常的-DXXX=YYY第二次是能覆盖的,覆盖不了的是那些被set(... CACHE ... FORCE)或者内部逻辑提前写死的。实在搞不清就删 build,成本最低。
4.3 换编译器和切 Debug/Release
Windows 上如果装了 Visual Studio,CMake 默认就用 VS 生成器,走的是多配置模式,CMAKE_BUILD_TYPE在 VS 生成器下是无效的,那个变量只有单配置生成器(Makefile、Ninja)才认。
单配置生成器切配置:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j8多配置生成器(VS、Xcode)切配置:
cmake --build build --config Release这两个写法混用是高频错误。有人在 Ninja 下写--config Release,发现压根没生效,编出来的还是没优化的版本;反过来在 VS 下写-DCMAKE_BUILD_TYPE=Release,CMake 连警告都不会给你,静默忽略。记住一句话:单配置用 CMAKE_BUILD_TYPE,多配置用 --config。
换编译器则通过CMAKE_C_COMPILER传:
cmake -S . -B build-mingw -G "MinGW Makefiles" \ -DCMAKE_C_COMPILER=gcc \ -DCMAKE_CXX_COMPILER=g++这里有个铁律:编译器一旦选定,就不能在同一个 build 目录里换。想换就新开一个目录,或者删掉缓存。
5. 嵌入式视角:CMake 和 Keil、J-Link、ESP-IDF 怎么打交道
热词里好几个都指向嵌入式:cmake jlink、cmake 可以代替 keil5 吗、如何将 keil 工程变成 cmake、还有那行include($ENV{IDF_PATH}/tools/cmake/project.cmake)。这块单独拎出来说,因为场景和纯 PC 开发差别挺大。
5.1 CMake 能代替 Keil5 吗,边界在哪
直接回答:构建环节能替代,调试和芯片支持包这块替代不了。
Keil5 强在几件事:一是 Arm 官方的设备支持包(DFP),点几下就把启动文件、链接脚本、寄存器头文件全配好了;二是 μVision 里集成的调试器,接上 ST-Link 或 J-Link 直接打断点看寄存器,这套体验目前没有同等顺手的开源替代;三是 Flash 算法,各家芯片的下载算法都是现成的。
CMake 能接管的是构建:源文件组织、编译选项、依赖管理、生成 elf/hex/bin。它不管调试,也不管芯片的寄存器定义。
所以比较务实的做法是构建用 CMake,调试用 Keil 或者 OpenOCD + GDB。真要把整个流程搬出来,就得自己准备这几样东西:
arm-none-eabi-gcc工具链- 芯片的启动文件(startup_xxx.s)
- 链接脚本(.ld)
- CMSIS 头文件
- 一份 toolchain file 告诉 CMake 用交叉编译器
5.2 从 Keil 工程迁移到 CMake 的四步拆解
这活儿我做过几次,流程是固定的,急不来。
第一步:把文件清单导出来。Keil 的 .uvprojx 本质是 XML,用记事本打开能找到所有参与编译的源文件路径。把它们按目录分类整理,启动文件和链接脚本单独标记出来。
第二步:写 toolchain file。这个文件只负责告诉 CMake“我要用交叉编译器”,和具体项目无关,可以复用。
# arm-none-eabi.cmake set(CMAKE_SYSTEM_NAME Generic) set(CMAKE_SYSTEM_PROCESSOR cortex-m4) set(CMAKE_C_COMPILER arm-none-eabi-gcc) set(CMAKE_CXX_COMPILER arm-none-eabi-g++) set(CMAKE_ASM_COMPILER arm-none-eabi-gcc) set(CMAKE_OBJCOPY arm-none-eabi-objcopy) set(CMAKE_SIZE arm-none-eabi-size) set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)最后那行STATIC_LIBRARY很关键。默认情况下 CMake 会尝试链接一个可执行文件来验证编译器,但裸机环境没有默认的链接脚本,一链接就报一堆undefined reference to _start。设成静态库就跳过链接验证了,这个坑我第一次迁移时卡了大半天。
第三步:把编译选项搬过来。Keil 的 Options for Target 里那些勾,对应 GCC 的参数大体是:
| Keil 配置项 | GCC/CMake 对应 |
|---|---|
| Optimization Level -O2 | -O2 |
| C99 Mode | -std=c99 |
| Define 宏 | target_compile_definitions |
| Include Paths | target_include_directories |
| Misc Controls | target_compile_options |
注意 Keil 的 ARMCC 和 GCC 有些内联汇编语法不同,__asm那种写法在 GCC 下要改成__asm volatile,一段段过。
第四步:加生成 hex/bin 和烧录的 target。
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD COMMAND ${CMAKE_OBJCOPY} -O ihex $<TARGET_FILE:${PROJECT_NAME}> app.hex COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:${PROJECT_NAME}> app.bin)$<TARGET_FILE:...>是生成器表达式,能自动解析出完整产物路径,比手写路径靠谱得多,改配置也不会断。
5.3 J-Link 接入:加一个 flash target 就够
我不太推荐把烧录写死在构建流程里,那样每次编译都会触发下载,调代码时很烦。更好的做法是单独定义一个 target:
add_custom_target(flash COMMAND JLinkExe -device STM32F407VG -if SWD -speed 4000 -autoconnect 1 -CommanderScript flash.jlink DEPENDS ${PROJECT_NAME} COMMENT "download firmware via J-Link")flash.jlink 里写:
halt loadfile build/app.hex r go qc这样平时cmake --build build只编译,要下载的时候再cmake --build build --target flash。Windows 下把 JLinkExe 换成 JLink.exe,参数一样。
提示:JLinkExe 得在 PATH 里,或者写全路径。公司电脑没管理员权限的话,写全路径最省事,别折腾环境变量。
5.4 那行 include($ENV{IDF_PATH}/tools/cmake/project.cmake) 在干什么
这是 ESP-IDF 项目的标准开头,很多第一次看到的人完全懵:为什么一上来就 include 一个环境变量拼出来的路径?
拆开看就三步。$ENV{IDF_PATH}是读环境变量 IDF_PATH,也就是你 ESP-IDF 的安装目录。tools/cmake/project.cmake是这个目录下的一个脚本文件。include 就是把它加载进来。
那这个脚本到底做了什么?它重定义了 project() 这个命令的行为。正常情况下 project() 只管声明工程名,但在 ESP-IDF 里,它被扩展成了:扫描 components 目录下所有组件的 CMakeLists.txt、构建 bootloader、生成分区表、把 FreeRTOS 和各种驱动一次性挂上。所以你才会看到 IDF 项目的 CMakeLists.txt 只有短短几行,核心工作全被那行 include 带来的脚本接管了。
顺带说一句,idf.py本身就是一个包装了 CMake 的 Python 脚本。你敲idf.py build,它内部就是先调 cmake 配置,再调 cmake --build,只是顺手处理了工具链路径、环境变量和 Python 依赖。理解这层关系之后,很多“idf.py 和 cmake 到底谁管谁”的困惑就没了。
6. 环境维护:卸载、多版本共存和一些提速手法
环境这东西,装着装着就脏了。清理干净和当初装好一样重要,尤其是磁盘紧张或者搞多版本测试的时候。
6.1 Windows 卸载:删完程序还得清 PATH
正确顺序是:控制面板 → 程序和功能 → 找到 CMake → 卸载。卸载完还有个尾巴——PATH 里那条C:\Program Files\CMake\bin不会被自动移除。
这条残留看着无害,但实际上很坑:下次你换个方式重装 CMake(比如用 scoop 装到用户目录),系统里就有两条 PATH,前面那条指向已经不存在的目录,命令行可能还是找不到 cmake。所以卸载后记得回环境变量里把那条删掉。
另外,如果之前用过 MSI 安装包,注册表里可能还有残留,用系统的“应用和功能”卸干净一般就够了,不用上第三方清理工具。
6.2 Linux 多版本共存:靠软链接切换
Linux 上想同时留几个版本做兼容性测试,思路很简单:每个版本解压到 /opt 下一个独立目录,只在前台维护一个软链接。
# 两个版本都解压好之后 sudo ln -sf /opt/cmake-3.29.6-linux-x86_64/bin/cmake /usr/local/bin/cmake cmake --version # 想切回旧版本 sudo ln -sf /opt/cmake-3.16.9-linux-x86_64/bin/cmake /usr/local/bin/cmake如果连 make、ctest 这些配套命令也想跟着切,就干脆把整个 bin 目录软链过去,或者用 update-alternatives 管理。不过说实话,大部分人只用到 cmake 一条命令,单链一个文件最省事。
卸载的话,删掉 /opt 下的目录和 /usr/local/bin 里的那条软链接就完事了,不会有别的残留,这也是我喜欢手动装的原因——来去干净。
6.3 几个能省时间的日常手法
用 Ninja 代替 make。CMake 支持-G Ninja,增量编译速度比 make 快不少,尤其在文件多的项目上。前提是装了 ninja:Ubuntu 上sudo apt install ninja-build,Windows 上 winget 或者 scoop 都能装。切过去就是:
cmake -S . -B build -G Ninja cmake --build build用 CMakePresets.json 固化配置。如果你每次都要敲一长串 -D 参数,把它写进预设文件,以后一条命令搞定。CMake 3.19 之后支持:
{ "version": 3, "configurePresets": [ { "name": "debug", "generator": "Ninja", "binaryDir": "${sourceDir}/build/debug", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" } } ] }然后cmake --preset debug、cmake --build --preset debug就行。
打开 compile_commands.json。上面那个CMAKE_EXPORT_COMPILE_COMMANDS开关,会在 build 目录里生成一份编译数据库。VS Code 配上 clangd 或者 C/C++ 插件之后,跳转、补全、报错提示全都能准确识别到实际的编译参数,比靠猜头文件路径配出来的体验好太多。做嵌入式交叉编译时,不打开这个,编辑器大概会给你的寄存器操作代码标满红。
善用 --fresh。CMake 3.24 起提供了cmake --fresh,等价于清空缓存重新配置,但不用手动删目录。清理缓存这事儿从此有了一条正经命令。
这一路从命令行报错、装环境、写最小工程,到目录组织、缓存机制,再到嵌入式迁移和烧录接入,基本覆盖了入门阶段会撞上的所有典型问题。真正上手之后你会发现,CMake 的语法其实不多,难点全在“为什么这么设计”和“出错了往哪查”。我的经验是,遇到怪问题先看三样:CMakeCache.txt 里的变量、cmake --version 的版本号、以及 build 目录是不是该删了。这三样排查完,八成的问题就有方向了。