CMake 工程里很多问题到了最后都会指向同一个指令include。我自己的一个跨平台项目构建脚本维护了快两年中间换过三波人每次有人报“编译选项不生效”“变量没传过来”“明明加了配置却像没加”这类诡异问题我排查到最后十有八九是 include 的位置、路径或者作用域出了问题。说实话中文社区把这个指令讲透的文章非常少大多数人只是把它当作 C/C 里的 #include 在用以为就是“把别人的代码搬过来”实际上 CMake 的 include 语义要微妙得多。这篇就专门拆一拆这个指令把我踩过的坑、验证过的用法、排查经验都写出来适合已经能写基本 CMakeLists.txt、但开始碰多目录项目、想封装自己的 .cmake 模块的人看。1. include 指令的核心语义它并不只是“文本粘贴”1.1 文件被执行在当前位置而不是创建新作用域先建立一个最关键的认知CMake 里的 include 不是文本粘贴。C 语言的 #include 是预处理阶段把文件内容原封不动插入进来然后交给编译器统一处理但 CMake 本身是一个解释器include 的意思是“现在立刻解释执行这个文件里的所有命令”。这个区别带来的第一个影响是作用域。include 不会创建新的作用域被包含的文件里的命令和你写在调用位置的命令处于同一个作用域层级。也就是说如果被包含文件里写了一条set(MY_FLAG ON)执行完 include 之后调用者这边就真的有了 MY_FLAG 变量值是 ON。这和 add_subdirectory 的隔离语义完全相反也恰恰是很多人搞不清楚的点。如果文件内部只是定义了函数和宏那还好函数和宏自带局部作用域函数里怎么 set 都不会污染外部但如果那是一个“裸执行”的脚本文件顶层直接写了变量赋值、调用了命令那这些操作会直接作用到当前作用域。我见过不少团队把“工具函数库”和“初始化脚本”混在一起放结果 include 一个封装函数时还把一堆无关变量带进了当前作用域后面的判断逻辑莫名其妙被影响。所以我的习惯是真正会被 include 的 .cmake 文件要么全部内容都是 function/macro 定义要么就把它当成一块“会直接执行的配置代码”来管理不要两头混着写。1.2 与 add_subdirectory 的差异选错会打乱变量传递这两者经常被拿来比较但使用场景差异巨大。add_subdirectory 会为子目录开启一个完全独立的作用域子目录 CMakeLists.txt 里 set 的变量默认在父目录不可见想要传给父级得显式写成set(SOME_VAR hello PARENT_SCOPE)而 include 不开启作用域层级文件里的裸 set 是直接留在当前作用域的。简单说维度include()add_subdirectory()作用域不创建新作用域代码在调用位置执行创建新的子目录作用域变量可见性被包含文件的裸 set 会留在当前作用域子目录的变量默认不可见需 PARENT_SCOPE 传回适用范围函数库、宏定义、全局配置、工具脚本需要独立构建的模块、子项目、库目标影响构建目标不直接产生目标子目录可正常定义库、可执行文件缓存变量不自动创建缓存子目录的缓存变量不会传回父作用域举个例子我早期做过一个项目把公共编译选项写在 common.cmake 里然后希望每个子模块 include 之后能拿到统一的选项。后来发现某个子目录总是不一致排查发现该子目录的 CMakeLists.txt 用的是 add_subdirectory 引入而公共选项写在另一个上层文件里根本没有被 include 进去。这就是没搞清楚两个指令适用场景的后果。判断标准很简单如果你想“复用一段脚本逻辑”用 include如果你想“维护一个独立的构建单元”用 add_subdirectory。两者也可以混用但千万不要指望 add_subdirectory 会自动继承 include 带来的动态变量。2. 三种最常见的调用方式与参数细节2.1 按文件路径 include最常见相对路径也最容易踩雷基本语法是include(file|module [OPTIONAL] [RESULT_VARIABLE var] [NO_POLICY_SCOPE])当参数里有路径分隔符或者给的是一个带 .cmake 后缀的完整文件名时CMake 会把参数当作一个文件来处理。这里最关键的是相对路径解析文件名如果是相对路径会相对于 CMAKE_CURRENT_LIST_DIR 来解析也就是当前正在处理的文件所在的目录。这句话很多人都知道但真正碰到问题时的表现是顶层 include 一个文件没问题因为 CMAKE_CURRENT_LIST_DIR 就是项目根目录一旦这个命令放在某个子目录里或者放在从别处 include 进来的脚本里CMAKE_CURRENT_LIST_DIR 变了路径就找不到。我在一个项目里写过这样的代码# 这个文件位于 cmake/utils.cmake include(helper.cmake)这个写法看起来没问题但如果 utils.cmake 本身是被另一个目录里的 CMakeLists.txt include 进来的helper.cmake 的解析基准就不再是 utils.cmake 所在目录而是“当前正在处理的那个文件”的目录。实际结果就是 helper.cmake 找不到。保险的写法是无论什么时候只要 include 的是相对路径都主动加前缀include(${CMAKE_CURRENT_LIST_DIR}/helper.cmake)这样可以保证被包含文件内部的相对引用始终基于自己所在位置。另一个方案是把所有 .cmake 集中放到一个目录然后通过 CMAKE_MODULE_PATH 把它变成一个“模块”这就要用到下一种方式。2.2 按模块名 include真正的模块化用法如果 include 的参数里不带路径分隔符比如include(utils)CMake 会把它当作模块名去 CMAKE_MODULE_PATH 指定的目录里寻找对应的utils.cmake文件。这种方式比写完整路径清爽得多也符合模块化的思路。使用方法一般是list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake) include(utils)只要 cmake 目录下存在 utils.cmake这条命令就能正确加载。CMake 查找模块的顺序是先遍历 CMAKE_MODULE_PATH 中列出的目录找到第一个匹配的 .cmake 文件如果都没找到再去 CMake 自带的模块目录里找比如 include(CMakePrintHelpers) 就能直接加载内置模块。这种模块化方式非常适合团队协作大家约定好所有公共函数放在一个目录每个文件只做一件事命名清晰然后通过 CMAKE_MODULE_PATH 统一管理。项目大了以后每个模块文件还可以开头加 include_guard()避免一份代码被多个入口重复加载。2.3 OPTIONAL、RESULT_VARIABLE、NO_POLICY_SCOPE 的应用场景这三个参数平时用得少但特定场景下很关键逐一说明。OPTIONAL 表示文件或模块找不到时不报错。配合 RESULT_VARIABLE可以把找到的完整路径、或者 NOTFOUND 字符串写入指定变量。这段逻辑非常适合加载“可选配置”include(optional_config OPTIONAL RESULT_VARIABLE config_path) if (config_path STREQUAL NOTFOUND) message(WARNING 没找到 optional_config使用默认参数) else() message(STATUS 加载可选配置: ${config_path}) endif()需要注意RESULT_VARIABLE 只在没有 OPTIONAL 时才有完整意义。如果不用 OPTIONAL文件找不到会直接报错通常也就等不到 RESULT_VARIABLE 的处理。NO_POLICY_SCOPE 的理解稍微绕一些。CMake 默认在 include 外层会推开一个新的策略作用域被包含文件里设置的策略比如 CMAKE_POLICY_DEFAULT_CMP0077 之类的不会影响调用方这样设计是为了隔离脚本带来的策略副作用。但某些场景下你可能希望被包含脚本对后续生效比如脚本里明确设置了一个新策略的默认行为后面整个工程都需要它这时可以加上 NO_POLICY_SCOPE让策略设置直接落在当前策略作用域里。这个参数我个人很少用更多是在加载老项目兼容层时才会碰见知道有这个东西就行。3. 用 include 组织 CMake 项目的实际模式3.1 全局配置统一入口最常见也最值得推荐的模式写一个 common.cmake把公共设置集中放进去然后每个需要这些设置的目录都 include 一下。典型内容大致是# cmake/common.cmake set(PROJECT_CPP_STANDARD 17) set(PROJECT_WARNINGS_AS_ERRORS ON) function(apply_common_warnings target) if (MSVC) target_compile_options(${target} PRIVATE /W4 /permissive-) else() target_compile_options(${target} PRIVATE -Wall -Wextra -Wpedantic) endif() endfunction()然后在子目录里这样用include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/common.cmake) add_library(foo foo.cpp) apply_common_warnings(foo) target_compile_features(foo PRIVATE cxx_std_${PROJECT_CPP_STANDARD})这种模式的好处有两个。一是配置只维护一份改一个地方所有目录生效二是天然自带“作用域展开”的特性include 之后的变量和函数都可直接用不必像 add_subdirectory 那样额外传变量。我实测下来只要把公共配置从“裸变量”提升到“函数变量”的组合后续维护成本能降不少。3.2 复用函数库和宏库的封装方式第二个成熟模式是把可复用的函数、宏单独封装成 .cmake 文件形成一个轻量级“函数库”。比如项目中经常需要打印目标信息、生成版本头文件、封装安装路径等逻辑这些都可以独立成函数。以我的一个模拟项目 X 为例我拆了这么几个模块# cmake/message_utils.cmake function(print_target_summary target) get_target_property(target_type ${target} TYPE) get_target_property(target_srcs ${target} SOURCES) message(STATUS Target ${target}: type${target_type}) foreach(src IN LISTS target_srcs) message(STATUS source: ${src}) endforeach() endfunction()# cmake/install_utils.cmake function(install_shared_library target) install(TARGETS ${target} LIBRARY DESTINATION lib RUNTIME DESTINATION bin) endfunction()使用方只需要list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake) include(message_utils) include(install_utils)使用这种方式时我建议在每一个模块文件第一行加 include_guard()理由后面单独说。另外建议函数命名带上项目前缀比如myproject_print_summary否则多个库之间容易出现函数重名非常难调试。3.3 配合 include_guard 防止重复执行include_guard() 是 CMake 3.10 开始提供的指令用来防止同一个文件被重复 include。用法非常直白放在文件开头# cmake/utils.cmake include_guard(GLOBAL) function(helper_func) endfunction()不加 GLOBAL 时保护范围基于当前目录及全局变量加 GLOBAL 则整个构建过程全局生效。对于跨模块依赖的场景强烈建议用 GLOBAL。为什么要刻意防重复因为重复定义同名函数CMake 不会报错只是后定义的覆盖先定义的但如果文件里除了定义函数还有裸执行内容比如往全局列表里 push 编译选项、添加 install 规则、或者执行 add_definitions那每被 include 一次就执行一次后面会发现编译参数莫名其妙多了一倍或者 install 规则重复注册。这个问题在没有 include_guard 的老项目里尤其常见。我建议的编码规范就两条所有模块文件一律在开头写 include_guard()“纯函数库”文件不要包含裸执行命令“裸执行配置”文件则反向处理尽量不要放函数定义进去保持文件功能单一。4. include 与 find_package 的关系理解依赖查找的真相4.1 find_package 模块模式本质上是“指定位置 include”如果你理解了 include再看 find_package 就会发现它不再那么神秘。find_package(Foo MODULE) 做的事情其实就是按照一定规则去 CMAKE_MODULE_PATH 和系统模块路径里找 FindFoo.cmake找到之后就把它 include 进当前作用域。你可以理解为find_package 帮你做了“定位文件 调用 include”这两步的标准化工作。这意味着我们完全有能力自己写一个 FindXXX.cmake放到自己的模块目录里然后用 find_package(XXX) 触发。比如我在某个项目里需要自定义一个托管依赖库的查找逻辑就自己写了 FindMytool.cmake里面标准结构是# FindMytool.cmake find_path(MYTOOL_INCLUDE_DIR mytool.h PATHS ${MYTOOL_ROOT}/include) find_library(MYTOOL_LIBRARY mytool PATHS ${MYTOOL_ROOT}/lib) if (MYTOOL_INCLUDE_DIR AND MYTOOL_LIBRARY) add_library(Mytool::Mytool UNKNOWN IMPORTED) set_target_properties(Mytool::Mytool PROPERTIES IMPORTED_LOCATION ${MYTOOL_LIBRARY} INTERFACE_INCLUDE_DIRECTORIES ${MYTOOL_INCLUDE_DIR}) set(MYTOOL_FOUND TRUE) else() set(MYTOOL_FOUND FALSE) endif()然后在使用方模块里list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake) find_package(Mytool) if (MYTOOL_FOUND) target_link_libraries(app PRIVATE Mytool::Mytool) endif()这就是模块模式的全部逻辑。它依赖两个前提模块目录路径被加进了 CMAKE_MODULE_PATH以及你的 FindXXX.cmake 里正确设置了 FOUND 变量和 imported target。4.2 Config 模式也一样只不过是为 config 文件准备的 includefind_package(Foo CONFIG) 和模块模式的区别只在于查找文件名和位置。它并不会创建专门的钩子本质上还是“找到 FooConfig.cmake 或 foo-config.cmake然后 include 进来”。只不过 Config 模式的文件通常由被依赖项目安装时生成里面已经包含了精确的 imported target 定义、依赖关系和版本信息。所以如果你想知道某个第三方包到底塞进了什么变量、导出了什么目标最高效的办法是找到它对应的 config 文件然后当成一个普通 .cmake 文件去阅读。比如某库的 FooConfig.cmake 里写了add_library(Foo::Foo INTERFACE IMPORTED) set_target_properties(Foo::Foo PROPERTIES INTERFACE_INCLUDE_DIRECTORIES ${FOO_INCLUDE_DIR} INTERFACE_LINK_LIBRARIES Bar::Bar)它在 find_package 时被 include于是当前作用域就出现了 Foo::Foo 这个 imported target。这就是为什么 find_package 之后可以直接 target_link_libraries(app PRIVATE Foo::Foo) 的原因。理解这一层以后遇到“为什么 find_package 之后某些变量看不到”的问题时排查方向就清楚了去打开那个被 include 的 config 文件看它是否把变量定义成普通变量、缓存变量或者仅仅作为内部临时变量使用。很多时候变量不可见不是文件的错而是你自己预设了错误的变量名。5. 实战排查我用 include 踩过的坑和给新手的建议5.1 路径问题的教训相对路径会“跟人走”我最早踩的一次坑是在 CMakeLists.txt 里写include(module/utils.cmake)从根目录构建时一切正常。后来某天我把这个调用挪到了一个子目录的 CMakeLists.txt 里立刻报“找不到文件”而文件根本没有移动过。原因就是前面说的相对路径基于“当前正在处理的文件目录”从子目录发起时路径基准就变成了子目录目录自然找不到 module/utils.cmake。后来我强制自己在工程里建立了一条规则凡是 include 的文件只要不是通过 CMAKE_MODULE_PATH 解析的模块名一律写绝对路径或者至少用 ${CMAKE_CURRENT_LIST_DIR} 锚定include(${CMAKE_CURRENT_LIST_DIR}/module/utils.cmake)这个习惯刚开始会觉得代码长但多目录项目里几乎能消灭一半的路径类报错。5.2 变量污染被包含脚本里不能乱裸 set第二个高频问题是变量污染。某次我从一个工具模块里拿到了一个封装函数顺手 include 了它结果发现上层 CMakeLists 里一个同名变量被覆盖了。那个模块文件末尾恰好没有任何函数包裹的裸 set 语句于是每次 include 都会把它的私有默认值灌进当前作用域。这种情况的解决方法是代码审查约定模块文件的定义部分必须全部用 function 或 macro 包裹如果确实有需要暴露的默认配置要么用带前缀的变量名要么明确定义为缓存变量set(PROJECT_MYTOOL_DEFAULT_ENABLED ON CACHE BOOL Enable mytool by default)缓存变量至少要看得到出问题也好定位。通用变量名比如 SRCS、LIBS、CONFIG 在公共库文件里一律禁止太容易踩雷。5.3 排查技巧与常见问题速查最后分享一套我排查 include 问题的顺序。遇到“变量没传过去”“函数找不到”“编译选项不对”这类问题先别改业务代码按顺序检查先确认 include 路径是否基于 CMAKE_CURRENT_LIST_DIR再确认模块搜索路径 CMAKE_MODULE_PATH 是否在 include 之前已添加然后用 message 打印一下包含后关键变量的值最后检查是否因为重复 include 导致函数定义被覆盖。下面是我经常回看的问题速查表现象可能原因定位方法找不到被 include 的文件相对路径基准不对改用 ${CMAKE_CURRENT_LIST_DIR} 或绝对路径include(模块) 后函数未定义CMAKE_MODULE_PATH 未包含模块目录在执行 include 前打印 CMAKE_MODULE_PATH同名变量被覆盖被包含脚本里出现裸 set检查被包含文件的顶层是否有未包裹的变量赋值变量设置两次导致行为不同同一份文件被多次 include在文件开头加 include_guard()并打印计数install 规则重复注册模块文件被多入口 include全局策略统一用 include_guard(GLOBAL)被包含文件里的编译选项影响了其他目录裸执行内容放进了“函数库”文件拆分纯函数库和裸执行配置脚本还有一个常见操作我记得不少新手搞错过抱着“include 后变量应该传回父层”的想象去函数里写 set(FOO 1 PARENT_SCOPE)结果又发现函数外根本取不到。这是因为 PARENT_SCOPE 的意义是“写入调用函数的地方的上一级作用域”也就是函数调用者的作用域而不是 include 文件的上一层。只要整个 include 发生在函数外部函数里的 PARENT_SCOPE 写入的目标就是当前作用域表现看起来没问题一旦你把 include 挪到某个函数内部这种传递关系就会立刻变化。建议新人先画清楚自己的调用层级再决定变量怎么传。个人经验里还有一个值得单独说的点include 完成之后文件内部通过 add_subdirectory 创建的作用域、或者函数里的局部作用域都不会被“包含”这个动作改变。你唯一能控制的是它在当前作用域里的执行结果。理解这一点就能解释绝大多数 CMake 变量混乱的问题。我最后一次踩 include 的坑就是写自定义 Find 模块时发现函数里找到的路径传不出来后来把 find_path 和 find_library 的调用放到函数外变量立即可见问题当场消失。如果你现在开始做 CMake 模块化重构我的建议很直接先把所有散落的公共逻辑归拢到一个 cmake 目录每个文件首行加 include_guard(GLOBAL)变量统一加项目前缀被包含的文件只做一件事。把这些规范执行到位之后include 指令就不是坑而是你管理大型工程最顺手的一张牌。