锐单电子商城 , 一站式电子元器件采购平台!
  • 电话:400-990-0325

如何编写更高层次嵌入式C代码?

时间:2022-12-05 12:30:00 bca73sl072传感器数字型传感器e3x

如何编写更高层次嵌入式C代码?

    • 1. 简介
    • 2. C语言特性
      • 2.到处都是陷阱
        • 2.1.1 无心之过
        • 2.1.2 数组下标
        • 2.1.3 容易被忽视break关键字
        • 2.1.4 意想不到的八进制
        • 2.1.5 指针加减运算
        • 2.1.6 关键字sizeof
        • 2.1.7 增量运算符增量运算符
        • 2.1.8 逻辑与’&&和逻辑或||’的陷阱
        • 2.1.9 填充结构体
      • 2.2 优先级不的优先级
      • 2.3 隐式转换
    • 3. 编译器
      • 3.1 不能简单地认为是工具
      • 3.2 不能依赖编译器的语义检查
        • 3.2.1 莫名的死机
        • 3.2.2 不起眼的变化
        • 3.2.3 数组越界难查
        • 3.2.4 神奇的volatile
        • 3.2.5 局部变量
        • 3.2.6 使用外部工具
      • 3.3 你认为有意义的代码可能不正确
        • 3.3.1 常见的未定义行为
        • 3.3.2 如何避免C语言未定义行为?
      • 3.4 了解你的编译器
        • 3.4.1 一些编译知识
        • 3.4.2 初始全局变量和静态变量的初始值在哪里?
        • 3.4.3 编译器将C代码中使用的变量分配到RAM的哪里?
        • 3.4.4 默认情况下,栈被分配到RAM哪里?
        • 3.4.5 有多少RAM会初始化吗?
        • 3.4.6 MDK编译器如何设置非零初始变量?
    • 4. 防御性编程
      • 4.1 具有形参函数的,需要判断传递的实参是否合法。
      • 4.2 仔细检查函数的返回值
      • 4.3 防止指针越界
      • 4.4 防止数组越界
      • 4.5 数学算数运算
        • 4.5.1 除法测除数为零是否可靠?
        • 4.5.2 检测运算溢出
        • 4.5.3 检测移位
      • 4.6 如果有硬件看门狗,用它
      • 4.7 关键数据存储多个备份,数据采用表决法
      • 4.8 备份存储非易失性存储器
      • 4.9 软件
      • 4.10 通信
      • 4.11 检测和确认开关量输入
      • 4.12 开关量输出
      • 4.13 保存和恢复初始化信息
      • 4.14 陷阱
      • 4.15 阻塞处理
    • 5. 测试,再测试
      • 5.1 使用硬件调试器进行测试
      • 5.2 有些缺陷很难纠缠
        • 5.2.1 调试函数简单易用
        • 5.2.2 进一步封装调试函数
    • 6. 编程思想
      • 6.1 编程风格
        • 6.1.1 整洁的样式
        • 6.1.2 清晰的命名
        • 6.1.3 恰当的注释
      • 6.2 数据结构
    • 7. 总结和阅读书目
      • 7.1 关于语言特征
      • 7.2 关于编译器
      • 7.3 关于防御编程
      • 7.4 关于编程思想

摘要:本文首先分析了C语言的陷阱和缺陷,总结了容易出错的地方;分析了编译器语义检查的不足,并采取了预防措施Keil MDK以编译器为例,介绍了编译器的特点、对未定义行为的处理以及一些高级应用;在此基础上,介绍了防御性编程的概念,提出了编程过程中就应该防范于未然的多种措施;提出了测试对编写优质嵌入式程序的重要作用以及常用测试方法;最后,本文试图以更高的层次看待编程,讨论一些通用的编程思想。

1. 简介

市场上有很多书介绍C语言和编程方法,但很少介绍如何编写高质量的嵌入式C程序,特别是单片机,ARM7、Cortex-M这种微控制器上高质量的C程序编写方法几乎是空白的。本文的目标是使用单片机,ARM7、Cortex-M3这种微控制器的底层编程人员。

