Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

2.12 软件单元设计与MISRA C——被驯服的C语言

场景

你面前的屏幕上开了两个窗口。左边是一段C代码,你上个月写的:充满了年轻工程师的“聪明“:动态分配、函数指针跳来跳去、两处goto用来快速跳出嵌套循环。它通过了单元测试,单元测试也覆盖了所有分支。

右边是同一段功能的实现,按照MISRA C:2012重写的。没有动态内存、没有递归、没有goto、每个函数只有一个return。它看起来更冗长、更“笨拙“。

产品经理路过瞄了一眼,说:“右边的代码更慢吧?”

你说:“对,慢了大约3%。”

产品经理反问:“那为什么要改?”

你沉默了三秒。这不是性能问题。这是:左边那份代码,无法被分析。它“能跑“,但你无法向任何人证明它是安全的。右边那份代码,可以被静态分析工具审理,可以被结构覆盖率工具测量,可以在审计员面前摊开来逐条对证。

ISO 26262-6 Table 6讲的就是这件事。

先把两边代码铺开

版本A:“正常C”

/* 方向盘转角计算 —— "正常C"风格 */
#include <stdlib.h>
#include <string.h>

typedef struct {
    float *raw_samples;
    int count;
    float offset;
} AngleCalc_t;

static AngleCalc_t *g_calc = NULL;

float calc_steering_angle(void) {
    if (g_calc == NULL) {
        g_calc = (AngleCalc_t *)malloc(sizeof(AngleCalc_t));
        g_calc->raw_samples = (float *)malloc(100 * sizeof(float));
        g_calc->count = 0;
        g_calc->offset = 0.0f;
    }
    
    float raw = read_sensor();
    
    if (g_calc->count >= 100) {
        goto filter_done;
    }
    g_calc->raw_samples[g_calc->count++] = raw;
    
filter_done:
    float sum = 0.0f;
    int valid = 0;
    for (int i = 0; i < g_calc->count; i++) {
        /* 模拟:某些情况下需要跳过异常值 */
        if (g_calc->raw_samples[i] > 900.0f || 
            g_calc->raw_samples[i] < -900.0f) continue;
        sum += g_calc->raw_samples[i];
        valid++;
    }
    
    return (valid > 0) ? (sum / valid - g_calc->offset) : 0.0f;
}

这段代码能编译,能运行,在测试台上跑了一个下午没出任何问题。但让我们数一数它踩了ISO 26262-6 Table 6的哪些红线:

  1. 一处入口、多处出口(违反Table 6-1a: One entry and one exit point):函数有两个隐式的return路径(if条件里的0.0f和末尾return),还有一个goto跳转逻辑。这在结构覆盖率分析中会制造几乎无法追踪的路径组合。

  2. 动态对象(违反Table 6-1b: No dynamic objects):使用malloc在堆上分配内存。在实时嵌入式系统中,malloc的行为是非确定性的。它可能返回NULL(内存碎片化导致分配失败),而你的代码在if (g_calc == NULL)时确实会调用malloc,但如果在运行中期因碎片化而malloc失败呢?你没有处理。

  3. 没有初始化(违反Table 6-1c: Initialization of variables):局部变量sum、valid声明时未初始化。编译器通常不会为栈变量清零,它们的初值是前一个函数调用残留的栈数据:一个“不可见“但真实存在的非确定性输入。

  4. 全局变量(违反Table 6-1e: Avoid global variables):g_calc是全局指针。任何其他函数(包括QM分区的那些代码:假设有人错误地链接了它们)都可以读取甚至改写它。

  5. 函数指针等效行为(违反Table 6-1f: Restricted use of pointers):通过指针间接访问动态分配的数组,没有边界检查,count的增量没有上限保护。

  6. 隐藏的数据流(违反Table 6-1h: No hidden data flow):count静态地保留了上一次调用的状态,这是通过全局变量实现的状态机。但它的状态转换没有被显式地建模,使得测试和验证难以穷举所有状态。

  7. 无条件跳转(违反Table 6-1i: No unconditional jumps):goto filter_done破坏了线性控制流。

  8. 隐式类型转换(违反Table 6-1g: No implicit type conversions):read_sensor()的返回值类型不明确,赋值给float可能涉及隐式转换。

一份不到50行的C代码,违反了Table 6十项中的八项。 它不是“恶意“的。它是用“正常“的C风格写的。问题是,在ASIL-D的世界里,“正常C“本身就是恶意代码。

版本B:MISRA C:2012

现在看右边:同样的功能,按照MISRA C:2012(ISO 26262-6 Table 6明确指出MISRA C覆盖了Table 6的多项原则)重写的:

/* PRQA S 0492, 3450 EOF */
/* 方向盘转角计算 —— MISRA C:2012风格 */

#define ANGLE_SAMPLE_SIZE   16U
#define ANGLE_MAX_VALID     900.0f
#define ANGLE_MIN_VALID     -900.0f
#define ANGLE_INVALID       0.0f

typedef enum {
    FILTER_INIT,
    FILTER_FILLING,
    FILTER_READY
} FilterState_en;

