编写优质的嵌入式C程序代码(上)

一起学嵌入式 2023-03-21 11:16

点击下方一起学嵌入式】关注,一起学习,一起成长

由于文章比较长,分为两篇来发。这是第一篇(上篇)。

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

1. 简介

市面上介绍 C 语言以及编程方法的书数目繁多,但对如何编写优质嵌入式 C 程序却鲜有介绍,特别是对应用于单片机、ARM7、Cortex-M3 这类微控制器上的优质 C 程序编写方法几乎是个空白。本文面向的,正是使用单片机、ARM7、Cortex-M3 这类微控制器的底层编程人员。
编写优质嵌入式C程序绝非易事,它跟设计者的思维和经验积累关系密切。嵌入式 C 程序员不仅需要熟知硬件的特性、硬件的缺陷等,更要深入一门语言编程,不浮于表面。为了更方便的操作硬件,还需要对编译器进行深入的了解。

本文将从语言特性、编译器、防御性编程、测试和编程思想这几个方面来讨论如何编写优质嵌入式 C 程序。与很多杂志、书籍不同,本文提供大量真实实例、代码段和参考书目,不仅介绍应该做什么,还重点介绍如何做、以及为什么这样做。编写优质嵌入式 C 程序涉及面十分广,需要程序员长时间的经验积累,本文希望能缩短这一过程。

2. C语言特性

语言是编程的基石,C 语言诡异且有种种陷阱和缺陷,需要程序员多年历练才能达到较为完善的地步。虽然有众多书籍、杂志、专题讨论过 C 语言的陷阱和缺陷,但这并不影响本节再次讨论它。总是有大批的初学者,前仆后继的倒在这些陷阱和缺陷上,民用设备、工业设备甚至是航天设备都不例外。本节将结合具体例子再次审视它们,希望引起足够重视。深入理解C语言特性,是编写优质嵌入式C程序的基础。

2.1 处处都是陷阱

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 的左边,即使程序员误将 ’==’写成了’=’,编译器会产生一个任谁也不能无视的语法错误信息:不可给常量赋值!

2) 复合赋值运算符

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

1. tmp=+1;

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

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

复合赋值运算符 ”-=” 也有类似问题存在。

3) 其它容易误写

  • 使用了中文标点
  • 头文件声明语句最后忘记结束分号
  • 逻辑与 && 和位与 &、逻辑或 || 和位或 |、逻辑非!和 位取反~
  • 字母 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 从而引起顺序执行多个c ase语句,这也许是 C 的一个缺陷之处。

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

2) 不能乱加的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;pRAMaddr4)
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. struct {  
2.     char  c;
3.     short s;
4.     int   x;
5. }str_test1;
第二个结构体:
1. struct {  
2.     char  c;
3.     int   x;
4.     short s;
5. }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.           
3. if(READSDA==(1<<11))          //判断端口p0.11是否为高电平   
4. {
5.     //其它代码  
6. }

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

1. if(IO0PIN&(1<<11) ==(1<<11))
2. {
3.     //其它代码   
4. }
运算符'=='的优先级是大于'&'的,代码IO0PIN&(1<<11) ==(1<<11))等效为IO0PIN&0x00000001:判断端口 P0.0 是否为高电平,这与原意相差甚远。因此,使用宏定义的时候,最好将被定义的内容用括号括起来。
按照常规方式使用时,可能引起误会的运算符还有很多,如表2-1所示。C语言的运算符当然不会只止步于数目繁多!

有一个简便方法可以避免优先级问题:不清楚的优先级就加上”()”,但这样至少有会带来两个问题:
  • 过多的括号影响代码的可读性,包括自己和以后的维护人员
  • 别人的代码不一定用括号来解决优先级问题,但你总要读别人的代码

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

2.3 隐式转换

C 语言的设计理念一直被人吐槽,因为它认为 C 程序员完全清楚自己在做什么,其中一个证据就是隐式转换。C 语言规定,不同类型的数据(比如char和int型数据)需要转换成同一类型后,才可进行计算。如果你混合使用类型,比如用char 类型数据和 int 类型数据做减法,C 使用一个规则集合来自动(隐式的)完成类型转换。这可能很方便,但也很危险。
这就要求我们理解这个转换规则并且能应用到程序中去!
  1. 当出现在表达式里时,有符号和无符号的 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 一直是 unigned 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;     /*强制转换*/
  1. 在包含两种数据类型的任何运算里,两个值都会被转换成两种类型里较高的级别。类型级别从高到低的顺序是 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仍可能溢出*/ 
  1. 在赋值语句里,计算的最后结果被转换成将要被赋予值的那个变量的类型。这一过程可能导致类型提升也可能导致类型降级。降级可能会导致问题。比如将运算结果为 321 的值赋值给 8 位 char 类型变量。程序必须对运算时的数据溢出做合理的处理。很多其他语言,像Pascal(C语言设计者之一曾撰文狠狠批评过Pascal语言),都不允许混合使用类型,但 C 语言不会限制你的自由,即便这经常引起 Bug。
  2. 当作为函数的参数被传递时,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(第二个 fo r循环无线执行)。需要说明的是,赋值代码 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] 赋初值的语句,编译器也是不给任何警告的。实际上,编译器是将数组名 SensorData 隐含的转化为指向数组第一个元素的指针,函数体是使用指针的形式来访问数组的,它当然也不会知道数组元素的个数了。造成这种局面的原因之一是 C 编译器的作者们认为指针代替数组可以提高程序效率,而且,可以简化编译器的复杂度。
