6.6 编码规范、静态分析与评审
在许多嵌入式项目的代码评审中,常常会出现一种令人疲惫的循环:同一个项目在不同迭代阶段被反复指出同质化的低级缺陷——将 16 位整数隐式赋值给 8 位对象导致的溢出截断、有符号数与无符号数混合比较引发的逻辑倒转、中断服务函数与主循环共享标志时遗漏了 volatile 限定符、或是试图用 1 << 31 构造最高位掩码而触发未定义行为。这些隐患的本质特征非常一致:在肉眼审查时极易被忽略,而编译器与静态分析工具若配置得当则能够百分之百在构建期拦截。
让稀缺的人工反复去充当编译器的“肉眼过滤器”,是对工程效率的严重损耗。合理的质量保障链条应当实现明确的层次分工:依托严密的编码规范明确界定受限的 C 语言安全子集;借助编译器告警矩阵与静态分析引擎将能够被语法树识别的潜在缺陷彻底拦截在自动化流水线中;将宝贵的人工代码评审带宽收拢至工具无法触达的系统级上下文——审查软硬件交互的原子性、超时退出的完备性以及中断临界区的保护边界。
1. 为什么嵌入式需要编码规范
桌面程序里一个未定义行为,通常表现为某个输入下崩溃,用户重开一次就好了。固件里同样一个未定义行为,可能表现为设备在某个温度、某个电压、某个恰好到来的中断组合下死机;现场没有调试器,复现条件说不清,唯一的补救办法是升级——而升级本身正是 6.4 要保证不出错的那件事。
未定义行为与实现定义行为在 C 里到处都是,编译器不会为每一个都报警告。int 溢出是未定义行为,编译器可以假定它不发生,于是 if (x + 1 < x) 这样的检查会被优化掉。有符号右移、位域的内存布局、char 是否有符号是实现定义行为,换编译器或换优化等级,结果就变了。结构体里成员的填充字节、联合体的读写顺序、volatile 的访问次数,这些细节在 语法和标准库 16.5 遵从性和行为 里有系统讲解。
编码规范把语言子集收窄到「不容易出错的那部分」。规则定下来,团队里每个人的写法趋同,评审时不用再讨论风格,工具也能照着规则自动检查。
工具只能自动检查规范的一部分。查不出来的规则只能靠人执行,所以这部分规则最该写成文字。
2. 三个常用规范
嵌入式 C 有三份常见的规范,定位不同。
MISRA C 是汽车工业发起、现在由 MISRA 协会维护的规范,2025 年 3 月发布的 C:2025 是当前版本。它分「指令」(directive)与「规则」(rule)两类,每条有强制(mandatory)、必需(required)、建议(advisory)三个等级。规则原文受版权保护,要完整执行得购买文档;执行时还要为每条不适用或无法执行的规则写偏离记录(deviation)。它的强项是把语言里容易出错的角落几乎全部覆盖,代价是规则多、学习曲线陡。
SEI CERT C 由卡内基梅隆大学软件工程研究所维护,免费在线,按「安全」组织:每条规则对应一类可被利用的缺陷(缓冲区溢出、整数溢出、格式化字符串、竞争条件),编号形如 INT30-C、STR31-C。它比 MISRA 宽松,但每条规则都配了正例、反例与后果说明,适合当学习材料。
BARR-C:2018 由 Barr Group 发布,免费,偏重风格与可读性:命名、缩进、注释、函数长度、魔数。它不深入语言细节,与 MISRA 可以共存——很多团队用 BARR-C 管风格、用 MISRA 的子集管正确性。
| 方面 | MISRA C:2025 | SEI CERT C | BARR-C:2018 |
|---|---|---|---|
| 定位 | 安全相关系统的语言子集 | 安全漏洞的防范规则 | 风格与可读性 |
| 获取方式 | 购买(规则原文受版权保护) | 免费在线 | 免费下载 |
| 规模 | 规则与指令合计上百条,按等级分类 | 上百条规则,按缺陷类别分章 | 一百多条风格与可读性条款 |
| 是否要求偏离记录 | 是,完整流程的一部分 | 不强制 | 不要求 |
| 适用场合 | 汽车、医疗、工业安全 | 任何 C 项目,尤其联网设备 | 所有项目的风格基线 |
三份规范都覆盖不到的一类问题,是「业务逻辑对不对」。规范管的是「这样写会不会出错」,评审管的是「这样做对不对」。
偏离规则必须记录
采用一份规范的必需规则时,如果某条规则在项目里确实无法执行(例如某个第三方库必须原样使用),正确的做法是写一条偏离记录:说明规则编号、涉及的代码位置、不能执行的原因、替代的防范措施、批准人。偏离记录本身也是评审对象。悄悄关掉一条规则、不留下任何文字,等于这条规则从来没有存在过。
2.1 最常被违反的几条
MISRA C 的规则里,嵌入式项目最常撞上的几条都与「C 的整数提升」有关。下面三条先记住:
- 本质类型(essential type):MISRA 把每个表达式归类为有符号、无符号、布尔、字符、浮点、枚举之一,「本质类型」比 C 的声明类型更严格。
uint8_t与uint16_t相加,在 C 里都被提升为int,结果是有符号的;MISRA 认为这是有符号与无符号的混合运算,报规则 10.x。 switch必须有default:没有default的switch在新增枚举值后会静默走空,MISRA 规则 16.4 要求每个switch都有default分支。- 运行期不使用动态内存:
malloc/free在嵌入式里带来的碎片与不确定性,MISRA 规则 21.3 直接禁止。这与 4.5 内存与资源管理 的静态分配主张一致。
在通常的商业产品开发中,一种兼顾工程质量与落地成本的务实策略是采用“BARR-C 格式规范 + 精选的 MISRA 核心规则集 + 严苛的编译器告警”这一组合拳。这种方案既能以较低的维护成本收回大部分安全性收益,又不会给团队带来过度繁重的文档负担。唯有当项目涉及汽车电子(ISO 26262)或功能安全认证时,才需要严格落实 MISRA 全集并建立完备的偏离归档流程。
关于三份规范,下面哪一句是对的?
3. 编译器警告是第一道工具
静态分析的第一道工具不需要安装,编译器自带。默认的 -Wall -Wextra 只打开了常用的一部分,嵌入式项目应该再加几个:
| 选项 | 查什么 | 为什么嵌入式需要 |
|---|---|---|
-Wall -Wextra | 常用警告集合 | 基线 |
-Wconversion | 隐式转换可能改变值 | 整型宽度不一致、截断 |
-Wsign-conversion | 有符号与无符号之间的隐式转换 | 比较与位移最容易出错的地方 |
-Wshadow | 内层对象遮蔽外层同名对象 | 大函数里改错了对象 |
-Wcast-align | 强制转换后对齐要求变高 | 把字节缓冲区当 32 位字访问时,LDM/STM 与独占访问会出错 |
-Wdouble-promotion | float 被隐式提升为 double | Cortex-M3 没有 FPU,双精度全靠软件 |
-Wundef | 在 #if 里使用未定义的宏 | 拼错的配置宏静默取 0 |
-Wformat=2 | 格式串与实参不匹配,含 %n 相关 | 打印调试信息时的常见错误 |
-Wshift-overflow=2 | 移位量超出类型宽度 | 1 << 31 这类写法 |
-Wswitch-default | switch 缺少 default 分支 | 新增枚举值后静默走空 |
-Wnull-dereference | 可能解引用空指针 | 初始化顺序问题 |
CI 里再加 -Werror,让警告直接变成构建失败。团队里常有的顾虑是「第三方代码报一堆警告」。可行的做法是用 -isystem 引入第三方头文件、对第三方源文件单独编译并关掉这套选项;把警告整个关掉,自家代码的问题也会一起被吞掉。
-Wdouble-promotion 在没有 FPU 的 Cortex-M3 上尤其该打开。C 的算术转换规则规定:float 与 double 运算时,float 被提升为 double;一个不带 f 后缀的浮点常量是 double。于是 x * 3.14159265358979 里的 x 先被转成 double,乘法在双精度里做完,结果再转回 float。F103 没有任何浮点指令,双精度乘法要调用 __aeabi_dmul,代码体积与执行时间都是单精度的好几倍:
# return x * 3.14159265f; (单精度)
text data bss dec hex filename
380 0 0 380 17c fp_a.elf
# return x * 3.14159265358979; (常量是 double,x 被提升)
text data bss dec hex filename
1684 0 0 1684 694 fp_b.elf2
3
4
5
6
7
一个字符的差别,链接进固件的代码多了 1.3 KiB。用 objdump 看符号表,双精度版本引入了 __aeabi_dmul、__aeabi_dadd、__aeabi_f2d、__aeabi_d2f 一整套软件浮点例程;在没有 FPU 的 M3 上,每一次乘法都要执行其中一段软件循环。
3.1 一段有问题的固件
下面这段代码每一处都对应一个真实项目里见过的错误,全部在开启上述警告后编译。这里省掉了头文件与 main,警告里的行号对应完整文件:
static uint8_t g_rx_flag; /* ISR 写、主循环读,但不是 volatile */
void isr_rx(void) {
g_rx_flag = 1;
}
int buffer_full(int used, unsigned capacity) { /* 1. 有符号与无符号比较 */
if (used > capacity) {
return 1;
}
return 0;
}
void store_length(uint8_t *dst, uint16_t len) { /* 2. 隐式截断 */
dst[0] = len;
}
float scale(float x) { /* 3. float 被提升为 double */
return x * 3.14159265358979;
}
uint32_t high_bit(void) { /* 4. 1 << 31 是未定义行为 */
return 1 << 31;
}
int command_period(int cmd) { /* 5. switch 没有 default */
switch (cmd) {
case 0:
return 10;
case 1:
return 100;
}
return -1;
}
void log_count(int count) { /* 6. 格式串按无符号解释有符号实参 */
char line[32];
snprintf(line, sizeof line, "count=%u\n", count);
puts(line);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
arm-none-eabi-gcc 13.3 在 -O2 下给出的警告:
bad_fw.c: In function 'buffer_full':
bad_fw.c:12:14: warning: comparison of integer expressions of different signedness: 'int' and 'unsigned int' [-Wsign-compare]
12 | if (used > capacity) {
| ^
bad_fw.c: In function 'store_length':
bad_fw.c:20:14: warning: conversion from 'uint16_t' {aka 'short unsigned int'} to 'uint8_t' {aka 'unsigned char'} may change value [-Wconversion]
20 | dst[0] = len;
| ^~~
bad_fw.c: In function 'scale':
bad_fw.c:25:14: warning: implicit conversion from 'float' to 'double' to match other operand of binary expression [-Wdouble-promotion]
25 | return x * 3.14159265358979;
| ^
bad_fw.c:25:14: warning: conversion from 'double' to 'float' may change value [-Wfloat-conversion]
25 | return x * 3.14159265358979;
| ~~^~~~~~~~~~~~~~~~~~
bad_fw.c: In function 'high_bit':
bad_fw.c:30:14: warning: result of '1 << 31' requires 33 bits to represent, but 'int' only has 32 bits [-Wshift-overflow=]
30 | return 1 << 31;
| ^~
bad_fw.c:30:14: warning: unsigned conversion from 'int' to 'uint32_t' {aka 'long unsigned int'} changes value from '-2147483648' to '2147483648' [-Wsign-conversion]
30 | return 1 << 31;
| ~~^~~~~
bad_fw.c: In function 'command_period':
bad_fw.c:35:5: warning: switch missing default case [-Wswitch-default]
35 | switch (cmd) {
| ^~~~~~
bad_fw.c: In function 'log_count':
bad_fw.c:47:41: warning: format '%u' expects argument of type 'unsigned int', but argument 4 has type 'int' [-Wformat=]
47 | snprintf(line, sizeof line, "count=%u\n", count);
| ~^ ~~~~~
| | |
| | int
| | unsigned int
| %u2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
逐条看。第一条,used 是 int、capacity 是 unsigned,比较时 used 被转成 unsigned,-1 变成 0xFFFFFFFF,buffer_full(-1, 8) 返回 1——语义完全反了。第二条,len 是 300 时 dst[0] 存进去的是 44,高字节被丢掉,这种错误在协议实现里最致命。第三条,两行警告指的是同一件事:常量是 double,所以乘法在双精度里做,结果再截回 float。第四条,1 << 31 对 int 是未定义行为,GCC 在这里给出的是「需要 33 位」的诊断,换个编译器可能什么都不说;写成 1u << 31 就没有这个问题。第五条 switch 缺 default,GCC 默认不报,要单独加 -Wswitch-default。第六条,%u 期待 unsigned,实参是 int,负数会被打印成一个巨大的无符号数。
这段代码里还有一个警告没出现,但它是最危险的:g_rx_flag 没有 volatile。GCC 不会为缺少 volatile 的对象报警告,因为从语言角度它是合法的。用一个最小的等待循环对比两种写法:
static uint8_t g_flag_plain;
static volatile uint8_t g_flag_vol;
void wait_plain(void) {
while (g_flag_plain == 0u) {
}
}
void wait_vol(void) {
while (g_flag_vol == 0u) {
}
}2
3
4
5
6
7
8
9
10
11
12
-O2 编出来的结果完全不同:
00000000 <wait_plain>:
0: e7fe b.n 0 <wait_plain> ; 编译成死循环,永远不再读内存
2: bf00 nop
00000004 <wait_vol>:
4: 4a02 ldr r2, [pc, #8] @ (10 <wait_vol+0xc>)
6: 7813 ldrb r3, [r2, #0] ; 每一轮都重新读
8: 2b00 cmp r3, #0
a: d0fc beq.n 6 <wait_vol+0x2>
c: 4770 bx lr2
3
4
5
6
7
8
9
10
没有 volatile 的那一版,编译器把 while (g_flag_plain == 0) 优化成了 b.n 0 的无条件跳转——它「知道」g_flag_plain 在循环里没被修改,所以只读一次。ISR 改了这个对象,循环也看不见。这类问题只能靠 volatile 的使用规范加上人工评审解决,工具帮不上忙。
关于 ISR 与主循环共享标志缺 volatile 的问题,下面哪些说法成立?
int buffer_full(int used, unsigned capacity) {
if (used > capacity) {
return 1;
}
return 0;
}
void store_length(uint8_t *dst, uint16_t len) {
dst[0] = len;
}
float scale(float x) {
return x * 3.14159265358979;
}
uint32_t high_bit(void) {
return 1 << 31;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int buffer_full(int used, unsigned capacity) {
if (used < 0 || (unsigned)used > capacity) {
return 1;
}
return 0;
}
void store_length(uint8_t *dst, uint16_t len) {
if (len > UINT8_MAX) {
return; /* 或者把接口改成返回错误码 */
}
dst[0] = (uint8_t)len;
}
float scale(float x) {
return x * 3.14159265f;
}
uint32_t high_bit(void) {
return 1u << 31;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
4. 静态分析器
编译器只做局部检查,跨函数、跨路径的问题要靠静态分析器。三种免费工具覆盖的深度不同。
4.1 GCC -fanalyzer
-fanalyzer 是 GCC 10 起内置的路径敏感分析器:它把函数的控制流展开成图,沿着路径检查空指针解引用、释放后使用、内存泄漏、重复释放。它不需要额外安装,与编译器用同一套语法,缺点是慢——对一个中型工程可能慢十倍。
下面这段代码有两处问题,都是只有沿着特定路径才能看到的:
typedef struct node {
char name[16];
struct node *next;
} node_t;
int append_command(const char *name) {
node_t *n = malloc(sizeof *n);
if (n == NULL) {
return -1;
}
if (name[0] == '\0') {
return -3; /* 泄漏:n 没有释放 */
}
if (strlen(name) >= sizeof n->name) {
free(n);
memcpy(n->name, name, sizeof n->name); /* 释放后写 */
return -2;
}
...
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
MinGW gcc 13.2 加上 -fanalyzer 编译,两处都报出来:
analyzer_demo.c: In function 'append_command':
analyzer_demo.c:29:17: warning: pointer 'n' used after 'free' [-Wuse-after-free]
29 | memcpy(n->name, name, sizeof n->name); /* 释放后写 */
| ~^~~~~~
analyzer_demo.c:28:9: note: call to 'free' here
28 | free(n);
| ^~~~~~~
analyzer_demo.c:25:16: warning: leak of 'n' [CWE-401] [-Wanalyzer-malloc-leak]
25 | return -3; /* 泄漏:n 没有释放 */
| ^
'main': events 1-2
|
| 54 | int main(void) {
| | ^~~~
...
| | (19) ...to here
| | (20) 'n' leaks here; was allocated at (14)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
第二条诊断把整条路径都画了出来:从 main 调用、进 append_command、malloc 成功、判断名字为空、return -3、对象泄漏。这种输出读起来长,但比「第 25 行有泄漏」有用得多——它告诉你从哪个调用点进来才能触发。
-fanalyzer 的局限也要知道:它按函数单独分析,跨翻译单元的路径看不到;对 volatile、中断、RTOS 的调度不做任何建模。它找的是 C 语言层面的缺陷,触及不到嵌入式系统层面的问题。
4.2 clang-tidy
clang-tidy 基于 Clang 的 AST 做检查,规则丰富得多,分几个命名空间:bugprone-* 查易错写法,cert-* 实现 CERT C 的规则,readability-* 查可读性,modernize-*、performance-* 面向 C++。它需要一个 compile_commands.json 才知道每个文件的编译选项,CMake 加 -DCMAKE_EXPORT_COMPILE_COMMANDS=ON 就会生成。
工程根目录放一份 .clang-tidy:
---
Checks: >
bugprone-*,
cert-*,
readability-*,
-readability-magic-numbers,
-readability-identifier-length,
-readability-function-cognitive-complexity,
-bugprone-easily-swappable-parameters,
-bugprone-narrowing-conversions,
-cert-err33-c,
-readability-uppercase-literal-suffix
WarningsAsErrors: ''
HeaderFilterRegex: '.*'
FormatStyle: file
CheckOptions:
- key: readability-identifier-naming.FunctionCase
value: lower_case
- key: readability-identifier-naming.VariableCase
value: lower_case
- key: readability-identifier-naming.GlobalConstantCase
value: UPPER_CASE2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Checks 里的 - 前缀表示关掉。一个全开 *-* 的配置在真实工程上会报几千条,最后没人看;合理做法是从 bugprone-* 与 cert-* 起,逐步调。readability-magic-numbers 与 -identifier-length 在寄存器操作密集的嵌入式代码里噪音太大,多数项目会关掉。
对前面那段有问题代码的简化版,LLVM 19 的 clang-tidy 给出四条:
tidy_demo.c:8:19: warning: repeated branch body in conditional chain [bugprone-branch-clone]
8 | if (v > 1000) {
| ^
tidy_demo.c:10:6: note: end of the original
10 | } else if (v > 1000) {
| ^
tidy_demo.c:10:26: note: clone 1 starts here
10 | } else if (v > 1000) {
| ^
tidy_demo.c:10:7: warning: do not use 'else' after 'return' [readability-else-after-return]
10 | } else if (v > 1000) {
| ^~~~~~~~~~~~~~~~~~~~
11 | return 2;
| ~~~~~~~~~
12 | }
| ~
tidy_demo.c:19:5: warning: redundant return statement at the end of a function with a void return type [readability-redundant-control-flow]
19 | return;
| ^~~~~~~
20 | }
tidy_demo.c:24:12: warning: performing an implicit widening conversion to type 'uint64_t' (aka 'unsigned long long') of a multiplication performed in type 'uint32_t' (aka 'unsigned int') [bugprone-implicit-widening-of-multiplication-result]
24 | return a * b;
| ^
tidy_demo.c:24:12: note: make conversion explicit to silence this warning
1 | return a * b;
| ^~~~~
| (uint64_t)( )
tidy_demo.c:24:12: note: perform multiplication in a wider type
24 | return a * b;
| ^
| (uint64_t)
Suppressed 7598 warnings (7598 in non-user code).2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
最后一条是嵌入式里很典型的缺陷:return a * b 想返回 64 位乘积,但乘法在 32 位里做完才拓宽,两个 70000 相乘的结果在 32 位里已经溢出。clang-tidy 连改法都给了:把其中一个操作数先转成 uint64_t。
cert-* 在 C 代码上也能用,比如 cert-err34-c 会指出 sscanf("%d", &port) 不做转换错误检查:
tidy_cert.c:7:9: warning: 'sscanf' used to convert a string to an integer value, but function will not report conversion errors; consider using 'strtol' instead [cert-err34-c]
7 | if (sscanf(text, "%d", &port) != 1) {
| ^2
3
4.3 cppcheck
cppcheck 是独立工具,不依赖编译器,装好就能跑,适合在构建系统里作为第二意见。它对未初始化对象、数组越界、返回局部对象地址、内存泄漏这类问题的检出率不错,而且几乎没有误报:
Checking cpp_demo.c ...
cpp_demo.c:16:12: error: Returning pointer to local variable 'cfg' that will be invalid when returning. [returnDanglingLifetime]
return p; /* 返回局部对象的地址 */
^
cpp_demo.c:23:19: error: Array 'table[4]' accessed at index 5, which is out of bounds. [arrayIndexOutOfBounds]
total += table[5]; /* 越界 */
^
cpp_demo.c:35:1: error: Memory leak: tmp [memleak]
}
^
cpp_demo.c:16:12: error: Uninitialized variable: p [uninitvar]
return p; /* 返回局部对象的地址 */
^
cpp_demo.c:22:5: error: Uninitialized variable: total [legacyUninitvar]
total += table[0]; /* total 未初始化 */
^2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cppcheck 的 MISRA 插件要把规则文本转成可执行的数据文件,规则原文受版权保护,工具本身不带,需要自己准备,这里只给命令形式:cppcheck --addon=misra.json。
商业工具(PC-lint Plus、Polyspace、Coverity)在规则覆盖与误报率上比免费工具强,尤其是 Polyspace 用抽象解释给出「这段代码在所有输入下都不会溢出」这类结论。它们值不值那个价钱,取决于项目对认证的要求。
4.4 误报管理
静态分析器一定会有误报,判断是不是误报本身也要花时间。三条做法能让这件事可控:
- 行内抑制必须写理由。
// cppcheck-suppress arrayIndexOutOfBounds后面跟一句为什么这里安全,否则下一个人不知道是误报还是没看见。 - 维护基线。老代码里已有的告警记进基线文件,CI 只要求「新增代码零告警」,避免一次引入工具就要求全工程整改。
- 只让高置信度的检查进入 CI。
-fanalyzer适合本地跑;bugprone-*与 cppcheck 的warning级别适合进 CI;readability-*不进 CI,留在编辑器的格式化里。
抑制注释写理由
/* MISRA 10.1 偏离:这里的位掩码必须按无符号处理,
上游协议规定该字段为无符号数,见 PROTO-42。 */
uint32_t mask = (uint32_t)value & 0x0000FFFFu;2
3
一句理由加一个工单号,比任何口头约定都管用。
5. 代码评审
工具能查的都查完之后,人看的应该是工具看不到的东西:这段代码在中断里做了什么、这个共享数据有没有保护、这个等待有没有可能永远不返回。
评审的第一步是看 CI 有没有绿,然后才读代码。警告、静态分析、单元测试、体积预算都过了再安排人看,否则评审者会把时间花在本来能自动发现的问题上。
5.1 嵌入式检查清单
| 检查项 | 要问的问题 | 依据 |
|---|---|---|
| 中断与任务共享的数据 | 谁写、谁读、有没有 volatile 与临界区 | 4.1 |
volatile 的使用范围 | 是否只用于寄存器与 ISR 共享标志,有没有拿它当同步原语 | 16.3 |
| ISR 里的操作 | 有没有阻塞调用、打印、动态分配、长循环 | 4.4 |
| 整数宽度与符号 | 有没有隐式截断、有符号移位、1 << 31 | 3 表达式与类型转换 |
| 寄存器位操作 | 位号与位定义是否与 RM0008 一致,写 1 清零的标志是否写对 | 芯片手册 |
| 返回值 | 每个可能失败的调用有没有处理 | 通用 |
| 栈上大对象 | 有没有几百字节的局部数组,任务栈够不够 | 4.5 |
| 等待与超时 | 每个轮询或阻塞等待有没有超时出口 | 通用 |
| 资源释放 | 锁、缓冲区、DMA 通道在所有路径上是否释放 | 4.3 |
| 提交的粒度 | 一次提交是否只做一件事,信息是否说清为什么 | 17 Git 协作 |
5.2 一条评审意见的写法
评审意见要让人能改。结构是「问题 + 依据 + 建议」,三句话说完:
这里是不是有问题?USART1_IRQHandler 里调用了 printf,会把 newlib 的格式化代码
与串口发送拉进中断上下文,发送一个字节要等 TXE,中断可能
持续几毫秒,期间同优先级或更低优先级的中断都被推迟(4.4)。
建议在 ISR 里只把字节放进环形缓冲区并置一个标志,打印
留给任务或在调试构建里才开。2
3
4
5
第一句话说了位置与问题,第二句给出影响与依据,第三句给出可行的改法。评审者不需要自己猜「他希望我改成什么样」,被评审者也不用反复问「为什么」。
评审意见里避免「我觉得」「不太好吧」这类措辞,也避免只说「这样写不对」却不说明理由。评审意见针对代码本身,别去评价写代码的人。
5.3 几个常被漏掉的问题
有些问题工具查不出、评审时也容易一眼带过,这里单独说几条:
volatile 不能当同步原语用。 两个任务共享一个 volatile 计数器,++ 仍然是读-改-写三步,不加锁就会丢更新。volatile 只保证每次真的访存,不保证原子性。
等待没有超时。 while (!(USART1->SR & USART_SR_TC)) {} 在时钟配置错误时永远不返回,看门狗复位之后现场什么都没留下。加一个循环计数上限,超时返回错误码。
ISR 里访问非原子的大对象。 32 位对象在 Cortex-M3 上一条指令读写完;64 位对象和大数组要拆成几条指令,中途可能被打断。ISR 写一个结构体、任务读同一个结构体,读到一半被打断就是撕裂读。
位操作写错寄存器。 GPIOA->ODR |= 1 << 5 是读-改-写,如果另一个中断同时在改同一个端口,可能丢掉对方的修改;GPIOA->BSRR = 1u << 5 是单次写,没有这个问题。
错误处理吞掉了错误码。 (void)flash_write(...) 之后继续往下跑,写失败时后续操作全在错误的前提上。
工具已经接进 CI 之后,人工评审应该从哪一步开始?
6. 接入流程
三件事按顺序接进开发流程,每件都在最早能拦住问题的位置:
clang-format 在提交前把格式统一,评审时就不用讨论缩进与空格。仓库根目录的 .clang-format 按 README 的约定写成 LLVM 风格、4 空格:
BasedOnStyle: LLVM
IndentWidth: 4
ColumnLimit: 1002
3
CI 里做的检查依次是:-Wall -Wextra 加第 3 节那套选项,-Werror;clang-tidy 的 bugprone-* 与 cert-*;cppcheck 的 warning 级别;单元测试;体积预算(6.1 定的上限,超出就失败)。CI 配置本身不在这一页展开,18 持续集成 讲怎么写。
在存量工程中推行静态检查时,切忌采取“一刀切”的休克疗法。如果将全套严苛的 MISRA 规则无差别套用到数万行存量代码上,瞬间弹出的数千条告警往往会令团队产生严重的抵触心理,最终甚至导致工具被全盘放弃。行之有效的方法是建立“存量守底线、增量零容忍”的基线演进策略:为新增源码文件配置最高等级的静态检查规则,将既有文件的存量告警导入白名单基线;在后续日常维护和功能演进中,要求每次提交在满足增量零新增告警的同时,顺手清理改动区域内的存量技术债务。
7. 习题
7.1 巩固
正文第 3 节列出了十一个警告选项。下面七段代码在 x86 主机上触发其中八个,在 ARM 上触发九个:
int window_ok(int used, unsigned capacity) {
if (used > capacity) {
return 0;
}
return 1;
}
void set_period(uint8_t *reg, uint16_t period_us) {
*reg = period_us / 10u;
}
float celsius(float raw) {
return raw * 0.0625 * 3.3;
}
uint32_t top_mask(void) {
return 1 << 31;
}
int mode_of(int cmd) {
switch (cmd) {
case 1:
return 10;
case 2:
return 20;
}
return -1;
}
int scaled(int n) {
int sum = 0;
{
int sum = n * 2;
return sum;
}
return sum;
}
uint32_t load_word(const uint8_t *buf) {
return *(const uint32_t *)buf;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
(1) 不编译,先判断每段会触发哪个选项、在哪一行。写成一张表。
(2) 用 gcc -std=c17 -Wall -Wextra -O2 加正文那套选项编译,贴出实际警告,与 (1) 对照。哪一段在 x86 主机上不报、在 arm-none-eabi-gcc -mcpu=cortex-m3 下才报?
(3) window_ok(-1, 8) 返回什么?逐位说明 used 被转成 unsigned 之后的值。
(4) 逐段改成不报警告的写法。set_period 改完之后,period_us 为 3000 时写进寄存器的值是多少?
正文用 objdump 对比了带 volatile 与不带 volatile 的等待循环:
00000000 <wait_plain>:
0: e7fe b.n 0 <wait_plain> ; 编译成死循环,永远不再读内存
2: bf00 nop
00000004 <wait_vol>:
4: 4a02 ldr r2, [pc, #8] @ (10 <wait_vol+0xc>)
6: 7813 ldrb r3, [r2, #0] ; 每一轮都重新读
8: 2b00 cmp r3, #0
a: d0fc beq.n 6 <wait_vol+0x2>
c: 4770 bx lr2
3
4
5
6
7
8
9
10
(1) wait_plain 编译成了 b.n 0。编译器凭什么认为可以不读那个对象?把它改成 volatile uint8_t g_flag_vol 之后,反汇编多了哪几条指令,分别对应 C 代码的哪个动作?
(2) wait_plain 的循环体是空的,-O2 之后那条 ldr 已经消失。这时 g_flag_plain 这个对象还在不在 .o 里的符号表中?用 arm-none-eabi-nm 回答。去掉 static 再编译一次,符号还在不在?说明这个对象是被哪一步(编译还是链接)去掉的。
(3) 如果 ISR 里写的是 g_flag_plain = 1u、主循环等待的却是 g_flag_plain == 2u,加 volatile 能不能解决?用一句话说明 volatile 保证了什么、没保证什么。
(4) 一个 volatile uint32_t 计数器被两个中断同时 ++。反汇编之后是几条指令?哪两条之间被打断会丢更新?用 atomic_uint 或临界区各写一句修正。
(5) 正文说「-O0 下恰好不优化」。如果把发布构建改成 -O0 来绕开这类问题,除了性能之外还失去什么?
正文的 -fanalyzer 示例里有一段 append_command,它有一处内存泄漏和一处释放后使用。下面两个文件里的代码一共四处缺陷,其中三处已用注释标出,还有一处没有标:
/* make.c */
#include <stdlib.h>
typedef struct {
int id;
char name[8];
} item_t;
item_t *item_make(int id) {
item_t *p = malloc(sizeof *p);
p->id = id;
return p;
}2
3
4
5
6
7
8
9
10
11
12
13
/* load.c */
#include <stdlib.h>
typedef struct {
int id;
char name[8];
} item_t;
item_t *item_make(int id);
int load(int which) {
item_t *slot = item_make(1);
if (slot == NULL) {
return -1;
}
if (which == 0) {
return 0; /* 第一处缺陷 */
}
free(slot);
return 0;
}
int use_after_free(void) {
item_t *p = item_make(3);
free(p);
return p->id; /* 第二处缺陷 */
}
int free_stack(void) {
item_t local = {0};
free(&local); /* 第三处缺陷 */
return 0;
}
int main(void) {
return load(1) + use_after_free() + free_stack();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
(1) 先人工列出四处缺陷,标注在 make.c 还是 load.c。再用 gcc -std=c17 -Wall -Wextra -fanalyzer -O2 分别编译两个文件,贴出诊断,与清单对照。
(2) 哪一处缺陷没有被报出来?它属于哪一类分析盲区?把 item_make 的定义整个搬进 load.c、并把 main 里的调用改成能走到那条分支的形式,再编译一次,这一处会被报出来吗?
(3) item_make 里的 malloc 没有检查返回值,-fanalyzer 给的诊断叫 -Wanalyzer-possible-null-dereference,而不是「没有检查 malloc」。说明两者在语义上的区别。
(4) 把四处都修好,重新编译,确认两个文件都零警告。给出修 use_after_free 与 free_stack 的代码,并说明 -Wanalyzer-* 的哪几个子选项可以分别打开这三类检查。
正文第 5.3 节列了五个评审时容易漏掉的问题。下面一段 ISR 与任务代码里都犯了:
static volatile uint32_t g_drop_count;
static volatile uint8_t g_frame[64];
static volatile uint8_t g_frame_len;
void USART1_IRQHandler(void) {
g_frame[g_frame_len++] = (uint8_t)USART1->DR;
++g_drop_count;
printf("rx %u\n", (unsigned)g_frame_len);
}
void frame_task(void *arg) {
for (;;) {
if (g_frame_len > 0u) {
uint8_t n = g_frame_len;
process(g_frame, n);
g_frame_len = 0u;
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(1) 逐条指出问题:ISR 里的打印、g_frame_len++ 的原子性、g_frame 的撕裂读、printf 的代价、任务的忙等、g_frame_len 的类型。每条写出它在什么情况下出错。
(2) 把 g_frame_len++ 展开成 Cortex-M3 上的指令序列,指出中断落在哪两条之间会丢更新。这一段的影响跟 -O2 有关吗?把 g_frame_len 换成 volatile uint32_t 能解决吗?
(3) 改写成「ISR 只搬字节进环形缓冲、任务处理」的版本。给出两个函数与共享对象的完整声明,说明哪些对象还需要 volatile、哪些不需要。
(4) 给这段代码写三条评审意见,按正文「问题 + 依据 + 建议」的结构,每条不超过三句话。
(5) 用上面这套检查清单逐项过一遍你自己的代码(或正文里任意一段示例),找出至少一处可以改进的地方。
7.2 编程
下面这段代码是某个传感器节点里的采集与命令处理。用正文那套警告选项编译,x86 主机上会报 8 条,arm-none-eabi-gcc 下会报 9 条;另有一处工具完全查不出、只能靠评审:
#include <stdint.h>
#include <stdio.h>
static uint8_t g_rx_done; /* ISR 里置位,主循环里等待 */
void usart_isr(void) {
g_rx_done = 1u;
}
int window_ok(int used, unsigned capacity) {
if (used > capacity) {
return 0;
}
return 1;
}
void set_period(uint8_t *reg, uint16_t period_us) {
*reg = period_us / 10u;
}
float celsius(float raw) {
return raw * 0.0625 * 3.3;
}
uint32_t top_mask(void) {
return 1 << 31;
}
int mode_of(int cmd) {
switch (cmd) {
case 1:
return 10;
case 2:
return 20;
}
return -1;
}
int scaled(int n) {
int sum = 0;
{
int sum = n * 2;
return sum;
}
return sum;
}
uint32_t load_word(const uint8_t *buf) {
return *(const uint32_t *)buf;
}
int main(void) {
uint8_t reg = 0;
uint8_t word[4] = {1, 2, 3, 4};
set_period(®, 3000u);
while (g_rx_done == 0u) {
}
printf("%u %d %u %d\n", (unsigned)reg, window_ok(-1, 8u), (unsigned)top_mask(), mode_of(9));
printf("%.3f %d %u\n", (double)celsius(1.0f), scaled(3), (unsigned)load_word(word));
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
(1) 用 gcc -std=c17 -Wall -Wextra 加正文第 3 节的十一个选项、-O2 编译,数出警告条数,逐条对应到行号与选项。再换 arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb 编译,哪一条只在 ARM 上出现?为什么 x86 上看不到?
(2) 逐处修正。修 set_period 时要决定「截断」还是「拒绝」,两种改法的接口各写成什么样?修 load_word 时要处理对齐,给出两种写法并说明哪种在 M3 上更可靠。
(3) main 里 word 是 uint8_t[4],转成 const uint32_t * 之后传给 load_word。把 word 改成 uint32_t word[2] = {1, 2} 之后,-Wcast-align 还报吗?说明原因。
(4) 那份工具查不出的缺陷是哪一个?写出会触发它的场景(从编译器角度说明 while 被优化成了什么),以及在评审清单里对应哪一项。
(5) 加 -Werror 重新编译。逐条修完之后应当零警告零错误;贴出最终的编译命令与输出。
给上面这份工程配一套能进 CI 的检查,并让「新增代码零告警」可执行。
(1) 写一份 .clang-format,基于 LLVM 风格、4 空格缩进、行宽 100。用 clang-format --dry-run --Werror 检查一个故意乱排格式的文件,贴出失败信息;再格式化,贴出 diff。
(2) 写一份 .clang-tidy,只开 bugprone-* 与 cert-*,关掉 bugprone-narrowing-conversions 与 cert-err33-c。用正文那个 tidy_demo 的简化版验证:哪几条仍然报、哪些因为关掉而不报了?
(3) 写一个脚本 check.sh:先用 -Werror 编译,再跑 clang-tidy,再跑 cppcheck 的 warning 级别,任一失败就以非零退出。给出脚本与一次通过的输出。
(4) 基线怎么维护?设计一个基线文件格式(每行一条:文件、行号、规则名、工单号),写出把当前告警导入基线、以及只检查新增告警的两个命令。
(5) 三级检查各覆盖什么:编译器、-fanalyzer、clang-tidy、cppcheck。做一张表,列为「能看到跨函数吗」「能看到跨文件吗」「典型误报」「适合放在哪一步」。
正文 5.2 节给出了一条评审意见的写法。把一批真实的问题写成评审意见,并做一次模拟评审。
(1) 对 6.4 节 jump_to_app 里「先换 MSP 再用局部对象」的写法,写一条评审意见(问题 + 依据 + 建议)。
(2) 对 6.4 节 journal_append 里「日志写满返回 −2、调用方没有处理」的写法,写一条评审意见。
(3) 对 6.6 节自己这份代码里 1 << 31 的写法,写一条评审意见。要求第一句点明位置与问题,第二句给出后果,第三句给出可行改法。
(4) 三条意见里哪一条属于「工具查不出、评审才看得到」?哪一条工具能查、应该直接让 CI 拦下来?说明理由,并指出如果 CI 已经拦下,评审者还需要写这条吗。
(5) 反过来,写三条「不该提出的评审意见」:属于风格偏好、工具已经覆盖、或者没有依据的猜测。说明它们为什么会被正文的方法挡掉。
7.3 综合与挑战
为一支五人嵌入式团队起草一份编码规范的裁剪方案,并说明每条规则的执行手段。
(1) 从正文的三个规范里选一个组合。给出规则清单,不超过 25 条,分成三组:语言与类型、并发与中断、风格与可读性。每条注明来源(MISRA 编号、CERT 编号或 BARR-C 条款)与执行手段(编译器选项、clang-tidy 检查、cppcheck、人工评审)。
(2) 逐条判断能否自动化。做一张表:规则、能否自动检查、用什么工具、误报情况。不能自动化的那些,写进评审清单的哪一项?
(3) 设计偏离记录的模板:规则编号、代码位置、原因、替代措施、批准人、复查日期。用 6.4 节「UTD 必须用回调而不是轮询」这类与规范冲突的设计举一个例子,填一份。
(4) 团队已有 3 万行老代码,一次全量整改会产生几千条告警。给出分三步走的收紧方案:每步的目标、验收标准、以及「什么时候可以进入下一步」。
(5) 说明这套方案在半年后怎么维护:规则增删的依据、基线的清理、以及怎么避免工具被关掉。
8. 参考资料
- MISRA, MISRA C:2025:规则分类(指令与规则、强制/必需/建议)与偏离流程。
- SEI, SEI CERT C Coding Standard:按缺陷类别组织的规则与正反例。
- Barr Group, Embedded C Coding Standard (BARR-C:2018):命名、格式与可读性条款。
- GCC, Warning Options:
-Wconversion、-Wsign-conversion、-Wdouble-promotion、-Wshift-overflow等选项的确切含义。 - GCC, Static Analyzer Options:
-fanalyzer的检查项与-Wanalyzer-*子选项。 - LLVM, clang-tidy:检查项的命名空间与
.clang-tidy的写法。 - Cppcheck, Cppcheck manual:
--enable、--addon、--suppress与基线文件的用法。 - Memfault, The Best and Worst GCC Compiler Flags For Embedded:嵌入式项目推荐警告集的取舍。
- Google, Google Engineering Practices: Code Review:评审意见的写法与评审者应关注什么。
- 本页的编译器警告、
-fanalyzer诊断、clang-tidy 与 cppcheck 输出、代码体积均为本机实测:示例代码用 MinGW gcc 13.2 与 Arm GNU Toolchain 13.3 编译,clang-tidy 为 LLVM 19.1.7,cppcheck 为 2.14.0,体积用 arm-none-eabi-size 量取。