typedef struct {
    float32 sample_buffer[ANGLE_SAMPLE_SIZE];
    uint8   write_index;
    uint8   sample_count;
    float32 offset;
    FilterState_en state;
} AngleFilter_st;

static AngleFilter_st s_filter;

FUNC(void, ANGLECALC_CODE) AngleFilter_Init(void)
{
    uint8 i;
    
    s_filter.write_index   = 0U;
    s_filter.sample_count  = 0U;
    s_filter.offset        = 0.0f;
    s_filter.state         = FILTER_INIT;
    
    for (i = 0U; i < ANGLE_SAMPLE_SIZE; i++)
    {
        s_filter.sample_buffer[i] = 0.0f;
    }
}

FUNC(float32, ANGLECALC_CODE) AngleFilter_Update(float32 raw_angle)
{
    float32 sum           = 0.0f;
    float32 average       = 0.0f;
    uint8   valid_count   = 0U;
    uint8   i;

    if (s_filter.state == FILTER_INIT)
    {
        s_filter.state = FILTER_FILLING;
    }

    if (s_filter.write_index < ANGLE_SAMPLE_SIZE)
    {
        s_filter.sample_buffer[s_filter.write_index] = raw_angle;
        s_filter.write_index++;
    }
    else
    {
        s_filter.write_index = 0U;
    }

    if (s_filter.sample_count < ANGLE_SAMPLE_SIZE)
    {
        s_filter.sample_count++;
        s_filter.state = FILTER_FILLING;
    }
    else
    {
        s_filter.state = FILTER_READY;
    }

    if (s_filter.state == FILTER_FILLING)
    {
        average = raw_angle;
    }
    else
    {
        sum = 0.0f;
        valid_count = 0U;

        for (i = 0U; i < ANGLE_SAMPLE_SIZE; i++)
        {
            if ((s_filter.sample_buffer[i] >= ANGLE_MIN_VALID) &&
                (s_filter.sample_buffer[i] <= ANGLE_MAX_VALID))
            {
                sum += s_filter.sample_buffer[i];
                valid_count++;
            }
        }

        if (valid_count > 0U)
        {
            average = (sum / (float32)valid_count) - s_filter.offset;
        }
        else
        {
            average = ANGLE_INVALID;
        }
    }

    return average;
}

逐一对比Table 6的符合情况:

  • Table 6-1a(一进一出):每个函数只有一个return语句。控制流不跳转、不goto。状态机用显式的枚举类型建模,每个状态的转换路径清晰可追踪。

  • Table 6-1b(无动态对象):所有内存在编译时静态分配。sample_buffer是16个float的静态数组。没有malloc,没有运行时内存分配的不确定性。也正因为没有动态内存,不存在“分配失败“的故障模式。

  • Table 6-1c(变量初始化):所有局部变量在声明时被显式初始化。sum、average、valid_count、i:每一个都从已知值出发。

  • Table 6-1f(指针使用限制):完全不使用指针,只通过数组索引和静态结构体成员访问数据。Cortex-R5寻址不会因为写*(ptr++)还是写array[index]而有性能差别。但后者的地址范围在编译时可以被静态分析工具完全验证。

  • Table 6-1e(避免全局变量):虽然s_filter在文件作用域,但它被声明为static,链接器级的作用域仅限于当前编译单元,外部代码无法访问。在AUTOSAR上下文中,这通常在RTE的封装机制下进一步限制:每个SW-C拥有自己的RTE端口,数据流在架构层面被规范化。

  • Table 6-1g(无隐式类型转换):显式类型转换 (float32)valid_count : MISRA要求所有类型转换要么是同类型的赋值,要么显式写出转换操作。

  • Table 6-1h(无隐藏数据流):所有状态都在AngleFilter_st结构体中显式表示。每个状态转换都有明确的入口条件和出口条件。没有“悄悄地改变了一个全局计数器而后又通过条件表达式读取它“的模式。

  • Table 6-1i(无条件跳转):没有goto。没有setjmp/longjmp。控制流只有if/else和for,路径数量有限且可证明。

  • Table 6-1j(无递归):所有函数均采用迭代实现,无递归调用,调用栈深度在编译时可完全确定。

这不是“写得更保守“。这是“写得可以被分析“。

MISRA C与Table 6的关系

ISO 26262-6第8章的Table 6列出了软件单元设计和实现的原则:一共十条(1a到1j)。标准在Table 6之后的注释中明确引用了MISRA C作为可以实现Table 6中多条原则的编码指南(原文:“For the C language, MISRA C covers many of the principles listed in Table 6”)。这不是巧合:MISRA C:2012有143条必须遵守的规则和16条建议规则,覆盖了Table 6关注的几乎所有领域。

MISRA的核心思想一点也不神秘,一句话就能概括:消除C语言中所有不确定的、依赖于实现的、不可静态分析的行为。 它不关心“这段代码能不能跑“。它关心“这段代码能不能被证明总是按预期跑“。

一个例子帮你理解“不确定行为“和Table 6的关系:

/* 危险:函数参数求值顺序未定义 */
result = calc_a(x++) + calc_b(x);