指针和数组是容易给程序造成混乱的,我们有必要仔细的区分它们的不同。其实换一个角度想想,它们也是容易区分的:可以将数组名等同于指针的情况有且只有一处,就是上面例子提到的数组作为函数形参时。其它时候,数组名是数组名,指针是指针。
下面的例子编译器同样检查不出数组越界。

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

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

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

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

3.2.4 神奇的 volatile

做嵌入式设备开发,如果不对 volatile 修饰符具有足够了解,实在是说不过去。volatile 是 C 语言 32 个关键字中的一个,属于类型限定符,常用的 const 关键字也属于类型限定符。

volatile 限定符用来告诉编译器,该对象的值无任何持久性,不要对它进行任何优化;它迫使编译器每次需要该对象数据内容时都必须读该对象,而不是只读一次数据并将它放在寄存器中以便后续访问之用(这样的优化可以提高系统速度)。

这个特性在嵌入式应用中很有用,比如你的 IO 口的数据不知道什么时候就会改变,这就要求编译器每次都必须真正的读取该 IO 端口。这里使用了词语“真正的读”,是因为由于编译器的优化,你的逻辑反应到代码上是对的,但是代码经过编译器翻译后,有可能与你的逻辑不符。你的代码逻辑可能是每次都会读取 IO 端口数据,但实际上编译器将代码翻译成汇编时,可能只是读一次 IO 端口数据并保存到寄存器中,接下来的多次读 IO 口都是使用寄存器中的值来进行处理。因为读写寄存器是最快的,这样可以优化程序效率。与之类似的,中断里的变量、多线程中的共享变量等都存在这样的问题。

不使用 volatile,可能造成运行逻辑错误,但是不必要的使用 volatile 会造成代码效率低下(编译器不优化 volatile 限定的变量),因此清楚地知道何处该使用volatile 限定符,是一个嵌入式程序员的必修内容。

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

1. unsigned int test; 
并在头文件中声明该变量:
1. extern unsigned long test;

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

1. volatile unsigned int test;

在头文件中这样声明变量:

1. extern unsigned int test;     /*缺少volatile限定符*/
编译器却不会给出错误信息(有些编译器仅给出一条警告)。当你在另外一个模块(该模块包含声明变量 test 的头文件)使用变量 test 时,它已经不再具有volatile 限定,这样很可能造成一些重大错误。比如下面的例子,注意该例子是为了说明 volatile 限定符而专门构造出的,因为现实中的 volatile 使用 Bug 大都隐含,并且难以理解。
在模块 A 的源文件中,定义变量:
1. volatile unsigned int TimerCount=0;

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

1. TimerCount++; 
在模块 A 的头文件中,声明变量:
1. extern unsigned int TimerCount;   //这里漏掉了类型限定符volatile  

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

1. #include “…A.h”               //首先包含模块A的头文件  
2. //其他代码  
3. TimerCount=0;
4. while(TimerCount<=TIMER_VALUE);   //延时一段时间(感谢网友chhfish指出这里的逻辑错误)  
5. //其他代码  
实际上,这是一个死循环。由于模块 A 头文件中声明变量 TimerCount 时漏掉了volatile 限定符,在模块 B 中,变量 TimerCount 是被当作 unsigned int 类型变量。由于寄存器速度远快于 RAM,编译器在使用非 volatile 限定变量时是先将变量从 RAM 中拷贝到寄存器中,如果同一个代码块再次用到该变量,就不再从 RAM 中拷贝数据而是直接使用之前寄存器备份值。代码while(TimerCount<=TIMER_VALUE) 中,变量 TimerCount 仅第一次执行时被使用,之后都是使用的寄存器备份值,而这个寄存器值一直为 0,所以程序无限循环。图3-1 的流程图说明了程序使用限定符 volatile 和不使用 volatile 的执行过程。

为了更容易的理解编译器如何处理 volatile 限定符,这里给出未使用 volatile 限定符和使用 volatile 限定符程序的反汇编代码:

  • 没有使用关键字 volatile,在 keil MDK V4.54 下编译,默认优化级别,如下所示(注意最后两行):

