如果你和一个优秀的程序员共事,你会发现他对他使用的工具非常熟悉,就像一个画家了解他的画具一样。----比尔.盖茨
1不能简单的认为是个工具嵌入式程序开发跟硬件密切相关,需要使用C语言来读写底层寄存器、存取数据、控制硬件等,C语言和硬件之间由编译器来联系,一些C标准不支持的硬件特性操作,由编译器提供。汇编可以很轻易的读写指定RAM地址、可以将代码段放入指定的Flash地址、可以精确的设置变量在RAM中分布等等,所有这些操作,在深入了解编译器后,也可以使用C语言实现。C语言标准并非完美,有着数目繁多的未定义行为,这些未定义行为完全由编译器自主决定,了解你所用的编译器对这些未定义行为的处理,是必要的。嵌入式编译器对调试做了优化,会提供一些工具,可以分析代码性能,查看外设组件等,了解编译器的这些特性有助于提高在线调试的效率。此外,堆栈操作、代码优化、数据类型的范围等等,都是要深入了解编译器的理由。如果之前你认为编译器只是个工具,能够编译就好。那么,是时候改变这种思想了。不能依赖编译器的语义检查编译器的语义检查很弱小,甚至还会“掩盖”错误。现代的编译器设计是件浩瀚的工程,为了让编译器设计简单一些,目前几乎所有编译器的语义检查都比较弱小。为了获得更快的执行效率,C语言被设计的足够灵活且几乎不进行任何运行时检查,比如数组越界、指针是否合法、运算结果是否溢出等等。这就造成了很多编译正确但执行奇怪的程序。
C语言足够灵活,对于一个数组test[0],它允许使用像test[-1]这样的形式来快速获取数组首元素所在地址前面的数据;允许将一个常数强制转换为函数指针,使用代码(((void()())0))()来调用位于0地址的函数。C语言给了程序员足够的自由,但也由程序员承担滥用自由带来的责任。
.1莫名的死机下面的两个例子都是死循环,如果在不常用分支中出现类似代码,将会造成看似莫名其妙的死机或者重启。
unsignedchari;//例程1for(i=0;i56;i++){//其它代码}
unsignedchari;//例程for(i=10;i=0;i--){//其它代码}
对于无符号char类型,表示的范围为0~55,所以无符号char类型变量i永远小于56(第一个for循环无限执行),永远大于等于0(第二个for循环无限执行)。需要说明的是,赋值代码i=56是被C语言允许的,即使这个初值已经超出了变量i可以表示的范围。C语言会千方百计的为程序员创造出错的机会,可见一斑。
.不起眼的改变假如你在if语句后误加了一个分号,可能会完全改变了程序逻辑。编译器也会很配合的帮忙掩盖,甚至连警告都不提示。代码如下:
if(ab);//这里误加了一个分号a=b;//这句代码一直被执行
不但如此,编译器还会忽略掉多余的空格符和换行符,就像下面的代码也不会给出足够提示:
这段代码的本意是n时程序直接返回,由于程序员的失误,return少了一个结束分号。编译器将它翻译成返回表达式logrec.data=x[0]的结果,return后面即使是一个表达式也是C语言允许的。这样当n=时,表达式logrec.data=x[0];就不会被执行,给程序埋下了隐患。
.难查的数组越界上文曾提到数组常常是引起程序不稳定的重要因素,程序员往往不经意间就会写数组越界。
一位同事的代码在硬件上运行,一段时间后就会发现LCD显示屏上的一个数字不正常的被改变。经过一段时间的调试,问题被定位到下面的一段代码中:
intSensorData[0];//其他代码for(i=0;i0;i--){SensorData=…;//其他代码}
这里声明了拥有0个元素的数组,不幸的是for循环代码中误用了本不存在的数组元素SensorData[0],但C语言却默许这么使用,并欣然的按照代码改变了数组元素SensorData[0]所在位置的值,SensorData[0]所在的位置原本是一个LCD显示变量,这正是显示屏上的那个值不正常被改变的原因。真庆幸这么轻而易举的发现了这个Bug。
其实很多编译器会对上述代码产生一个警告:赋值超出数组界限。但并非所有程序员都对编译器警告保持足够敏感,况且,编译器也并不能检查出数组越界的所有情况。比如下面的例子:
你在模块A中定义数组:
intSensorData[0];
在模块B中引用该数组,但由于你引用代码并不规范,这里没有显示声明数组大小,但编译器也允许这么做:
externintSensorData[];
这次,编译器不会给出警告信息,因为编译器压根就不知道数组的元素个数。所以,当一个数组声明为具有外部链接,它的大小应该显式声明。
再举一个编译器检查不出数组越界的例子。函数func()的形参是一个数组形式,函数代码简化如下所示:
这个给SensorData[0]赋初值的语句,编译器也是不给任何警告的。实际上,编译器是将数组名Sensor隐含的转化为指向数组第一个元素的指针,函数体是使用指针的形式来访问数组的,它当然也不会知道数组元素的个数了。造成这种局面的原因之一是C编译器的作者们认为指针代替数组可以提高程序效率,而且,可以简化编译器的复杂度。
指针和数组是容易给程序造成混乱的,我们有必要仔细的区分它们的不同。其实换一个角度想想,它们也是容易区分的:可以将数组名等同于指针的情况有且只有一处,就是上面例子提到的数组作为函数形参时。其它时候,数组名是数组名,指针是指针。
下面的例子编译器同样检查不出数组越界。
我们常常用数组来缓存通讯中的一帧数据。在通讯中断中将接收的数据保存到数组中,直到一帧数据完全接收后再进行处理。即使定义的数组长度足够长,接收数据的过程中也可能发生数组越界,特别是干扰严重时。
这是由于外界的干扰破坏了数据帧的某些位,对一帧的数据长度判断错误,接收的数据超出数组范围,多余的数据改写与数组相邻的变量,造成系统崩溃。由于中断事件的异步性,这类数组越界编译器无法检查到。
如果局部数组越界,可能引发ARM架构硬件异常。
同事的一个设备用于接收无线传感器的数据,一次软件升级后,发现接收设备工作一段时间后会死机。调试表明ARM7处理器发生了硬件异常,异常处理代码是一段死循环(死机的直接原因)。接收设备有一个硬件模块用于接收无线传感器的整包数据并存在自己的缓冲区中,当硬件模块接收数据完成后,使用外部中断通知设备取数据,外部中断服务程序精简后如下所示:
__irqExintHandler(void){unsignedcharDataBuf[50];GetData(DataBug);//从硬件缓冲区取一帧数据//其他代码}
由于存在多个无线传感器近乎同时发送数据的可能加之GetData()函数保护力度不够,数组DataBuf在取数据过程中发生越界。由于数组DataBuf为局部变量,被分配在堆栈中,同在此堆栈中的还有中断发生时的运行环境以及中断返回地址。溢出的数据将这些数据破坏掉,中断返回时PC指针可能变成一个不合法值,硬件异常由此产生。
如果我们精心设计溢出部分的数据,化数据为指令,就可以利用数组越界来修改PC指针的值,使之指向我们希望执行的代码。
年,第一个网络蠕虫在一天之内感染了到6台计算机,这个蠕虫程序利用的正是一个标准输入库函数的数组越界Bug。起因是一个标准输入输出库函数gets(),原来设计为从数据流中获取一段文本,遗憾的是,gets()函数没有规定输入文本的长度。
gets()函数内部定义了一个字节的数组,攻击者发送了大于字节的数据,利用溢出的数据修改了堆栈中的PC指针,从而获取了系统权限。目前,虽然有更好的库函数来代替gets函数,但gets函数仍然存在着。
.4神奇的volatile做嵌入式设备开发,如果不对volatile修饰符具有足够了解,实在是说不过去。volatile是C语言个关键字中的一个,属于类型限定符,常用的const关键字也属于类型限定符。
volatile限定符用来告诉编译器,该对象的值无任何持久性,不要对它进行任何优化;它迫使编译器每次需要该对象数据内容时都必须读该对象,而不是只读一次数据并将它放在寄存器中以便后续访问之用(这样的优化可以提高系统速度)。
这个特性在嵌入式应用中很有用,比如你的IO口的数据不知道什么时候就会改变,这就要求编译器每次都必须真正的读取该IO端口。这里使用了词语“真正的读”,是因为由于编译器的优化,你的逻辑反应到代码上是对的,但是代码经过编译器翻译后,有可能与你的逻辑不符。
你的代码逻辑可能是每次都会读取IO端口数据,但实际上编译器将代码翻译成汇编时,可能只是读一次IO端口数据并保存到寄存器中,接下来的多次读IO口都是使用寄存器中的值来进行处理。因为读写寄存器是最快的,这样可以优化程序效率。与之类似的,中断里的变量、多线程中的共享变量等都存在这样的问题。
不使用volatile,可能造成运行逻辑错误,但是不必要的使用volatile会造成代码效率低下(编译器不优化volatile限定的变量),因此清楚的知道何处该使用volatile限定符,是一个嵌入式程序员的必修内容。
一个程序模块通常由两个文件组成,源文件和头文件。如果你在源文件定义变量:
unsignedinttest;
并在头文件中声明该变量:
externunsignedlongtest;
编译器会提示一个语法错误:变量’test’声明类型不一致。但如果你在源文件定义变量:
volatileunsignedinttest;
在头文件中这样声明变量:
externunsignedinttest;/*缺少volatile限定符*/
编译器却不会给出错误信息(有些编译器仅给出一条警告)。当你在另外一个模块(该模块包含声明变量test的头文件)使用变量test时,它已经不再具有volatile限定,这样很可能造成一些重大错误。比如下面的例子,注意该例子是为了说明volatile限定符而专门构造出的,因为现实中的volatile使用Bug大都隐含,并且难以理解。
在模块A的源文件中,定义变量:
volatileunsignedintTimerCount=0;
该变量用来在一个定时器中断服务程序中进行软件计时:
TimerCount++;
在模块A的头文件中,声明变量:
externunsignedintTimerCount;//这里漏掉了类型限定符volatile
在模块B中,要使用TimerCount变量进行精确的软件延时:
#include“…A.h”//首先包含模块A的头文件//其他代码TimerCount=0;while(TimerCount=TIMER_VALUE);//延时一段时间(感谢网友chhfish指这里的逻辑错误)//其他代码
实际上,这是一个死循环。由于模块A头文件中声明变量TimerCount时漏掉了volatile限定符,在模块B中,变量TimerCount是被当作unsignedint类型变量。由于寄存器速度远快于RAM,编译器在使用非volatile限定变量时是先将变量从RAM中拷贝到寄存器中,如果同一个代码块再次用到该变量,就不再从RAM中拷贝数据而是直接使用之前寄存器备份值。
代码while(TimerCount=TIMER_VALUE)中,变量TimerCount仅第一次执行时被使用,之后都是使用的寄存器备份值,而这个寄存器值一直为0,所以程序无限循环。下面的流程图说明了程序使用限定符volatile和不使用volatile的执行过程。
为了更容易的理解编译器如何处理volatile限定符,这里给出未使用volatile限定符和使用volatile限定符程序的反汇编代码:
没有使用关键字volatile,在keilMDKV4.54下编译,默认优化级别,如下所示(注意最后两行):1:unIdleCount=0;1:0x0E10E59F11D4LDRR1,[PC,#0x01D4]0x0E14EA05MOVR5,#key1(0x00)0x0E18E1A05MOVR0,R50x0E1CE5815STRR5,[R1]14:while(unIdleCount!=00);//延时S钟15:0x0E0E5C8CMPR0,#0xC80x0E41AFFFFFDBNE0x0E0/span使用关键字volatile,在keilMDKV4.54下编译,默认优化级别,如下所示(注意最后三行):
1:unIdleCount=0;1:0x0E10E59F01D4LDRR0,[PC,#0x01D4]0x0E14EA05MOVR5,#key1(0x00)0x0E18E5805STRR5,[R0]14:while(unIdleCount!=00);//延时S钟15:0x0E1CE5901LDRR1,[R0]0x0E0EC8CMPR1,#0xC80x0E41AFFFFFCBNE0x0E1C
可以看到,如果没有使用volatile关键字,程序一直比较R0内数据与0xC8是否相等,但R0中的数据是0,所以程序会一直在这里循环比较(死循环);再看使用了volatile关键字的反汇编代码,程序会先从变量中读出数据放到R1寄存器中,然后再让R1内数据与0xC8相比较,这才是我们C代码的正确逻辑!
.5局部变量ARM架构下的编译器会频繁的使用堆栈,堆栈用于存储函数的返回值、AAPCS规定的必须保护的寄存器以及局部变量,包括局部数组、结构体、联合体和C++的类。默认情况下,堆栈的位置、初始值都是由编译器设置,因此需要对编译器的堆栈有一定了解。
从堆栈中分配的局部变量的初值是不确定的,因此需要运行时显式初始化该变量。一旦离开局部变量的作用域,这个变量立即被释放,其它代码也就可以使用它,因此堆栈中的一个内存位置可能对应整个程序的多个变量。
局部变量必须显式初始化,除非你确定知道你要做什么。下面的代码得到的温度值跟预期会有很大差别,因为在使用局部变量sum时,并不能保证它的初值为0。编译器会在第一次运行时清零堆栈区域,这加重了此类Bug的隐蔽性。
由于一旦程序离开局部变量的作用域即被释放,所以下面代码返回指向局部变量的指针是没有实际意义的,该指针指向的区域可能会被其它程序使用,其值会被改变。
char*GetData(void){charbuffer[];//局部数组…returnbuffer;}.6使用外部工具
由于编译器的语义检查比较弱,我们可以使用第三方代码分析工具,使用这些工具来发现潜在的问题,这里介绍其中比较著名的是PC-Lint。
PC-Lint由GimpelSoftware公司开发,可以检查C代码的语法和语义并给出潜在的BUG报告。PC-Lint可以显著降低调试时间。
目前公司ARM7和Cortex-M内核多是使用KeilMDK编译器来开发程序,通过简单配置,PC-Lint可以被集成到MDK上,以便更方便的检查代码。MDK已经提供了PC-Lint的配置模板,所以整个配置过程十分简单,KeilMDK开发套件并不包含PC-Lint程序,在此之前,需要预先安装可用的PC-Lint程序,配置过程如下:
点击菜单Tools---Set-upPC-Lint…PC-LintIncludeFolders:该列表路径下的文件才会被PC-Lint检查,此外,这些路径下的文件内使用#include包含的文件也会被检查;
LintExecutable:指定PC-Lint程序的路径
ConfigurationFile:指定配置文件的路径,该配置文件由MDK编译器提供。
菜单Tools---Lint文件路径.c/.h检查当前文件。
菜单Tools---LintAllC-SourceFiles检查所有C源文件。
PC-Lint的输出信息显示在MDK编译器的BuildOutput窗口中,双击其中的一条信息可以跳转到源文件所在位置。
编译器语义检查的弱小在很大程度上助长了不可靠代码的广泛存在。随着时代的进步,现在越来越多的编译器开发商意识到了语义检查的重要性,编译器的语义检查也越来越强大,比如公司使用的KeilMDK编译器,虽然它的编辑器依然不尽人意,但在其V4.47及以上版本中增加了动态语法检查并加强了语义检查,可以友好的提示更多警告信息。建议经常