代码生成:configure_file()、add_custom_command()、add_custom_target()、生成头文件

嵌入式项目里,代码生成是个绕不开的话题。说白了,就是让CMake在构建过程中自动帮我们写代码。我最早接触这个功能时,觉得挺玄乎——编译还能自动生成代码?后来用顺手了才发现,这简直是嵌入式开发的「减负神器」。

今天咱们就聊聊三个核心工具:configure_file()add_custom_command()add_custom_target()。它们各自有擅长的场景,配合起来能解决很多实际问题。

configure_file():模板替换,简单粗暴

这个命令我用的最多。它的工作方式很简单:你准备一个模板文件,里面放一些占位符,CMake在配置阶段把这些占位符替换成实际值,生成一个新文件。

举个例子。我在项目中经常需要把版本号、编译时间这些信息编译进固件。以前都是手动改头文件,后来改用configure_file(),省心多了。

先准备一个模板文件 version.h.in

#ifndef VERSION_H
#define VERSION_H

#define APP_VERSION_MAJOR @APP_VERSION_MAJOR@
#define APP_VERSION_MINOR @APP_VERSION_MINOR@
#define APP_VERSION_PATCH @APP_VERSION_PATCH@
#define BUILD_TIMESTAMP "@BUILD_TIMESTAMP@"

#endif

然后在CMakeLists.txt里这样写:

set(APP_VERSION_MAJOR 2)
set(APP_VERSION_MINOR 1)
set(APP_VERSION_PATCH 0)
string(TIMESTAMP BUILD_TIMESTAMP "%Y-%m-%d %H:%M:%S")

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

配置完成后,@APP_VERSION_MAJOR@会被替换成2@BUILD_TIMESTAMP@会被替换成当前时间。生成的文件在构建目录里,不会污染源码树。

我的习惯:模板文件用.h.in后缀,一眼就能看出它是模板。生成的文件放到${CMAKE_CURRENT_BINARY_DIR}/generated/目录下,方便管理。

这里有个坑要注意——configure_file()只在cmake配置阶段执行一次。如果你改了模板文件,需要重新运行cmake才能生效。我刚开始用的时候,改了模板文件直接make,发现没变化,愣了半天才反应过来。

add_custom_command():构建时执行,灵活多变

如果说configure_file()是静态替换,那add_custom_command()就是动态执行。它可以在构建过程中运行任意命令,生成文件。

我遇到过一个场景:项目里有个配置文件是JSON格式,需要转换成C语言的头文件。手动转换太麻烦,用add_custom_command()就完美解决。

add_custom_command(
    OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/config_data.h
    COMMAND python3 ${CMAKE_CURRENT_SOURCE_DIR}/tools/json2header.py
        ${CMAKE_CURRENT_SOURCE_DIR}/config.json
        ${CMAKE_CURRENT_BINARY_DIR}/config_data.h
    DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/config.json
            ${CMAKE_CURRENT_SOURCE_DIR}/tools/json2header.py
    COMMENT "Generating config_data.h from config.json"
)

关键点在于DEPENDS——它告诉CMake,只有当依赖文件发生变化时,才重新执行这个命令。这样避免了每次构建都重复生成,节省时间。

我曾经踩过的坑:忘记写DEPENDS,结果每次构建都重新生成头文件。如果这个头文件又被其他源文件依赖,就会导致所有源文件重新编译。一个项目从增量编译变成全量编译,构建时间从30秒变成5分钟。嗯,那次之后我再也不敢漏掉DEPENDS了。

生成的config_data.h怎么用?把它加到某个库或可执行文件的源文件列表里就行:

add_executable(firmware
    main.c
    ${CMAKE_CURRENT_BINARY_DIR}/config_data.h
)
target_include_directories(firmware PRIVATE
    ${CMAKE_CURRENT_BINARY_DIR}
)

注意,头文件本身不需要编译,但把它列在源文件里,CMake会自动识别依赖关系。当config_data.h重新生成时,所有包含它的源文件都会重新编译。

add_custom_target():独立任务,按需执行

有时候我们需要一个「独立」的任务,不依赖任何源文件,也不生成特定输出。比如生成文档、打包固件、运行测试脚本。这时候add_custom_target()就派上用场了。

我习惯用它来生成一个「版本信息头文件」,每次构建时都更新:

