38、代码生成:使用configure_file()生成配置文件或头文件。

说实话,很多C++项目里都有这么个痛点:代码里硬编码了一堆路径、版本号、编译选项。换台机器编译,或者换个环境部署,就得手动改代码。我早年带团队时,就见过同事因为忘了改数据库连接串,把测试库的配置直接发到了生产环境……那场面,真是惨不忍睹。

其实CMake早就给了我们一个优雅的解决方案——configure_file()。说白了,它就是个模板引擎。你写一个带占位符的模板文件,CMake在配置阶段把变量填进去,生成真正的源码或配置文件。

核心用法:从模板到成品

先看个最简单的例子。假设你有一个头文件模板 config.h.in

#ifndef CONFIG_H
#define CONFIG_H

#define PROJECT_VERSION "@PROJECT_VERSION@"
#define INSTALL_DIR "@CMAKE_INSTALL_PREFIX@"
#define DEBUG_MODE @DEBUG_MODE@

#endif

然后在CMakeLists.txt里这样写:

cmake_minimum_required(VERSION 3.10)
project(MyApp VERSION 1.2.3)

set(DEBUG_MODE 1)

configure_file(
    config.h.in
    ${CMAKE_CURRENT_BINARY_DIR}/config.h
)

CMake配置完成后,build/config.h 里就会变成:

#ifndef CONFIG_H
#define CONFIG_H

#define PROJECT_VERSION "1.2.3"
#define INSTALL_DIR "/usr/local"
#define DEBUG_MODE 1

#endif

嗯,这里要注意:@VAR@ 这种写法是CMake的变量替换语法。你还可以用 ${VAR},但 @VAR@ 更常见,因为它不会跟CMake自身的变量展开混淆。

实战场景:版本信息自动注入

我在做嵌入式项目时,经常需要把Git提交哈希和编译时间写进固件里。这样出问题了,一看日志就知道是哪个版本。模板文件 version.h.in

#pragma once

#include <string>

namespace app {

struct VersionInfo {
    static constexpr int major = @PROJECT_VERSION_MAJOR@;
    static constexpr int minor = @PROJECT_VERSION_MINOR@;
    static constexpr int patch = @PROJECT_VERSION_PATCH@;
    static constexpr const char* git_hash = "@GIT_HASH@";
    static constexpr const char* build_time = "@BUILD_TIME@";
};

}

CMake里获取Git信息:

execute_process(
    COMMAND git log -1 --format=%H
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
    OUTPUT_VARIABLE GIT_HASH
    OUTPUT_STRIP_TRAILING_WHITESPACE
)

string(TIMESTAMP BUILD_TIME "%Y-%m-%d %H:%M:%S")

configure_file(
    version.h.in
    ${CMAKE_CURRENT_BINARY_DIR}/generated/version.h
)

这样每次CMake配置时,版本信息都是最新的。我个人习惯把生成的文件放到 generated/ 子目录下,方便跟手写的源码区分。

避坑指南:文件权限与路径

我曾经踩过一个坑:用 configure_file() 生成shell脚本,结果生成的脚本没有执行权限。后来才发现,configure_file() 默认不会保留源文件的权限属性。解决办法是手动设置:

configure_file(
    deploy.sh.in
    ${CMAKE_CURRENT_BINARY_DIR}/deploy.sh
    @ONLY
)

# 手动加执行权限
file(CHMOD ${CMAKE_CURRENT_BINARY_DIR}/deploy.sh
     PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE
                 GROUP_READ GROUP_EXECUTE
                 WORLD_READ WORLD_EXECUTE)

另外,路径问题也容易翻车。如果你在模板里写了相对路径,生成后的文件路径是相对于 CMAKE_CURRENT_BINARY_DIR 的。我建议所有输出路径都用绝对路径,或者用 ${CMAKE_CURRENT_BINARY_DIR} 拼接。

高级技巧:条件生成与多平台适配

你想想看,一个项目要同时支持Windows和Linux,配置项肯定不一样。我们可以用CMake的变量来控制生成内容。模板文件 platform_config.h.in

#ifndef PLATFORM_CONFIG_H
#define PLATFORM_CONFIG_H

// 平台检测
#if defined(_WIN32)
    #define PLATFORM_NAME "Windows"
    #define PATH_SEPARATOR '\\'
#elif defined(__linux__)
    #define PLATFORM_NAME "Linux"
    #define PATH_SEPARATOR '/'
#else
    #error "Unsupported platform"