122:     unIdleCount=0;
2.    123:
3. 0x00002E10  E59F11D4  LDR       R1,[PC,#0x01D4]
4. 0x00002E14  E3A05000  MOV       R5,#key1(0x00000000)
5. 0x00002E18  E1A00005  MOV       R0,R5
6. 0x00002E1C  E5815000  STR       R5,[R1]
7.    124:     while(unIdleCount!=200);   //延时2S钟   
8.    125:
9.  0x00002E20  E35000C8  CMP       R0,#0x000000C8  
10. 0x00002E24  1AFFFFFD  BNE       0x00002E20
  • 使用关键字 volatile,在 keil MDK V4.54 下编译,默认优化级别,如下所示(注意最后三行):
122:     unIdleCount=0;
2.    123:
3. 0x00002E10  E59F01D4  LDR       R0,[PC,#0x01D4]
4. 0x00002E14  E3A05000  MOV       R5,#key1(0x00000000)
5. 0x00002E18  E5805000  STR       R5,[R0]
6.    124:     while(unIdleCount!=200);   //延时2S钟   
7.    125:
8. 0x00002E1C  E5901000  LDR       R1,[R0]
9. 0x00002E20  E35100C8  CMP       R1,#0x000000C8  
10. 0x00002E24  1AFFFFFC  BNE       0x00002E1C 

可以看到,如果没有使用 volatile 关键字,程序一直比较 R0 内数据与 0xC8 是否相等,但 R0 中的数据是 0,所以程序会一直在这里循环比较(死循环);再看使用了 volatile 关键字的反汇编代码,程序会先从变量中读出数据放到 R1 寄存器中,然后再让 R1 内数据与 0xC8 相比较,这才是我们 C 代码的正确逻辑!

3.2.5 局部变量

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

局部变量必须显式初始化,除非你确定知道你要做什么。下面的代码得到的温度值跟预期会有很大差别,因为在使用局部变量 sum 时,并不能保证它的初值为 0。编译器会在第一次运行时清零堆栈区域,这加重了此类 Bug 的隐蔽性。
1. unsigned intGetTempValue(void)  
2. 
{
3.     unsigned int sum;    //定义局部变量,保存总值  
4.     for(i=0;i<10;i++)
5.     {
6.         sum+=CollectTemp();   //函数CollectTemp可以得到当前的温度值  
7.     }
8.     return (sum/10);
9. }

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

1. char * GetData(void)  
2. 
{
3.      char buffer[100];                 //局部数组  
4.      …
5.      return buffer;
6. }

3.2.6 使用外部工具

由于编译器的语义检查比较弱,我们可以使用第三方代码分析工具,使用这些工具来发现潜在的问题,这里介绍其中比较著名的是 PC-Lint。

PC-Lint 由 Gimpel Software 公司开发,可以检查 C 代码的语法和语义并给出潜在的 BUG 报告。PC-Lint 可以显著降低调试时间。
目前公司 ARM7 和 Cortex-M3 内核多是使用 Keil MDK 编译器来开发程序,通过简单配置,PC-Lint 可以被集成到MDK上,以便更方便的检查代码。MDK 已经提供了 PC-Lint 的配置模板,所以整个配置过程十分简单,Keil MDK 开发套件并不包含 PC-Lint 程序,在此之前,需要预先安装可用的 PC-Lint 程序,配置过程如下:
  1. 点击菜单Tools---Set-up PC-Lint…

PC-Lint Include Folders:该列表路径下的文件才会被 PC-Lint 检查,此外,这些路径下的文件内使用 #include 包含的文件也会被检查;
Lint Executable:指定PC-Lint程序的路径

Configuration File:指定配置文件的路径,该配置文件由MDK编译器提供。

  1. 菜单 Tools---Lint 文件路径 .c/.h

检查当前文件。

  1. 菜单Tools---Lint All C-Source Files

检查所有C源文件。

PC-Lint 的输出信息显示在 MDK 编译器的 Build Output 窗口中,双击其中的一条信息可以跳转到源文件所在位置。
编译器语义检查的弱小在很大程度上助长了不可靠代码的广泛存在。随着时代的进步,现在越来越多的编译器开发商意识到了语义检查的重要性,编译器的语义检查也越来越强大,比如公司使用的 Keil MDK 编译器,虽然它的编辑器依然不尽人意,但在其  V4.47 及以上版本中增加了动态语法检查并加强了语义检查,可以友好的提示更多警告信息。建议经常关注编译器官方网站并将编译器升级到 V4.47 或以上版本,升级的另一个好处是这些版本的编辑器增加了标识符自动补全功能,可以大大节省编码的时间。

推荐阅读:Keil MDK免费社区版(附赠安装包)

3.3 你觉得有意义的代码未必正确

C 语言标准特别的规定某些行为是未定义的,编写未定义行为的代码,其输出结果由编译器决定!C 标准委员会定义未定义行为的原因如下:

  • 简化标准,并给予实现一定的灵活性,比如不捕捉那些难以诊断的程序错误;
  • 编译器开发商可以通过未定义行为对语言进行扩展

    C 语言的未定义行为,使得 C 极度高效灵活并且给编译器实现带来了方便,但这并不利于优质嵌入式 C 程序的编写。因为许多 C 语言中看起来有意义的东西都是未定义的,并且这也容易使你的代码埋下隐患,并且不利于跨编译器移植。Java 程序会极力避免未定义行为,并用一系列手段进行运行时检查,使用 Java 可以相对容易的写出安全代码,但体积庞大效率低下。作为嵌入式程序员,我们需要了解这些未定义行为,利用 C 语言的灵活性,写出比Java 更安全、效率更高的代码来。

3.3.1 常见的未定义行为

  1. 自增自减在表达式中连续出现并作用于同一变量,或者自增自减在表达式中出现一次,但作用的变量多次出现。
自增(++)和自减(--)这一动作发生在表达式的哪个时刻是由编译器决定的,比如:
1. r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++];

不同的编译器可能有着不同的汇编代码,可能是先执行 i++ 再进行乘法和加法运行,也可能是先进行加法和乘法运算,再执行 i++,因为这句代码在一个表达式中出现了连续的自增并作用于同一变量。更加隐蔽的是自增自减在表达式中出现一次,但作用的变量多次出现,比如:

1. a[i] = i++; /* 未定义行为 */

先执行 i++ 再赋值,还是先赋值再执行 i++ 是由编译器决定的,而两种不同的执行顺序的结果差别是巨大的。

  1. 函数实参被求值的顺序

函数如果有多个实参,这些实参的求值顺序是由编译器决定的,比如:

1. printf("%d %d\n", ++n, power(2, n));    /* 未定义行为 */ 
是先执行++n还是先执行 power(2,n) 是由编译器决定的。
  1. 有符号整数溢出
有符号整数溢出是未定义的行为,编译器决定有符号整数溢出按照哪种方式取值。比如下面代码:
1. int value1,value2,sum
2.   
3. //其它操作  
4. sum=value1+value;    /*sum可能发生溢出*/
  1. 有符号数右移、移位的数量是负值或者大于操作数的位数

  2. 除数为零

  3. malloc()、calloc() 或 realloc() 分配零字节内存

3.3.2 如何避免 C 语言未定义行为

代码中引入未定义行为会为代码埋下隐患,防止代码中出现未定义行为是困难的,我们总能不经意间就会在代码中引入未定义行为。但还是有一些方法可以降低这种事件,总结如下:
  • 了解 C 语言未定义行为

标准C99附录J.2 “未定义行为” 列举了 C99 中的显式未定义行为,通过查看该文档,了解那些行为是未定义的,并在编码中时刻保持警惕;
  • 寻求工具帮助

编译器警告信息以及 PC-Lint 等静态检查工具能够发现很多未定义行为并警告,要时刻关注这些工具反馈的信息;

  • 总结并使用一些编码标准

1)避免构造复杂的自增或者自减表达式,实际上,应该避免构造所有复杂表达式;

