众所周知,在制造业(尤其是汽车制造业)非常注重代码安全。MISRA-C 就是一个汽车制造业嵌入式 C 编码标准,最早由汽车工业软件可靠性联合会(Motor Industry Software Reliability Association,简称 MISRA)于 1998 年发布。2004 年发布了第二版的 MISRA C,即《MISRA-C-:2004 Guidelines for the use of the C language in critical systems》,是目前工业界常用的 C 语言编码规范。MISRA-C-:2004 规则分为 21 类,覆盖从「开发环境」到「运行期错误」,包含 141 项规则,其中 121 项是强制要求,其余的 20 项是推荐使用的规则。
本文以产品安全为目标,提炼部分 MISRA-C 语言的编码规范,提高企业级产品的代码可靠性、可读性和可移植性。
关键词 | 解释 |
---|---|
C 语言 | 一门面向过程、抽象化的通用程序设计语言,广泛应用于底层开发。 |
MISRA-C | 汽车制造业嵌入式C编码标准。 |
1. 质量保证
软件工程师编码时,应该根据标准《MISRA-C-:2004》的规则进行编码,在设计过程中构筑软件质量。
1.1 代码质量保证优先原则
- 正确性,指程序要实现设计要求的功能。
- 稳定性、安全性,指程序稳定、可靠、安全。
- 可测试性,指程序要具有良好的可测试性。
- 规范/可读性,指程序书写风格、命名规则等要符合规范。
- 全局效率,指软件系统的整体效率。
- 局部效率,指某个模块/子模块/函数的本身效率。
- 个人表达方式/个人方便性,指个人编程习惯。
1.2 使用第三方提供的软件开发工具包或控件时,要注意以下几点:
- 充分了解应用接口、使用环境及使用时注意事项。
- 不能过分相信其正确性。
- 除非必要,不要使用不熟悉的第三方工具包与控件。
2. 代码编辑、编译、审查
- 打开编译器的所有告警开关对程序进行编译。
- 通过代码走读及审查方式对代码进行检查。
- 编写代码时要注意随时保存,并定期备份,防止由于断电、硬盘损坏等原因造成代码丢失。
- 同产品软件内,最好使用相同的编辑器,并使用相同的设置选项。
- 合理地设计软件系统目录,方便开发人员使用。
- 某些语句经编译后产生告警,但如果你认为它是正确的,那么应通过某种手段去掉告警信息。
3. 代码测试、维护
- 单元测试要求至少达到语句覆盖。
- 单元测试开始要跟踪每一条语句,并观察数据流及变量的变化。
- 清理、整理或优化后的代码要经过审查及测试。
- 代码版本升级要经过严格测试。
- 使用工具软件对代码版本进行维护。
- 正式版本上软件的任何修改都应有详细的文档记录。
- 发现错误立即修改,并且要记录下来。
- 关键的代码在汇编级跟踪。
- 仔细设计并分析测试用例,使测试用例覆盖尽可能多的情况,以提高测试用例的效率。
- 尽可能模拟出程序的各种出错情况,对出错处理代码进行充分的测试。
- 仔细测试代码处理数据、变量的边界情况。
- 保留测试信息,以便分析、总结经验及进行更充分的测试。
- 不应通过“试”来解决问题,应寻找问题的根本原因。
- 对自动消失的错误进行分析,搞清楚错误是如何消失的。
- 修改错误不仅要治表,更要治本。
- 测试时应设法使很少发生的事件经常发生。
- 明确模块或函数处理哪些事件,并使它们经常发生。
- 坚持在编码阶段就对代码进行彻底的单元测试,不要等以后的测试工作来发现问题。
- 去除代码运行的随机性(如去掉无用的数据、代码及尽可能防止并注意函数中的“内部寄存器”等),让函数运行的结果可预测,并使出现的错误可再现。
4. 可测性
-
在同一项目组或产品组内,要有一套统一的为集成测试与系统联调准备的调测开关及相应打印函数,并且要有详细的说明。
-
在同一项目组或产品组内,调测打印出的信息串的格式要有统一的形式。信息串中至少要有所在模块名(或源文件名)及行号。
-
编程的同时要为单元测试选择恰当的测试点,并仔细构造测试代码、测试用例,同时给出明确的注释说明。测试代码部分应作为(模块中的)一个子模块,以方便测试代码在模块中的安装与拆卸(通过调测开关)。
-
在进行集成测试/系统联调之前,要构造好测试环境、测试项目及测试用例,同时仔细分析并优化测试用例,以提高测试效率。
-
在软件系统中设置与取消有关测试手段,不能对软件实现的功能等产生影响。
说明:即有测试代码的软件和关掉测试代码的软件,在功能行为上应一致。
-
用调测开关来切换软件的 DEBUG 版和正式版,而不要同时存在正式版本和 DEBUG 版本的不同源文件,以减少维护的难度。
-
软件的 DEBUG 版本和发行版本应该统一维护,不允许分家,并且要时刻注意保证两个版本在实现功能上的一致性。
-
在编写代码之前,应预先设计好程序调试与测试的方法和手段,并设计好各种调测开关及相应测试代码如打印函数等。
5. 规则
5.1 环境
所有代码都必须遵照 ISO 9899:1990“Programming languages - C”,由 ISO/IEC 9899/COR1:1995,ISO/IEC 9899/AMD1:1995,和 ISO/IEC9899/COR2:1996 修订。
5.2 语言扩展
- 汇编语言应该被封装并隔离。
- 字符序列 /* 不应出现在注释中。
5.3 文档
- 所有实现定义(implementation-defined)的行为的使用都应该文档化。
- 字符集和相应的编码应该文档化。
- 应该确定、文档化和重视所选编译器中整数除法的实现。
- 产品代码中使用的所有库都要适应本文档给出的要求,并且要经过适当的验证。
5.4 标识符
- 标识符(内部的和外部的)的有效字符不能多于 31。
- typedef 的名字应当是唯一的标识符。
- 具有静态存储期的对象或函数标识符不能重用。
5.5 类型
- 单纯的 char 类型应该只用做存储和使用字符值。
- signed char 和 unsigned char 类型应该只用做存储和使用数字值。
- 应该使用指示了大小和符号的 typedef 以代替基本数据类型。
- 位域只能被定义为 unsigned int 或 singed int 类型。
- unsigned int 类型的位域至少应该为 2 bits 长度。
5.6 声明与定义
- 函数应当具有原型声明,且原型在函数的定义和调用范围内都是可见的。
- 不论何时声明或定义了一个对象或函数,它的类型都应显式声明。
- 函数的每个参数类型在声明和定义中必须是等同的,函数的返回类型也该是等同的。
- 如果对象或函数被声明了多次,那么它们的类型应该是兼容的。
- 头文件中不应有对象或函数的定义。
- 如果对象的访问只是在单一的函数中,那么对象应该在块范围内声明。
- 外部对象或函数应该声明在唯一的文件中。
5.7 初始化
- 所有自动变量在使用前都应被赋值。
- 应该使用大括号以指示和匹配数组和结构的非零初始化构造。
- 在枚举列表中,“=”不能显式用于除首元素之外的元素上,除非所有的元素都是显式初始化的。
5.8 数值类型转换
- 下列条件成立时,整型表达式的值不应隐式转换为不同的基本类型:
- 转换不是带符号的向更宽整数类型的转换,或者
- 表达式是复杂表达式,或者
- 表达式不是常量而是函数参数,或者
- 表达式不是常量而是返回的表达式。
- 下列条件成立时,浮点类型表达式的值不应隐式转换为不同的类型:
- 转换不是向更宽浮点类型的转换,或者
- 表达式是复杂表达式,或者
- 表达式是函数参数,或者
- 表达式是返回表达式。
- 整型复杂表达式的值只能强制转换到更窄或相同大小的类型且与表达式的基本类型具有相同的符号。
- 浮点类型复杂表达式的值只能强制转换到更窄或相同大小的浮点类型。
5.9 指针类型转换
- 不应在指针类型和整型之间进行强制转换。
- 不应在某类型对象指针和其他不同类型对象指针之间进行强制转换。
- 如果指针所指向的类型带有 const 或 volatile 限定符,那么移除限定符的强制转换是不允许的。
5.10 表达式
- 不要过分依赖 C 表达式中的运算符优先规则。
- 表达式的值在标准所允许的任何运算次序下都应该是相同的。
- 逻辑运算符 && 或 || 的右手操作数不能包含副作用。
- 逻辑 && 或 || 的操作数应该是 primary-expressions。
- 逻辑运算符(&&、|| 和 ! )的操作数应该是有效的布尔数。
- 位运算符不能用于基本类型是有符号的操作数上。
- 移位运算符的右手操作数应该位于零和某数之间,这个数要小于左手操作数的基本类型的位宽。
- 不要使用逗号运算符。
- 不应使用浮点数的基本(underlying)的位表示法(bit representation)。
5.11 控制语句表达式
- 赋值运算符不能使用在产生布尔值的表达式上。
- 数的非零检测应该明确给出,除非操作数是有效的布尔类型。
- 浮点表达式不能做相等或不等的检测。
- for 语句的控制表达式不能包含任何浮点类型的对象。
- for 语句的三个表达式应该只关注循环控制。
- for 循环中用于迭代计数的数值变量不应在循环体中修改。
- 不允许进行结果不会改变的布尔运算。
5.12 控制流
- 不能有不可到达(unreachable)的代码。
- 所有非空语句(non-null statement)应该:
- 不管怎样执行都至少有一个副作用(side-effect),或者
- 可以引起控制流的转移
- 在预处理之前,空语句只能出现在一行上;其后可以跟有注释,假设 紧跟空语句的第一个字符是空格。
- 不应使用 goto 语句。
- 对任何迭代语句至多只应有一条break 语句用于循环的结束。
- 一个函数在其结尾应该有单一的退出点。
- 组成 switch、while、do…while 或 for 结构体的语句应该是复合语句。
- if(表达式)结构应该跟随有复合语句。else 关键字应该跟随有复合语句或者另外的 if 语句。
- 所有的 if … else if 结构应该由 else 子句结束。
5.13 switch 语句
- 无条件的 break 语句应该终止每个非空的 switch 子句。
- switch 语句的最后子句应该是 default 子句。
- switch 表达式不应是有效的布尔值。
- 每个 switch 语句至少应有一个 case 子句。
5.14 函数
- 函数定义不得带有可变数量的参数。
- 在函数的原型声明中应该为所有参数给出标识符。
- 函数的声明和定义中使用的标识符应该一致。
- 传递给一个函数的参数应该与声明的参数匹配。
- 带有 non-void 返回类型的函数其所有退出路径都应具有显式的带表达式的 return 语句。
5.15 指针和数组
- 指针的数学运算只能用在指向数组或数组元素的指针上。
- 指针减法只能用在指向同一数组中元素的指针上。
- >、>=、<、<= 不应用在指针类型上,除非指针指向同一数组。
- 数组的索引应当是指针数学运算的唯一可允许的方式。
- 对象声明所包含的间接指针不得多于 2 级。
- 自动存储对象的地址不应赋值给其他的在第一个对象已经停止存在后仍然保持的对象。
5.16 结构与联合
- 所有结构与联合的类型应该在转换单元(translation unit)的结尾是完善的。
- 对象不能赋值给重叠(overlapping)对象。
- 不能为了不相关的目的重用一块内存区域。
5.17 预处理指令
#include
预处理指令应该跟随<filename>
或"filename"
序列。- C 的宏只能扩展为用大括号括起来的初始化、常量、小括号括起来的表达式、类型限定符、存储类标识符或do-while-zero 结构。
- 不要使用
#undef
。 - 函数宏的调用不能缺少参数。
- 递给函数宏的参数不能包含看似预处理指令的标记。
- 在定义函数宏时,每个参数实例都应该以小括号括起来,除非它们做为
#
或##
的操作数。 - 预处理指令中所有宏标识符在使用前都应先定义,除了
#ifdef
和#ifndef
指令及defined()
操作符。 - 在单一的宏定义中最多可以出现一次
#
或##
预处理器操作符。 - 不要使用
#
或##
预处理器操作符。 defined
预处理操作符只能使用两种标准形式之一。- 应该采取防范措施以避免一个头文件的内容被包含两次。
- 预处理指令在句法上应该是有意义的,即使是在被预处理器排除的情况下。
- 所有的
#else
、#elif
和#endif
预处理指令应该同与它们相关的#if
或#ifdef
指令放在相同的文件中。
5.18 标准库
- 标准库中保留的标识符、宏和函数不能被定义、重定义。
- 不能重用标准库中宏、对象和函数的名字。
- 传递给库函数的值必须检查其有效性。
- 不能使用动态堆的内存分配。
- 不要使用错误指示 errno。