1.2 玩具般的编译器语义检查
为了更简单的设计编译器,目前几乎所有编译器的语义检查都比较弱小,加之为了获得更快的执行效率,C语言被设计的足够灵活且几乎不进行任何运行时检查,比如数组越界、指针是否合法、运算结果是否溢出等等。 C语言足够灵活,对于一个数组a[30],它允许使用像a[-1]这样的形式来快速获取数组首元素所在地址前面的数据;允许将一个常数强制转换为函数指针,使用代码(*((void(*)())0))()来调用位于0地址的函数。C语言给了程序员足够的自由,但也由程序员承担滥用自由带来的责任。下面的两个例子都是死循环,如果在不常用分支中出现类似代码,将会造成看似莫名其妙的死机或者重启。 a. unsigned char i; b. unsigned chari;
for(i=0;i<256;i++) {… } for(i=10;i>=0;i--) { … }
对于无符号char类型,表示的范围为0~255,所以无符号char类型变量i永远小于256(第一个for循环无限执行),永远大于等于0(第二个for循环无线执行)。需要说明的是,赋值代码i=256是被C语言允许的,即使这个初值已经超出了变量i可以表示的范围。C语言会千方百计的为程序员创造出错的机会,可见一斑。
假如你在if语句后误加了一个分号改变了程序逻辑,编译器也会很配合的帮忙掩盖,甚至连警告都不提示。代码如下:
if(a>b); //这里误加了一个分号
a=b; //这句代码一直被执行
不但如此,编译器还会忽略掉多余的空格符和换行符,就像下面的代码也不会给出足够提示:
if(n<3)
return //这里少加了一个分号
logrec.data=x[0];
logrec.time=x[1];
logrec.code=x[2];
这段代码的本意是n<3时程序直接返回,由于程序员的失误,return少了一个结束分号。编译器将它翻译成返回表达式logrec.data=x[0]的结果,return后面即使是一个表达式也是C语言允许的。这样当n>=3时,表达式logrec.data=x[0];就不会被执行,给程序埋下了隐患。 可以毫不客气的说,弱小的编译器语义检查在很大程度上纵容了不可靠代码可以肆无忌惮的存在。
上文曾提到数组常常是引起程序不稳定的重要因素,程序员往往不经意间就会写数组越界。一位同事的代码在硬件上运行,一段时间后就会发现LCD显示屏上的一个数字不正常的被改变。经过一段时间的调试,问题被定位到下面的一段代码中:
int SensorData[30];
…for(i=30;i>0;i--)
{
SensorData=…;
…
}
这里声明了拥有30个元素的数组,不幸的是for循环代码中误用了本不存在的数组元素SensorData[30],但C语言却默许这么使用,并欣然的按照代码改变了数组元素SensorData[30]所在位置的值, SensorData[30]所在的位置原本是一个LCD显示变量,这正是显示屏上的那个值不正常被改变的原因。真庆幸这么轻而易举的发现了这个Bug。 其实很多编译器会对上述代码产生一个警告:赋值超出数组界限。但并非所有程序员都对编译器警告保持足够敏感,况且,编译器也并不能检查出数组越界的所有情况。举一个例子,你在模块A中定义数组: int SensorData[30];
在模块B中引用该数组,但由于你引用代码并不规范,这里没有显示声明数组大小,但编译器也允许这么做: extern int SensorData[];
如果在模块B中存在和上面一样的代码:
for(i=30;i>0;i--)
{
SensorData=…;
…
}
这次,编译器不会给出警告信息,因为编译器压根就不知道数组的元素个数。所以,当一个数组声明为具有外部链接,它的大小应该显式声明。 再举一个编译器检查不出数组越界的例子。函数func()的形参是一个数组形式,函数代码简化如下所示:
char * func(char SensorData[30])
{
unsignedint i;
for(i=30;i>0;i--)
{
SensorData=…;
…
}
}
这个给SensorData[30]赋初值的语句,编译器也是不给任何警告的。实际上,编译器是将数组名Sensor隐含的转化为指向数组第一个元素的指针,函数体是使用指针的形式来访问数组的,它当然也不会知道数组元素的个数了。造成这种局面的原因之一是C编译器的作者们认为指针代替数组可以提高程序效率,而且,还可以简化编译器的复杂度。 指针和数组是容易给程序造成混乱的,我们有必要仔细的区分它们的不同。其实换一个角度想想,它们也是容易区分的:可以将数组名等同于指针的情况有且只有一处,就是上面例子提到的数组作为函数形参时。其它时候,数组名是数组名,指针是指针。 下面的例子编译器同样检查不出数组越界。 我们常常用数组来缓存通讯中的一帧数据。在通讯中断中将接收的数据保存到数组中,直到一帧数据完全接收后再进行处理。即使定义的数组长度足够长,接收数据的过程中也可能发生数组越界,特别是干扰严重时。这是由于外界的干扰破坏了数据帧的某些位,对一帧的数据长度判断错误,接收的数据超出数组范围,多余的数据改写与数组相邻的变量,造成系统崩溃。由于中断事件的异步性,这类数组越界编译器无法检查到。 如果局部数组越界,可能引发ARM架构硬件异常。同事的一个设备用于接收无线传感器的数据,一次软件升级后,发现接收设备工作一段时间后会死机。调试表明ARM7处理器发生了硬件异常,异常处理代码是一段死循环(死机的直接原因)。接收设备有一个硬件模块用于接收无线传感器的整包数据并存在自己的硬件缓冲区中,当一帧数据接收完成后,使用外部中断通知设备取数据,外部中断服务程序精简后如下所示:
__irq ExintHandler(void)
{
unsignedchar DataBuf[50];
GetData(DataBug); //从硬件缓冲区取一帧数据
…
}
由于存在多个无线传感器近乎同时发送数据的可能加之GetData()函数保护力度不够,数组DataBuf在取数据过程中发生越界。由于数组DataBuf为局部变量,被分配在堆栈中,同在此堆栈中的还有中断发生时的运行环境以及中断返回地址。溢出的数据将这些数据破坏掉,中断返回时PC指针可能变成一个不合法值,硬件异常由此产生。 如果我们精心设计溢出部分的数据,化数据为指令,就可以利用数组越界来修改PC指针的值,使之指向我们希望执行的代码。1988年,第一个网络蠕虫在一天之内感染了2000到6000台计算机,这个蠕虫程序利用的正是一个标准输入库函数的数组越界Bug。起因是一个标准输入输出库函数gets(),原来设计为从数据流中获取一段文本,遗憾的是,gets()函数没有规定输入文本的长度。gets()函数内部定义了一个500字节的数组,攻击者发送了大于500字节的数据,利用溢出的数据修改了堆栈中的PC指针,从而获取了系统权限。
一个程序模块通常由两个文件组成,源文件和头文件。如果你在源文件定义变量: unsigned int a;
并在头文件中声明该变量:extern unsigned long a; 编译器会提示一个语法错误:变量’a’声明类型不一致。但如果你在源文件定义变量: volatile unsigned int a,
在头文件中声明变量:extern unsigned int a; /*缺少volatile限定符*/ 编译器却不会给出错误信息(有些编译器仅给出一条警告)。这里volatile属于类型限定符,另一个常见的类型限定符是const关键字。限定符volatile在嵌入式软件中至关重要,用来告诉编译器不要优化它修饰的变量。这里举一个刻意构造出的例子,因为现实中的volatile使用Bug大都隐含且难以理解。 在模块A的源文件中,定义变量: volatile unsigned int TimerCount=0;
该变量用来在一个定时器服务程序中进行软件计时: TimerCount++; //读取IO端口1的值
在模块A的头文件中,声明变量: extern unsigned int TimerCount; //这里漏掉了类型限定符volatile
在模块B中,要使用TimerCount变量进行精确的软件延时:
#include “...A.h” //首先包含模块A的头文件
…
TimerCount=0;
while(TimerCount>=TIMER_VALUE); //延时一段时间
…
实际上,这是一个死循环。由于模块A头文件中声明变量TimerCount时漏掉了volatile限定符,在模块B中,变量TimerCount是被当作unsigned int类型变量。由于寄存器速度远快于RAM,编译器在使用非volatile限定变量时是先将变量从RAM中拷贝到寄存器中,如果同一个代码块再次用到该变量,就不再从RAM中拷贝数据而是直接使用之前寄存器备份值。代码while(TimerCount>=TIMER_VALUE)中,变量TimerCount仅第一次执行时被使用,之后都是使用的寄存器备份值,而这个寄存器值一直为0,所以程序无限循环。下面的流程图说明了程序使用限定符volatile和不使用volatile的执行过程。
ARM架构下的编译器会频繁的使用堆栈,堆栈用于存储函数的返回值、AAPCS规定的必须保护的寄存器以及局部变量,包括局部数组、结构体、联合体和C++的类。从堆栈中分配的局部变量的初值是不确定的,因此需要运行时显式初始化该变量。一旦离开局部变量的作用域,这个变量立即被释放,其它代码也就可以使用它,因此堆栈中的一个内存位置可能对应整个程序的多个变量。 局部变量必须显式初始化,除非你确定知道你要做什么。下面的代码得到的温度值跟预期会有很大差别,因为在使用局部变量sum时,并不能保证它的初值为0。编译器会在第一次运行时清零堆栈区域,这加重了此类Bug的隐蔽性。
unsigned intGetTempValue(void)
{
unsigned int sum; //定义局部变量,保存总值
for(i=0;i<10;i++)
{
sum+=CollectTemp(); //函数CollectTemp可以得到当前的温度值
}
return (sum/10);
}
由于一旦程序离开局部变量的作用域即被释放,所以下面代码返回指向局部变量的指针是没有实际意义的,该指针指向的区域可能会被其它程序使用,其值会被改变。
char * GetData(void)
{
char buffer[100]; //局部数组
…
return buffer;
}
让人欣慰的是,现在越来越多的编译器意识到了语义检查的重要性,编译器的语义检查也越来越强大,比如著名的Keil MDK编译器在其 V4.47或以上版本中增加了动态语法检查并加强了语义检查,可以友好的提示更多警告信息。
|