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的哪些红线:
-
一处入口、多处出口(违反Table 6-1a: One entry and one exit point):函数有两个隐式的return路径(if条件里的0.0f和末尾return),还有一个goto跳转逻辑。这在结构覆盖率分析中会制造几乎无法追踪的路径组合。
-
动态对象(违反Table 6-1b: No dynamic objects):使用malloc在堆上分配内存。在实时嵌入式系统中,malloc的行为是非确定性的。它可能返回NULL(内存碎片化导致分配失败),而你的代码在if (g_calc == NULL)时确实会调用malloc,但如果在运行中期因碎片化而malloc失败呢?你没有处理。
-
没有初始化(违反Table 6-1c: Initialization of variables):局部变量sum、valid声明时未初始化。编译器通常不会为栈变量清零,它们的初值是前一个函数调用残留的栈数据:一个“不可见“但真实存在的非确定性输入。
-
全局变量(违反Table 6-1e: Avoid global variables):g_calc是全局指针。任何其他函数(包括QM分区的那些代码:假设有人错误地链接了它们)都可以读取甚至改写它。
-
函数指针等效行为(违反Table 6-1f: Restricted use of pointers):通过指针间接访问动态分配的数组,没有边界检查,count的增量没有上限保护。
-
隐藏的数据流(违反Table 6-1h: No hidden data flow):count静态地保留了上一次调用的状态,这是通过全局变量实现的状态机。但它的状态转换没有被显式地建模,使得测试和验证难以穷举所有状态。
-
无条件跳转(违反Table 6-1i: No unconditional jumps):goto filter_done破坏了线性控制流。
-
隐式类型转换(违反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 A | ASIL B | ASIL C | ASIL 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 A | ASIL B | ASIL C | ASIL 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呢? 只有通过独立分析每个条件的影响力,你才能确信“这个逻辑没有死条件,每个条件都真的在起作用“。
本篇小结
- ISO 26262-6 Table 6定义了ASIL软件单元设计的十条基本原则:设计原则(模块化/封装/简洁性)、单入口单出口、禁止动态对象、变量初始化、限制指针、避免全局变量、禁止隐式类型转换、无隐藏数据流、无无条件跳转、无递归
- MISRA C:2012被标准明确引用为覆盖这些原则的编码指南
- ASIL D必须达到分支覆盖+MC/DC覆盖
【下集预告】: MISRA C让代码变得可分析,但分析不等于验证。下一节面对一个看似简单的if语句——三个条件通过&&连接决定是否触发安全关机。语句覆盖用一个测试就过了。分支覆盖用了两个。但ASIL D要求的是MC/DC——你必须证明每一个条件都能“独立地“决定最终结果。三个条件,只需要四个测试用例,但任何一个条件的隐藏漏洞都无处遁形。