#endif

// 由CMake注入的配置
#define MAX_CONNECTIONS @MAX_CONNECTIONS@
#define LOG_LEVEL @LOG_LEVEL@
#define ENABLE_SSL @ENABLE_SSL@

#endif

CMake里根据平台设置不同值:

if(WIN32)
    set(MAX_CONNECTIONS 100)
    set(LOG_LEVEL "info")
    set(ENABLE_SSL 0)
else()
    set(MAX_CONNECTIONS 500)
    set(LOG_LEVEL "debug")
    set(ENABLE_SSL 1)
endif()

configure_file(
    platform_config.h.in
    ${CMAKE_CURRENT_BINARY_DIR}/platform_config.h
)

这样做的好处是,平台相关的逻辑集中在CMake里,C++代码里只需要包含生成的头文件,不用写一堆 #ifdef 宏判断。

核心流程示意图

下面这张图展示了 configure_file() 的完整工作流程:

configure_file() 工作流程 模板文件 config.h.in CMake变量 @VAR@ / ${VAR} configure_file() 变量替换 + 文件生成 生成的文件 ${CMAKE_CURRENT_BINARY_DIR}/config.h @ONLY 模式只替换 @VAR@,不替换 ${VAR}

@ONLY 模式:控制替换范围

configure_file() 有个 @ONLY 参数,我建议你默认加上。为什么呢?因为如果不加,CMake会把模板里所有 ${VAR} 都尝试替换。万一你的模板文件里有 ${} 这种语法(比如某些脚本语言),就会被误替换,导致生成的文件语法错误。

用法很简单:

configure_file(
    script.py.in
    ${CMAKE_CURRENT_BINARY_DIR}/script.py
    @ONLY
)

这样CMake只会替换 @VAR@ 形式的变量,${VAR} 原样保留。

实战:生成JSON配置文件

除了头文件,configure_file() 也可以生成JSON、XML、INI等任何文本格式。比如生成一个数据库连接配置 db_config.json.in

{
    "host": "@DB_HOST@",
    "port": @DB_PORT@,
    "database": "@DB_NAME@",
    "user": "@DB_USER@",
    "password": "@DB_PASSWORD@",
    "pool_size": @POOL_SIZE@,
    "ssl_enabled": @SSL_ENABLED@
}

CMake里设置变量:

set(DB_HOST "192.168.1.100")
set(DB_PORT 5432)
set(DB_NAME "myapp_prod")
set(DB_USER "app_user")
set(DB_PASSWORD "secret123")
set(POOL_SIZE 10)
set(SSL_ENABLED true)

configure_file(
    db_config.json.in
    ${CMAKE_CURRENT_BINARY_DIR}/config/db_config.json
    @ONLY
)

生成后的JSON文件就是完整的配置,可以直接被程序读取。我个人习惯把敏感信息(比如密码)通过环境变量传入CMake,而不是硬编码在CMakeLists.txt里:

if(DEFINED ENV{DB_PASSWORD})
    set(DB_PASSWORD $ENV{DB_PASSWORD})
else()
    message(FATAL_ERROR "环境变量 DB_PASSWORD 未设置")
endif()

总结一下

configure_file() 的核心价值就四个字:分离配置。把可变的部分写成模板,把不变的部分留在CMake里。这样项目移植、环境切换都变得非常干净。

最佳实践清单:

  • 模板文件后缀用 .in,一目了然
  • 默认加 @ONLY,避免误替换
  • 生成的文件放到 ${CMAKE_CURRENT_BINARY_DIR} 下,不要污染源码目录
  • 敏感信息通过环境变量或CMake缓存变量传入
  • 生成的文件要加入 .gitignore,不要提交到版本库

小技巧:如果你需要生成多个配置文件,可以写一个CMake函数封装 configure_file() 调用,避免重复代码。比如:

function(generate_config input output)
    configure_file(
        ${input}
        ${CMAKE_CURRENT_BINARY_DIR}/${output}
        @ONLY
    )
endfunction()

generate_config(config.h.in config/config.h)
generate_config(db.json.in config/db.json)

注意:configure_file() 只在CMake配置阶段执行一次。如果你修改了模板文件或CMake变量,需要重新运行 cmake 命令(或点击IDE的重配置按钮)才能生效。增量编译不会触发重新生成。

好了,这一讲就到这里。记住:能用模板解决的问题,就别手写。这是我从无数次改配置改到崩溃中总结出来的教训。


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