核心规则解读(二):控制流与循环结构——规则51-100
各位好,欢迎来到第三章。上一章我们聊了基础的数据类型和声明,今天要啃的这块骨头,是控制流和循环。说白了,就是代码怎么“拐弯”,怎么“转圈”。
这部分规则多,而且跟运行时bug直接挂钩。我见过太多因为一个break没写对,或者循环条件写拧巴了,导致现场设备死机的案例。咱们一条一条捋清楚。
本章核心:控制流结构必须清晰、可预测、无歧义。任何“跳转”和“分支”都要让代码审查者一眼看穿意图。
3.1 goto语句:能用,但得锁在笼子里
关于goto,争议一直很大。MISRA-C的态度很明确:不是禁用,是严格限制。规则51-56就是给它上的枷锁。
规则51:禁止使用goto语句(除非用于单一出口的错误处理)。
我个人习惯,只在一种场景下用goto:函数末尾的“统一清理”。比如你打开了一个文件、申请了一段内存、获取了一个锁,中间任何一步失败,都得跳到最后去释放资源。
/* 合规用法:单一出口错误处理 */
int32_t process_data(void)
{
int32_t result = -1;
FILE *fp = NULL;
uint8_t *buffer = NULL;
fp = fopen("data.bin", "rb");
if (fp == NULL) {
goto cleanup; /* 跳转到统一清理 */
}
buffer = (uint8_t*)malloc(1024);
if (buffer == NULL) {
goto cleanup; /* 跳转到统一清理 */
}
/* ... 处理数据 ... */
result = 0;
cleanup:
if (buffer) free(buffer);
if (fp) fclose(fp);
return result;
}
避坑指南:我曾经在一个项目里看到有人用goto实现“状态机跳转”,代码像蜘蛛网一样。后来重构时,把所有goto都换成了switch-case,可读性直接翻倍。记住:goto只能向下跳,不能向上跳;只能跳向函数末尾的标签,不能跳进任何控制结构内部。
规则52-56:禁止goto跳入循环或选择结构内部;禁止goto跳过变量声明。
这些规则其实是一个意思:别让goto破坏代码的结构化。你想想看,如果goto能随便跳进一个for循环中间,那循环的初始化、条件判断全被绕过了,变量状态根本不可控。
3.2 switch-case:每个case都得是“孤岛”
switch-case是嵌入式里最常用的多分支结构。但用不好,它就是bug的温床。规则57-62专门针对它。
规则57:每个switch语句都必须有default分支。
哪怕你确信所有枚举值都覆盖了,也得写个default。为什么?因为枚举变量在内存里可能被篡改(比如硬件干扰),或者未来有人新增了枚举值但忘了更新switch。一个default分支,就是最后的防线。
/* 合规写法 */
switch (state) {
case STATE_IDLE:
/* ... */
break;
case STATE_RUNNING:
/* ... */
break;
case STATE_ERROR:
/* ... */
break;
default:
/* 捕获所有未处理的情况 */
report_error(ERR_UNKNOWN_STATE);
break;
}
规则58:每个非空的case子句必须以break结尾。
这就是著名的“fall-through”陷阱。C语言默认会“穿透”到下一个case,但MISRA-C禁止这种隐式行为。如果你确实需要连续执行多个case,必须显式注释说明。
/* 允许的fall-through:必须加注释 */
switch (cmd) {
case CMD_STOP:
/* falls through — 继续执行初始化 */
case CMD_START:
initialize();
break;
default:
break;
}
警告:我曾经调试过一个电机控制器的bug,现象是按下“停止”键后电机反而加速。查了两天,最后发现是case CMD_STOP:后面漏了break,代码直接“穿透”到了加速逻辑。从那以后,我写switch必先写break,再写处理逻辑。
规则59-62:case标签必须是整型常量表达式;每个switch最多只能有一个default;禁止在case内部定义变量。
最后这条尤其重要。如果你非要在case里定义变量,必须用大括号括起来形成独立作用域:
case CMD_CONFIG: {
uint32_t timeout = 100; /* 用大括号包裹 */
configure(&timeout);
break;
}
3.3 循环结构:让终止条件一目了然
循环是嵌入式软件的“心脏”——大部分时间CPU都在循环里跑。规则63-70聚焦于循环的终止条件,确保它不会变成“永动机”。
规则63:循环的终止条件必须是常量表达式,或者是在循环开始前就确定的变量。
说白了,不要在循环体里修改循环控制变量(除非你很清楚自己在做什么)。比如:
/* 危险写法:循环体内修改了终止条件 */
for (i = 0; i < get_count(); i++) { /* get_count()每次循环都调用 */
/* ... */
}
/* 安全写法:终止条件提前确定 */
uint32_t count = get_count();
for (i = 0; i < count; i++) { /* count是常量 */
/* ... */
}
规则64:禁止在for循环的迭代语句中修改循环控制变量。
这个规则针对的是for(i=0; i<n; i++)里的i++部分。你可以在循环体里读i,但不要写i。如果需要更复杂的步进,用while循环。
规则65:while循环的条件表达式必须为布尔类型。
这跟之前说的“布尔表达式”规则一脉相承。不要写while(x),要写while(x != 0)。虽然编译器生成的代码一样,但可读性天差地别。
/* 不推荐 */
while (buffer_ptr) { ... }
/* 推荐 */
while (buffer_ptr != NULL) { ... }
规则66-70:禁止在循环体内使用break跳出多层循环;禁止使用continue;循环体必须用大括号包裹。
这些规则的核心思想是:循环的入口和出口必须单一。如果你需要跳出多层循环,正确的做法是用一个标志变量:
/* 替代多层break的方法 */
bool_t done = false;
for (i = 0; (i < ROWS) && (!done); i++) {
for (j = 0; (j < COLS) && (!done); j++) {
if (matrix[i][j] == TARGET) {
done = true; /* 设置标志,两层循环都会退出 */
}
}
}
个人经验:关于continue,MISRA-C是禁止的。我刚开始觉得这太苛刻了,直到有一次review代码,发现一个continue藏在嵌套if的深处,导致循环末尾的资源释放代码被跳过了。从那以后,我写循环都只用if-else控制流程,不用continue。
3.4 控制流知识体系总览
下面这张图把规则51-100的核心逻辑串起来了。你可以把它当作一个“决策树”,写代码时对照着检查。
3.5 实战对照表:常见违规与修正
最后,我整理了一个对照表。你在做代码审查时,可以直接拿这张表去核对。
| 违规模式 | 风险等级 | 典型后果 | 修正方案 |
|---|---|---|---|
| goto跳入循环内部 | 致命 | 跳过初始化,变量状态未知 | 改用函数调用或状态机 |
| switch缺少default | 高 | 未处理值导致静默失败 | 添加default分支,记录错误 |
| case fall-through无注释 | 高 | 逻辑错误,行为不符合预期 | 加break或显式注释 |
| 循环体内修改控制变量 | 中 | 循环次数不可预测,可能死循环 | 用while+标志变量替代 |
| while条件非布尔 | 低 | 可读性差,审查时容易遗漏 | 显式比较,如while(x != 0) |