以下文章来源于ADAS与ECU之吾见,作者奋斗的农民工

软件Bug是怎样写成的               
很多小伙伴们都会有这样的疑虑,为什么别人写的代码总是那么的优雅、简洁、清晰明了,而自己的代码却是毫无章法,Bug百出,有些时候自己都觉得自己就是个Bug制造机。工作中时常有同事开玩笑,我们的daily work就是“找Bug,解Bug,写Bug”,一直是陷入到这样的无线loop中不能自拔图片。那么要解除此封印,你只需要一本葵花宝典在手-《软件编码规范指南》,从此便可释放出你的洪荒之力,让你摇身一变为找Bug大神而不是写Bug大神。接下来我就结合自身的工作经验说点自己觉得比较重要的一些金科玉律。

1
头文件


规则 1.1 头文件中仅放置接口的声明,不放置接口的实现;


例如内部使用的宏、枚举、结构定义不应放在头文件中,变量定义不应放在头文件中,而应当放在.c文件中;如果使用全局变量,也应当在.c中定义全局变量,在.h中仅作声明。

规则1.2 头文件应当向稳定的方向包含;

一般而言,应当让不稳定的模块依赖于稳定的模块,就产品而言,即产品依赖于平台,平台依赖于标准库。

规则1.3 禁止头文件循环依赖并禁止包含用不到的头文件;


头文件循环依赖,比如a.h包含b.h,b.h包含c.h, c.h包含a.h, 这样就会导致任何一个头文件的更改,都会导致a.h/b.h/c.h全部编译一遍,如果是单向依赖,如a.h包含b.h, b.h包含c.h,而c.h可以不需要包含。

规则 1.4 总是编写内部#include保护符(即#Define 保护)


多个文件包含同一文件实际上可以通过良好的系统设计来避免,但是保险起见,我们要有防御式编程意识,即所以头文件都应当使用#Define防止头文件被重复包含,如下图所示:
1.jpg

规则 1.5 禁止在头文件中定义变量


因为在头文件中定义的变量很容易被其他.c 文件包含易造成重复定义而出错。

规则 1.6 当C++文件需调用C文件函数或者变量时,需要用extern “C” 声明;

因为C++语言支持函数重载,而C语言不支持,这样用C++编译器编译出的函数同时包含函数名与变量类型,而C语言则只存在函数名,因为需要通过该标识符来告诉C++编译器这部分代码需要按照C语言来进行编译链接,否则链接会出错,找不到该函数,具体使用方法如下:

2.jpg

2
函数

规则2.1  一个函数仅完成一个功能


如果一个函数同时完成多项功能,不仅不利于维护,而且会大大增加系统的耦合性,不能够让读者清晰明白本函数的用途,因此保持此原则是写出优秀代码的关键。

规则2.2  重复代码应当尽可能提炼成函数


如果一直保持着“代码能用就不改”的原则,那么一大坨烟囱式的代码堆积,不仅容易出错,而且可读性差,十分不友好,往往这类代码存在着极大的优化空间,应当立马着手重构代码,即当一段代码出现2次,应当考虑消除,当重复代码出现3次,应当立刻着手消除。

规则2.3  避免函数过长,新增函数有效代码行数不应超过50行(非空非注释)

代码过长一般意味着函数实现功能不单一,可读性差,建议将内部相关算法步骤整合成函数,以便函数实现步骤清晰可见。另外,业界普遍认为一个函数允许的最大长度不应该超过当前整个屏幕,避免来回翻页影响阅读。

规则2.4 避免函数代码嵌套过深,新增函数代码块一般不应超过4层;

规则2.5 可重入函数应避免使用共享变量,若需要使用,则应通过互斥手段(关中断,信号量)来加以保护;


所谓可重入函数就是可同时被多个任务并发调用的函数,共享变量指的是全局变量与静态变量;不可重入函数即不可中断的函数,因为其内部使用了共享变量。

