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 文件准备的 include
find_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 指令就不是坑,而是你管理大型工程最顺手的一张牌。