ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

大型工程CMakeLists实战:模块化、依赖管理与避坑指南

大型工程CMakeLists实战:模块化、依赖管理与避坑指南 简介面向C开发者的CMake工程管理参考包聚焦如何用CMakeLists组织多目录、多模块的大型项目CMake作为跨平台构建工具统一配置编译选项、依赖库与生成规则在Windows、Linux、macOS上保持一致。实例包含common、io与主程序模块直观演示了add_subdirectory、file(GLOB...)、target_link_libraries、find_package、enable_testing及install等关键语法。压缩包共10个文件3个cpp文件承担核心功能与I/O封装2个h头文件提供接口声明5个txt文件覆盖各层CMakeLists及使用说明整体仅5KB小巧易读。目录按功能分层便于对照学习源文件分组、依赖查找、测试集成和安装部署的整体思路已有1422人学习下载适合刚接触CMake或希望规范项目构建方式的C开发者。1. 大型工程里CMakeLists不是写出来的是长出来的接手过一个几十万行代码的老工程里面散落着二十多个CMakeLists.txt每个都在自己开变量、自己拼路径顶层文件里几百行include和add_subdirectory像蜘蛛网一样缠着。那时候最怕的不是写代码而是给工程加一个新模块——改完CMakeLists编译能过是运气链接挂了才是常态。把这段经历理顺之后我最大的体会是管理大型工程的CMakeLists本质是做依赖管理和变更控制不是写配置。这篇文章讲的就是这个从“能编译”到“可维护”的过程怎么把目录拆出模块感怎么让target之间的依赖清晰怎么在Debug和Release、MinGW和MSVC、Qt老版本之间不翻车以及一套能长期落地的排查方法。适合刚把工程从一个小项目撑大、正被CMakeLists折磨的C/C从业者。2. 从零搭一个模块化CMakeLists目录结构、target与依赖关系2.1 先定目录结构把“包”和“目标”分开大型工程的CMakeLists首先不是写出来的是跟着目录结构长出来的。如果目录本身就是乱的CMakeLists写得再好也没用。常见的坏味道是一个目录里同时放着多个可执行文件、一堆静态库、还有测试代码结果add_subdirectory进来的每个子目录都在往外冒target顶层根本看不清楚谁依赖谁。我一般会把大型工程按“包”来划分而不是按“目录”划分。一个包是一组具有清晰边界的功能集合比如core、io、ui、cli。每个包内自有src、include、tests并且每个包含一个独立的CMakeLists.txt。顶层只负责三件事全局设置、找到外部依赖、把各个包用add_subdirectory引进来。这样整个工程的结构从CMakeLists就能读出来而不是靠人肉翻目录。一个好用的目录骨架长这样project-root/ ├── CMakeLists.txt ├── cmake/ │ ├── toolchain-mingw.cmake │ └── CompilerWarnings.cmake ├── libs/ │ ├── core/ │ │ ├── CMakeLists.txt │ │ ├── include/core/ │ │ └── src/ │ ├── io/ │ │ ├── CMakeLists.txt │ │ └── ... ├── apps/ │ ├── cli/ │ │ ├── CMakeLists.txt │ │ └── main.cpp │ └── gui/ │ ├── CMakeLists.txt │ └── ... ├── tests/ │ ├── CMakeLists.txt │ └── ... └── CMakePresets.json这样分完之后顶层CMakeLists就非常干净每个子目录也只需要关心自己这一层的事。一个子包被别的包依赖时通过target暴露接口而不是暴露一堆变量。这点后面会细说。2.2 最小CMakeLists骨架顶层、子目录、add_subdirectory只留这个结构的话顶层CMakeLists我可以压到很薄。下面是一份能直接跑起来的最小例子我们用project-root/CMakeLists.txt作为起点cmake_minimum_required(VERSION 3.20) project(LargeProject VERSION 1.0.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) add_subdirectory(libs/core) add_subdirectory(libs/io) add_subdirectory(apps/cli) add_subdirectory(apps/gui) add_subdirectory(tests)这段配置里cmake_minimum_required(VERSION 3.20)定死了CMake的下限版本避免用到高版本特性时在老环境里报出莫名其妙的错误。project里声明了版本和语言之后就可以直接用PROJECT_VERSION、PROJECT_NAME这些变量。CMAKE_CXX_STANDARD放全局是图省事但如果某个子包需要不同的语言标准可以在子包里覆盖。注意顶层这里没有写任何include_directories或link_directories。这两个命令在大型工程里应该尽量不用因为它们影响的是全局的编译和链接路径今天加上去明天别人用你的库时就可能踩到隐蔽的路径污染。正确做法是让每个target通过target_include_directories和target_link_libraries把自己需要的路径和依赖明明白白地暴露出来。子包比如libs/core/CMakeLists.txt的写法是这样的add_library(core STATIC src/core_impl.cpp src/algorithm.cpp ) add_library(core::core ALIAS core) target_include_directories(core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include ) target_link_libraries(core PRIVATE # 只在这个库内部使用的依赖 )这里有个细节我建了一个core::core的ALIAS target。因为大型工程里target重名是常有的事ALIAS能让你在依赖时写core::core一眼就知道这是来自哪个包的库而不是一个随机命名的裸target。同时PUBLIC和PRIVATE要分清——include目录给出来是因为外部要用头文件所以是PUBLIC内部私有依赖比如某个加密库就是PRIVATE。2.3 用target_link_libraries串起依赖别再用变量满天飞很多大型工程的CMakeLists难维护根因就是把依赖关系写成了一堆变量。比如有人在被依赖的库A里设置set(A_LIBRARIES ${A_LIBRARIES} ${B_LIBRARY})然后在库C里再拼一遍。一旦变量被多次set或被顺带修改链接顺序就很难控制改一处崩三处。正确的做法是只用target_link_libraries把依赖声明在target上。拿一个io库依赖core来举例add_library(io STATIC src/io_reader.cpp src/io_writer.cpp ) target_include_directories(io PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include ) target_link_libraries(io PRIVATE core::core PUBLIC # io的头文件里用到了core的类型所以core要留给下游 core::core )这里故意写了两种写法方便对比。如果io的头文件里包含了core的头文件那core必须放在PUBLIC段如果只是.cpp里用到放在PRIVATE就够了。这么做的好处是链接顺序由CMake根据target依赖关系自动计算不再需要手工调整-l参数的顺序。你在命令行里看到的可能是-lio -lcore但你不必关心它只要依赖图是对的CMake会按拓扑排序输出。至于链接时的“顺序玄学”我会在避坑章专门讲。这里先记住一个原则永远不要让一个target通过变量去“继承”另一个target的依赖而是让目标本身携带依赖信息。凡是出现${xxx_LIBRARIES}满天飞的地方都是在给未来的自己埋雷。3. 让大型工程可配置选项、生成器表达式与多配置3.1 option与cmake-gui给用户留开关大型工程的CMakeLists如果写死了所有配置用户换个编译器或者想要个不带GUI的版本就得去改源码。正确做法是把可变的部分暴露成option这样既能用cmake-gui点选也能在命令行里通过-D传参。注意cmake-gui在大型工程里的作用不只是“能configure”而是让不熟悉CMake的人也能直观地看到有哪些开关。我习惯在顶层CMakeLists集中定义一组开关option(BUILD_TESTING Enable tests ON) option(BUILD_GUI Build the GUI application ON) option(BUILD_SHARED_LIBS Build shared libraries OFF)然后根据开关决定要不要add_subdirectoryif(BUILD_GUI) add_subdirectory(apps/gui) endif()这里有个坑BUILD_TESTING是CMake自带的一个开关很多子工程也会用同名变量如果你在自己的工程里重新定义可能覆盖子工程的行为。所以我会先保留CMake内置的BUILD_TESTING让子工程通过include(CTest)拿到统一开关。还有如果某个子包的测试代码很重建议把它单独拆成一个tests目录只在BUILD_TESTING为真时编译这样发行包的用户能省下大量编译时间。配置开关的同时别忘了给用户一个默认值说明。比如BUILD_SHARED_LIBS默认OFF是保守选择因为静态库部署简单但如果你做的是插件系统或依赖很多默认ON更合理。这些决定要在CMakeLists里写清楚而不是让用的人猜。3.2 生成器表达式处理Debug/Release差异大型工程里Debug和Release的差异远不止一个-g和-O2还可能有不同的预处理宏、不同的链接库、甚至不同的输出目录。用简单的if(CMAKE_BUILD_TYPE STREQUAL Debug)写分支在Makefile生成器下能用但到了Visual Studio这种多配置生成器下就失灵了——因为多配置生成器在configure阶段不知道最终会构建哪个配置CMAKE_BUILD_TYPE是空的。这时候必须用生成器表达式。比如给Debug和Release设置不同的预处理宏target_compile_definitions(core PRIVATE $$CONFIG:Debug:CORE_DEBUG_MODE1 $$CONFIG:Release:CORE_NDEBUG1 )再比如给不同的配置设置不同的输出目录避免Debug生成的core.lib覆盖Release版本set_target_properties(core PROPERTIES ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib/$CONFIG LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib/$CONFIG RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin/$CONFIG )这里的$CONFIG就是生成器表达式它在生成构建系统时会展开成当前配置名所以Debug的库和Release的库会放在不同目录不会再互相覆盖。生成器表达式还能嵌套比如$$CONFIG:Debug:...就是“当配置是Debug时”的意思。大型工程里凡是和配置相关的路径、定义、flag我都建议用生成器表达式而不是if分支后者只会照顾到单配置生成器。3.3 用CMakePresets.json统一CI与本地构建大型工程最怕“本地能编CI不能编”。原因是本地开发者在命令行敲了一堆-DCMAKE_PREFIX_PATH...CI又是另一套环境变量两边的CMakeCache里记录的值不一样最终configure出来的工程就完全不是一回事。CMake从3.19开始引入的CMakePresets.json就是为了解决这个问题——把常用的configure和build参数固化成一个命名的预设本地和CI共用同一份。我一般会放这样一个CMakePresets.json在工程根目录{ version: 3, configurePresets: [ { name: windows-msvc-x64, generator: Visual Studio 17 2022, architecture: x64, cacheVariables: { CMAKE_PREFIX_PATH: C:/Qt/5.9.4/msvc2017_64/lib/cmake, BUILD_TESTING: ON } }, { name: windows-mingw-x64, generator: MinGW Makefiles, binaryDir: ${sourceDir}/build/mingw-x64, cacheVariables: { CMAKE_C_COMPILER: x86_64-w64-mingw32-gcc, CMAKE_CXX_COMPILER: x86_64-w64-mingw32-g } } ], buildPresets: [ { name: windows-msvc-x64, configurePreset: windows-msvc-x64 }, { name: windows-mingw-x64, configurePreset: windows-mingw-x64 } ] }这里需要解释一下。architecture字段只对Visual Studio生成器有效它指定目标架构是x64而不是Win32。binaryDir不写的话默认是${sourceDir}/build/${presetName}但我会显式写出来这样构建目录里能直观地区分不同预设的产物。在VSCode里装了CMake Tools插件后底部状态栏会直接显示当前预设点击它就能切换预设不再需要手动敲命令。用到Qt的老工程最头疼的就是CMAKE_PREFIX_PATH找不到Qt的CMake配置。在preset里把这个路径固化下来配合cmake --preset windows-msvc-x64一行命令就不会再出现“我这能编你那儿不行”的鬼故事。CI那边只要也装了CMake 3.20同样可以cmake --preset一把梭。4. 大型工程避坑指南链接错误、路径玄学和Qt老版本4.1 现象qt5config.cmake找不到典型报错是CMake Error at C:/Qt/qt5.9.4/5.9.4/msvc2017_64/lib/cmake/Qt5/Qt5Config.cmake:194 (message): The imported target Qt5::Core references the file ... but this file does not exist.或者更直白的Could not find a package configuration file provided by Qt5。原因多数情况下是CMAKE_PREFIX_PATH没有指向Qt的安装根目录或者指向的路径不对。注意Qt的CMake包搜索逻辑是去找lib/cmake/Qt5但你传给CMake的CMAKE_PREFIX_PATH要写到Qt的根目录也就是C:/Qt/qt5.9.4/5.9.4/msvc2017_64这一层而不是写到lib/cmake。很多人看到报错在Qt5Config.cmake就以为要指定到那一层结果越指越错。解决在CMakePresets.json的cacheVariables里写根目录或者在命令行显式传cmake -S . -B build -DCMAKE_PREFIX_PATHC:/Qt/qt5.9.4/5.9.4/msvc2017_64另一个隐蔽情况是Qt库本身没编译完整或依赖的运行时缺失。比如报缺Qt5Cored.dll那不是CMake配置问题是编译和运行环境不对劲。这时候我会先用find_package(Qt5 COMPONENTS Core Widgets REQUIRED)在单独的try_compile里验证能过就说明路径没错错的只是其它环节。4.2 现象静态库链接顺序翻车现象编译都过了但链接时报一堆undefined reference to ...而且这些符号明明在某个库里存在。新手会去反复检查target_link_libraries是不是漏了库但更常见的原因是链接顺序不对。CMake虽然会自动排序依赖但如果你手动加了link_directories和-l参数就会破坏CMake的顺序控制。原因静态库在链接时是“单向扫描”的依赖者要放在被依赖者前面。比如库io依赖于core链接命令必须是... io.lib core.lib反过来就不行。CMake的target_link_libraries知道这个顺序它会从依赖关系里推导出正确的命令行顺序。但如果你在某个子包里用了target_link_libraries(io PRIVATE core)而另一个地方又用target_link_libraries(io PUBLIC something-else)CMake会分别处理顺序仍然是对的。中间有人插了一脚link_directories或用add_library(... IMPORTED)时反复set_target_properties(IMPORTED_LOCATION ...)顺序就会乱。解决第一除非是系统库别用link_directories。第二把IMPORTED库也纳入target_link_libraries而不是手动加-l。第三如果遇到循环依赖A依赖B、B依赖A那说明模块划分有问题应该改用interface library或把公共部分抽出去而不是靠调序硬绕。这个坑在大型工程里出现得极为频繁也是我见过最“玄学”的一个实际就是依赖图没理清。4.3 现象MinGW和MSVC生成的文件打架现象同一份CMakeLists在Windows上用MinGW Makefiles生成器能编过但切到Visual Studio生成器就报一堆“语法错误”或“无法解析的外部符号”。或者反过来。原因CMake的生成器不仅决定构建工具还隐含了ABI、标准库、甚至在Windows上关键的__declspec(dllexport)宏处理。MSVC和MinGW虽然都是Windows下的C编译器但它们的头文件、库文件格式COFF vs GNU不兼容。坑在于很多人用cmake -S . -B build时忘了指定-GCMake会默认使用本机最新安装的Visual Studio但工程里某些库是用MinGW编译的就没法链接。解决养成每次configure都指定-G和-A的习惯或者用preset写死。在CMakeLists里尽量少用WIN32这个宏做分支因为它对两种编译器都成立容易让人误判。另一点如果你同时装了mingw32-make和nmake别在同一个构建目录里反复切换生成器。CMakeCache会记住第一次的生成器换生成器前要清空build目录或另建新目录。VSCode里切编译器也是同理直接把build目录删掉重来比在状态栏里点来点去靠谱。4.4 现象VSCode的CMake Tools状态栏没有Configure按钮很多人装了VSCode的CMake Tools插件之后看教程说底部状态栏应该有一个“Configure”按钮自己的却没有。网上搜“cmake tools 底部状态栏应该有configure按钮吗”答案经常是“有”但实际界面空空的。原因CMake Tools的按钮是分层的状态栏默认显示的是“当前target”而不是“Configure”。你需要先点击状态栏里的“CMake: Select a Kit”选一个编译器套件Kit之后才会出现Configure的相关入口。另外如果工作区里没有CMakeLists.txt或者VSCode没有识别到工程根目录插件也不会显示任何CMake按钮。解决按CtrlShiftP输入“CMake: Select a Kit”选好编译器。再输入“CMake: Configure”手动执行一次配置。配置成功之后状态栏自然会出现Build按钮和Target选择器。还有一个细节CMake Tools默认使用CMakePresets.json里的预设如果你写了多个预设需要在设置里指定cmake.configureSettings或直接点状态栏的预设名称切换否则它可能就是按照某种默认策略选择了第一个按钮位置也不一样。4.5 现象改一个头文件全工程重编现象在一个公共头文件里加一行代码然后整棵依赖树全部重新编译构建时间从两分钟变成二十分钟。在大型工程里这是最劝退的体验。原因头文件的依赖被过度扩大了。常见的有三种来源一是某个target把include目录用PUBLIC传播给所有下游但实际头文件里根本不需要这些下游二是在源文件里#include core_all.h把所有内部头文件聚合在一起三是target_include_directories用了一个很上层的大目录比如整棵include/而不是精确到include/foo子目录。解决先从依赖关系上拆。把公共头文件放到独立的interface库只有真正需要它的target才链接这个interface。用target_include_directories时尽量给出子目录的精确路径。另一个行之有效的手段是把前向声明的类拆出来减少头文件里的#include。如果C版本允许多用std::filesystem这种标准库头文件别自己搞“基础工具头”让所有人都包含。实在碰到历史遗留的全局头我一般会先用cmake --graphviz看依赖图找到是谁在传播这个头文件的依赖再一层层收敛。5. 用CMake管理大型工程的构建矩阵多编译器和交叉编译5.1 构建矩阵用toolchain文件切出多个编译器大型工程常常要同时支持MSVC、MinGW、Clang甚至嵌入式交叉编译。如果直接在CMakeLists里判断编译器厂商代码会越来越脏。正确做法是把编译器的差异隔离到一个独立的toolchain文件里CMakeLists本身只关心“这个target是谁”不关心“它是用什么编译器编出来的”。举个例子给MinGW写一个cmake/toolchain-mingw.cmakeset(CMAKE_SYSTEM_NAME Windows) set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc) set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g) set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres) set(CMAKE_FIND_ROOT_PATH /opt/mingw-w64)然后这样使用cmake -S . -B build-mingw -DCMAKE_TOOLCHAIN_FILEcmake/toolchain-mingw.cmake -G Ninja在CMakeLists里你不需要写任何if(MINGW)就可以用WIN32这个宏判断目标平台因为toolchain里已经set(CMAKE_SYSTEM_NAME Windows)了。这样当你想换一套编译器时只要新写一个toolchain文件工程代码和CMakeLists都不用动。需要留意的是toolchain文件的加载时机它会在project()命令之前生效所以你无法在CMakeLists里检查它的内容再决定后续配置。如果你想根据编译器类型输出点提示用CMAKE_CXX_COMPILER_ID或MSVC这种变量但千万别在两个toolchain文件里做互相矛盾的设置否则configure时会得到非常诡异的cache错误。5.2 输出与安装规则install与export让包可复用大型工程做久了最终是要把库交付给别的工程用的。只用add_subdirectory把整个工程拉进去会让对方被迫编译你的测试、你的GUI、你的全部依赖。更干净的做法是让每个模块提供install和export规则让其它工程通过find_package来使用。以core库为例include(GNUInstallDirs) install(TARGETS core EXPORT CoreTargets ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} ) install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})然后生成一个带版本信息的Config.cmakeinstall(EXPORT CoreTargets FILE CoreTargets.cmake NAMESPACE core:: DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/Core )下游工程这样用find_package(Core CONFIG REQUIRED) target_link_libraries(myapp PRIVATE core::core)这里EXPORT CoreTargets会把core这个target的编译选项、头文件路径、依赖信息都固化成CoreTargets.cmake。注意NAMESPACE core::会把导出的target重命名这样下游根本不需要知道头文件在哪也不用管库文件叫什么。实际做的时候还有两个细节。第一要导出的target不能是一个普通ALIAS必须是真实的库target如果是接口库可以用EXPORT导出。第二Config.cmake最好生成一个版本文件方便下游写find_package(Core 1.2 CONFIG REQUIRED)。用write_basic_package_version_file这一步省了后面太多麻烦。5.3 大型工程里最常见的误用把路径写死不管是用add_subdirectory还是find_package总有人喜欢在CMakeLists里写“当前机器上的绝对路径”比如include_directories(C:/Users/lisi/code/project/libs/core/include) set(FOO_LIBRARY D:/somewhere/foo.lib)这种写法在个人开发机上能跑一旦clone到别处或者CI上立马报错。教训来自现实太多工程就是被这种“路径写死”搞坏的最后只能靠改CMakeCache续命。正确做法任何路径都应该是相对于CMAKE_CURRENT_SOURCE_DIR或CMAKE_CURRENT_BINARY_DIR的。不要在顶层出现${PROJECT_SOURCE_DIR}下面挂一堆手工拼接的路径该用target_sources就老老实实列出源文件该用target_include_directories就写${CMAKE_CURRENT_SOURCE_DIR}/include。外部依赖的路径要么通过find_package寻找要么通过-D传入并在CMakePresets.json里固化而不是写进CMakeLists。如果你碰到一个“不写死就跑不了”的场景——比如第三方库没有提供CMake配置——那也尽量用一条set命令在preset里指向而不是散落在各个子包的CMakeLists里。这样以后换机器改一个文件就好。6. 进阶用CMake的依赖图与调试技巧给工程做“体检”大型工程越到后期CMakeLists本身会成为和源码同等重要的资产。我建议每个月或者每个大版本发布前用CMake自带的能力给工程做一次“体检”核心工具是--graphviz和--trace。生成依赖图先要装好Graphviz然后用cmake --graphvizdep.dot -S . -B build dot -Tpng dep.dot -o dep.png这一下能把所有target之间的依赖画成一张图。我在实际项目里发现大部分依赖腐化——比如io库居然依赖gui库——在这种图里是藏不住的。看到不该有的边就顺着CMakeLists去查是哪个target无意间传出了依赖。很多时候是某个接口库的头文件里include了不该include的东西导致依赖传播。这个图还有一个用途给新成员看比人肉讲半小时的模块划分有效得多。调试configure过程时--trace是后悔药。它能打印CMake每一条函数调用的具体位置和变量值cmake --trace --trace-expand -S . -B build-trace--trace-expand会把变量展开成实际值方便看到底是哪个路径被拼错了。我平时排查“这里明明set了变量为什么那边是空”的时候就用这一招。如果输出太多可以先grep关键词或者关掉其它不必要的if分支。另一个我习惯做的“体检”是检查有没有target泄漏了不该有的编译选项。在顶层临时加一段get_property(dirs DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} PROPERTY INCLUDE_DIRECTORIES) message(STATUS Top-level include dirs: ${dirs})如果在顶层还有include目录说明有子包用include_directories污染了全局。这种“卫生检查”虽然简单但在大型工程里很管用。我给工程定下的规矩是顶层CMakeLists里不允许出现include_directories、link_directories、add_compile_options这三个命令谁用谁就重写。这条规矩执行下来依赖关系清晰了很多。最后说到自己在老工程上最深的教训一次上线前因为某个target的PUBLIC依赖多传了一层导致release版链接了一个Debug的第三方库运行时出现诡异的堆栈损坏排查了一天。事后用依赖图才发现是那个公共接口库不该带上-l参数。从那以后我每次新增target都会先问一句这个依赖是PRIVATE还是PUBLIC头文件里到底用到了谁的符号宁可多写两行不敢少想一步。希望这个”从能编译到可维护“的拆解思路能帮到你。CMakeLists管理大型工程本质不是把配置写漂亮而是把依赖关系变成能被机器检查的规则然后遵守它。本文还有配套的精品资源点击获取
返回列表