
1. CMake 工程场景的核心设计思路1.1 为什么工程场景比语法更重要很多人学 CMake 的路径是这样的先找一份教程把add_executable、target_link_libraries、find_package这几个命令过一遍然后觉得自己会了。结果一进真实项目就懵——顶层 CMakeLists 几百行子目录层层嵌套第三方库有的用find_package有的用FetchContent编译选项在好几个地方重复定义改一个宏定义要翻五个文件。问题出在哪出在把 CMake 当成一门语法课来学而它本质上是一门工程组织课。命令就那么几十个常用的但怎么把几十个源文件、十几个模块、若干第三方依赖、多套编译配置组织成一个可维护的构建系统这才是真正的难点。语法是工具工程场景才是目的。我见过太多项目CMakeLists 写得能跑但没人敢动。加一个新模块要复制粘贴一大堆配置换个编译器就报一堆错交叉编译更是无从下手。这些都不是语法问题是工程结构问题。所以这个系列我一直在强调先想清楚工程怎么组织再去查对应的命令怎么写。1.2 工程场景的四个典型维度把实际项目里的 CMake 需求拆开看基本逃不出这四个维度第一个维度是模块划分。一个项目通常有可执行程序、若干静态库或动态库、公共头文件、测试代码、示例代码。这些东西在文件系统里怎么放在 CMake 里就怎么映射成 target。target 是 CMake 现代用法的核心概念一个 target 就是一个构建单元它有自己的源文件、头文件路径、编译选项、链接依赖。把模块划分清楚本质上就是把 target 划分清楚。第二个维度是依赖管理。依赖分三类系统自带的库比如 pthread、dl、第三方开源库比如 OpenCV、Qt、protobuf、项目内部的模块依赖。这三类的处理方式完全不同。系统库直接target_link_libraries就行第三方库优先用find_package找不到再考虑FetchContent或add_subdirectory内部模块依赖则通过 target 名字直接链接CMake 会自动处理头文件路径传递。第三个维度是构建配置。Debug、Release、RelWithDebInfo、MinSizeRel 这几套配置每套的编译选项、宏定义、优化级别都不一样。还有平台差异Windows 和 Linux 的库名、路径分隔符、运行时库都不一样。这些差异如果散落在各个 CMakeLists 里维护起来就是灾难。好的做法是集中管理用target_compile_options、target_compile_definitions配合生成器表达式来区分。第四个维度是工具链集成。用 Ninja 还是 Make用 GCC 还是 Clang 还是 MSVC交叉编译怎么配这些属于工具链层面。CMake 的设计是把工具链信息和工程信息分离工具链通过 toolchain file 或者命令行参数指定工程本身不关心用的是哪个编译器。这个分离做得好同一份工程代码就能在 Windows、Linux、嵌入式平台之间复用。1.3 一个真实项目的目录结构参考光说维度太抽象直接看一个我常用的目录结构模板project_root/ ├── CMakeLists.txt # 顶层只做全局设置和 add_subdirectory ├── cmake/ # 存放自定义 Find 模块和工具链文件 │ ├── FindXXX.cmake │ └── toolchain-arm.cmake ├── src/ │ ├── CMakeLists.txt # 汇总子模块 │ ├── core/ # 核心库 │ │ ├── CMakeLists.txt │ │ ├── include/ │ │ └── src/ │ ├── utils/ # 工具库 │ │ ├── CMakeLists.txt │ │ ├── include/ │ │ └── src/ │ └── app/ # 可执行程序 │ ├── CMakeLists.txt │ └── main.cpp ├── tests/ │ ├── CMakeLists.txt │ └── test_core.cpp ├── examples/ │ ├── CMakeLists.txt │ └── demo.cpp └── third_party/ # 第三方源码如果用 FetchContent 则不需要这个结构的关键点在于每个有源码的目录都有自己的 CMakeLists.txt顶层只负责全局设置和调度。这样做的好处是模块自包含加一个新模块只需要在父目录的 CMakeLists 里加一行add_subdirectory其他什么都不用改。注意不要把所有源文件都堆在顶层 CMakeLists 里。我接手过一个项目顶层 CMakeLists 有 800 多行所有 target 都在里面定义改一个模块的编译选项要在几百行里找位置。这种结构一旦超过三个模块就没法维护了。1.4 顶层 CMakeLists 应该放什么顶层 CMakeLists 的职责要克制只放三类东西第一类是cmake_minimum_required和project这是必须的。cmake_minimum_required建议写一个合理的下限比如 3.16 或 3.20不要写太低否则用不了现代特性也不要写太高否则老环境跑不了。project命令要写清楚项目名、版本、支持的语言。第二类是全局设置比如 C 标准、全局编译选项、输出目录。这些设置影响所有子目录放在顶层最合适。但要注意全局设置尽量用add_compile_options这种追加的方式不要用set(CMAKE_CXX_FLAGS ...)直接覆盖后者会把 CMake 自动加的选项也覆盖掉。第三类是add_subdirectory调度。按依赖顺序添加子目录被依赖的模块先加。CMake 会按顺序处理但 target 之间的依赖关系是通过target_link_libraries建立的不依赖添加顺序所以顺序主要影响的是变量作用域。cmake_minimum_required(VERSION 3.20) project(MyProject VERSION 1.0.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) # 统一输出目录方便查找产物 set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) # 把 cmake/ 目录加入模块搜索路径方便 find_package 找到自定义模块 list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake) add_subdirectory(src) add_subdirectory(tests) add_subdirectory(examples)这段代码里有个细节值得说CMAKE_CXX_EXTENSIONS OFF表示禁用编译器扩展用标准 C。这个选项在跨平台项目里很重要因为 GCC 默认开启 GNU 扩展MSVC 没有这些扩展不开这个选项会导致同一份代码在两个平台上行为不一致。2. 模块划分与 target 设计的实操要点2.1 库 target 的三种类型怎么选CMake 里库 target 分三种STATIC、SHARED、INTERFACE。选哪种不是拍脑袋决定的要看模块的用途。STATIC静态库是最常用的编译时把代码打进最终产物部署简单没有运行时依赖问题。缺点是多个程序链接同一个静态库时每个程序都有一份代码副本体积大。内部模块、工具库、算法库一般用静态库。SHARED动态库适合需要被多个程序共享、或者需要独立升级的场景。比如插件系统、被外部程序调用的 SDK。动态库要注意符号导出问题Windows 上默认不导出符号需要__declspec(dllexport)或者 CMake 的WINDOWS_EXPORT_ALL_SYMBOLS属性Linux 上默认全部导出反而要注意隐藏内部符号。INTERFACE库是个特殊存在它不产生任何编译产物只用来传递头文件路径、编译选项、链接依赖。典型用途是header-only 库和依赖聚合。比如你有一组编译选项要传给所有用某个模块的 target就可以定义一个 INTERFACE 库把这些选项挂上去。# 静态库 add_library(core STATIC src/core/engine.cpp src/core/config.cpp ) target_include_directories(core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include ) # 动态库 add_library(plugin SHARED src/plugin/plugin.cpp ) set_target_properties(plugin PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON ) # INTERFACE 库只传递编译选项 add_library(project_warnings INTERFACE) target_compile_options(project_warnings INTERFACE $$CXX_COMPILER_ID:GNU,Clang:-Wall -Wextra -Wpedantic $$CXX_COMPILER_ID:MSVC:/W4 )这里用到了生成器表达式$$CXX_COMPILER_ID:GNU,Clang:...它的意思是如果编译器是 GNU 或 Clang就加上后面的选项。生成器表达式是 CMake 里处理平台差异的利器比在 CMakeLists 里写 if-else 干净得多。2.2 PUBLIC、PRIVATE、INTERFACE 的传递规则target_include_directories、target_link_libraries、target_compile_options这些命令都有 PUBLIC、PRIVATE、INTERFACE 三个关键字这是 CMake 现代用法里最容易搞混的地方。我用一张表说清楚关键字对当前 target 生效传递给依赖方PRIVATE是否PUBLIC是是INTERFACE否是理解这张表的关键是搞清楚依赖方是谁。如果 A 链接了 B那么 A 就是 B 的依赖方。B 在target_include_directories里用 PUBLIC 声明的路径A 也能用用 PRIVATE 声明的只有 B 自己能用。举个实际例子。假设 core 库内部用了 OpenCV但 core 的公开头文件里不暴露任何 OpenCV 类型。那么 OpenCV 的 include 路径和链接库都应该用 PRIVATE因为用 core 的人不需要知道 OpenCV 的存在。反过来如果 core 的公开头文件里用了cv::Mat那 OpenCV 就必须用 PUBLIC否则用 core 的人编译不过。# core 内部用 OpenCV但头文件不暴露 target_link_libraries(core PRIVATE opencv_core opencv_imgproc) # utils 的头文件里用了 Eigen 类型必须 PUBLIC target_link_libraries(utils PUBLIC Eigen3::Eigen)实操心得判断用 PUBLIC 还是 PRIVATE就看这个依赖会不会出现在你的公开头文件里。会就用 PUBLIC不会就用 PRIVATE。拿不准的时候先用 PRIVATE编译报错了再改 PUBLIC这样能最小化依赖传播。2.3 头文件目录的组织方式头文件目录有两种常见组织方式一种是include/和src/分离公开头文件放include/私有头文件放src/另一种是头文件和源文件放一起公开的用某种命名约定区分。我推荐第一种理由很直接target_include_directories只需要指向include/目录私有头文件通过相对路径引用不会污染公开接口。而且安装的时候只需要安装include/目录src/里的私有头文件自然不会被安装。target_include_directories(core PUBLIC $BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include $INSTALL_INTERFACE:include PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src )这里用了BUILD_INTERFACE和INSTALL_INTERFACE两个生成器表达式。BUILD_INTERFACE里的路径只在构建时使用INSTALL_INTERFACE里的路径在安装后使用。这样同一份 CMakeLists 既能支持源码内构建也能支持安装后通过find_package使用。2.4 源文件的收集方式源文件怎么列进 target有两种做法手动列举和file(GLOB)自动收集。file(GLOB)看起来很方便加个新文件不用改 CMakeLists。但它有个致命问题CMake 不会自动检测到新加的文件必须重新运行 cmake 配置才能生效。在 IDE 里这会导致加了文件但编译不进去的困惑。而且 GLOB 的结果在配置阶段就固定了构建阶段新增文件不会触发重新配置。所以我的建议是生产项目手动列举源文件原型阶段可以用 GLOB 图省事。手动列举虽然啰嗦但清晰、可控、IDE 友好。而且现在很多 IDE 都能自动帮你维护这个列表。# 推荐手动列举 add_library(core STATIC src/engine.cpp src/config.cpp src/logger.cpp ) # 不推荐用于生产GLOB file(GLOB CORE_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp ) add_library(core STATIC ${CORE_SOURCES})如果非要用 GLOB记得加CONFIGURE_DEPENDS它会让 CMake 在每次构建时检查文件列表是否变化变化了就重新配置。但这会带来额外的构建开销大项目里能明显感觉到。3. 依赖管理与第三方库集成3.1 find_package 的两种模式find_package有 MODULE 和 CONFIG 两种模式这是很多人没搞清楚的。MODULE 模式查找的是FindXXX.cmake文件这些文件要么是 CMake 自带的要么是你自己写的放在CMAKE_MODULE_PATH里的。它的原理是搜索系统路径找到库文件和头文件然后手动设置变量。这种模式适合那些没有提供 CMake 配置文件的库。CONFIG 模式查找的是XXXConfig.cmake或xxx-config.cmake文件这些文件由库自己提供里面定义了 imported target。现代 CMake 库基本都提供 CONFIG 模式比如 OpenCV、Qt、protobuf。CONFIG 模式的好处是 imported target 自带所有信息头文件路径、链接库、编译选项用起来干净。# 优先 CONFIG 模式找不到再回退 MODULE 模式 find_package(OpenCV REQUIRED CONFIG) find_package(Threads REQUIRED) # 这个只有 MODULE 模式 target_link_libraries(app PRIVATE ${OpenCV_LIBS} # MODULE 模式变量 opencv_core # CONFIG 模式 target如果 OpenCV 提供了 Threads::Threads # MODULE 模式也提供了 imported target )判断一个库用哪种模式看它安装目录下有没有lib/cmake/XXX/目录。有就是 CONFIG 模式没有就得自己写 Find 模块或者用 pkg-config。3.2 FetchContent 拉取第三方源码有些第三方库没有预编译包或者你需要特定版本、特定编译选项这时候FetchContent就派上用场了。它在配置阶段下载源码然后add_subdirectory进来一起构建。include(FetchContent) FetchContent_Declare( googletest GIT_REPOSITORY https://github.com/google/googletest.git GIT_TAG v1.14.0 ) # 设置一些选项避免构建不需要的部分 set(gtest_force_shared_crt ON CACHE BOOL FORCE) set(INSTALL_GTEST OFF CACHE BOOL FORCE) FetchContent_MakeAvailable(googletest) target_link_libraries(tests PRIVATE gtest gtest_main)FetchContent的坑主要在版本管理和网络依赖上。GIT_TAG 一定要写具体的 tag 或 commit hash不要写分支名否则每次配置拉到的代码可能不一样构建不可复现。网络不通的环境下可以配合FETCHCONTENT_SOURCE_DIR_XXX变量指定本地源码路径跳过下载。注意FetchContent在配置阶段下载如果网络慢cmake 配置会卡很久。大项目里建议把常用的第三方库预先下载到本地用FETCHCONTENT_SOURCE_DIR_XXX指向本地路径避免每次配置都联网。3.3 内部模块依赖的处理内部模块之间的依赖直接用 target 名字链接就行CMake 会自动处理头文件路径传递。关键是依赖关系要清晰不能有循环依赖。# utils 依赖 core target_link_libraries(utils PUBLIC core) # app 依赖 utils 和 core target_link_libraries(app PRIVATE utils core)这里 utils 用 PUBLIC 链接 core意味着用 utils 的人也会自动链接 core。如果 utils 的头文件里不暴露 core 的类型用 PRIVATE 更合适能减少不必要的依赖传播。循环依赖是必须避免的。A 依赖 BB 又依赖 ACMake 会报错。如果确实有循环依赖说明模块划分有问题应该把公共部分抽出来做成第三个模块让 A 和 B 都依赖它。3.4 依赖查找失败时的排查思路find_package失败是最常见的问题排查思路可以按这个顺序来第一步确认库是否真的安装了。Linux 上用dpkg -l | grep xxx或find /usr -name *xxx*Windows 上直接看安装目录。第二步确认 CMake 能不能找到配置文件。用cmake --find-package或者加--debug-find参数运行 cmake会打印详细的搜索路径。第三步如果库装在非标准路径通过CMAKE_PREFIX_PATH告诉 CMake。命令行加-DCMAKE_PREFIX_PATH/path/to/lib或者在 CMakeLists 里list(APPEND CMAKE_PREFIX_PATH ...)。第四步如果库没有提供 CMake 配置文件考虑用 pkg-config 或者自己写 Find 模块。# 调试 find_package 的利器 cmake -B build --debug-find 21 | grep -i opencv\|xxx这个命令会打印 CMake 搜索某个包时尝试的所有路径一眼就能看出为什么没找到。4. 构建配置与工具链集成4.1 编译选项的集中管理编译选项散落在各个 CMakeLists 里是维护噩梦。好的做法是定义几个 INTERFACE 库把选项分组管理。# 警告选项 add_library(project_warnings INTERFACE) target_compile_options(project_warnings INTERFACE $$CXX_COMPILER_ID:GNU,Clang,AppleClang:-Wall -Wextra -Wpedantic -Wshadow $$CXX_COMPILER_ID:MSVC:/W4 /permissive- ) # 优化选项只在 Release 下生效 add_library(project_optimization INTERFACE) target_compile_options(project_optimization INTERFACE $$CONFIG:Release:-O3 -marchnative $$CONFIG:RelWithDebInfo:-O2 -g ) # 使用 target_link_libraries(core PRIVATE project_warnings) target_link_libraries(app PRIVATE project_warnings project_optimization)这样管理的好处是改警告级别只需要改一个地方不同模块可以选用不同的选项组合生成器表达式自动处理平台和配置差异。4.2 Debug 和 Release 的差异处理Debug 和 Release 的差异不只是优化级别还包括宏定义、运行时库、调试信息。这些差异用生成器表达式处理最干净。target_compile_definitions(core PRIVATE $$CONFIG:Debug:DEBUG_BUILD $$CONFIG:Release:NDEBUG ) # MSVC 的运行时库选择 set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded$$CONFIG:Debug:Debug)CMAKE_MSVC_RUNTIME_LIBRARY这个变量控制 MSVC 用静态还是动态运行时库。默认是动态MultiThreadedDLL如果要做单文件发布改成静态MultiThreaded。注意 Debug 版本要加Debug后缀否则会跟 Release 的运行时库冲突。4.3 Ninja 与 Make 的选择Ninja 和 Make 都是构建工具CMake 生成它们的构建文件。Ninja 的优势是快尤其是增量构建大项目里能比 Make 快好几倍。原因是 Ninja 的设计目标就是速度它不做复杂的依赖推导依赖关系由 CMake 生成好直接告诉它。# 用 Ninja cmake -B build -G Ninja cmake --build build # 用 Make cmake -B build -G Unix Makefiles cmake --build build -j$(nproc)Ninja 的另一个好处是跨平台一致Windows、Linux、macOS 上行为一样。Make 在 Windows 上要用 MinGW 的 mingw32-make跟 Linux 的 make 有些差异。实操心得能用 Ninja 就用 Ninja。我实测过一个中型项目约 500 个源文件全量构建 Ninja 比 Make 快 30% 左右增量构建改一个头文件快 2 到 3 倍。唯一要注意的是 Ninja 默认并行度是 CPU 核心数内存小的机器可能 OOM可以用-j限制。4.4 交叉编译的工具链文件交叉编译的核心是告诉 CMake 用哪个编译器、哪个 sysroot、哪些编译选项。这些信息写在 toolchain file 里通过CMAKE_TOOLCHAIN_FILE指定。# toolchain-arm.cmake set(CMAKE_SYSTEM_NAME Linux) set(CMAKE_SYSTEM_PROCESSOR arm) set(CMAKE_C_COMPILER arm-linux-gnueabihf-gcc) set(CMAKE_CXX_COMPILER arm-linux-gnueabihf-g) set(CMAKE_SYSROOT /opt/arm-sysroot) set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) # 只在 sysroot 里找库和头文件 set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)CMAKE_FIND_ROOT_PATH_MODE_*这几个变量控制find_package、find_library等命令的搜索范围。PROGRAM设为 NEVER 表示找可执行程序比如代码生成工具时用宿主机的LIBRARY、INCLUDE、PACKAGE设为 ONLY 表示只在 sysroot 里找避免误用宿主机的库。cmake -B build-arm -DCMAKE_TOOLCHAIN_FILEcmake/toolchain-arm.cmake工具链文件的好处是工程代码完全不用改同一份 CMakeLists 既能本机编译也能交叉编译。5. 常见问题与排查技巧实录5.1 编译报错找不到头文件这是最高频的问题原因通常有三类第一类target_include_directories没写对。检查路径是相对路径还是绝对路径相对路径是相对于CMAKE_CURRENT_SOURCE_DIR还是CMAKE_CURRENT_BINARY_DIR。建议统一用${CMAKE_CURRENT_SOURCE_DIR}开头的绝对路径避免歧义。第二类依赖传递断了。A 用了 B 的头文件但 A 没有链接 B或者 B 的头文件路径是 PRIVATE 的。检查target_link_libraries和target_include_directories的 PUBLIC/PRIVATE 设置。第三类生成器表达式条件不满足。比如$BUILD_INTERFACE:...在安装后就不生效了$CONFIG:Debug在 Release 构建下不生效。用cmake --build build --verbose看实际的编译命令能直接看到-I参数里有没有你要的路径。5.2 链接报错 undefined reference链接错误比编译错误难查因为报错信息往往只给符号名不给具体位置。排查思路先确认符号属于哪个库用nm或objdump查。然后确认这个库有没有链接进来用--verbose看链接命令。如果库链接了还报错可能是链接顺序问题被依赖的库要放在后面。# 查符号在哪个库 nm -C /path/to/libxxx.a | grep symbol_name # 看详细链接命令 cmake --build build --verbose 21 | grep linkC 的符号名会被 name manglingnm输出的是修饰后的名字。加-C参数可以 demangle显示可读的名字。如果还是找不到可能是 inline 函数或者模板实例化的问题这类符号不会出现在库文件里。5.3 CMake 缓存导致的诡异问题CMake 会把配置结果缓存在CMakeCache.txt里有时候改了 CMakeLists 但行为没变就是缓存的问题。常见场景改了find_package的路径但缓存里还记着旧路径改了编译器但缓存里还是旧编译器。解决办法是删掉 build 目录重新配置。但每次都全量重建太慢可以只删CMakeCache.txt和CMakeFiles/目录保留编译产物。# 清理缓存但保留编译产物 rm -rf build/CMakeCache.txt build/CMakeFiles cmake -B build注意切换编译器比如从 GCC 换到 Clang必须清缓存否则 CMake 会用旧编译器的配置报一堆莫名其妙的错。这个坑我踩过好几次现在养成习惯换工具链就删 build 目录。5.4 常见问题速查表问题现象可能原因排查方法找不到头文件include 路径没设对--verbose看-I参数undefined reference库没链接或顺序不对nm查符号看链接命令find_package 失败路径不对或没装--debug-find看搜索路径改了 CMakeLists 没生效缓存问题删 CMakeCache.txt 重配换编译器报错缓存里是旧编译器删 build 目录重配交叉编译找不到库sysroot 没设对检查 toolchain fileNinja 构建 OOM并行度太高-j限制并行数生成器表达式不生效条件不满足看实际编译命令5.5 几个容易被忽略的细节第一个细节cmake_minimum_required会影响策略。CMake 有很多策略policy不同版本默认行为不一样。cmake_minimum_required设的版本决定了用哪套策略。设低了会用旧行为可能有警告设高了老版本 CMake 跑不了。建议设一个合理的下限比如 3.16然后在新项目里用 3.20 以上。第二个细节CMAKE_BUILD_TYPE只在单配置生成器下有效。Make 和 Ninja 是单配置生成器配置时指定CMAKE_BUILD_TYPE。Visual Studio 和 Xcode 是多配置生成器构建时用--config指定。写 CMakeLists 时要注意这个差异用生成器表达式$CONFIG:...而不是直接判断CMAKE_BUILD_TYPE。第三个细节target_link_libraries的顺序有讲究。对于静态库被依赖的库要放在依赖它的库后面。CMake 一般能自动处理但如果用了-Wl,--start-group之类的选项就要注意顺序。现代 CMake 通过 target 依赖关系自动排序基本不用手动管。第四个细节安装规则要配套。如果项目要被别人find_package使用必须写install规则和导出配置。这部分内容比较多核心是install(TARGETS ... EXPORT ...)和install(EXPORT ...)配合configure_package_config_file生成配置文件。install(TARGETS core EXPORT MyProjectTargets LIBRARY DESTINATION lib ARCHIVE DESTINATION lib RUNTIME DESTINATION bin INCLUDES DESTINATION include ) install(DIRECTORY include/ DESTINATION include) install(EXPORT MyProjectTargets FILE MyProjectTargets.cmake NAMESPACE MyProject:: DESTINATION lib/cmake/MyProject )这套规则写好后别人find_package(MyProject)就能拿到MyProject::core这个 imported target用起来跟系统库一样。6. 从 Keil 工程迁移到 CMake 的实操路径6.1 迁移的整体思路Keil 工程迁移到 CMake本质是把 Keil 的工程配置翻译成 CMake 的 target 配置。Keil 的.uvprojx文件里记录了源文件列表、头文件路径、宏定义、编译选项、链接脚本、启动文件等信息这些都要在 CMake 里重新表达。迁移不是一蹴而就的建议分三步走第一步把源文件列表和头文件路径搬过来让代码能编译通过第二步把编译选项和宏定义搬过来让行为跟 Keil 一致第三步把链接脚本和启动文件配好让固件能正常链接和运行。6.2 源文件与头文件路径的迁移Keil 工程里源文件是分组管理的每个组对应一个目录。迁移时按组划分 CMake target或者全部放进一个 target 但保持目录结构。# 从 Keil 的 Groups 翻译过来 add_executable(firmware # Group: Startup startup/startup_stm32f103.s # Group: CMSIS cmsis/system_stm32f1xx.c # Group: HAL_Driver hal/stm32f1xx_hal.c hal/stm32f1xx_hal_gpio.c # Group: User user/main.c user/app.c ) target_include_directories(firmware PRIVATE startup cmsis hal/inc user )Keil 里的头文件路径在 Options for Target - C/C - Include Paths 里逐个搬过来即可。6.3 编译选项与宏定义的迁移Keil 的 Define 对应 CMake 的target_compile_definitionsMisc Controls 对应target_compile_options。target_compile_definitions(firmware PRIVATE STM32F103xB USE_HAL_DRIVER ) target_compile_options(firmware PRIVATE -mcpucortex-m3 -mthumb -ffunction-sections -fdata-sections -Wall )注意 Keil 用的是 ARMCC 编译器CMake 这边通常用 arm-none-eabi-gcc编译选项的写法不一样。ARMCC 的--cpuCortex-M3对应 GCC 的-mcpucortex-m3这类对应关系要查编译器文档。6.4 链接脚本与启动文件的处理链接脚本通过-T选项指定启动文件作为源文件加入 target。target_link_options(firmware PRIVATE -T${CMAKE_CURRENT_SOURCE_DIR}/STM32F103C8Tx_FLASH.ld -Wl,-Map${CMAKE_BINARY_DIR}/firmware.map -Wl,--gc-sections -nostartfiles )--gc-sections配合编译时的-ffunction-sections -fdata-sections可以去掉未使用的代码减小固件体积。-nostartfiles表示不用标准启动文件用我们自己的 startup 文件。6.5 迁移后的验证方法迁移完成后要验证生成的固件跟 Keil 版本行为一致。最直接的方法是烧录到板子上跑一遍看功能是否正常。更细致的方法是对比 map 文件看代码段、数据段的大小是否接近。# 查看固件大小 arm-none-eabi-size build/firmware.elf # 输出 # text data bss dec hex filename # 12345 678 9012 22035 5613 build/firmware.elf如果 text 段比 Keil 版本大很多可能是优化级别不对或者--gc-sections没生效。如果 bss 段差异大可能是堆栈大小配置不一样。实操心得迁移 STM32 工程时最容易出问题的是中断向量表。Keil 的启动文件里向量表名字是__VectorsGCC 的启动文件里可能是g_pfnVectors链接脚本里的引用要对上。还有SystemInit函数Keil 的启动文件会调用它GCC 的启动文件可能不调用需要手动在main之前调用。这些细节不处理好程序会跑飞。7. IDE 集成与调试配置7.1 VSCode 的 CMake 集成VSCode 配合 CMake Tools 插件是现在很流行的开发方式。核心是.vscode/settings.json里的配置。{ cmake.generator: Ninja, cmake.buildDirectory: ${workspaceFolder}/build, cmake.configureArgs: [ -DCMAKE_BUILD_TYPEDebug, -DCMAKE_EXPORT_COMPILE_COMMANDSON ], cmake.buildArgs: [-j8] }CMAKE_EXPORT_COMPILE_COMMANDSON会生成compile_commands.json这个文件记录了每个源文件的完整编译命令clangd、ccls 等语言服务器靠它做代码补全和跳转。没有这个文件代码补全基本不可用。7.2 调试配置VSCode 调试 C 需要launch.json配置。关键是program指向可执行文件miDebuggerPath指向 gdb。{ version: 0.2.0, configurations: [ { name: Debug, type: cppdbg, request: launch, program: ${workspaceFolder}/build/bin/app, args: [], stopAtEntry: false, cwd: ${workspaceFolder}, environment: [], externalConsole: false, MIMode: gdb, miDebuggerPath: /usr/bin/gdb, setupCommands: [ { description: Enable pretty-printing, text: -enable-pretty-printing, ignoreFailures: true } ], preLaunchTask: cmake-build } ] }preLaunchTask指向tasks.json里的构建任务这样每次调试前会自动构建。7.3 Qt 多模块工程的顶层 CMakeListsQt 项目用 CMake 时顶层 CMakeLists 要处理 Qt 的自动 moc、uic、rcc。现代 Qt5.15 和 6.x提供了qt_add_executable、qt_add_library等命令配合CMAKE_AUTOMOC、CMAKE_AUTOUIC、CMAKE_AUTORCC使用。cmake_minimum_required(VERSION 3.20) project(QtApp VERSION 1.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) find_package(Qt6 REQUIRED COMPONENTS Core Widgets) add_subdirectory(src/core) add_subdirectory(src/gui) add_subdirectory(src/app)子模块里用qt_add_library创建库用target_link_libraries链接 Qt 模块。Qt 的 imported target 会自动带上 include 路径和编译选项不用手动设。# src/gui/CMakeLists.txt qt_add_library(gui STATIC mainwindow.cpp mainwindow.h mainwindow.ui ) target_link_libraries(gui PUBLIC Qt6::Widgets core)mainwindow.ui会被 AUTOUIC 自动处理生成ui_mainwindow.h不需要手动调 uic。7.4 OpenCV 工程的 CMake 配置OpenCV 用 CMake 编译或者用预编译包集成方式略有不同。用预编译包的话find_package(OpenCV REQUIRED)就能找到。find_package(OpenCV REQUIRED) add_executable(image_proc main.cpp) target_link_libraries(image_proc PRIVATE ${OpenCV_LIBS}) target_include_directories(image_proc PRIVATE ${OpenCV_INCLUDE_DIRS})如果 OpenCV 是自己编译的装在非标准路径配置时指定OpenCV_DIRcmake -B build -DOpenCV_DIR/opt/opencv/lib/cmake/opencv4OpenCV_DIR指向的是包含OpenCVConfig.cmake的目录不是 OpenCV 的安装根目录。这个路径搞错了find_package就找不到。注意OpenCV 4.x 的 CMake 配置文件在lib/cmake/opencv4/下不是lib/cmake/OpenCV/。这个目录名的大小写和版本号容易搞错找不到的时候用find /opt/opencv -name OpenCVConfig.cmake确认一下。8. 工程场景的扩展与演进8.1 从单模块到多模块的演进项目初期往往是一个 CMakeLists 打天下所有代码在一个 target 里。随着代码量增长要逐步拆分模块。拆分的时机是某个目录的代码开始被多个地方复用或者编译时间明显变长或者需要独立测试某个部分。拆分的第一步是把可复用的部分抽成库 target第二步是理清依赖关系第三步是把测试和示例独立出来。每拆一步都要保证能编译通过不要一次性大改。8.2 引入包管理与依赖锁定项目依赖多了之后版本管理就成了问题。FetchContent虽然能拉源码但版本靠 GIT_TAG 手动维护容易漏。可以考虑引入 Conan、vcpkg 这类包管理器它们能锁定依赖版本还能预编译二进制包加快配置速度。vcpkg 的集成方式是提供 toolchain fileCMake 配置时指定即可cmake -B build -DCMAKE_TOOLCHAIN_FILE/path/to/vcpkg/scripts/buildsystems/vcpkg.cmakevcpkg 会自动处理find_package把安装的包暴露成 imported target。缺点是首次安装包要编译比较慢但之后就有缓存了。8.3 持续集成中的 CMake 配置CI 环境里跑 CMake关键是配置要可复现、构建要快。建议用 Ninja 生成器开启 ccache 加速重复构建用CMAKE_BUILD_TYPE明确指定配置。# CI 脚本示例 cmake -B build -G Ninja \ -DCMAKE_BUILD_TYPERelease \ -DCMAKE_C_COMPILER_LAUNCHERccache \ -DCMAKE_CXX_COMPILER_LAUNCHERccache cmake --build build -j$(nproc) ctest --test-dir build --output-on-failureCMAKE_C_COMPILER_LAUNCHER和CMAKE_CXX_COMPILER_LAUNCHER指定编译器启动器ccache 会缓存编译结果第二次构建同样的代码直接命中缓存速度飞快。CI 里如果每次都是干净环境ccache 的缓存要持久化否则没效果。8.4 工程模板的沉淀每个项目都从头写 CMakeLists 太累建议沉淀一套自己的工程模板。模板里包含常用的目录结构、编译选项配置、测试框架集成、安装规则。新项目直接复制模板改改项目名就能用。模板不用太复杂覆盖 80% 的常见场景就行。特殊需求在模板基础上改。我自己的模板里固定包含顶层 CMakeLists、cmake/ 目录放工具链文件和自定义 Find 模块、src/ 和 tests/ 的标准结构、一个 INTERFACE 库管警告选项、CTest 集成。这套模板用了好几年新项目起步能省半天时间。最后分享一个我踩过的坑早期我图省事把编译选项直接写在CMAKE_CXX_FLAGS里结果 CMake 自动加的-stdc17被覆盖了编译报了一堆 C11 的错。后来改成用target_compile_options追加再也没出过这个问题。CMake 的变量和属性能用 target 属性就用 target 属性全局变量尽量少动这是现代 CMake 用法的核心原则。