3. CMake跨平台构建系统

CMake 不是编译器,也不是构建工具本身,而是一个“构建系统生成器”。它读取项目根目录下的 CMakeLists.txt,结合当前平台、编译器、生成器和用户传入的缓存变量,生成对应平台的原生构建文件:在 Windows + Visual Studio 下生成 .sln 与 .vcxproj,在 Linux/macOS 下生成 Makefile 或 Ninja 构建文件。理解这一点,是组织跨平台工程的前提——我们写的是一份“描述构建意图”的脚本,而不是某个平台的构建脚本。

CMakeLists.txt 的组织方式

一个可维护的跨平台项目通常采用“根 + 子目录”的层级组织。根 CMakeLists.txt 负责全局策略、最低版本、项目声明、编译选项和子目录引入;每个子目录的 CMakeLists.txt 只负责本目录内的目标定义。这样做的价值在于:模块边界与目录边界一致,新增平台或新增模块时改动范围可控。

cmake_minimum_required(VERSION 3.20)
project(CrossDemo VERSION 1.0.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
  set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE)
endif()

add_subdirectory(src/core)
add_subdirectory(src/app)

注意 CMAKE_CXX_EXTENSIONS OFF:它关闭 GNU 扩展,使代码在 MSVC 与 GCC/Clang 下语义更一致,这是跨平台工程中很实用的一条纪律。另外,单配置生成器(Makefile、Ninja)需要显式指定 CMAKE_BUILD_TYPE,而多配置生成器(Visual Studio、Xcode)由 --config 决定,因此上面的兜底判断只对单配置生成器生效。

目标与依赖管理

现代 CMake 的核心是“以目标为中心”。每个库或可执行文件都是一个 target,依赖通过 target_link_libraries 表达,并且要区分 PUBLIC、PRIVATE、INTERFACE 三种传播范围:PRIVATE 表示仅本目标使用,PUBLIC 表示本目标使用且传递给依赖它的目标,INTERFACE 表示本目标不使用但必须传给使用者。头文件目录同理,应使用 target_include_directories 而不是全局的 include_directories。

# src/core/CMakeLists.txt
add_library(core STATIC core.cpp)
target_include_directories(core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_compile_features(core PUBLIC cxx_std_17)

# src/app/CMakeLists.txt
add_executable(app main.cpp)
target_link_libraries(app PRIVATE core)

这里 core 的 include 目录是 PUBLIC,因此 app 链接 core 后自动获得该头文件路径,无需重复声明。这种“依赖自带使用要求”的模型,正是跨平台工程避免路径错乱的关键。

平台条件判断

平台差异应尽量收敛到少数位置,而不是散落在每个源文件里。CMake 提供了 WIN32、APPLE、UNIX、CMAKE_SYSTEM_NAME 等变量,以及 if() 条件。常见做法是:平台相关的源文件、链接库、编译定义集中在一个判断块中。

if(WIN32)
  target_sources(core PRIVATE platform_win.cpp)
  target_link_libraries(core PRIVATE ws2_32)
  target_compile_definitions(core PRIVATE PLATFORM_WINDOWS=1)
elseif(APPLE)
  target_sources(core PRIVATE platform_mac.cpp)
  target_link_libraries(core PRIVATE "-framework CoreFoundation")
else()
  target_sources(core PRIVATE platform_linux.cpp)
  target_link_libraries(core PRIVATE pthread)
  target_compile_definitions(core PRIVATE PLATFORM_LINUX=1)
endif()

需要强调的是:if(WIN32) 判断的是“目标平台”,在交叉编译时它反映的是目标系统而非宿主系统,这一点与直觉不同,务必留意。同时,能用生成器表达式(如 $<PLATFORM_ID:Windows>)表达的地方优先用生成器表达式,因为它能在生成阶段而非配置阶段求值,对多配置生成器更友好。

生成器与构建类型

生成器决定“产出什么形式的构建文件”,构建类型决定“优化与调试信息如何取舍”。二者是正交的。常用组合如下表所示。

生成器配置方式典型平台构建类型来源
Unix Makefiles单配置Linux/macOSCMAKE_BUILD_TYPE
Ninja单配置全平台CMAKE_BUILD_TYPE
Visual Studio 17 2022多配置Windows--config 参数
Xcode多配置macOS--config 参数

典型命令为:cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release,随后 cmake --build build。使用 -S/-B 的源目录与构建目录分离方式,可以保证源码树不被中间文件污染,这是跨平台协作的基本卫生习惯。若使用多配置生成器,则改为 cmake --build build --config Release。

安装与导出配置

当项目需要被其他工程以 find_package 方式复用时,就要提供安装规则与导出配置。核心是三步:用 install(TARGETS ...) 安装产物,用 install(EXPORT ...) 导出目标信息,用 configure_package_config_file 生成 xxxConfig.cmake。这样下游只需 find_package(CrossDemo REQUIRED) 即可获得带 include 路径和依赖关系的导入目标。

install(TARGETS core
  EXPORT CrossDemoTargets
  ARCHIVE DESTINATION lib
  LIBRARY DESTINATION lib
  RUNTIME DESTINATION bin)

install(EXPORT CrossDemoTargets
  FILE CrossDemoTargets.cmake
  NAMESPACE CrossDemo::
  DESTINATION lib/cmake/CrossDemo)

下游使用时写 target_link_libraries(app PRIVATE CrossDemo::core),命名空间前缀能有效避免目标名冲突。至此,一个跨平台工程从“能编译”走向“可被复用”。

CMake 跨平台构建:从源码描述到可复用包 CMakeLists.txt 根 + 子目录层级 target 为中心 配置阶段 configure 平台判断 WIN32/APPLE 缓存变量 / 生成器表达式 生成阶段 generate 选择生成器 -G 构建类型 Release/Debug 单配置生成器 Unix Makefiles / Ninja -DCMAKE_BUILD_TYPE=Release cmake --build build 多配置生成器 Visual Studio / Xcode cmake --build build --config Release Debug/Release 共存于同一工程 构建产物 libcore.a / core.lib app.exe / app 依赖关系已解析 安装与导出:install(TARGETS) → install(EXPORT) → CrossDemoConfig.cmake 下游:find_package(CrossDemo REQUIRED) target_link_libraries(app PRIVATE CrossDemo::core) 命名空间前缀避免目标名冲突,依赖自带使用要求
跨平台工程的一条实用纪律:把平台差异集中到少数 if(WIN32)/elseif(APPLE)/else() 块或生成器表达式中,业务代码尽量保持平台无关。这样新增平台时,改动点清晰可数。
常见误区:在根目录使用全局的 include_directories 和 link_libraries。它们会污染所有目标,导致依赖关系不可追踪。应始终使用 target_* 系列命令,让每个目标的依赖显式可见。

把以上几部分串起来,一个跨平台 CMake 工程的工作流就是:源码树中维护层级化的 CMakeLists.txt,用 target 表达模块与依赖,用平台判断和生成器表达式隔离差异,用生成器与构建类型控制产物形态,最后用安装导出把工程变成可被 find_package 复用的包。掌握这条链路,后续引入第三方库、交叉编译和 CI 多平台构建时,都会顺畅很多。