规则 2.5 设计高扇入,合理扇出的函数(小于7)的函数;

所谓高扇入即多个函数调用本函数,所谓高扇出即本函数调用了过多的函数;一般而言,在不破坏软件架构的前提下尽可能追求高扇入,扇出要适当,一般3-5为宜,过高则表示缺少中间层过渡,过低,如总是1,就会给堆栈空间造成不必要的压力。

规则 2.6 废弃代码坚决立刻删除;


所谓废弃代码就是没有被调用的函数或者变量以及相关临时测试代码,因为现代软件版本管理工具如Git完全可查看任意文件的修改历史,无需放在代码中占用空间,有时甚至会给代码维护带来一定的干扰,降低工作效率,得不偿失。

几点建议:

    函数传入的不变参数应使用const;
    检查函数所有输入参数的有效性;
    函数的参数一般不应该超过5个;

    在源文件范围内声明并定义的函数,除非外不可见,否则应当增加static关键字。

3
标识符命名与定义


规则 3.1 公司或者项目组应当采用统一的变量命名规则且定义清晰明确;

对于变量命名应当有统一规范,不应当百家齐放,保持统一风格为佳,如局部变量前缀为l_,全局变量前缀为g_ ,是采用windows风格还是匈牙利命名法,最好有章可循,统一规范。

规则 3.2 除了常见的缩写,不使用单词缩写,以免词不达意;

如argument 可缩写为 arg, clock 可缩写为 clk,device可缩写为dev, error可缩写为err, increment 可缩写为inc,message 可缩写为 msg,synchronize 可缩写为sync ,temp 可缩写为tmp等。

规则 3.3 宏必须全部使用大写;

规则 3.4 应当尽量不使用全局变量或者静态变量;


因为全局变量的大量使用,会大大破坏软件架构,而且给代码维护带来极大的不便,有时候如果不注意很容易出现全局变量被编译器优化,导致多线程访问时出现缓存不一致性问题,所以应当尽可能减少全局变量或静态变量的使用。

4
变量与宏、常量

规则 4.1 一个变量只能有一种功能,不能把一个变量用作多种用途;

规则 4.2  禁止局部变量与全局变量同名;

规则 4.3 通讯过程中使用的结构,必须注意字节序;


在通讯报文中,字节序是一个很重要的问题,因为CPU类型,大小端、32位/64位的处理器均有,因此字节序是一个不得不考虑的问题。

规则 4.4 在首次使用前初始化变量,初始化的地方离使用的地方越近越好;

规则 4.5 明确全局变量初始化顺序,避免跨模块的初始化变量;


系统启动阶段,在使用全局变量前,要考虑到全局变量在什么时候初始化,否则会有意想不到的惊喜。

规则 4.6 使用宏定义表达式,要使用完备的括号;

规则 4.7 将宏定义的多条表达式放在大括号中;


最好的方法写成do while(0)的形式;

规则 4.8 不要使用魔鬼数字;

使用魔鬼数字不仅不易理解,而且一旦该数字多地方使用,那么将损失惨重,0作为一个特殊的数字,可以作为一般默认值并且没有歧义时,可以不用特别定义说明。

几点建议:

    常量尽量使用const定义代替宏;

    宏定义中尽量不使用return,goto,continue,break等直接改变软件流程的语句;

5
代码质量保证

规则 5.1 保证代码质量优先原则


正确性、简洁性、可维护性、可靠性、代码可测试性、代码性能高效、可移植性等这些都是保证软件质量的重要前提,但是一般而言,代码的可读性均要优于代码性能。

规则 5.2 必须了解编译系统的内存分配方式,特别是编译系统对不同类型的变量的内存分配规则,如局部变量何处分配,静态变量在何处分配等;

规则 5.3 禁止内存操作越界以及内存泄漏;

规则 5.4 所有的if ... else if 结构应该由else子句结束,switch语句必须有default分支;


建议:if语句尽量加上else语句,对没有else语句要多加小心;

