CMake实战:相对路径、输出目录与预编译头的高效用法

CMake实战:相对路径、输出目录与预编译头的高效用法
写CMake的我见过很多但大多数教程都停留在“教你背命令”的层面看的时候觉得懂了一上手还是被相对路径、预编译头、Debug输出目录这些问题按在地上摩擦。这篇文章我不打算讲那些教科书里有的东西单纯把我在实际工程里和CMake反复过招后沉淀下来的套路、踩过的坑、以及一些非常规但好用的写法一次性倒给你。不管你是刚把第一行cmake_minimum_required写进CMakeLists的新手还是已经被大型项目的多目录结构折腾到头秃的熟手这篇内容都值得你花十分钟看完——我会把“CMake生成的VS工程如何正确使用相对路径”“CMake里优雅地执行bash命令”“预编译头怎么指定才能不吃亏”这几个高票问题结合真实场景拆开揉碎讲清楚。1. 先理顺思路再动手CMake真正的价值是什么很多人把CMake当成一个“自动生成Makefile的工具”这么理解虽然不算错但格局小了。CMake的核心价值在于把“构建过程”和“构建系统”解耦。你的源代码、依赖关系、编译选项、目标类型这些信息用一套CMakeLists.txt描述清楚之后它可以给你生成Visual Studio的.sln、Unix系的Makefile甚至是Xcode工程和Ninja构建文件。一套描述到处生成这才是跨平台的意义。实际项目里我见过最痛苦的场面不是代码写得烂而是接手的人打开工程发现有一堆手动维护的.sln和.vcxproj每个平台的工程文件还都不一样改一个新增源文件要同步五个工程。用CMake之后这个痛点基本消亡了——你只需要维护CMakeLists.txt剩下的交给CMake去生成。1.1 搭建基础工程时我习惯这样组织目录先说一个我比较推荐的单体应用目录结构这也是很多开源项目在用的范式MyProject/ ├── CMakeLists.txt ├── src/ # 业务源码 │ ├── CMakeLists.txt │ ├── main.cpp │ └── module/ ├── include/ # 对外暴露的头文件 │ └── MyProject/ ├── third_party/ # 第三方依赖 └── tests/ # 测试用例根目录的CMakeLists.txt只做三件事设置项目信息、指定C标准、把子目录add进来。具体的源文件收集和编译选项下放到各自的子目录CMakeLists里负责。这样做最大的好处是职责单一。根目录像是一个总指挥它不关心src里有哪些.cpp文件只负责“把src这个模块纳入构建体系”。subdirectory里面的CMakeLists才去关心源文件列表、头文件路径、链接库依赖。新加文件的时候你只需要动src那一层不会误伤到全局配置。至于add_library和add_executable怎么选我的经验法则是凡是可能被复用的逻辑一律先编成静态库STATIC最后再在最顶层组装成可执行文件。这样做不仅让链接边界更清晰也能显著加快增量编译速度——改一个库内部的文件只需要重新编那个库不需要把整个exe重新链接一遍。1.2 为什么我推荐在CMakeLists里用target_include_directories而非include_directories新手最常见的写法是在根目录写一个全局include_directories然后所有子目录都接收到这个头文件搜索路径。项目小的时候没什么感觉一旦目录多起来头文件搜索路径的传播会变得难以控制——你根本不知道某个路径是被谁引入的排查的时间比写代码都长。更好的做法是用target_include_directories把头文件路径绑定到具体的target上并且明确用PUBLIC还是PRIVATE修饰add_library(my_lib STATIC src/module/impl.cpp ) target_include_directories(my_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src )PUBLIC的意思是不仅自己编译的时候要这个路径链接我的下游target也要继承这个路径。PRIVATE只对自己生效下游不可见。这个设计哲学和C的访问控制一脉相承职责边界越清晰工程维护成本越低。注意CMAKE_CURRENT_SOURCE_DIR和CMAKE_SOURCE_DIR是两个不同的变量前者是当前CMakeLists所在的目录后者永远是根目录。在子目录里写路径时尽量用CMAKE_CURRENT_SOURCE_DIR否则一旦工程被移动或子目录被复用路径就直接废掉。2. 路径问题一网打尽相对路径和VS工程输出目录的坑热词里有个“cmake生成的vs工程使用相对路径的写法”这确实是困扰很多人的地方。生成出来的VS工程默认用的是绝对路径一旦你换了机器或者整个目录移动了位置打开工程就全是红的重新生成一遍又麻烦。2.1 从CMakeLists层面规避绝对路径依赖首先要明白CMake本身在描述源文件路径时是支持相对路径的关键看你写的是相对谁的路径。我推荐的做法是在子目录CMakeLists里源文件列表统一基于CMAKE_CURRENT_SOURCE_DIR来表达相对路径。举个例子add_executable(my_app main.cpp module/helper.cpp )这里main.cpp和module/helper.cpp看起来就是相对路径实际上CMake会认为它们是相对于当前CMakeLists所在的目录。所以只要你不在源码列表里硬编码D:/work/project/...这种绝对路径生成的VS工程也不会带绝对路径的源码引用。真正会出问题的地方是target_link_libraries里链接了外部lib还有自定义命令里用了绝对路径引用外部工具。这种场景建议用变量把外部依赖的根目录抽出来比如set(THIRD_PARTY_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../third_party CACHE PATH third party root) target_link_libraries(my_app PRIVATE ${THIRD_PARTY_DIR}/lib/foo.lib)通过CACHE PATH暴露配置项这样其他人在自己机器上如果路径不同可以cmake的时候通过-DTHIRD_PARTY_DIRxxx覆盖不需要改CMakeLists。这个习惯养成之后工程的可移植性会提升一个台阶。2.2 把VS工程输出目录里的Debug去掉“cmake输出路径去掉debug”这个热词说的是Visual Studio的多配置生成器在输出目录会默认拼上配置名导致你的exe跑到build/Debug/和build/Release/里。有时候你写脚本、做CI、或者事后去翻产物这种多级目录确实不太方便。CMake对这件事有专门的变量CMAKE_RUNTIME_OUTPUT_DIRECTORY而且可以针对不同的配置设置不同的输出路径。你可以直接在根CMakeLists里统一管理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) foreach(OUTPUTCONFIG ${CMAKE_CONFIGURATION_TYPES}) string(TOUPPER ${OUTPUTCONFIG} OUTPUTCONFIG_UPPER) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_${OUTPUTCONFIG_UPPER} ${CMAKE_BINARY_DIR}/bin) set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_${OUTPUTCONFIG_UPPER} ${CMAKE_BINARY_DIR}/lib) set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY_${OUTPUTCONFIG_UPPER} ${CMAKE_BINARY_DIR}/lib) endforeach()这里有一个很关键的细节只设置不带_CONFIG后缀的变量往往只对单配置生成器比如Makefile有效。VS这种多配置生成器在编译时会追加Debug、Release这样的子目录所以你必须同时对带_DEBUG、_RELEASE后缀的变量也赋值才能真正去掉输出路径里的Debug/Release层级。这么设置完VS工程生成出来的exe统一落在build/bin下不会再出现build/bin/Debug/app.exe和build/bin/Release/app.exe这种分叉路径。对于Qt插件路径、动态库dll的运行时搜索路径、或者你自己写的自动化部署脚本来说会省掉很多心智负担。2.3 一个容易被忽略的坑VS工程属性页里的工作目录输出目录的问题解决了还有一个我当初被坑过的地方——调试时的工作目录Working Directory。即便你把exe输出到了build/binVS默认的调试工作目录仍然是.vcxproj所在的目录也就是build那一层而不是build/bin。如果你程序里用相对路径去读配置文件、资源目录一按F5就提示文件找不到。原因就在这里。解决办法通常有两种一种是在程序里用编译期宏把源码目录或者可执行文件路径注进去另一种是在CMake里给VS调试器设置工作目录set_target_properties(my_app PROPERTIES VS_DEBUGGER_WORKING_DIRECTORY ${CMAKE_BINARY_DIR}/bin )VS_DEBUGGER_WORKING_DIRECTORY是CMake针对VS生成器的专属属性设置之后生成的.vcxproj里会写好调试会话的工作目录按F5就能直接在build/bin下跑起来。这个小细节在官方文档里很不起眼但实操中能省下大量定位问题的精力。3. 在CMake里执行bash命令用add_custom_command就对了“cmake执行bash命令”是另一个高频搜索。你可能需要在构建过程中自动生成版本头文件、拉取依赖、执行脚本转换数据、或者编译一些非C源文件。CMake本身不直接“执行shell脚本”但它提供了非常灵活的机制可以在构建的不同阶段插入自定义命令——这个机制就是add_custom_command。3.1 构建期执行脚本的正确姿势add_custom_command有两种常用形态一种挂在target上在target构建前/后执行另一种定义文件级别依赖当某个文件不存在或者比依赖旧时触发。下面这个场景是我在项目里经常用的——根据git提交信息生成版本头文件set(VERSION_HEADER ${CMAKE_CURRENT_BINARY_DIR}/generated/version.h) add_custom_command( OUTPUT ${VERSION_HEADER} COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/scripts/gen_version.sh ${VERSION_HEADER} DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/scripts/gen_version.sh COMMENT Generating version header ) add_executable(my_app main.cpp ${VERSION_HEADER} )这里有几个需要解释的地方第一add_custom_command里的命令是在构建阶段执行的不是在cmake配置阶段。cmake配置完工程之后你按一次构建它才会去跑gen_version.sh。第二把${VERSION_HEADER}加进add_executable的源文件列表不是要编译这个头文件而是为了让CMake知道这个target依赖这个文件的生成。这样CMake会检查如果version.h不存在或者gen_version.sh比它新就先生成它再去编译其他源文件。这个依赖关系是整个机制的精髓。第三DEPENDS里写的是输入依赖。如果你不在DEPENDS里列出脚本本身那脚本改了之后CMake也不会自动重新生成version.h必须删掉整个build目录或者手动touch一下文件才行。这种坑极其隐蔽建议一定把相关的脚本、输入文件都挂在DEPENDS。3.2 跨平台脚本要怎么写才不会被Windows坑到这个点我必须单独拿出来说。很多人在Windows上用CMake bash命令明明命令在Git Bash里跑得好好的放到CMake里就各种报错。核心原因是CMake在VS生成器下执行命令时并不是在Git Bash环境里它用cmd.exe去拉起进程bash和sh不一定在PATH里、|这类shell语法也可能被解释错。所以跨平台执行脚本的安全姿势是尽量把逻辑写进独立的脚本文件然后把脚本作为命令传给解释器if(WIN32) set(SHELL_EXECUTABLE C:/Program Files/Git/bin/bash.exe) else() set(SHELL_EXECUTABLE bash) endif() add_custom_command( OUTPUT ${VERSION_HEADER} COMMAND ${SHELL_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/scripts/gen_version.sh ${VERSION_HEADER} DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/scripts/gen_version.sh )通过定义SHELL_EXECUTABLE变量把解释器的路径单独抽出来不同平台给不同的值避免在每个add_custom_command里重复写死。另外脚本内部建议设置set -e这样某个命令失败时脚本能立即以非零状态退出CMake会感知到并把整个构建判定为失败不会出现“脚本没跑成功但构建继续”的脏状态。3.3 事件级的钩子构建前和构建后干点私活add_custom_command还有一个PRE_BUILD、PRE_LINK、POST_BUILD的形态挂在target级别适合做一些“在编译前把资源拷贝过来”“在链接后把dll复制到输出目录”这类事情。比如动态库编译完成后希望把dll自动拷贝到exe旁边避免每次手动复制add_custom_command(TARGET my_app POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different $TARGET_FILE:my_dll_target $TARGET_FILE_DIR:my_app COMMENT Copying dll to exe directory )这里用的${CMAKE_COMMAND} -E是CMake自己内置的跨平台文件操作命令copy_if_different只有在文件内容不同时才覆盖能减少不必要的磁盘写入。$TARGET_FILE:...和$TARGET_FILE_DIR:...是生成器表达式会在生成时被替换成实际的目标文件路径和所在目录。这套组合拳在Windows和Linux上都能工作不会因为操作系统差异而翻车。4. 预编译头的正确打开方式预编译头PCH是C项目里提高编译速度的经典手段但CMake里对它的一等公民支持是相对晚才加入的。很多老教程还在让你手动配置/Yu、/Fp这种编译器选项现在完全不用这么麻烦了。从CMake 3.16开始官方提供了target_precompile_headers命令简直是我这种懒人的福音。4.1 target_precompile_headers的语法和适用场景使用方法非常直白target_precompile_headers(my_app PRIVATE vector string iostream my_project/config.h )PRIVATE表示这个预编译头只对my_app自己生效不会传递给链接它的下游target。如果你的多个target都要用到同一批公共头文件可以单独写一个interface库add_library(common_headers INTERFACE) target_precompile_headers(common_headers INTERFACE vector string iostream ) target_link_libraries(my_app PRIVATE common_headers) target_link_libraries(my_tests PRIVATE common_headers)把预编译头集中放在一个INTERFACE库里统一管理是最符合工程化的做法。新来的同事只需要关注“我的模块要不要link common_headers”完全不需要关心PCH的具体内容。4.2 预编译头踩坑实录哪些头文件不能放进去我必须提醒一句预编译头不是越多越好。放进去的头文件必须具备两个特性一是足够稳定不会频繁修改二是本身编译成本高值得缓存在PCH里。有一类头文件绝对不要放进PCH——那些包含宏定义的、或者可能因为不同target而改变内容的头文件。比如config.h这种通过编译选项定义宏的如果目标A用/DUSE_FEATURE_X1编译目标B不用而PCH被共享了就会出现极其诡异的“我明明没定义这个宏代码里却生效了”的问题。排查这种问题的时间足够你重编十次工程。另外PCH的内存消耗相当可观。一个大型项目的PCH文件动辄几百MB甚至上GB如果你的开发机只有16G内存建议不要把整个STL和一堆第三方库的头文件全塞进去。我一般在PCH里放STL容器、string、iostream、memory这类高频且稳定的东西第三方重量级库比如OpenCV、boost的一部分视情况单独处理避免PCH文件过大反而拖慢构建。注意用target_precompile_headers时MSVC生成器会自动在CMake生成的工程里勾选“使用C预编译头”但你是不能在源码里再手动#include pch.h的CMake会通过编译器的/FI强制注入。如果你在源文件里重复包含了预编译头文件部分编译器会给出警告甚至报错。这个行为比较隐蔽我在之前项目里确实遇到过同事“好心”地在每个.cpp顶部补了一行#include pch.h结果VS直接报警告删掉之后世界才安静下来。4.3 处理第三方预编译头的特殊姿势如果你在热词里看到“cmake 指定precompiledheaderfile”那可能是在说更老的用法——把工程自带的stdafx.h或者pch.h直接作为预编译头来指定。做法其实是一样的target_precompile_headers(my_app PRIVATE pch.h )但如果你的pch.h里并没有把所有公共头文件集中#include只是把预编译头本身当作一个“壳”那这个写法就失去了意义。PCH的核心价值在于仅编译一次把它依赖的所有头文件的AST抽象语法树缓存下来。后续每个.cpp文件编译时都直接复用这个PCH缓存相当于把所有公共头文件的解析工作一次性做完了。所以正确的配合方式是pch.h里安心地#include那些稳定的、公用的头文件然后在target_precompile_headers里引用这个pch.h。这样CMake在编译时先编译pch.h生成PCH缓存接下来的所有源文件编译都会自动带上这个缓存。5. 用CMake生成VS工程后这几个调试技巧能救命很多人的认知止步于“CMake能生成VS工程”但生成完之后的日常开发调试其实也有不少操作细节。这里把我自己的习惯整理一下简单但非常实用。5.1 切换构建配置的正确姿势用CMake生成VS工程之后不要直接在VS界面里切换Debug/Release然后傻等。VS的配置切换本身没问题但更大的风险在于CMakeCache.txt中记录的CMake变量并不会因为你切换了VS配置而变化。比如你想在Release模式下开启特定宏、在Debug下关闭优化这种“配置相关”的需求不应该用if(CMAKE_BUILD_TYPE)去判断而要用生成器表达式target_compile_definitions(my_app PRIVATE $$CONFIG:Debug:DEBUG_ONLY_MACRO1 $$CONFIG:Release:NDEBUG )$$CONFIG:Debug:...这种生成器表达式会在每个配置的编译命令中动态判断只有当前配置满足条件时才会展开成对应的宏定义。这样VS里切换配置时编译选项会跟着变而不是CMakeCache里那套固定不变的值。还有一件事修改CMakeLists.txt之后要记得让VS重新生成工程。VS在打开CMake工程时一般会自动检测到变更并重新生成但偶尔也会抽风不触发。强制重新生成的方式是菜单里的“项目”“重新生成缓存/全部重新生成”或者命令行下cmake --build .。否则你会看到改完CMakeLists毫无反应或者出现“无法找到先前编译的头文件因为工程配置已更改”这类报错。5.2 预处理文件和单文件编译的排查思路调试构建问题的时候我一般会先问自己一个问题编译器到底看到了什么有时候代码在IDE里看一切正常编译却报错那十有八九是头文件搜索顺序、宏定义或者预处理分支出了问题。CMake给了你一个很方便的排查工具——cmake --build配合--verbose参数。比如cmake --build build --config Debug --verbose这样可以把每条编译命令的完整参数打出来包括所有-I头文件路径、-D宏定义、优化选项等。看到真实命令之后很多“玄学”编译问题都变得有迹可循了。如果你还想进一步看某个源文件的预处理输出可以在CMakeLists里加一行set_target_properties(my_app PROPERTIES COMPILE_OPTIONS /EP # 仅限MSVC输出预处理后的文件到标准输出 )当然只是临时排查用查完就删别留在工程里当成常规配置。5.3 自定义CMake目标把一键部署做成标配工程到后期构建和管理自动化变得越来越重要。我在团队的CMake工程里定义了很多自定义target这些target不产生编译产物只用来编排各种任务add_custom_target(deploy COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_CURRENT_SOURCE_DIR}/assets $TARGET_FILE_DIR:my_app/assets COMMENT Deploying assets ) add_dependencies(deploy my_app)add_custom_target和add_custom_command的区别在于前者创建的是target你可以直接在VS的资源管理器里看到它也可以右键选择生成。通过add_dependencies把deploy和my_app关联起来之后构建deploy时会自动先构建my_app然后拷贝资源。这种方式比每次手动复制目录要规范得多新同事上手也几乎零学习成本。如果想让depoly在每次构建时自动跑可以把ALL关键字加上add_custom_target(deploy ALL COMMAND ... )但我不建议无脑加ALL因为资源拷贝有时候并不需要每次都执行尤其是资源体积比较大的时候每次构建全量拷贝会拖慢整个周期。按需触发的add_custom_target更符合我的使用习惯。6. 关于CMake下载安装与版本选择我最后聊几句热词里还有“cmake下载安装”这个我相信绝大多数人都会但我还是想补一个容易忽略的点CMake自身的版本策略。新特性的引入速度远比编译器慢但个别功能也有最低版本门槛。比如target_precompile_headers是3.16才有的FILE_SET头文件组织方式是3.23引入的。如果一个工程在本地正常到了CI机器上报“未知的CMake命令”先别急着怀疑代码查一下那台机器上的cmake版本。安装本身一般就是官网下安装包双击、或者包管理器一条命令的事。Linux上用apt install cmake或yum install cmakemacOS上用brew install cmakeWindows上除了官方安装包还可以用winget install cmake。装完之后验证一下cmake --version如果你需要同时维护多个版本的CMake环境可以用CMake官方的pip安装方式或者直接下免安装版本解压到指定目录然后用环境变量切换。日常维护多个项目时我会在项目文档里明确标注“please use cmake 3.24”并把CMakeLists.txt里第一行的cmake_minimum_required(VERSION 3.24)写死。这个最低版本声明不是写给CMake看的是写给所有协作者看的。多说一嘴关于“cmake教程”这个问题。入门的时候看官方文档可能会觉得枯燥我的建议是直接找一个成熟的开源项目比如你平时用的那些C库把它的CMakeLists.txt通读一遍。读别人的构建脚本是学习CMake最快的方式比刷任何教程都有效。遇到不认识的函数再去查官方文档记忆会深刻很多。回到最开始的问题CMake到底是工具还是负担我的答案很明确——当你只在一个平台、一个编译器、一个小目录下写代码的时候CMake可能确实是一种负担因为Makefile或者VS的工程文件来得更直接。但一旦代码需要跨平台、需要多人协作、需要接入CI、需要复用模块CMake前期多花的那一点点配置时间会在后面以指数级回报给你。用CMake管理构建本质上是把你的构建流程变成“数据”而数据是可以被版本控制、被审查、被复用、被自动化和被跨平台复现的。这才是它真正值钱的地方。下一次你再看到有人用一长串手写命令编译C项目可以把这篇转给他——也许能帮他省掉一个加班的夜晚。

最新新闻

日新闻

周新闻

月新闻