比如 a[i] = i++; 语句可以改为 a[i] = i; i++; 这两句代码。

2)只对无符号操作数使用位操作;

  • 必要的运行时检查
检查是否溢出、除数是否为零,申请的内存数量是否为零等等,比如上面的有符号整数溢出例子,可以按照如下方式编写,以消除未定义特性:
1. int value1,value2,sum;
2.   
3. //其它代码  
4. if((value1>0 && value2>0 && value1>(INT_MAX-value2))||
5.    (value1<0 && value2<0 && value1<(INT_MIN-value2)))
6. {
7.     //处理错误  
8. }
9. else  
10. {
11.     sum=value1+value2;
12. }
上面的代码是通用的,不依赖于任何 CPU 架构,但是代码效率很低。如果是有符号数使用补码的 CPU 架构(目前常见CPU绝大多数都是使用补码),还可以用下面的代码来做溢出检查:
int value1, value2, sum;
unsigned int usum = (unsigned int)value1 + value2;

if((usum ^ value1) & (usum ^ value2) & INT_MIN)
{
 /*处理溢出情况*/
}
else
{
 sum = value1 + value2;
}

使用的原理解释一下,因为在加法运算中,操作数 value1 和 value2 只有符号相同时,才可能发生溢出,所以我们先将这两个数转换为无符号类型,两个数的和保存在变量 usum 中。如果发生溢出,则 value1、value2 和 usum 的最高位(符号位)一定不同,表达式 (usum ^ value1) & (usum ^ value2)  的最高位一定为1,这个表达式位与(&)上 INT_MIN 是为了将最高位之外的其它位设置为0。

  • 了解你所用的编译器对未定义行为 的处理策略

