Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第三章 结构验算的日子

3.1 版本控制——每一版图纸永久存档

上海的图纸档案柜

1920年代的上海,一位建筑师的事务所里,靠墙立着一排平柜。那是图纸档案柜:扁平的大抽屉一个叠一个,每个抽屉里躺着几卷硫酸纸蓝图。新建成的外滩大楼,从地基到穹顶,每一张图纸都按编号归档。建筑师知道,十年后如果有人要在东翼加盖一层,他必须能找出当年地基的配筋图,否则新楼层可能压垮旧基础。

但图纸档案柜有个致命缺陷:它怕火。

1930年代,汉口一家建筑事务所的档案室失火,整栋大楼的全部施工图纸化为灰烬。那栋楼还在,但它的“设计记忆“永久消失了。后来的工程师想改造配电系统,只能在墙上打洞试探,他们不知道暗埋的管线在哪里,不知道梁柱内部的钢筋走向。每一次改造都是一次赌博。

这是纸质时代的版本管理:集中存储,单点故障,灾难不可逆。

今天,你打开任何一个嵌入式软件仓库。git log 一敲,成百上千条提交记录滚过屏幕。每一条记录着一个时间点、一个作者、一段变更说明、一个完整的代码快照。1920年的建筑师如果看到Git,会嫉妒到失眠。他们毕生追求的“永久存档、可追溯、不可篡改“,在软件世界里已经是默认配置。

但拥有工具不等于拥有能力。真正的挑战不在于学会 git commit,而在于建立一套让团队在两年后仍然能看懂历史的版本管理纪律。

核心洞察:拥有工具不等于拥有能力,版本管理的真正挑战是长期纪律。 图纸档案柜的悲剧源于“集中存储、单点故障、灾难不可逆“,Git 天然解决了这三点,但工具只是前提——让团队在两年后仍能看懂历史,靠的是提交纪律而非工具本身。


图纸管理的基本单元:一次提交的解剖

建筑图纸的管理有三个核心动作:存档(什么时候存)、命名(叫什么)、关联(这张图和哪张图有关系)。版本控制完全对应。

存档时机:什么时候 commit?

建筑工地上,结构工程师不会每焊一根钢筋就更新一次图纸。他们在一个“有意义的完整状态“时才更新:地基浇筑完毕、一层柱网立起、屋面桁架合拢。

代码提交同理。一个健康的提交频率是“完成一个逻辑上完整的变更“时提交一次。具体到嵌入式项目:

  • 太频繁:每改一行就 commit,提交历史变成噪音。就像每运来一车沙子就出一版新图纸,没人看得懂。
  • 太稀疏:两星期 commit 一次,一次提交改了 47 个文件,涉及 3 个模块。这就像把地基、一层、二层的图纸打包成一张图,改图的人不知道从哪下手。

经验法则:一个 commit 应该能在一句话里说清楚做了什么。如果一句话说不清,拆成两个 commit。

好的提交说明:
  "ADC 驱动增加 DMA 双缓冲模式,修复高采样率下数据丢失问题"

坏的提交说明:
  "修改了一些文件"  ← 等于什么都没说
  "fix"              ← 同上
  "更新"             ← 同上

命名规范:Conventional Commits

建筑图纸的编号有规范:结构图 S-101、给排水图 P-201、电气图 E-301。看到编号前缀就知道图纸类型。

代码提交同样需要前缀。业界标准是 Conventional Commits:

<type>(<scope>): <description>

[optional body]

[optional footer]

嵌入式团队的常见 type:

type含义建筑类比
feat新功能新增结构图纸
fixBug 修复勘误单,修正原图纸错误
refactor重构优化梁柱布局,不改变受力
perf性能优化换更高标号混凝土
test测试相关材料试验报告
docs文档施工说明
chore杂项图纸目录更新
ciCI 配置修改检验流程

scope 在嵌入式项目里通常是模块名:can, adc, nvm, com, diag。

举个例子:

feat(can): 增加 CAN 总线 off 状态自动恢复机制

当 CAN 控制器进入 bus-off 状态后,在 128 次 11 位隐性位检测后
自动发起恢复,不再需要上层手动调用 Can_Init()。

Jira: AUTOSAR-1427

这个提交说明让一年后接手的人立刻知道:改了什么模块(can),做了什么改动(新增自动恢复),为什么(不再需要手动恢复),以及关联的需求编号。

关联关系:Tag 是竣工图章

建筑项目有一个特殊时刻:竣工图。竣工图是一个“法定版本“,而不是某一次修改的记录,它代表“这就是最终建成的样子“。如果有纠纷,法院看的是竣工图。

嵌入式软件里,Tag 就是竣工图章。

git tag -a v2.1.3 -m "量产版本,SOP 2025-03 批次,对应标定数据包 cal_20250301"

每一个烧录到 ECU 里的固件,必须有一个对应的 tag。不是大概记得是哪个 commit,是精确的 tag。原因很简单:如果你收到一个从三年前量产车上拆下来的 ECU,你要复现它的软件状态来排查一个间歇性故障,你靠什么定位代码?靠“大概是三月中旬的那个版本“吗?

