版本管理: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_VERSION2.1.3完整版本字符串
PROJECT_VERSION_MAJOR2主版本号
PROJECT_VERSION_MINOR1次版本号
PROJECT_VERSION_PATCH3补丁号

有了这些变量,你就可以在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:最近的tag
  • 5:自tag以来有5次提交
  • g3a4b2c1:当前commit的短哈希
  • -dirty:工作区有未提交的修改

你想想看,有了这个信息,你拿到任何固件都能反查到它对应的源码状态。我曾经靠这个特性,半小时定位了一个「只在某个特定commit上出现」的时序bug。

注意:如果构建环境没有安装Git,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架构图

把上面几块拼起来,整个版本管理的流程就清晰了。我画了一张图帮你理解:

CMake版本管理自动生成流程 project() 版本声明 静态版本号 (2.1.3) Git 仓库信息 tag / commit / dirty状态 构建时间戳 string(TIMESTAMP ...) CMake 构建系统 execute_process + configure_file 自动生成的 version.h 包含版本宏、Git描述、构建时间 固件 .elf / .hex

从图上你能看到,三个输入源(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嵌入式,几乎没出过问题。你想想看,当客户拿着固件问你「这是哪个版本」时,你只需要让他读一下串口打印的版本信息,一切就都清楚了。这才是工程化的做法。


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