很多引入了未定义行为的程序也能运行良好,这要归功于编译器处理未定义行为的策略。不是你的代码写的正确,而是恰好编译器处理策略跟你需要的逻辑相同。了解编译器的未定义行为处理策略,可以让你更清楚的认识到那些引入了未定义行为程序能够运行良好是多么幸运的事,不然多换几个编译器试试!

以 Keil MDK 为例,列举常用的处理策略如下:

1) 有符号量的右移是算术移位,即移位时要保证符号位不改变。

2)对于 int 类的值:超过 31 位的左移结果为零;无符号值或正的有符号值超过31 位的右移结果为零。负的有符号值移位结果为 -1。

3)整型数除以零返回零

3.4 了解你的编译器

在嵌入式开发过程中,我们需要经常和编译器打交道,只有深入了解编译器,才能用好它,编写更高效代码,更灵活的操作硬件,实现一些高级功能。下面以公司最常用的 Keil MDK 为例,来描述一下编译器的细节。

3.4.1 编译器的一些小知识

  1. 默认情况下,char 类型的数据项是无符号的,所以它的取值范围是0~255;
  2. 在所有的内部和外部标识符中,大写和小写字符不同;
  3. 通常局部变量保存在寄存器中,但当局部变量太多放到栈里的时候,它们总是字对齐的。
  4. 压缩类型的自然对齐方式为 1。使用关键字 __packed 来压缩特定结构,将所有有效类型的对齐边界设置为 1;
  5. 整数以二进制补码形式表示;浮点量按 IEEE 格式存储;
  6. 整数除法的余数的符号于被除数相同,由 ISO C90 标准得出;
  7. 如果整型值被截断为短的有符号整型,则通过放弃适当数目的最高有效位来得到结果。如果原始数是太大的正或负数,对于新的类型,无法保证结果的符号将于原始数相同。
  8. 整型数超界不引发异常;像 unsigned char test; test=1000; 这类是不会报错的;
  9. 在严格C中,枚举值必须被表示为整型。例如,必须在 ‑2147483648 到+2147483647 的范围内。但 MDK 自动使用对象包含 enum 范围的最小整型来实现(比如 char 类型),除非使用编译器命令 ‑‑enum_is_int  来强制将enum 的基础类型设为至少和整型一样宽。超出范围的枚举值默认仅产生警告:#66:enumeration value is out of "int" range;
  10. 对于结构体填充,根据定义结构的方式,keil MDK 编译器用以下方式的一种来填充结构:

I>  定义为 static 或者 extern 的结构用零填充;
II>  栈或堆上的结构,例如,用 malloc() 或者 auto 定义的结构,使用先前存储在那些存储器位置的任何内容进行填充。不能使用 memcmp() 来比较以这种方式定义的填充结构!
  1. 编译器不对声明为 volatile 类型的数据进行优化;

  2. __nop():延时一个指令周期,编译器绝不会优化它。如果硬件支持NOP指令,则该句被替换为NOP指令,如果硬件不支持NOP指令,编译器将它替换为一个等效于NOP的指令,具体指令由编译器自己决定;

  3. __align(n):指示编译器在 n 字节边界上对齐变量。对于局部变量,n 的值为1、2、4、8;

  4. attribute((at(address))):可以使用此变量属性指定变量的绝对地址;

  5. __inline:提示编译器在合理的情况下内联编译C或C++ 函数;

3.4.2 初始化的全局变量和静态变量的初始值被放到了哪里?

我们程序中的一些全局变量和静态变量在定义时进行了初始化,经过编译器编译后,这些初始值被存放在了代码的哪里?我们举个例子说明:
1. unsigned int g_unRunFlag=0xA5
2. static unsigned int s_unCountFlag=0x5A

我曾做过一个项目,项目中的一个设备需要在线编程,也就是通过协议,将上位机发给设备的数据通过在应用编程(IAP)技术写入到设备的内部 Flash 中。我将内部 Flash 做了划分,一小部分运行程序,大部分用来存储上位机发来的数据。随着程序量的增加,在一次更新程序后发现,在线编程之后,设备运行正常,但是重启设备后,运行出现了故障!经过一系列排查,发现故障的原因是一个全局变量的初值被改变了。这是件很不可思议的事情,你在定义这个变量的时候指定了初始值,当你在第一次使用这个变量时却发现这个初值已经被改掉了!这中间没有对这个变量做任何赋值操作,其它变量也没有任何溢出,并且多次在线调试表明,进入 main 函数的时候,该变量的初值已经被改为一个恒定值。

要想知道为什么全局变量的初值被改变,就要了解这些初值编译后被放到了二进制文件的哪里。在此之前,需要先了解一点链接原理。

