
1. 从单目录到多目录为什么你的C工程迟早要迈这一步写过C的人大概都有过这样的经历一开始项目就一个main.cpp顶多加个utils.cpp编译命令直接g main.cpp utils.cpp -o app就完事了。后来文件越来越多开始写个简单的Makefile再后来发现Makefile的依赖关系维护起来让人头大于是转向了CMake。但真正让人抓狂的往往不是CMake本身的语法而是当项目从“一个文件夹装所有源码”演变成“按模块分目录组织”时构建脚本该怎么写。这就是“CMake构建工程-cpp多目录”这个主题要解决的核心问题。它不是一个花哨的新技术而是每一个C项目从玩具走向工程化都必须跨过的一道坎。你可能会问多目录到底难在哪不就是把源文件放到不同文件夹里吗问题恰恰在于CMake的add_executable和add_library需要明确知道每个源文件的位置而头文件的包含路径、库之间的依赖关系、编译选项的传递都会随着目录结构的变化而变得复杂。这篇文章适合两类人一类是已经会写简单CMakeLists.txt但项目一变大就不知道怎么组织的开发者另一类是被别人写的多目录CMake工程坑过想搞清楚里面到底怎么回事的工程师。我会从目录结构设计讲起一步步拆解每个CMakeLists.txt该写什么、为什么这么写再补充那些文档里不会告诉你的实操经验和踩坑记录。整篇内容基于我这些年维护过的大小C项目的常见实践不涉及任何特定公司的内部方案你可以直接拿去套用到自己的工程里。2. 多目录工程的整体设计与目录结构规划2.1 为什么不能把所有源文件塞进一个CMakeLists.txt很多人第一次尝试多目录时会图省事在根目录的CMakeLists.txt里用file(GLOB_RECURSE ...)把所有.cpp文件一网打尽然后直接生成可执行文件。这种做法在项目初期确实能跑但它有几个致命问题。第一GLOB是在CMake配置阶段执行的如果你新增了一个源文件CMake不会自动感知到变化必须手动重新运行cmake命令。这在团队协作中非常容易导致“我本地能编译你那边少文件”的诡异问题。第二所有源文件被编译成一个目标模块之间的边界完全消失你无法单独编译某个模块也无法控制模块间的依赖方向。第三编译选项和头文件路径全部混在一起想给某个模块单独加一个宏定义都做不到。所以多目录工程的核心思路是每个目录对应一个CMake目标库或可执行文件目录之间的依赖关系通过target_link_libraries显式声明。这样做的好处是模块边界清晰、编译可以增量进行、依赖关系可追踪。2.2 一个典型的多目录结构长什么样我一般会把工程组织成下面这个样子你可以根据自己的项目规模调整project_root/ ├── CMakeLists.txt # 顶层负责全局设置和添加子目录 ├── cmake/ # 存放自定义的.cmake模块 │ └── CompilerOptions.cmake ├── src/ │ ├── CMakeLists.txt # 负责生成主可执行文件 │ ├── main.cpp │ ├── core/ │ │ ├── CMakeLists.txt # 生成core静态库 │ │ ├── include/ │ │ │ └── core/ │ │ │ └── engine.h │ │ └── engine.cpp │ ├── utils/ │ │ ├── CMakeLists.txt # 生成utils静态库 │ │ ├── include/ │ │ │ └── utils/ │ │ │ └── string_helper.h │ │ └── string_helper.cpp │ └── app/ │ ├── CMakeLists.txt # 生成app可执行文件 │ └── app_main.cpp ├── tests/ │ ├── CMakeLists.txt │ └── test_core.cpp └── third_party/ # 第三方库通常用FetchContent或子模块管理这个结构里core和utils各自生成一个静态库app生成可执行文件并链接这两个库。tests目录单独生成测试可执行文件。顶层CMakeLists.txt只做三件事设置CMake最低版本和项目名、定义全局编译选项、用add_subdirectory把各个子目录加进来。2.3 静态库还是动态库这个选择比你想的重要在多目录工程里每个模块生成什么类型的目标是一个需要提前想清楚的问题。静态库STATIC在链接时会把代码直接复制进最终的可执行文件好处是部署简单、没有运行时库路径问题坏处是如果多个可执行文件链接同一个静态库每个可执行文件都会有一份代码副本体积会变大。动态库SHARED则是运行时加载多个可执行文件可以共享同一份库文件。但动态库需要处理运行时搜索路径RPATH的问题尤其是在安装到系统目录之前你需要确保可执行文件能找到.so文件。我的经验是内部模块默认用静态库除非有明确的共享需求或者模块本身很大且被多个可执行文件使用。对于测试代码直接链接静态库最省事。如果你确实需要动态库CMake提供了BUILD_SHARED_LIBS变量可以在顶层设置set(BUILD_SHARED_LIBS ON)让所有add_library默认生成动态库但我不建议这么做因为不同模块的需求可能不一样显式指定更安全。3. 核心细节解析每个CMakeLists.txt到底该写什么3.1 顶层CMakeLists.txt的职责边界顶层文件是整个工程的入口但它不应该包揽一切。我见过有人在顶层文件里写了几百行把所有源文件、头文件路径、编译选项全塞进去结果子目录的CMakeLists.txt形同虚设。正确的做法是让顶层文件保持精简只做全局性的决策。一个典型的顶层CMakeLists.txt大概长这样cmake_minimum_required(VERSION 3.16) project(MyMultiDirProject VERSION 1.0.0 LANGUAGES CXX) # 设置C标准注意不要用全局的CMAKE_CXX_FLAGS来设标准 set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) # 导出编译命令数据库方便clangd等工具使用 set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # 默认构建类型 if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release) endif() # 全局编译选项通过interface库传递更优雅但这里先简单处理 add_compile_options(-Wall -Wextra -Wpedantic) # 添加子目录 add_subdirectory(src) add_subdirectory(tests)这里有几个细节值得展开。CMAKE_CXX_STANDARD设置为17是当前比较稳妥的选择C20在一些老编译器上支持还不完整。CMAKE_CXX_EXTENSIONS OFF会禁用GNU扩展让代码更符合标准但如果你用了某些依赖GNU扩展的库可能需要设为ON。CMAKE_EXPORT_COMPILE_COMMANDS ON会生成compile_commands.json这个文件对代码补全和静态分析工具非常重要强烈建议开启。注意add_compile_options会影响当前目录及所有子目录但它不会传递给通过target_link_libraries链接的外部目标。如果你想让编译选项跟随目标传播应该用target_compile_options配合INTERFACE或PUBLIC关键字。3.2 子目录中库目标的创建与头文件路径处理以src/core/CMakeLists.txt为例它需要生成一个名为core的静态库并正确处理头文件路径# 收集源文件显式列出而不是用GLOB set(CORE_SOURCES engine.cpp parser.cpp renderer.cpp ) add_library(core STATIC ${CORE_SOURCES}) # 头文件目录PUBLIC表示链接core的目标也能看到这些头文件 target_include_directories(core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include ) # 如果core依赖了其他库在这里链接 # target_link_libraries(core PUBLIC utils)这里的关键是target_include_directories的PUBLIC关键字。它的含义是这个包含路径不仅用于编译core自身还会传递给任何链接了core的目标。这样在app目录里你只需要#include core/engine.h而不需要手动添加core/include路径。对应的头文件应该放在include/core/下面这样包含时写#include core/engine.h就能避免不同模块之间的头文件重名冲突。这个习惯在项目变大之后会救你一命我见过太多因为两个模块都有utils.h而导致包含错文件的案例。3.3 可执行文件目标如何链接多个库src/app/CMakeLists.txt负责生成最终的可执行文件add_executable(myapp app_main.cpp) target_link_libraries(myapp PRIVATE core utils ) # 如果可执行文件需要额外的包含路径 target_include_directories(myapp PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} )PRIVATE表示这些库只用于链接myapp不会传递给其他目标。对于可执行文件来说通常用PRIVATE就够了因为可执行文件一般不会被其他目标链接。这里有一个常见的坑如果core库的头文件里包含了utils库的头文件那么core在链接utils时应该用PUBLIC或INTERFACE否则myapp在编译时可能找不到utils的头文件。CMake的依赖传播规则是PRIVATE不传播INTERFACE只传播不链接PUBLIC既链接也传播。理解这三个关键字的区别是多目录工程能否顺利编译的关键。3.4 测试目录的独立配置测试代码通常需要单独的可执行文件并且可能依赖测试框架。tests/CMakeLists.txt可以这样写add_executable(test_core test_core.cpp) target_link_libraries(test_core PRIVATE core utils ) # 注册测试需要先enable_testing() add_test(NAME core_tests COMMAND test_core)别忘了在顶层CMakeLists.txt里加上enable_testing()否则add_test不会生效。测试目标通常不需要安装所以不用写install规则。4. 实操过程从零搭建一个多目录工程4.1 环境准备与初始文件创建假设你在Linux或macOS上已经安装了CMake 3.16以上版本和GCC/Clang。先创建目录结构mkdir -p myproject/src/core/include/core mkdir -p myproject/src/utils/include/utils mkdir -p myproject/src/app mkdir -p myproject/tests cd myproject然后创建各个源文件和头文件。core/engine.h里声明一个简单的类#pragma once #include string namespace core { class Engine { public: explicit Engine(std::string name); std::string greet() const; private: std::string name_; }; }core/engine.cpp实现它#include core/engine.h namespace core { Engine::Engine(std::string name) : name_(std::move(name)) {} std::string Engine::greet() const { return Hello from name_; } }utils/string_helper.h提供一个工具函数#pragma once #include string namespace utils { std::string to_upper(const std::string input); }utils/string_helper.cpp实现#include utils/string_helper.h #include algorithm #include cctype namespace utils { std::string to_upper(const std::string input) { std::string result input; std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) { return std::toupper(c); }); return result; } }app/app_main.cpp使用这两个库#include core/engine.h #include utils/string_helper.h #include iostream int main() { core::Engine engine(MultiDirProject); std::cout utils::to_upper(engine.greet()) std::endl; return 0; }4.2 逐层编写CMakeLists.txt并验证先写顶层CMakeLists.txt内容如3.1节所示。然后写src/CMakeLists.txtadd_subdirectory(core) add_subdirectory(utils) add_subdirectory(app)src/core/CMakeLists.txt和src/utils/CMakeLists.txt按照3.2节的模板写注意把源文件名替换成实际的文件名。src/app/CMakeLists.txt按照3.3节写。tests/CMakeLists.txt先写一个最简单的测试add_executable(test_core test_core.cpp) target_link_libraries(test_core PRIVATE core utils) add_test(NAME core_tests COMMAND test_core)tests/test_core.cpp可以先用一个简单的断言#include core/engine.h #include cassert #include iostream int main() { core::Engine engine(Test); assert(engine.greet() Hello from Test); std::cout All tests passed std::endl; return 0; }现在开始构建。在项目根目录执行cmake -S . -B build -DCMAKE_BUILD_TYPEDebug cmake --build build -j$(nproc)如果一切顺利你会在build/src/app/下看到myapp可执行文件在build/tests/下看到test_core。运行./build/src/app/myapp应该输出HELLO FROM MULTIDIRPROJECT。4.3 编译选项的精细化控制上面的配置能跑但编译选项的管理还比较粗糙。更好的做法是创建一个INTERFACE库来承载全局编译选项然后在每个目标里链接它。在顶层CMakeLists.txt里添加add_library(project_warnings INTERFACE) target_compile_options(project_warnings INTERFACE -Wall -Wextra -Wpedantic -Wshadow -Wnon-virtual-dtor )然后在每个库和可执行文件的target_link_libraries里加上project_warnings。这样做的好处是编译选项可以按需组合比如测试代码可以额外加-Werror而主程序不加。另一个实用技巧是用target_compile_definitions来传递版本号或调试开关target_compile_definitions(core PUBLIC CORE_VERSION_MAJOR1 CORE_VERSION_MINOR0 )这样在代码里就可以用CORE_VERSION_MAJOR这个宏而且链接了core的目标也能看到这个定义。4.4 安装规则的配置如果你的工程需要安装到系统目录需要在各个CMakeLists.txt里添加install规则。对于库目标install(TARGETS core ARCHIVE DESTINATION lib LIBRARY DESTINATION lib RUNTIME DESTINATION bin ) install(DIRECTORY include/core DESTINATION include )对于可执行文件install(TARGETS myapp RUNTIME DESTINATION bin )安装规则看起来简单但有一个容易忽略的点install(DIRECTORY ...)会保留目录结构所以include/core会被安装到include/core而不是把core目录下的文件直接铺到include下。这个行为通常是符合预期的因为头文件包含时写的是#include core/engine.h。5. 常见问题与排查技巧实录5.1 头文件找不到从包含路径到依赖传播的排查链路“fatal error: core/engine.h: No such file or directory” 是多目录工程里最常见的报错。排查思路应该从目标出发而不是盲目加路径。首先确认报错的目标是哪个。如果是app报错检查app的target_link_libraries里有没有core。如果没有加上。如果有检查core的target_include_directories是不是用了PUBLIC或INTERFACE。如果用了PRIVATE那么app看不到core的头文件路径就会报这个错。还有一种情况是头文件路径写错了。比如target_include_directories(core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)那么包含时应该写#include core/engine.h因为include目录下还有一层core目录。如果你写成了#include engine.h就会找不到。实操心得用cmake --build build --verbose可以看到完整的编译命令里面包含了所有的-I路径。对比一下你期望的路径和实际路径问题通常一目了然。5.2 链接错误未定义引用与库顺序问题“undefined reference tocore::Engine::greet() const” 说明链接器找到了函数声明但没有找到实现。可能的原因有几个core库没有被链接到最终目标core库的源文件没有被正确编译进去或者链接顺序不对。CMake通常会自动处理链接顺序但如果你手动写了target_link_libraries并且库之间有循环依赖就可能出问题。解决循环依赖的办法是把公共部分抽到一个新的库里或者用INTERFACE库来打破循环。另一个常见原因是add_library时源文件列表写错了比如漏掉了engine.cpp。检查一下CORE_SOURCES变量里是不是包含了所有需要的.cpp文件。5.3 修改CMakeLists.txt后不生效缓存与重新配置CMake会把配置结果缓存到build/CMakeCache.txt里。如果你修改了CMakeLists.txt但构建时没有重新运行cmake改动可能不会生效。cmake --build通常会自动检测到CMakeLists.txt的变化并重新配置但有时候缓存会导致奇怪的行为。如果遇到莫名其妙的问题第一步是删除build目录重新来一遍rm -rf build cmake -S . -B build cmake --build build这个操作能解决大部分“我明明改了但没生效”的问题。虽然重新编译会花时间但比在那里猜原因要高效得多。5.4 常见问题速查表问题现象可能原因排查方法头文件找不到包含路径未传播检查target_include_directories的 PUBLIC/PRIVATE未定义引用库未链接或源文件缺失检查target_link_libraries和源文件列表修改不生效CMake缓存删除build目录重新配置重复定义头文件缺少pragma once或include guard检查头文件保护编译选项不传递用了全局变量而非target命令改用target_compile_options测试不运行未调用enable_testing在顶层添加enable_testing()5.5 几个让我踩过坑的细节第一个坑是CMAKE_CURRENT_SOURCE_DIR和CMAKE_SOURCE_DIR的区别。前者是当前CMakeLists.txt所在的目录后者是顶层目录。在子目录里写路径时一定要用CMAKE_CURRENT_SOURCE_DIR否则路径会指向顶层目录导致找不到文件。第二个坑是target_link_libraries里库名的顺序。虽然CMake通常会处理但在某些情况下被依赖的库应该放在依赖它的库后面。如果遇到链接错误可以尝试调整顺序。第三个坑是add_subdirectory的顺序。如果app依赖core那么add_subdirectory(core)必须在add_subdirectory(app)之前。虽然CMake不强制要求但逻辑上应该保持依赖顺序否则可能在某些配置下出问题。6. 进阶技巧让多目录工程更易维护6.1 用函数封装重复的库创建逻辑当你有十几个模块时每个模块的CMakeLists.txt都写一遍add_library、target_include_directories、target_link_libraries会很啰嗦。可以定义一个函数来简化function(add_project_library name) cmake_parse_arguments(ARG SOURCES;DEPS ${ARGN}) add_library(${name} STATIC ${ARG_SOURCES}) target_include_directories(${name} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include ) target_link_libraries(${name} PUBLIC project_warnings ${ARG_DEPS} ) endfunction()然后在子目录里只需要add_project_library(core SOURCES engine.cpp parser.cpp renderer.cpp DEPS utils )这样代码量大幅减少而且保证了所有库的配置一致性。不过要注意函数定义必须在add_subdirectory之前通常放在顶层CMakeLists.txt或者一个单独的.cmake文件里用include引入。6.2 条件编译与可选模块有些模块可能只在特定平台或特定配置下才需要编译。可以用option来定义开关option(BUILD_TESTS Build tests ON) option(ENABLE_LOGGING Enable logging module OFF) if(BUILD_TESTS) add_subdirectory(tests) endif() if(ENABLE_LOGGING) add_subdirectory(logging) endif()这样用户可以通过cmake -DENABLE_LOGGINGON来控制是否编译日志模块。对于跨平台项目还可以用if(WIN32)、if(UNIX)等条件来添加平台特定的源文件或链接平台特定的库。6.3 第三方依赖的管理策略多目录工程通常需要引入第三方库。常见的方式有三种find_package、FetchContent和子模块。find_package适合系统已经安装的库FetchContent适合自动下载和编译子模块适合需要修改源码的情况。我一般优先用find_package因为它最快而且不会把第三方代码混进构建目录。如果系统没有安装再用FetchContent作为备选。FetchContent的用法大概是这样include(FetchContent) FetchContent_Declare( googletest GIT_REPOSITORY https://github.com/google/googletest.git GIT_TAG release-1.12.1 ) FetchContent_MakeAvailable(googletest)然后测试目标就可以直接链接gtest_main了。注意FetchContent会在配置阶段下载代码如果网络不稳定可能会失败所以最好把下载的代码缓存起来。6.4 编译速度优化从并行构建到预编译头多目录工程的一个副作用是编译时间变长因为每个库都要单独编译。除了用-j并行构建之外还有几个技巧可以加速。预编译头PCH可以把常用的标准库头文件预先编译好减少重复解析的时间。CMake 3.16以上支持target_precompile_headerstarget_precompile_headers(core PRIVATE string vector memory )另一个技巧是合理划分库的粒度。库太小会导致链接开销增加库太大则会导致每次修改都要重新编译大量文件。一般来说一个库对应一个逻辑模块是比较合适的粒度。7. 写在最后一些个人体会多目录CMake工程的核心其实不是CMake语法本身而是对项目结构的思考。你需要在写第一行CMake代码之前就想清楚哪些代码属于同一个模块模块之间的依赖方向是什么哪些模块需要被多个可执行文件共享。这些决策一旦做错后面改起来会很痛苦。我自己的习惯是在项目初期就建立好多目录结构哪怕一开始只有两三个文件。因为从单目录迁移到多目录的成本远高于一开始就按多目录组织。而且多目录结构会强迫你思考模块边界这对代码质量本身也有好处。另外不要害怕写多个CMakeLists.txt。有些人觉得文件多了不好管理但实际上每个文件都很短职责单一反而比一个几百行的顶层文件更容易维护。关键是保持一致性所有库的CMakeLists.txt用同样的模板所有可执行文件的CMakeLists.txt用同样的模板这样任何人看到一个新模块都能快速理解它的构建方式。最后分享一个小技巧在顶层CMakeLists.txt里加上set(CMAKE_EXPORT_COMPILE_COMMANDS ON)然后把build/compile_commands.json软链接到项目根目录。这样你的代码编辑器或IDE就能自动找到所有头文件路径代码补全和跳转都会顺畅很多。这个文件在排查头文件路径问题时也特别有用因为它记录了每个源文件编译时实际使用的所有-I参数。