ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CMake从入门到实战:构建系统核心原理与跨平台开发

CMake从入门到实战:构建系统核心原理与跨平台开发 搞 C/C 开发这么多年最绕不开的工具就是 CMake。这名字起得也好——CMake the Most of Software Development一语双关既是充分利用 CMake 做开发也是让软件开发这件事物尽其用。我最早接触 CMake 是 2010 年前后那时还在用 Autotools 写 Makefile每次新增一个源文件就要手动改依赖跨平台更是噩梦。后来切到 CMake说实话前两周很不适应觉得多了一层抽象但等我真正吃透它的构建规则和变量传递机制之后就再也回不去了。这篇东西我想从实际工程的角度把 CMake 从安装、语法、集成到发布整个链路捋一遍。不管你是刚入门的嵌入式开发者还是在 Linux 下做服务端开发的老手只要你需要管理 C/C 项目的构建过程这篇文章应该都能给你一些能直接上手的思路。我会把 CubeMX 生成的 STM32 工程、VSCode 的调试配置、Ubuntu 的版本管理这些具体场景都过一遍也会把我踩过的坑、排查过的报错原原本本说出来。1. 先聊聊为什么是 CMake1.1 从 Makefile 到 CMake构建工具的演进很多人第一次学 C 语言老师教的编译命令是gcc main.c -o main。一个文件还好等到文件多起来比如十几个模块互相依赖这条命令就写不下了。于是出现了 Makefile它用target: dependencies的规则描述构建关系配合make命令执行。Makefile 有两个很明显的问题。第一语法太古老Tab 缩进错误、变量展开规则这些细节能折腾死人。第二跨平台能力基本为零——Linux 下用 GNU MakeWindows 下可能是 nmake也可能是 Visual Studio 的 MSBuild同一套构建脚本没法直接搬。如果你做过 Windows 和 Linux 双平台的项目一定体会过维护两套 Makefile 的痛苦。CMake 的解决思路是你只写一份 CMakeLists.txt描述项目需要哪些源文件、生成什么目标、链接什么库然后 CMake 根据当前平台自动生成对应的构建系统——在 Linux 上生成 Makefile在 Windows 上生成 Visual Studio 工程文件。用一句业内常说的话CMake 不是构建器它是构建系统的生成器。1.2 CMake 核心优势在哪先说依赖管理。大型项目的第三方库很多OpenSSL、libcurl、json-c如果这些库的 CMake 配置文件写得规范你在自己的 CMakeLists 里只要写find_package(OpenSSL REQUIRED)CMake 就会自动找到头文件路径和库文件路径再通过target_link_libraries把你的目标跟 OpenSSL 绑在一起。这套机制比手动在 Makefile 里写-I和-L参数靠谱得多。再说自动化。CMake 的 configure 阶段会做大量的环境探测——编译器支不支持某个特性、某个库是否可用、头文件在哪个目录。这些探测结果会被缓存在 CMakeCache.txt 里后面每次构建增量更新不需要重新探测节省大量时间。还有一点很重要CMake 对 IDE 的支持非常到位。生成 Visual Studio 工程、Xcode 工程都只是-G参数的事。你在 IDE 里点的 Build 按钮底层调用的还是 CMake 生成的构建系统。这意味着团队协作时每个人可以用自己熟悉的开发环境而构建逻辑全由同一份 CMakeLists.txt 控制。1.3 什么场景不适合 CMake这话题得说实话。CMake 不是万能的如果一个项目只有两个源文件也不打算跨平台那直接 gcc 编译就够了引入 CMake 反而有点小题大做。另外纯脚本项目Python、Shell也不需要 CMake它只服务编译型语言。还有一些极简的嵌入式裸机项目内存和存储资源紧张到连标准 C 库都用不全这时构建系统的配置能力反而不如直接写 Makefile 来得可控。但只要你面对的是中大型项目、需要发布成软件包、需要集成第三方库CMake 几乎就是当下最靠谱的选择没有之一。2. 环境搭建与版本管理2.1 从零开始装 CMake不同平台的安装方式差别挺大我分别说。LinuxUbuntu/Debian 系sudo apt update sudo apt install cmake装完验证cmake --versionUbuntu 官方源里的 CMake 版本通常不是最新的但胜在稳定和系统库的兼容性有保障。如果你需要最新特性比如新的语言标准支持官方推荐从 CMake 官网下载源码或预编译二进制包。macOSbrew install cmakeWindows两个主流方案直接下载安装包 cmake.org/download 或者用包管理器winget install Kitware.CMake # 或者 choco install cmake装完记得把 CMake 加到 PATH。Windows 版 CMake 安装器会问你是否把 CMake 加入系统 PATH勾选上就行。装完打开命令行敲cmake --version能看到版本说明就 OK 了。2.2 Ubuntu 版本降级实操降到 3.16.3 的完整流程为什么要降级我遇到的实际场景是某个项目是给客户交付的客户那边的服务器还是老系统预装的 CMake 是 3.16.3而我在开发机上装了 3.22。CMake 有一个很坑的行为高版本生成构建系统后低版本有可能无法正常读取缓存文件或者直接报CMake 3.22.0 is higher than the version 3.16.3的错误。为了保证交付链路一致只能降级。我当时用的是源码编译方式过程如下# 1. 下载 CMake 3.16.3 源码包 wget https://github.com/Kitware/CMake/releases/download/v3.16.3/cmake-3.16.3.tar.gz # 2. 解压 tar -xzvf cmake-3.16.3.tar.gz cd cmake-3.16.3 # 3. 配置编译prefix 指定安装到 /usr/local避免污染系统目录 ./bootstrap --prefix/usr/local make -j$(nproc) # 4. 安装 sudo make install # 5. 验证版本 /usr/local/bin/cmake --version有几点值得提醒。第一bootstrap阶段需要系统里有 gcc 和 make它们是编译 CMake 自身的工具链因为 CMake 也是 C 写的。第二如果你不想编译也可以直接下载官方发布的cmake-3.16.3-Linux-x86_64.tar.gz二进制包解压后把bin目录加到 PATH 即可比源码编译快很多。源码编译的好处是安装路径完全可控--prefix/usr/local可以把新版装到/usr/local/bin而系统自带的旧版本留在/usr/bin两者共存需要用哪个就改 PATH 顺序。我在生产服务器上就是这么干的系统自带的 cmake 不动项目构建时显式调用/usr/local/bin/cmake互不干扰。2.3 Windows 与 Cygwin 环境细节Windows 下的 CMake 用得最多的场景是配 Visual Studio。默认情况下CMake 会自动探测已安装的 VS 版本比如 VS2019 对应的是 Visual Studio 16 2019 这个生成器名称VS2022 对应 Visual Studio 17 2022。Cygwin 环境有点特殊它是 Windows 上模拟 Linux 环境的工具提供了 GCC 编译器和 Make。在 Cygwin 里装 CMake 有个坑如果你直接用cygwin包管理装 cmake它的默认生成器是 Unix Makefiles这没问题。但如果你把 Windows 原生的 CMake 拿到 Cygwin 的 bash 里跑路径格式会不兼容比如C:\path\to\file会被当成转义字符解析出现一堆莫名其妙的报错。我的建议是Cygwin 环境下就老老实实用 Cygwin 包管理器装的 cmake不要混用原生 Windows 版。反过来也一样在 Windows 命令行里不要用 Cygwin 版本的 cmake除非你想体验路径解析错乱的感觉。3. CMake 核心语法与可复用模板3.1 一个能直接抄的 CMakeLists.txt 模板很多入门教程把 CMake 语法讲得特别细但我更偏向直接给一个最小可运行的模板然后在实战中逐步加东西。这是一个单目标项目的典型配置cmake_minimum_required(VERSION 3.10) project(MyProject VERSION 1.0.0 LANGUAGES C CXX) set(CMAKE_C_STANDARD 11) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(my_app src/main.cpp src/utils.cpp src/utils.h ) target_include_directories(my_app PRIVATE include) target_compile_options(my_app PRIVATE -Wall -Wextra)几点说明。cmake_minimum_required写在最前面它声明了项目需要的最低 CMake 版本。如果用户机器上的 CMake 低于这个版本会在配置阶段直接报错而不是等编译到一半才崩溃。这行建议保留版本号就写你实际测试过的最低版本。project()命令有两个重要作用定义项目名称以及声明项目使用的语言。如果只写了project(MyProject)CMake 会默认探测 C 和 C 编译器但 C 的情况比较特殊后面链接标准库时有坑所以建议显式写LANGUAGES C CXX。add_executable列出了目标名和源文件列表。注意头文件也可以列进来CMake 会自动处理头文件的依赖关系前提是你把头文件路径通过target_include_directories告诉它。3.2 核心命令解析为什么这么写很多人写 CMakeLists 喜欢把所有设置都写在顶层比如add_executable(a a.c) target_include_directories(a PRIVATE include) target_link_libraries(a PRIVATE m) add_executable(b b.c) target_include_directories(b PRIVATE include) target_link_libraries(b PRIVATE m)这种写法能跑但可维护性很差。我推荐从第一天就养成用变量的习惯set(SOURCE_FILES src/main.cpp src/utils.cpp) set(INCLUDE_DIRS include) set(LINK_LIBS m pthread) add_executable(my_app ${SOURCE_FILES}) target_include_directories(my_app PRIVATE ${INCLUDE_DIRS}) target_link_libraries(my_app PRIVATE ${LINK_LIBS})源文件列表用变量统一管理新增文件只需要改一处。链接库同理如果后面要加 zlib、openssl就加到LINK_LIBS里一目了然。还有两个高频命令需要理解区别。target_include_directories有三个访问修饰符PUBLIC、PRIVATE、INTERFACE。简单说PRIVATE表示头文件路径只对本目标可见PUBLIC会传递到链接这个目标的其他目标INTERFACE则表示这个目标本身不用但使用它的目标需要。规则是如果头文件在编译源文件时需要就至少用PRIVATE如果这个头文件出现在目标对外暴露的接口头文件里就改成PUBLIC。这个规则刚开始容易混淆我提供一个简单的判断法——想象你要把一个库发布给第三方用第三方只需要知道哪个头文件、链接哪个库那就用PUBLIC其余用PRIVATE。3.3 如何指定编码方式这个需求最开始是搞 Windows 项目的同事提的。由于历史原因很多老代码是 GBK/GB2312 编码而现代编译器和 VSCode 默认按 UTF-8 解析。源文件是 GBK 编码编译器按 UTF-8 读取字符串字面量全变乱码中文注释直接编译失败。CMake 层面能做的是给编译器传编码相关参数。MSVC 下指定编译源文件使用 GBK 编码if(MSVC) target_compile_options(my_app PRIVATE /source-charset:.936) endif()GCC 和 Clang 下则用-finput-charsetif(CMAKE_CXX_COMPILER_ID STREQUAL GNU) target_compile_options(my_app PRIVATE -finput-charsetGBK) endif()不过说实话这只是救急方案。长期来看还是把所有源文件统一转成 UTF-8 最省心。我在项目里是写了个脚本自动转换然后加了个 CMake 检测如果某个文件不是合法 UTF-8直接报错提示。这样能避免乱码问题从源头复发。3.4 开发规范与最小版本约束CMake 的版本号有时还会带来额外约束。比如cmake_minimum_required(VERSION 3.10)之上的新特性target_sources命令是 3.1 加入的FetchContent是 3.11 才有的CMAKE_CXX_STANDARD从 3.8 开始支持。如果你的项目代码里用到某个命令请先去 CMake 官方文档确认它引入的版本再回来决定cmake_minimum_required写多少。我看到过不少项目cmake_minimum_required写 3.10但代码里用了 3.14 才支持的FetchContent低版本环境一跑就报Unknown CMake command。4. 实战场景CubeMX VSCode CMake 搭建 STM32 工程4.1 CubeMX 生成 CMake 工程的基础原理STM32 开发最常见的工具链是 STM32CubeMX 生成初始化代码配合 Keil 或 IAR 编译。CubeMX 从某个版本开始支持直接生成 CMake 工程IDE 部分不再依赖 Keil而是可以用 VSCode CMake 完成编译调试这对 Linux 环境下搞嵌入式的人来说简直是福音。CubeMX 生成 CMake 工程的操作很简单在 Project Manager 页签里Toolchain/IDE 下拉框选择 CMake。生成后的工程目录里有几个关键文件. ├── CMakeLists.txt # 顶层构建脚本 ├── Core/ │ ├── Inc/ │ └── Src/ ├── Drivers/ │ ├── CMSIS/ │ └── STM32F4xx_HAL_Driver/ └── cmake/ ├── gcc-arm-none-eabi.cmake # 交叉编译工具链配置 └── stm32cubemx.cmake # CubeMX 生成的辅助配置cmake/gcc-arm-none-eabi.cmake是核心它定义了编译器路径和标志。生成后通常需要你确认两点编译器路径是否存在于你的系统芯片型号和链接脚本是否正确。比如我的环境里arm-none-eabi-gcc装在/opt/gcc-arm-none-eabi/bin/下就要把这个路径写对。4.2 VSCode 里的配置流程VSCode 需要装三个插件C/C微软官方、CMake Tools、Cortex-Debug。打开项目根目录后CMake Tools 插件会自动扫描 CMakeLists.txt。如果你的工具链不是默认的需要在.vscode/settings.json里指定{ cmake.generator: Unix Makefiles, cmake.toolchainFile: ${workspaceFolder}/cmake/gcc-arm-none-eabi.cmake, cmake.configureSettings: { CMAKE_BUILD_TYPE: Debug } }关键点是cmake.toolchainFile它告诉 CMake 用交叉编译配置而不是宿主机编译器。如果不写这个CMake 会拿系统自带的 gcc 去编译 STM32 的代码结果必然是头文件找不到、链接失败。配置完成后底部状态栏会显示构建按钮。点击后 CMake Tools 会依次执行 configure生成构建系统和 build编译。编译产物是.elf文件再配合 Cortex-Debug 插件和 ST-Link 调试器就能在 VSCode 里打断点看变量了。4.3 交叉编译链与烧录参数交叉编译的完整流程比桌面程序多几个环节。主要有三件事编译参数。芯片型号不同CPU 指令集参数也不同。以 STM32F407 为例-mcpucortex-m4 -mthumb -mfpufpv4-sp-d16 -mfloat-abihard。这些参数决定编译器生成什么样的机器码漏掉任何一个程序都跑不起来。CubeMX 生成的工具链文件里已经写好了但如果你手动搭建工程这些必须自己加上。链接脚本。stm32f407vetx_flash.ld文件定义了 Flash 和 RAM 的起始地址和大小。CubeMX 会根据你在图形界面里选的芯片型号自动生成但如果你换芯片了脚本要重新生成。烧录。命令行可以用openocd或者st-flash。OpenOCD 的方式openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c program build/my_app.elf verify reset exitst-flash更简单st-flash write build/my_app.bin 0x08000000注意0x08000000是 STM32 Flash 的起始地址这个地址在别的型号上可能是0x08010000务必看参考手册确认。我用 CMake 搭建 STM32 工程已经三年多了最大的感受是构建和调试完全可以脱离 GUI IDE 工作CI 服务器上也能直接跑构建做自动化测试非常方便。Keil 那种 IDE 确实上手快但是一涉及版本管理和团队协作就头大——多人改一个.uvprojx文件经常产生冲突而 CMakeLists.txt 是纯文本冲突解决起来容易得多。5. 进阶玩法用 CPack 打包 deb 发布软件5.1 用 CPack 一键生成 deb 包项目开发完了要发布Linux 下最常见的软件包格式是 debDebian/Ubuntu和 rpmRedHat。CMake 自带的 CPack 模块可以自动生成多格式安装包刚才那个my_app项目加几行配置就能打包成 deb。只需要在 CMakeLists.txt 末尾加include(InstallRequiredSystemLibraries) set(CPACK_GENERATOR DEB) set(CPACK_PACKAGE_NAME my-app) set(CPACK_PACKAGE_VERSION 1.0.0) set(CPACK_PACKAGE_CONTACT devexample.com) set(CPACK_DEBIAN_PACKAGE_MAINTAINER Your Name) set(CPACK_DEBIAN_PACKAGE_DEPENDS libc6 ( 2.31)) include(CPack)然后构建、打包两步走cmake -B build -DCMAKE_BUILD_TYPERelease cmake --build build cpack --config build/CPackConfig.cmake跑完当前目录会出现my-app-1.0.0-Linux.deb一顿操作下来不需要手写 maintainer 脚本。安装时sudo dpkg -i my-app-1.0.0-Linux.deb5.2 安装路径管理打包之前要告诉 CPack 哪些文件需要安装到用户系统里。光有add_executable不代表安装包会包含这个可执行文件你还需要用install命令声明安装规则install(TARGETS my_app RUNTIME DESTINATION bin )这样my_app才会被装到/usr/local/bin默认前缀下。如果你的程序有配置文件、图标、桌面入口文件也要用 install 命令分别声明install(FILES config/app.conf DESTINATION /etc/my-app) install(FILES packaging/my-app.desktop DESTINATION /usr/share/applications)一个常见的坑是开发机上cmake --install build --prefix /usr安装后能跑但打出来的 deb 包安装后my_app命令找不到。原因往往是install命令里写死了绝对路径或者 CMake 的CMAKE_INSTALL_PREFIX在 configure 时被修改过。建议在配置阶段打印关键变量确认cmake -B build -DCMAKE_INSTALL_PREFIX/usr cmake --build build cmake --install build which my_app5.3 版本号、依赖声明与前后脚本deb 包对依赖管理有着严格要求。CPACK_DEBIAN_PACKAGE_DEPENDS里的内容最终会被写进 deb 包的控制文件dpkg 安装时会检查依赖是否满足。版本号建议用project(MyProject VERSION 1.0.0)定义的版本通过${PROJECT_VERSION}引用避免重复维护set(CPACK_PACKAGE_VERSION ${PROJECT_VERSION})如果安装后要执行某些清理或更新操作可以指定安装前后脚本set(CPACK_DEBIAN_PACKAGE_CONTROL_EXTRA ${CMAKE_SOURCE_DIR}/packaging/postinst;${CMAKE_SOURCE_DIR}/packaging/prerm)postinst脚本在安装后执行可以用来创建系统用户、设置权限、启动服务。prerm在卸载前执行用来停止服务。注意脚本要有可执行权限并且第一行写#!/bin/bash否则可能出现 subprocess installed post-installation script returned error exit status 2 这类问题。有些人可能会问打包这种事为什么不直接写 shell 脚本我的回答是shell 脚本能打成 deb 包但依赖信息、架构信息、版本冲突管理全都得自己处理而 CPack 在你写清楚 CMakeLists 的前提下可以自动完成这些效率高得多。6. 常见错误与排查经验6.1 Visual Studio 16 2019 generator 不匹配这是一个 Windows 下非常高频的报错最初在论坛上看到很多新手中招CMake Error: Error: generator : Visual Studio 16 2019 does not match the generator used previously: Visual Studio 17 2022原因很简单CMake 在 configure 阶段把生成器信息写进了CMakeCache.txt。如果你先用-G Visual Studio 17 2022配置过项目之后又用-G Visual Studio 16 2019再次 configureCMake 发现缓存里的生成器和当前指定的不一致就直接报错了。解决方式有几种方式一清理缓存重来最快推荐删除 build 目录下的CMakeCache.txt和CMakeFiles文件夹重新 configurerm -rf build/CMakeCache.txt build/CMakeFiles cmake -G Visual Studio 16 2019 -B build方式二换一个新的 build 目录cmake -G Visual Studio 16 2019 -B build_vs2019如果你需要在 VS2019 和 VS2022 之间来回切换强烈建议用方式二每个 VS 版本用独立的 build 目录。VSCode 的 CMake Tools 插件里切换 kit 时也经常遇到同样的问题我的习惯就是切 kit 前先清空 build 目录一劳永逸。6.2 其他高频坑汇总坑一add_executable里少了头文件导致更改头文件不触发重编译这个问题的表现是你改了某个.h文件里的宏定义重新构建编译系统提示nothing to be done。原因就是你写add_executable时只列了.c/.cpp文件没把.h文件加进源文件列表。CMake 依赖扫描器对 Makefile 生成器通常用依赖文件里的头文件信息而不是 CMakeLists 里的列表。但如果你把头文件列进去至少 CMake 自己的规则会有记录。稳妥做法还是列进去否则就要靠编译器的-MMD -MP自动生成依赖信息然后include_directories时必须正确设置头文件搜索路径。坑二find_package找到了错误的库版本这种情况多发生在系统里同时装了多个版本的时候。比如 OpenCV 3 和 4 共存find_package(OpenCV)可能找到旧版本。解决方法是指定版本和路径find_package(OpenCV 4.5 REQUIRED)或者在 configure 时用-DOpenCV_DIR/path/to/opencv4/lib/cmake/opencv4指定搜索路径。核心逻辑是find_package会先搜索CMAKE_PREFIX_PATH和各模块的XXX_DIR缓存变量确认XXX_DIR的值就控制了检索目标。坑三链接时符号找不到报一堆 undefined reference大部分情况下是链接库的顺序问题。GCC 的链接器是单遍扫描静态库只能顺序解析符号如果你让libB.a先于libA.a被链接而 A 里的符号被 B 引用那么链接会失败。CMake 的target_link_libraries会自动处理依赖顺序前提是你按依赖关系写顺序。比如target_link_libraries(my_app PRIVATE libA libB)这里的规则是my_app 依赖 libAlibA 依赖 libB写的时候从右往左看。我在实际项目见到的错误大部分是开发者把多个库混在一起不知道谁依赖谁结果顺序错了。遇到这类问题最快的排查办法把库的依赖关系画出来从被依赖的库往上层写。坑四GCC 与 CMake 缓存不刷新Linux 下换编译器后CMake 也经常出现新旧编译器混用的情况。比如系统里同时装了 gcc-9 和 gcc-12你在 configure 时用了CCgcc-12 cmake -B build但 build 目录之前是用 gcc-9 configure 过的会出现 The C compiler identification is unknown 或者编译器特性检测失败。解决方法和 generator 不匹配一样直接删缓存重来。6.3 调试 CMake 配置的方法论最后说下排查 CMake 问题的一般思路。当 configure 阶段报错时第一条命令肯定是cmake -B build --trace-expand--trace-expand会打印每一条 CMake 命令的展开结果包括变量的实际值。找一个正在被判断的if条件你可以直接看到这个条件是true还是false判断依据是什么。如果你要查看某个变量在配置时的值可以临时加一行message(STATUS DEBUG: variable_name ${variable_name})configure 后输出-- DEBUG: variable_name /usr/lib/x86_64-linux-gnu/libssl.so这样可以快速确认 CMake 的路径探测结果是否符合预期。在复杂项目里这个办法比我用过所有 GUI 调试工具都好使。另外建议把 configure 输出保存成日志文件方便反复查阅cmake -B build 21 | tee configure.log这样即使构建系统没有崩溃你也能事后回顾 configure 阶段到底探测到哪些信息。7. 最后分享几个小习惯跑cmake --build build而不是直接make这样 CMake 会自动选择正确的构建命令不用关心生成器是 Makefile 还是 Ninja。养成-DCMAKE_BUILD_TYPEDebug和-DCMAKE_EXPORT_COMPILE_COMMANDSON同时使用的习惯前者保证调试信息完整后者会在 build 目录下生成compile_commands.json文件很多 IDE 的 IntelliSense 都靠这个文件工作。我在处理那些老项目时也有个固定的操作路径先跑一次 configure 看基础环境是否 OK再全量编译一把把编译器警告和错误先过一遍然后再开始改代码。这个流程虽然简单但能避免很多编译报错 - 改代码 - 发现是环境问题 - 改回来的死循环。从接触 CMake 到现在它已经从另一个构建工具变成了我项目里最底层的自动化设施。不管是桌面程序、服务器进程、还是嵌入式固件只要涉及编译链接第一件事就是把它写进 CMake 的框架里。你不需要记所有语法记住几个最常用的命令遇到新需求再从文档里查多踩几次坑自然就熟练了。如果你刚开始用 CMake找个小的练习项目试着把它的配置一步步加进去等跑通了再迁移大项目这个过程会顺畅很多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进