同一份代码换编译器或把 -O0 调到 -O2 就返回乱值,查到根因往往是没写函数原型、参数被隐式转换,或者宏把 a++ 算了两遍。结论先给:MISRA C:2004 里真正决定编译器能否替你检查的,是第 8 章的原型与定义位置、第 16 章的参数数量与递归、第 19 章的宏括号、第 20 章的标准库黑名单这四组规则。
同一份 C 代码,在 A 家编译器上跑得好好的,换 B 家编译器,或者把优化等级从 -O0 调到 -O2,就开始返回乱值;查到最后,往往不是算法错,而是函数没写原型、参数被隐式转换,或者宏展开把 a++ 算了两遍。结论先给:MISRA C:2004 里真正决定"编译器能不能替你检查"的,是第 8 章的原型与定义位置、第 16 章的参数数量与递归、第 19 章的宏括号、第 20 章的标准库黑名单这四组规则,其余规则更多是编码风格。
规则 8.1 要求函数必须声明原型,并且原型在函数定义和调用时都必须可见。原因很直接:C 不是强类型语言,非原型声明里的参数类型可以缺省,缺省就是 int,编译器于是没有依据去比对实参和形参。
典型例子是 double square(double x) 被 square(i) 这样的 long 实参调用。没有原型时,编译器不会给出任何警告,函数悄悄返回一个错误的值;补上原型后,编译器会发现实参与形参类型不符,并按原型把实参转换成 double,结果才是对的。原型还有一个附带好处:编译器可以据此生成更有效的调用序列。
规则 8.5 管的是"在哪里定义":头文件中不允许包含对象或函数的定义。头文件被多个源文件包含时,定义会在每个 .c 里原样展开,链接期就是重复定义。合规做法是头文件里只写 extern 声明,定义放到某个 .c 里;也可以用一个 EXT 宏在头文件里切换 extern 与空,效果等价,但前提是只有一个 .c 定义 EXT。
| 常见写法 | 后果 | 合规写法 |
|---|---|---|
| 头文件里直接定义全局变量 | 多个 .c 包含后重复定义 | 头文件 extern 声明,.c 中定义 |
| K&R 风格、无原型声明 | 参数类型不检查,返回值可能错 | 头文件写完整原型,参数逐个标类型 |
| 只在调用方写原型 | 定义与声明可能悄悄不一致 | 定义所在 .c 也包含同一头文件 |
| 原型参数与定义参数不一致 | 问题拖到链接期或运行期才暴露 | 由同一个头文件同时约束定义与调用 |
规则 16.1 不允许定义参数数量不确定的函数。标准库的 printf() 之所以能用,是因为编译器对它提供了专门的调用机制;用户自己照着写一个可变参数函数,编译器就无法检查调用处的参数一致性,出错只能在运行期看到现象。工程上的替代是固定参数:把可变部分收进一个结构体,或者传数组加长度。
规则 16.2 更彻底:函数不得直接或间接调用自身。嵌入式系统的 RAM 本来就紧,递归的栈深度又很难预先估计,估不出来就意味着栈溢出随时可能发生。链表遍历、树遍历这类天然适合递归的算法,在 MCU 上应改成迭代加显式栈;状态机则更适合表驱动。
| 规则 | 要求 | 背后的原因 | 工程替代 |
|---|---|---|---|
| 16.1 | 不定义参数数量不确定的函数 | 编译器无法检查调用参数一致性 | 固定参数、结构体打包或数组加长度 |
| 16.2 | 禁止直接或间接递归 | 栈深度不可预估,RAM 有限 | 迭代 + 显式栈,或表驱动状态机 |
| 20.4 | 不使用动态堆空间分配 | 分配失败与碎片在长期运行中不可控 | 静态内存池、编译期确定容量 |
规则 19.10 要求函数式宏的任何一个参数都加上小括号(# 与 ## 运算符中的参数除外)。这条规则不是洁癖,一个求绝对值的宏就能演示清楚它为什么必须存在:
/* 第一版:参数没括号 */
#define abs(x) (x >= 0) ? x : -x
/* abs(a - b) 展开成 (a - b >= 0) ? a - b : -a - b,减法优先级把结果带偏 */
/* 第二版:参数加了括号,整体没加 */
#define abs(x) ((x) >= 0) ? (x) : -(x)
/* abs(a) + 1 展开成 ((a) >= 0) ? (a) : -(a) + 1,仍然是错的 */
/* 第三版:参数和整体都加括号 */
#define abs(x) (((x) >= 0) ? (x) : -(x))
更隐蔽的是副作用。#define SQUARE(x) ((x) * (x)) 看起来已经足够安全,但 b = SQUARE(a++) 展开后是 ((a++) * (a++)),a++ 被执行两次:a 从 3 变成 5,b 得到 12 而不是预期的 9。宏展开只做文本替换,它不会像函数那样"先求值一次再传进去"。
规则 19.7 是推荐条款:应优先考虑使用函数而非函数式宏。宏的两个先天缺陷——没有参数类型检查、参数可能多次求值——都指向同一句话:多数情况下,函数的运行速度应该让位于结果的正确性。
第 20 章先立两条底线:规则 20.1 不得定义、重新定义或取消定义标准库中的标识符、宏和函数;规则 20.3 必须检查传递给库函数的数值有效性。在这之上,MISRA C 列出了一份禁用清单:
| 规则 | 禁止内容 | 为什么收 | 工程替代 |
|---|---|---|---|
| 20.4 | 动态堆空间分配 | 碎片与分配失败在长期运行中不可控 | 静态池、编译期定容 |
| 20.5 | 错误指示符 errno | 成功执行时不会强制清零,无法判断是不是本次失败 | 只信函数返回值与自己的错误码 |
| 20.6 | stddef.h 的 offsetof | 结果依赖实现与结构体填充 | 显式偏移常量 + 编译期检查 |
| 20.7 | setjmp / longjmp | 非局部跳转破坏栈与初始化纪律 | 返回码 + 状态机 |
| 20.8 | signal.h 信号处理 | 异步语义在不同平台差异大 | 硬件中断 + 标志位 |
| 20.9 | 用 stdio.h 产生代码 | 格式化输出在嵌入式里体积与重入都难控 | 自写串口输出函数 |
| 20.10 | atof / atoi / atol | 没有错误检测,非法输入直接返回 0 | 手写解析并做范围检查 |
| 20.11 | abort / exit / system | 直接终止程序或调用外部命令 | 复位流程或错误状态机 |
| 20.12 | time.h 时间处理函数 | 依赖目标环境的时间实现 | 硬件 RTC 或系统 tick 计数 |
| 现象 | 命中规则 | 改法 |
|---|---|---|
| 头文件里定义了变量,多个 .c 包含后报重复定义 | 8.5 | 头文件改 extern,定义收进一个 .c |
| 换编译器后浮点函数返回值不对 | 8.1 | 补原型,让编译器做参数类型检查 |
| 自定义日志函数多传一个参数也不报错 | 16.1 | 改成固定参数,可变部分用结构体 |
| 遍历目录树时偶发栈溢出 | 16.2 | 改迭代 + 显式栈 |
| 宏算平方结果偏大、变量被多加了一次 | 19.10 / 19.7 | 参数与整体都加括号,能用函数就不用宏 |
| 判断 errno 得到"失败",实际调用是成功的 | 20.5 | 改用函数返回值判断 |
| 看门狗喂狗任务里调用了 exit | 20.11 | 改成受控复位流程 |
一句话总结:这四组规则其实只做一件事——把"运行期才发现"的问题尽量挪到编译期。原型让编译器检查参数,禁用递归让栈深度可算,宏加括号让展开结果可预期,标准库黑名单把不可控的运行时行为换成可验证的替代实现。规则不必一次全上,先把这四组做成评审清单,代码的"换编译器就出错"会明显减少。
还想接着看:MISRA C 141 条规则怎么分优先级、七步落地、C51 调试中最常见的几类坑、C51 存储模式与代码重构。
本文基于《单片机与嵌入式系统应用》2006 年第 6 期《构建安全的编译环境》(清华大学 陈萌萌、邵贝贝)与 MISRA C:2004 规则条文整理优化。
沧州艾诺威电子 — 国家高新技术企业,20+项国家专利
嵌入式系统开发 · 物联网方案 · AI智能硬件 · 一站式交付
电话:13930711029 | 邮箱:tech@czinv.com | 24小时内响应