3.2 代码审查——第二双眼睛看第一双的盲区
波音707的机翼计算书
1954年,波音707的机翼设计团队里有一条不成文的规矩:任何一张结构计算书,必须经过另一位工程师的独立验算才能签字盖章。这一规矩的起因来自一次险些发生的灾难:一位资深工程师在计算一个翼肋连接板的载荷时,正确地应用了公式,正确地查了材料手册,却遗漏了一个关键假设:他默认这块连接板装在机翼内部(干区),但实际上它暴露在机翼前缘(湿区),会经受雨水反复侵蚀。他的计算本身没有任何数学错误,但他的视野有一个盲区:他不知道这块板的具体安装位置。
另一位工程师在复核时,第一句话就是:“等等,这块板是装在哪里的?”
就这一句话,阻止了一个潜在的空中解体事故。
软件世界里,这种“计算正确但假设错误“的盲区每天都在发生。你看一段代码,逻辑没问题,语法没问题,但你没有意识到 CAN_Write() 这个函数在某个中断优先级下不可重入;你没有意识到这块 static 缓冲区在两个任务之间共享但没有任何保护;你没有意识到这片 AD 采样的滤波窗口刚好和发动机的一个振动周期重叠。
这些都不是 Bug,是盲区。 而盲区的本质特征是:你自己看不到它。
这就是代码审查(Code Review)存在的根本原因:不是找 Bug,是补盲区。
核心洞察:代码审查不是找 Bug,是补盲区。 “计算正确但假设错误“的缺陷,恰恰是你自己看不到的:不可重入的函数、无保护的共享缓冲区、与振动周期重叠的滤波窗口。盲区的本质特征是“自己看不到它”,因此只有第二双懂得领域的眼睛能发现它——它检查的是代码里隐含的、跨边界的假设。
为什么编译器、静态分析和测试都不能替代审查?
你可能会问:编译器不就能检查语法吗?静态分析不就能检查潜在错误吗?单元测试不就能验证逻辑吗?为什么还需要人眼看?
因为编译器、静态分析器和测试各自有各自的盲区:
| 工具 | 能检查什么 | 看不到什么 |
|---|---|---|
| 编译器 | 语法、类型 | 意图、上下文 |
| 静态分析 | 模式匹配的错误 | 特定于领域的错误假设 |
| 单元测试 | 给定输入的输出正确性 | 你没有写的测试用例 |
| 代码审查 | 意图与实现的一致性 | (审查者自身的盲区) |
编译器不知道 TIMEOUT_MS 设为 500 是对的还是错的,它只知道这是个合法的整数。静态分析器不知道这个 SPI 设备的时序要求是 10ns 还是 100ns,它只知道指针解引用是否安全。单元测试不知道你没有测试“中断在 UART 发送中途到来“这个场景。
只有另一双懂得这个领域的眼睛能判断:你写的代码,和你以为你写的代码,是不是同一个东西。
举个嵌入式 C 的真实例子:
/* 你写的代码: */
void Can_ProcessMessages(void)
{
for (uint8 i = 0; i < can_msg_cnt; i++)
{
if (can_msg_buf[i].id == 0x18FEF100) /* 诊断请求 */
{
Diag_HandleRequest(&can_msg_buf[i]);
}
}
}
编译器:✅ 语法正确,类型匹配。
静态分析:✅ 无越界风险(i 有界),指针非空(can_msg_buf 已验证)。
单元测试:✅ 模拟了 0x18FEF100 的 CAN 消息,Diag_HandleRequest 被正确调用。
代码审查者:“等一下,这个函数是在主循环里调用的还是中断里调用的?如果在中断里,Diag_HandleRequest 可能调用 NVM 写入函数,而 NVM 写入函数可能阻塞 50ms。你在中断里阻塞了 50ms,你知道这会导致什么吧?”
这就是代码审查的独特价值:它检查的是你代码里隐含的、跨边界的假设。
核心洞察:编译器、静态分析、测试各有盲区,唯独人眼能检查“意图与实现的一致性“。 编译器不知道 500ms 超时是否合理,静态分析器不知道 SPI 的时序要求,单元测试不知道你没写的场景。只有另一双懂领域的眼睛能判断:你写的代码,和你以为你写的代码,是不是同一个东西。
嵌入式 C 代码审查清单
不是所有的代码审查都是平等的。漫无目的地“看一看“效果很有限,审查者需要一个结构化的清单来系统性地扫描盲区。
以下是针对汽车嵌入式 C 的代码审查清单,按检查层级排列。这相当于结构工程师复核计算书时的“标准检查表“。
第一层:中断与并发安全(ISR Safety)
这是嵌入式 C 的第一大杀手。审查时对每一处中断服务函数问:
- ISR 内是否有阻塞操作(循环等待、NVM 写入、大内存拷贝)?
- ISR 内调用的所有函数是否都是可重入的(只使用栈变量或原子操作)?
- ISR 与主循环共享的变量是否声明为
volatile并且有适当的临界区保护? - 中断嵌套时,高优先级 ISR 是否会破坏低优先级 ISR 的中间状态?
-
volatile是否只在真正需要的地方使用?(滥用volatile会抑制编译器优化并使意图模糊)
建筑类比:地震来的时候,大楼的每一层都承受不同的水平剪力。结构工程师必须检查地基和上部结构的连接节点,那里是应力最集中的地方。ISR 就是软件里的“连接节点“。
第二层:内存与栈
- 所有动态内存分配(
malloc)是否在初始化阶段完成?(运行时分配在汽车嵌入式上通常是禁止的) - 栈深度是否被显式评估过?(最坏情况调用链的栈使用量 < 配置的栈大小,且留有余量)
- 递归调用是否存在?(在安全关键系统中,递归通常是禁止的)
- 数组访问是否有边界检查?
- 所有指针解引用前是否已检查非空?
建筑类比:一栋楼的活荷载(人、家具、设备)加上恒荷载(自身重量)不能超过地基承载力的某个百分比。内存和栈就是软件的“荷载“。
第三层:状态机与逻辑完整性
- 状态机的每个状态对每个可能的事件都有定义好的处理(包括“不可能发生“但确实可能因硬件故障而发生的事件)?
- 状态迁移是否原子化?(一个状态迁移涉及多个变量的修改时,中途是否可能被中断打断?)
- 所有
switch语句是否有default分支? - 所有
if-else if链是否有最终的else兜底? - 错误路径是否被处理?(通信超时、校验失败、传感器返回异常值,这些路径虽然“不应该发生“,但必须被处理)
建筑类比:防火楼梯可能永远不被使用,但只要有一次真实火灾,它就是唯一的生路。错误处理路径就是软件的“防火楼梯“。
第四层:硬件交互
- 硬件寄存器访问是否有适当的
volatile修饰?(注意:某些编译器下硬件寄存器通过特定地址访问,volatile是必需的) - 写入硬件寄存器后是否有必要的等待周期?(硬件不会立刻响应)
- SPI/I2C/CAN 通信是否有时钟容错机制?(如果对方设备的时钟偏移 5%,通信会失败吗?)
- ADC 采样值是否有滤波和合理性检查?(传感器故障时,原始值可能是满量程或零)
- 看门狗是否正确喂狗?(喂狗的位置是否在健康检查之后,而不是在循环的任意位置?)
第五层:MISRA C 合规性
如果你的项目遵循 MISRA C:2012(多数汽车项目都如此),审查时额外关注:
- Rule 2.2:没有死代码(Dead Code)。
- Rule 8.3:声明与定义的类型一致。
- Rule 11.3:指向不同对象类型的指针之间没有强制转换。
- Rule 13.2:
for循环控制变量的值没有被循环体修改。 - Rule 17.2:没有使用递归。
- Directive 4.9:优先使用
const或枚举而非#define来定义常量(函数式宏应避免)。
请注意,这些规则不是教条。MISRA 允许偏差(Deviation),但每一个偏差必须有文档化的理由和缓解措施。
核心洞察:漫无目的地看收效甚微,审查需要结构化清单来系统扫描盲区。 五层清单从最致命的 ISR 与并发安全,到内存与栈、状态机完整性、硬件交互、MISRA 合规,逐层逼近软件里的“连接节点“。真正危险的往往不是显而易见的错误,而是每个状态机里“不可能发生、却因硬件故障会发生“的路径。
PR(Pull Request)的最佳实践
在代码审查的实际操作层面,有两种极端的反模式:
反模式 A:巨型 PR,一个 PR 改了 38 个文件,涉及 6 个模块,总共 3200 行。审查者打开第一页就开始头疼,翻到第三页已经无法保持注意力。最终的结果要么是“粗略扫一眼就批准“,要么是审查者花一整天认真看,而这一整天本可以做其他工作。
反模式 B:零上下文 PR,PR 标题写着“fix“,描述为空,没有链接到需求或 Bug 追踪系统。审查者完全不知道这个改动是解决什么问题,也就不可能判断这个解决方案是否恰当。
小型 PR 的纪律
经验法则:一个 PR 不超过 400 行变更(新增 + 删除)。
400 行不是绝对的硬限制,但它是一个心理阈值。超过 400 行,审查者的注意力开始衰减。人的大脑在一段时间内只能有效跟踪有限数量的“变更-影响“关系。
把大功能拆成一系列小 PR 是一种技能,也是一种纪律:
不要: PR #42 "实现 UDS 诊断服务全套功能" (2800 行)
应该: PR #42 "实现 UDS Service 0x22 ReadDataByIdentifier" (320 行)
PR #43 "实现 UDS Service 0x2E WriteDataByIdentifier" (280 行)
PR #44 "实现 UDS Service 0x19 ReadDTCInformation" (450 行)
拆 PR 的技巧在于找到最小可审查单元。一个最小可审查单元的特点:
- 可以独立编译通过(或至少在逻辑上自洽)。
- 可以用一两句话清楚地描述“做了什么、为什么“。
- 如果需要,可以单独回滚而不破坏其他功能。
PR 描述:不只是写了什么,更是为什么
一个好的 PR 描述回答三个问题:
## 做了什么
为 ADC 驱动增加了 DMA 双缓冲模式,解决高采样率下数据丢失问题。
## 为什么这样做
当前单缓冲模式下,当 ADC 以 100kHz 采样时,DMA 传输完成的回调函数
处理数据花费约 15μs。在此期间新的采样值可能覆盖尚未处理的缓冲区数据。
改为双缓冲(Ping-Pong)后,一个缓冲区在填充时,另一个已被 DMA 传输
完成通知触发处理。
## 如何验证
- 单元测试:验证了双缓冲切换逻辑(test/ut/test_adc_dma.c)
- 集成测试:在目标硬件上以 100kHz 连续采样 60 秒,零数据丢失
- 标定数据:使用数据采集卡对比,采样值与参考值偏差 < 0.1%
## 关联
JIRA: AUTOSAR-1823
需求: TSR-ADC-014 (DMA 双缓冲模式)
审查反馈的语调:关于代码,不关于人
一段糟糕的审查评论:
“你怎么能这样写?这是典型的错误。你应该用互斥锁保护共享变量。回去重写。”
这段话攻击的是人(“你”),而不是代码。
一段好的审查评论:
“我看到
can_msg_cnt在 ISR 和主循环中都被修改了。这两个上下文之间没有互斥保护。如果主循环在读取can_msg_cnt时被 CAN ISR 中断,并且 ISR 修改了该值,主循环会使用一个不一致的计数值。建议在读取前后加临界区保护,或者使用原子操作。”“参考:《Understanding AUTOSAR》第 8.3 节关于 ISR 与 Task 间数据一致性的讨论。”
这段话的特点:
- 描述了具体的技术问题,不是评价个人能力。
- 解释了为什么这是个问题(不一致的计数值)。
- 给出了建议方案,不是单纯的否定。
- 引用了权威来源,让审查反馈有据可查。
审查文化最核心的原则是:代码不是你。代码是对你当前解决方案的一个描述,而这个描述可以被改进。 这和建筑师审图一样,审图人是在用第二双眼睛确认结构的安全性。
核心洞察:PR 的可审查性与质量成正比:小到能看透,描述到能懂,语气对事不对人。 超过 400 行注意力开始衰减;描述要回答“做了什么、为什么、如何验证“;反馈要描述具体技术问题、解释原因、给出建议、引用依据。审查文化最核心的一条:代码不是你,它只是一个可以被改进的描述。
审查流程的节奏:不要挤压在最后
许多团队的代码审查流程是这样的:开发者在功能分支上写了两个星期的代码,周五下午提交 PR,审查者在周一上午集中看,发现大量问题,开发者又花两天修改,审查者再花一天复查,最终合并。
这个流程的致命问题是反馈延迟。开发者在写完代码两周后才得到审查反馈,他已经忘记了很多设计决策的细节。审查者面对的是一个已经成熟的解决方案,提出结构性修改意见的成本极高。
健康的节奏:功能分支的生命周期短(1-2天),PR 小(<400行),审查在 PR 提交后24 小时内完成。
这等于在说:审查是开发过程中的同步活动,而不是开发的“下一阶段“。就像结构工程师画完一层柱网图立即请同事复核,而不是画完整栋楼再拿过去,等到那时发现问题,改图成本已经爆炸了。
核心洞察:审查是开发中的同步活动,不是开发后的“审核关卡“。 写两周再审的最大代价是反馈延迟:开发者已忘记设计细节,结构性修改的成本已爆炸。健康的节奏是短分支、小 PR、24 小时内完成审查——就像画完一层柱网立即请同事复核,而不是盖完整栋楼再返工。
本篇小结
- 代码审查不是找 Bug,是补盲区。编译器、静态分析、测试各有盲区,审查填补它们之间的空白。
- 嵌入式 C 审查清单五层:ISR 安全 → 内存与栈 → 状态机完整性 → 硬件交互 → MISRA 合规。
- PR 不超过 400 行。拆大功能为最小可审查单元。PR 描述回答“做了什么、为什么、如何验证“三个问题。
- 审查反馈关于代码,不关于人。用具体的技术语言描述问题、解释原因、给出建议、引用依据。
- 审查是开发过程中的同步活动,不是开发之后的“审核关卡“。24 小时内完成审查。
- 审查记录是团队的知识遗产。它记录的是设计决策。
【下集预告】:代码审查靠的是人眼,但人眼也会疲劳,也会遗漏。建筑行业有一项技术叫“有限元分析“,计算机模型自动计算结构每一处的应力分布,找出工程师可能遗漏的应力集中点。软件工程里,这项技术叫静态分析。
你的 CAN 总线关闭恢复逻辑通过了代码审查。但审查者和你都没有注意到,在某个极端的引脚配置下,CAN 控制器的初始化顺序与你假设的不同。这个缺陷只有在特定的硬件板本和特定的上电时序下才会触发。审查者是人,人需要工具的辅助。下一节,让机器当你的第三只眼。