
简介这份《CMake实战.pdf》面向需要掌握跨平台自动化构建的C/C开发者、嵌入式工程师及在校学生尤其适合正在从手写Makefile或Autotools转向CMake的中级读者。内容围绕CMake的核心机制展开涵盖安装配置、CMakeLists.txt语法、PROJECT与ADD_EXECUTABLE等常用指令、静态库与动态库构建、外部共享库与头文件引用、CMAKE_INCLUDE_PATH等环境变量以及Find模块的编写与自定义并给出多目录带so生成的工程模板。资源包内共1个PDF文件约998KB以图文与代码示例结合的方式呈现便于对照实践。目前已有978人学习适合作为系统入门与查阅手册帮助读者理解模块化构建思路提升多平台项目的构建效率与工程组织能力。1. CMake 实战从一份 CMakeLists.txt 到可交付的构建系统很多人第一次接触 CMake是因为接手了一个只有CMakeLists.txt、没有 README 的工程敲下cmake ..之后满屏红字最后卡在某个CMake Error at .../Qt5Config.cmake上。CMake 实战要解决的不是「背命令」而是把一份散落的源码目录变成别人 clone 下来就能cmake -B build cmake --build build跑通的构建系统。它适合三类人刚把 Makefile 换成 CMake 的 C/C 开发者、需要在 Windows 和 Linux 之间来回切工具链的跨平台选手、以及要给团队交付 SDK 或库的维护者。这一篇按「先立住概念、再动手复现、最后讲坑」的顺序走核心围绕CMakeLists.txt、ADD_EXECUTABLE、ADD_SUBDIRECTORY、INSTALL这几个高频词展开读完你应该能独立把一个多目录工程改造成可安装、可被find_package消费的形态。2. 先搞懂 CMake 到底在构建流程里干了什么2.1 从源码到可执行文件CMake 站在哪一层编译一个 C 程序本质是「预处理 → 编译 → 汇编 → 链接」四步Makefile 直接描述这四步的依赖关系而 CMake 描述的是「更高一层的意图」我要一个可执行文件、它依赖哪些源文件、链接哪些库、装到哪里。CMake 读取CMakeLists.txt后生成的是构建系统文件——在 Linux 上默认是 Makefile在 Windows Visual Studio 上是.sln/.vcxproj在 Ninja 环境下是build.ninja。这就是makefile和cmake的区别这个热搜词背后的核心Makefile 是构建规则的直接描述CMake 是构建规则的生成器它多了一层抽象换来的是跨平台和依赖管理能力。理解这一层很多报错就顺了。比如cmake ..报找不到编译器那是生成阶段就失败了根本没到编译而cmake --build .报undefined reference那是链接阶段的问题跟 CMake 语法无关。把「生成」和「构建」两个阶段分开看是排查一切 CMake 问题的起点。2.2 一个最小可跑的 CMakeLists.txt 长什么样先不搞多目录就一个main.cpp把最小闭环跑通。目录结构hello/ ├── CMakeLists.txt └── main.cppmain.cpp随便写个打印#include iostream int main() { std::cout hello cmake std::endl; return 0; }CMakeLists.txt只需要三行核心内容# 声明工程名和最低 CMake 版本版本号决定可用命令集 cmake_minimum_required(VERSION 3.16) project(hello LANGUAGES CXX) # 用源文件生成可执行目标目标名 hello产物默认叫 hello add_executable(hello main.cpp)cmake_minimum_required不是形式主义它决定了 CMake 用哪套策略Policy来解释你的脚本写低了会警告写高了老环境跑不了工程里一般锁在团队最低支持版本。project()会顺带定义PROJECT_NAME、PROJECT_SOURCE_DIR等变量后面写安装路径时经常用到。add_executable的第一个参数是目标名它是个逻辑名字后面target_link_libraries、install都靠这个名字引用跟最终产物文件名可以不一致。构建命令固定套路# 在源码目录外建 build保持源码树干净这是官方推荐的 out-of-source 构建 cmake -S . -B build -DCMAKE_BUILD_TYPERelease cmake --build build -j-S指定源码目录-B指定构建目录-D传缓存变量。CMAKE_BUILD_TYPE只在单配置生成器Makefile、Ninja下有效Visual Studio 这种多配置生成器要用--config Release。这一点是新手最容易翻车的地方在 Windows 上设了CMAKE_BUILD_TYPERelease却发现还是 Debug就是因为生成器类型不对。2.3 目标target才是现代 CMake 的基本单位老教程喜欢用include_directories、link_directories这种全局命令现代 CMake 的写法是围绕 target 展开target_include_directories、target_link_libraries、target_compile_options。区别在于作用域——全局命令污染整个目录及子目录target 命令只作用于指定目标还能通过PUBLIC/PRIVATE/INTERFACE把依赖关系传递给下游。add_executable(hello main.cpp) # PRIVATE这个头文件路径只给 hello 自己编译用不对外传播 target_include_directories(hello PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) # 链接系统线程库PRIVATE 表示下游不需要知道 find_package(Threads REQUIRED) target_link_libraries(hello PRIVATE Threads::Threads)PUBLIC表示「我自己用也传给依赖我的人」INTERFACE表示「我不用但依赖我的人要用」。做库的时候这三个关键字选错下游就会报找不到头文件或找不到符号。我一般的原则是可执行文件全用PRIVATE静态库对外暴露的头文件路径用PUBLIC纯头文件库用INTERFACE。3. 多目录工程ADD_SUBDIRECTORY 怎么拆才不乱3.1 目录结构设计与顶层 CMakeLists.txt单文件工程跑通后真实项目通常是这样的project/ ├── CMakeLists.txt ├── src/ │ ├── CMakeLists.txt │ ├── main.cpp │ └── math/ │ ├── CMakeLists.txt │ ├── add.cpp │ └── add.h └── include/ └── project/ └── version.h顶层CMakeLists.txt负责全局设置和子目录调度cmake_minimum_required(VERSION 3.16) project(demo VERSION 1.0.0 LANGUAGES CXX) # 全局 C 标准比在每个 target 上重复设置省事 set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 把子目录加进来子目录里的 CMakeLists.txt 会被依次执行 add_subdirectory(src)add_subdirectory的关键点是它会立即执行子目录的CMakeLists.txt并且子目录会继承父目录的变量普通变量是拷贝缓存变量是共享。所以顶层设的CMAKE_CXX_STANDARD在子目录里能直接用。但要注意子目录里set的普通变量不会回传到父目录这是很多人想在子目录里改全局变量却失败的原因。3.2 子目录里定义库并向上暴露src/math/CMakeLists.txt定义一个静态库# 收集当前目录下的源文件避免手写一长串 add_library(math STATIC add.cpp) # PUBLIC头文件路径既给自己编译用也传给链接 math 的目标 target_include_directories(math PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) # 设置库的输出名产物为 libmath.a set_target_properties(math PROPERTIES OUTPUT_NAME math)src/CMakeLists.txt把库和可执行文件串起来add_subdirectory(math) add_executable(demo main.cpp) # 链接同工程内的目标CMake 会自动处理依赖顺序和头文件路径 target_link_libraries(demo PRIVATE math)这里有个高频疑问target_link_libraries(demo PRIVATE math)里的math是目标名还是库文件名答案是目标名。CMake 看到math是当前工程定义过的 target就会自动建立依赖还会把math的PUBLIC/INTERFACE头文件路径一并传给demo。如果写的是-lmath或libmath.a那就退化成按文件名找库跨平台会出问题。优先链接 target不要链接文件路径这是现代 CMake 的一条硬规矩。3.3 用 target_link_libraries 串起依赖链依赖链一长顺序和可见性就容易乱。假设demo依赖mathmath依赖系统线程库# math 的 CMakeLists.txt find_package(Threads REQUIRED) target_link_libraries(math PUBLIC Threads::Threads)因为用了PUBLICdemo链接math时会自动继承Threads::Threads不需要在demo里再写一遍。如果这里写成PRIVATEdemo在链接阶段就可能报undefined reference to pthread_create因为线程符号没传下来。判断标准很简单这个依赖会不会出现在我对外暴露的头文件里。会就用PUBLIC只在.cpp里用就PRIVATE。提示find_package找到的库通常提供命名空间::目标名形式如Threads::Threads、Qt5::Widgets链接时用这种带::的目标比用${XXX_LIBRARIES}变量更可靠因为它自带头文件路径和编译选项。4. INSTALL 与导出让工程能被 find_package 消费4.1 install 的三类对象目标、文件、目录INSTALL是 CMake 实战里从「能编译」到「能交付」的分水岭。它把构建产物按规则拷到CMAKE_INSTALL_PREFIX下默认 Linux 是/usr/localWindows 是C:/Program Files/xxx。三类常见写法# 安装可执行文件到 bin install(TARGETS demo RUNTIME DESTINATION bin) # 安装库到 lib同时导出供下游 find_package 使用 install(TARGETS math EXPORT mathTargets ARCHIVE DESTINATION lib LIBRARY DESTINATION lib RUNTIME DESTINATION bin) # 安装头文件目录到 include install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/include/ DESTINATION include)TARGETS后面跟目标名DESTINATION是相对CMAKE_INSTALL_PREFIX的路径。RUNTIME对应可执行文件和 Windows 下的 DLLLIBRARY对应 Linux 下的动态库ARCHIVE对应静态库和 Windows 下的导入库。这三个关键字不写全跨平台安装就会漏文件——这是血泪经验Linux 上跑得好好的到 Windows 上 DLL 没装进去。4.2 导出目标文件让下游 find_package 能找到光装文件还不够下游要能find_package(math)用起来得导出配置文件# 把 math 目标导出到 mathTargets.cmake install(EXPORT mathTargets FILE mathTargets.cmake NAMESPACE math:: DESTINATION lib/cmake/math) # 生成 mathConfig.cmake下游 find_package(math) 时加载 include(CMakePackageConfigHelpers) configure_package_config_file( ${CMAKE_CURRENT_SOURCE_DIR}/cmake/mathConfig.cmake.in ${CMAKE_CURRENT_BINARY_DIR}/mathConfig.cmake INSTALL_DESTINATION lib/cmake/math ) install(FILES ${CMAKE_CURRENT_BINARY_DIR}/mathConfig.cmake DESTINATION lib/cmake/math)NAMESPACE math::意味着下游用math::math来引用这个目标。mathConfig.cmake.in里通常只需要一行PACKAGE_INIT加include(${CMAKE_CURRENT_LIST_DIR}/mathTargets.cmake)。下游工程这样用find_package(math REQUIRED) target_link_libraries(app PRIVATE math::math)这套流程走通你的库就具备了被其他 CMake 工程直接消费的能力不用再手动传头文件路径和库路径。很多开源库Eigen、fmt、spdlog都是这个套路cmake下载eigen3之后能find_package(Eigen3)就是因为它们做了导出。4.3 安装路径与 CMAKE_INSTALL_PREFIX 的控制CMAKE_INSTALL_PREFIX可以在配置时指定cmake -S . -B build -DCMAKE_INSTALL_PREFIX/opt/demo cmake --build build -j cmake --install buildcmake --install是 3.15 之后推荐的安装方式比make install更跨平台。如果要做打包还可以配合CPack但那是另一个话题。这里要提醒的是不要用绝对路径写死 DESTINATION比如DESTINATION /usr/lib这样 Windows 上直接失败。始终用相对路径让CMAKE_INSTALL_PREFIX去决定根。5. 避坑与排查那些让 cmake 配置阶段就翻车的细节5.1 现象CMake Error at .../Qt5Config.cmake找不到 Qt原因find_package(Qt5)找不到 Qt 的 CMake 配置目录通常是 Qt 安装路径没进CMAKE_PREFIX_PATH。这在cmake error at c:/qt/qt5.9.4/.../Qt5Config.cmake这类报错里非常典型。解决配置时显式指定前缀路径。cmake -S . -B build -DCMAKE_PREFIX_PATHC:/Qt/5.15.2/msvc2019_64如果同时依赖多个第三方库用分号分隔多个路径。Windows 上路径带空格要加引号。Linux 下如果库装在非标准位置同样用这个变量比改PKG_CONFIG_PATH更直接。5.2 现象改了 CMakeLists.txt 但构建行为没变原因CMake 有缓存CMakeCache.txt里存了上次配置的变量值某些改动尤其是set的缓存变量和find_package结果不会自动刷新。解决删掉 build 目录重新配置或者用cmake -U 变量名清掉特定缓存项。# 最省事的做法整个 build 目录重来 rm -rf build cmake -S . -B build我一般遇到「明明改了却没生效」的玄学问题第一反应就是清缓存。这不是 CMake 的 bug是缓存机制的正常行为理解它比抱怨它有用。5.3 现象undefined reference但头文件明明能找到原因头文件路径对了只说明编译通过链接阶段找不到符号说明库没链上或者链接顺序不对。常见于target_link_libraries写成了PRIVATE但符号其实在头文件里暴露。解决先确认符号属于哪个库再检查可见性关键字。用nm或dumpbin看库里的符号# Linux 下查看静态库导出的符号 nm -C libmath.a | grep add如果符号在库里但链接报错多半是PUBLIC/PRIVATE用反了或者链接顺序里被依赖的库排在了依赖者后面。CMake 的 target 链接会自动处理顺序所以尽量用 target 而不是裸库名能规避大部分顺序问题。5.4 现象Windows 上编译过了运行时报缺 DLL原因动态库装到了bin但可执行文件运行时找不到同目录或 PATH 里的 DLL。解决安装时确保RUNTIME DESTINATION bin对库也生效或者用$TARGET_RUNTIME_DLLS:target在构建后拷贝。更简单的做法是开发阶段把库设成静态库交付阶段再切动态库。# 构建后自动把依赖的 DLL 拷到可执行文件旁边 add_custom_command(TARGET demo POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different $TARGET_RUNTIME_DLLS:demo $TARGET_FILE_DIR:demo COMMAND_EXPAND_LISTS)5.5 现象add_subdirectory 报「目录不存在」或重复定义目标原因路径写错或者同一个子目录被两个父目录add_subdirectory了导致目标名冲突。解决add_subdirectory的路径是相对当前CMakeLists.txt的不是相对工程根。重复添加时 CMake 会报add_library cannot create target ... because another target with the same name already exists。检查是否有多个入口引用了同一目录必要时用if(NOT TARGET xxx)做保护。6. 进阶技巧用 CMakePresets 和工具链文件固化配置配置参数一多命令行就越写越长团队里每个人敲的还不一样。CMake 3.19 之后引入的CMakePresets.json可以把这些固化下来配合CMakeUserPresets.json做个人覆盖。一个最小 preset{ version: 3, configurePresets: [ { name: release, generator: Ninja, binaryDir: ${sourceDir}/build/release, cacheVariables: { CMAKE_BUILD_TYPE: Release, CMAKE_INSTALL_PREFIX: ${sourceDir}/install } } ], buildPresets: [ { name: release, configurePreset: release } ] }之后只需要cmake --preset release cmake --build --preset release跨平台编译还要处理工具链差异用 toolchain file 把编译器和 sysroot 固定下来# toolchains/arm-linux.cmake set(CMAKE_SYSTEM_NAME Linux) set(CMAKE_SYSTEM_PROCESSOR arm) set(CMAKE_C_COMPILER arm-linux-gnueabihf-gcc) set(CMAKE_CXX_COMPILER arm-linux-gnueabihf-g) set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)配置时-DCMAKE_TOOLCHAIN_FILEtoolchains/arm-linux.cmake即可。CMAKE_FIND_ROOT_PATH_MODE_*这三行是交叉编译的关键它决定find_package是去宿主机找还是去目标 sysroot 找写错就会出现「找到了宿主机的 x86 库链接到 ARM 程序里」这种诡异问题。验证一套构建系统是否真的可交付我习惯做三件事在干净目录里cmake --preset release cmake --build --preset release cmake --install build/release全流程跑一遍写一个独立的 consumer 工程用find_package消费安装产物在另一个平台上重复第一步。三件事都过才算真的能交给别人用。我自己最早做库的时候只在自己机器上编译通过就发出去了结果同事find_package死活找不到回头补导出配置花了一整天才理清EXPORT和Config.cmake的关系。从那以后我改任何CMakeLists.txt都会顺手跑一遍 consumer 验证这个习惯帮我省了无数次返工。希望帮到你。本文还有配套的精品资源点击获取