公式源码编译-编译高质量嵌入式C程序代码(上)

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

由于文章比较长公式源码编译,所以会分为两部分。 这是第一篇文章(下一篇)。

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

1 简介

市面上介绍C语言和编程技巧的书籍有很多,但是关于如何编译高质量的嵌入式C程序,特别是单片机等微控制器的高质量C程序的编译却很少有介绍, ARM7 和 Cortex-M3。 该方法几乎是空白。 本文针对使用微控制器、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语句一直为true。 如果参数运算符出现在逻辑决策表达式中,现在大多数编译器都会发出警告消息。 例如keilMDK会给出警告提示:“warning:#187-D:useof”="where"=="mayhavebeenintending",但并不是所有程序员都会注意到这样的警告,所以有经验的程序员使用下面的代码来防止这个错误:

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

把常量放在变量x的一边,虽然程序员把‘==’误写成了‘=’,但编译器会形成一句无人能忽视的错误信息:你不能给常量参数!

2)复合参数算子

虽然复合形参运算符(+=、*=等)可以使表达式更加简洁,可能形成更加高效的机器代码,但单独的复合形参运算符也会给程序带来隐含的bug,比如很容易出现“+=”误写为“=+”,代码如下:

1. tmp=+1;

代码的本意是表达tmp=tmp+1,而复合参数运算符“+=”却被误写成了“=+”:将正整数常量1参数赋予给变量tmp。 编译器会很乐意接受这种代码而不发出警告。

如果你能在调试阶段发现这个bug,那么你真的应该恭喜你,否则这很可能成为一个重大的隐含bug,而且不容易被发现。

复合参数运算符“-=”也有类似的问题。

3)其他的很容易写错

虽然这种写错很容易被编译器检测到,但只要关注编译器的提示信息,就可以很快解决。

许多软件错误源于打字错误。 在 Google 上搜索时,某些结果列表会出现警告,表明 Google 认为它包含恶意代码。 如果您在 2009 年 1 月 31 日清晨进行 Google 搜索,您会发现在当天早上的 55 分钟内,Google 的搜索结果将每个网站都标记为对您的 PC 有害。 这适用于整个互联网上的所有网站,包括 Google 自己的所有网站和服务。 谷歌的恶意软件检查功能通过在已知攻击者列表中查找危险网站来识别危险网站。 1 月 31 日下午,此列表的更新意外地包含了正斜杠(“/”)。 所有 URL 都包含斜杠,反恶意软件功能将此斜杠解释为意味着所有 URL 都是可疑的,因此它很高兴地向搜索结果中的每个网站添加警告。 很少见到如此简单的输入错误造成如此奇怪而广泛的影响,但程序就是这样,容不得半点疏漏。

2.1.2 字段下标

字段往往是程序不稳定的重要原因。 C语言字段的欺骗与链表的下标从0开始是分不开的。你可以定义inttest[30],但绝对不能使用链表元素test[30],除非你清楚地知道我要做什么我正在做。

2.1.3 容易被忽视的break关键字

1)不能省略的中断

switch...case语句可以轻松实现多分支结构,但要注意在适当的位置添加break关键字。 程序员常常容易忽略添加中断而导致多个 case 语句顺序执行。 这可能是C的一个缺陷。

对于switch...case语句,从概率的角度来看,大多数程序一次只需要执行一个匹配的case语句,并且每个这样的case语句后面都必须有一个break。 让高概率风暴复杂化有些不自然。

2) 不能随意添加的break

break关键字用于跳出最近的循环语句或切换语句,但程序员往往对此不够重视。

1990年1月15日,温哥华AT&T电话网络的一个交换机发生故障并重新启动,导致邻近的交换机瘫痪。 曾经,6000人九小时打不了长途电话。 当时的解决办法:工程师重新安装了之前的软件版本。 。 。 随后的车祸调查发现,这是由于误用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. }

公式源码编译-编译高质量嵌入式C程序代码(上)

那种想跳出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”为前缀,这有点可笑:3 的补码表示方式完全不同。 如果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是一个unsigned 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. }

我们知道,对于一个链表数组[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困境测试是一个不错的选择。所以,《TheCPuzzleBook》这本书一定不要missing),你甚至可以用这个困难的句子作为不友好代码的反例。 而且还可以让你对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 结构的填充

结构可以形成填充。 由于对于大多数处理器来说,访问字或半字对齐的数据速度更快,因此在定义结构时,编译器可能会以半字或字的形式放置它们,以优化性能。 对齐,这会导致padding问题。 例如下面两个结构体:

第一个结构:

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;

这两个结构体的元素是同一个变量,但是元素的位置改变了。 那么,这两个结构体变量占用的内存大小是否相同呢?

