前言:这是一年前我为公司内部写的一个文档,旨在向年轻的嵌入式软件工程师们介绍如何在裸机环境下编写优质嵌入式C程序。感觉是有一定的参考价值,所以拿出来分享,抛砖引玉。
摘要:本文首先分析了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从而引起顺序执行多个case语句,这也许是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. }
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]=‘a’;
- 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中的前四个元素了。