简介面向 C/C 开发者的 CMake 实战 PDF系统讲解这一跨平台构建工具从入门到项目落地的方法。资源定位清晰适合需要摆脱手写 Makefile、在 Linux/Windows/macOS 多平台统一管理构建流程以及处理多目录、多组件大型工程的开发者。全包仅 1 个 PDF 文件大小 998KB便于离线阅读。内容覆盖 CMake 安装、Hello World 初试、静态库与动态库构建、外部共享库引用、常用变量与指令、INSTALL/FIND 控制指令、Find 模块使用与自定义模块编写并附带多目录工程模板能直接对照实践。同时梳理了常见环境变量与开关选项便于排查构建配置问题。书中配有完整的 CMakeLists.txt 示例代码读者可按目录逐章上机验证。已有 982 人学习下载适合作为系统学习 CMake 的案头参考。通过书中每一步命令和 CMakeLists.txt 写法读者可快速搭建可维护的跨平台构建体系降低多平台交付成本。1. CMake实战到底在解决什么问题从“在我机器上能跑”说起如果你同时维护过 Linux 和 Windows 上的 C 项目大概率经历过这种场景代码在某台 Linux 服务器上编译链接一气呵成换到 Windows 上一打开 Visual Studio 就报一堆 linker 错误然后花一整天检查预处理宏、库搜索路径、运行时库开关最后发现只是某个ifdef没罩住或者依赖库的库里路径写死了。CMake 这个工具从诞生到现在核心目标就是把“构建过程”从“一个人脑里的经验”变成“一份可复现的清单”。它在 CMakeLists.txt 里同时描述源文件、头文件路径、编译选项、链接库、安装规则再由它生成对应平台的构建文件。CMake 值得花时间投入是因为它是目前跨平台 C 项目事实上的标准构建系统无论你是接手老代码、给开源库写客户端还是给自己的算法工程做落地交付总会遇到那个CMakeLists.txt。这篇笔记从零开始把我平时配置 CMake 的顺序和踩过的细节按工程落地路径讲清楚你可以直接照抄也能看出每行配置背后的判断逻辑。适合刚把 CMake 用起来的人也适合被各种“玄学编译失败”折腾过、想系统把规则理清的开发者。2. 白手起家一个最小 CMake 工程从零到跑通2.1 先写一份能跑的 CMakeLists.txt 与目录结构常见的做法是源码目录和二进制目录分开源码目录放工程文件另外建一个 build 目录存放 CMake 生成的中间产物和最终可执行文件。我先给出一个没有任何第三方依赖的最小结构。hello_cmake/ ├── CMakeLists.txt └── src ├── CMakeLists.txt └── main.cpp根目录的CMakeLists.txt内容如下# 声明 CMake 最低版本比你实际用的版本稍低即可 cmake_minimum_required(VERSION 3.15) # project 必须在 add_executable 之前名称会被用于生成工程名 project(hello_cmake LANGUAGES CXX) # 让根目录的 CMakeLists 去处理 src 子目录 add_subdirectory(src)src/CMakeLists.txt负责编译可执行文件# 生成可执行文件 hello_cmake add_executable(hello_cmake main.cpp) # 后续需要时在这里追加 target_include_directoriesmain.cpp就是一个简单的入口文件内容不特殊返回 0 即可。先在工程根目录执行配置和构建命令# -S 指定源码目录-B 指定 build 目录 cmake -S . -B build cmake --build build -j4第一行命令做“配置”CMake 读取所有 CMakeLists.txt检测编译器和平台变量生成对应构建系统Linux 下默认是 MakefileWindows 下默认是 Visual Studio 工程。第二行命令做“构建”调用编译器把所有源文件编成可执行文件可执行文件位置在build/src/下。这里-j4只是给底层构建工具传参让编译并行度变成 4 个任务如果机器核心多调大能加快速度。2.2 配置后的 build 目录里到底多了什么执行完cmake -S . -B build之后build 目录里能看到CMakeCache.txt、CMakeFiles/目录、Makefile还有针对每个子目录生成的cmake_install.cmake文件。CMakeCache.txt是最值得熟悉的文件——它保存了本次配置的全部变量结果包括编译器路径、构建类型、各功能检测结果。很多人遇到“我改了 CMakeLists 为什么不生效”或者“换了个编译器版本怎么还是旧的”根源都在于没有清理或没有正确更新这个缓存。我一般排查问题时先不看 CMakeLists而是直接看缓存当前的值。比如想看构建类型和安装前缀可以这样grep -E CMAKE_BUILD_TYPE|CMAKE_INSTALL_PREFIX build/CMakeCache.txt如果发现某行变量值是空或者指向了错误路径最干净的处理方式是删掉 build 目录重新配置而不是手动改缓存文件。缓存文件格式是变量名:类型值手动编辑缓存里的字符串类型值虽然可行但 CMake 内部可能还有另外的派生状态没更新。2.3 为什么需要 cmake_minimum_required以及版本怎么选cmake_minimum_required(VERSION 3.15)不只做一个“版本门槛”检查它还决定了一组策略policy的启用状态。CMake 大版本更新时会改变某些历史行为比如include_directories的传递性、install规则在某些场景下的默认路径。写一个足够低但能满足你语法的版本号可以避免旧工程一看新版本编译器就报一堆兼容性错误。如果代码里用了target_sources或者FetchContent建议直接设为 3.14 到 3.20 之间不必追最新。另一个细节是project(hello_cmake LANGUAGES CXX)这一行里的LANGUAGES。如果省略CMake 默认同时启用 C 和 CXX碰上某些嵌入式工具链只有 C 编译器、没有 C 编译器的情况构建会在检测阶段直接失败。显式写LANGUAGES CXX能减少这种不必要的检测步骤。3. 现代 CMake 核心用 target 把库和依赖组织起来3.1 target 是什么以及为什么别用过时的全局变量早期 CMake 风格里常见的是include_directories()、add_definitions()、link_libraries()这些命令修改的都是全局属性所有后续 target 都会继承。小工程里这么写很爽但是目标一多就失控你只是想给某个库加一条头文件路径结果它影响了后面所有模块A 库依赖 Boost 的某个版本B 库依赖另一个版本全局设置根本没法隔离。现代 CMake 把这些信息挂在 target 上核心命令如下# 生成一个静态库 math_utils add_library(math_utils STATIC src/math_utils.cpp ) # 让引用 math_utils 的其他 target 自动带上 include 路径 target_include_directories(math_utils PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include ) # 为 math_utils 自己私有的编译宏不会泄漏给下游 target_compile_definitions(math_utils PRIVATE MATH_UTILS_BUILDING_LIB)这里 PUBLIC 的含义是“既影响自己也传递给链接方”PRIVATE 是“只对自己生效”INTERFACE 是“自己不需要但链接方需要”。对新手来说最迷惑的是 PRIVATE 和 PUBLIC 的区别我的经验是先别死记定义用失败案例来理解如果带头文件路径写成 PRIVATE那么链接 math_utils 的可执行文件在编译时找不到math_utils.h因为头文件搜索路径没有传过去如果写成 PUBLIC则信息会一路传递编译可执行文件时就不需要再写一遍。3.2 一个稍微完整的多 target 示例假定工程结构变成这样sample/ ├── CMakeLists.txt ├── include/ │ └── app/version.h ├── libs/ │ ├── CMakeLists.txt │ ├── math_utils/ │ └── string_utils/ ├── src/ │ ├── CMakeLists.txt │ └── main.cpp根目录 CMakeLists.txt 主要做全局配置cmake_minimum_required(VERSION 3.16) project(sample_app LANGUAGES CXX) # 统一设置 C 标准同时让编译器把警告显示出来 set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) add_subdirectory(libs) add_subdirectory(src)libs/CMakeLists.txt里添加两个静态库。注意两个库都设置有头文件搜索路径但互不干扰这是全局 include 方案做不到的# 静态库 math_utils只提供基础数值函数 add_library(math_utils STATIC math_utils/math_utils.cpp ) target_include_directories(math_utils PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/math_utils ) # 静态库 string_utils只提供字符串处理函数 add_library(string_utils STATIC string_utils/string_utils.cpp ) target_include_directories(string_utils PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/string_utils )src/CMakeLists.txt里生成可执行文件并用target_link_libraries声明依赖add_executable(app_main main.cpp ) target_link_libraries(app_main PRIVATE math_utils string_utils )这段配置跑完之后app_main的编译命令行会自动带上两个库的 include 路径链接时也会找到对应的.a文件。这就是现代 CMake 的“依赖传递”你不需要在src/CMakeLists.txt里再次添加include_directories(${PROJECT_SOURCE_DIR}/libs/math_utils)。3.3 静态库还是动态库以及 BUILD_SHARED_LIBS 的作用add_library(math_utils STATIC ...)的STATIC是显式指定静态库。如果想在配置阶段决定静态还是动态可以把它改成# 默认是静态库但如果全局设置了 BUILD_SHARED_LIBSON则按动态库构建 add_library(math_utils ...)add_library不带类型时遵守全局变量BUILD_SHARED_LIBS的值。这个变量通常由用户在配置时传入或者在 CMakeLists 里用option()暴露成 ON/OFF。实际中我不会让业务代码自己去翻这个变量因为一旦两个库需要不同策略全局变量就失灵了。更好的方式是为每个库单独用add_library声明类型只在需要给使用者提供选择时才把它暴露成option。另外要留意.a和.so的符号链接问题同一份代码编译成静态库时所有对象文件都被打包进去最后链接可执行文件时即使有多个库也不存在“运行时找不到 so”的问题变成动态库后Windows 上还需要处理__declspec(dllexport)导出符号否则外部模块拿不到函数。这属于跨平台章节的范畴第 4 章细述。4. 跨出本机生成器、工具链与交叉编译4.1 不同平台上的“生成器”决定了构建文件长什么样在同一份 CMakeLists.txt 上Windows 大概率生成 Visual Studio 解决方案Linux 默认生成 Makefile想更快一点还可以用 Ninja。生成器generator负责把 CMake 的抽象规则转成具体构建工具认识的文件。配置时指定生成器的参数是-G# Linux 下用 Ninja 构建比 Makefile 通常更快 cmake -S . -B build -G Ninja cmake --build build选择生成器需要注意Makefile 和 Ninja 是单配置生成器CMAKE_BUILD_TYPE在配置时就要确定是 Debug 还是 ReleaseVisual Studio 和 Xcode 是多配置生成器一个工程文件里同时包含多个配置到构建或 IDE 里再选择。这个差异是跨平台项目最常见的切换成本。4.2 一个实际的跨平台移植问题运行时库和宏定义同一个.cpp文件在 Linux 上编译通过Windows 上却报错通常不是语言层面的问题而是运行时库和系统头文件行为不同。经典坑点之一是数学常量的定义在 Linux 的cmath里M_PI默认可用MSVC 环境里默认不定义必须提前声明_USE_MATH_DEFINES。另一种常见情况是字符串比较函数Linux 有strcasecmpWindows 叫_stricmp。工程代码里如果只是写#include cmath然后直接用M_PI跨平台时翻车概率非常高。我一般会在一开始就把平台差异收敛到一个公共头文件里而不是在每个源文件里补#ifdef// platform_compat.h: 统一处理平台差异 #pragma once #ifdef _WIN32 #define _USE_MATH_DEFINES #include cmath #ifndef M_PI #define M_PI 3.14159265358979323846 #endif #else #include cmath #endif #ifdef _WIN32 #define strcasecmp _stricmp #endif这个头文件被所有源文件包含。只要平台相关代码集中在这一处后续维护只需要在这一个文件里加宏而不是 20 个源文件里各加一段。4.3 交叉编译让 CMake 找到正确工具链而不是用宿主编译器如果你的目标平台是 ARM Linux 或其他嵌入式平台而开发机是 x86最常见的做法是提供一个工具链文件。工具链文件不是 CMakeLists它只是一组变量定义告诉 CMake“别探测宿主机编译器了用我给的路径”。# toolchain-arm.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) # 根文件系统路径指向目标平台的 sysroot set(CMAKE_SYSROOT /opt/arm-linux-gnueabihf/sysroot) # 头文件和库搜索策略程序可执行文件用宿主机的库和头文件只搜目标平台 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_FILE指定它cmake -S . -B build-arm -DCMAKE_TOOLCHAIN_FILEtoolchain-arm.cmake这里的CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER意思是查找程序类工具时不禁锢在 sysroot 里因为编译器本身的辅助工具如as、ld需要由工具链携带但编出来的二进制要链接的库必须在目标平台上找所以后两项设为ONLY。如果发现find_package找到的是宿主机里的库多半就是这两个模式没设对。4.4 特性检测别猜编译器支不支持直接跑小程序验证跨平台工程里还有一种普遍需求确认某个函数或特性在目标平台是否可用。常见做法是用 CMake 的check_symbol_exists。它的原理是在配置阶段生成一个小的 C 文件并尝试编译根据结果设置变量。include(CheckCXXSourceCompiles) # 检查是否支持 aligned_alloc check_cxx_source_compiles( #include cstdlib int main() { void* p std::aligned_alloc(64, 1024); (void)p; return 0; } HAVE_ALIGNED_ALLOC) if(HAVE_ALIGNED_ALLOC) target_compile_definitions(my_lib PRIVATE HAVE_ALIGNED_ALLOC1) endif()这种方式比在代码里死等某个编译器版本要可靠。因为“编译器版本号”只能代表某个大版本行为同一个版本还可能有不同标准库实现。运行时检测到结果后生成的HAVE_ALIGNED_ALLOC宏才可以安全地在源码层做条件编译。5. CMake 实战高频踩坑记录五个值得背下来的问题5.1 改了 CMakeLists.txt 但不生效现象修改一个target_include_directories之后重新执行cmake --build build发现编译命令里完全没有新路径手动删掉某个源文件列表里的 .cpp 文件后重新构建居然还在编译这个文件。原因cmake --build只负责调用底层构建工具并不会主动重新执行 CMake 配置。CMake 会在自己生成构建规则时写入一些依赖信息让 Make 或 Ninja 在 CMakeLists.txt 变更后自动重新配置但如果你改的文件恰好不在它监视的范围内比如一个新创建但尚未被任何 target 引用的目录它感知不到。解决直接删掉 build 目录重新执行cmake -S . -B build cmake --build build。不要试图自己去改CMakeCache.txt或半路生成的构建文件。与其反复踩这个坑不如在本地写一个rebuild.sh把清理和配置放在一起#!/bin/bash set -e rm -rf build cmake -S . -B build -DCMAKE_BUILD_TYPEDebug cmake --build build -j45.2 Windows 下编译通过链接时一堆“无法解析的外部符号”现象同一个库在 Linux 上链接正常Windows 下生成动态库后使用方工程报一堆 unresolved external symbol且集中在类成员函数上。原因Windows 的动态库必须显式导出符号否则__declspec(dllimport)和dllexport对不上。Linux 的默认可见性是“全部导出”Windows 默认是“不导出”。我见过很多跨平台项目在移植到 Windows 时只顾着ifdef宏忘了处理导出标记。解决库内部公共头文件统一加导出宏并区分构建和引用状态。下面是常见模板#ifdef _WIN32 #ifdef MYLIB_BUILDING_LIB #define MYLIB_API __declspec(dllexport) #else #define MYLIB_API __declspec(dllimport) #endif #else #define MYLIB_API #endif class MYLIB_API MathUtils { public: double Sqrt(double value); };同时在 CMakeLists 里给库加上构建时宏add_library(math_utils SHARED src/math_utils.cpp) target_compile_definitions(math_utils PRIVATE MYLIB_BUILDING_LIB)加了这个之后链接问题一般就消失了。还要注意 Windows 下的动态库会产生两个文件.dll运行时加载和.lib链接时导入库。CMake 会把导入库放在CMAKE_ARCHIVE_OUTPUT_DIRECTORY指定的位置部署时只复制.dll不够如果你在其他机器上换了个路径又要开始新一轮 “找不到 DLL”。5.3 静态库链接顺序导致 undefined reference现象项目里有 A、B 两个静态库B 依赖 A。链接时可执行文件先写了 B 后写了 A结果报“undefined reference to A 里的函数”把顺序换过来就通过。原因静态链接器按顺序扫描库文件并且只提取“当前还没解决符号”的对象。先把 B 扫完时它的符号被记为未解决但尚无对象被提取等到扫 A 时如果 A 里面某个对象正好定义了被依赖的函数这个对象才被提取但 B 的引用已经记录在更早的位置。问题在于“循环依赖”和“顺序过期”很容易被误判为代码本身的问题。解决不要手写一串.a文件路径来链接而是把所有依赖都平铺在target_link_libraries里并让 CMake 根据 target 依赖关系自动排序target_link_libraries(app PRIVATE string_utils math_utils )如果出现了循环依赖A 依赖 BB 也依赖 A需要先重构代码把公共部分抽到 C而不是靠链接参数反复试顺序。循环静态依赖在 Linux 上最终会因链接器模型问题变得无解。5.4 find_package 找到了错误版本的库现象机器上装了 OpenCV 3.4 和 OpenCV 4.5find_package(OpenCV REQUIRED)结果命中的是 3.4另一个方向是完全相反系统默认路径里装着一个旧库但我们希望优先使用自己编译安装到自定义前缀下的版本。原因find_package在找 Config 文件时会依次搜索系统路径、CMAKE_PREFIX_PATH、注册表/标准包位置。多个版本并存时找错版本几乎无法完全避免只能通过路径优先级控制。解决优先使用自己指定的前缀在配置命令里显式注入cmake -S . -B build \ -DCMAKE_PREFIX_PATH/opt/opencv4.5/lib/cmake/opencv4;/opt/otherlib还可以用-Dxxx_DIR直接指向具体的xxxConfig.cmake所在目录cmake -S . -B build -DOpenCV_DIR/opt/opencv4.5/lib/cmake/opencv4另外要看匹配到的是 Config 模式OpenCVConfig.cmake还是 Module 模式FindOpenCV.cmake。两种模式搜索路径完全不同前者认xxx_DIR后者认CMAKE_MODULE_PATH。定位问题时先用cmake -LAH build看OpenCV_DIR当前指向哪里。5.5 换 IDE 生成器后安装路径失效现象set(CMAKE_INSTALL_PREFIX /opt/myapp)在 Makefile 生成器下正常换成 Visual Studio 多配置生成器后安装到的工作目录多出一层Debug/Release或者安错了目录。原因多配置生成器在其输出目录里默认按配置分目录install的DESTINATION如果写死相对路径会追加配置名。这不是 CMake 的 bug而是生成器语义差异。解决安装路径尽量使用绝对路径或include(GNUInstallDirs)提供的变量同时要用--config指定配置否则安装阶段不知道该取哪份产物cmake --install build --prefix /opt/myapp --config Release如果发现路径仍不对逐层检查build/cmake_install.cmake里生成的安装目标路径这一文件里内容比缓存的变量更直接。6. 把 CMake 工程做成正式产物install 规则、CPack 与排障习惯前面章节解决了“编译和链接跑通”但工程交付给同事时不只是把可执行文件拷过去还要把头文件、库文件按标准目录放好。install规则搞定的是“构建产物如何分发”的问题。我一般在 CMakeLists 末尾统一写安装规则尽量不散落在子模块里。# 让 CMAKE_INSTALL_LIBDIR 等变量根据平台自动适配 include(GNUInstallDirs) install(TARGETS math_utils string_utils app_main RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} ) install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) # 安装 CMake 包配置文件方便其他项目 find_package install(EXPORT app_main_Targets FILE app_mainConfig.cmake NAMESPACE app_main:: DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/app_main )执行安装的命令是cmake --install build --prefix /opt/myappinstall(TARGETS ...)尾部那三行是很多人觉得繁琐的地方。它的实际作用是告诉 CMake“这个 target 生成的可执行程序放 bin动态库放 lib静态库和导入库也放 lib。” 如果把某一个 target 的 RUNTIME 和 LIBRARY 漏写动态库可能被默认放到 bin或者干脆不安装。建议每加一个 target 就顺手补全 install 规则别存到发布前再统一处理。多目标导出时为了不跟系统里已有包重名我习惯给导出的配置文件加一个工程名前缀并用NAMESPACE把目标名改成带域名的形式。这样其他工程通过find_package引入后引用的是app_main::math_utils而不是裸的math_utils从名称上就能直接看出是哪个包提供的排查依赖关系时一眼定位。CPack 做打包时逻辑很依赖 install 规则是否完整。想拿它出 tar.gz 或 zip最少只需要两行include(CPack)然后执行cpack -G TGZ。它会把 install 规则里的目标目录结构原样收进压缩包。如果发现打包出来的目录里少了头文件或者多了无关目录回头看 install 规则别先怀疑打包格式。说一个我自己的排障习惯遇到 CMake 某个变量行为不可预期时先立刻跑一行命令看清楚真实值而不是反复改 CMakeLists 猜。cmake -LAH build可以列出所有已定义的变量和说明配合--trace-source可以定位是哪一行修改了它。做交叉编译时这两个命令几乎是必备工具。最后补一句小提示如果你发现自己长期靠着删 build 目录来解决问题那么离掌握 CMake 还差一个层次。能主动去CMakeCache.txt和cmake_install.cmake里找到答案才算真正把 CMake 从“别人的工具”变成自己可控的构建流程。希望这篇整理能帮你少走点弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?