26、代码生成:configure_file、模板文件、版本头文件生成、配置头文件

代码生成这事儿,说白了就是让 CMake 在构建前帮你写代码。你想想看,每次改个版本号就要手动改头文件?每次换个平台就要改一堆宏定义?太累了。CMake 的 configure_file 就是干这个的。

26.1 configure_file 的基本用法

configure_file 的作用很简单:读入一个模板文件,把里面的变量替换掉,输出一个新文件。我刚开始用的时候觉得这玩意儿不就是个文本替换吗?后来发现它其实是个「构建时代码生成器」。

基本语法:

configure_file(<input> <output> [COPYONLY] [ESCAPE_QUOTES] [@ONLY])
  • input:模板文件路径(通常是 .in 后缀)
  • output:生成的目标文件路径
  • COPYONLY:只复制,不替换变量
  • ESCAPE_QUOTES:转义引号(默认不转义)
  • @ONLY:只替换 @VAR@ 形式的变量,不替换 ${VAR}

核心机制:CMake 会扫描模板文件中的 @VAR@${VAR} 占位符,用当前 CMake 变量值替换它们。如果变量未定义,替换为空字符串。

26.2 模板文件的设计

模板文件通常以 .h.in.cpp.in 结尾。我个人习惯用 .h.in,因为一眼就能看出这是个模板头文件。

举个最简单的例子。假设我们有个 version.h.in

#ifndef VERSION_H
#define VERSION_H

#define PROJECT_NAME "@PROJECT_NAME@"
#define PROJECT_VERSION "@PROJECT_VERSION@"
#define PROJECT_VERSION_MAJOR @PROJECT_VERSION_MAJOR@
#define PROJECT_VERSION_MINOR @PROJECT_VERSION_MINOR@
#define PROJECT_VERSION_PATCH @PROJECT_VERSION_PATCH@

#endif // VERSION_H

然后在 CMakeLists.txt 里:

cmake_minimum_required(VERSION 3.16)
project(MyApp VERSION 2.1.3)

configure_file(
  "${PROJECT_SOURCE_DIR}/version.h.in"
  "${PROJECT_BINARY_DIR}/version.h"
)

构建后生成的 version.h 长这样:

#ifndef VERSION_H
#define VERSION_H

#define PROJECT_NAME "MyApp"
#define PROJECT_VERSION "2.1.3"
#define PROJECT_VERSION_MAJOR 2
#define PROJECT_VERSION_MINOR 1
#define PROJECT_VERSION_PATCH 3

#endif // VERSION_H

小技巧:模板文件中的字符串变量建议用 @VAR@ 形式,数字或宏定义用 ${VAR}。这样一眼就能区分「这是字符串」还是「这是数值」。

26.3 版本头文件生成

版本管理是 configure_file 最经典的应用场景。我在项目中遇到过好几次版本号不一致的问题——代码里写的是 1.0,安装包却是 1.0.1,debug 起来特别痛苦。

更完善的版本头文件模板 version.h.in

#ifndef VERSION_H
#define VERSION_H

// 自动生成,请勿手动修改
#define APP_NAME             "@PROJECT_NAME@"
#define APP_VERSION          "@PROJECT_VERSION@"
#define APP_VERSION_MAJOR    @PROJECT_VERSION_MAJOR@
#define APP_VERSION_MINOR    @PROJECT_VERSION_MINOR@
#define APP_VERSION_PATCH    @PROJECT_VERSION_PATCH@
#define APP_BUILD_TIME       "@BUILD_TIME@"
#define APP_GIT_HASH         "@GIT_HASH@"

// 版本比较宏
#define APP_VERSION_CHECK(major, minor, patch) \
    (APP_VERSION_MAJOR > (major) || \
     (APP_VERSION_MAJOR == (major) && APP_VERSION_MINOR > (minor)) || \
     (APP_VERSION_MAJOR == (major) && APP_VERSION_MINOR == (minor) && APP_VERSION_PATCH >= (patch)))

#endif // VERSION_H

对应的 CMake 配置:

# 获取构建时间
string(TIMESTAMP BUILD_TIME "%Y-%m-%d %H:%M:%S")

# 获取 Git commit hash(如果存在)
find_package(Git QUIET)
if(Git_FOUND)
  execute_process(
    COMMAND ${GIT_EXECUTABLE} rev-parse --short HEAD
    WORKING_DIRECTORY ${PROJECT_SOURCE_DIR}
    OUTPUT_VARIABLE GIT_HASH
    OUTPUT_STRIP_TRAILING_WHITESPACE
  )
else()
  set(GIT_HASH "unknown")
endif()

configure_file(
  "${PROJECT_SOURCE_DIR}/version.h.in"
  "${PROJECT_BINARY_DIR}/version.h"
)

注意execute_process 在配置阶段执行,不是构建阶段。如果你改了 Git commit 但没有重新 cmake,版本号不会更新。我踩过这个坑——发布时版本号还是旧的,用户反馈 bug 都定位不到正确的 commit。

26.4 配置头文件

配置头文件用来控制「这个功能在哪些平台/条件下启用」。说白了就是编译时的开关。

举个例子,我们有个 config.h.in

#ifndef CONFIG_H
#define CONFIG_H

// 平台检测
#cmakedefine PLATFORM_WINDOWS
#cmakedefine PLATFORM_LINUX
#cmakedefine PLATFORM_MACOS

// 功能开关
#cmakedefine ENABLE_LOGGING
#cmakedefine ENABLE_SSL
#cmakedefine ENABLE_GPU_ACCELERATION

