
写在前头我正在整理一份完整CMake教程。前阵子有朋友跟我抱怨说代码在自己电脑上跑得好好的换到Linux服务器上编译直接挂掉一查头文件路径全写死成本地目录格式了。我问他为什么不用CMake他的回答很有意思那玩意儿太复杂我写了三年Makefile都没弄明白。这话我听过不止一遍。但说实话CMake根本不需要当一门编程语言去啃。它更像一个资产管理工具帮你把编译哪些文件、用什么参数、依赖什么东西、装到哪里去这些事说清楚剩下的事它自动干。这篇文章我会用模拟项目X一个叫imgproc的小图像处理库从单文件到可安装发布版的完整过程把CMake里真正常用、真正容易踩坑的知识点挨个过一遍。适合两类人C/C项目刚起步、觉得Makefile难以维护的新手以及已经会用CMakeLists.txt、但一直靠复制粘贴改改、不太理解背后逻辑的进阶用户。1. 为什么是CMakeMakefile解决不了的那摊子事先聊动机。很多人写C/C程序最初的构建方式就是一条gcc命令后来文件多了就上Makefile。但Makefile有个天生缺陷它描述的是一条条具体的构建命令而这些命令依赖具体的操作系统、编译器、路径风格。1.1 手写构建脚本的死穴同样是编译一个静态库Linux下ar打出来的库叫libxxx.aWindows下lib.exe打出来的叫xxx.lib同样的头文件搜索路径Linux用冒号分隔Windows用分号编译选项更是完全两套GCC用-O2 -WallMSVC得用/O2 /W3。如果一份代码要跨平台交付你手写Makefile就得同时维护好几套规则再加一层if层判断系统。我见过不少项目就是这么缝出来的一套Makefile里堆满ifeq ($(OS),Windows_NT)另一套.bat批处理文件负责Windows下的构建。每次改依赖、加目录、调参数两边都要同步改稍不留神就漏一处然后就是长达半天的心智损耗排查。1.2 CMake的核心思想描述目标而不是描述命令CMake换了个思路。你的CMakeLists.txt不写怎么编译只写这个项目有哪些目标(target)每个目标包含哪些源文件、需要什么头文件路径、链接什么库。至于编译器怎么调用、静态库叫什么后缀、路径用什么分隔符CMake会在配置阶段根据当前平台的默认规则自己搞定。这就是它自称为元构建系统的原因CMake本身不直接编译代码它根据一份抽象描述生成当前平台最合适的构建文件Linux下生成MakefileWindows下可以生成Visual Studio工程也可以生成Ninja文件。1.3 两阶段执行模型先画图纸再施工理解CMake的关键是搞清楚它的两阶段执行模型。配置阶段Configure是CMake读取CMakeLists.txt、执行各种逻辑、探测环境的过程最后生成一套构建文件。构建阶段Build是你真正调用编译器、链接器出产物的过程。这两件事分离带来一个好处同一个项目可以保持多套构建目录一套Debug、一套Release、一套带静态分析工具互不干扰随时切换。这也是为什么我们习惯用cmake -S . -B build单独指定目录让源码树保持干净。形象点说CMake是画图纸的设计师Make/Ninja是拿着图纸施工的队伍。理解了这个模型后面看到变量缓存、生成器表达式这些概念就不会发怵它们都是在配置阶段这一层做文章。2. 最小CMake项目从一行打印到双目录结构好理论先放一边直接上手。模拟项目X最初只有一个main.cpp功能是读一张图片做灰度化。最基础的CMakeLists.txt长这样cmake_minimum_required(VERSION 3.16) project(imgproc) add_executable(imgproc_demo main.cpp)这就是起步所需的最小集合。cmake_minimum_required不只是检查版本号它还设置CMake的策略级别决定哪些新版本的行为变更会生效这会影响后面很多语法细节。project命令有两件事要做设置项目名以及探测当前环境支持的C/C编译器。add_executable是主角命令声明一个可执行目标。目标是CMake里的头等公民后续的include路径、编译选项、链接库全部围绕目标展开。2.1 构建流程为什么强烈建议用 -B 参数配置和构建的命令长这样cmake -S . -B build cmake --build build-S .指定源码目录-B build指定构建目录。这里我多说一句很多旧教程教你mkdir build cd build cmake ..效果差不多但-S -B写法更直白尤其当源码目录和构建目录不在相邻位置时一眼就能看懂谁是谁。构建产物全部落在build里源码目录只有一份干净的CMakeLists.txt和源码就算你同时开一个build-debug和一个build-release也不会互相污染。2.2 遇到的第一批问题头文件路径与源文件膨胀很快main.cpp就膨胀了我把图像格式解析拆到img_format.cpp把滤波算法拆到filter.cpp。这时候CMakeLists变成cmake_minimum_required(VERSION 3.16) project(imgproc) add_executable(imgproc_demo main.cpp img_format.cpp filter.cpp ) target_include_directories(imgproc_demo PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)target_include_directories这条命令值得重点讲。它把某个头文件搜索路径作用到指定目标上PRIVATE表示这个路径只对imgproc_demo自己有效。后续如果别的目标也要用这些头文件得单独声明。2.3 拆库add_library、target_link_libraries与依赖传递代码量继续上涨main.cpp引用的函数越来越多编译单元之间的依赖也乱了。这时候一个想法很自然把图像格式解析和滤波算法拆成一个独立的库让main.cpp只做入口逻辑顺便以后写单元测试时不用重复编译全部源码。于是拆成一层静态库cmake_minimum_required(VERSION 3.16) project(imgproc) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(imgproc_core STATIC img_format.cpp filter.cpp ) add_executable(imgproc_demo main.cpp) target_include_directories(imgproc_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src) target_link_libraries(imgproc_demo PRIVATE imgproc_core)这里库的类型有三种写法STATIC静态库SHARED动态库INTERFACE头文件库。有接口(INTERFACE)的库下面链接时会用到。target_link_libraries(imgproc_demo PRIVATE imgproc_core)既有字面意思也有隐含的传递逻辑。这个传递逻辑是多数初学者最容易懵的地方。我给个生活化解释你家的书房imgproc_core里有一些头文件对外暴露的接口你儿子imgproc_demo要借用书房的资料你得告诉儿子这些接口在哪儿。问题是儿子以后要不要把书房的位置转告给他的朋友另一个目标PUBLIC表示我用了而且我的使用方式导致我的依赖方也得知道这些头文件路径PRIVATE表示我只是内部实现用了外部不需要知道。翻译到代码里就是PUBLIC会把自己的include目录、编译定义、链接库一并传给下游目标PRIVATE只留在当前目标自己这层。3. 变量、选项与生成器表达式配置阶段的分支逻辑当项目规模再上来你会发现CMakeLists.txt开始写成程序的样子要定义变量、要根据条件走不同分支、要根据不同构建类型施加不同参数。这一章我们把CMake的编程能力说透。3.1 set变量与缓存变量set(VAR value)定义的是普通变量只在当前CMakeLists.txt及其子目录生效。还有一种缓存变量通过set(VAR value CACHE STRING 说明)或option()定义它会被写进构建目录下的CMakeCache.txt下次配置时依然保留也可以在cmake -D命令行里覆盖它的默认值。举个实际例子我给模拟项目X加一个开关是否编译测试代码。这个开关不能是普通变量得让用户构建时能改、能覆盖。option(BUILD_TESTING Build unit tests OFF) if(BUILD_TESTING) enable_testing() add_subdirectory(tests) endif()用option而不是set的好处是CMake缓存里会自动生成一个可勾选的开关图形界面和命令行都方便调整而且默认值只在第一次配置时生效用户之后通过-DBUILD_TESTINGON改过会一直记住新值。3.2 if判断里的门道if()判断语句看起来和所有语言一样但有几个坑。比如if(BUILD_TESTING) # 变量为ON/OFF/TRUE/FALSE时都能解析 if(DEFINED SOME_VAR) # 判断变量是否已定义实际工作中我建议用带上引号的写法避免变量值未定义时报错或者被当作路径解析if(${BUILD_TESTING})3.3 生成器表达式配置阶段最后的灵活手段这是CMake里最抽象的概念也最值钱。普通变量在配置阶段就被固定成字符串了而生成器表达式(Generator Expressions)可以推迟到构建阶段才求值所以它能拿到每个具体构建目标、具体配置类型的信息。最常用的几个生成器表达式表达式含义$TARGET_EXISTS:name目标是否存在$TARGET_NAME_IF_EXISTS:name目标存在则用目标名否则返回空$CONFIG当前构建配置Debug/Release等$BOOL:...把值转成ON/OFF$BUILD_INTERFACE:...仅在使用构建目录时生效$INSTALL_INTERFACE:...仅在安装后被外部项目使用时生效我给个具体用法。模拟项目X要支持一个可选特性图像去雾。这个功能依赖OpenCV但不是每个人都有OpenCV环境于是按需编译find_package(OpenCV QUIET) if(OpenCV_FOUND) target_sources(imgproc_core PRIVATE dehaze.cpp) target_compile_definitions(imgproc_core PRIVATE WITH_DEHAZE1) target_link_libraries(imgproc_core PRIVATE ${OpenCV_LIBS}) endif()还有更常见的场景是编译选项按配置区别target_compile_options(imgproc_core PRIVATE $$CONFIG:Debug:-g3;-O0 $$CONFIG:Release:-O2;-DNDEBUG )因为target_compile_options本身就允许带生成器表达式所以Debug和Release可以一口气写好不用if套if。这种写法在Makefile时代要维护两三套规则在CMake里就一行。3.4 为什么优先使用target_*系列命令看多了各种代码你会发现CMake有两条命令路线目录级命令如include_directories、add_compile_options以及目标级命令如target_include_directories、target_compile_options。我强烈建议新项目一律用target_系列。原因很简单作用域。目录级命令一旦执行影响的是整个目录及其子目录的所有目标后面对某个目标单独调整时很难精准覆盖。目标级命令则精确控制到单个目标依赖传递关系也清晰。小项目感觉不明显一旦目标一多全局影响就是排查问题的黑洞。4. 第三方依赖find_package与FetchContent的分工C/C项目很少是不依赖任何第三方库的。模拟项目X开始需要一个JSON解析库需要接SQLite。这一章讲依赖管理的两种主要手段。4.1 find_package寻找系统里已经装好的库find_package有很多人无脑用但理解它分两种模式很有必要。Module模式CMake自带的FindXXX.cmake模块负责在系统里找库找到后设置XXX_FOUND、XXX_INCLUDE_DIRS、XXX_LIBRARIES变量。Config模式库本身安装时提供了xxx-config.cmake文件里面定义好了导入目标imported targetfind_package执行后你可以直接用target_link_libraries(app PRIVATE xxx::xxx)这种带命名空间的方法链接。后者是现在的趋势因为导入目标把头文件路径、链接库、编译选项、甚至依赖的其他库都打包好了使用方不需要自己拼路径。find_package(spdlog CONFIG REQUIRED) target_link_libraries(imgproc_core PRIVATE spdlog::spdlog)技巧怎么查一个库是否提供Config模式去系统或安装目录看通常会有lib/cmake/xxx/xxxConfig.cmake或类似路径。如果没有就得退到Module模式或者用下面说的FetchContent。4.2 FetchContent构建时直接拉源码如果你需要的是某个特定版本的库而且希望整个项目可复现、不依赖系统环境FetchContent是更省心的方案。它会在配置阶段把源码下载到构建目录里然后通过add_subdirectory把库的目标直接加进当前构建。include(FetchContent) FetchContent_Declare( fmt GIT_REPOSITORY https://example.com/fmt/archive.tar.gz GIT_TAG v9.1.0 URL_HASH SHA256按实际填写 ) FetchContent_MakeAvailable(fmt)这句话执行完fmt库的目标会自动加入直接用target_link_libraries(imgproc_core PRIVATE fmt::fmt)链接即可。我在实际使用中一般指定下载源码包或Git tag并锁定hash避免某些镜像不稳定或上游仓库变动导致构建突然失败。4.3 两种方式的选择思路场景选哪个系统管理员已装好特定版本库希望直接用find_package项目要完全可复现不依赖他人环境FetchContent依赖库较大编译一次耗时较长优先find_package减少无谓编译依赖库较小、单个头文件为主FetchContent更简单我的习惯是外部依赖尽量优先find_package当某个库在常见环境里都没装、或者版本不满足要求时再退回FetchContent。两个方案可以共存用find_package探测探测不到就用FetchContent兜底。5. 构建类型与编译选项Debug/Release的完整控制默认情况下CMake生成的构建系统可能是单配置的也可能是多配置的。这个差异直接决定你怎么指定构建类型很多人在这里踩坑。5.1 单配置生成器与多配置生成器Unix下的Makefile是单配置的。你执行cmake -B build -DCMAKE_BUILD_TYPEDebug配置阶段就决定好了Debug之后cmake --build build编译出来就是带调试信息的版本。想变Release得重新开一个构建目录或者改配置再重新生成。Visual Studio和Xcode是多配置的CMake生成时就同时包含Debug、Release、RelWithDebInfo等多个配置。你不需要在配置阶段指定而是在构建阶段用--config选cmake --build build --config Release生成器表达式$CONFIG在多配置生成器下才有意义因为它能拿到每个具体的构建配置。如果你在单配置生成器下使用它会在配置阶段就展开成CMAKE_BUILD_TYPE的值。5.2 Debug和Release到底差在哪很多新手会问Debug和Release的区别是CMake自己搞定的吗答案是CMake只会挑选合适的默认编译选项但如果你有特定的优化需求得自己加。以GCC为例CMake默认的Debug会带-gRelease带-O3 -DNDEBUG。-DNDEBUG这个宏会把assert()全部禁用掉这是Release更快的原因之一也是很多依赖断言检查的程序在Release下看起来正常但行为变了的根源。在MSVC下Debug默认关闭优化并开启调试符号Release默认/O2。两者的CRT版本还不同Debug对应多线程调试DLLRelease对应多线程DLL混用会直接链接报错。5.3 编译选项的精准投放基于上面的分析我一般会在项目里这样配置target_compile_options(imgproc_core PRIVATE $$CONFIG:Debug:-fno-omit-frame-pointer;-fsanitizeaddress $$CONFIG:Release:-O3;-marchnative ) target_link_options(imgproc_core PRIVATE $$CONFIG:Debug:-fsanitizeaddress )调试时开AddressSanitizer帮助抓内存问题发布时开激进优化。target_link_options也要跟着加因为像sanitize这种选项必须同时影响编译阶段和链接阶段。这里有个容易被忽略的细节如果你给某个目标开启了-marchnative那这个库编译出的二进制就只能在本机或相同指令集的机器上跑。分发到别的机器前要重新评估。真实项目里我倾向于不加-marchnative改用-O2兼顾通用与性能。6. 安装与导出让构建产物能被别人一键使用构建好的库如果不安装就只能在自己构建树里用。想让其他项目通过find_package(imgproc)的方式使用需要给模拟项目X补上安装规则和包配置文件。6.1 install命令的常规姿势include(GNUInstallDirs) install(TARGETS imgproc_core EXPORT imgprocTargets LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} PUBLIC_HEADER DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/imgproc )GNUInstallDirs会按GNU的约定帮我们把目录设好lib放库include放头文件bin放可执行文件。这样安装出来的布局符合大多数系统的惯例后面接包管理工具也方便。PUBLIC_HEADER DESTINATION是安装公开头文件的一种简写前提是你在add_library或target_sources里把这些头文件列出来了。如果头文件较多、目录结构较复杂也可以用install(DIRECTORY src/ DESTINATION include/imgproc FILES_MATCHING PATTERN *.h)打包安装。6.2 导出包配置文件光安装库和头文件还不够还需要告诉别的项目我提供了什么目标、依赖什么库、版本是多少。这一块CMake提供了辅助模块include(CMakePackageConfigHelpers) install(EXPORT imgprocTargets FILE imgprocTargets.cmake NAMESPACE imgproc:: DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/imgproc ) configure_package_config_file( cmake/imgprocConfig.cmake.in ${CMAKE_CURRENT_BINARY_DIR}/imgprocConfig.cmake INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/imgproc ) write_basic_package_version_file( ${CMAKE_CURRENT_BINARY_DIR}/imgprocConfigVersion.cmake VERSION ${PROJECT_VERSION} COMPATIBILITY SameMajorVersion ) install(FILES ${CMAKE_CURRENT_BINARY_DIR}/imgprocConfig.cmake ${CMAKE_CURRENT_BINARY_DIR}/imgprocConfigVersion.cmake DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/imgproc )对应的imgprocConfig.cmake.in模板通常长这样PACKAGE_INIT include(${CMAKE_CURRENT_LIST_DIR}/imgprocTargets.cmake)PACKAGE_INIT是configure_package_config_file处理的一段占位符它提供一些宏帮你在安装目录和构建目录之间做路径切换。完成后其他项目只需find_package(imgproc CONFIG REQUIRED) target_link_libraries(my_app PRIVATE imgproc::imgproc_core)imgproc::imgproc_core的命名来自包名NAMESPACE目标名这是CMake社区现在推荐的风格能有效避免不同库之间的目标重名冲突。6.3 构建树导出与安装导出的区别export(EXPORT ...)和install(EXPORT ...)都能导出目标但用处完全不同。export导出的是当前构建树里的目标只在这台机器上、这个构建目录里有效主要用于同一个工程内的子项目互连。install(EXPORT)则生成安装树里的配置文件跟着库一起部署到系统里这是面向其他项目的接口。实际交付外部库时我几乎只用install导出。如果只是内部多个可执行文件共享代码是不需要走安装这一步的直接add_subdirectory加target_link_libraries就够了。7. 避坑清单这些年踩过的CMake坑CMake语法不难坑都在细节里。这一章集中记录我在实际项目里反复踩过的几类问题很多是从同事和社区里学来的教训。7.1 不要用CMAKE_SOURCE_DIR判断当前项目路径CMAKE_SOURCE_DIR指向最顶层源码目录PROJECT_SOURCE_DIR指向最近一次调用project()的目录。如果你的主项目通过add_subdirectory引入了一大堆别的项目这些宏的取值会不一样。# 在子项目里用 ${CMAKE_SOURCE_DIR} 时指向的是主项目根目录不是当前子项目 target_include_directories(imgproc_core PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)正确姿势是${CMAKE_CURRENT_SOURCE_DIR}它永远表示当前CMakeLists.txt所在的目录。每当我看到${CMAKE_SOURCE_DIR}/src这种写法第一反应就是查这个变量用在了哪个CMakeLists里。7.2 file(GLOB)收集源文件的隐患file(GLOB sources src/*.cpp)会自动收集目录下的源文件用起来很爽但有个隐蔽问题CMake不会自动追踪新加入的文件。你往目录里加一个cpp如果配置阶段没重新跑构建系统就不会把它编进去。新版本可以用file(GLOB sources CONFIGURE_DEPENDS src/*.cpp)让CMake在每次构建前重新检查文件变化代价是一些构建系统的额外开销。但我仍然建议大项目手动列出源文件或者至少把这种写法局限在非常稳定的目录里。手动列出来的另一个好处是代码审查时能直观看到新增了哪些编译单元。7.3 CMakeCache.txt请不要手工改配置阶段生成的所有缓存变量都存在CMakeCache.txt里。有些同学排查问题时喜欢直接编辑这个文件改完当时有效下次配置又被覆盖而且格式稍有差池整个配置就坏了。我的建议是改缓存值一律用命令行cmake -S . -B build -DCMAKE_BUILD_TYPERelease如果你发现改了之后没生效先把build目录删掉重新配置。构建系统损坏时的经典操作就是删掉构建目录再重新来这比任何修复都干净。7.4 add_subdirectory与FetchContent的二进制冲突当一个库既可能被add_subdirectory引入又可能被FetchContent引入或者同一个库在项目的不同层级被多次加入时会碰到目标重名最常见的报错是add_library cannot create target ... because another target with the same name already exists。解决办法是先用find_package探测探测不到再用FetchContent或者给子项目加上统一的前缀路径。这里有个很实用的习惯在fetch新依赖时指定EXCLUDE_FROM_ALL避免它的所有目标都默认参与全量编译否则构建时间会失控。7.5 跨平台编译选项的写法差异给GCC写的编译选项MSVC不认识反之亦然。所以跨平台项目里的编译选项要分平台给比较稳的写法是if(MSVC) target_compile_options(imgproc_core PRIVATE /W4 /permissive-) else() target_compile_options(imgproc_core PRIVATE -Wall -Wextra -Wpedantic) endif()MSVC变量只在Windows MSVC编译器时定义用它可以干净地把两套参数分开。如果你不知道某个选项是否兼容最稳妥的办法是看库的开源项目怎么处理或者用check_cxx_compiler_flag先做编译器能力探测。7.6 C标准设置引用全局变量不如目标级设置set(CMAKE_CXX_STANDARD 17)是全局设置对之后创建的所有目标生效但也容易影响通过add_subdirectory引入的其他子项目。推荐做法是把标准设在目标级别target_compile_features(imgproc_core PUBLIC cxx_std_17)cxx_std_17是CMake内置的特性名设置后如果编译器不支持C17配置阶段就直接报错不会等到编译到某个语法时才发现。7.7 调试CMake脚本最实用的工具最后分享一个实战技巧message()是调试CMake时的首选武器。message(STATUS imgproc_core include dirs: ${CMAKE_CURRENT_SOURCE_DIR}/src) message(WARNING This branch is deprecated, consider refactoring) message(FATAL_ERROR Stop now, this config is invalid)FATAL_ERROR会在配置阶段直接掐断输出红色信息非常适合用来做前置条件校验。我经常在大型项目的配置逻辑里加一段if(NOT DEFINED REQUIRED_FEATURE_X) message(FATAL_ERROR Required feature X is missing, set -DREQUIRED_FEATURE_XON) endif()另外cmake --build build --verbose可以打印出实际执行的编译命令排查为什么参数没生效时很有用。如果想追某条变量是怎么来的cmake --trace系列能打印配置阶段每一行的执行痕迹能把你从变量谜题里救出来。写在最后我在实际用CMake帮模拟项目X完成所有这些重构之后最大的感受是CMake真正的主战场不是语法集锦而是构建系统的维护成本。把target抽象用好、把传递依赖理清、把配置阶段和构建阶段分清楚大部分项目场景都能应对。最后再分享一个小建议不要沉迷于把CMakeLists.txt写成万能模板。很多项目需要的东西就是那么几条命令保持简单直接让人能看懂比炫技重要得多。等真有需求了再去查文档按需加查一次用一次慢慢就会形成你自己的套路。