以上就是我在工作中以及自我学习的过程中觉得非常不错的重要规则,如果在Coding的过程中能够有上述编码规则在心中,那么就可以做到胸有成竹,写出优秀、可靠、可读性强、安全性更高的代码,当然,这只是系列一,与君共勉,希望能对大家的编码能力有所提升!

上文结合实际工作经验讲解了一些常见的避免软件Bug的方法,但对于某些特定的应用场合,特别是安全性要求更高的场合,往往上篇的基本方法可能还不足以保证整个系统的安全性,例如汽车电子领域,工程机械等领域。因此,1998年MISRA组织发布了汽车制造业MISRA-C标准来保证该嵌入式领域的编安全,下面将聊聊关于MISRA-C的那些事儿,共同探讨一些编码过程中我们极容易忽视的点儿。

1. 汇编语言应该被封装并隔离;


出于执行效率的考虑,有些时候不得不嵌入简单的汇编指令,比如开关中断,但是无论如何,都应当使用宏定义或者汇编函数来进行合理的隔离,以防止出现任何安全隐患。

#define NOP asm("    NOP");

2. 所有#pragma指令的使用应当文档化并给出良好解释;


由于#pragma指令作用于编译器,且不同编译器执行同一条指令有时效果不一致,所以对于#pragma指令的使用应当文档化,以方便进行软件移植。

3. 单纯的char类型只用作存储和使用字符值,而signed char 和unsigned char类型应该只用做存储和使用数字值;(强制)

4. 应当使用指示大小和符号的typedef来替代基本类型;


开发者不应当直接使用char,short,int, long, float等基本类型,不便于软件移植,下面通过列举ISO(POSIX)的typedef,对于32位计算机,如下所示:
3.jpg
5.  外部对象或函数应该声明在唯一的文件中;

通常是在一个头文件中声明一个标识符,而在定义或使用该标识符的任何文件中包含该头文件。一般而言,只有全局变量或者函数才用extern, 虽然也可以包含其中一个定义该变量的头文件,但是暴露了所有接口的同时也增加了编译时间,所以如果只需使用某个特定的函数或者变量,建议直接使用external +类型+变量或者函数来实现跨文件使用。

6. 后缀“U”应该用在unsigned 类型的常量上;

整数常量类型的存在是混淆的根源, 为了避免此类混淆,提高代码的可读性与清晰性,我们需要对unsigned类型后面都添加后缀“U”,相关注意事项如下:

    任何带有“U”后缀的值都是unsigned类型;
    任何不带“U”后缀且小于231的十进制数是signed类型;
    不带后缀的大于或等于215的十六进制数可能是signed或unsigned类型;
    不带后缀的大于或等于231的十进制数可能是signed或者unsigned类型;

7. 浮点表达式不能做相等或者不等的检测;

由于浮点类型的固有特性,等值比较通常不会计算为TRUE,即使比较通常不会计算为TRUE,所以为了获得确定的浮点类型比较,建议写个实现比较运算的库,该库应该考虑浮点的粒度(FLT_EPSILON)以及参与比较的数的量级;在面试题中经常会有如何将float类型数据 x 与0比较,一般做法如下所示:
const float EPSINON 0.00001;if((x >= -EPSINON) && (x <= EPSINON)){return TRUE;}
复制代码
8. for循环中用于迭代计算的数值变量不应在循环体中修改;

9. 不能有不可到达(unreachable)的代码;(强制)


本规则适用于在任何环境中均不会到达或者调用的代码,无论是在逻辑设计层面不可达,还是因为压根就没有调用而遗留在程序中的废弃代码。

10. 所有的if...else if 结构应该由else 子句结束;

不管是一条if语句跟后一个或者多个else if语句都需要应用本规则,常见的if ...else组合,应当使用防御性编程defensive proggramming来保证所有情况均考虑在内。

11. 函数不能调用自身,不管是直接还是间接调用;