**语义化版本(Semantic Versioning)**是嵌入式固件版本命名的黄金标准:

v<MAJOR>.<MINOR>.<PATCH>

MAJOR:不兼容的 API 变更(比如 CAN 接口函数签名改了)
MINOR:向后兼容的新功能(比如增加了新的诊断服务)
PATCH:向后兼容的 Bug 修复(比如修复了 SPI 时钟极性配置错误)

对于汽车嵌入式,通常额外加上一个构建号:

v2.1.3-build42  →  版本号 + CI 构建号,确保每一个二进制文件可唯一追溯

核心洞察:一次提交就是一个“有意义的完整变更“,要能用一句话说清楚。 太频繁则历史变噪音,太稀疏则无法拆解。Conventional Commits 让类型与模块一眼可辨;Tag 是量产固件的“竣工图章“,配合语义化版本与构建号,保证每个烧录到 ECU 的固件都能精确追溯。


分支策略:一座楼的多期工程

分支(Branch)在建筑里的类比是分期工程。

一栋综合体大楼可能分三期建设:一期是主楼,二期是裙楼商业,三期是地下连通通道。三期工程共享同一套地基(main 分支),但各自有不同的施工进度和交付日期。

嵌入式项目的分支挑战通常是多版本并行维护:

  • 主线分支(main/trunk):下一个大版本的开发,对应“下一代车型平台的软件“。
  • 发布分支(release/v2.1):当前量产版本的维护,对应“在售车型的软件更新“。
  • 修补分支(hotfix/xxx):紧急修复,对应“召回级别的安全补丁“。

Trunk-Based Development:小团队的简单方案

如果你的团队在 10 人以下,只维护一个主要版本,Trunk-Based Development 是最实用的策略:

main ──●──●──●──●──●──●── (所有开发在 main 上)
        \                /
         feature分支(短期,最长1-2天)

核心纪律:feature 分支的生命周期不超过两天。超过两天还没合并回 main,要么是功能拆解得不够细,要么是有人在做“独立王国“式的开发,把自己关在小黑屋里写两周,然后扔出一个 5000 行的 PR,没人敢认真 review。

GitFlow:多版本并行的重型方案

当你的团队需要同时维护 3 个量产版本(v1.4 在老车型、v2.1 在新车型、v3.0 在下一代平台),Trunk-Based 就不够了。你需要 GitFlow 的结构化分支模型:

main        ●────────────●────────── (量产版本,只接受 release/ 合并)
            |\           |\
release/v2.1 | ●──●──●   | ●──● (发布分支,只修 Bug)
            |           |
develop     ●──●──●──●──●──● (日常开发主线)
                |\     |\
feature/         ●──●   ●──● (功能分支)

汽车嵌入式的一个典型痛苦:Cherry-Pick 跨版本移植。v2.1 上发现了一个 Bug,修好了。但这个 Bug 在 v1.4 上也存在。你需要在 v1.4 的 release 分支上也修一遍。GitFlow 对此没有魔法,它只是把必要的 cherry-pick 操作结构化地记录下来,至少你能追踪“这个 fix 在哪些版本上已应用“。

核心洞察:分支策略取决于团队规模与并行维护的版本数,越复杂不代表越好。 十人以下、单一版本用 Trunk-Based,feature 分支不超过两天,避免“独立王国“式开发;多版本并行才需要 GitFlow。Cherry-Pick 无法消灭,但结构化的分支模型至少让“修复在哪些版本已生效“可追踪。


二进制产物的管理:砖块不是图纸

这里有一个嵌入式团队极易踩的坑:把二进制产物放进 Git 仓库。

.elf 文件、.hex 文件、.bin 文件不是代码,它们是编译产物。把它们放进 Git 就像把烧好的砖垒进图纸档案柜。图纸柜是放图纸的,不是放建材的。

原因:

  1. 体积:一个 ELF 文件几十 MB,一百次提交仓库就膨胀到几 GB。Git 的设计假设是存储文本差异,对于二进制文件,每次修改都存储完整副本。
  2. 不可 diff:两个 ELF 之间的差异不可读。git diff 对二进制文件失效。
  3. 可重建性:给定同一个 commit 的源码和同一个工具链版本,ELF 应该是可重现的(至少理论上是)。既然可重建,就不需要存储。

正确的做法:

  • 源码用 Git。
  • 二进制产物用制品仓库(Artifact Repository),例如 JFrog Artifactory、Nexus,或者最简易的方案:一个带版本号命名的网络共享目录。
  • 链接关系用 Git Tag 或元数据文件:在 tag 信息里写明对应的制品仓库路径。

Arctic Core 等 AUTOSAR 基础软件项目的一个实用模式:

Git仓库: arctic-core/
  ├── src/          (源码,Git 管理)
  ├── config/       (配置,Git 管理)
  └── artifacts.txt (指向制品仓库的链接)