虽然这两个结构体变量占用的显存不同,但是对于KeilMDK编译器来说,默认情况下,第一个结构体变量占用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&(14的结果是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;     /*强制转换*/

在任何涉及两种数据类型的操作中,两个值都会转换为两种类型中较高级别的。 类型级别从高到低的顺序为longdouble、double、float、unsignedlonglong、longlong、unsignedlong、long、unsignedint、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的value参数赋予8位char类型变量。 程序在运行过程中必须对数据溢出进行合理的处理。 许多其他语言,例如 Pascal(C 的设计者之一写了一篇关于 Pascal 的非常批评的文章),不允许混合类型,但 C 不会限制您的自由,尽管这通常会导致错误。

当作为参数传递给函数时,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 不要简单地认为它是一个工具 3.2 不要依赖编译器的语义检测

编译器的语义检测非常弱,甚至会“掩盖”错误。 现代编译器设计是一项浩大的工程。 为了使编译器设计更加简单,几乎所有编译器的语义检测都比较弱。 为了达到更快的执行效率,C语言设计得足够灵活,几乎不进行任何运行时检查,比如链表越界、指针是否合法、运算结果是否溢出等等在。 这导致很多程序可以正确编译,但行为却很奇怪。

公式源码编译-编译高质量嵌入式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. }

对于unsigned char类型来说,表示范围是0~255,所以unsigned 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时,表达式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 个元素的字段。 不幸的是,不存在的链表元素SensorData[30]在for循环代码中被误用了,但是C语言却怂恿这样的使用,并且愉快地根据代码改变了链表元素SensorData[30]位置处的值30],SensorData[30]的位置本来是LCD显示变量,这就是显示屏上的值发生异常变化的原因。 幸运的是,这个错误很容易就被发现了。

虽然很多编译器都会对上面的代码产生警告:形参超出了链表的界限。 但并非所有程序员都对编译器警告足够敏感。 毕竟,编译器无法检测到所有字段越界的情况。 例如下面的情况:

您在模块 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 年,第一个互联网蠕虫在三天内感染了 2,000 至 6,000 台计算机。 该蠕虫程序依赖于标准输入库函数的形式参数越界错误。 起因是一个标准输入输出库函数gets(),它最初的设计目的是从数据流中获取一段文本。 不幸的是,gets() 函数没有指定输入文本的粗细。 gets()函数内部定义了一个500字节的字段,攻击者发送了小于500字节的数据,并用溢出的数据改变了堆栈中的PC指针,从而获得系统权限。 目前,即使有更好的库函数来替代gets函数,但gets函数一直存在。

3.2.4 神奇的挥发性

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

volatile 限定符用于告诉编译器该对象的值没有持久性,所以不要对其进行优化; 它促使编译器每次需要对象的数据内容时都读取该对象,而不是只读取一次数据并将其放入寄存器中以供以后访问(这样的优化可以提高系统速度)。

该功能在嵌入式应用中非常有用。 比如你的IO口的数据不知道什么时候会改变,这就需要编译器每次都去实际读取IO口。 这里用“真读”这个成语是因为编译器的优化,你的代码体现的逻辑是正确的,而代码经过编译器翻译后可能与你的逻辑不符。 你的代码逻辑可能每次都会读取IO口数据,但实际上,编译器将代码翻译成汇编时,可能只读取一次IO口数据并保存在寄存器中,然后多次读取IO口数据。 它是使用寄存器中的值进行处理的。 由于读写寄存器是最快的,这优化了程序效率。 Similarly, variables in interrupts, shared variables in multithreading, etc. all have such problems.

Not using volatile may lead to operational logic errors, and unnecessary use of volatile will lead to code inefficiency (the compiler does not optimize variables limited by volatile), so it is an embedded programmer who clearly knows where to use the volatile qualifier elective content.

A program module generally consists of two files, a source file and a header file. If you define a variable in a source file:

1. unsigned int test; 

And declare the variable in the header file:

1. extern unsigned long test;

The compiler will prompt a sentence error: variable 'test' declaration type is inconsistent. But if you define the variable in the source file:

1. volatile unsigned int test;

Declare the variable in the header file like this:

1. extern unsigned int test;     /*缺少volatile限定符*/

The compiler does not give an error message (some compilers only give a warning). When you use the variable test in another module (the module contains the header file declaring the variable test), it is no longer volatile, which is likely to lead to some serious mistakes. For example, in the example below, note that this example is specially constructed to illustrate the volatile qualifier, because most of the bugs in the use of volatile in reality are implied, but they cannot be understood.

In the source file of module A, define the variable:

1. volatile unsigned int TimerCount=0;

This variable is used for software timing in a timer interrupt service routine:

1. TimerCount++; 

In the header file of module A, declare the variable:

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

In module B, use the TimerCount variable for precise software delays:

1. #include “…A.h”               //首先包含模块A的头文件  
2. //其他代码  
3. TimerCount=0;
4. while(TimerCount<=TIMER_VALUE);   //延时一段时间(感谢网友chhfish指出这里的逻辑错误)  
5. //其他代码  

In fact, it's an endless loop. Because the volatile qualifier is omitted when the variable TimerCount is declared in the header file of module A, in module B, the variable TimerCount is regarded as an unsignedint type variable. Because the speed of the register is much faster than that of RAM, the compiler first copies the variable from RAM to the register when using a non-volatile limited variable. If the variable is used again in the same code block, it no longer copies the data from RAM but Use the previous register backup value directly. Code while (TimerCount defined as static or extern structure is filled with zeros;