ARM 映象文件各组成部分在存储系统中的地址有两种:一种是映象文件位于存储器时(通俗的说就是存储在 Flash 中的二进制代码)的地址,称为加载地址;一种是映象文件运行时(通俗的说就是给板子上电,开始运行 Flash 中的程序了)的地址,称为运行时地址。赋初值的全局变量和静态变量在程序还没运行的时候,初值是被放在 Flash 中的,这个时候他们的地址称为加载地址。当程序运行后,这些初值会从 Flash 中拷贝到 RAM 中,这时候就是运行时地址了。
原来,对于在程序中赋初值的全局变量和静态变量,程序编译后,MDK 将这些初值放到 Flash 中,位于紧靠在可执行代码的后面。在程序进入 main 函数前,会运行一段库代码,将这部分数据拷贝至相应 RAM 位置。由于我的设备程序量不断增加,超过了为设备程序预留的 Flash 空间,在线编程时,将一部分存储全局变量和静态变量初值的 Flash 给重新编程了。在重启设备前,初值已经被拷贝到 RAM 中,所以这个时候程序运行是正常的,但重新上电后,这部分初值实际上是在线编程的数据,自然与初值不同了。

3.4.3 在C代码中使用的变量,编译器将他们分配到RAM的哪里?

我们会在代码中使用各种变量,比如全局变量、静态变量、局部变量,并且这些变量时由编译器统一管理的,有时候我们需要知道变量用掉了多少 RAM,以及这些变量在 RAM 中的具体位置。这是一个经常会遇到的事情,举一个例子,程序中的一个变量在运行时总是不正常的被改变,那么有理由怀疑它临近的变量或数组溢出了,溢出的数据更改了这个变量值。要排查掉这个可能性,就必须知道该变量被分配到 RAM 的哪里、这个位置附近是什么变量,以便针对性的做跟踪。

其实MDK编译器的输出文件中有一个“工程名.map”文件,里面记录了代码、变量、堆栈的存储位置,通过这个文件,可以查看使用的变量被分配到 RAM 的哪个位置。要生成这个文件,需要在 Options for Targer 窗口,Listing 标签栏下,勾选 Linker Listing 前的复选框,如图3-1所示。

图3-1 设置编译器生产 MAP 文件

3.4.4 默认情况下,栈被分配到 RAM 的哪个地方?

MDK 中,我们只需要在配置文件中定义堆栈大小,编译器会自动在 RAM 的空闲区域选择一块合适的地方来分配给我们定义的堆栈,这个地方位于 RAM 的哪个地方呢?

通过查看 MAP 文件,原来 MDK 将堆栈放到程序使用到的 RAM 空间的后面,比如你的 RAM 空间从 0x4000 0000 开始,你的程序用掉了 0x200 字节 RAM,那么堆栈空间就从 0x4000 0200 处开始。

使用了多少堆栈,是否溢出?

3.4.5 有多少RAM会被初始化?

在进入 main() 函数之前,MDK 会把未初始化的 RAM 给清零的,我们的 RAM 可能很大,只使用了其中一小部分,MDK 会不会把所有 RAM 都初始化呢?

答案是否定的,MDK 只是把你的程序用到的 RAM 以及堆栈 RAM 给初始化,其它 RAM 的内容是不管的。如果你要使用绝对地址访问 MDK 未初始化的 RAM,那就要小心翼翼的了,因为这些 RAM 上电时的内容很可能是随机的,每次上电都不同。

3.4.6 MDK编译器如何设置非零初始化变量?

对于控制类产品,当系统复位后(非上电复位),可能要求保持住复位前 RAM中的数据,用来快速恢复现场,或者不至于因瞬间复位而重启现场设备。而 keil mdk 在默认情况下,任何形式的复位都会将 RAM 区的非初始化变量数据清零。

MDK 编译程序生成的可执行文件中,每个输出段都最多有三个属性:RO 属性、RW 属性和 ZI 属性。对于一个全局变量或静态变量,用 const 修饰符修饰的变量最可能放在 RO 属性区,初始化的变量会放在 RW 属性区,那么剩下的变量就要放到 ZI 属性区了。默认情况下,ZI 属性区的数据在每次复位后,程序执行main 函数内的代码之前,由编译器“自作主张”的初始化为零。所以我们要在 C 代码中设置一些变量在复位后不被零初始化,那一定不能任由编译器“胡作非为”,我们要用一些规则,约束一下编译器。

分散加载文件对于连接器来说至关重要,在分散加载文件中,使用 UNINIT 来修饰一个执行节,可以避免编译器对该区节的 ZI 数据进行零初始化。这是要解决非零初始化变量的关键。因此我们可以定义一个 UNINIT 修饰的数据节,然后将希望非零初始化的变量放入这个区域中。于是,就有了第一种方法:
  1. 修改分散加载文件,增加一个名为 MYRAM 的执行节,该执行节起始地址为0x1000A000,长度为 0x2000 字节(8KB),由 UNINIT 修饰:
1:   LR_IROM1 0x00000000 0x00080000  {    ; load region size_region
    2:   ER_IROM1 0x00000000 0x00080000  {  ; load address = execution address
    3:    *.o (RESET, +First)
    4:    *(InRoot$$Sections)
    5:    .ANY (+RO)
    6:   }
    7:   RW_IRAM1 0x10000000 0x0000A000  {  ; RW data
    8:    .ANY (+RW +ZI)
    9:   }
   10:   MYRAM 0x1000A000 UNINIT 0x00002000  {
   11:    .ANY (NO_INIT)
   12:   }
   13: }

那么,如果在程序中有一个数组,你不想让它复位后零初始化,就可以这样来定义变量:

1. unsigned char  plc_eu_backup[32] __attribute__((at(0x1000A000)));
变量属性修饰符 __attribute__((at(adde))) 用来将变量强制定位到 adde 所在地址处。由于地址 0x1000A000 开始的 8KB 区域 ZI 变量不会被零初始化,所以位于这一区域的数组 plc_eu_backup 也就不会被零初始化了。
这种方法的缺点是显而易见的:要程序员手动分配变量的地址。如果非零初始化数据比较多,这将是件难以想象的大工程(以后的维护、增加、修改代码等等)。所以要找到一种办法,让编译器去自动分配这一区域的变量。
  1. 分散加载文件同方法1,如果还是定义一个数组,可以用下面方法:
unsigned char  plc_eu_backup[32] __attribute__((section("NO_INIT"),zero_init));
变量属性修饰符 __attribute__((section(“name”),zero_init)) 用于将变量强制定义到 name 属性数据节中,zero_init 表示将未初始化的变量放到 ZI 数据节中。因为 “NO_INIT” 这显性命名的自定义节,具有 UNINIT 属性。
  1. 将一个模块内的非初始化变量都非零初始化

假如该模块名字为 test.c,修改分散加载文件如下所示:

1: LR_IROM1 0x00000000 0x00080000  {    ; load region size_region
    2:   ER_IROM1 0x00000000 0x00080000  {  ; load address = execution address
    3:    *.o (RESET, +First)
    4:    *(InRoot$$Sections)
    5:    .ANY (+RO)
    6:   }
    7:   RW_IRAM1 0x10000000 0x0000A000  {  ; RW data
    8:    .ANY (+RW +ZI)
    9:   }
   10:   RW_IRAM2 0x1000A000 UNINIT 0x00002000  {
   11:    test.o (+ZI)
   12:   }
   13: }

在该模块定义时变量时使用如下方法:

这里,变量属性修饰符  __attribute__((zero_init))  用于将未初始化的变量放到 ZI 数据节中变量,其实 MDK 默认情况下,未初始化的变量就是放在 ZI 数据区的。

原文:http://blog.csdn.net/zhzht19861011/article/details/45508029

文章来源于网络,版权归原作者所有,如有侵权,请联系删除。



关注我【一起学嵌入式】,一起学习,一起成长。



觉得文章不错,点击“分享”、“”、“在看” 呗!