这就意味着安全系统就不应该使用递归函数进行调用,因为递归本身就可能发生堆栈空间使用过度的危险,这将导致严重的后果,除非递归函数经过严格的测试,否则不可能在执行之前确定什么是最坏情况下的堆栈使用。

12. 函数原型中的指针参数如果不是用于修改所指向的对象,就应该声明指向const的指针;

其中const只限定用于指针所指向的对象,而非指针本身,如下代码所示:
void func(int16_t *par0, const int16_t *par2, int16_t *par3)
  • {
  •   *par0 = *par2 + *par3;
  •   return;
  • }
  • 复制代码
    可见par0在函数func中被修改,par2以及par3均未被修改,但是仅仅par2正确添加了const标识符,但是par3没有,所以par3的使用不符合MISRA-C的标准。

    13. 指针的数学运算只能用于指向数组或者数组元素上,且对象声明的间接指针一般建议不多余两级;

    对于指针的增减运算,一定要小心,否则会出现未定义的行为,另外如果间接指针多于两级,那么很容易导致代码可读性变差,不利于后续的软件维护,在此也顺便提一点很多人都容易将指针与引用混淆,下面就简要总结一下指针与引用的区别与联系。
    联系:两者都代表的是指向对象的地址;

    区别:

        引用必须初始化,而指针不必;
        引用初始化之后不能改变,而指针可以;
        可以存在指向空值的指针,但是不能存在指向空值的引用;

    14. C的宏只能扩展为用大括号括起来的初始化,常量,小括号括起来的表达式、类型限定符、存储类标识符或者do-while-zero结构;


    其中do-while-zero结构是在宏语句体中唯一可接受的具有完整语句的形式,该结构可以用于封装语句序列并保证其是正确的。注意,在宏语句的末尾一定必须省略分号。
    #define PI 3.14159F
  • #define XSTAL 100000
  • #define CLOCK (XSTAL/16)
  • #define PLUS(X)((X)+ 2)
  • #define READ_TIME_32()\
  • do{\
  •   DISABLE_INTERRUPTS();\
  •   time_now = (uint32_t)TIME_HI << 16;
  •   time_now = time_now|(uint32_t)TIMER_LO;\
  •   ENABLE_INTERRUPTS();\
  •   }while(0)    /* example of do-while-zero */
  • 复制代码
    15. 在定义函数宏时,每个参数实例都应该以小括号括起来,除非它们作为#或者##的操作数。

    在函数宏的定义中,参数都应该用小括号括起来,例如一个ABS函数可以定义为
    #define abs(x) (((x) >=0)? (x): -(x)) /* compliant */
  • #define abs(x) (((x) >= 0) ? x: -x)   /* not compliant */
  • 复制代码

    16. 不能重用标准库宏、对象和函数的名称,同时传递给库函数的值必须检查其有效性;

    对于C语言诸多库函数都是根据ISO标准并不需要检查传递给它们的参数的有效性,即使标准要求这样,或者编译器的编写者声明要这么做,也不能保证会做出充分的检查,因此在调用库函数之前务必对输入参数进行相关检查,否则即使库函数自身有自身的参数域检查,但是由于编译器的不同,它们的返回值也可能不同,这对定位问题会产生干扰。

    17. 不要使用动态堆的内存分配;

    这就禁止了alloc、malloc、realloc和free的使用,总而言之,需要尽量避免此类函数的使用,容易导致内存不一致、内存泄漏、内存碎片过多的问题产生如有些string.h中某些函数的使用到动态堆的分配,也应当避免此类函数的使用。

    上述内容是自己阅读相关MISRA的相关文档并结合工作经验,总结出来一些较为重要的points,如果有存在不合理的地方,欢迎大家指正批评,也希望能给大家在编码过程中一些启示,如果感觉对你有所帮助的话,欢迎点赞加关注,我会继续努力分享自己工作过程中的所见所得!如需要MISRA-C的相关文档,也可后台留言与我联系,我会免费分享给大家。