硬件基础:嵌入式软件可靠性设计要注意的问题

自从40多年前嵌入式系统诞生以来,随着技术的发展和需求的变化,嵌入式系统软件就在嵌入式系统中越来越重要。现在,甚至一些嵌入式系统硬件一模一样,仅仅是软件不同,就是不一样的产品(如交换机和路由器)。

 

嵌入式系统应用领域千差万别、他们对嵌入式系统的要求和侧重点不尽相同,(如工业控制特别强调可靠性),但基本要求嵌入式系统功能强大、性能稳定、工作可靠。但这3点不是相辅相成的,而是互相之间有矛盾的。

 

嵌入式系统的功能、稳定性、可靠应与嵌入式系统的硬件、软件都有关系。本文仅讨论版嵌入式系统软件的可靠性设计问题,因此假设嵌入式系统的硬件是稳定可靠的。尽管一些应用可以在不可靠的硬件上通过软件设计获得可靠的产品(如U盘,NAND FLASH是一个不可靠的存储介质,但通过软件设计,可以得到可靠的存储设备,硬盘更是如此),但这不在本文的讨论范围之内。

 

可靠性与稳定性之间的关系

 

定律1:越简单的东西越容易做得可靠

相对锤子来说,机械手表足够复杂。如果让一个锤子和一个机械手表都从10层楼高处掉到普通水泥地面,哪个损坏的可能性更大?

 

当然,如果花费大的代价,如使用最好的材料,并增减减震系统,机械手表甚至可以做到锤子摔坏了而手表不坏。不相信?飞行员从几万米高空掉下来不受伤的比比皆是(当然有降落伞啦)。

 

从上述说明可知,简单的东西很容易做得高可靠,但复杂的东西要做高可靠花费的代价就高多了。这是普遍原则,对于嵌入式软件也适用。既然如此,那为什么人们还要做复杂的东西呢?这就涉及第二定律了。

 

定律2:越复杂的东西越容易做得稳定

记得大学刚入学时有军训,最后一项是打靶。本班奉命在打靶的前一天下午擦拭打靶用得半自动步枪,具体型号记不得了,但肯定是中国建国后早期生产的。在擦拭前教官给我们讲注意事项,其中有一句是这样的:“一个人擦一把枪,不要把零件搞混,否则装不上的。”也就是说,同样型号的两把枪,同一个零件不能互换!只是因为建国初期的枪都是使用简单的工具制造的,零件的尺寸、质量都不稳定,而一把枪上一些零件间的公差要求较小,只好用人工的方法筛选能够互相配合的零件组装成成品。这样,由于产品零件的不稳定,造成了同一个型号的产品的零件互不通用。再看一些现在的枪支,不同型号的枪支60%零件可以互换是很正常的,这有设计的原因,同时也要归功于制造工具足够精密复杂,足以制造尺寸质量足够稳定的零件。

 

嵌入式系统软件也是这样。我们的代码越写越大,越写越复杂,很大程度不就是让软件在各种情况下都能够稳定运行吗?

 

定律3:每个系统有一个最小的复杂度

一般普通的锤子必须有一个锤柄和一个锤体,锤柄最简单估计是圆柱体了,锤体也一样。似乎最简单的锤子就是由两个圆柱体组成了,笔者想象不出更简单的锤子。而要把锤子做复杂一些很容易,方法很多,例如在锤子上铸龙雕凤。

 

也就是说,在相同的功能与稳定性的前提下,每个系统有一个最小的复杂度。锤子的功能是敲打东西,仅仅是这个功能的话,仅需要一个垂体即可,但那样容易伤到人的手(稳定性不好),所以需要一个锤柄。嵌入式系统软件也是如此。

 

结论

由上面3条定律可知,系统的稳定和可靠之间有一定的矛盾:提高稳定性容易实现的方式是降低系统的复杂度,这又往往降低了系统的稳定性。同样,提高系统的稳定性又容易降低系统的可靠性,要稳定和可靠都高就需要花费比较大的代价。

 

功能与可靠性、稳定性之间的关系

 

由上可知,系统的功能与可靠性、稳定性之间不是孤立的,而是互相联系互相制约的,下面详细分析。

 

定律1:功能的增加是依靠复杂度的增加而增加的

大家知道,普通的锤子只能锤东西,现在需要增加拔钉子的功能,锤体的一端需要改变形状,很显然更难制造了(复杂度增加了)。锤子功能增加了,可是也更难使用也更容易损毁了(锤子拿反了,用拔钉子的一面锤东西……)。

 

复杂度增加了,要保证同样的可靠性就需要花费更多的代价,显然功能和可靠性也是一对矛盾。

 

定律2:功能的增加可能造成单个功能的复杂度的减少

大家可以找一个目前市场上可以买到的最好的拍照手机,和一个普通的数码相机,比较它们的拍照效果。可以肯定,数码相机的效果更好。原因是拍照手机由于种种限制,不能把其集成的数码相机功能做得与普通数码相机一样复杂(镜头不够精密、闪光灯只能用LED或低挡的氖灯、感光元件也只能用简单的),当然稳定性要差一些了。对于嵌入式软件也是如此,受限于存储空间的大小、人机接口等,嵌入式软件的往往只能简化各个功能代码才能把它们集成在一起。

 

复杂度降低了,要保证同样的稳定性就需要花费更多的代价,所以保证同样的稳定性甚至是不可能完成的任务,显然功能和稳定性也是一对矛盾。

 

结论

由上面2条定律可知,系统的功能和系统的稳定、可靠之间有一定的矛盾。要功能多又要稳定可靠就需要花费比较大的代价。

 

增加嵌入式系统软件的可靠性和稳定性的有效方法

 

优化系统框架设计可以提高系统的稳定性和可靠性

在一定的稳定性和可靠性的基础上,一个系统有一个理论上最小的最小复杂度,但在实际上要达到这个最小复杂度是不可能的。在实际工作中,往往如在锤子上雕花一样,增加了复杂度,不但不会提高系统的稳定性,如果做得不好,反而会降低系统的稳定性。

 

系统的复杂度的增加,要保持原有的可靠性更困难,对提高系统可靠性没有任何帮助。

 