// 路径配置
#define DATA_DIR "@DATA_DIR@"
#define LOG_DIR  "@LOG_DIR@"

// 编译选项
#cmakedefine DEBUG_MODE
#cmakedefine USE_OPENMP

#endif // CONFIG_H

注意这里用的是 #cmakedefine,不是 #define#cmakedefine 是 CMake 的专有指令,它的行为是:

  • 如果 CMake 变量为真(ON/TRUE/1),生成 #define VAR
  • 如果 CMake 变量为假(OFF/FALSE/0/未定义),生成 /* #undef VAR */

在 CMakeLists.txt 里控制这些开关:

# 平台检测
if(WIN32)
  set(PLATFORM_WINDOWS ON)
elseif(APPLE)
  set(PLATFORM_MACOS ON)
elseif(UNIX)
  set(PLATFORM_LINUX ON)
endif()

# 功能开关
option(ENABLE_LOGGING "Enable logging" ON)
option(ENABLE_SSL "Enable SSL support" OFF)
option(ENABLE_GPU_ACCELERATION "Enable GPU acceleration" OFF)

# 路径配置
set(DATA_DIR "${CMAKE_INSTALL_PREFIX}/share/${PROJECT_NAME}")
set(LOG_DIR  "${CMAKE_INSTALL_PREFIX}/var/log/${PROJECT_NAME}")

# 编译选项
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
  set(DEBUG_MODE ON)
endif()

find_package(OpenMP)
if(OpenMP_FOUND)
  set(USE_OPENMP ON)
endif()

configure_file(
  "${PROJECT_SOURCE_DIR}/config.h.in"
  "${PROJECT_BINARY_DIR}/config.h"
)

26.5 知识体系图

下面这张图帮你理清 configure_file 的完整工作流:

configure_file 工作流 模板文件 version.h.in / config.h.in CMake 变量 set() / option() / project() configure_file() 变量替换 + 文件生成 生成的文件 ${PROJECT_BINARY_DIR}/xxx.h 版本头文件 版本号 / Git hash / 构建时间 配置头文件 功能开关 / 平台检测 / 路径 模板中的 @VAR@ 和 ${VAR} 被替换为 CMake 变量的实际值

26.6 避坑指南与最佳实践

configure_file 这么多年,我总结了几条经验:

  1. 输出路径一定要用 ${PROJECT_BINARY_DIR}。别把生成的文件放到源码目录里,否则每次 clean 都要手动删。我见过有人直接输出到源码目录,结果 git status 永远有未跟踪文件。
  2. 模板文件用 .in 后缀。这样 IDE 和编辑器能识别,而且一眼就能看出这是模板,不是最终文件。
  3. 字符串变量记得加引号。在模板里写 @VAR@ 时,如果 VAR 是字符串,一定要写成 "@VAR@",否则生成出来的代码编译会报错。
  4. #cmakedefine 代替 #define @VAR@。前者会自动处理 ON/OFF 的语义,后者你得自己写条件判断。
  5. 不要过度使用。如果只是简单的版本号,用 project() 自带的变量就够了。只有需要复杂逻辑时才用 configure_file

个人习惯:我会在模板文件头部加一行注释 // 自动生成,请勿手动修改。这样其他开发者看到生成的文件就知道去改模板,而不是直接改生成的文件。这个小习惯帮我省了不少沟通成本。

26.7 完整示例:一个生产级配置

最后给一个我在实际项目中用过的完整配置。这个例子结合了版本管理和功能开关:

# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(DataPipeline VERSION 3.2.1 LANGUAGES CXX)

# 功能选项
option(BUILD_TESTS "Build unit tests" ON)
option(ENABLE_REDIS "Enable Redis support" ON)
option(ENABLE_KAFKA "Enable Kafka support" OFF)
option(ENABLE_MONITORING "Enable performance monitoring" ON)

# 依赖检测
find_package(OpenSSL QUIET)
if(OpenSSL_FOUND)
  set(HAS_OPENSSL ON)
endif()

find_package(ZLIB QUIET)
if(ZLIB_FOUND)
  set(HAS_ZLIB ON)
endif()

# 构建信息
string(TIMESTAMP BUILD_TIMESTAMP "%Y-%m-%d %H:%M:%S UTC" UTC)

# 生成配置头文件
configure_file(
  "${PROJECT_SOURCE_DIR}/config.h.in"
  "${PROJECT_BINARY_DIR}/config.h"
)

# 生成版本头文件
configure_file(
  "${PROJECT_SOURCE_DIR}/version.h.in"
  "${PROJECT_BINARY_DIR}/version.h"
)

# 让所有源文件都能找到生成的头文件
target_include_directories(${PROJECT_NAME} PRIVATE ${PROJECT_BINARY_DIR})

对应的 config.h.in

#ifndef CONFIG_H
#define CONFIG_H

// 自动生成,请勿手动修改

// 功能开关
#cmakedefine BUILD_TESTS
#cmakedefine ENABLE_REDIS
#cmakedefine ENABLE_KAFKA
#cmakedefine ENABLE_MONITORING

// 依赖检测
#cmakedefine HAS_OPENSSL
#cmakedefine HAS_ZLIB

// 构建信息
#define BUILD_TIMESTAMP "@BUILD_TIMESTAMP@"

#endif // CONFIG_H

嗯,到这里你应该对 configure_file 有了比较全面的了解。它不复杂,但用好了能让你的项目结构清晰很多。下次改版本号或者加功能开关时,记得用这个方式,别再去手动改头文件了。


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