C标准对函数参数的求值顺序不做规定:编译器可以先算calc_a也可以先算calc_b。这意味着x++可能在calc_b(x)之前执行也可能之后。编译器的选择会导致不同的结果,而这一切在你的代码中没有明示。MISRA C Rule 13.2明确禁止这种模式。

另一个例子:

/* 危险:有符号整数溢出是未定义行为 */
int32_t angle = 2147483640;
angle = angle + sensor_offset;

sensor_offset如果大于7,angle溢出。C标准规定有符号整数溢出是未定义行为:编译器可以删除所有依赖这个溢出值的代码。不是“得到一个错误结果“,而是“整个程序的行为不再受标准约束“。

这些不是“不小心写错“的Bug,是“在正常情境下C标准允许但不该出现在安全关键代码中“的语言特性。 MISRA把这些特性全部关掉:MISRA给的是一个更小的、但行为完全确定的C语言子集。

软件单元验证:Table 7与Table 9

写完代码之后,你需要验证它。不是靠“跑了几个测试用例没崩“:ISO 26262-6 Table 7按ASIL等级列出了必须执行的验证方法:

方法ASIL AASIL BASIL CASIL D
静态代码分析++++++++
故障注入测试+++++
资源使用测试+++++

“++“表示“高度推荐”,“+“表示“推荐”。

几点说明:

静态代码分析(对所有ASIL都是++):这不是你IDE里那种“变量未使用“检查。这是基于MISRA C规则的自动化合规检查:工具逐条对证143条强制规则,告诉你第几行违反了Rule X.X。PRQA(QA-C)就是这类工具的代表,它会为每一条违规生成一个报告项,你需要为每一个报告项提供一个Deviation(偏离说明),解释为什么允许这次违反。

故障注入测试(ASIL D为++):你在代码中注入错误:比如人为把sensor_offset设成异常值、强制sample_count跳到一个不合理的数字。然后观察系统是否安全地响应(进入安全状态或降级运行)。这不是“看看代码有没有Bug“,这是“看看代码有多抗揍“。

资源使用测试(ASIL D为++):Stack深度分析、执行时间分析(WCET)。你需要证明运行这个函数不会栈溢出、不会超过CPU时间预算。对于ASIL D,你需要最坏情况执行时间的测量数据:不是平均执行时间,而是理论上可能出现的最大值。

结构覆盖率:ASIL D = Branch + MC/DC

等到单元测试写完,你还必须度量结构覆盖率(Structural Coverage),依据是ISO 26262-6 Table 9:

覆盖指标ASIL AASIL BASIL CASIL D
语句覆盖率++++++
分支覆盖率+++++++
MC/DC+++++

ASIL D要求达到MC/DC(Modified Condition/Decision Coverage):修正条件判定覆盖。

很多人觉得MC/DC很难,其实它的概念简单:

对于if (A && B),要满足MC/DC,你需要证明:

  • A的改变能独立影响整个判定(B保持为true时,改变A会改变结果)
  • B的改变能独立影响整个判定(A保持为true时,改变B会改变结果)

这意味着你至少需要三个测试用例:(true, true), (false, true), (true, false)。三个:不是四个。MC/DC关心的不是所有组合(那是MCDC的完整真值表要求),而是每个条件在判定中的独立影响

为什么ASIL D要求MC/DC?因为语句覆盖和分支覆盖可以遗漏逻辑缺陷。看这个例子:

if ((speed > 5) && (gear == GEAR_DRIVE)) {
    enable_steering_assist();
}
  • 语句覆盖:只需要一个测试用例(speed=10, gear=GEAR_DRIVE)就能覆盖enable_steering_assist()
  • 分支覆盖:需要两个用例:(speed=10, gear=GEAR_DRIVE)和(speed=0, gear=GEAR_PARK)
  • MC/DC:需要三个用例,其中speed>5必须独立地决定结果。所以你需要(speed=10, gear=GEAR_DRIVE)和(speed=0, gear=GEAR_DRIVE),这里gear保持为GEAR_DRIVE不变,改变speed来验证speed的独立影响。

但一次MC/DC分析就能发现的问题:如果gear == GEAR_DRIVE的短路求值隐藏了speed<=5时的Bug呢? 只有通过独立分析每个条件的影响力,你才能确信“这个逻辑没有死条件,每个条件都真的在起作用“。

本篇小结

  1. ISO 26262-6 Table 6定义了ASIL软件单元设计的十条基本原则:设计原则(模块化/封装/简洁性)、单入口单出口、禁止动态对象、变量初始化、限制指针、避免全局变量、禁止隐式类型转换、无隐藏数据流、无无条件跳转、无递归
  2. MISRA C:2012被标准明确引用为覆盖这些原则的编码指南
  3. ASIL D必须达到分支覆盖+MC/DC覆盖

【下集预告】: MISRA C让代码变得可分析,但分析不等于验证。下一节面对一个看似简单的if语句——三个条件通过&&连接决定是否触发安全关机。语句覆盖用一个测试就过了。分支覆盖用了两个。但ASIL D要求的是MC/DC——你必须证明每一个条件都能“独立地“决定最终结果。三个条件,只需要四个测试用例,但任何一个条件的隐藏漏洞都无处遁形。