13、CMake实战:现代CMake项目结构(3.0+)、FetchContent管理依赖、Sanitizer集成、跨平台编译最佳实践

说实话,CMake 这门工具,很多人用了好几年还停留在「能跑就行」的阶段。我记得刚入行那会儿,项目里的 CMakeLists.txt 动辄几百行,全局变量满天飞,target_link_libraries 写得跟蜘蛛网似的。后来接触了现代 CMake(3.0+)的写法,才恍然大悟——原来构建系统也可以写得像代码一样优雅。

这一章,我就带你看看现代 CMake 到底该怎么写。不是那种「能用就行」的写法,而是真正能上生产环境、能跨平台、能让你少熬夜的实战套路。

13.1 现代 CMake 项目结构(3.0+)

先说说项目结构。我个人习惯的现代 CMake 项目,长这样:

my_project/
├── CMakeLists.txt          # 顶层构建文件
├── cmake/
│   ├── FetchContent.cmake  # 依赖管理模块
│   └── Sanitizer.cmake     # Sanitizer 配置
├── src/
│   ├── CMakeLists.txt
│   └── main.cpp
├── libs/
│   ├── core/
│   │   ├── CMakeLists.txt
│   │   └── core.cpp
│   └── utils/
│       ├── CMakeLists.txt
│       └── utils.cpp
├── tests/
│   ├── CMakeLists.txt
│   └── test_core.cpp
└── extern/                 # FetchContent 下载的依赖放这里

顶层 CMakeLists.txt 怎么写?我建议用 cmake_minimum_required(VERSION 3.15) 起步,别再用 2.8 了。3.15 之后很多特性才真正成熟。

cmake_minimum_required(VERSION 3.15)
project(MyProject VERSION 1.0.0 LANGUAGES CXX)

# 设置 C++ 标准,别用全局的 CMAKE_CXX_STANDARD
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

# 开启测试
enable_testing()

# 添加子目录
add_subdirectory(src)
add_subdirectory(libs/core)
add_subdirectory(libs/utils)
add_subdirectory(tests)

这里有个坑:千万别在顶层设置全局的编译选项。我见过太多项目在顶层写 add_compile_options(-Wall -Wextra),结果所有 target 都继承了这个选项,包括第三方库。正确的做法是用 target_compile_options 针对每个 target 单独设置。

核心原则:现代 CMake 是「面向 target」的,不是「面向变量」的。能用 target_xxx 就别用全局变量。

13.2 FetchContent 管理依赖

依赖管理一直是 C++ 的痛点。以前我们手动下载源码、复制到项目里、或者用 git submodule。说实话,submodule 用起来真的挺痛苦的——版本管理混乱,更新麻烦。

CMake 3.11 引入了 FetchContent,3.14 之后稳定了。我现在所有项目都用它来管理依赖。

来看一个实际例子。假设你的项目需要 fmtlibnlohmann_json

include(FetchContent)

# 声明依赖
FetchContent_Declare(
    fmt
    GIT_REPOSITORY https://github.com/fmtlib/fmt.git
    GIT_TAG 10.1.1
    GIT_SHALLOW TRUE
)

FetchContent_Declare(
    nlohmann_json
    GIT_REPOSITORY https://github.com/nlohmann/json.git
    GIT_TAG v3.11.2
    GIT_SHALLOW TRUE
)

# 下载并构建
FetchContent_MakeAvailable(fmt nlohmann_json)

就这么简单。然后你在 target 里直接 target_link_libraries(my_target PRIVATE fmt::fmt nlohmann_json::nlohmann_json) 就行了。

小技巧:加上 GIT_SHALLOW TRUE 可以只拉取最新 commit,不下载完整历史,能省不少时间。我在 CI 环境里经常这么干。

我曾经遇到过一个坑:两个依赖都依赖了同一个第三方库的不同版本。FetchContent 默认会报错。解决办法是用 OVERRIDE_FIND_PACKAGE 或者手动指定版本。嗯,这里要注意,最好在项目初期就统一好依赖的版本。

13.3 Sanitizer 集成

Sanitizer 是 Google 出品的内存错误检测工具。说白了,就是帮你找出 use-after-free、内存泄漏、越界访问这些 bug。我建议每个 C++ 项目在开发阶段都开启它。

现代 CMake 集成 Sanitizer 非常方便。我一般写一个独立的 cmake 模块:

# cmake/Sanitizer.cmake
function(enable_sanitizer target)
    if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
        message(WARNING "Sanitizer 建议只在 Debug 模式下使用")
    endif()

    if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
        target_compile_options(${target} PRIVATE -fsanitize=address -fsanitize=undefined)
        target_link_options(${target} PRIVATE -fsanitize=address -fsanitize=undefined)
    elseif(MSVC)
        target_compile_options(${target} PRIVATE /fsanitize=address)
        target_link_options(${target} PRIVATE /fsanitize=address)
    endif()
endfunction()

然后在 CMakeLists.txt 里这样用:

add_executable(my_app src/main.cpp)
target_link_libraries(my_app PRIVATE core utils)

# 只在 Debug 模式下开启 Sanitizer
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
    include(cmake/Sanitizer.cmake)
    enable_sanitizer(my_app)
endif()

注意:Sanitizer 会显著降低运行速度(大约 2-5 倍),所以只建议在 Debug 模式下开启。Release 模式千万别开,否则性能会崩。

我曾经在一个线上服务里忘了关 Sanitizer,结果内存占用直接翻了三倍,被运维同事追着骂了一下午。从那以后,我养成了在 CMake 里用 CMAKE_BUILD_TYPE 做条件判断的习惯。

13.4 跨平台编译最佳实践

跨平台编译,说白了就是让你的代码在 Windows、Linux、macOS 上都能编译通过。这事说起来简单,做起来全是坑。

我总结了几条经验:

  • 别用平台相关的宏:比如 _WIN32__linux__。能用 CMake 判断的就用 CMake 判断。
  • 路径分隔符:永远用正斜杠 /,CMake 会自动处理。
  • 动态库导出:Windows 上需要 __declspec(dllexport),Linux/macOS 不需要。用 CMake 的 GenerateExportHeader 模块自动生成。

来看一个实际的跨平台配置:

# 判断平台
if(WIN32)
    message(STATUS "检测到 Windows 平台")
    # Windows 上需要设置运行时库
    set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreadedDLL")
elseif(APPLE)
    message(STATUS "检测到 macOS 平台")
    # macOS 上需要设置 macOS 版本
    set(CMAKE_OSX_DEPLOYMENT_TARGET "10.15")
elseif(UNIX)
    message(STATUS "检测到 Linux 平台")
    # Linux 上可能需要链接 pthread
    find_package(Threads REQUIRED)
endif()

# 跨平台的编译选项
target_compile_options(my_app PRIVATE
    $<$<CXX_COMPILER_ID:MSVC>:/W4 /utf-8>
    $<$<CXX_COMPILER_ID:GNU>:-Wall -Wextra -Wpedantic>
    $<$<CXX_COMPILER_ID:Clang>:-Wall -Wextra -Wpedantic>
)

这里用了 CMake 的生成器表达式 $<...>,可以根据编译器自动选择不同的选项。你想想看,这比写一堆 if 判断清爽多了吧?

最佳实践:在 CI 里至少跑三个平台(Windows、Linux、macOS)的编译。我见过太多「在我机器上能跑」的 bug 了。

13.5 知识体系总览

下面这张图总结了现代 CMake 的核心知识体系。你可以把它当作一个检查清单,看看自己哪些地方还没做到位。

现代 CMake 知识体系 项目结构 • 面向 target 设计 • 子目录分离(src/libs/tests) • 最小版本要求 ≥ 3.15 • 避免全局变量 依赖管理 • FetchContent 声明式管理 • GIT_SHALLOW 加速下载 • 版本冲突处理 • 替代 submodule Sanitizer 集成 • AddressSanitizer • UndefinedBehaviorSanitizer • 仅 Debug 模式开启 • 独立 cmake 模块封装 跨平台编译 • 生成器表达式 • 平台判断(WIN32/APPLE/UNIX) • 运行时库设置 • CI 多平台验证 核心思想:面向 target 设计,避免全局变量,善用生成器表达式

这张图把现代 CMake 的四个核心模块串起来了。你仔细看,每个模块之间其实是有联系的——比如跨平台编译里用到的生成器表达式,在 Sanitizer 集成里也能用。说白了,现代 CMake 的哲学就是「把逻辑写在 target 上,而不是全局变量里」。

好了,这一章的内容就到这里。记住:写 CMake 就像写代码一样,要追求可读性、可维护性。别偷懒,把每个 target 的依赖和选项都写清楚,将来你会感谢自己的。

公众号:蓝海资料掘金营,微信 deep3321