II> Structures on the stack or heap, eg, structures defined with malloc() or auto, are filled with whatever was originally stored at those memory locations. Filled structures defined with these methods cannot be compared using memcmp()!

The compiler does not optimize data declared as volatile;

__nop(): Delays one instruction cycle, the compiler will never optimize it. If the hardware supports the NOP instruction, this sentence is replaced by the NOP instruction. If the hardware does not support the NOP instruction, the compiler replaces it with an instruction equivalent to NOP. The specific instruction is determined by the compiler itself;

__align(n): Instructs the compiler to align variables on n-byte boundaries. For local variables, the value of n is 1, 2, 4, 8;

attribute((at(address))): You can use this variable attribute to specify the absolute address of the variable;

__inline: Prompt the compiler to inline compile C or C++ functions under reasonable circumstances;

3.4.2 Where are the initial values ​​of initialized global variables and static variables placed?

Some global variables and static variables in our program are initialized when they are defined. After the compiler compiles, where is this initial value stored in the code? Let us give a counter-example:

1. unsigned int g_unRunFlag=0xA5
2. static unsigned int s_unCountFlag=0x5A

I have worked on a project in which a device needs to be programmed online, that is, through a contract, the data sent by the host computer to the device is written into the internal Flash of the device through in-application programming (IAP) technology. I defined the internal Flash, a small part is used to run the program, and most of it is used to store the data sent by the host computer. With the reduction of the amount of programs, after updating the program once, it is found that after online programming, the device operates normally, and after restarting the device, the operation fails! After a series of investigations, it was found that the cause of the failure was that the final value of a global variable was changed. This is a very incredible thing, you specify the initial value when defining this variable, but when you use this variable for the first time, you find that the annual rate has already been changed! There is no formal parameter operation on this variable, and there is no overflow of other variables, and multiple online debugging shows that when entering the main function, the annual rate of this variable has already been changed to a constant value.

If you want to know why the annual rate of the global variable is changed, you need to know that this annual rate is compiled and placed in the two's complement file. Before that, you need to understand a little link principle.

There are two types of addresses for each component of the ARM image file in the storage system: one is the address of the image file when it is located in the storage (in plain terms, it is the two-complement code stored in Flash), which is called the load address ; One is the address of the image file when it is running (in simple terms, it is to power on the board and start running the program in Flash), which is called the address of the running time. Global variables and static variables assigned final values ​​are placed in Flash before the program is running, and their addresses at this time are called loading addresses. When the program is running, this annual rate will be copied from Flash to RAM, and then it will be the runtime address.

Originally, for global variables and static variables assigned final values ​​​​in the program, after the program is compiled, MDK puts these variables in Flash, located next to the executable code. Before the program enters the main function, a piece of library code will be run to copy this part of the data to the corresponding RAM location.因为我的设备程序量不断降低,超过了为设备程序预留的Flash空间,在线编程时,将一部份储存全局变量和静态变量终值的Flash给重新编程了。在重启设备前,年率早已被拷贝到RAM中,所以这个时侯程序运行是正常的,但重新上电后,这部份年率实际上是在线编程的数据,自然与终值不同了。

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

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

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

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

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

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

通过查看MAP文件,原先MDK将堆栈放在程序使用到的RAM空间的旁边,例如你的RAM空间从0x40000000开始,你的程序用掉了0x200字节RAM,这么堆栈空间就从0x40000200处开始。

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

3.4.5有多少RAM会被初始化?

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

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

3.4.6MDK编译器怎么设置非零初始化变量?

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

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

分散加载文件对于联接器来说至关重要,在分散加载文件中,使用UNINIT来修饰一个执行节,可以防止编译器对该区节的ZI数据进行零初始化。这是要解决非零初始化变量的关键。因而我们可以定义一个UNINIT修饰的数据节,之后将希望非零初始化的变量装入这个区域中。于是,就有了第一种方式:

更改分散加载文件,降低一个名为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,假若还是定义一个链表,可以用下边方式:

unsigned char  plc_eu_backup[32] __attribute__((section("NO_INIT"),zero_init));

变量属性修饰符__attribute__((section(“name”),zero_init))用于将变量强制定义到name属性数据节中,zero_init表示将未初始化的变量放在ZI数据节中。由于“NO_INIT”这显性命名的自定义节,具有UNINIT属性。

将一个模块内的非初始化变量都非零初始化

如果该模块名子为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数据区的。

原来的:

文章来始于网路,版权归原作者所有,如有侵权,请联系删掉。

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

认为文章不错,点击“分享”、“赞”、“在看”呗!

收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

悟空资源网 源码编译 公式源码编译-编译高质量嵌入式C程序代码(上) https://www.wkzy.net/game/144814.html

常见问题

相关文章

官方客服团队

为您解决烦忧 - 24小时在线 专业服务