规则详解-基础篇:禁止使用动态内存分配(Rule 20-4),禁止使用setjmp/longjmp(Rule 20-7)

好,我们进入基础篇的两个硬核规则。说实话,这两条规则在MISRA C里属于那种「新手觉得无所谓,老手看了直摇头」的类型。我最早接触MISRA时也觉得「不用malloc?那我链表怎么办?」——后来在项目里吃了几次亏,才明白这些规则背后全是血泪教训。

Rule 20-4:禁止使用动态内存分配

这条规则禁止的不仅仅是malloc和free,还包括calloc、realloc以及C11的aligned_alloc。说白了,堆上分配内存这件事,在MISRA合规的嵌入式项目里是禁区。

为什么禁止?

我直接说三个最要命的问题:

  • 内存碎片化:嵌入式系统往往连续运行几个月甚至几年。频繁分配释放,堆会碎成一片一片的。我曾经在一个工控项目里见过,系统跑了72小时后,明明还有30%的堆空间,但malloc一个128字节的块就是失败——因为找不到连续的空闲块。
  • 分配失败无保障:malloc返回NULL时你怎么办?很多代码直接忽略检查,或者检查了也不知道怎么处理。安全关键系统里,「分配失败」必须是一个可预测的状态,而不是运行时才暴露的炸弹。
  • 释放时机不确定:谁负责释放?什么时候释放?double-free、use-after-free、内存泄漏——这些bug在静态分析阶段很难全部抓出来。

核心观点:动态内存分配引入了运行时不确定性,而MISRA C追求的是「编译时就能确定行为」。静态分配虽然死板,但安全。

替代方案

不用动态分配,那数据结构和变长需求怎么处理?我列几个实际项目里常用的做法:

需求场景 替代方案 说明
变长数组 静态数组 + 最大长度宏 定义#define MAX_BUF_SIZE 256,实际使用uint8_t buf[MAX_BUF_SIZE],用变量记录当前有效长度
链表/队列 静态池(Static Pool) 预分配固定数量的节点数组,运行时从池中取用,用完归还
动态字符串 固定长度字符数组 比如char name[32],截断或返回错误,不接受变长
运行时对象创建 状态机 + 预分配对象 所有可能用到的对象在初始化时全部创建好,运行时切换状态

我的习惯:静态池是我最推荐的方案。你预先算好最大并发数,分配一个数组,自己实现pool_alloc和pool_free。这样既避免了堆碎片,又保留了「分配/释放」的语义。我在一个车载ECU项目里就是这么做的,跑了三年没出过内存问题。

代码示例:静态池 vs malloc

/* 违反MISRA:使用动态内存分配 */
#include <stdlib.h>

typedef struct {
    uint32_t id;
    uint8_t data[64];
} Message;

Message* create_message(uint32_t id) {
    Message* msg = (Message*)malloc(sizeof(Message));
    if (msg != NULL) {
        msg->id = id;
    }
    return msg;  /* 调用者必须记得free,否则泄漏 */
}

/* 合规方案:静态池 */
#define POOL_SIZE 10

static Message msg_pool[POOL_SIZE];
static bool pool_used[POOL_SIZE] = {false};

Message* pool_alloc(void) {
    for (int i = 0; i < POOL_SIZE; i++) {
        if (!pool_used[i]) {
            pool_used[i] = true;
            return &msg_pool[i];
        }
    }
    return NULL;  /* 池满,分配失败 */
}

void pool_free(Message* msg) {
    for (int i = 0; i < POOL_SIZE; i++) {
        if (&msg_pool[i] == msg) {
            pool_used[i] = false;
            return;
        }
    }
    /* 如果走到这里,说明传入的指针不属于池,属于逻辑错误 */
}

避坑指南:我曾经在一个项目里看到有人用「全局大数组 + 自己写malloc」来绕过规则检查。嗯,这其实只是把堆搬到了BSS段,碎片化问题一个都没少。MISRA禁止的是「动态分配」这个行为模式,不是禁止某个函数名。你手写的分配器如果也是动态管理,照样违规。

Rule 20-7:禁止使用setjmp/longjmp

这条规则禁止<setjmp.h>里的setjmp和longjmp。这两个函数提供了一种「非局部跳转」——你可以从深层嵌套的函数调用里直接跳回某个保存点。

为什么禁止?

