代码生成: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里不仅要写输入文件,还要写生成脚本本身。脚本改了,头文件也应该重新生成。
知识体系图
下面这张图展示了本章的核心逻辑:三种代码生成方式的关系和适用场景。
三种方式各有侧重,但实际项目中经常混合使用。我的建议是:能用configure_file()解决的,就别折腾脚本;需要复杂逻辑的,用add_custom_command();需要手动控制的,加个add_custom_target()兜底。
代码生成用好了,能省下大量重复劳动。你想想看,每次改个引脚配置,都要手动改头文件、改宏定义,多容易出错。让CMake帮你做这些脏活累活,你就能把精力放在真正重要的逻辑上。