编写高质量的嵌入式C程序并不容易。它与设计师的思维和经验积累密切相关。嵌入式C程序员不仅需要熟悉硬件的特性和缺陷,还需要深入语言编程,而不是浮在表面上。为了更方便地操作硬件,还需要对编译器有深入的了解。

本文将讨论如何从语言特征、编译器、防御编程、测试和编程思想等方面编写高质量的嵌入式C程序。与许多杂志和书籍不同,本文提供了大量的真实例子、代码段和参考书目,不仅介绍了该做什么,而且还关注了如何做,以及为什么要这样做。编写高质量的嵌入式C程序涉及广泛的领域,需要程序员长期的经验积累。本文希望缩短这个过程。

2. C语言特性

语言是编程的基石,C语言奇怪,有各种各样的陷阱和缺陷,需要程序员多年的经验才能达到更完美的水平。虽然有许多书籍、杂志和专题讨论了C语言的陷阱和缺陷,但这并不影响本节再次讨论。总有大量的初学者落在这些陷阱和缺陷上,民用设备、工业设备甚至航天设备也不例外。本节将结合具体例子重新审视,希望引起足够的重视。对C语言特性的深入理解是编写高质量嵌入式C程序的基础。

2.到处都是陷阱

2.1.1 无心之过

  1. “=”和”==”

将比较运算符==误写成赋值运算符=大多数人可能都遇到过这样的代码:

1. if(x=5) 2. { 
         3.     ///其他代码  4. } 

代码的初衷是比较变量x是否等于常量5,但误将=="写成了 =,if句子恒为真。

如果赋值运算符出现在逻辑判断表达式中,大多数编译器现在给出警告信息。如keil MDK会给出警告提示:“warning: #187-D: use of “=” where"==" may have been intended”,但并非所有程序员都会注意到这类警告,因此有经验的程序员使用下面的代码来避免此类错误:

1. if(5==x)
2. { 
        
3.     //其它代码 
4. }

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

  1. 复合赋值运算符

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

1. tmp=+1;

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

如果你能在调试阶段就发现这个Bug,真应该庆祝一下,否则这很可能会成为一个重大隐含Bug,且不易被察觉。复合赋值运算符”-=”也有类似问题存在。

  1. 其它容易误写
  • 使用了中文标点
  • 头文件声明语句最后忘记结束分号
  • 逻辑与&&和位与&、逻辑或||和位或|、逻辑非!和位取反~
  • 字母l和数字1、字母O和数字0

这些误写其实容易被编译器检测出,只需要关注编译器对此的提示信息,就能很快解决。

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

2.1.2 数组下标

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

2.1.3 容易被忽略的break关键字

  1. 不能漏加的break

switch…case语句可以很方便的实现多分支结构,但要注意在合适的位置添加break关键字。程序员往往容易漏加break从而引起顺序执行多个case语句,这也许是C的一个缺陷之处。

对于switch…case语句,从概率论上说,绝大多数程序一次只需执行一个匹配的case语句,而每一个这样的case语句后都必须跟一个break。去复杂化大概率事件,这多少有些不合常情。

  1. 不能乱加的break

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

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

1. network code()  
2. { 
        
3.     switch(line)
4.      { 
        
5.         case  THING1:
6.      { 
        
7.             doit1();
8.          } break;
9.         case  THING2:
10.      { 
        
11.             if(x==STUFF)
12.              { 
        
13.                 do_first_stuff();
14.                 if(y==OTHER_STUFF)
15.                     break;
16.                 do_later_stuff();
17.             }  /*代码的意图是跳转到这里… …*/  
18.             initialize_modes_pointer();
19.      } break;
20.         default :
21.             processing();
22.     } /*… …但事实上跳到了这里。*/  
23.     use_modes_pointer(); /*致使modes_pointer未初始化*/  
24. }

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

2.1.4 意想不到的八进制

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

1. int a=34, b=034; 

变量a和b相等吗?

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

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

2.1.5 指针加减运算

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

1. int a=1;
2. int *p=(int *)0x00001000;
3. a=a+1;
4. p=p+1;

对于a的值很容判断出结果为2,但是p的结果却是0x00001004。指针p加1后,p的值增加了4,这是为什么呢?原因是指针做加减运算时是以指针的数据类型为单位。p+1实际上是按照公式p+1*sizeof(int)来计算的。不理解这一点,在使用指针直接操作数据时极易犯错。

某项目使用下面代码对连续RAM初始化零操作,但运行发现有些RAM并没有被真正清零。

1. unsigned int *pRAMaddr;         //定义地址指针变量 
2. for(pRAMaddr=StartAddr;pRAMaddr<EndAddr;pRAMaddr+=4)
3. { 
        
4.      *pRAMaddr=0x00000000;   //指定RAM地址清零 
5. }

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

2.1.6 关键字sizeof

不知道有多少人最初认为sizeof是一个函数。其实它是一个关键字,其作用是返回一个对象或者类型所占的内存字节数,对绝大多数编译器而言,返回值为无符号整形数据。需要注意的是,使用sizeof获取数组长度时,不要对指针应用sizeof操作符,比如下面的例子:

1. void ClearRAM(char array[])  
2. { 
        
3.     int i ;
4.     for(i=0;i<sizeof(array)/sizeof(array[0]);i++)     //这里用法错误,array实际上是指针 
5.     { 
        
6.         array[i]=0x00;
7.     }
8. }
9.   
10. int main(void)  
11. { 
        
12.     char Fle[20];
13.       
14.     ClearRAM(Fle);          //只能清除数组Fle中的前四个元素 
15. }

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

2.1.7 增量运算符’++’和减量运算符‘–‘

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

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

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

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

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

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

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

2.1.8 逻辑与’&&’和逻辑或’||’的陷阱

为了提高系统效率,逻辑与和逻辑或操作的规定如下:如果对第一个操作数求值后就可以推断出最终结果,第二个操作数就不会进行求值!比如下面代码:

1. if((i>=0)&&(i++ <=max))
2. { 
        
3.        //其它代码 
4. }

在这个代码中,只有当i>=0时,i++才会被执行。这样,i是否自增是不够明确的,这可能会埋下隐患。逻辑或与之类似。

2.1.9 结构体的填充

结构体可能产生填充,因为对大多数处理器而言,访问按字或者半字对齐的数据速度更快,当定义结构体时,编译器为了性能优化,可能会将它们按照半字或字对齐,这样会带来填充问题。比如以下两个个结构体:

第一个结构体:

1. // 第一个结构体:
2. struct { 
          
3.     char  c;
4.     short s;
5.     int   x;
6. }str_test1;
7. 
8. // 第二个结构体:
9. struct { 
          
10.     char  c;
11.     int   x;
12.     short s;
13. }str_test2;

这两个结构体元素都是相同的变量,只是元素换了下位置,那么这两个结构体变量占用的内存大小相同吗?

其实这两个结构体变量占用的内存是不同的,对于Keil MDK编译器,默认情况下第一个结构体变量占用8个字节,第二个结构体占用12个字节,差别很大。第一个结构体变量在内存中的存储格式如图2-1所示:
在这里插入图片描述
图2-1:结构体变量1内存分布
第二个结构体变量在内存中的存储格式如图2-2所示。对比两个图可以看出MDK编译器是是怎么将数据对齐的,这其中的填充内容是之前内存中的数据,是随机的,所以不能在结构之间逐字节比较;另外,合理的排布结构体内的元素位置,可以最大限度减少填充,节省RAM。

图2-2 :结构体变量2内存分布

2.2 不可轻视的优先级

C语言有32个关键字,却有34个运算符。要记住所有运算符的优先级是困难的。稍不注意,你的代码逻辑和实际执行就会有很大出入。

比如下面将BCD码转换为十六进制数的代码:

1. result=(uTimeValue>>4)*10+uTimeValue&0x0F; 

这里uTimeValue存放的BCD码,想要转换成16进制数据,实际运行发现,如果uTimeValue的值为0x23,按照我设定的逻辑,result的值应该是0x17,但运算结果却是0x07。经过种种排查后,才发现’+’的优先级是大于’&’的,相当于(uTimeValue>>4)*10+uTimeValue与0x0F位与,结果自然与逻辑不符。符合逻辑的代码应该是:

1.result=(uTimeValue>>4)*10+(uTimeValue&0x0F); 

不合理的#define会加重优先级问题,让问题变得更加隐蔽。

1. #define READSDA IO0PIN&(1<<11)  //读IO口p0.11的端口状态 
2.
2. if(READSDA==(1<<11))            //判断端口p0.11是否为高电平 
3. { 
        
4.     //其它代码 
5. }

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

if(IO0PIN & (1<<11) == (1<<11))
{ 
        
    //其它代码 
}

运算符’==‘的优先级是大于’&'的,代码 IO0PIN&(1<<11)==(1<<11)) 等效为 IO0PIN & 0x00000001:判断端口P0.0是否为高电平,这与原意相差甚远。因此,使用宏定义的时候,最好将被定义的内容用括号括起来。

按照常规方式使用时,可能引起误会的运算符还有很多,如表2-1所示。C语言的运算符当然不会只止步于数目繁多!

有一个简便方法可以避免优先级问题:不清楚的优先级就加上”( )”,但这样至少有会带来两个问题:

  • 过多的括号影响代码的可读性,包括自己和以后的维护人员
  • 别人的代码不一定用括号来解决优先级问题,但你总要读别人的代码

无论如何,在嵌入式编程方面,该掌握的基础知识,偷巧不得。建议花一些时间,将优先级顺序以及容易出错的优先级运算符理清几遍。

2.3 隐式转换

C语言的设计理念一直被人吐槽,因为它认为C程序员完全清楚自己在做什么,其中一个证据就是隐式转换。C语言规定,**不同类型的数据(比如char和int型数据)需要转换成同一类型后,才可进行计算。**如果你混合使用类型,比如用char类型数据和int类型数据做减法,C使用一个规则集合来自动(隐式的)完成类型转换。这可能很方便,但也很危险。

这就要求我们理解这个转换规则并且能应用到程序中去!

当出现在表达式里时,有符号和无符号的char和short类型都将自动被转换为int类型,在需要的情况下,将自动被转换为unsigned int(在short和int具有相同大小时)。这称为类型提升。

提升在算数运算中通常不会有什么大的坏处,但如果位运算符 ~ 和 << 应用在基本类型为unsigned char或unsigned short 的操作数,结果应该立即强制转换为unsigned char或者unsigned short类型(取决于操作时使用的类型)。

1. uint8_t  port =0x5aU;
2. uint8_t  result_8;
3. 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。经过这么诡异的隐式转换,结果跟我们期望的值,已经大相径庭!正确的表达式语句应该为:

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

在包含两种数据类型的任何运算里,两个值都会被转换成两种类型里较高的级别。类型级别从高到低的顺序是long double、double、float、unsigned long long、long long、unsigned long、long、unsigned int、int。

这种类型提升通常都是件好事,但往往有很多程序员不能真正理解这句话,比如下面的例子(int类型表示16位)。

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

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

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

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

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

在赋值语句里,计算的最后结果被转换成将要被赋予值的那个变量的类型。这一过程可能导致类型提升也可能导致类型降级。降级可能会导致问题。比如将运算结果为321的值赋值给8位char类型变量。程序必须对运算时的数据溢出做合理的处理。很多其他语言,像Pascal(C语言设计者之一曾撰文狠狠批评过Pascal语言),都不允许混合使用类型,但C语言不会限制你的自由,即便这经常引起Bug。

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

当不得已混合使用类型时,一个比较好的习惯是使用类型强制转换。强制类型转换可以避免编译器隐式转换带来的错误,同时也向以后的维护人员传递一些有用信息。这有个前提:你要对强制类型转换有足够的了解!下面总结一些规则:

  • 并非所有强制类型转换都是由风险的,把一个整数值转换为一种具有相同符号的更宽类型时,是绝对安全的。
  • 精度高的类型强制转换为精度低的类型时,通过丢弃适当数量的最高有效位来获取结果,也就是说会发生数据截断,并且可能改变数据的符号位。
  • 精度低的类型强制转换为精度高的类型时,如果两种类型具有相同的符号,那么没什么问题;需要注意的是负的有符号精度低类型强制转换为无符号精度高类型时,会不直观的执行符号扩展,
    例如:
1. unsigned int bob;
2. signed char fred = -1;
3.    
4. bob=(unsigned int )fred;              /*发生符号扩展,此时bob为0xFFFFFFFF*/ 

3. 编译器

如果你和一个优秀的程序员共事,你会发现他对他使用的工具非常熟悉,就像一个画家了解他的画具一样。----比尔.盖茨

3.1 不能简单的认为是个工具

  • 嵌入式程序开发跟硬件密切相关,需要使用C语言来读写底层寄存器、存取数据、控制硬件等,C语言和硬件之间由编译器来联系,一些C标准不支持的硬件特性操作,由编译器提供。
  • 汇编可以很轻易的读写指定RAM地址、可以将代码段放入指定的Flash地址、可以精确的设置变量在RAM中分布等等,所有这些操作,在深入了解编译器后,也可以使用C语言实现。
  • C语言标准并非完美,有着数目繁多的未定义行为,这些未定义行为完全由编译器自主决定,了解你所用的编译器对这些未定义行为的处理,是必要的。
  • 嵌入式编译器对调试做了优化,会提供一些工具,可以分析代码性能,查看外设组件等,了解编译器的这些特性有助于提高在线调试的效率。
  • 此外,堆栈操作、代码优化、数据类型的范围等等,都是要深入了解编译器的理由。

如果之前你认为编译器只是个工具,能够编译就好。那么,是时候改变这种思想了。

3.2 不能依赖编译器的语义检查

编译器的语义检查很弱小,甚至还会“掩盖”错误。现代的编译器设计是件浩瀚的工程,为了让编译器设计简单一些,目前几乎所有编译器的语义检查都比较弱小。为了获得更快的执行效率,C语言被设计的足够灵活且几乎不进行任何运行时检查,比如数组越界、指针是否合法、运算结果是否溢出等等。这就造成了很多编译正确但执行奇怪的程序。

C语言足够灵活,对于一个数组test[30],它允许使用像test[-1]这样的形式来快速获取数组首元素所在地址前面的数据;允许将一个常数强制转换为函数指针,使用代码(((void()())0))()来调用位于0地址的函数。C语言给了程序员足够的自由,但也由程序员承担滥用自由带来的责任。

3.2.1 莫名的死机

下面的两个例子都是死循环,如果在不常用分支中出现类似代码,将会造成看似莫名其妙的死机或者重启。

1. unsigned char i;    //例程1 
2. for(i=0;i<256;i++)
3. { 
        
4.     //其它代码 
5. }
1. unsigned char i;     //例程2 
2. for(i=10;i>=0;i--)
3. { 
        
4.     //其它代码 
5. }

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

3.2.2 不起眼的改变

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

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

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

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

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

3.2.3 难查的数组越界

上文曾提到数组常常是引起程序不稳定的重要因素,程序员往往不经意间就会写数组越界。

一位同事的代码在硬件上运行,一段时间后就会发现LCD显示屏上的一个数字不正常的被改变。经过一段时间的调试,问题被定位到下面的一段代码中:

1. int SensorData[30];
2. //其他代码 
3. for(i=30;i>0;i--)
4. { 
        
5.      SensorData[i]=;
6.      //其他代码 
7. }

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

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

你在模块A中定义数组:

1. int SensorData[30];

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

1. extern int SensorData[]; 

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

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

1. char * func(char SensorData[30])  
2. { 
        
3.      unsignedint i;
4.      for(i=30;i>0;i--)
5.      { 
        
6.           SensorData[i]=;
7.           //其他代码
8.      }
9. }

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

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

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

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

如果局部数组越界,可能引发ARM架构硬件异常。

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

1. __irq ExintHandler(void)  
2. { 
        
3.      unsignedchar DataBuf[50];
4.      GetData(DataBug);        //从硬件缓冲区取一帧数据 
5.      //其他代

相关文章