你想想看,longjmp本质上是一个不受控制的goto,而且它能跨越函数边界。这会带来几个严重问题:

  • 资源泄漏:跳转时,中间函数栈上的局部变量不会被正常析构。如果那些函数打开了文件、申请了互斥锁、分配了资源——全泄漏了。我记得有个同事在通信协议栈里用了longjmp做错误恢复,结果每次异常跳转都漏掉一个socket句柄,系统跑了半天就报「打开文件过多」。
  • 栈状态不可预测:longjmp恢复的是保存点的栈帧,但中间栈帧已经被破坏了。如果你在中间函数里修改了全局变量或硬件寄存器,跳回去之后这些状态是错乱的。
  • 静态分析失效:控制流图被longjmp打碎,静态分析工具无法准确追踪变量生命周期和路径覆盖。MISRA合规要求可分析性,longjmp直接让分析工具「瞎了」。

一句话总结:setjmp/longjmp把结构化编程的「单入单出」原则彻底打破了。你无法从代码文本上看出执行路径会怎么跳,调试时也根本没法复现。

替代方案

不用longjmp,那错误处理和异常恢复怎么做?

场景 替代方案 说明
深层错误恢复 返回错误码 + 逐层传递 每个函数返回状态码,调用者检查并决定是否继续
超时/中断处理 状态机 + 定时器回调 用状态机管理流程,超时事件触发状态切换,而不是跳转
异常退出 全局错误标志 + 主循环检查 设置一个全局错误变量,主循环在每次迭代时检查并跳转到恢复点

我建议:如果你觉得「逐层返回错误码」太啰嗦,可以试试「集中式错误处理」模式——定义一个goto error_handler标签,所有错误路径都跳到这里做清理。但注意,这个goto只能在同一个函数内跳转,不能跨函数。这比longjmp安全得多,静态分析也能处理。

代码示例:longjmp vs 状态机

/* 违反MISRA:使用setjmp/longjmp */
#include <setjmp.h>

static jmp_buf env;

void process_data(void) {
    if (some_error) {
        longjmp(env, 1);  /* 直接跳回主函数 */
    }
    /* 正常处理 */
}

int main(void) {
    if (setjmp(env) == 0) {
        process_data();
    } else {
        /* 错误恢复 */
        cleanup();
    }
}

/* 合规方案:状态机 + 错误码 */
typedef enum {
    STATE_INIT,
    STATE_PROCESS,
    STATE_ERROR,
    STATE_DONE
} State_t;

State_t run_workflow(void) {
    State_t state = STATE_INIT;
    ErrorCode_t err = ERR_NONE;

    while (state != STATE_DONE) {
        switch (state) {
            case STATE_INIT:
                err = init_subsystem();
                state = (err == ERR_NONE) ? STATE_PROCESS : STATE_ERROR;
                break;

            case STATE_PROCESS:
                err = process_data();
                state = (err == ERR_NONE) ? STATE_DONE : STATE_ERROR;
                break;

            case STATE_ERROR:
                cleanup();
                state = STATE_DONE;  /* 或者重试逻辑 */
                break;

            default:
                /* 不应到达 */
                break;
        }
    }
    return state;
}

避坑指南:有些RTOS提供了类似longjmp的任务级跳转机制,比如FreeRTOS的taskEXIT_CRITICAL配合taskYIELD做异常恢复。嗯,这些本质上也是非局部跳转。MISRA的精神是「控制流要可预测」,所以这类机制在合规项目里同样不推荐。用状态机吧,虽然代码量多一点,但每个路径都是可见的、可测试的。

知识结构总览

下面这张图把两条规则的核心逻辑串起来了。你可以看到,两条规则都指向同一个目标——消除运行时不确定性。

MISRA C 基础规则:消除运行时不确定性 Rule 20-4:禁止动态内存分配 malloc / free / calloc / realloc 问题:碎片化 / 分配失败 / 泄漏 替代:静态池 / 最大长度数组 Rule 20-7:禁止setjmp/longjmp 非局部跳转:跨函数goto 问题:资源泄漏 / 栈混乱 / 不可分析 替代:状态机 / 错误码逐层返回 共同目标:编译时可确定的控制流与资源管理 静态分析可覆盖 → 测试可覆盖 → 缺陷可预防

这两条规则,一个管「资源怎么分配」,一个管「控制流怎么跳」。它们看起来是独立的,但底层逻辑一致——让代码的行为在写出来的时候就能被完整理解。动态内存分配让资源生命周期变得模糊,longjmp让执行路径变得不可追踪。去掉它们,你的代码会「笨」一点,但会「稳」很多。

我的经验:刚开始做MISRA合规时,团队里最大的阻力就是这两条规则。大家都觉得「不用malloc怎么写程序?」「不用longjmp怎么处理深层错误?」——但坚持下来之后,我们发现代码的可读性、可测试性和可靠性都上了一个台阶。说白了,安全关键系统不需要「灵活」,需要的是「确定」。