想要花费比较小的代价提高系统的稳定性和可靠性,比较好的办法就是减少系统不必要的复杂度。而对系统复杂度影响最大的就是系统框架,一个好的系统框架能够抑制系统复杂度的不必要的增加,并且在系统功能变化时对已存在的功能模块的影响降到最低。

 

这样,提高系统的稳定性和可靠性所花费的代价就较低,间接提高了系统的稳定性和可靠性。

 

稳定可靠来源于严格的测试

人永远不能完全了解世界,因此设计系统时不可能把所有的情况都考虑到。因此,稳定可靠不是嘴说出来的,也不能仅通过分析系统设计而来确定。

 

提高稳定性的第二步来自严格的测试,包括先期的设计人员自己测试和中后期的第三方测试。在测试中发现了问题就必须修改设计并重新测试。如此反复,直到在一定的时间内测试不出问题。

 

稳定可靠有赖于时间的检验

产品经过严格的内部测试和小批量试产并提供给友好顾客使用后(外部测试),终于大批量上市了。但即使这样,世界级的大公司也会出现产品大规模召回的现象,为什么?

 

前面说过,人永远不能完全了解世界,因此再严格的测试也不可能模拟出实际使用过程中的所有情况。这样,用户使用的环境和方法与测试的环境与方法不一致时,产品潜在的不稳定点或不可靠点被暴露出来。如果这些不稳定点或不可靠点是致命的,产品必须被召回。如果不是致命的,也需要改进设计,提高系统的稳定性和可靠性。如此反复。如果系统大量和长时间的使用而不需要改进,说明是稳定可靠的。

 

因为专业所以稳定可靠

在古代,如果您与专业打铁匠做铁锤,谁的产量和质量稳定可靠呢?显然是打铁匠。为什么?因为您是业余的而打铁匠是专业的。

 

为什么专业会导致稳定可靠?

 

最重要的原因是他们已经在这个领域花费了很多代价提高系统的稳定和可靠性(否则就不专业了),他们与非专业的已经不在一条起跑线上,非专业的想在短期内超过专业的是不可能的。

 

其次,是他们对本领域内的情况非常了解,制定的测试方法与实际情况符合度很高,增加了稳定性和可靠性。

 

第三,是他们可以利用已经经过时间检验的系统作为新系统的基础,甚至直接使用老系统,不可控的复杂度增加有限,只要花费较小的代价就可以保证系统的稳定性和可靠性。

 

结论:专业分工合作是提高嵌入式系统软件的最快最省方法

随着技术的发展和社会的进步,现在用户要求嵌入式系统功能强大、性能稳定、工作可靠。一个功能强大、稳定的系统有比较高的复杂度,但不是所有的复杂度都对系统的可靠性有大的负面影响。一个经过时间检验的可靠模块对系统可靠性的负面影响很小。

 

但一个强大的系统往往涉及多方面的知识,很多往往还不是自己的专业范围内,自己研发要做到可靠需要花费的代价太大,甚至超过收益。此时,寻找专业的合作伙伴提供稳定可靠的模块集成到自己的系统中,自己只做自己专业内的部分,这样,复杂度的各个部分对可靠性的负面影响都较少,同时整体复杂度也容易控制,产品可以较快的上市。

 

嵌入式系统软件更加适合这种模式。这是因为软件是一种容易复制的东西,复制品的可靠性、稳定性和复杂度都不会改变。专业公司的软件模块一般已经被多个公司在完全不同的环境使用,其功能、稳定性、可靠性都经过严格的检验,不会对自己的系统带来大的负面影响。多个公司使用也可以分担软件研发的费用,直接使用成本较低。同时,专业公司对自己所属的领域非常了解,他们可以协助用户开发,更进一步降低用户成本。

 

所以说专业分工合作是提高嵌入式系统软件的最快最省方法。


需要注意的问题

男人征服世界,女人通过征服男人来征服世界;硬件叱咤江湖,软件通过控制硬件来统治江湖。当今世界,放眼江湖,有电子的地方就有嵌入式软件,有电子故障的地方,也就有嵌入式软件设计缺陷的影子。我们今天就把软件所容易犯的错误和规避的方法一一罗列,并给出应对之法。

嵌入式软件的最大特点是以控制为主,软硬结合的较多,功能性的操作较多,模块相互间调用的较多,外部工作环境复杂容易受到干扰或干扰别的设备,且执行错误的后果不仅仅是数据错误而是有可能导致不可估量的灾难,所以总结起来,嵌入式软件可靠性设计需注意的问题有四个方面:

1、软件接口

先说软件接口中容易出问题的地方和编程人员容易犯的错误。

软件接口调用一般会有数据的赋值,赋值变量的数据类型可能会存在强制的数据转换;需加以检查。如果为了防范出问题的话,可以添加对数据范围和数据类型的检查。

赋值数据的数量不对路,多了少了的都不好,会出现意外的赋值结果,不过还好,这项错误比较好检查。

软件编程中,会有对某一功能操作代码的复用,比如对某个端口的数据检查和控制,在整个程序中只会发生两次,为了图省事,可能就直接把该段代码直接插入实际程序模块中去了,这样,在源程序代码中,就出现了两段完全相同,完成相同功能,只是服务于不同模块的代码,按道理来说,这样设计其实也没啥问题,是的,你没错,但你的行为会使别人无意中犯错。就像青年男女相处,女孩子纯粹是想和男孩子充分享受温馨的气氛和心情,并不想更深入的发生什么,但女孩子邀请男生去的是她的家,在家里换上了家居的睡衣,窗户紧闭,放着的还是暧昧的音乐,被男孩子半强迫发生后,无限哀怨地说“我没想到结果会是这样的”,那怪得谁来呢?在代码方面,您的这种做法与貌似引诱男孩上钩的少女无异。

有人会说了,我这样写代码怎么就算引诱呢?原因是程序可能会升级,您这几行代码在实际应用过程中也不能保证是尽善尽美的,发现不完善的地方后,势必会修改,如果你还能想得起来,可能不会遗漏,如果修改此代码的是别的人,改了一个地方,别的地方没改,是不是还留着隐患?那如何做呢?方法不难,把这段功能单独做成一个模块即可,对此端口的读取和控制赋值均由此独立模块完成,如果数据的正确性影响大的话,还需要对端口数据的正确性进行检查和判断。嵌入式软件可靠性编程方法的四个目的是防错、判错、纠错、容错。对端口数据的判断属于判错的内容,如果数据有错的话,纠错和容错的设计方法应该不用我深入讲解了吧?