一起学嵌入式 公众号【一起学嵌入式】,RTOS、Linux编程、C/C++,以及经验分享、行业资讯、物联网等技术知
评论
  • 作为优秀工程师的你,已身经百战、阅板无数!请先醒醒,新的项目来了,这是一个既要、又要、还要的产品需求,ARM核心板中一个处理器怎么能实现这么丰富的外围接口?踌躇之际,你偶阅此文。于是,“潘多拉”的魔盒打开了!没错,USB资源就是你打开新世界得钥匙,它能做哪些扩展呢?1.1  USB扩网口通用ARM处理器大多带两路网口,如果项目中有多路网路接口的需求,一般会选择在主板外部加交换机/路由器。当然,出于成本考虑,也可以将Switch芯片集成到ARM核心板或底板上,如KSZ9897、
    万象奥科 2024-12-03 10:24 90浏览
  • 遇到部分串口工具不支持1500000波特率,这时候就需要进行修改,本文以触觉智能RK3562开发板修改系统波特率为115200为例,介绍瑞芯微方案主板Linux修改系统串口波特率教程。温馨提示:瑞芯微方案主板/开发板串口波特率只支持115200或1500000。修改Loader打印波特率查看对应芯片的MINIALL.ini确定要修改的bin文件#查看对应芯片的MINIALL.ini cat rkbin/RKBOOT/RK3562MINIALL.ini修改uart baudrate参数修改以下目
    Industio_触觉智能 2024-12-03 11:28 109浏览
  • 当前,智能汽车产业迎来重大变局,随着人工智能、5G、大数据等新一代信息技术的迅猛发展,智能网联汽车正呈现强劲发展势头。11月26日,在2024紫光展锐全球合作伙伴大会汽车电子生态论坛上,紫光展锐与上汽海外出行联合发布搭载紫光展锐A7870的上汽海外MG量产车型,并发布A7710系列UWB数字钥匙解决方案平台,可应用于数字钥匙、活体检测、脚踢雷达、自动泊车等多种智能汽车场景。 联合发布量产车型,推动汽车智能化出海紫光展锐与上汽海外出行达成战略合作,联合发布搭载紫光展锐A7870的量产车型
    紫光展锐 2024-12-03 11:38 126浏览
  • 11-29学习笔记11-29学习笔记习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习笔记&记录学习习笔记&记学习学习笔记&记录学习学习笔记&记录学习习笔记&记录学习学习笔记&记录学习学习笔记记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&学习学习笔记&记录学习学习笔记&记录学习学习笔记&记
    youyeye 2024-12-02 23:58 92浏览
  •         温度传感器的精度受哪些因素影响,要先看所用的温度传感器输出哪种信号,不同信号输出的温度传感器影响精度的因素也不同。        现在常用的温度传感器输出信号有以下几种:电阻信号、电流信号、电压信号、数字信号等。以输出电阻信号的温度传感器为例,还细分为正温度系数温度传感器和负温度系数温度传感器,常用的铂电阻PT100/1000温度传感器就是正温度系数,就是说随着温度的升高,输出的电阻值会增大。对于输出
    锦正茂科技 2024-12-03 11:50 141浏览
  • 最近几年,新能源汽车愈发受到消费者的青睐,其销量也是一路走高。据中汽协公布的数据显示,2024年10月,新能源汽车产销分别完成146.3万辆和143万辆,同比分别增长48%和49.6%。而结合各家新能源车企所公布的销量数据来看,比亚迪再度夺得了销冠宝座,其10月新能源汽车销量达到了502657辆,同比增长66.53%。众所周知,比亚迪是新能源汽车领域的重要参与者,其一举一动向来为外界所关注。日前,比亚迪汽车旗下品牌方程豹汽车推出了新车方程豹豹8,该款车型一上市就迅速吸引了消费者的目光,成为SUV
    刘旷 2024-12-02 09:32 138浏览
  • 概述 说明(三)探讨的是比较器一般带有滞回(Hysteresis)功能,为了解决输入信号转换速率不够的问题。前文还提到,即便使能滞回(Hysteresis)功能,还是无法解决SiPM读出测试系统需要解决的问题。本文在说明(三)的基础上,继续探讨为SiPM读出测试系统寻求合适的模拟脉冲检出方案。前四代SiPM使用的高速比较器指标缺陷 由于前端模拟信号属于典型的指数脉冲,所以下降沿转换速率(Slew Rate)过慢,导致比较器检出出现不必要的问题。尽管比较器可以使能滞回(Hysteresis)模块功
    coyoo 2024-12-03 12:20 170浏览
  • RDDI-DAP错误通常与调试接口相关,特别是在使用CMSIS-DAP协议进行嵌入式系统开发时。以下是一些可能的原因和解决方法: 1. 硬件连接问题:     检查调试器(如ST-Link)与目标板之间的连接是否牢固。     确保所有必要的引脚都已正确连接,没有松动或短路。 2. 电源问题:     确保目标板和调试器都有足够的电源供应。     检查电源电压是否符合目标板的规格要求。 3. 固件问题: &n
    丙丁先生 2024-12-01 17:37 114浏览
  • TOF多区传感器: ND06   ND06是一款微型多区高集成度ToF测距传感器,其支持24个区域(6 x 4)同步测距,测距范围远达5m,具有测距范围广、精度高、测距稳定等特点。适用于投影仪的无感自动对焦和梯形校正、AIoT、手势识别、智能面板和智能灯具等多种场景。                 如果用ND06进行手势识别,只需要经过三个步骤: 第一步&
    esad0 2024-12-04 11:20 103浏览
  • 光伏逆变器是一种高效的能量转换设备,它能够将光伏太阳能板(PV)产生的不稳定的直流电压转换成与市电频率同步的交流电。这种转换后的电能不仅可以回馈至商用输电网络,还能供独立电网系统使用。光伏逆变器在商业光伏储能电站和家庭独立储能系统等应用领域中得到了广泛的应用。光耦合器,以其高速信号传输、出色的共模抑制比以及单向信号传输和光电隔离的特性,在光伏逆变器中扮演着至关重要的角色。它确保了系统的安全隔离、干扰的有效隔离以及通信信号的精准传输。光耦合器的使用不仅提高了系统的稳定性和安全性,而且由于其低功耗的
    晶台光耦 2024-12-02 10:40 143浏览
我要评论
0
点击右上角,分享到朋友圈 我知道啦
请使用浏览器分享功能 我知道啦