21、CMake进阶:自定义命令与目标:add_custom_command与add_custom_target的使用,生成源文件、后处理操作。
说实话,很多嵌入式工程师用CMake,就停留在add_executable和target_link_libraries这个层面。项目一复杂,需要自动生成代码、跑脚本、做后处理,就傻眼了。我早年带团队的时候,就见过有人手动去跑Python脚本生成配置文件,然后commit到仓库里——每次改完硬件描述,还得手动跑一遍,漏一次就出bug。
其实CMake早就给你准备好了两把利器:add_custom_command和add_custom_target。说白了,它们就是让你在构建流程里插入自定义的“钩子”,想干啥就干啥。
21.1 核心概念:命令 vs 目标
先搞清楚这两个东西的区别,不然你后面会绕晕。
- add_custom_command:它本身不是一个“构建目标”,它是一段“附加动作”。它必须依附于某个目标(比如一个可执行文件)或者某个文件。当那个目标或文件被构建时,它才会触发。
- add_custom_target:它本身就是一个“构建目标”。你可以直接
make my_target或者ninja my_target来调用它。它没有输出文件,纯粹是为了执行命令而存在。
我个人习惯这样记:add_custom_command是“副作用”,add_custom_target是“入口”。
一句话总结:如果你需要生成一个文件(比如从模板生成.c文件),用add_custom_command。如果你需要执行一个动作(比如烧录、打包、运行测试),用add_custom_target。
21.2 add_custom_command:生成源文件
嵌入式项目里,最常见的场景就是“根据某个配置文件,自动生成C代码”。比如你有一个硬件寄存器映射表(JSON或YAML),需要生成对应的regs.h和regs.c。
我以前做过一个项目,MCU有200多个寄存器,手动写头文件写到崩溃。后来我用Python脚本解析Excel表格,自动生成代码。但问题来了:怎么让CMake在构建时自动调用这个脚本?
# 假设我们有一个Python脚本:gen_regs.py
# 它读取 regs.json,输出 regs.h 和 regs.c
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/regs.h
${CMAKE_CURRENT_BINARY_DIR}/regs.c
COMMAND python3 ${CMAKE_CURRENT_SOURCE_DIR}/gen_regs.py
${CMAKE_CURRENT_SOURCE_DIR}/regs.json
${CMAKE_CURRENT_BINARY_DIR}
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/regs.json
${CMAKE_CURRENT_SOURCE_DIR}/gen_regs.py
COMMENT "Generating register files from regs.json..."
VERBATIM
)
# 然后把这个生成的文件加到你的目标里
add_executable(firmware
main.c
${CMAKE_CURRENT_BINARY_DIR}/regs.c
)
# 别忘了告诉CMake,regs.h 是生成出来的,需要搜索这个路径
target_include_directories(firmware PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
这里有几个关键点,我踩过坑,得跟你讲讲:
- OUTPUT:必须写生成的文件路径。CMake靠这个来判断是否需要重新运行命令。如果输出文件已经存在且比所有
DEPENDS都新,它就不跑了。 - DEPENDS:输入文件。这里我把JSON和脚本都列上了。只要任何一个变了,就会重新生成。
- VERBATIM:这个我建议每次都加上。它保证命令参数被正确转义,不会因为空格或特殊字符出问题。我曾经在Windows上被路径空格坑了一整天,加了
VERBATIM就没事了。
小技巧:生成的文件最好放到CMAKE_CURRENT_BINARY_DIR(构建目录),不要放到源码目录。这样make clean的时候能清掉,也不会污染源码树。
21.3 add_custom_target:后处理操作
生成完代码,接下来就是后处理。比如编译完固件后,自动计算CRC、生成烧录脚本、或者直接调用烧录工具把固件烧到板子上。
这时候add_custom_target就派上用场了。它不产生文件,只执行命令。
# 假设我们已经编译出了 firmware.bin
# 现在需要计算CRC,并生成一个带CRC头的固件
add_custom_target(gen_crc_firmware
COMMAND python3 ${CMAKE_SOURCE_DIR}/tools/append_crc.py
${CMAKE_BINARY_DIR}/firmware.bin
${CMAKE_BINARY_DIR}/firmware_with_crc.bin
DEPENDS firmware # 确保firmware目标先构建完成
COMMENT "Appending CRC to firmware.bin..."
VERBATIM
)
然后你可以这样调用:
make gen_crc_firmware
嗯,这里要注意:DEPENDS后面跟的是CMake目标名(比如firmware),而不是文件名。这样CMake会保证先编译出firmware.bin,再执行你的后处理脚本。
21.4 组合使用:完整的自动化流水线
实际项目中,我经常把两者组合起来,形成一条完整的自动化流水线。下面这张图展示了典型的流程:
你看,整个流程非常清晰:输入文件 -> 自动生成代码 -> 编译 -> 后处理。每一步都是自动触发的,你只需要make一下,所有事情都搞定。
21.5 避坑指南与实用技巧
这些东西我都是真金白银换来的教训,你记一下:
我曾经犯过的错:
- 忘记加DEPENDS:有一次我写了个脚本生成头文件,但没加
DEPENDS。结果改了JSON文件后,重新make,CMake根本没重新运行脚本。我排查了半天,才发现是依赖没写。记住:DEPENDS是增量构建的关键。 - 路径问题:在
COMMAND里写相对路径,结果构建目录和源码目录不同,脚本找不到文件。我现在的习惯是:所有路径都用${CMAKE_CURRENT_SOURCE_DIR}和${CMAKE_CURRENT_BINARY_DIR}拼出来,绝对不出错。 - 跨平台兼容:如果你在Windows上开发,
COMMAND python3可能不行,因为Windows上叫python。我一般用find_package(Python3 REQUIRED),然后用Python3_EXECUTABLE变量,这样跨平台没问题。
几个实用小技巧:
- 用
COMMENT参数加一句说明,构建的时候会打印出来,方便看进度。 - 如果命令很长,可以用
\\换行,或者把命令写到脚本文件里,CMake里只调用脚本。 - 对于
add_custom_target,可以加ALL关键字,让它成为默认构建的一部分。比如add_custom_target(flash ALL ...),这样每次make都会执行烧录?不,我建议别这么干,烧录操作应该手动触发。
21.6 更高级的用法:BYPRODUCTS 与 USES_TERMINAL
这两个参数你可能用得少,但关键时刻很有用。
- BYPRODUCTS:如果你的命令除了
OUTPUT之外,还生成了其他文件(比如日志、中间文件),可以用BYPRODUCTS声明。这样make clean的时候会清理它们。 - USES_TERMINAL:如果你的命令需要交互式终端(比如让你输入密码,或者显示进度条),加上这个参数。CMake会为这个命令分配一个真正的终端,而不是把输出重定向到日志里。
add_custom_command(
OUTPUT ${BINARY_DIR}/calibrated.bin
COMMAND ${CMAKE_SOURCE_DIR}/tools/calibrate.sh
${BINARY_DIR}/firmware.bin
${BINARY_DIR}/calibrated.bin
DEPENDS ${BINARY_DIR}/firmware.bin
BYPRODUCTS ${BINARY_DIR}/calibration_log.txt
USES_TERMINAL
COMMENT "Running calibration (may require user input)..."
VERBATIM
)
嗯,这个USES_TERMINAL我一般在调用烧录工具时用,因为有些烧录器驱动会弹窗让你选择端口。
21.7 总结
说白了,add_custom_command和add_custom_target就是CMake给你的“瑞士军刀”。它们让构建系统不再只是“编译+链接”,而是变成了一个完整的自动化流水线。
我个人觉得,掌握这两个命令,是CMake从入门到进阶的分水岭。你想想看,当你的项目能自动生成代码、自动做后处理、一键烧录,那种感觉真的很爽。团队里其他人还在手动跑脚本的时候,你已经make && make flash搞定一切了。
公众号:蓝海资料掘金营,微信deep3321