制品仓库: artifacts/arctic-core/
  ├── v2.1.3-build42/
  │   ├── arctic-core.elf
  │   ├── arctic-core.hex
  │   ├── arctic-core.map
  │   └── build.log
  └── v2.1.3-build43/
      └── ...

**Git LFS(Large File Storage)**是另一种选择,适合需要把二进制文件和源码的版本紧密绑定的场景(比如标定数据文件 .a2l / .hex 随源码一起变迁)。但注意:Git LFS 只是用指针替代了二进制文件本身,存储压力转移到了 LFS 服务器。

核心洞察:源码进 Git,二进制产物进制品仓库,用元数据维护链接关系。 二进制文件体积大、不可 diff 且可重建,放进 Git 只会让仓库膨胀而毫无收益;制品仓库按版本号管理 ELF/HEX/map,Tag 或元数据文件把源码与产物关联。Git LFS 只是转移存储压力,并非银弹。


三样东西,救过无数工程师的命

版本控制的真正价值不在于“能回滚“,而在于回答三个问题:

第一:谁写的?为什么这样写?——git blame

凌晨两点,你盯着一段没有注释的状态机跳转逻辑,完全看不懂为什么要加这个 if (counter > 128) 的判断。128 是什么?为什么不是 127 或者 129?

git blame src/can/Can_SM.c -L 342,342

输出告诉你:

a3f2b1c8 (张工 2024-11-12 15:23:42 +0800 342)     if (counter > 128) {

你继续看这个 commit 的完整信息:

git show a3f2b1c8
commit a3f2b1c8
Author: 张工 <zhang@company.com>
Date:   2024-11-12 15:23:42 +0800

    fix(can): 修复 CAN bus-off 恢复时序问题

    根据 Infineon TC397 勘误表 CAN_ERRATA_2024_03,当 CAN 模块
    进入 bus-off 状态后,需要等待至少 128 个隐性位序列(而非
    标准规定的 128 次 11 位隐性位)才能可靠恢复通信。

    Ref: Infineon AURIX TC39x Errata Sheet V1.2, Section 4.7.3

你不需要再猜。你知道了 128 的来源是芯片勘误表,知道了为什么是 128 而不是 127。你甚至可以在芯片厂商的文档里找到原始依据。

git blame 不是用来追责的工具。它是一台时光机。 它把你送回那段代码被写下的时刻,让你看到作者当时掌握的全部信息。

第二:哪个版本发布的?——git tag

车厂售后工程师打电话给你:“客户说他的车仪表盘偶尔黑屏。ECU 零件号是 XXX,你们用的哪个版本?”

你不需要翻邮件、查 Jira、问同事。你打开终端:

git tag --list "v2.1*" --sort=-creatordate | head -5

然后根据 ECU 的生产批次找到对应的 tag,进而找到对应的完整源码状态。

第三:这个 Bug 什么时候引入的?——git bisect

“这个功能在 v2.0.3 是好的,v2.1.0 就坏了,中间有 200 个 commit。”

git bisect start
git bisect bad v2.1.0
git bisect good v2.0.3

Git 会自动二分查找:checkout 中间那个 commit,然后你测试,再告诉 Git 是 good 还是 bad,Git 继续二分。通常不超过 8 步(log₂(200) ≈ 7.6)就能定位到引入 Bug 的那一次提交。

这比手动排查 200 个 commit 快了 25 倍。

核心洞察:版本控制的真正价值不是回滚,而是回答三个问题:谁写的、发了哪个版本、Bug 何时引入。 git blame 是一台时光机,让你看到代码诞生时作者掌握的信息;git tag 让“这个 ECU 用的哪个版本“几秒钟有答案;git bisect 用二分查找把两百次提交的排查压缩到八步以内。把“靠记忆、靠翻邮件“变成“靠历史、靠系统“。


本篇小结

  • 一次提交 = 一次有意义的完整变更,能用一句话说明白。
  • Conventional Commits 是提交说明的行业标准,嵌入式项目用 type(scope): description 格式。
  • 语义化版本 + Tag 确保每个量产固件精确可追溯。不要靠记忆,靠标签。
  • 分支策略:小团队用 Trunk-Based,多版本并行用 GitFlow。
  • 二进制产物不进 Git。源码进 Git,产物进制品仓库,链接关系用元数据维护。
  • git blame, git tag, git bisect 是三个救人命的命令。熟练掌握它们。
  • 你为未来的同事提交。这是版本控制最深层的意义。

【下集预告】:图纸存档做好了,但图纸本身有没有错误?下一节,我们走进结构工程师的审图室:两位工程师把同一张梁柱计算书摊在桌上,用一种系统化方法检查“第一双眼睛没看到的盲区“。

你写完了一段 ADC 驱动,准备提交。你最后看了一遍 diff,发现中断服务函数里有一个 volatile 关键字,而共享缓冲区却没有加保护。这不是编译器的错,也不是测试能稳定复现的场景。你需要在提交之前,有第二双眼睛帮你看一眼。下一节告诉你如何让那第二双眼睛发挥最大的价值。