add_custom_target(update_version ALL
    COMMAND python3 ${CMAKE_SOURCE_DIR}/tools/gen_version.py
        ${CMAKE_BINARY_DIR}/generated/version.h
    COMMENT "Updating version header"
)

ALL关键字表示这个目标默认会被构建。如果不加ALL,你需要手动执行make update_version才会运行。

你可能会问:这和add_custom_command()有什么区别?区别在于:add_custom_command()必须绑定到一个输出文件,而add_custom_target()不需要。另外,add_custom_target()每次构建都会执行(除非你加了BYPRODUCTS),而add_custom_command()只在依赖变化时执行。

三者配合:一个完整的例子

咱们来看一个实际项目中的组合用法。假设我们要生成一个pin_config.h,它定义了所有GPIO引脚的宏。引脚配置来自一个YAML文件。

第一步,用configure_file()生成一个Python脚本的配置文件:

set(PIN_CONFIG_YAML "pin_config.yaml")
configure_file(
    ${CMAKE_SOURCE_DIR}/tools/pin_config.py.in
    ${CMAKE_BINARY_DIR}/tools/pin_config.py
)

第二步,用add_custom_command()执行这个脚本,生成头文件:

add_custom_command(
    OUTPUT ${CMAKE_BINARY_DIR}/generated/pin_config.h
    COMMAND python3 ${CMAKE_BINARY_DIR}/tools/pin_config.py
        ${CMAKE_SOURCE_DIR}/${PIN_CONFIG_YAML}
        ${CMAKE_BINARY_DIR}/generated/pin_config.h
    DEPENDS ${CMAKE_SOURCE_DIR}/${PIN_CONFIG_YAML}
            ${CMAKE_BINARY_DIR}/tools/pin_config.py
    COMMENT "Generating pin_config.h"
)

第三步,用add_custom_target()提供一个手动触发的方式:

add_custom_target(regenerate_pins
    COMMAND python3 ${CMAKE_BINARY_DIR}/tools/pin_config.py
        ${CMAKE_SOURCE_DIR}/${PIN_CONFIG_YAML}
        ${CMAKE_BINARY_DIR}/generated/pin_config.h
    COMMENT "Force regenerate pin_config.h"
)

这样,正常构建时自动生成头文件,需要强制重新生成时执行make regenerate_pins

生成头文件的注意事项

代码生成头文件时,有几个细节我特别在意:

  • 输出目录要统一:我习惯把所有生成的头文件放到${CMAKE_BINARY_DIR}/generated/下,然后用target_include_directories()添加这个目录。这样源码里包含时用#include "generated/version.h",路径清晰。
  • 使用include guards:生成的头文件也要有#ifndef保护。模板文件里加上,生成的文件自然就有。
  • 考虑跨平台:如果生成脚本用Python,确保目标机器上有Python环境。嵌入式开发中,我一般用Python3,因为大部分Linux发行版和Windows都支持。
  • 依赖关系要完整DEPENDS里不仅要写输入文件,还要写生成脚本本身。脚本改了,头文件也应该重新生成。
核心思路:代码生成不是炫技,而是解决实际问题。当你发现手动维护头文件很痛苦时,就是该用这些工具的时候了。

知识体系图

下面这张图展示了本章的核心逻辑:三种代码生成方式的关系和适用场景。

CMake代码生成三种方式 代码生成需求 configure_file() 配置阶段执行 适用场景 版本号、编译时间 宏定义、路径配置 模板替换 add_custom_command() 构建阶段执行 适用场景 JSON/YAML转头文件 代码生成器 依赖驱动的生成 add_custom_target() 独立目标执行 适用场景 文档生成 固件打包 手动触发任务 核心原则 静态模板用configure_file | 动态生成用add_custom_command | 独立任务用add_custom_target

三种方式各有侧重,但实际项目中经常混合使用。我的建议是:能用configure_file()解决的,就别折腾脚本;需要复杂逻辑的,用add_custom_command();需要手动控制的,加个add_custom_target()兜底。

代码生成用好了,能省下大量重复劳动。你想想看,每次改个引脚配置,都要手动改头文件、改宏定义,多容易出错。让CMake帮你做这些脏活累活,你就能把精力放在真正重要的逻辑上。


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