2、软硬件接口

硬件如男人,对外的执行都靠它来实现,一旦出现问题,执行后的后果就不可控了,周总理说过“外交无小事”。但如何注意呢?

对读进来的硬件接口的数据要判断其真伪;

对输出的数据的执行效果要检测;

对输出的数据的可能后果要进行预防性设计,数据输出的过程,我们从设计上要做一个分析,分析的思路是一般容易局限在稳态过程,忽视了过渡过程。举例说明,比如我们控制一个支路的供电,从软件控制来说,直接给继电器一个启动信号,让开状态的触点闭合就可以了,非“关”即“开”,是受控继电器的两个稳态状态,但事实上,在从开到闭合的过程中,支路供电的电压并不是一个简单0V—24V(24V为示例而已)的跳变状态,而是一个抖动,有冲击信号的过程,这种情况在硬件上的防护是必不可少的,但在软件上也不是可以事不关己、高高挂起的。

另外在逻辑上,宜将容易被干扰和容易产生的干扰控制动作从时序上控制好,予以分开隔离。比如,控制继电器的过程是容易产生抖动尖峰脉冲而干扰数据总线和控制信号总线的,这时候从控制上,不宜同时实施数据的发送和接收工作,不宜作出其他的控制动作,惹不起咱躲得起,躲过这一阵干扰的时候总可以了吧?

3、软件代码

软件的可靠性是随着时间的推移,可靠性逐渐增加的,这一点区别于电子可靠性、机械可靠性。电子可靠性服从指数分布,在整个生命周期内,其失效率为一个常数;机械可靠性因为磨损、腐蚀、运动等因素的存在,随时间推移可靠度会下降。因此也就有了软件可靠性设计的一个特定规律和注意事项。

既然需要通过时间推移,通过不断改进,软件可靠性得到提升。那么软件的可维护性就是一个大问题了。这也是为什么软件工程管理方面特别关注软件文档、注释的原因了。但做这些要求的人只是人云亦云,并不理解如此做法的真正动机。至于注释如何去做、变量如何命名、软件配置管理如何操作,这里面既有很常规的方法,也有一些我们司空见惯然而是错误的做法。信手举上几个值得注意的细节供参考。

变量定义时宜将变量类型的变量名程中体现于其中;如AD_result_int、Cal_result_float等。这样为的好检查,防止数据类型的强制转换或强制赋值时出现数据类型的错误;

注释要充分;

代码的布局风格宜统一,便于阅读查找;

不可出现非受控的default流程,所有数值和变量,不论是调用函数时赋予的、读取接口读进来的、还是中间变量计算出来的,在应用前都宜作数据有效性的判断,并对判定的所有可能结果均做受控的对应处理。

… …

关于软件可维护性编程方法方面的文章资料在网上是铺天盖地,不予赘述,综合采用之即可。很多文章把软件可维护性编程规范推荐做成企业的嵌入式软件可靠性设计规范,实在是有点以偏概全,有失偏颇的,用一句娱乐圈的话来说,“爱情是生活的重要内容,但它不是生活的全部”,软件可维护性编程方法亦然。

软件代码在执行中容易出现的下一个问题是跑飞,程序指针受到干扰,跳转到了一个非受控位置,执行了不该执行的代码。如果执行了不该执行的代码,如果在程序中加入了足够的变量判断、读值判断、状态检测判断等,那倒还好了,后果也不会太严重,甚至最终还是可能自己跑回来的。但有一种跑飞是比较可怕的,一般我们在ROM中存放的程序目标代码是1-3字节的指令,就是最多3条字段的目标码组成了执行动作,如果程序指针跑飞到了某个3字节指令的第2个字节上的时候,执行的后果是什么,可就真的没人知道了,即使在程序上作了足够的数据判错、逻辑跳转的防范措施,结果也不会好。而且ROM一般是不可能全部都被程序代码填满的,总有富余空间,富余空间中的默认内容是啥,这些默认字节是否也会导致一些操作呢?单片机中的默认空间是0FFH,DSP的我没查过,大家有兴趣查一下,跳到这些字段里,也是容易出麻烦的。

好了,不再罗嗦,直接给出解决方法吧,就是每隔一段程序代码或控制区域,就人为放置上几个NOP指令,在NOP指令后放置一个长跳转的ERR处理程序。注意NOP最少放置3个,这样任何的跑飞最多只能占用2个NOP,第三个NOP一样还是能把程序代码揪回来,揪回来后就执行ERR处理程序。

如果碰到安全性、可靠性等级要求比较高的程序,推荐的处理方法可以采用热备份的处理方法,即用两段代码同时执行同一个功能,执行的结果进行对比,如果一致则放行通过,如果结果不一致,咋处理就看您的喽。但是… …国人有的是办法,为了图省事,你领导不是要求我编热备份程序吗,那好,我就把原来的代码复制一遍,重新插入到某个地方,您这和明朝时代冯保太监(还是严嵩、张居正阿?拿不准了,大家有兴趣的翻看《明朝那些事儿》查阅下)玩的没啥两样,自己写奏章,自己给自己审批奏章。既然是备份就是为了防止一个人出问题,那最好的办法自然是不同的人来编这段,如果原理计算方法上也不同,数据采集通道也不同,那就过年带娶媳妇的,好上加好了。

安全性和可靠性的编程细节注意事项还有很多,窥一斑难见全豹呵,诸位仁兄一起努力钻研了。

4、数据、变量

变量的定义是为的避免各种混淆,同一程序内数据和数据的混淆、不同人读程序时对变量理解上出现的二义性、视觉效果上容易出现的错误(字母的“o”和数字的“0”,字母的“l”和数字的“1”)。这里要遵循一个“要么相同,要么迥异”的基本规则,这条规则在很多的领域都有应用,用的最绝的是朱元璋,对待贪官,要么不理你,自觉点您贪差不多了就收手吧,您自己不收手的话,做的过了直接就杀,株连几族,所以在明朝,朱元璋是杀人最多的皇帝;在结构的防呆性设计上,接插件的选型也是如此,如果一个乳白色和一个浅灰色的同类接插件,最好的选择是有很直观的视觉差异或结构的差异,或者干脆就是相同的,相同须基于一个前提,互换性要好。

