版本管理:CMake与Git版本号、project()版本、版本头文件自动生成
版本管理这事儿,说简单也简单,说复杂真能折腾死人。我早年带团队做嵌入式项目时,最怕的就是拿到一个固件,不知道它是哪个版本编译的——是修了bug的v2.1?还是没修的老代码?后来我学乖了,把版本管理直接嵌进CMake构建流程里,让编译器替我把版本号写进固件。
今天咱们就聊聊,怎么用CMake把Git版本号、project()版本声明、以及自动生成版本头文件这三件事串起来。说白了,就是让版本号不再靠人肉维护。
1. project()版本声明——你的项目身份证
每个CMake项目的第一行,几乎都是project()。但很多人只写了项目名,忽略了版本号。我个人习惯,哪怕是个demo工程,也会写上版本:
cmake_minimum_required(VERSION 3.16)
project(my_firmware VERSION 2.1.3 LANGUAGES C CXX ASM)
这里VERSION 2.1.3会被拆解成四个变量:
| CMake变量 | 值 | 说明 |
|---|---|---|
| PROJECT_VERSION | 2.1.3 | 完整版本字符串 |
| PROJECT_VERSION_MAJOR | 2 | 主版本号 |
| PROJECT_VERSION_MINOR | 1 | 次版本号 |
| PROJECT_VERSION_PATCH | 3 | 补丁号 |
有了这些变量,你就可以在CMakeLists.txt里直接引用。比如给输出文件命名:
set(OUTPUT_NAME "app_${PROJECT_VERSION}.elf")
add_executable(${OUTPUT_NAME} main.c)
嗯,这里要注意:project()的版本号是静态的,你改了CMakeLists.txt它才会变。但实际开发中,我们更希望版本号能跟Git提交自动关联。
2. Git版本号自动提取——让代码自己说话
我在项目中遇到过这样的场景:客户反馈了一个bug,我拿到固件后第一件事就是问「你烧的是哪个commit编译的?」对方一脸懵。后来我决定,每次编译时自动把Git信息写进固件。
CMake里可以用execute_process()调用Git命令:
execute_process(
COMMAND git describe --tags --always --dirty
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE GIT_DESCRIBE
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
execute_process(
COMMAND git rev-parse --short HEAD
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE GIT_COMMIT_HASH
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
这里git describe --tags --always --dirty会输出类似v2.1.3-5-g3a4b2c1-dirty的字符串。什么意思呢?
v2.1.3:最近的tag5:自tag以来有5次提交g3a4b2c1:当前commit的短哈希-dirty:工作区有未提交的修改
你想想看,有了这个信息,你拿到任何固件都能反查到它对应的源码状态。我曾经靠这个特性,半小时定位了一个「只在某个特定commit上出现」的时序bug。
execute_process会报错。建议加上ERROR_QUIET,并设置一个默认值兜底。
3. 版本头文件自动生成——把版本信息写进C代码
光在CMake层面拿到版本号还不够,C代码里也得能读到。我的做法是用configure_file()自动生成一个头文件。
首先,创建一个模板文件version.h.in:
#ifndef VERSION_H
#define VERSION_H
#define FIRMWARE_VERSION_MAJOR @PROJECT_VERSION_MAJOR@
#define FIRMWARE_VERSION_MINOR @PROJECT_VERSION_MINOR@
#define FIRMWARE_VERSION_PATCH @PROJECT_VERSION_PATCH@
#define GIT_DESCRIBE "@GIT_DESCRIBE@"
#define GIT_COMMIT_HASH "@GIT_COMMIT_HASH@"
#define BUILD_TIMESTAMP "@BUILD_TIMESTAMP@"
#endif
然后在CMakeLists.txt里生成实际文件:
# 获取构建时间戳
string(TIMESTAMP BUILD_TIMESTAMP "%Y-%m-%d %H:%M:%S")
# 配置头文件
configure_file(
${CMAKE_SOURCE_DIR}/version.h.in
${CMAKE_BINARY_DIR}/generated/version.h
@ONLY
)
@ONLY表示只替换@变量名@形式的占位符。生成后的version.h会变成:
#define FIRMWARE_VERSION_MAJOR 2
#define FIRMWARE_VERSION_MINOR 1
#define FIRMWARE_VERSION_PATCH 3
#define GIT_DESCRIBE "v2.1.3-5-g3a4b2c1"
#define GIT_COMMIT_HASH "3a4b2c1"
#define BUILD_TIMESTAMP "2025-03-15 14:30:22"
C代码里直接包含这个头文件,就能打印版本信息了:
#include "version.h"
void print_firmware_info(void) {
printf("Firmware: v%d.%d.%d\n",
FIRMWARE_VERSION_MAJOR,
FIRMWARE_VERSION_MINOR,
FIRMWARE_VERSION_PATCH);
printf("Git: %s\n", GIT_DESCRIBE);
printf("Build: %s\n", BUILD_TIMESTAMP);
}
${CMAKE_BINARY_DIR}/generated/),而不是源码目录。这样每次重新构建都会生成新文件,不会污染源码树。
4. 完整流程与SVG架构图
把上面几块拼起来,整个版本管理的流程就清晰了。我画了一张图帮你理解:
从图上你能看到,三个输入源(project版本、Git信息、时间戳)汇聚到CMake,经过configure_file处理后,生成一个包含所有版本信息的头文件。C代码包含这个头文件,最终版本数据就固化到固件里了。
5. 避坑指南与最佳实践
我曾经踩过一个坑:在CI服务器上构建时,Git仓库是浅克隆(--depth 1),导致git describe找不到tag,输出了纯哈希。后来我加了个回退逻辑:
if(NOT GIT_DESCRIBE)
set(GIT_DESCRIBE "unknown-${GIT_COMMIT_HASH}")
endif()
另外还有几个要点:
- 增量构建问题:
configure_file只在输入文件变化时才重新生成。如果你改了Git commit但没改模板,CMake可能不会重新配置。我建议在CMakeLists.txt里加一句add_dependencies,或者干脆每次构建前强制重新配置。 - 跨平台兼容:Windows上Git命令路径可能不在PATH里。可以用
find_program(GIT git)先查找,找不到就报个警告。 - 版本号策略:我个人习惯,发布版本用tag(如v2.1.3),开发版本用
git describe的输出。这样一看就知道是正式版还是开发版。
核心要点总结:
- 用
project(VERSION ...)声明静态版本号 - 用
execute_process提取Git动态信息 - 用
configure_file生成版本头文件 - C代码包含头文件,版本信息随固件发布
这套方案我用了好几年,从STM32到ESP32再到Linux嵌入式,几乎没出过问题。你想想看,当客户拿着固件问你「这是哪个版本」时,你只需要让他读一下串口打印的版本信息,一切就都清楚了。这才是工程化的做法。