程序设计语言

c语言探险

2008年5月24日 阅读(321)

转载请注明作者:phylips@bmy  出处:http://duanple.blog.163.com/blog/static/70971767200842415342426/

本文首先对c语言的一些问题进行了探讨,如果你觉得你的c学的很好了,不妨看看这些例子检验一下你是否真的懂了它?然后就语言中的一些未定义细节进行了列举,通过这你可以写出移植性更好的c程序,对一些编程的惯用法进行了提示,帮助你写出更好的c程序。最后列举了c语言的一些著名的bug,在这里你会发现小小的bug所引发的历史上著名的灾难性事故。最后的附录里将是一些著名的c puzzles,原文出自国外。

一.c语言实例
1.表达式 y=x/*p;有问题没?
按照普通的理解(其实也不普通,需要你知道运算符的优先级)上式应该理解为y=(x/(*p));也就是说*的优先级高于/。这样看来这个式子是没有问题的。

不过很可惜,它是有问题的,问题出在词法分析阶段,/*会被识别成注释,这样它是无法通过编译的。这实际是由词法分析的贪心选择造成的。也就是在识别出一个符号之后,下一个符号将选择尽可能多的字符组成。

但是如果我们这样来写y = x / *p,便是可以的这也是为何将大多数运算符加空格带来的好处,如这样写条件表达式一样if(0 == x)

再如a+++++b;会被识别成 a++ ++ +b;这样它也是错的,因为a++不能做左值,而++需要左指。

2.函数声明
c语言的老版本,函数声明是没有形参列表的,为了兼容性,当函数形参为空的时候,表示没有对形参的描述,但不代表没有参数,如果真的没有形参,请用void显示说明。

库的头文件的引入,便主要是为了函数和一些变量的声明,当然不引入头文件也是可以的。但是这样编译器不会进行参数检查,同时会对函数进行某些假定,如果假定与实际不符合,则行为无定义。这样可以通过编译,但是在连接阶段可能出错。

double a();
double a(char a){}
如上的声明和定义在编译时会产生冲突,原因是对于没有函数参数的声明时,会对参数进行参数提升,char,short类型将变为int,float将变为double,而对原本参数是char,short,float的函数定义这显然不是作者的本意。

而对于这样的声明double sqrt(int );因为与库函数定义是冲突的,产生结果便是未定义的。原因是编译器在编译时会根据声明和实际参数进行类型假定,如果最终结果与本来定义相同则没有问题,否则结果未定义。

如果函数在被定义或声明前调用,返回类型默认为int,参数类型默认为提升后的类型。

3.什么是溢出?如何检测?
整形运算中分有符号和无符号运算两者,无符号运算没有溢出一说,结果会mod 2的n次方。如果运算中一个无符号一个有符号,则会发生转换为无符号,也不会溢出。只有全是有符号时才会溢出。一种可能的方法是这样的
if(a+b < 0) 溢出;但是这是不正确的,因为a+b的溢出成为事实后其结果不再可靠。

可行的方法是可以将他们都转换为无符号类型if((unsigned)a+(unsigned)b > INT_MAX)溢出;
或者if(a > INT_MAX-b)溢出;
更详细的内容请参见我zz的一篇文章<<Basic Integer Overflows >>

4.printf与scanf工作原理
它们都具有可变参数列表,关于可变参数的实现可以参考<<cprogramming language>>.可变参数要求,必须至少具有一个已知参数,如printf第一个必然是字符串,这样便可以根据第一个字符串来识别其他参数类型。

printf("%ld,%ld,%ld,%ld",n1,n2,n3,n4);

这个调用告诉计算机,要把变量n1,n2,n3和n4的值交给计算机,它把这些变量放进称作栈(stack)的内存区域中,来完成这一任务。计算机把这些值放进栈中,其根据是变量的类型而不是转换说明符,比如n1,把8个字节放入栈中(float被转换成double),类似地,为n2放了8字节,其后给n3和n4各放了4个字节。接着,控制的对象转移到printf();此函数从栈中读数,不过在这一过程中,它是在转换说明符的指导下,读取数值的。
说明符%ld指定printf()应读4个字节(va_arg( va_listarg_ptr, type )中type=long),因此printf()读入栈中的4个字节,作为它的第一个值。但是这只是n1的前半部分,这个值被看成一个long整数。下一个说明符%ld读入4个字节,这正是n1的后半部分,这个值被看成第二个long整数。类似地,第三、第四次又读入n2的前后两部分。因此,尽管我们对n3和n4使用了正确的说明符,printf()仍然会产生错误。

而对于scanf来说,则是通过格式控制串对后面的变量地址进行赋值的。 仅仅把参数作为地址,而类型则通过格式控制串指定。

5.int c = getchar();
为何c要用int类型,getchar()函数返回值本身为int类型。在如下while(c != EOF){}中,如果c为char会出现什么问题?

答案是这样的在很多系统中EOF可能是-1,也可能是其他值,总之是个int型。如果赋给char类型,则会发生截断错误,总之结果将是编译器相关的。

另外如果c为char,但是不标明是否signed,这样类型提升时编译器可能表现出不同的行为,如果是看成signed类型,则可能牵扯到符号扩展问题,这样转化后的字符可能便是-128–127,如果是看成无符号类型则变成了0–255.

6.expr1 op= expr2的含义是什么?与expr1 = (expr1) op (expr2)区别是什么?

其含义就是expr1 = (expr1) op (expr2),区别在前者只对expr1求值一次。

7.求值顺序
在c中只有&&,||,?:,","的求值顺序是有明确规定的,其他的诸如函数参数等等均没有定义,因此取决于实现。这是因为具体的求值顺序跟硬件相关,这样可以让编译器根据具体环境进行优化。

prt[count] = name[++count];该式变依赖于求值顺序,因此在不同的实现上就可能出现不同结果。应当明确的是prt[count] 实际上可以看成*(prt+count),因此实际包含了一个指针与整数的加法运算。

8.n与-n:atoi与itoa函数

我见过很多类似的实现但是它们中绝大部分都存在着c陷阱与缺陷在7.11里提到的问题。

观察如下的atoi实现:
void   itoa(int   n,char   s[])  
  {  
    int   i,sign;  
    if   ((sign=n)<0)  
            n=-n;  
    i=0;  
    do{  
        s[i++]=n%10+’0′;  
    }while   ((n/=10)>0);  
    if(sign<0)  
            s[i++]=’-‘;  
    s[i]=’0′;  
    reverse(s);  
  }  

这里面便涉及到了n与-n的问题,之所以会出问题是因为在二进制补码表示中,正负部分不是对称的,比如16位整数是-32768-32767, -32768是没有正数与其相对的,即-(-32768)会出问题。

一个简单的解决方案如下,对INT_MIN进行特例判断,如果发现n==INT_MIN,则直接输出"-32768"不再进行其他处理。

9.操作符相关
?:会进行类型转换,看成普通运算符即可。
关于各操作符的介绍,可以参考<<c programming language>>的附录部分。

10.副作用
所谓副作用是指在求值的同时,还修改了变量的值。函数调用,嵌套定义,自增,自减都可能具有副作用。

11.预处理
#include 属于包含
#define 属于替换,#为了进行字符串表示,##为了符号连接
#if 后面的表达式中不能含sizeof ,强制类型转换,enum常量
而#define可以。原因是if会对表达式求值,而define不会。
defined(),表达式是这样实现的,首先进行宏替换,如果发现已经被替换了,则改为1,否则为0。
#undef即使未定义过的符号使用它也没有问题。
宏中的空格是不能被忽略的
当参数表达式具有副作用时,避免使用宏。

考虑下assert宏的实现,参见<<c traps and pitfalls>>,你会发现实现一个宏不是那么简单的,里面的陷阱很多。
如果这样定义
#define assert(e) if(!e) assert_error(__FILE__,__LINE__)
因为使用了if,很可能造成else的短路。如if(x > 0 && y > 0 ) assert(x > y);

有专门关于预处理器的书籍<<The C Preprocessor>>,可参考。

12.理解复杂声明
具体可参考<<c programming language>>和<<c traps and pitfalls>>.另外可以考虑实现一下<<c programming language>>上那个标示符解释器。

另外使用typedef可以简化声明。

13.其他问题
如移位(有符号右移会出现是否符号扩展的不同实现),字符编码unicode asc,大小端问题,三字母词,x&(x-1),浮点运算,溢出。这些问题都是基本的程序设计问题,很多出现在所有的语言中,而非仅仅是c。
________________________________________________________________________________________

.
二.为定义行为
在c里面很多行为是未定义的,我们的程序不应当依赖于这些未定义行为,否则将不可移植。仅列举一二,<<c programming language>>的参考手册中可以找到很多。

1.不带限定符的char是否signed取决于具体实现
2.大小写转换,某些字符集如EBCDIC的字母不是连续的
3.char转换为int是否进行符号扩展取决于实现
4.带符号值与无符号值进行是与机器相关的,取决于字长
5.double->float是截取还是四舍五入,取决于实现
6.signed类型右移是否符号扩展与机器相关
7.函数声明于定义不一致,结果无定义
8.很多运算符的求值顺序是编译器相关的
9.printf格式串中的%后的字符不是控制符,结果无定义,参数个数类型不一致,结果无定义
10.不同数组指针进行比较结果未定义
11.位域在字节中是从左往右还是从右往左分配是机器相关的
12.试图修改常量字符串,结果未定义
13.union的最近一次写读必须使用相同类型,否则结果取决于实现
———————————————————————————————————————————————–

.

三.编程建议
充分利用语言的特性,编写出高质量的程序。可以关注语言每次更新增加的特性,很明显这些特性就是为了提
高程序效率,质量而增加的。

1.如果可以请尽量使用static,const,enum类型,尽量不使用全局变量,避免出现常量字面值。
2.函数变量使用前一定声明,并提供完整的形参说明
3.函数书写返回值独占一行,保证函数名在行首,提高程序可读性
4.不要使用未定义行为,依赖于硬件,操作系统,编译器,非标准库的行为。
5.即使作用域不同也不要使用同名变量,避免混淆
6.使用typedef对声明简化
7.使用库函数<limits.h><ctype.h>等进行字符串和数的相关操作。
8.尽量避免使用switch的顺次执行性质和goto语句。但特殊情况也可使用比如多字符判断,以及处理深层循环调到出错处理
9.main函数应当提供返回值以报告程序运行结果状态。
———————————————————————————————————————————————–
四.史上著名bug
最早的bug应当是mark 2上的那只飞蛾,由于它飞到了继电器上造成了短路。这个飞蛾后来被弄走并且粘在了项目的日志上,oh,很具有历史性的一刻。
1.千年虫
忘了什么年代看的一部电视剧了,大概叫做<<力克千年虫>>,所谓的千年虫问题源于远见的缺乏。上世纪的人们在软件中普遍采用两位数表示年份,但是到世纪末问题便出现了,这样的软件仍在大量运行中,如何区分2008和1908变出现了问题,当2000年年份突然从99变到00时会出现何种问题呢?

2.Therac-25可能是近年来损失最大的一个bug。
在1985-1987年间,有6个病人被它治疗过,其中三人死亡,死于过量治疗。该系统是由加拿大自动能源公司开发的放射治疗仪器。

这是因为该仪器工作在光子或电子两种模式,光子的默认能量水平是25meV。当操作员选择光子模式,之后发现错误,再修改,但是由于模块中的一个逻辑错误,使得系统在模式能量在初始输入8秒钟内无法发现编辑的变化,于是会导致输送过剂量的射线。

在该机器的生命里出现了两个bug:
1.一个逻辑错误,使得操作员修改了机器状态后,并没有升级机器参数
2.当一个8位的参数溢出变为0时,安全检查便被跳过了

如果采用必要的硬件防护措施,或许就不会造成那么坏的影响了
所以不要对你的软件总是充满信心,当有问题时,说明是真的有问题,不是别的。。。

3.intel浮点处理器bug
1993年intel推出了奔腾芯片,后来Nicely教授用它来寻找素数三元偶(连续三个奇数都是素数),由于他还采用了另一套验证程序,发现运算结果出现了问题,经过半年的努力,最终将错误定位的intel的浮点运算单元。并报告给了intel,起初intel极力维护,并声称该bug出现的概率为90亿分之一。后来一位Stanford大学的教授指出他可以将这个bug每30毫秒触发一次,而且看起来比较平常的一个运算4.999999/14.999999结果将是0.00000407。后来ibm也发表报告,质疑intel的宣称。

导致最后intel不得不将其所有的有问题的芯片召回。这个bug是很隐秘的,如果不是大规模的科学运算,估计很难被发现。该bug的原因在于一个脚本错误忽略了查找表中的一些项。

4.鱼雷
早期鱼雷设计时,为了防止发射后破坏本方潜艇,被设计成这样"当发生180度转向时自毁"。一天一个潜艇上尉决定发射一颗鱼雷,但是不幸的是,鱼雷被卡在发射舱。于是上尉决定让潜艇返港以进行修理,当潜艇自身进行180度转弯时,那颗鱼雷暴了。。。

5.Ariane操作数错误
这是一个将64位浮点数转换为16位的有符号整数的时候,因为一个未被解决的异常而引起的事故。

Ariane5火箭在它1996年6月4日首次发射的40秒之后爆炸了。对飞行数据分析显示,在爆炸之前一切正常包括天气。最终错误的来源于重用自Ariane4的模块,可见不合适的重用会是一个灾难的开始。

而它之所以通过了测试,是因为测试人员在飞行软件的功能模拟测试中并没有加入实际的惯性制导系统。

人们一直以为当软件没有表现出错误的时候,就可以认为是正确的。实际上在软件被验证正确之前,我们应当把它看成错误的。这就是不同的软件测试哲学。

6.火星气象卫星

这是美国火星探测计划的一部分,该计划准备在未来的10年内,平均每年发射一个飞行器。头两个飞行器在1996年发射,火星气象卫星在1999年1月3日发射,但是在它和"火星极地登陆者"到达火星不久之后便消失了。这两个飞行器大约化了NASA3亿三千万美元,原因在于某个数值本应该使用公制单位牛顿,但是却被错误的使用了磅。

7.AT&T电话中断
1990年1月15日,AT&T发生了全国范围内持续9个小时的电话中断。原因就在于switch-case里的break语言并非按照期望的地方跳转。

8.缓冲区
在c中,很容易出现的bug,c的字符串表示法以及scanf,strcpy等函数极易出现该错误,唯一的方法就是使用更为安全的strncpy等函数。
比如ms的outlook,1999年的“ILOVEYOU”病毒都是这方面的例子。

这篇够长了,c puzzles部分另开头。。。。。。

You Might Also Like