用显意的符号来命名变量和语句标号。标识符的命名有明确含义,且是完整单词或易理解的缩写。短单词通过去掉“元音”形成缩写;长单词取头几个字母形成缩写;一些单词有公认的缩写。如:

Temp — tmp;

Flag — f.l.g;(*注:请去年中间的.号)

Statistic — stat;

Increment — inc;

Message — msg。

特殊约定或缩写,要有注释说明。在源文件开始处,对使用的缩写或约定注释说明。自己特有的命名风格,要自始至终保持一致。对于变量命名,禁止取单个字符(如i、j、k...);含义+变量类型、数据类型等,i、j、k作局部循环变量是允许的,但容易混淆的字母慎用。如int Liv_Width,L代表局部变量(Local)(g全局变量Global)、i代表数据类型(Interger)、 v代表 变量(Variable)(c常量Const)、Width代表变量的含义,这种命名方式可防止局部变量与全局变量重名。

禁用易混淆的标识符(R1和Rl,DO和D0等)来表示不同的变量、文件名和语句标号。

除了编译开关/头文件等特殊应用,避免使用_EXAMPLE_TEST_之类以下划线开始和结尾的定义。

全局变量是战略性资源,它决定了模块和模块间的耦合度,需在项目上提升到一个足够高的高度,慎用全局变量,不得不用的时候,要单独为每一个全局变量编写独立的操作模块或函数,在修改全局变量的时候,要检查是否有别的函数在调用它并且需要此数值保持稳定。

对变量代表某个特定含义的时候,尽量不要仅仅用位来代表什么,比如用某变量的第零位代表某个状态(0000 0001,其中仅用1代表某个内容,这样01H、03H、05H… 会有很多个组合都能代表这个状态);位容易受干扰被修改,信息出现错误的几率大很多。

也不要用00H、FFH等数据代表,就像我们面试一群人一样,第一个被面试人和最后一个被面试人容易被记住,00H和FFH亦然,系统默认状态是00和FF的时候较多,他们容易被复位或置位成这类数值。推荐以四位的二进制码的某个中间值为状态变量,如1001。

变量数据在应用之前宜作数据类型和数值范围的判断;

数据在存储过程中也容易出现问题,EEPROM、RAM等都有过类似的案例。数据出错时避免不了的,解决的办法是学花旗银行等美国金融企业,之所以在9.11后他们能很快恢复业务,基本没有数据方面的损失,原因何在?因为他们有异地容灾数据备份系统,知里面有两个关键词,异地、备份。我们的信息也同样,首先选择存在不同的介质中、或相同的介质但迥异的存放环境和位置下,双重备份的结局是两边不一致的时候,数据被怀疑并拒绝反映执行,但嵌入式软件很多时候是要靠数据来推动执行机构的,即使发现数据有问题也不允许行政不作为,这种情况下,作为我们也很难办,2个不同的数据,有明显问题的还好排除,都在有限范围内可如何判定哈?这种时候没办法只好三备份,少数服从多数是唯一的选择了。石头剪刀布的方式不好用,葛优的分歧终端机也不适用,就只好选择这种最原始最有效的办法了,唯一需要注意的是数据宜存放于三种不同的备份环境下,不然岂不成了你家哥俩儿,咋表决都占便宜啊。

以上仅就嵌入式软件可靠性的关注方面分了几大类,进行了基本的描述,实际应用中,需要关注的点还有很多很多,如果是准备自行制定设计规范的话,以上的思路应该也可以给与一些启迪了。

如何防错

设备的可靠性涉及多个方面:稳定的硬件、优秀的软件架构、严格的测试以及市场和时间的检验等等。这里着重谈一下对嵌入式软件可靠性设计的一些理解,通过一定的技巧和方法提高软件可靠性。这里所说的嵌入式设备,是指使用单片机、ARM7、Cortex-M0,M3之类为核心的测控或工控系统。

 

嵌入式软件可靠性设计应该从防错、判错和容错三方面进行考虑. 此外,还需理解自己所使用的编译器特性。  

此文属抛砖引玉.     

1.防错

良好的软件架构、清晰的代码结构、掌握硬件、深入理解C语言是防错的要点,这里只谈一下C语言。

“人的思维和经验积累对软件可靠性有很大影响"。C语言诡异且有种种陷阱和缺陷,需要程序员多年历练才能达到较为完善的地步。“软件的质量是由程序员的质量以及他们相互之间的协作决定的”。因此,作者认为防错的重点是要考虑人的因素。

“深入一门语言编程,不要浮于表面”。软件的可靠性,与你理解的语言深度密切相关,嵌入式C更是如此。除了语言,作者认为嵌入式开发还必须深入理解编译器。

本节将对C语言的陷阱和缺陷做初步探讨。

1.1 处处皆陷阱

最初开始编程时,除了英文标点被误写成中文标点外,可能被大家普遍遇到的是将比较运算符==误写成赋值运算符=,代码如下所示:

           

   if(x=5) { … }

这里本意是比较变量x是否等于常量5,但是误将’==’写成了’=’,if语句恒为真。如果在逻辑判断表达式中出现赋值运算符,现在的大多数编译器会给出警告信息。并非所有程序员都会注意到这类警告,因此有经验的程序员使用下面的代码来避免此类错误:

        

      if(5==x) { … }

将常量放在变量x的左边,即使程序员误将’==’写成了’=’,编译器会产生一个任谁也不能无视的语法错误信息:不可给常量赋值!

 

+=与=+、-=与=-也是容易写混的。复合赋值运算符(+=、*=等等)虽然可以使表达式更加简洁并有可能产生更高效的机器代码,但某些复合赋值运算符也会给程序带来隐含Bug,如下所示代码:

          

    tmp=+1;

该代码本意是想表达tmp=tmp+1,但是将复合赋值运算符+=误写成=+:将正整数常量1赋值给变量tmp。编译器会欣然接受这类代码,连警告都不会产生。

如果你能在调试阶段就发现这个Bug,你真应该庆祝一下,否则这很可能会成为一个重大隐含Bug,且不易被察觉。

 

