CMake多目录构建:源文件与头文件组织全指南 📅 发布时间:2026/9/17 5:37:48 👁 浏览次数: 做 C/C 项目目录一多CMakeLists.txt 怎么写就成了绕不过去的坎。刚开始接触 CMake 的朋友十有八九都遇到过这种尴尬源码文件明明就在 src 目录里头文件也在 include 目录里可项目一编译要么报找不到源文件要么报找不到头文件要么干脆链接的时候一堆 undefined reference。其实这些问题背后全是对同一个核心概念没吃透——CMake 到底是怎么把不同目录下的文件组织进一个构建系统的这篇文章就围绕CMake 添加不同目录文件这件事把几种主流写法、各自的使用场景、以及我实际踩过的坑一次讲清楚。如果你正在用 CMake 搭建新项目或者接手了一个目录结构特别复杂的旧工程想知道 src、lib、include 这些目录里的文件到底该怎么加进构建这篇文章基本够用了。我会先讲整体思路再给可以直接抄的写法最后整理一份排查清单。1. 项目结构设计与思路拆解1.1 一个典型的多目录工程长什么样很多从单片机开发转过来的朋友一开始可能不太适应 CMake 那套目标思维。Keil 或者 IAR 里你把文件一个一个添加进工程树就行编译时会自动处理头文件路径IDE 会帮你把一切都安排得明明白白。CMake 不一样它默认对文件系统里的目录结构没有感知你写了哪些文件、去哪找头文件、编译成什么目标全部要在 CMakeLists.txt 里明确告诉它。举个例子一个稍微规范点的 C 项目目录一般是这样project/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ └── module/ │ ├── module.cpp │ └── module.h ├── lib/ │ ├── utils.cpp │ └── utils.h └── include/ └── public/ └── api.h这种结构在真实工程里非常普遍src 放业务代码lib 放自己封装的工具库include 放对外的公共头文件。如果不做任何处理直接把 src 下所有文件写进 add_executable你会发现一个尴尬的问题main.cpp 里#include public/api.h的时候编译器根本不知道 public 目录在哪更不要说去 lib 目录找 utils.h 了。所以这里要先有个整体思路跨目录添加文件其实要解决两个层面的问题。第一源文件.c/.cpp要进入编译列表让构建系统知道要编译谁第二头文件.h/.hpp要能通过 include 搜索路径被找到。这两个问题可以分开处理也可以一起处理就看你的项目规模和维护习惯。1.2 CMake 里目录到底指什么我见过的初学者最容易搞混的地方就是 CMake 里的目录和文件系统里的目录不完全是一回事。CMake 的构建体系里有一个概念叫Source Directory也就是包含 CMakeLists.txt 的目录。CMake 执行时所有相对路径都是相对于当前 CMakeLists.txt 所在目录来解析的。这里有个很容易踩的坑你在子目录里放了一个 CMakeLists.txt在这个文件里写的相对路径参考的是子目录自己而不是根目录。很多人习惯性把根目录的结构套到子目录里结果 CMake 直接报 Cannot find source file。我自己的经验是先在脑子里画清楚两棵树一棵是文件系统里的目录树另一棵是 CMake 构建系统里的目标树由 add_executable / add_library / add_subdirectory 构成。理解 CMake 的过程其实就是把文件系统里的文件按照你定义的方式挂到目标树上的过程。在实际工程里这个过程通常有两种思路一种是中心化所有文件路径都写在根目录的 CMakeLists.txt 里简单粗暴但难以维护另一种是去中心化每个子目录放一个 CMakeLists.txt各自管理自己的文件根目录通过 add_subdirectory 把它们挂进来。绝大多数需要长期维护的中大型项目都会选第二种。原因很简单让每个目录自己管自己的事目录结构再怎么变影响范围也只在子目录内部。2. 最直接的做法add_executable 里写相对路径2.1 简单写法与适用场景先把最朴素的方法拿出来说。如果项目文件不多或者只是想快速搭个验证工程直接在根目录的 CMakeLists.txt 里这样写就行cmake_minimum_required(VERSION 3.16) project(Demo) add_executable(app src/main.cpp src/module/module.cpp lib/utils.cpp )这种写法很直白add_executable 可以接受一个文件列表列表里的每一项可以带路径。注意这里的src/main.cpp是相对路径它相对的是当前 CMakeLists.txt 所在的目录所以如果你的 CMakeLists.txt 放在 project 根目录那src/main.cpp指的就是project/src/main.cpp。头文件怎么处理如果只有这么几个文件而且头文件和源文件在同一个目录下编译器在编译src/module/module.cpp时默认会在当前源文件所在目录找module.h所以同一个目录下的#include module.h通常不用额外配置。但如果main.cpp里要#include module/module.h那就得把src目录加进头文件搜索路径用下面这种写法include_directories( src lib include )加了这一行之后编译器在找头文件时就会在这几个目录里一层层找。例如#include public/api.h因为搜索路径里有include所以会命中include/public/api.h。2.2 直接写路径的维护成本说实话这种写法在文件超过二三十个之后维护成本会迅速上升。每次新增或重命名一个源文件都要手动去 CMakeLists.txt 里改某天老板让你把 lib 目录挪到别的地方你还要一个个改相对路径。更麻烦的是如果某个目录是后来才加的很容易漏掉编译时就会出现一堆 undefined reference排查起来相当折磨人。我个人的经验是这种中心化直写的方式只适合三种情况一是项目文件很少比如三五个文件的小工具二是快速写实验代码不想搞复杂的目录结构三是刚接触 CMake想理解基础语法。一旦项目开始分目录、分模块我建议尽早换成后面要讲的目录拆分方式。另外提醒一句如果路径里有反斜杠\在 CMake 里很容易出问题因为反斜杠在 CMake 字符串里有转义语义。尤其从 Windows 上拷贝路径过来时记得把src\module\module.cpp改成src/module/module.cpp。CMake 在 Windows 和 Linux 下都支持正斜杠所以统一用正斜杠是最稳妥的。3. 解决头文件搜索路径include_directories 和 target_include_directories3.1 include_directories 的老办法刚才已经演示了 include_directories 的用法。它干的事情很简单往全局的头文件搜索路径里加目录。在这个命令之后定义的所有 target编译时都会自动带上这些搜索路径。include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include) include_directories(${PROJECT_SOURCE_DIR}/lib) include_directories(${PROJECT_SOURCE_DIR}/src/module)这种写法的好处是省事一个目录加一次后面所有 target 通用。但坏处也在全局这两个字上它会污染整个项目的编译配置。比如某个子目录里的头文件可能不想暴露给别人但用 include_directories 就全暴露了再比如两个不同目录下有同名头文件全局搜索路径一多到底会 include 到哪个文件就变得非常不可控。自己写 C 的时候可能感触还不深但如果是做嵌入式配合第三方 SDK这种全局 include 路径很容易和新 SDK 头文件产生命名冲突。我碰到过一次非常典型的定位问题两个库都用了一个名叫types.h的头文件全局路径搜索顺序决定了一个库莫名其妙找到另一个库的头文件报错信息完全看不懂最后排查了快一天才发现是 include 路径顺序的问题。3.2 target_include_directories 的传递机制CMake 3.0 往后官方推荐的做法是用 target_include_directories把 include 路径和具体目标绑定在一起add_library(module STATIC src/module/module.cpp) target_include_directories(module PUBLIC src/module) add_executable(app src/main.cpp) target_include_directories(app PRIVATE include) target_link_libraries(app PRIVATE module)这里面的关键点是 PUBLIC / PRIVATE / INTERFACE 的传递语义。module把自己的头文件目录标记为 PUBLIC意思是我自己的源码编译时要能在这个目录找到头文件而且别人链接我的时候也要自动加上这个搜索路径。于是app在 target_link_libraries 里链接了module就不需要再手动往自己的 include 路径里加src/module直接#include module.h就能找到。这就是传递性。这样设计的好处是每个 target 负责告诉 CMake 自己需要什么、能给别人什么依赖关系非常清楚。目录结构一旦变得复杂这种按 target 隔离的方式能把头文件冲突的概率降到最低。3.3 PUBLIC、PRIVATE 怎么选这块是很多人的疑惑点我速记几个判断标准如果头文件只在 target 自己内部使用编译时不需要暴露给其他模块用 PRIVATE。如果其他模块链接了这个 target 之后也需要 include 这组头文件才能正常开发用 PUBLIC。如果是接口库INTERFACE自身没有编译源码纯给别人提供头文件用 INTERFACE。个人建议一开始拿不准的时候先用 PRIVATE编译报 file not found 再改成 PUBLIC。虽然听起来有点像试错但比起一开始就全部 PUBLIC、把依赖关系搞得一塌糊涂这种方法反而更可控。我见过不少工程师为了省事把 target_include_directories 的目录写的和 include_directories 一样多结果每个 target 都能见到所有头文件等于这个接口设计白做了还多了一堆传递依赖。还有一个细节target_include_directories 里如果写的是相对路径CMake 会把它解释为相对于当前源码目录。所以在子目录的 CMakeLists.txt 里写target_include_directories(module PUBLIC .)是完全合法的它表示当前目录比用${CMAKE_CURRENT_SOURCE_DIR}或者${PROJECT_SOURCE_DIR}拼接要明显简洁也不容易出现路径拼接错误。4. 拆分子项目add_subdirectory 与 target_link_libraries4.1 子目录里定义目标的写法当项目规模发展到一定程度把所有文件堆在根 CMakeLists.txt 里就不太合适了。更合理的组织方式是每个子目录一个 CMakeLists.txt自己管理自己的文件。这时用到的核心命令是 add_subdirectory# 根目录 CMakeLists.txt cmake_minimum_required(VERSION 3.16) project(Demo) add_subdirectory(src/module) add_subdirectory(lib/utils) add_subdirectory(src) add_executable(app src/main.cpp) target_link_libraries(app PRIVATE module utils)子目录src/module里的 CMakeLists.txt 大概长这样add_library(module STATIC module.cpp module.h ) target_include_directories(module PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})子目录lib/utils类似add_library(utils STATIC utils.cpp utils.h ) target_include_directories(utils PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})这样根目录的 CMakeLists.txt 只负责构建一个大目标并声明它依赖哪些子目标。每个子目录里新增、删除文件时只需要改对应子目录的 CMakeLists.txt不会牵动整个工程。对于几十个目录的大工程来说这种隔离性带来的好处非常明显。我实际用过一段时间之后最大的感受是这种写法让谁负责什么变得特别清晰。比如某个目录是公司内部的公共算法库它的负责人只需要关心自己的 CMakeLists.txt其他人链接的时候不用管它的内部实现细节一切都由 target_link_libraries 自动传递。4.2 变量作用域与目录深度使用 add_subdirectory 后经常要处理的另一个问题是变量作用域。CMake 里的变量作用域和编程语言里的函数作用域有点类似子目录可以读父目录的变量但是默认情况下不能修改父目录的变量。比如在根目录定义了一个变量set(COMMON_FLAGS -O2) add_subdirectory(src/module)然后去子目录里读COMMON_FLAGS是可以读到的。但如果在子目录里写set(COMMON_FLAGS -O3)这个修改不会影响根目录。如果确实想从子目录向父目录传值要在 set 的时候加一个 PARENT_SCOPEset(COMMON_FLAGS -O3 PARENT_SCOPE)这里引出一个和目录深度直接相关的经验如果你嵌套了很多层 add_subdirectory变量往上传递会非常麻烦。嵌套三层以上代码很难读懂变量到底在哪一层定义、在哪一层被修改几乎没法一眼看出来。我的建议是超过两层的子目录结构就该考虑是不是该抽象成独立的 CMake 函数或模块了而不是继续靠 add_subdirectory 一层层嵌套。还有一类做法是使用缓存变量写成set(SOME_VAR value CACHE STRING )这样这个变量会进入 CMakeCache.txt全局可见。缓存变量适合那种希望用户在 cmake 命令行里通过-DSOME_VAR...覆盖的配置项不适合用来传递普通的构建中间变量。很多工程把普通的内部变量写成 CACHE结果用户一改命令行参数构建行为就变得不可预测这个坑也要留意。顺带提一句嵌入式开发里常接触的 ESP-IDF它的工程之所以能支持非常复杂的组件目录关系核心机制就是include($env{idf_path}/tools/cmake/project.cmake)把整套 IDF 构建逻辑引入到你的工程 CMakeLists.txt 里。你往里添加自己的组件目录时本质上就是在用 add_subdirectory 一层层挂接。这种第三方框架的写法很值得借鉴它把目录组织这一块抽象得非常彻底。4.3 被忽略的链接顺序问题拆成多个子目标之后还要注意静态库的链接顺序。在旧版的 CMake 和绝大多数 GNU 工具链下target_link_libraries列出的库在链接命令行上是有先后顺序的。顺序不对就会遇到一种让人抓狂的情况undefined reference to xxx源文件也已经编进去了、头文件也能找到了、函数就在那个库里但链接就是过不去。这往往不是漏链接而是链接顺序错误。比如 A 依赖 B命令行上必须 B 在 A 之后或者 B 在 A 之前不同链接器的规则略有差异但最有效的办法是让库的依赖关系表达清楚如果app依赖module而module依赖utils那么要在module的 target_link_libraries 里写上对utils的依赖而不是只在app里把module和utils一股脑写上去。现代 CMake 处理这个问题的能力已经好了很多但仍建议尽量让每个 target 只声明自己直接的依赖循环依赖能避免就避免。所谓循环依赖就是 A 链接 B、B 又链接 A这在 C 静态库里经常导致链接失败。遇到这种设计通常说明需要把公共部分拆成独立的 C 层。5. 批量收集源文件file(GLOB) 与 aux_source_directory5.1 file(GLOB) 的用法和坑目录层级多、文件也多很多程序员嫌一个个写太麻烦就想着用通配符自动收集所有 .cpp 文件。CMake 确实提供了这种能力file(GLOB_RECURSE SOURCES CONFIGURE_DEPENDS src/*.cpp lib/*.cpp ) add_executable(app ${SOURCES})GLOB表示收集当前目录下的匹配文件不递归子目录GLOB_RECURSE表示递归收集所有子目录里的匹配文件。CONFIGURE_DEPENDS是 CMake 3.12 之后加入的选项它让 CMake 在每次构建前去检查文件列表有没有变化如果有新增文件会自动重新收集。不加这个选项的话哪怕你新加了一个 .cpp 文件到 src 目录CMake 也不会察觉除非你手动重新执行 cmake 命令。为什么 CMake 官方文档一直不推荐把 file(GLOB) 当成默认做法核心原因是它会让构建系统的输入到底有哪些文件这件事变得隐性破坏了可预测性。如果你在一个大型 CI 系统里跑构建某天新增了源文件但是忘记重新生成 Makefile编译出来的东西可能还是旧的而且这种错很难一下子发现。我自己的折中做法是面对那些目录里的文件本来就该全部参与编译的场景比如某个 GUI 程序用工具自动生成的 UI 代码目录用 GLOB 完全没问题省心。但如果目录里的文件需要分门别类编译成不同库就别用 GLOB老老实实列出来反而更清晰。5.2 aux_source_directory 的适用场景与 file(GLOB) 相似的另一个命令是 aux_source_directoryaux_source_directory(src/module MODULE_SOURCES)它会把指定目录下的所有源文件不递归子目录收集到变量里。看起来挺方便但它对比 file 有一个明显限制只能收集源文件而且不递归。实际工程里你需要递归收集的场景远多于单层目录收集。另外 aux_source_directory 无法收集头文件头文件虽然一般不参与编译但在某些 IDE 生成的工程里把头文件也加入工程会方便浏览。如果你只是想少写几行用 aux_source_directory 也够用但我的经验是它和 file(GLOB) 并不是完全等价的替代。多数情况下 file(GLOB 能覆盖 aux_source_directory 的功能还更灵活所以我个人用得比较少。5.3 批量方式对比怎么选我把几种方式的区别整理成一张表方便对照着选方式是否递归能否加过滤条件新增文件自动感知适用场景手动列出文件不涉及不涉及需要手动修改文件少、结构稳定的核心模块include_directories 只加头文件路径不涉及不涉及不涉及解决头文件搜索路径add_subdirectory 子目录构建由子目录决定由子目录决定由子目录方式决定中大型项目、模块化程度高file(GLOB)默认不递归GLOB_RECURSE 可递归支持通配符需 CONFIGURE_DEPENDS自动生成代码目录、文件变动频繁的目录aux_source_directory不递归不支持需重新 cmake单目录内全部源文件参与编译这几件事不是互斥的实际项目里经常混合使用。比如你用 add_subdirectory 组织目录每个子目录内部再用 file(GLOB) 收集自己的源文件然后子目录各自生成一个库目标最后根目录把它们链接起来。这是很多成熟 C 工程的实际形态。6. 常见问题与排查技巧实录6.1 高频报错与解决办法用 CMake 管理多目录文件翻车最多的就是下面几个错误。我按从最常见到少但难排查的顺序整理一下。第一类报错CMake Error: Cannot find source file: src/module/module.cpp。这个绝大多数原因是路径参考基准搞错了。CMake 里的相对路径都是相对于当前 CMakeLists.txt 所在目录如果你在子目录里写add_library(module src/module/module.cpp)而子目录本身已经在src下那实际找的就是src/src/module/module.cpp。排查方法是先message(STATUS ${CMAKE_CURRENT_SOURCE_DIR})打印出当前目录再对着相对路径算一遍。第二类报错fatal error: module.h: No such file or directory。这说明虽然源文件进了编译列表但头文件搜索路径还没配置。要么补 include_directories要么给对应 target 配 target_include_directories。具体的取舍前面已经说过这里不重复。经验是优先怀疑是不是漏配了环境里的第三方 SDK 目录再怀疑自己工程内的头文件目录。第三类报错undefined reference to xxx()。原因有很多源文件没进编译列表、头文件声明和实现不一致、库没有链接、链接顺序不对。排查思路是倒着来先看编译后的 object 文件列表里有没有包含这个符号的源文件有的话确认对应库有没有进 target_link_libraries进了的话再检查链接顺序。大部分情况都能定位。第四类报错无法将“cmake”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。这个纯粹是 Windows 下 cmake 命令不在 PATH 环境变量里。解决办法是在安装 CMake 时勾选Add CMake to the system PATH for all users装完再重开一个终端。类 Unix 系统上如果敲 cmake 提示找不到先检查是不是没安装或者安装路径没写进 PATH。顺手用cmake --version确认一下版本很多语法特性不同版本支持不同比如 CONFIGURE_DEPENDS 就需要 3.12 以上版本。第五类问题比较隐蔽在 IDE 里明明能看到目录里有新文件但生成的工程里没有。这种情况十有八九是 CMake 没有重新执行配置。命令行构建工具一般会在 CMakeLists.txt 改过之后自动重新 configure但如果只是往文件系统里新增了文件且没有用 CONFIGURE_DEPENDS那 CMake 是不知道的。手动删掉 build 目录重新 cmake一般都能解决。6.2 排查变量和路径的实用方法CMake 的变量多且乱最笨也最有效的排查方法就是打 message。调试时我经常在关键位置加这样的代码message(STATUS CMAKE_CURRENT_SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}) message(STATUS CMAKE_CURRENT_LIST_DIR ${CMAKE_CURRENT_LIST_DIR}) message(STATUS PROJECT_SOURCE_DIR ${PROJECT_SOURCE_DIR}) message(STATUS SOURCES ${SOURCES})CMAKE_CURRENT_SOURCE_DIR 是当前处理 CMakeLists.txt 的目录PROJECT_SOURCE_DIR 是最近一次 project() 命令所在的目录这俩在嵌套子目录时经常被混淆。搞清楚它们之间的区别很多路径问题能原地解决。另外如果 CMake 缓存里的某个变量值一直和你预期的不同别急着改代码先看 build 目录下的 CMakeCache.txt搜索这个变量名确认它是从命令行、环境变量还是某个 set 语句写入的。缓存这个东西一旦存在可能会覆盖你后续在 CMakeLists.txt 里写的普通 set 值。这种问题排查思路比死磕 CMakeLists.txt 更有效。6.3 环境层面的检查建议最后补一点环境层面的心得。在不同平台上换行符、路径分隔符、文件系统大小写敏感都会给跨目录添加文件制造麻烦。Windows 下路径分隔符是反斜杠Linux 和 macOS 是正斜杠CMake 里统一用正斜杠能减少 99% 的路径字符串问题。如果你的工程文件在 Windows 上进 Git到了 Linux 上又因为大小写问题找不到头文件那多半是文件名大小写写错了。养成统一小写或者统一驼峰的习惯能省不少事。还有一个容易被忽略但非常重要的问题源文件编码和中文路径。虽然 CMake 本身处理 UTF-8 路径没问题但某些老旧的编译器和链接器在 Windows 下遇到中文路径会莫名其妙地报打不开文件。我处理过一个真实案例工程目录放在桌面用户名是中文结果 CMake 配置和编译都没有问题但最后生成的文件调用时报错把工程挪到一个纯英文路径下就全好了。如果读者遇到这种诡异问题可以先怀疑路径下的非 ASCII 字符。再一个是关于 CMake 版本。如果你下载安装的是很新的 CMake但某个老教程里的写法却报错先确认一下教程对应的版本。现在很多 Linux 发行版自带的 CMake 版本往往偏旧遇到target_sources、CONFIGURE_DEPENDS这些功能不支持时可以考虑从官网下载新版本或者用包管理器更新到较新的版本。版本不同写法差异很大这是很多照着教程写却过不了编译的隐藏原因。对于需要频繁在命令行操作 CMake 的朋友我再给个实用建议给常用命令建几个别名或脚本。比如在 Linux 下执行cmake -S . -B build cmake --build build -j$(nproc)在 Windows 下用 PowerShell 执行cmake -S . -B build; cmake --build build --config Release。这些命令不直接解决添加文件的问题但当你改动目录结构后需要反复验证时能明显提高效率。个人体会是及时重新构建、及时清空 build 目录重新 configure比对着屏幕猜要高效得多。多目录文件管理这件事说大不大说小也绝对不小。不同的项目规模、不同的团队协作方式会导向完全不同的写法。我这些年接触过的工程里既有一个人维护的小工具也有几十人协作的 SDK它们在 CMakeLists.txt 上的组织方式几乎完全不同。但底层的逻辑是一致的源文件进编译列表头文件进搜索路径目标之间通过链接和传递依赖建立关系。把这三点想清楚再回头去看那些五花八门的 CMake 语法你会发现它们都是在解决这三件事中的某一件。