核心规则解读(二):控制流与循环结构——规则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的核心逻辑串起来了。你可以把它当作一个“决策树”,写代码时对照着检查。

MISRA-C 控制流与循环结构知识体系(规则51-100) MISRA-C 控制流与循环结构知识体系(规则51-100) 控制流结构 goto语句(规则51-56) switch-case(规则57-62) 循环结构(规则63-70) 仅用于单一出口错误处理 禁止跳入控制结构内部 禁止跳过变量声明 必须有default分支 非空case必须break case内禁止定义变量 终止条件必须提前确定 禁止修改循环控制变量 条件必须为布尔类型 核心原则:单一出口、结构清晰、可预测 任何控制流都要让审查者一眼看穿执行路径

3.5 实战对照表:常见违规与修正

最后,我整理了一个对照表。你在做代码审查时,可以直接拿这张表去核对。

违规模式 风险等级 典型后果 修正方案
goto跳入循环内部 致命 跳过初始化,变量状态未知 改用函数调用或状态机
switch缺少default 高 未处理值导致静默失败 添加default分支,记录错误
case fall-through无注释 高 逻辑错误,行为不符合预期 加break或显式注释
循环体内修改控制变量 中 循环次数不可预测,可能死循环 用while+标志变量替代
while条件非布尔 低 可读性差,审查时容易遗漏 显式比较,如while(x != 0)