MISRA-C是汽车工业软件可靠性联会推出的辅助汽车厂商开发安全可靠的软件的一项标准。该标准从头文件、源文件、函数定义、变量定义等多个方面阐述了开发者在编写软件时应该满足的规范。本篇文章将根据所掌握的知识进行阐述。
一、MISRA-C标准是什么?
由于在C语言设计初期为了保证可以兼容不同的处理器,因此C中的某些行为是未定义的但是是被允许的。同时C程序可以编译为高效的机器代码,并且在静态分析和测试工具中也广泛支持它。截至目前为止,C语言在汽车、航空、医疗等领域都广受关注,因此为了提高C的安全及可靠性,提高代码的可读、可移植、可维护性,MISRA C标准应运而生。
MISRA C标准分为两个部分,第一个部分是指令,主要是对C编写时的环境、代码结构、行为定义进行标准化说明和文档性追溯,重点在于框架的构建;第二个部分是规则,主要针对C编写的代码规范和不合理的语法进行说明,重点在于代码细节的实现。接下来本文章便开始逐步描述MISRA C标准。
二、MISRA-C标准之指令 (Directives)
2.1 实施与实现
原理:C语言中有些行为在不同的编译器或者开发平台上可能不一致,为了保证代码的可移植性和可靠性,应该将定义的行为通过文档详细记录,其核心应包括如何识别编译过程中产生的诊断信息、函数main的类型、整数类型的定义等。
例子: C代码部分 typedef uint8_t U8; 文档解析部分 使用typedef重定义uint8_t类型为U8
2.2 编译与构建
Dir 2.1 源文件编译应该无错误(必要)
原理:有些平台编译错误也会生成目标文件,但是此时的文件存在风险,不具备使用的效益。
2.3 需求可追溯性
Dir 3.1 代码应具有可追溯性(必要)
原理:不存放与业务无关的代码块,或者通过注释及标号的行为明确标注该代码块的用处。比如当开发人员利用某个引脚的电平变化来验证逻辑后,但是在项目提交后未关闭该测试代码块或没有对应的文档进行描述,那么就会对团队其它成员造成干扰。
例程如下 C代码部分: #include <stdio.h> // 包含标准输入输出库的头文件,用于使用printf和scanf函数[^17^] // 定义一个函数,用于计算三个整数的平均值 float calculateAverage(int num1, int num2, int num3) { int sum = num1 + num2 + num3; // 计算三个整数的和 float average = sum / 3.0; // 计算平均值,使用3.0确保结果为浮点数 return average; // 返回计算得到的平均值 } int main() { int a, b, c; // 声明三个整数变量,用于存储用户输入的值 // 提示用户输入三个整数 printf("请输入三个整数,用空格分隔:"); scanf("%d %d %d", &a, &b, &c); // 从键盘读取三个整数,分别存储到变量a、b、c中 // 调用calculateAverage函数,计算三个整数的平均值 float avg = calculateAverage(a, b, c); // 输出计算结果 printf("三个整数的平均值是:%.2f\n", avg); return 0; // 程序正常结束,返回0 } 文档记录部分: 函数 calculateAverage 输入参数: int num1:第一个整数。 int num2:第二个整数。 int num3:第三个整数。 返回值: float:三个整数的平均值。 功能描述: 该函数接收三个整数作为输入,计算它们的和,然后除以3得到平均值。为了确保结果是浮点数,使用了3.0进行除法运算。 主函数 main 变量声明: int a, b, c:用于存储用户输入的三个整数。 用户输入: 使用printf提示用户输入三个整数,并使用scanf从键盘读取输入。 调用函数: 调用calculateAverage函数,传入用户输入的三个整数,计算平均值。 输出结果: 使用printf输出计算得到的平均值,保留两位小数。 程序结束: 返回0,表示程序正常结束
2.4 代码设计
Dir 4.1 代码尽量减少运行时的故障(必要)
原理:应保证表达式求值过程中不会出现上下溢、不会出现0除;在数组操作时不应出现空指针、野指针,指针的地址应该是有效的。
以下行为是不被许可的: 1、零除 a/0是不被允许的 2、溢出 如uint8_t a=255;那么此时a ++的操作是不被允许的 3、异常指针 如*p=NULL或只定义了*p的前提下进行解引用是不允许的
Dir 4.2 使用的汇编语言应有文档记录(建议)
原理:在某些场景下会使用汇编语言来提升性能,但是其可读性、移植性等比较差,因此需要文档记录每一条汇编指令的功能和调用的接口,以及所包含的业务逻辑。
例程如下 汇编语言如下: DATA SEGMENT BUF DB 'HELLO WORLD! THIS IS MY FIRST ASM FILE! $' DATA ENDS CODE SEGMENT ASSUME CS:CODE,DS:DATA START: MOV AX,DATA MOV DS,AX LEA DX,BUF MOV AH,09H INT 21H MOV AH,4CH INT 21H CODE ENDS END START 文档如下: 数据段(DATA SEGMENT) 定义了一个字符串BUF,内容为HELLO WORLD! THIS IS MY FIRST ASM FILE!,以$结尾,$是DOS中断中字符串的结束标志。 代码段(CODE SEGMENT) 初始化数据段:通过MOV AX,DATA和MOV DS,AX将数据段的地址加载到DS寄存器中。 加载字符串地址:使用LEA DX,BUF将字符串BUF的地址加载到DX寄存器。 调用DOS中断显示字符串:通过MOV AH,09H和INT 21H调用DOS中断,显示DX寄存器指向的字符串。 程序结束:通过MOV AH,4CH和INT 21H调用DOS中断,结束程序。 运行过程 程序启动后,首先将数据段的地址加载到DS寄存器。然后将字符串BUF的地址加载到DX寄存器。 调用DOS中断INT 21H,功能号09H,显示DX寄存器指向的字符串。最后调用DOS中断INT 21H,功能号4CH,结束程序。 注意事项 字符串结束标志:在DOS中断中,字符串必须以$结尾。 寄存器使用:LEA DX,BUF等价于MOV DX,OFFSET BUF,用于加载字符串的地址。 功能号:09H用于显示字符串,4CH用于结束程序。
Dir 4.3 使用的汇编语言应被封装与隔离(必要)
原理:由于汇编语言的生涩性,为提高汇编语言的可读性和可替代性。需要通过封装来重定义所使用的汇编指令的功能,借助文档来映射代码块的调用。
例子 汇编封装: #include <stdio.h> // 使用#define封装汇编语言实现加法操作 #define ADD_ASM(a, b, result) \ __asm__ ( \ "movl %1, %%eax;" \ "addl %2, %%eax;" \ "movl %%eax, %0;" \ : "=r" (result) \ : "r" (a), "r" (b) \ : "%eax" \ ) 封装调用: int main() { int a = 5; int b = 3; int result; // 调用封装的汇编加法操作 ADD_ASM(a, b, result); printf("The result of %d + %d is %d\n", a, b, result); return 0; }
Dir 4.4 代码段不应被注释(建议)
原理:如果要求源代码中的某部分不被编译,应使用条件编译来实现即#if或#ifdef结构。而不是使用//或者/*...... */。
例程: 1、不推荐使用/* */ /* if(2==x) { y=3; } */ 2、不推荐使用// if(2==x) { // y=3; } 3、建议使用#if #endif #if 0 if(2==x) { y=3; } #endif
Dir 4.5 不应存在名称含糊的定义(建议)
原理:因为使用字体的不同,会出现相似字符混淆的情况,因此应避免会产生显示歧义的标识符命名。如int32_t id4_I; int32_t id4_1,应该避免类似的命名。
Dir 4.6 使用typedef类型代替基本数字类型(建议)
原理:使用特定长度类型可以清楚地确认为每个对象保留多少存储空间。因此不直接使用基本数字类型。
typedef signed char int8_t; typedef signed short int16_t; typedef signed int int32_t; typedef signed long int64_t; typedef unsigned char uint8_t; typedef unsigned short uint16_t; typedef unsigned int uint32_t; typedef unsigned long uint64_t; typedef float float32_t; typedef double float64_t; typedef long double float128_t;
Dir 4.7 应检查函数返回状态(必要)
原理:某些函数会返回指令执行状态,若在产生错误的情况下不检查而继续执行程序可能会产生不可预计的后果。
Dir 4.8 隐藏结构体或联合体的实现(建议)
原理:若一个指向结构体或联合体的指针未被反引用,那么需要隐藏该结构体或联合体的实现。即指针声明在头文件中,实现函数在源文件中,这样子就可以保证只调用指针而无法访问结构体或联合体的实现。
/* Opaque.h */ #ifndef OPAQUE_H #define OPAQUE_H typedef struct OpaqueType *pOpaqueType; #endif /* Opaque.c */ #include "Opaque.h" struct OpaqueType { /* 对象的实现 */ }; /* UseOpaque.c */ #include "Opaque.h" void f ( void ) { pOpaqueType pObject; pObject = GetObject ( ); /* 获取 OpaqueType 对象的句柄 */ UseObject ( pObject ); /* Use it... */ }
Dir 4.9 优先使用函数(建议)
原理:在某些地方,如果宏定义与功能一致的函数可以互换,则需要优先使用函数,因为函数在调用时会被检查,更安全。但是使用宏初始化具有静态存储持续时间的对象是合规的,因为此处不允许进行函数调用,反之则只能使用函数调用。
例程: 如宏定义 #define MyAdd(a,b) ((a)+(b)) //宏定义一个加法 函数定义 uint8_t AddTest(uint8_t a,uint8_t b);//定义加法函数,实现略 那么在static uint8_t c = 调用函数初始化时,只能使用MyAdd即宏定义,因为此时不允许使用函数 在uint8_t c = 调用函数初始化时,推荐使用AddTest即函数,因为此时宏跟函数均可使用 即在功能一致且均可使用的前提下,优先使用函数。
Dir 4.10 头文件唯一性(必要)
原理:为防止头文件被多次包含导致多次定义错误,应该使用以下结构
#ifndef identifier #define identifier /* 文件内容 */ #endif
Dir 4.11 传递有效值(必要)
原理:在某些库函数中,不允许传递非法值,否则会出现异常错误,比如负数不能传递给sqrt或log函数;fmod函数的第二个参数不能为0等。因此也需要保证库函数应具备纠错和预防错误的能力。
Dir 4.12 避免使用动态分配(必要)
原理:即避免使用malloc/free进行动态内存分配。因为动态分配需要保证程序状态是可预测的、当前内存是足够的、使用内存分配和取消分配时间差异不大。在取消分配后不得再使用该指针。若不满足上述规则而强行使用动态分配会导致内存泄漏和程序异常运行。
Dir 4.13 顺序调用资源(建议)
原理:按顺序正确调用资源模块有助于编译器检查和分配。比如文件的操作,需遵循打开->读写(非必须)->关闭的顺序。
总结:以上为MISRA C的13条指令定义,这些从编译环境、编译需求、代码验证、代码注释文档等方面规范了开发人员的某些设计步骤。按照这些指令进行代码的编写虽然不能保证100%的无安全隐患,但是可以提高代码的可移植和可理解性,同时提升其安全与标准化。
三、MISRA-C标准之规则 ((Rules)
MISRA C的规则主要对代码的实现做了标准化的约束,这一准则使得开发者在设计时可以避免许多隐藏的坑,同时也便于平台化和提升代码的可靠性。
3.1 标准C环境
Rule 1.1 严格遵守C标准(必要)
原理:有些编译器即使编译出错或者出现警告等信息,依旧可以生成可执行文件,但是一般而言这种文件存在风险,不具备使用条件。因此若不存在语言扩展,则代码应严格遵守所选用的C语言标准。不得违反语法和标准约束的内容。
Rule 1.2 不允许使用语言扩展(建议)
原理:在某些情况下使用语言扩展可以带来便利或提高性能,但它们通常是适用于特定的编译器,所以会导致代码的可移植性降低。而且,某些扩展在不符合标准的情况下无法控制异常行为,这样子的行为会对程序的运行会带来隐患。
Rule 1.3 不允许存在未知行为(必要)
原理:在某些场景下比如数组越界,指针地址异常使用都会导致程序异常运行而带来安全隐患。并且有些行为触发条件苛刻不容易被察觉,因此遵守规则可以有效避免潜在的威胁。
以下行为是不被许可的: 1、零除 a/0是不被允许的 2、溢出 如uint8_t a=255;那么此时a ++的操作是不被允许的 3、异常指针 如*p=NULL或只定义了*p的前提下进行解引用是不允许的
3.2 未使用的代码
Rule 2.1 不得包含不可达代码(必要)
原理:除了特殊测试场景下的代码,不可出现始终无法执行的代码。尽管编译器会优化删除这种代码,但是如果非项目需求,不得存在于代码片段中。
在项目发布时代码不可存在以下形式: uint8_t a = 1; if (a > 0) { a=2; } else { a=3;// 这段代码永远不会被执行 }
Rule 2.2 不得包含无效代码(必要)
原理:与不可达代码不同的是,无效代码是一段可以运行但无实际意义的代码,这种代码的存在会增加内存的占有率并且有可能会被编译器删除,在某些场景下会引起逻辑混乱。
a=1;//无效代码 a=2; b=a;
Rule 2.3 不得包含未使用的类型声明(建议)
原理:已声明但未使用的代码会干扰代码的理解。如声明了
typedef int16_t local_Type;
但是不使用该类型,那么在程序中需要删除以免带来误解。
Rule 2.4 不得包含未使用的类型标签声明(建议)
原理:若存在针对结构体、联合体、枚举的已声明但未使用的代码会干扰代码的理解。如声明了
typedef struct record_t { uint16_t key; uint16_t val; } record1_t;
但是在后续使用中只用到了record1_t,那么可以删除标签record_t;又或者声明了
typedef struct { uint16_t key; uint16_t val; } record2_t;
但是后续中未使用该结构体定义,那么该结构体可以在代码中删除。
Rule 2.5 不得包含未使用的宏(建议)
原理:已声明但未使用的代码会干扰代码的理解。
Rule 2.6 不得包含未使用的执行标签声明(建议)
原理:若存在针对goto语句的已声明但未使用的代码会干扰代码的理解。如声明了
test_goto: i ++;
标签 test_goto,但从未被 goto 语句引用,那么就可以在代码中被删除。
Rule 2.7 不得包含未使用的变量(建议)
原理:若存在未使用的变量会干扰代码的理解。
3.3 注释
Rule 3.1 字符序列"/*"和"//"不得在注释中嵌套使用(必要)
原理:若注释中/*与//嵌套使用,可能会导致正常代码被注释掉。
Rule 3.2 "//"注释中不得使用换行符"\"(必要)
原理:若注释中/*与\嵌套使用,可能会导致正常代码被注释掉。
3.4 字符集和词汇约定
Rule 4.1 八进制和十六进制转译序列应有明确的终止识别标识(必要)
原理:八进制或十六进制转译序列会有隐藏的类型转换,如果它们后面直接跟随其他字符,会造成混淆,因此此类转译一般在末尾添加空字符或者空字符串来表示结束。
八进制转义:\ooo,其中 ooo 是 1 到 3 位的八进制数(0-377),对应 ASCII 码。 十六进制转义:\xhh,其中 hh 是任意长度的十六进制数(通常 2 位,00-FF),对应 ASCII 码。 char a = '\141'; // 八进制表示字符 'a' char b = '\x41'; // 十六进制表示字符 'A'
Rule 4.2 避免使用三字母词(建议)
原理:在C中三字母词会被替换成其它的符号,在某些场景下会导致异常的结果。
3.5 标识符
Rule 5.1 外部标识符不得重名(必要)
原理:如全局变量这种具有外部链接属性的标识符,在不同的文件中命名不得重复,否则会产生冲突。
在test1.c中定义,uint8_t a=1;若在test2.c中再次定义同名uint8_t a=1;则会出错
Rule 5.2 同范围和命名空间内的标识符不得重名(必要)
原理:即在同一个文件或者同一个函数命名范围中,不得重复定义变量名。
Rule 5.3 内部声明的标识符不得隐藏外部声明的标识符(必要)
原理:即在同一区域内函数内的变量名定义不得与函数外的变量名定义一致。
以下定义方式是冲突的 uint8_t a=1; void test(void) { uint8_t a=1;//与外部定义冲突,不被允许 }
Rule 5.4 宏标识符不得重名(必要)
原理:即不能重复定义同一个名字的宏。
Rule 5.5 宏标识符与其他标识符不得重名(必要)
原理:即宏定义标识符应具有唯一性。
Rule 5.6 typedef 名称应是唯一标识符(必要)
原理:即使用typedef的结构体或类型定义应该具有唯一性,不得与其它定义的标识符一致。
Rule 5.7 标签(tag)名称应是唯一标识符(必要)
原理:联合体或者结构体定义的标识符应该具有唯一性,并且二者不能重复定义。
Rule 5.8 全局(external linkage)对象和函数的标识符应是唯一的(必要)
原理:相同的函数的标识符但是不同的属性不可以在不同文件中被定义。否则会引起混淆。
在test1.c中定义,uint8_t a=1;若在test2.c中定义同名uint16_t a=1;此行为不被允许
Rule 5.9 局部全局(internal linkage)对象和函数的标识符应是唯一的(建议)
原理:即使该标识符没有其它链接对象,也要保证在所有命名空间和编译单元中保持标识符的唯一性。
3.6 类型
Rule 6.1 位域仅允许使用适当的类型来声明(必要)
原理:即使是相同的类型如int在不同的编译器环境下其类型也不同。为了兼容性,在C中仅允许unsigned int 或 signed int进行位域定义,C99还允许bool类型的定义
Rule 6.2 单比特(single-bit)位域成员不可声明为有符号类型(必要)
原理:一个单比特(single-bit)带符号的位域数据具有1个符号位和0个值位。 在任何整数表示中,0 个值位都无法指定有意义的值。 因此,一个单比特(single-bit)带符号的位域数据不太可能以有用的方式允许,而且它的存在也会给程序员带来困惑。
3.7 字符和常量
Rule 7.1 禁止使用八进制常数(必要)
原理:八进制常数一般格式为0XX,容易被误解为十进制数。
Rule 7.2 后缀“u”或“U”应使用于所有无符号的整数常量(必要)
原理:为方便区分,所有无符号变量的定义尾缀应该以u或U结束。
Rule 7.3 小写字符“l”不得作为常量的后缀使用(仅可使用“L”)(必要)
原理:小写字符l容易与数字1进行混淆。
Rule 7.4 不得将任意字符串常量赋值给对象(必要)
原理:字符串文字是常量,当发生修改时会出现未定义的行为带来异常结果。当对象的类型是“指向常量限定字符的指针”时,说明该指针无法被修改。不会发生异常行为。
3.8 声明和定义
Rule 8.1 类型须明确声明(必要)
原理:尽管C90标准允许在某些情况下省略类型并且将隐式指定int类型,但是不推荐使用,建议使用完整的声明如extern uint8_t u8Test;
Rule 8.2 函数类型应为带有命名形参的原型形式(必要)
原理:函数类型定义应该完整如void/(uint8_t) XXX_Name(void/(uint8_t xxx));
Rule 8.3 对象或函数的所有声明均应使用相同的名称和类型限定符(必要)
原理:即函数或对象的名称标识符和类型限定符在定义与声明时应该完全保持一致。
Rule 8.4 全局(external linkage)的对象和函数,应有显式的合规的声明(必要)
原理:即具有全局属性的对象或者函数应该同时包含定义和extern 声明。
Rule 8.5 全局对象或函数应在且只在一个文件中声明一次(必要)
原理:即保证全局对象或函数的唯一性。
Rule 8.6 全局标识符应在且只在一处定义(必要)
原理:即保证全局标识符的唯一性。
Rule 8.7 仅在本编译单元中调用的对象和函数,应定义成局部属性(建议)
原理:只在本编译单元使用的对象定义成局部变量后可以防止被外部访问,同时降低与其它单元名称发生混淆的可能性。
Rule 8.8 “static”修饰符应用在所有局部全局对象和局部函数(internal linkage)的声明中(必要)
原理:当使用static修饰时,函数或者对象都应该用static进行声明和定义。
Rule 8.9 若一个对象的标识符仅在一个函数中出现,则应将它定义在块范围内(建议)
原理:限定范围便于理解,也防止被外部意外访问。
Rule 8.10 内联函数应使用静态存储类声明(必要)
原理:内联函数声明为外部链接, 但没有在同一翻译单元内定义, 会导致未定义行为。调用外部链接的内联函数, 可能调用外部函数的定义, 或者使用内联定义, 这会影响执行速度。注意: 可通过内联函数置于将头文件, 使得内联函数在多个翻译单元内可用。
在test.h中定义一个内联函数应以static定义,如 static inline void testwrite(void) { printf("number is: %d\n", 1); }
Rule 8.11 声明具有外部链接的数组时,应明确指定其大小(建议)
原理:尽管标准C允许使用不完整类型声明数组并访问其元素,但显式的确定数组大小更安全。为每个声明提供其大小信息,可以检查它们的一致性。它还可以允许静态检查器在无需分析多个编译单元的情况下执行某些数组边界分析。
extern uint8_t Myarr[10]; // 明确指定数组大小
Rule 8.12 在枚举列表中,隐式指定的枚举常量的值应唯一(必要)
原理:即枚举人为定义的初始值行为应该保证唯一性。
以下行为不建议: enum Number{ one, // 默认为 0 two, // 默认为 1 three= 1, // 显式指定为 1,与 one重复 four// 默认为 2 }; 遵循以下原则: enum Number{ one=0, // 指定为 0 two, // 默认为 1 three, // 显式指定为 2 four // 默认为 3 };
Rule 8.13 指针应尽可能指向 const 限定类型(建议)
原理:如果一个函数不需要修改指针指向的数据, 应该将指针参数声明为指向const的指针。表明该参数不可被修改。这可以防止意外修改数据,并提高代码的安全性。
Rule 8.14 不得使用类型限定符“restrict”(必要)
原理:虽然使用“restrict”类型限定符可以提高编译器编译代码的效率和优化静态分析。但是,当指针操作的内存区域重叠时则会极大的与预期不符的代码风险。
3.9 初始化
Rule 9.1 具有自动存储持续时间的对象(临时变量)的值在设置前不得读取(强制)
原理:即遵循先定义再使用的原则。
Rule 9.2 集合或联合体的初始化应括在花括号“{}”中(必要)
原理:特别是在聚合类型里,使用花括号可以提高代码的清晰度。
Rule 9.3 数组不得部分初始化(必要)
原理:若数组元素初始化不为0,则应为数组元素的每个值进行显示初始化,不得遗漏。
Rule 9.4 数组的元素不得被初始化超过一次(必要)
原理:重复初始化数组里的某个元素会产生隐式错误。
Rule 9.5 在使用指定初始化方式初始化数组对象的情况下,应明确指定数组的大小(必要)
原理:对未定义大小的数组进行指定初始化时,容易误隐式决定数组大小,有可能带来误解和操作上的越界行为。
3.10 基本类型模型
Rule 10.1 操作数不得为不适当的基本类型(必要)
原理:不允许对浮点数直接使用操作符、操作符两侧的数据类型应该保持一致、仅对本质上无符号的操作数执行移位或逐位操作、对有符号操作数避免使用一元减号运算符。
只能对整数使用移位等操作如a=9.9,b=10;那么仅可使用b<<2而不能使用a<<2。
Rule 10.2 字符类型的表达式不得在加减运算中使用不当(必要)
原理:字符类型的表达式不应用在不正确的加法和减法运算中。对于加减法表达式应该遵循进行“+”运算时,一个操作数应为字符型,而另一个操作数应为有符号型或无符号型,其操作结果为字符型;进行“-”运算时,第一个操作数应为字符型,第二个操作数应为有符号型、无符号型或字符型,如果两个操作数都是字符型,则其结果为标准类型(在这种情况下通常为 int),否则结果为字符型。
Rule 10.3 表达式的值不得赋值给具有较窄基本类型或不同基本类型的对象(必要)
原理:即表达式赋值操作应保证数据类型一致,如不能将字符串赋值uint8类型。
Rule 10.4 执行常规算术转换的运算符的两个操作数应有相同的基本类型(必要)
原理:即进行算术运算的运算符应保证基本数据类型一致。
Rule 10.5 表达式的值不应(强制)转换为不适当的基本类型(建议)
原理:C语言允许通过强制转换符转换数据类型,但是若转换成不适当的类型,会带来隐式错误。
Rule 10.6 复合表达式的值不得赋值给具有较宽基本类型的对象(必要)
原理:赋值时若数据类型不一致,则应该显示转换为被赋值变量一致的数据类型,即不允许隐式转换的存在。
Rule 10.7 常规算术转换的运算符应保持宽度一致(必要)
原理:即进行算术运算不允许存在隐式转换,都应以同一数据类型进行运算。
Rule 10.8 复合表达式的值不得转换为其他基本类型或更宽的基本类型(必要)
原理:即不应该对表达式执行更宽的类型转换,但是允许执行更窄的类型转换。
3.11 指针类型转换
Rule 11.1 不得在指向函数的指针和任何其他类型的指针之间进行转换(必要)
原理:指向函数的指针仅可与指向兼容类型的函数的指针间相互转换。若通过与被调用函数类型不兼容的指针来调用函数,其行为是不确定的。
Rule 11.2 不得在指向不完整类型的指针和其他任何类型间进行转换(必要)
原理:普通指针与指向不完整类型间的转换可能会导致指针未正确对齐,从而导致行为不确定。 将不完整类型的指针转与浮点型互相转换,也会导致未定义的行为。 指向不完整类型的指针有时用作隐藏对象的表示形式的一种封装方式,将不完整类型的指针转换为指向对象(完整类型/明确类型)的指针会破坏这种封装。
Rule 11.3 不得在指向不同对象类型的指针之间执行强制转换(必要)
原理:指向不同对象的指针间的转换可能会导致指针未正确对齐,从而导致未定义的行为。
Rule 11.4 不得在指向对象的指针和整数类型之间进行转换(建议)
原理:将整数转换为指向对象的指针可能会导致指针未正确对齐,从而导致未定义的行为。将对象的指针转换为整数可能会产生无法以所选整数类型表示的值,从而导致未定义的行为。
Rule 11.5 不得将指向 void 的指针转换为指向对象的指针(建议)
原理:将指向 void 的指针转换为指向对象的指针可能会导致指针未正确对齐,从而导致未定义的行为。
Rule 11.6 不得在指向 void 的指针和算术类型之间执行强制转换(必要)
原理:将整数转换为指向 void 的指针可能会导致指针未正确对齐,从而导致未定义的行为。将指向void的指针转换为整数可能会产生无法以所选整数类型表示的值,从而导致未定义的行为。任何非整数算术类型和指向void的指针之间的转换都是未定义的。
Rule 11.7 不得在指向对象的指针和非整数算术类型之间执行强制转换(必要)
原理:将基本型为布尔型、字符型或枚举型的数据转换为指向对象的指针可能会导致指针未正确对齐,从而导致未定义的行为。将对象的指针转换为布尔型、字符型或枚举型可能会产生无法用所选整数类型表示的值,从而导致未 定义的行为。任何指向对象的指针与浮点型之间的转换都将导致未定义的行为。
Rule 11.8 强制转换不得从指针指向的类型中删除任何 const 或 volatile 限定符(必要)
原理:通过强制转换来删除与所指向类型相关的限定条件的任何尝试都违反了类型限定原则。
Rule 11.9 宏“NULL”是整数型空指针常量的唯一允许形式(必要)
原理:NULL是唯一被认可的空指针常量。其也为了防止空指针出现未定义的行为。
3.12 表达式
Rule 12.1 表达式中运算符的优先级应明确(建议)
原理:由于C语言的运算逻辑比较多,因此一般采取括号的形式表明运算的优先级。但是也不能过度使用括号,否则容易混淆。
Rule 12.2 移位运算符的右操作数应在零到比左操作数基本类型的位宽度小一的范围内(必要)
原理:即根据数据类型应始终保证存在有效数据位,不得存在无意义的行为。
如纯在变量uint8_t a = 10;那么仅a<<或a>>最大7位,即不允许移位超过7位。
Rule 12.3 不得使用逗号(,)运算符(建议)
原理:使用逗号容易混淆逻辑,一般不建议使用。
Rule 12.4 常量表达式的求值不应导致无符号整数的回绕(建议)
原理:除非特殊应用场景,否则不允许常量表达式的求值超出限定范围。
3.13 副作用(即会造成未知性结果或者状态持续变化)
Rule 13.1 初始化程序列表不得包含持久性副作用(必要)
原理:即在元素初始化时不可存在具有可变因素的赋值行为比如被volatile修饰的变量不可以被用来初始化。针对数组元素必须使用变量初始化的场景下,必须单独为每个元素初始化,以此来保证副作用是可控的。
以下初始化行为是不被许可的: volatile uint8_t a=1; uint8_t b=a;//行为不允许
Rule 13.2 在所有合法的评估命令下,表达式的值应与其持续的副作用相同(必要)
原理:如果没有指定优先级,那编译器可以自由选择执行顺序,而不同的执行顺序结果是不同的。如当i=0时,如果执行以下函数f(i,i++),则至少会发生该函数等价于f(0,0)或f(1,0)的未知情况。
Rule 13.3 不应存在除自增(++)或自减(--)运算符的完整表达式之外的副作用(建议)
原理:多个副作用混淆在一起会影响代码的质量和存在潜在风险,同时会误导开发人员进行理解。
Rule 13.4 不得使用赋值运算符的结果(建议)
原理:如果使用赋值运算参与逻辑判断会产生新的副作用,也会混淆代码逻辑。
如if ((x = f()) != 0)。
Rule 13.5 逻辑与(&&)和逻辑或(||)的右操作数不得含有持久性副作用(必要)
原理:逻辑运算符“&&”和“||”的右侧操作数是否求值取决于左侧操作数的值。如果右侧操作数包含副作用,则可能会发生与程序员期望相反的那些副作用。
如下x的操作是不许可的,存在未知即副作用 若存在uint8_t x = 0; if (test() && (x++ > 0)) {} 语句,那么该&&存在副作用不被认可。
Rule 13.6 sizeof 运算符的操作数不得包含任何可能产生副作用的表达式(强制)
原理:sizeof 运算符在编译时求值,其操作数不会被执行。如果在sizeof的操作数中包含带有副作用的表达式,这些副作用将不会发生,可能会导致代码逻辑错误。
3.14 控制语句表达式
Rule 14.1 循环计数器的基本类型不能为浮点型(必要)
原理:浮点型存在精度问题,使用浮点型进行循环计数会导致循环存在误差。
Rule 14.2 for 循环应为良好格式(必要)
原理:尽管for循环允许省略部分表达式,但是为了程序的可读性和安全性,仅允许for存在两种形式。一种是完整的for结构即for(xx;xx;xx),另外一种则是完全省略的结构for(; ; )。
Rule 14.3 控制表达式不得是值不变的(必要)
原理:除特殊情况下,控制语句不得出现死逻辑即不得出现if(0)这种表达式。
Rule 14.4 if 语句和循环语句的控制表达式的基本类型应为布尔型(必要)
原理:尽管C中允许循环语句的多元性,但是为了便于代码的理解和规范性,仅允许循环判断语句以布尔类型或者显示的与0进行比较的形式存在。即不能出现if(10)这种比较,仅允许bool类型的判断。
3.15 控制流
Rule 15.1 不应使用 goto 语句(建议)
原理:尽管使用goto方便进行逻辑上的跳转,但是会带来代码结构的混乱和潜在风险。
Rule 15.2 goto 语句仅允许跳到在同一函数中声明的稍后位置的标签(必要)
原理:即应该遵循先goto标签,再定义标签内容的原则。否则可能会带来死循环等潜在问题。
goto语句应该遵循以下规范,先goto L1,再定义L1标签 goto L1; L1:x=1;
Rule 15.3 goto语句引用的标签必须在该语句所在代码块或包含该代码块的上级代码块中声明(必要)
原理:因为goto语句具有强制性跳转的功能,因此应该保证goto语句不应存在任何具有条件判断的子代码块中。
void test(uint8_t a) { goto L1; /* 合规 - L1 在同代码块 */ if (a <= 0) { goto L2; /* 违规 - L2 在其他代码块中 */ } if (0 == a) { goto L1; /* 合规 - L1 在包含此goto语句的上级代码块中 */ } goto L2; /* 违规 - L2 在子代码块中 */ L1: if (a > 0) { L2: a=2; } }
Rule 15.4 最多只能有一个用于终止循环语句的 break 或 goto 语句(建议)
原理:为了保证代码的清晰度和合规性,只能存在一个通道使得循环退出。
Rule 15.5 应仅在函数的末尾有单个函数出口(建议)
原理:即一个函数最多只能有一个 return 语句。
Rule 15.6 循环语句和选择语句的主体应为复合语句(必要)
原理:即循环语句或者选择语句的使用需要用大括号标识范围。
Rule 15.7 所有的 if…else if 构造都应以 else 语句结束(必要)
原理:所有包含else if的语句都应该以else结束,如果只有简单if则不需要。此举是为了保证所有条件都有涉及,属于防御性编程。
3.16 switch语句
Rule 16.1 Switch 语句应格式正确(必要)
原理:即保证有完整的switch表达式、至少两个case分支、每个case需以break结束、switch应以default结束。
Rule 16.2 switch 标签只能出现在构成 switch 语句主体的复合语句的最外层(必要)
原理:即switch标签不能被嵌入至其它语句中使用。
Rule 16.3 每一个switch子句都应以无条件 break 语句终止(必要)
原理:即每个case需以break结束。
Rule 16.4 每个 switch 语句都应具有 default 标签(必要)
原理:即每个switch需以default结束。
Rule 16.5 Default 标签应作为 switch 语句的第一个或最后一个 switch 标签(必要)
原理:虽switch对case序列具有无序化识别,但是为了代码的清晰化,虚保证默认转移始终只能出现在首或尾两个地方。
Rule 16.6 每个 switch 语句应至少有两个 switch 子句(必要)
原理:即使用switch语句时至少保证有两个分支(包括默认分支),否则该语句可能存在隐式错误。
Rule 16.7 switch语句的控制表达式的基本类型不得是布尔型(必要)
原理:switch的控制表达式应该为整形,虽然bool也可以作为整型进行判断,但是为了逻辑的清晰性,使用if语句会更合适。
3.17 函数
Rule 17.1 不得使用<stdarg.h>
的功能(必要)
原理:
<stdarg.h>
头文件提供了处理可变参数列表的功能如va_list,va_arg,va_start,va_end 和 C99 中的 va_ copy。但是,可变参数列表的使用会降低代码的可读性和可维护性,并且可能导致类型安全问题。
Rule 17.2 函数不得直接或间接调用自身(必要)
原理:即不可使用递归函数,尽管某些场景使用递归会带来许多遍历,但是随之而来的是栈空间的压缩和逻辑的模糊。
Rule 17.3 禁止隐式声明函数(强制)
原理:函数应该严格遵守函数的定义和声明,否则在使用时会出现类型不匹配等隐式错误。
Rule 17.4 具有非 void 返回类型的函数的所有退出路径都应为具有带有表达式的显式 return 语句(强制)
原理:即需要保证具有返回值定义的函数应该以唯一的return结束。
Rule 17.5 与数组型函数形参对应的函数入参应具有适当数量的元素(建议)
原理:即实参数组大小必须与函数参数的数组大小一致。
Rule 17.6 数组形参的声明不得在[]之间包含 static 关键字(强制)
原理:可以使用static来声明数组的最小容量,但是这会涉及到编译器的兼容性问题,因此不推荐使用。
Rule 17.7 非 void 返回类型的函数的返回值应该被使用(必要)
原理:即具有返回值的函数被调用时应该有实体参数来接收该返回值,若没有则需要强制以void来调用该函数。
Rule 17.8 不应更改函数形参(建议)
原理:即作为函数的形参,应该以传参赋值的形式被使用而不是作为参数被任意修改。
3.18 指针和数组
Rule 18.1 指针操作数的算术运算应仅用于寻址与该指针操作数相同数组的元素(必要)
原理:尽管某些编译器可能在编译时就能检查到已超出数组边界,但通常在运行时不会检查无效的数组下标。而无效数组下标的使用会导致程序的错误行为。即经过算术运算后的指针索引不能越界。
假设定义了如下数组和指针,那么在越界和指向不同数组时,操作是非法的,如 uint8_t arr[5] = {1, 2, 3, 4, 5}; uint8_t arr2[5]; uint8_t *p1 = &arr[0]; uint8_t *p2 = &arr[0]; p1 = p1 + 10; // 指针越界, 不允许 p1 = arr2; // 指向了不同的数组 p2 = p2 + 2;//指针未越界 p2 = &arr[4];//依旧指向当前数组
Rule 18.2 指针之间的减法应仅用于寻址同一数组元素的指针(必要)
原理:只有当两个指针指向同一个数组的元素,或者其中一个指针指向数组末尾元素的下一个位置时,才能进行指针减法运算。即指针减法适用于同数组之间的逻辑运算。
Rule 18.3 关系运算符>,> =,<和<=不得应用于指针类型的对象,除非它们指向同一对象(必要)
原理:如果两个指针未指向同一对象,则尝试在两个指针之间进行比较将产生不确定的行为。
Rule 18.4 +,-,+=和-=运算符不得应用于指针类型的表达式(建议)
原理:任何显式计算的指针值都有可能访问意外或无效的内存地址。因此建议不要直接对指针使用这些运算符, 而是使用下标或指针递增/递减。
Rule 18.5 声明中最多包含两层指针嵌套(建议)
原理:使用多于层的指针嵌套会严重损害理解代码行为的能力,因此应避免多重指针使用。
Rule 18.6 具有自动存储功能对象的地址不得复制给在它的生命周期结束后仍会存在的另一个对象(必要)
原理:即不得对执行完程序后就销毁的地址执行返回等操作。因为通过地址引用已经释放的对象, 会导致未定义行为。
//错误返回地址 uint8_t *test() { uint8_t a = 10; return &a; } //正确返回地址,则应该使用静态局部变量或动态分配的内存 uint8_t *test() { static uint8_t a = 10; return &a; }
Rule 18.7 不得声明灵活数组成员(必要)
原理:灵活数组成员的存在可能会以程序员无法期望的方式修改 sizeof 运算符的行为。
Rule 18.8 不得使用可变长数组类型(必要)
原理:可变长数组的使用可能超出栈的限制,导致栈溢出。其大小也难以预测,导致安全漏洞。同时,在某些编译器上也不支持改类型的定义,兼容性差。
3.19 重叠存储
Rule 19.1 不得将对象赋值或复制给重叠的对象(强制)
原理:在同一片存储区域内,除完全相同的完全重叠且具有兼容类型的两个对象之外的赋值和复制操作是不允许的,因为这会导致出现未定义的行为,存在风险。
Rule 19.2 不得使用 union 关键字(必要)
原理:由于联合体各成员重叠内存, 写一个成员会导致其他成员也发生改变。因此union的使用会降低代码的可读性和可维护性,并且可能导致难以发现的错误。
3.20 预处理指令
Rule 20.1 #include 指令之前仅允许出现预处理指令或注释(建议)
原理:为了提高代码的可读性,应将特定代码文件中的所有#include 伪指令组合在一起,放在文件顶部附近。即遵循先包含头文件再使用相关函数或指令的原则。
Rule 20.2 头文件名中不得出现“'”、“"”、“\”、字符以及“/*”或“//”字符序列(必要)
原理:若头文件名出现不支持的符号会带来潜在威胁。
Rule 20.3 #include 指令后须跟随<filename>或"filename"序列(必要)
原理:即仅支持<filename.h>或"filename.h"两种格式。
Rule 20.4 宏不得与关键字同名(必要)
原理:使用宏更改关键字的含义可能会造成混淆。即不可将宏定义成与库函数或者已有的功能函数同名。
Rule 20.5 不应使用#undef(建议)
原理:
#undef
指令用于取消已定义的宏。虽然#undef
在某些情况下可能有用,但它会降低代码的可读性和可维护性。过度使用#undef
可能导致宏的定义和取消定义之间的关系混乱,难以追踪。
Rule 20.6 预处理指令的符号不得出现在宏参数内(必要)
原理:即预处理符号不得作为首字符出现在宏参数内。否则会造成类型混乱与编译错误。
Rule 20.7 宏参数展开产生的表达式应放在括号内(必要)
原理:宏定义是字符运算,因此传入的每一个参数都应放在括号内以防止出现因运算符的优先级导致的计算错误。
Rule 20.8 #if 或#elif 预处理指令的控制表达式的计算结果应为 0 或 1(必要)
原理:即强类型要求条件预处理指令的控制表达式应具有布尔值。
Rule 20.9 #if 或#elif 预处理指令的控制表达式中使用的所有标识符应在其评估前被#define定义(必要)
原理:即在该情况下必须保证标识符先定义再使用,否则编译器会使用默认值进行处理,也可能造成异常运行。
Rule 20.10 不应使用“#”和“##”预处理运算符(建议)
原理:
#
(字符串化运算符) 和##
(标记粘贴运算符) 是 C 预处理器提供的两个特殊运算符。#
运算符将其操作数转换为字符串字面量。##
运算符将其左右两侧的操作数连接成一个单独的标记。在某些场景下使用这两个符号进行宏定义可以快速并规范代码的编写,但是也会降低代码的可读性和可维护性,并且可能导致意外的宏展开结果。
Rule 20.11 紧跟在“#”运算符之后的宏参数后面不得紧随“##”运算符(必要)
原理:#是字符串化运算符,如果#后面紧跟##可能会有未知的逻辑错误产生,因此一般不推荐使用。
Rule 20.12 用作“#”或“##”运算符的操作数的宏参数,不得是本身需要进一步宏替换的操作数(必要)
原理:即进行宏替换后不得出现类似X=X+A这种进一步替换X的情况,应该仅存在X=B+A的场景。
Rule 20.13 以“#”作为第一个字符的一行代码应为有效的预处理指令(必要)
原理:应该遵循以#开始的预处理指令标准。
Rule 20.14 所有相关联的预处理程序指令都应位于同一文件中(必要)
原理:即所有#else,#elif 和#endif 预处理程序指令都应和与其相关的#if,#ifdef 或#ifndef 指令位于同一文件中。
3.21 标准库
Rule 21.1 不得将#define 和#undef 用于保留的标识符或保留的宏名称(必要)
原理:即不可对库文件的定义执行宏操作。
Rule 21.2 不得声明保留的标识符或宏名称(必要)
原理:即不可将库文件里的内容进行显示声明或重定义。
Rule 21.3 不得使用<stdlib.h>中的内存分配和释放函数(必要)
原理:即不得随意使用malloc/free/calloc/realloc系列函数。 因为动态内存分配可能导致内存泄漏, 碎片, 以及难以预测的程序行为。 因此在安全关键的嵌入式系统中, 应该避免使用动态内存分配。
Rule 21.4 不得使用<setjmp.h>标准头文件(必要)
原理:由于setjmp和 longjmp允许绕过正常的函数调用机制。使用它们可能导致不确定的行为。因此<setjmp.h>中指定的任何功能均不得使用。
Rule 21.5 不得使用<signal.h>标准头文件(必要)
原理:该头文件包含信号处理设施。信号处理机制是操作系统提供的, 用于处理异步事件。 在嵌入式系统中, 信号处理可能会导致时序问题和不可预测的行为。
Rule 21.6 不得使用标准库输入/输出函数(必要)
原理:该项限制主要针对于<stdio.h>和<wchar.h>文件中一些指定的函数,例如其中的printf 和 scanf 的格式化字符串存在漏洞。而且文件操作可能导致资源泄漏或竞争。同时标准 I/O 函数可能依赖于底层操作系统,降低代码的可移植性。
Rule 21.7 不得使用<stdlib.h>中的 atof、atoi、atol 和 atoll 函数(必要)
原理:当无法转换字符串时,这些函数具有未定义的行为。
Rule 21.8 不得使用<stdlib.h>中的 abort, exit, getenv 和 system 函数(必要)
原理:这些函数涉及到底层操作,比如abort会立刻终止程序,这种操作会带来缓存区异常等问题。还有的函数太依赖底层,涉及到安全性等因素,因此该类函数不允许被使用。
Rule 21.9 不得使用<stdlib.h>中的 bsearch 和 qsort 函数(必要)
原理:如果比较函数在比较元素时行为不一致,或者修改某些元素,其行为是未定义的。qsort 的实现很可能是递归的,因此会对堆栈资源提出未知要求。
Rule 21.10 不得使用标准库时间和日期功能(必要)
原理:时间和日期函数具有未指定,未定义和实现定义的行为。
Rule 21.11 不得使用标准头文件<tgmath.h>(必要)
原理:使用<tgmath.h>的功能可能会导致不确定的行为。建议使用math.h。
Rule 21.12 不得使用<fenv.h>的异常处理功能(建议)
原理:
<fenv.h>
头文件提供了对浮点环境的访问和控制,包括浮点异常、舍入模式等。但是,浮点异常处理机制可能依赖于底层硬件和操作系统,并且可能导致代码的行为难以预测。因此不得使用标识符feclearexcept,fegetexceptflag,feraiseexcept,fesetexceptflag和fetestexcept, 并且不得展开含有这些名称的宏。 不得使用宏 FE_INEXACT,FE_DIVBYZERO,FE_UNDERFLOW,FE_OVERFLOW,FE_INVALID 和 FE_ALL_EXCEPT, 以及任何实现定义的浮点异常宏。
3.22 资源
Rule 22.1 通过标准库功能动态获取的所有资源均应明确释放(必要)
原理:分配资源的标准库函数包括 malloc,calloc,realloc 和 fopen。如果未明确释放资源,则可能由于这些资源的耗尽而发生故障。尽快释放资源可以减少耗尽的可能性。
Rule 22.2 只有通过标准库函数分配的内存块才能释放(强制)
原理:释放未分配的内存,或多次释放相同的已分配内存会导致未定义的行为。即正常定义的指针指向的地址不能释放,否则会出现问题。
Rule 22.3 不得在不同的数据流上同时打开同一文件以进行读写访问(必要)
原理:不得同时读写同一文件,否则会导致数据异常。应该遵循打开-读写-关闭-再打开-读写-关闭的原则。
Rule 22.4 禁止尝试对以只读方式打开的流执行写操作(强制)
原理:对只读的文件执行写操作认为是非法操作,会产生异常。
Rule 22.5 禁止反引用指向 FILE 对象的指针(强制)
原理:FILE 对象是标准库用于表示文件流的结构体。FILE 对象的内部结构是实现定义的,不应该直接访问其成员。这样做会导致代码不可移植,并且可能破坏 FILE 对象的内部状态。
Rule 22.6 关联的流关闭后,禁止再使用指向 FILE 的指针值(强制)
原理:在对流执行关闭操作后,FILE 指针的值不确定。
规则总结:MISRA C 的规则几乎涵盖了代码块编写的所有规范,基本上只要遵循该规则就可以避免很多的隐式错误,同时对代码的清晰度和安全性也会有很大的提升。
总结
以上便是对MISRA C标准的学习与理解,其中标记为强制的规则是在开发过程中必须遵守的规则。一旦不满该项规则,代码则认为不合格,具备风险。标记为要求的是不需完全满足的规则,但是必须对不满足的条件做合理解释,并且不得存在其它隐患。而标记为建议的则是要求尽可能遵守,但是也不能完全无视,也是需要对不满足的条件做出合理解释。
在我看来,MISRA C标准从编译环境的搭建到项目落地有着全套的比较详细的规范流程,因此,这套标准是保障嵌入式代码质量,提高代码安全性能的重要工具之一,同时在simulink、Etas等代码生成工具中也嵌入该标准说明其安全度和适配度也是及其良好的。总的来说,作为代码的编写者,熟悉MISRA C标准是一条必不可少的选择。