-=与=-也是同样道理。与之类似的还有逻辑与&&和位与&、逻辑或||和位或|、逻辑非!和位取反~。此外字母l和数字1、字母O和数字0也易混淆,这种情况可借助编译器来纠正。

       

很多的软件BUG自于输入错误。在Google上搜索的时候,有些结果列表项中带有一条警告,表明Google认为它带有恶意代码。如果你在2009年1月31日一大早使用Google搜索的话,你就会看到,在那天早晨55分钟的时间内,Google的搜索结果标明每个站点对你的PC都是有害的。这涉及到整个Internet上的所有站点,包括Google自己的所有站点和服务。Google的恶意软件检测功能通过在一个已知攻击者的列表上查找站点,从而识别出危险站点。在1月31日早晨,对这个列表的更新意外地包含了一条斜杠(“/”)。所有的URL都包含一条斜杠,并且,反恶意软件功能把这条斜杠理解为所有的URL都是可疑的,因此,它愉快地对搜索结果中的每个站点都添加一条警告。很少见到如此简单的一个输入错误带来的结果如此奇怪且影响如此广泛,但程序就是这样,容不得一丝疏忽。

 

数组常常也是引起程序不稳定的重要因素,C语言数组的迷惑性与数组下标从0开始密不可分,你可以定义int a[30],但是你绝不可以使用数组元素a[30],除非你自己明确知道在做什么。

 

switch…case语句可以很方便的实现多分支结构,但要注意在合适的位置添加break关键字。程序员往往容易漏加break从而引起顺序执行多个case语句,这也许是C的一个缺陷之处。对于switch…case语句,从概率论上说,绝大多数程序一次只需执行一个匹配的case语句,而每一个这样的case语句后都必须跟一个break。去复杂化大概率事件,这多少有些不合常情。

 

break关键字用于跳出最近的那层循环语句或者switch语句,但程序员往往不够重视这一点。

1990年1月15日,AT&T电话网络位于纽约的一台交换机当机并且重启,引起它邻近交换机瘫痪,由此及彼,一个连着一个,很快,114台交换机每六秒当机重启一次,六万人九小时内不能打长途电话。当时的解决方式:工程师重装了以前的软件版本。事后的事故调查发现,这是break关键字误用造成的。《C专家编程》提供了一个简化版的问题源码:

network code()
{
       switch(line) {
              case  THING1:
                     doit1();
              break;
              case  THING2:
                     if(x==STUFF) {
                            do_first_stuff();
                            if(y==OTHER_STUFF)
                                   break;
                            do_later_stuff();
                     } /*代码的意图是跳转到这里… …*/
                     initialize_modes_pointer();
              break;
              default:
                     processing();
       }/*… …但事实上跳到了这里。*/
       use_modes_pointer();/*致使modes_pointer未初始化*/
}

那个程序员希望从if语句跳出,但他却忘记了break关键字实际上跳出最近的那层循环语句或者switch语句。现在它跳出了switch语句,执行了use_modes_pointer()函数。但必要的初始化工作并未完成,为将来程序的失败埋下了伏笔。

 

将一个整形常量赋值给变量,代码如下所示:

        

      int a=34, b=034;

变量a和b相等吗?答案是不相等的。我们知道,16进制常量以’0x’为前缀,10进制常量不需要前缀,那么8进制呢?它与10进制和16进制表示方法都不相通,它以数字’0’为前缀,这多少有点奇葩:三种进制的表示方法完全不相通。如果8进制也像16进制那样以数字和字母表示前缀的话,或许更有利于减少软件Bug,毕竟你使用8进制的次数可能都不会有误使用的次数多!下面展示一个误用8进制的例子,最后一个数组元素赋值错误:


a[0]=106;              /*十进制数106*/
a[1]=112;      /*十进制数112*/
a[2]=052;              /*实际为十进制数42,本意为十进制52*/

指针的加减运算是特殊的。下面的代码运行在32位ARM架构上,执行之后,a和p的值分别是多少?

              int a=1;
              int *p=(int*)0x00001000;
              a=a+1;
              p=p+1;

对于a的值很容判断出结果为2,但是p的结果却是0x00001004。指针p加1后,p的值增加了4,这是为什么呢?原因是指针做加减运算时是以指针的数据类型为单位。p+1实际上是p+1*sizeof(int)。不理解这一点,在使用指针直接操作数据时极易犯错。比如下面对连续RAM初始化零操作代码:

unsigned int *pRAMaddr;                   //定义地址指针变量
for(pRAMaddr=StartAddr;pRAMaddr

由于pRAMaddr是一个指针变量,所以pRAMaddr+=4代码其实使pRAMaddr偏移了4*sizeof(int)=16个字节,所以每执行一次for循环,会使变量pRAMaddr偏移16个字节空间,但只有4字节空间被初始化为零。其它的12字节数据的内容,在大多数架构处理器中都会是随机数。

 

对于sizeof(),这里强调两点,第一它是一个关键字,而不是函数,并且它默认返回无符号整形数据(要记住是无符号);第二,使用sizeof获取数组长度时,不要对指针应用sizeof操作符,比如下面的例子:

void ClearRAM(char array[])
{
    int i ;
    for(i=0;i

我们知道,对于一个数组array[20],我们使用代码sizeof(array)/sizeof(array[0])可以获得数组的元素(这里为20),但数组名和指针往往是容易混淆的,而且有且只有一种情况下是可以当做指针的,那就是数组名作为函数形参时,数组名被认为是指针。同时,它不能再兼任数组名。注意只有这种情况下,数组名才可以当做指针,但不幸的是这种情况下容易引发风险。在ClearRAM函数内,作为形参的array[]不再是数组名了,而成了指针。sizeof(array)相当于求指针变量占用的字节数,在32位系统下,该值为4,sizeof(array)/sizeof(array[0])的运算结果也为4。所以在main函数中调用ClearRAM(Fle),也只能清除数组Fle中的前四个元素了。

 

增量运算符++和减量运算符--既可以做前缀也可以做后缀。前缀和后缀的区别在于值的增加或减少这一动作发生的时间是不同的。作为前缀是先自加或自减然后做别的运算,作为后缀时,是先做运算,之后再自加或自减。许多程序员对此认识不够,就容易埋下隐患。下面的例子可以很好的解释前缀和后缀的区别。

int a=8,b=2,y;
y=a+++--b;

代码执行后,y的值是多少?

这个例子并非是挖空心思设计出来专门让你绞尽脑汁的C难题(如果你觉得自己对C细节掌握很有信心,做一些C难题检验一下是个不错的选择。那么,《The C Puzzle Book》这本书一定不要错过。),你甚至可以将这个难懂的语句作为不友好代码的反面例子。但是它也可以让你更好的理解C语言。根据运算符优先级以及编译器识别字符的贪心法原则,代码y=a+++--b;可以写成更明确的形式:

y=(a++)+(--b);

当赋值给变量y时,a的值为8,b的值为1,所以变量y的值为9;赋值完成后,变量a自加,a的值变为9,千万不要以为y的值为10。这条赋值语句相当于下面的两条语句:

y=a+(--b);
a=a+1;

1.2 玩具般的编译器语义检查

 

为了更简单的设计编译器,目前几乎所有编译器的语义检查都比较弱小,加之为了获得更快的执行效率,C语言被设计的足够灵活且几乎不进行任何运行时检查,比如数组越界、指针是否合法、运算结果是否溢出等等。

C语言足够灵活,对于一个数组a[30],它允许使用像a[-1]这样的形式来快速获取数组首元素所在地址前面的数据;允许将一个常数强制转换为函数指针,使用代码(*((void(*)())0))()来调用位于0地址的函数。C语言给了程序员足够的自由,但也由程序员承担滥用自由带来的责任。下面的两个例子都是死循环,如果在不常用分支中出现类似代码,将会造成看似莫名其妙的死机或者重启。

a.     unsigned char i;                    b.   unsigned chari;

       for(i=0;i<256;i++)  {… }                 for(i=10;i>=0;i--) { … }

对于无符号char类型,表示的范围为0~255,所以无符号char类型变量i永远小于256(第一个for循环无限执行),永远大于等于0(第二个for循环无线执行)。需要说明的是,赋值代码i=256是被C语言允许的,即使这个初值已经超出了变量i可以表示的范围。C语言会千方百计的为程序员创造出错的机会,可见一斑。

      

假如你在if语句后误加了一个分号改变了程序逻辑,编译器也会很配合的帮忙掩盖,甚至连警告都不提示。代码如下:

       if(a>b);          //这里误加了一个分号
       a=b;               //这句代码一直被执行

不但如此,编译器还会忽略掉多余的空格符和换行符,就像下面的代码也不会给出足够提示:

       if(n<3)
       return    //这里少加了一个分号
       logrec.data=x[0];
       logrec.time=x[1];
       logrec.code=x[2];

这段代码的本意是n<3时程序直接返回,由于程序员的失误,return少了一个结束分号。编译器将它翻译成返回表达式logrec.data=x[0]的结果,return后面即使是一个表达式也是C语言允许的。这样当n>=3时,表达式logrec.data=x[0];就不会被执行,给程序埋下了隐患。

可以毫不客气的说,弱小的编译器语义检查在很大程度上纵容了不可靠代码可以肆无忌惮的存在。

      

上文曾提到数组常常是引起程序不稳定的重要因素,程序员往往不经意间就会写数组越界。一位同事的代码在硬件上运行,一段时间后就会发现LCD显示屏上的一个数字不正常的被改变。经过一段时间的调试,问题被定位到下面的一段代码中:   

      int SensorData[30];
       …for(i=30;i>0;i--)
       {
              SensorData[i]=…;
              …
       }

这里声明了拥有30个元素的数组,不幸的是for循环代码中误用了本不存在的数组元素SensorData[30],但C语言却默许这么使用,并欣然的按照代码改变了数组元素SensorData[30]所在位置的值, SensorData[30]所在的位置原本是一个LCD显示变量,这正是显示屏上的那个值不正常被改变的原因。真庆幸这么轻而易举的发现了这个Bug。

其实很多编译器会对上述代码产生一个警告:赋值超出数组界限。但并非所有程序员都对编译器警告保持足够敏感,况且,编译器也并不能检查出数组越界的所有情况。举一个例子,你在模块A中定义数组:

int SensorData[30];

在模块B中引用该数组,但由于你引用代码并不规范,这里没有显示声明数组大小,但编译器也允许这么做:

extern int SensorData[];

       如果在模块B中存在和上面一样的代码:

       for(i=30;i>0;i--)
       {
              SensorData[i]=…;
              …
       }

这次,编译器不会给出警告信息,因为编译器压根就不知道数组的元素个数。所以,当一个数组声明为具有外部链接,它的大小应该显式声明。

再举一个编译器检查不出数组越界的例子。函数func()的形参是一个数组形式,函数代码简化如下所示:

char * func(char SensorData[30])
{
              unsignedint i;
              for(i=30;i>0;i--)
              {
                     SensorData[i]=…;
                     …
              }
}

这个给SensorData[30]赋初值的语句,编译器也是不给任何警告的。实际上,编译器是将数组名Sensor隐含的转化为指向数组第一个元素的指针,函数体是使用指针的形式来访问数组的,它当然也不会知道数组元素的个数了。造成这种局面的原因之一是C编译器的作者们认为指针代替数组可以提高程序效率,而且,还可以简化编译器的复杂度。

指针和数组是容易给程序造成混乱的,我们有必要仔细的区分它们的不同。其实换一个角度想想,它们也是容易区分的:可以将数组名等同于指针的情况有且只有一处,就是上面例子提到的数组作为函数形参时。其它时候,数组名是数组名,指针是指针。

下面的例子编译器同样检查不出数组越界。

我们常常用数组来缓存通讯中的一帧数据。在通讯中断中将接收的数据保存到数组中,直到一帧数据完全接收后再进行处理。即使定义的数组长度足够长,接收数据的过程中也可能发生数组越界,特别是干扰严重时。这是由于外界的干扰破坏了数据帧的某些位,对一帧的数据长度判断错误,接收的数据超出数组范围,多余的数据改写与数组相邻的变量,造成系统崩溃。由于中断事件的异步性,这类数组越界编译器无法检查到。

如果局部数组越界,可能引发ARM架构硬件异常。同事的一个设备用于接收无线传感器的数据,一次软件升级后,发现接收设备工作一段时间后会死机。调试表明ARM7处理器发生了硬件异常,异常处理代码是一段死循环(死机的直接原因)。接收设备有一个硬件模块用于接收无线传感器的整包数据并存在自己的硬件缓冲区中,当一帧数据接收完成后,使用外部中断通知设备取数据,外部中断服务程序精简后如下所示:

       __irq ExintHandler(void)
       {
              unsignedchar DataBuf[50];
              GetData(DataBug);        //从硬件缓冲区取一帧数据
              …
       }

由于存在多个无线传感器近乎同时发送数据的可能加之GetData()函数保护力度不够,数组DataBuf在取数据过程中发生越界。由于数组DataBuf为局部变量,被分配在堆栈中,同在此堆栈中的还有中断发生时的运行环境以及中断返回地址。溢出的数据将这些数据破坏掉,中断返回时PC指针可能变成一个不合法值,硬件异常由此产生。

如果我们精心设计溢出部分的数据,化数据为指令,就可以利用数组越界来修改PC指针的值,使之指向我们希望执行的代码。1988年,第一个网络蠕虫在一天之内感染了2000到6000台计算机,这个蠕虫程序利用的正是一个标准输入库函数的数组越界Bug。起因是一个标准输入输出库函数gets(),原来设计为从数据流中获取一段文本,遗憾的是,gets()函数没有规定输入文本的长度。gets()函数内部定义了一个500字节的数组,攻击者发送了大于500字节的数据,利用溢出的数据修改了堆栈中的PC指针,从而获取了系统权限。

      

一个程序模块通常由两个文件组成,源文件和头文件。如果你在源文件定义变量:

unsigned int a;

并在头文件中声明该变量:extern unsigned long a;

编译器会提示一个语法错误:变量’a’声明类型不一致。但如果你在源文件定义变量:

volatile unsigned int a,

在头文件中声明变量:extern unsigned int a;     /*缺少volatile限定符*/

       

编译器却不会给出错误信息(有些编译器仅给出一条警告)。这里volatile属于类型限定符,另一个常见的类型限定符是const关键字。限定符volatile在嵌入式软件中至关重要,用来告诉编译器不要优化它修饰的变量。这里举一个刻意构造出的例子,因为现实中的volatile使用Bug大都隐含且难以理解。

       

在模块A的源文件中,定义变量:

volatile unsigned int TimerCount=0;

该变量用来在一个定时器服务程序中进行软件计时:

              TimerCount++;                           //读取IO端口1的值

       在模块A的头文件中,声明变量:

extern unsigned int TimerCount;   //这里漏掉了类型限定符volatile

       在模块B中,要使用TimerCount变量进行精确的软件延时:

              #include “...A.h”   //首先包含模块A的头文件
              …
              TimerCount=0;
              while(TimerCount>=TIMER_VALUE);      //延时一段时间
              …

实际上,这是一个死循环。由于模块A头文件中声明变量TimerCount时漏掉了volatile限定符,在模块B中,变量TimerCount是被当作unsigned int类型变量。由于寄存器速度远快于RAM,编译器在使用非volatile限定变量时是先将变量从RAM中拷贝到寄存器中,如果同一个代码块再次用到该变量,就不再从RAM中拷贝数据而是直接使用之前寄存器备份值。代码while(TimerCount>=TIMER_VALUE)中,变量TimerCount仅第一次执行时被使用,之后都是使用的寄存器备份值,而这个寄存器值一直为0,所以程序无限循环。下面的流程图说明了程序使用限定符volatile和不使用volatile的执行过程。


       

ARM架构下的编译器会频繁的使用堆栈,堆栈用于存储函数的返回值、AAPCS规定的必须保护的寄存器以及局部变量,包括局部数组、结构体、联合体和C++的类。从堆栈中分配的局部变量的初值是不确定的,因此需要运行时显式初始化该变量。一旦离开局部变量的作用域,这个变量立即被释放,其它代码也就可以使用它,因此堆栈中的一个内存位置可能对应整个程序的多个变量。

       

局部变量必须显式初始化,除非你确定知道你要做什么。下面的代码得到的温度值跟预期会有很大差别,因为在使用局部变量sum时,并不能保证它的初值为0。编译器会在第一次运行时清零堆栈区域,这加重了此类Bug的隐蔽性。

       unsigned intGetTempValue(void)
       {
              unsigned int sum;                       //定义局部变量,保存总值
              for(i=0;i<10;i++)
              {
                     sum+=CollectTemp();               //函数CollectTemp可以得到当前的温度值
       }
       return (sum/10);
       }

由于一旦程序离开局部变量的作用域即被释放,所以下面代码返回指向局部变量的指针是没有实际意义的,该指针指向的区域可能会被其它程序使用,其值会被改变。

       char * GetData(void)
       {
              char buffer[100];                 //局部数组
              …
              return buffer;
       }

让人欣慰的是,现在越来越多的编译器意识到了语义检查的重要性,编译器的语义检查也越来越强大,比如著名的Keil MDK编译器在其 V4.47或以上版本中增加了动态语法检查并加强了语义检查,可以友好的提示更多警告信息。

1.3 不合理的优先级

C语言有32个关键字,却有34个运算符。要记住所有运算符的优先级是困难的。不合理的#define会加重优先级问题,让问题变得更加隐蔽。

       #define READSDA       IO0PIN&(1<<11)            //定义宏,读IO口p0.11的端口状态
              //判断端口p0.11是否为高电平
       if(READSDA==(1<<11))  
       {  
              …
       }

       编译器在编译后将宏带入,原if语句变为:

       if(IO0PIN&(1<<11) ==(1<<11))
       {
                     …
       }

运算符'=='的优先级是大于'&'的,代码IO0PIN&(1<<11) ==(1<<11))等效为IO0PIN&0x00000001:判断端口P0.0是否为高电平,这与原意相差甚远。

为了制造更多的软件Bug,C语言的运算符当然不会只止步于数目繁多。在此基础上,按照常规方式使用时,可能引起误会的运算符更是比比皆是!如下表所示:

常被误会的

优先级

表达式

常被误认为:

其实是:

取值运算符*与自增运算符++优先级相同,但它们是自右向左结合

*p++

(*p)++

*(p++)

成员选择运算符.高于取值运算符*

*p.f

(*p).f

*(p.f)

数组下标运算符[]优先级高于取值运算符*

int *ap[]

int (*ap)[]

ap为数组指针

int *(ap[])

ap为指针数组

函数()优先级高于取值运算符*

int * fp()

int (*fp)()

fp为函数指针

int * (fp())

fp为函数,返回指针

等于==和不等于!=运算符优先级高于位操作运算符&、^ 和 |

val & mask != 0

(val & mask)!= 0

val &(mask != 0)

等于==和不等于!=运算符高于赋值运算符=

c=getchar()!=EOF

(c=getchar())!=EOF

c=(getchar()!=EOF)

算数运算符+和-优先级高于移位运算符<<和>>

msb<<4+lsb

(msb<<4)+lsb

msb<<(4+lsb)


1.4 隐式转换和强制转换

这又是C语言的一大诡异之处,它造成的危害程度与数组和指针有的一拼。语句或表达式通常应该只使用一种类型的变量和常量。然而,如果你混合使用类型,C使用一个规则集合来自动完成类型转换。这可能很方便,但也很危险。

a.当出现在表达式里时,有符号和无符号的char和short类型都将自动被转换为int类型,在需要的情况下,将自动被转换为unsigned int(在short和int具有相同大小时)。这称为类型提升。提升在算数运算中通常不会有什么大的坏处,但如果位运算符 ~ 和 << 应用在基本类型为unsigned char或unsigned short 的操作数,结果应该立即强制转换为unsigned char或者unsigned short类型(取决于操作时使用的类型)。

       uint8_t  port =0x5aU;
       uint8_t  result_8;
       result_8= (~port) >> 4;

假如我们不了解表达式里的类型提升,认为在运算过程中变量port一直是unsigned char类型的。我们来看一下运算过程:~port结果为0xa5,0xa5>>4结果为0x0a,这是我们期望的值。但实际上,result_8的结果却是0xfa!在ARM结构下,int类型为32位。变量port在运算前被提升为int类型:~port结果为0xffffffa5,0xa5>>4结果为0x0ffffffa,赋值给变量result_8,发生类型截断(这也是隐式的!),result_8=0xfa。经过这么诡异的隐式转换,结果跟我们期望的值,已经大相径庭!正确的表达式语句应该为:

       result_8=(unsigned char) (~port) >> 4;             /*强制转换*/

b.在包含两种数据类型的任何运算里,两个值都会被转换成两种类型里较高的级别。类型级别从高到低的顺序是long double、double、float、unsigned long long、long long、unsigned long、long、unsigned int、int。这种类型提升通常都是件好事,但往往有很多程序员不能真正理解这句话,从而做一些想当然的事情,比如下面的例子,int类型表示16位。

       uint16_t  u16a = 40000;            /* 16位无符号变量*/
       uint16_t  u16b= 30000;          /*16位无符号变量*/
       uint32_t  u32x;                        /*32位无符号变量 */
       uint32_t  u32y;
       u32x = u16a +u16b;                /* u32x = 70000还是4464 ? */
       u32y =(uint32_t)(u16a + u16b);   /* u32y = 70000 还是4464 ? */

u32x和u32y的结果都是4464(70000%65536)!不要认为表达式中有一个高类别uint32_t类型变量,编译器都会帮你把所有其他低类别都提升到uint32_t类型。正确的书写方式:

                    u32x = (uint32_t)u16a +(uint32_t)u16b;或者:
                    u32x = (uint32_t)u16a + u16b;

后一种写法在本表达式中是正确的,但是在其它表达式中不一定正确,比如:

                   uint16_t u16a,u16b,u16c;
                   uint32_t  u32x;
                   u32x= u16a + u16b + (uint32_t)u16c;/*错误写法,u16a+ u16b仍可能溢出*/

c.在赋值语句里,计算的最后结果被转换成将要被赋予值得那个变量的类型。这一过程可能导致类型提升也可能导致类型降级。降级可能会导致问题。比如将运算结果为321的值赋值给8位char类型变量。程序必须对运算时的数据溢出做合理的处理。

很多其他语言,像Pascal语言(好笑的是C语言设计者之一曾撰文狠狠批评过Pascal语言),都不允许混合使用类型,但C语言不会限制你的自由,即便这经常引起Bug。

d.当作为函数的参数被传递时,char和short会被转换为int,float会被转换为double。

e.C语言支持强制类型转换,如果你必须要进行强制类型转换时,要确保你对类型转换有足够了解:

  • 并非所有强制类型转换都是由风险的,把一个整数值转换为一种具有相同符号的更宽类型时,是绝对安全的。

  • 精度高的类型强制转换为精度低的类型时,通过丢弃适当数量的最高有效位来获取结果,也就是说会发生数据截断,并且可能改变数据的符号位。

  •  精度低的类型强制转换为精度高的类型时,如果两种类型具有相同的符号,那么没什么问题;需要注意的是负的有符号精度低类型强制转换为无符号精度高类型时,会不直观的执行符号扩展,例如:

unsigned int bob;
signed char fred = -1;
 
bob=(unsigned int )fred;              /*发生符号扩展,此时bob为0xFFFFFFFF*/

一些编程建议:

  •  深入理解嵌入式C语言以及编译器

  •  细致、谨慎的编程

  • 使用好的风格和合理的设计

  • 不要仓促编写代码,写每一行的代码时都要三思而后行:可能会出现什么样的错误?是否考虑了所有的逻辑分支?

  • 打开编译器所有警告开关

  • 使用静态分析工具分析代码

  • 安全的读写数据(检查所有数组边界…)

  • 检查指针的合法性

  • 检查函数入口参数合法性

  • 检查所有返回值

  • 在声明变量位置初始化所有变量

  • 合理的使用括号

  • 谨慎的进行强制转换

  • 使用好的诊断信息日志和工具

-END-

整理文章为传播相关技术,版权归原作者所有 |

| 如有侵权,请联系删除 |

IT技术分享社区

个人博客网站:https://programmerblog.xyz

文章推荐程序员效率:画流程图常用的工具程序员效率:整理常用的在线笔记软件远程办公:常用的远程协助软件,你都知道吗?51单片机程序下载、ISP及串口基础知识硬件:断路器、接触器、继电器基础知识

你可能感兴趣的:(编译器,指针,powerdesigner,redirect,hashtable)