加入星计划,您可以享受以下权益:

  • 创作内容快速变现
  • 行业影响力扩散
  • 作品版权保护
  • 300W+ 专业用户
  • 1.5W+ 优质创作者
  • 5000+ 长期合作伙伴
立即加入
  • 正文
    • 1、词法陷阱
    • 2、语法陷阱
    • 3、 语义陷阱
    • 4、连接
  • 推荐器件
  • 相关推荐
  • 电子产业图谱
申请入驻 产业图谱

C陷阱与缺陷(上)

2023/06/12
4003
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

《C陷阱与缺陷》不是批判 C 语言,而是要帮助 C 程序员绕过编程过程中的陷阱和障碍。【嵌入式系统】对其删减简化,取其精华,即便 C 编程高手,也不妨多阅读参考,也可参考《高质量嵌入式软件的开发技巧》,或者关注微信公众号。

目录

1、词法陷阱

2、语法陷阱

3、语义陷阱

4、连接

5、库函数

6、预处理器

7、可移植性缺陷

8、建议与答案

1、词法陷阱

理解一个句子不用考虑组成句子的单词中单个字母的含义,而是把单词作为一个整体来理解,字母本身并没有意义。对于C 语言程序也是一样的,程序中的单个字符孤立看并没有意义,结合上下文才有意义。

p->s = "->"; 这个语句中两处出现的-字符的意义大相径庭,第一个-是符号->的组成部分,而第二个-字符是一个字符串的组成部分。

术语“符号”(token) 指的是程序的一个基本组成单元,相当于一个句子中的单词。符号就是程序中的一个基本信息单元。而组成符号的字符序列就不同,同一组字符序列在某个上下文环境中属于一个符号,而在另一个上下文环境中可能属于完全不同的另一个符号。

1.1 =不同于==

C 语言使用符号=作为赋值运算,符号==作为比较,这可能导致一个潜在的问题,本意是作比较运算,却可能误写成了赋值运算。比如本意是要检查 x 是否等于 y:

if (x = y) foo();

而实际上是将 y 的值赋给了 x,然后检查该值是否为零。

某些 C 编译器在发现形如 el = e2 的表达式出现在条件判断部分时,会给出警告,当确实需要对变量进行赋值并检查该变量的新值是否为 0 时,为了避免来自该类编译器的警告,应该显式地进行比较:

if ((x = y) != 0) foo();

这种写法也使得代码的意图一目了然。至于为什么要用括号把x=y 括起来, 2.2 节在讨论。

前面谈的是把比较运算误写成赋值运算的情形,另一方面,如果把赋值运算误写成比较运算,同样会造成混清。某些编译器在遇到这种情况时,会警告与 0 比较无效。但是程序员不能指望靠编译器来提醒,毕竟警告消息可以被忽略,而且不是所有编译器都具备这样的功能。

1.2 &和|不同于&&和||

按位运算符&与逻辑运算符&&,或按位运算符 | 与逻辑运算符 ||,也是很容易犯的错误。关于这些运算符精确含义的讨论见本书的 3.8 节。

1.3 词法分析中的贪心法

某些符号如/和=,只有一个字符长,称为单字符符号。其他符号如/*和 == 包括了多个字符,称为多字符符号。当 C 编译器读入一个字符/后又跟了一个字符,编译器就必须做出判断,是将其作为两个独立的符号对待,还是合起来作为一个符号对待?

C语言对这个问题的解决可以归纳为一个简单的规则: 每一个符号应该包含尽可能多的字符。编译器将程序分解成符号的方法是,从左到右一个一个字符地读入,如果该字符可能组成一个符号,那么再读入下一个字符,这个处理策略被称为“贪心法” 。

需要注意的是,除了字符串与字符常量,符号的中间不能嵌有空白 (空格符、制表符和换行符)。例如,==是单个符号,而=  = 则是两个符号,a---b  与 a -- - b 的含义相同,而与a - -- b 的含义不同。

同样地,如果/是为判断下一个符号而读入的第一个字符,而/之后紧接着 *  ,那么这两个字符都将被当作一个符号,表示一段注释的开始。下面的语句的本意是用x 除以p 所指向的值,把所得的商再赋给 y:

y = x/*p     /* p 指向除数* / ; 

实际上/*被编译器理解为一段注释的开始,也就是说该语句直接将 x 的值赋给 y,根本不会顾及到后面出现的p。将上面的语句重写

y = x /  *p   /* p 指向除数 */ ;

或者更加清楚一点,写作:

y = x/(*p)    /* p指向除数 */ :

1.4 整型常量

整型常量的第一个字符是数字 0,那么该常量将被视作八进制数,因此10 与 010 的含义截然不同。有时候在上下文中为了格式对齐的需要,可能无意中将十进制数写成了八进制数,例如:

//微信公众号:嵌入式系统
struct {
    int part_number;
    char xdescriptiony;
}Parttab[] = {
    046 , "1eft-handed widget",
    047 , "right-handed widget" ,
    125 , "frammis"
};

1.5 字符与字符审

C 语言中的单引号和双引号含义迥异,在某些情况下把两者弄混编译器并不会报错,但在运行时产生难以预料的结果。单引号引起的一个字符实际上代表一个整数,整数值对应于该字符在编译器采用的字符集中的序列值。对于采用 ASCI 字符集的编译器而音,'a'的含义与97(十进制) 严格一致。用双引号引起的字符串,代表的却是一个指向无名数组起始字符的指针,该数组被双引号之间的字符以及一个额外的二进制值为零的字符 。

printf ("Hellon");
//与
char hello[] = {'H','e','l','l','o','n',0};
printf (hello);
//是等效的

因为用单引号括起的一个字符代表一个整数,而用双引号括起的一个字符代表一个指针,两者混用,编译器的类型检查功能将会检测到这样的错误。例如  char *slash = '/';
在编译时将会生成一条错误消息,因为'/'并不是一个字符指针。然而某些 C 编译器对函数参数并不进行类型检查,则会在程序运行的时候产生难以预料的错误。

提示,被双括号括起的字符串中,注释符 /* 属于字符串的一部分,而在注释中出现的双引号“ ”又属于注释的一部分。

2、语法陷阱

理解一个C 程序,仅仅理解组成该程序的符号是不够的,还必须理解这些符号是如何组合成声明、表达式、语名和程序的。虽然这些组合方式的定义都很完备,但有时这些定义与人们的直觉相悖。

2.1 理解函数声明

一个问题,当计算机启动时, 硬件将调用首地址为 0 位置的子例程。为了模拟开机启动时的情形,以显式调用该子例程。使用的语句如下,

//微信公众号:嵌入式系统
( * (void(*)())0) ();

这样的表达式会令每个 C 程序员的内心都“不寒而栗”。任何 C 变量的声明都由两部分组成: 类型以及一组类似表达式的声明符(declarator)。声明符从表面上看与表达式有些类似,对它求值应该返回一个声明中给定类型的结果。

最简单的声明符就是单个变量,如 float f ; 这个声明的含义是: 当对其求值时,表达式f的类型为浮点数类型(float)。因为声明符与表达式的相似,也可以在声明符中任意使用括号 float ((f)) ;

同样的逻辑也适用于函数和指针类型的声明,例如 float ff(); 这个声明的含义是表达式 ff()求值结果是一个浮点数,即ff是一个返回值为浮点类型的函数。

类似地 float pf; 这个声明的含义是pf 是一个浮点数,即pf 是一个指向浮点数的指针。

以上这些形式在声明中还可以组合起来,例如下面的声明,

float (* h)();

表示 h 是一个指向返回值为浮点类型的函数的指针。

(float (*)());

表示一个“指向返回值为浮点类型的函数的指针”的类型转换符。

拥有了这些预备知识,分两步来分析表达式 ((void()())0)() 。

1、假定变量 fp是一个函数指针,调用fp 所指向的函数方法 (fp) ();
因为 fp 是一个函数指针,那么
fp 就是该指针所指向的函数,所以(*fp)()就是调用该函数的方式。

在表达式(fp)()中,fp 两侧的括号非常重要,因为函数运算符()的优先级高于单目运算符。如果fp 两侧没有括号,那么fp()实际上与(fp())的含义完全一致。

2、找到一个恰当的表达式来替换fp,可以这样写:
(0)();
上式并不能生效,因为运算符
必须要一个指针来做操作数。而且这个指针还应该是一个函数指针, 这样经运算符作用后的结果才能作为函数被调用。因此,在上式中必须对 0 作类型转换,转换后的类型可以大致描述为:“指向返回void 类型的函数的指针”。

如果fp是一个指向返回值为 void 类型的函数的指针, 那么(fp)()的值为 void,fp的声明如下:
void (fp)(); 因此可以用下式来完成调用存储位置为 0 的子例程:
void (fp)();
这种写法的代价是多声明了一个“哑” 变量,对常数0进行类型转换,将其转型为该变量的类型,只需要在变量声明中将变量名去掉即可。因此,将常数 0 转型为“指向返回值为 void 的函数的指针”类型,可以这样写;
(void ()())0
因此,我们可以用(void ()())0 来替换 fp,从而得到;
((void (*)())0)();
末尾的分号使得表达式成为一个语句。

实际使用typedef 能够使表述更加清晰:

//微信公众号:嵌入式系统
typedef void (*funcptr)();
(*(funcptr)0) ();

2.2 运算符的优先级问题

假设整常量 FLAG,整数值的二进制表示中只有某一位是 1,其余各位均为 0。如果判断整型变量 flags在常量 FLAG 为 1的那一位上是否同样也为 1,可以这样写;
if (flags & FLAG)
考虑到可读性,对表达式的值是否为 0 的判断能够显式地加以说明,写法如下,
if (flags & FLAG != 0)
这个语句虽然更好懂了,但却是错误的。因为!=运算符的优先级要高于 & 运算符,所以上式实际上被解释为:
if (flags & (FLAG != 0) )

用添加括号的方法可以完全避免这类问题,if ((flags & FLAG) != 0 ) ,但记住 C 语言中运算符的优先级是有益的。

2.3 注意作为语句结束标志的分号

C 程序中不小心多写一个分号可能不会造成什么不良后果, 也许会被视作一个不产生实际效果的空语句,或者编译器会因为这个多余的分号而产生一条警告信息。一个重要的例外情形是在 if 或 while 语句之后,如果多了一个分号,那原来紧跟在 if 或者 while 之后的语句就是一条单独的语句,与条件判断部分没有了任何关系。

if(x[i] > big);;
    big = x[i];

编译器会正常地接受第一行代码中的分号而不会提示任何警告信息,实际上相当于

if (x[i] > big) {}
     big = x[i];

如果不是多,而是遗漏一个分号,同样会产生问题。

if (n<3)
    return
logrec.date = x[0];
logrec.time = x[1];
logrec.code = x[2];

此处的 return 后面遗漏了一个分号,代码仍然会顺利通过编译而不会报错,只是代码实际上相当于:

if (n<3)
    return logrec.date = x[0];
logrec.time = x[1];
logrec.code = x[2];

如果代码所在的函数声明其返回值为 void,编译器会因为实际返回值的类型与声明返回值的类型不一致而报错。如果函数不需要返回值,声明时省略了返回值类型,对编译器而言会隐含地将函数返回值类型视作 int 类型。如果这样,上面的错误就不会被编译器检测到。上面的例子中,当 n>=3 时,第一个赋值语句会被直接跳过,由此造成的错误可能会是一个潜伏很深的Bug。

还有一种情形,有分号与没分号的实际效果相差极为不同。那就是当一个声明的结尾紧跟一个函数定义时,如果声明结尾的分号被省略,编译器可能会把声明的类型视作函数的返回值类型。考虑下面的例子:

struct logrec{
    int date;
    int time;
    int code;
}
main()
{    
}

第一个}与紧随其后的函数 main 之间遗漏了一个分号,上面代码段实际的效果是声明函数 main 的返回值是结构 logrec 类型。如果分号没有被省赂,函数 main 的返回值类型会缺省定义为 int 类型。

2.4 switch 语句

C 语言的 switch 语句的控制流程能够依次通过并执行各个 case 部分:

switch (color) {
    case 1:printf("red");
    case 2:printf("yellow");
    case 3:printf("blue");
}

假定变量 color 的值为2,程序将会输出 yellowblue,因为程序在执行了第二个 printf 之后,会自然而然地顺序执行下去。

C 语言中 switch 语句的这种特性,既是它的优势所在,也是它的一大弱点。说它弱点,是因为很容易遗漏各个 case 部分的 break 语句,造成一些难以理解的程序行为。说它是优势所在,是因为如果程序员有意略去break 语句,则可以表达出一些采用其他方式不方便实现的控制结构。特别是对于一些大的 switch 语句, 各个分支的处理大同小异,对某个分支情况的处理只要稍作改动,剩余部分就完全等同于另一个分支情况下的处理。

这个程序中包含有一个 switch 语句,用来处理每个不同的操作码,将第二个操作数的正负号反号后,减法运算和加法运算的处理本质上就是一样的。因此,像下面这样写代码,无疑会大大方便程序的处理:

case SUBTRRACT:
   opna2 = -opnd2:
   /* 此处没有break 语句,微信公众号 嵌入式系统 */
case ADD:
   ...

像上面的例子,添加适当的注释是必须的,当其他人阅读这段代码时,就能明白此处是有意省去了一个 break 语句。

2.5 函数调用

C 语言要求在函数调用时即使函数不带参数,也应该包括参数列表。因此,如果f是一个函数,f() ;是一个函数调用语句,而f;却是一个什么也不做的语句,准确地说是计算函数 f 的地址,却并不调用该函数。

2.6 悬挂else 引发的问题

if (x == 0)
     if (y == 0) error();
else{
     z=x+y;
     f(&z);
}

本意是有两种主要情况,x 等于0 以及x不等于 0。对于x 等于0 的情形,y 也等于0,调用函数 error,否则程序不作任
何处理;对于 x 不等于 0 的情形,先将 x 与 y 之和赋值给 z,然后以z 的地址为参数来调用函数 f。

然而,因为else 始终与同一对括号内最近的未匹配的 if结合,上面这段程序调整代码缩进,大致是这样子:

if (x== 0) {
     if (y == 0)
         error () ;
     else {
        z=x+y;
        f(&z);
     }
}

也就是说 x 不等于 0,程序将不会做任何处理。如果要实现原例子中编程者的本意,应该这样写:

//微信公众号:嵌入式系统
if (x== 0) {
     if (y == 0)
         error () ;
}else {
        z=x+y;
        f(&z);
}

现在,else 与第一个if 结合,即使它离第二个 if更近也是如此,因为第二个if 已经被括号“封装”起来了。

3、 语义陷阱

一个句子哪怕单词拼写正确,语法也无懈可击,仍可能有歧义或者并非希望表达的意思。程序也有可能表面看上去是一个意思,而实际上的效果却相去其远。

3.1 指针与数组

C 语言中指针与数组这两个概念之间的联系密不可分,值得注意的地方有以下两点:

数组的大小必须在编译期就作为一个常数确定下来,数组的元素可以是任何类型的对象。

一个数组只能够做两件事: 确定该数组的大小,以及获得指向该数组下标为 0 的元素的指针,其他有关数组的操作,实际上都是通过指针进行的。换名话说,任何一个数组下标运算都等同于一个对应的指针运算,因此完全可以依据指针行为定义数组下标的行为。

声明一个数组,例如,

struct {
    int p[4];
    double x;
}b[17];

声明了b 是一个拥有 17 个元素的数组,每个元素都是一个结构体,该结构体包括了一个拥有 4 个整型元素的数组(命名为 p) 和一个双精度类型的变量(命名为x)。

int calendar [12][31];
这个语句声明了 calendar 是一个数组,该数组拥有 12 个数组类型的元素,其中每个元素都是一个拥有 31 个整型元素的数组。sizeof(calendar) 的值是372 (31*12)与 sizeof(int)的乘积。

如果 calendar 不是用于 sizeof 的操作数,而是用于其他的场合, 那么 calendar总是被转换成一个指向 calendar 数组的起始元素的指针。任何指针都是指向某种类型的变量。例如:

int *ip;
int i;
ip = &i;

ip 是一个指向整型变量的指针,可以将整型变量i的地址赋给指针ip,如果给*ip 赋值,就能够改变i的取值。

如果一个指针指向的是数组中的一个元素,那么给指针加 1,就能够得到指向该数组中下一个元素的指针。同样地,如果给指针减 1得到就是指向该数组中前一个元素的指针。如果两个指针指向的是同一个数组中的元素,可以把这两个指针相减。这样做是有意义的,例如;
int *q = p+i;
可以通过q-p 而得到i 的值。如果p 与 q 指向的不是同一个数组中的元素,则不能直接加减。

int  a[3] 是一个拥有 3 个整型元素的数组。如果在应该出现指针的地方,采用数组名来替换,那么数组名就被当作指向该数组下标为
p=a;
就会把数组 a 中下标为0 的元素的地址赋值给 p。注意,这里我们并没有写成:
p = &a ;
这种写法在 ANSIC 中是非法的,因为&a 是一个指向数组的指针,而 p 是一个指向整型变量的指针,它们的类型不匹配。现在 p 指向数组 a 中下标为 0 的元素,p+1 指向数组 a 中下标为1的元素依次类推,如果希望p 指向数组 a 中下标为 1 的元素,可以这样写 p=p+1,该语句完全等同于 p++。

a除了被用作运算符 sizeof 的参数这一情形,sizeof(a)的结果是整个数组 a 的大小,而不是指向数组 a 的元素的指针的大小。在其他所有的情形中数组名 a都代表指向数组 a 中下标为 0 的元素的指针。

a 即数组 a 中下标为 0 的元素的引用。例如可以这样写  a = 84;这个语句将数组 a 中下标为 0 的元素的值设置为 84。同样道理,(a+1)是数组 a中下标为 1的元素的引用;(a+i)即数组 a 中下标为 i 的元素的引用,这种写法简记为 a[i] 。正是这一概念让许多 C 语言新手难于理解。实际上,由于 a+i 与 i+a 的含义一样,因此 a[i]与 i[a]也具有同样的含义,后一种写法绝对不推荐。

二维数组,正如前面所讨论的,实际上是以数组为元素的数组,也可以完全依据指针编写操纵一维数组的程序,这样做在一维情形下并不困难, 但是对于二维数组从记法上的便利性来说采用下标形式就几乎是不可替代的了。

int calenaar[12][31];
int *p;
int i;

然后,calendar[4]的含义是什么?因为 calendar 是一个有着 12 个数组类型元素的数组, 它的每个数组类型元素又是一个有着 31 个整型元素的数组,所以 calendar[4]是 calendar 数组的第 5 个元素,表现为一个有着 31 个整型元素的数组的行为。例如,sizeof(calendar[4])
的结果是 31 与 sizeof(int)的乘积。

又如:p = calenaqar [4]:
这个语句使指针 p 指向了数组 calendar[4]中下标为 0 的元素。因为 calendar[4]是一个数组,可以通过下标的形式来指定这个数组中的元素,就像下面这样,
i = calenadar[4] [7];
也可以写成下面这样而表达的意思保持不变;
i= *(calendar [4]+7);
这个语句还可以进一步写成,
i = ((calendar+4)+7);
很明显,用带方括号的下标形式很明显地要比完全用指针来表达简便得多,

下面再看:
p = calendar:
这个语句是非法的,因为 calendar 是一个二维数组,即“数组的数组” ,在此处 calendar 会将其转换为一个指向数组的指针,而 p 是一个指向整型变量的指针, 赋值是非法的。

很显然,我们需要一种声明指向数组的指针的方法:int (ap) [31]);
这个语句实际的效果是,声明了
ap 是一个拥有 31 个整型元素的数组,ap 就是一个指向这样的数组的指针。因而可以这样写:

int calenqar [12][31];
int (*monthp) [31];
monthp = calenqar;

monthp 将指向数组 calendar 的第 1 个元素,也就是数组 catendar 的 12个有着 31 个元素的数组类型元素之一。假定需要清空 calendar 数组,用下标形式可以很容易做到:

int month;
for (month=0;month<12; month++) {
    int day;
    for (day = 0 ;day < 31 ;day++)
        calendar [month][day] = 0;
}

如果采用指针应该如何表示呢?
calendar [month][day] = 0;
表示为
*(*calendar+month)+day) = 0;

3.2 非数组的指针

在 C 语言中, 字符串常量代表了一块包括字符串中所有字符以及一个空字符('') 的内存区域的地址。假定有字符串 s 和t,希望将这两个字符串连接成单个字符串 r 。借助库函数 strcpy 和 strcat。下面的方法似乎一目了然,可是却不能满足目标:

char *r;
strcpy (r,s);
strcat (r,t);

之所以不行的原因在于不能确定 r 指向何处,不仅要让r指向一个地址,而且r 所指向的地址处还应该有内存空间可供容纳字符串,修改给r 分配一定的内存空间:

char r[100];
strcpy (r,s);
strcat (r,t);

只要s 和上t 指向的字符串并不是太大,现在所用的方法就能够正常工作。不幸的是,C 语言强制要求必须声明数组大小为一个常量,因此不够确保r足够大。

大多数 C 语言提供库函数 malloc,该函数接受一个整数,然后分配能够容纳同样数目的一块内存,还提供库函数 strlen,该函数返回一个字符串中所包括的字符数,有了这两个库函数,似乎能够像下面这样操作了:

char *r;
r = malloc(strlen(s) + strlen(t));
strcpy (r,s);
strcat (r,t);

但这个例子还是错的,问题有三点。

1、malloc 函数有可能无法提供请求的内存,通过返回一个空指针来作为“内存分配失败”事件的信号,需要检查是否调用成功。

2、给 r 分配的内存在使用完之后应该及时释放,这一点务必记住。在前面的程序例子中 r 是一个局部变量,当离开r作用域时自动被释放,修订后的程序显式地给r 分配了动态内存,为此就必须显式地释放内存。

3、最重要的原因,就是前面的例程在调用 malloc 函数时并未分配足够的内存。字符串以空字符作为结束标志,库函数strlen 返回参数中字符串所包括的字符数目, 而作为结束标志的空字符并未计算在内。因此, 如果 strlen(s)的值是 n, 那么字符串实际需要 n+1 个字符的空间;所以必须为 r 多分配一个字符的空间。

正确的结果:

//微信公众号:嵌入式系统
char *r;
r = malloc(strlen(s) + strlen(t) + 1);
if(!r){}
   exit(1);
}
strcpy (r,s);
strcat (r,t);
/* 一段时间之后再使用 */
free(r) :

3.3 作为参数的数组声明

C 语言中没有办法将一个数组作为函数参数直接传递,如果使用数组名作为参数,那么数组名会被转换为指向该数组第 1 个元素的指
针。

例如下面的语句:char hello[] = "hello";
声明 hello 是一个字符数组。如果将该数组作为参数传递给一个函数,
printf("%sn",hello);
实际上与将该数组第 1 个元素的地址作为参数传递给函数的作用完全等效,即;
printf("%sn",&hello[0]);
C 语言中会自动地将作为参数的数组声明转换为相应的指针声明。

int strlen(char s[])
{
 /* 具体内容 */
}
//微信公众号:嵌入式系统
//两种写法完全相同
int strlen(char* s)
{
 /* 具体内容 */
}

C 程序员经常错误地假设,在其他情形下也会有这种自动地转换。

程序员经常在此处遇到麻烦:
extern char *hello;
这个语句与下面的语句有着天渊之别,
extern char hello[]:
如果一个指针参数并不实际代表一个数组,即使从技术上而言是正确的,采用数组形式的记法经常会起到误导作用。

3.4 避免举隅法

“举隅法”(synecdoche) 是一种文学修辞上的手段,有点类似于以微笑表示喜悦之情,或以隐喻表示指代物与被指物的相互关系。C 语言中一个常见的“陷阱” 混淆指针与指针所指向的数据。对于字符串的情形,编程者更是经常犯这种错误。例如:

char *p,*q;
p= "xyz";

上面的赋值语句使得 p 的值就是字符串"xyz",然而实际情况并不是这样,记住这一点尤其重要。实际上,p 的值是一个指向由'x'、'y'、'z"和'' 4 个字符组成的数组的起始元素的指针。因此,如果执行下面的语句:q=p;
p 和 q 现在是两个指向内存中同一地址的指针,这个赋值语句并没有复制内存中的字符,复制指针并不复制指针所指向的数据,p 和q 所指向的是同一块内存。

3.5 空指针并非空字符串

C 语言中将一个整数转换为一个指针,最后得到的结果都取决于具体的 C 编译器实现,特殊情况就是常数 0,编译器保证由 0 转换而来的指针不等于任何有效的指针。一般常数0 这个值经常用一个符号来代替:

#define NULL 0

无论是直接用常数 0,还是用符号 NULL,效果相同的。当常数 0 被转换为指针使用时,这个指针绝对不能被解除引用(dereference)。换句话说,将 0 赋值给一个指针变量时,绝对不能企图使用该指针所指向的内存中存储的内容。

下面的写法是完全合法的:
if (p == (char *) 0)
但是如果要写成这样:if (strcmp (p, (char *) 0) == 0)
就是非法的了,原因在库函数 strcmp 的实现中,会查看它的指针参数所指向内存中的内容的操作。如果p 是一个空指针,printf("%s",p);的行为也是未定义的, 与此类似的语句在不同的计算机上会有不同的效果。

3.6 边界计算与不对称边界

如果一个数组有 10 个元素,那么这个数组下标的允许取值范围是什么昵?不同的程序设计语言有着不同的答案。在C 语言中,这个数组的下标是从 0 到 9。一个拥有 n 个元素的数组,却不存在下标为n 的元素,它的元素的下标范围是从 0 到 n-1。看看下面的代码:

int i,a[10];
for (i=1;i<=10; i++)
    a[i]= 0;

本意是要设置数组 a 中所有元素为 0, 却产生了一个出人意料的“副效果” 。在 for 语句的比较部分本来是i< 10,却写成了i <= 10,因此并不存在的 a[10]被设置为0,也就是内存中在数组 a 之后的一个字(word) 的内存被设置为 0。如果用来编译这段程序的编译器按照内存地址递减的方式来给变量分配内存,那么内存中数组 a 之后的一个字 实际上是分配给了整型变量 i。此时,本来循环计数器i 的值为 10,循环体内将并不存在的 a[10]设置为0,实际上却是将计数器 i 的值设置为 0,这就陷入了一个死循环。

C 语言的数组会让新手感到麻烦, 然而这种特别的设计正是其最大优势所在。常见的程序设计错误中,最难于察觉的一类是“栏杆错误”, 也被称为“差一错误”(off-by-one error)。100 英尺长的围栏每隔 10 英尺需要一根支撑用的栏杆,如果不加思索,最“显而易见”的答案是将 100 除以 10,即需要 10 根栏杆,实际正确答案是 11。

避免“栏杆错误”的两个通用原则;
1、首先考虑最简单情况下的特例,然后将得到的结果外推。2、仔细计算边界,绝不掉以轻心。

3.7 求值顺序

//微信公众号:嵌入式系统
if (count != 0 && sum/count < smallaverage )
    printt("average < %gn",small1average);

即使变量 count 为 0也不会产生用 0 作除数的错误,C 语言中的某些运算符总是以一种已知的、规定的顺序来对其操作数进行求值,而另外一些则不是这样。考虑下面的表达式;
a<b && c<d

C 语言的定义中说明 a<b 应当首先被求值,如果 a 确实小于b,此时必须进一步对c<d求值,以确定整个表达式的值。如果 a 大于或等于b,则无需对c<d求值,表达式肯定为假。对 a<b 求值,编译器可能先对 a求值,也可能先对 b 求值,在某些机器上甚至有可能对它们同时并行求值。

C 语言中只有四个运算符〈&&、|| 、?: 和 ,) 存在规定的求值顺序。运算符&&和运算符||首先对左侧操作数求值, 只在有必要时才对右侧操作数求值。运算符?:有三个操作数: 在 a?b:c 中,操作数 a 首先被求值,根据 a 的值再求操作数b 或c的值。而逗号运算符,首先对左侧操作数求值,然后该值被“丢弃”,再对右侧操作数求值。

C 语言中其他所有运算符对其操作数求值的顺序是未定义的,赋值运算符并不保证任何求值顺序。下面这种从数组 x 中复制前n 个元素到数组 y 中的做法是不正确的:

i=0;
while (i < n)
    y[il = x[i++];

问题出在哪里昵? 上面的代码假设 y[i]的地址将在i 的自增操作执行之前被求值,这一点并没有任何保证! 有可能在 i 自增之前被求值,也可能与此相反。下面这种写法能正确工作:

//微信公众号:嵌入式系统
i= 0;
while (i < n) {
  y[il = x[i];
  i++;
}

3.8 运算符&&、|| 和 !

按位运算符&、| 和 ~ 对操作数的处理方式是将其视作一个二进制的位序列,分别对其每个位进行操作。如,10&12 的结果是 8(1010 & 1100 = 1000),当且仅当两个操作数的二进制表示的某位上同时是 1,最后结果的二进制表示中该位才是 1。

另一方面,逻辑运算符&&、||和 !对操作数的处理方式是将其视作要么是“真” 要么是“假”。通常约定将 0 视作“假” ,而非 0 视作“真"。这些运算符当结果为“真”时返回 1,当结果为“假”时返回0,它们只可能返回0 或 1。而且运算符&&和||在左侧操作数的值能够确定最终结果时,不会对右侧操作求值。

考虑下面的代码段,其作用是在表中查询一个特定的元素:

//微信公众号:嵌入式系统
i= 0;
while (i < tabsize && tab[il != x)
    i++;

这个循环语句的用意是:如果i 等于 tabsize 时循环终止,就说明在表中没有发现要找的元素,而如果是其他情况,此时 i 的值就是要找的元素在表中的索引。假定无意中用运算符&替换了上面语句中的运算符&&。

i= 0;
while (i < tabsize & tab[il != x)
    i++;

这个循环语句也有可能“正常”工作,但仅仅是因为两个非常侥幸的原因。

第一个“侥幸”是,while 中的表达式&运算符的两侧都是比较运算,只要x 和y 的取值都限制在0或1, 那么x&y 与x&&y 总是得出相同的结果。如果两个比较运算中的任何一个用除 1 之外的非 0 数代表“真”,那么这个循环就不能正常工作了。

第二个“侥幸”是,对于数组结尾之后的下一个元素,只要程序不去改变该元素的值,而仅仅读取它的值,一般情况下是不会有什么危害的。运算符两侧的操作数都必须被求值。所以在后一个代码段中,当循环进入最后一次迭代,即i等于tabsize,也就是说数组元素 tab[i]实际上并不存在,程序仍然会查看元素的值。对于数组结尾之后的下一个元素, 取它的地址是合法的,只是读取这个元素的值是未定义的,而且绝少有 C 编译器能够检测出这个错误。

3.9 整数溢出

C 语言中存在有符号运算与无符号运算。在无符号算术运算中,没有所谓的“溢出”一说。如果算术运算符的一个操作数征有符号整数,另一个是无符号整数,那么有符号整数会被转换为无符号整数,“溢出”也不可能发生。但当两个操作数都是有符号整数时,“溢出”就有可能发生,而且“溢出”的结果是末定义的。

假定a 和 b是两个非负整型变量,我们需要检查 a+b 是和否会“溢出”,一种想当然的方式是这样:

if (a+b< 0)
   complain():

这并不能正常运行,一种正确的方式是将a和b 都强制转换为无符号整数:

if ((unsigned)a + (unsigned)b > INT_MAX) .
   complain() ;

此处的 INT_MAX 是一个已定义常量, 代表可能的最大整数值。ANSI C 标准在<limits.h>中定义了 INT_MAX,不需要用到无符号算术运算的另一种可行方法是:

if (a > INT_MAX - b)
   complain();

3.10 为函数 main 提供返回值

最简单的 C 程序也许是像下面这样,

main()
{
//关注微信公众号:嵌入式系统
}

这个程序包含一个不易察觉的错误, 函数 main 与其他任何函数一样, 如果并未显式声明返回类型,那么函数返回类型就默认为是整型,但是这个程序中并没有给出任何返回值。通常说来,不会造成什么危害,只要该数值不被用到就无关紧要。

然而,在某些情形下函数 main 的返回值却并非无关紧要。大多数 C 语言实现都通过函数 main 的返回值,来告知操作系统该函数的执行是成功还是失败。典型的处理方案是, 返回值为 0 代表程序执行成功, 返回值非 0 则表示程序执行失败。如果 main 函数并不返回任何值,但系统关注程序被调用后执行是成功还是失败,那很可能得到令人惊讶的结果。

严格说来,最为经典的“hello world”程序看上去应该像这样:

//微信公众号:嵌入式系统
#include <stdio.h>
int main()
{
    printf("hello worldn");
    return 0;
}

4、连接

一个C 程序可能是由多个分别编译的部分组成,这些不同部分通过一个通常叫做连接器程序合并成一个整体。因为编译器一般每次只处理一个文件,所以它不能检测出那些需要一次了解多个源程序文件才能察觉的错误;同样前述错误的原因是与 C 语言相关的,连接器对此同样束手无策。

4.1 什么是连接器

C 语言中的一个重要思想就是分别编译 〈Separate Compilation),即若干个源程序可以在不同的时候单独进行编译,然后在恰当的时候整合到一起。

连接器一般是与 C 编译器分离的,它不可能了解 C 语言的诸多细节,不理解 C 语言,它能够理解机器语言和内存布局。编译器的责任是把 C 源程序“翻译”成对连接器有意义的形式,这样连接器就能够“读懂”C 源程序了。典型的连接器把由编译器或汇编器生成的若干个目标模块,整合成一个被称为载入模块或可执行文件的实体,该实体能够被操作系统直接执行。其中,某些目标模块是直接作为输入提供给连接器的,另外一些目标模块则是根据连接过程的需要,从包括有类似 printf 函数的库文件中取得的。

连接器通常把目标模块看成是由一组外部对象 (exteral object) 组成的,每个外部对象代表着机器内存中的某个部分,并通过一个外部名称来识别。大多数连接器都禁止同一个载入模块中不同外部对象拥有相同的名称,连接器的一个重要工作就是处理这类命名冲突。

连接器的输入是一组目标模块和库文件,输出是一个载入模块。连接器读入目标模块和库文件,同时生成载入模块。对每个目标模块中的每个外部对象,连接器都要检查,看是否已有同名的外部对象。如果没有,连接器就将该外部对象添加到载入模块中;如果有,连接器就要开始处理命名冲突。

除了外部对象之外,目标模块中还可能包括了对其他模块中的外部对象的引用。例如,一个调用了函数 printf 的 C 程序所生成的目标模块,就包括了对函数 printf 的引用。可以推测得出,该引用指向的是一个位于某个库文件中的外部对象。在连接器生成载入模块的过程中, 它必须同时记录这些外部对象的引用。当连接器读入一个目标模块时,它必须解析出这个目标模块中定义的所有外部对象的引用,并作出标记说明这些外部对象不再是未定义的。

4.2 声明与定义

下面的声明语句:
int a;
如果其出现在所有的函数体之外,那么它就被称为外部对象 a 的定义。这个语句说明了 a 是一个外部整型变量,同时为 a 分配了存储空间。因为外部对象 a 并没有被明确指定任何初始值,所以它的初始值可能为 0(某些系统中的连接器并不保证这一点)。

下面的声明语句
extern int a;
并不是对 a 的定义,这个语句仍然说明了 a 是一个外部整型变量,但是因为它包括了 extern 关键字, 这就显式地说明了 a 的存储空间是在程序的其他地方分配的。从连接器的角度来看,上述声明是一个对外部变量 a 的引用,而不是对 a 的定义。这种形式的声明是对一个外部对象的显式引用,即使它出现在一个函数的内部, 也仍然具有同样的含义。

下面的函数 srand 在外部变量 random_seed 中保存了其整型参数 n 的一份拷贝;

void srand(int n)
{
    extern int random_seed;
    random_seed = n;
}

每个外部对象都必须在程序某个地方进行定义,因此,如果一个程序中包括了语句
extern int a;
那么,这个程序就必须在别的某个地方包括语句
int a;
这两个语句既可以是在同一个源文件中,也可以位于程序的不同源文件之中。

如果一个程序对同一个外部变量的定义不止一次,又将如何处理昵? 也就是说,假定下面的语句
int a;
出现在两个或者更多的不同源文件中,情况会是怎样呢? 或者说,如果语句
int a= 7;
出现在一个源文件中,而语名
int a= 9;
出现在另一个源文件中,将出现什么样的情形呢?

这个问题的答案与系统有关,不同的系统可能有不同的处理方式。严格的规则是,每个外部变量只能够定义一次。如果外部变量的多个定义各指定一个初始值,大多数系统都会拒绝接受该程序。但是,如果一个外部变量在多个源文件中定义却并没有指定初始值,那么某些系统会接受这个程序,而另外一些系统则不会接受。

要想在所有的 C 语言实现中避免这个问题,惟一的解决办法就是每个外部变量只定义一次。

4.3 命名冲突与 static 修饰符

两个具有相同名称的外部对象实际上代表的是同一个对象,即使编程者的本意并非如此,但系统却会如此处理。因此,如果在两个不同的源文件中都包括了定义
int a;
那么,它或者表示程序错误(如果连接器禁止外部变量重复定义),或者在两个源文件中共享a的同一个实例。即使其中 a 的一个定义是在系统提供的库文件中,也进行同样的处理。当然,一个设计良好的函数库不至于定义 a 作外部名称。

static 修饰符是一个能够减少此类命名冲突的有用工具。例如以下声明:static int a;
其含义与下面的语句相同
int a;
只不过前者 a 的作用域限制在一个源文件内,对于其他源文件a 是不可见的。因此,如果若干个函数需要共享一组外部对象,可以将这些函数放到一个源文件中,把它们需要用到的对象也都在同一个源文件中以 static 修饰符声明。

static 修饰符不仅适用于变量,也适用于函数。如果函数 f 需要调用另一个函数 g,而且只有函数 f 需要调用函数 g,可以把函数f 与函数 g 都放到同一个源文件中,并且声明函数 g 为 static。

可以在多个源文件中定义同名的函数 g,只要所有的函数 g 都被定义为static,或者仅仅只有其中一个函数 g 不是 static。为了避免可能出现的命名冲突,如果一个变量或者函数,仅在同一个源文件被使用用,就应该声明为 static。

4.4 形参、实参与返回值

任何 C 函数都有一个形参列表,列表中的每个参数都是一个变量,该变量在函数调用过程中被初始化。任何一个 C 函数都有返回类型或者void。任何一个函数在调用它的每个文件中,都在第一次被调用之前进行了声明或定义,那么就不会有任何与返回类型相关的麻烦。例如下面的例子,

double square (double x)
{
    return x*x;
}
//微信公众号:嵌入式系统
//声明定义在前,调用在后
main()
{
    printft("%gn"square(0.3));
}

要使这个程序能够正常运行,函数 square 必须要么在 main 之前进行定义,要么在 main 之前进行声明。

如果一个函数在被定义或声明之前被调用, 那么它的返回类型就默认为整型。上面的例子中,如果将 main 函数单独抽取出来作为一个源文件,因为函数 main 假定函数 square 返回类型为整型,而函数 square 返回类型实际上是双精度类型,当它与 square 函数连接时就会得出错误的结果。

如果需要在两个不同的文件中分别定义函数 main 与函数 square, 那么应该如何处理呢? 函数 square 只能有一个定义,调用与定义分别位于不同的文件中,那么必须在调用它的文件中声明 square 函数:

extern doupble square(double);
main()
{
    printft("%gn"square(0.3));
}

上面的语名说明函数 square 接受一个双精度类型的参数,返回一个双精度类型的结果。根据这个声明,square(2)也是合法的, 整数 2 将会被自动转换为双精度类型,就好像程序员写成 :
square((double)2)或者 square(2.0)一样。

类型转换, float 类型的参数会自动转换为 double 类型,short 或 char 类型的参数会自动转换为 int 类型。对于下面的函数;
int isvowel (char c)
因为其形参为 char 类型,所以在调用该函数的其他文件中必须声明,否则,调用者将把传递给 isvowel 函数的实参自动转换为 int 类型,这样就与形参类型不一致了 。如果函数 isvowel 是这样定义的:

int isvowel (int c) {
    return c == 'a' || c== 'e' || c == 'i' ||
           c== 'o'  || c == 'u' ;
}

那么调用者可以无需进行声明。

4.5 检查外部类型

假定一个 C 程序由两个源文件组成,一个文件中包含外部变量 n的声明:
extern  int n;
另一个文件中包含外部变量n 的定义;
long n;
两个语句都不在任何一个函数体内,n 是外部变量;这是一个无效的 C 程序,因为同一个外部变量名在两个不同的文件中被声明为不同的类型。

编译器对这两个不同的文件分别进行处理,编译器在编译一个文件时,并不知道另一个文件的内容。连接器可能对 C 语言一无所知,因此它也不知道如何比较两个n 的定义中的类型。当这个程序运行时,究竟会发生什么情况呢?

1.C 语言编译器足够“聪明”,能够检测到类型冲突,提示变量n 在两个不同的文件中被给定了不同的类型。

2.C 语言实现对 int 类型的数值与long 类型的数值在内部表示上是一样的,在这种情况下,程序很可能正常工作,就好像 n 在两个文件中都被声明为 long (或 int) 类型一样。本来错误的程序因为某种巧合却能够工作,这是一个很好的例子。

3.变量n 的两个实例虽然要求的存储空间的大小不同, 但是它们共享存储空间的方式却恰好能够满足这样的条件, 赋给其中一个的值, 对另一个也是有效的。这是有可能发生的。举例来说,如果连接器安排 int 类型的n 与 long 类型的n 的低端部分共享存储空间,这样给每个 long 类型的n 赋值,恰好相当于把其低端部分赋给了 int 类型的n。本来错误的程序也因此巧合能够工作。

4.变量n 的两个实例共享存储空间的方式,使得对其中一个赋值时,其效果相当于同时给另一个赋了完全不同的值。在这种情况下,程序将不能正常工作。

因此, 必须保证所有外部定义在每个目标模块中都有相同的类型,而且是严格意义上的相同。

考虑下面的程序,在一个文件中包含定义:

char filename[] = "chengj";

而在另一个文件中包含声明:

extern char* filenarme:

尽管在某些上下文环境中,数组与指针非常类似,但它们毕竟不同。在第一个声明中,filename 是一个字符数组的名称。尽管在一个语句中引用 filename 的值将得到指向该数组起始元素的指针,但是 flename 的类型是“字符数组”,而不是“字符指针” 。在第二个声明中, filename 被确定为一个指针。这两个对 filename的声明使用存储空间的方式是不同的,它们无法以一种合乎情理的方式共存。

有关外部类型方面,另一种容易带来麻烦的方式是忽略了声明函数的返回类型,或者声明了错误的返回类型。

//微信公众号:嵌入式系统
//前面为声明声明 sqrt
int main()
{
    double s;
    s = sqrt(2);
    printf("%gn",s);
    return 0
}

这个程序没有对函数 sqrt 的声明,C 语言中的规则是,如果一个未声明的标识符后跟一个开括号,那么它将被视为一个返回整型的函数。因此,这个程序完全等同于下面的程序:extern int sqrt(int);显然函数 sqrt 返回的结果是错的。

4.6 头文件

避免大部分此类问题,每个外部对象只在一个地方声明,这个声明的地方一般就在一个头文件中,需要用到该外部对象的所有模块包含这个头文件,定义该外部对象的模块也应该包括这个头文件。

例如前面讨论过的 filename 例子,可以创建一个文件,比如叫做file.h,它包含了声明:
extern char filename[]:
需要用到外部对象 filename的每个 C 源文件都应该加上这样一个语句:
#include "file.h"
最后,选择一个 C 源文件,在其中给出 flename 的初始值,不妨称这个文件为 file.c;

#include "file.h"
char filename[] = "chengj";

只要源文件 file.c 中 filename的各个声明是一致的,而且这些声明中最多只有一个是 flename 的定义,这样才是合法的。

欲知后事如何,请关注微信公众号:嵌入式系统

 

推荐器件

更多器件
器件型号 数量 器件厂商 器件描述 数据手册 ECAD模型 风险等级 参考价格 更多信息
6N137S-TA1 1 Lite-On Semiconductor Corporation Logic IC Output Optocoupler,

ECAD模型

下载ECAD模型
$1.03 查看
ECS-.327-12.5-1210-TR 1 ECS International Inc Parallel - Fundamental Quartz Crystal, 0.032768MHz Nom, SMD, 2 PIN

ECAD模型

下载ECAD模型
$1.09 查看
AT45DB321E-SHF-T 1 Adesto Technologies Corporation Flash, 32MX1, PDSO8, 0.208 INCH, GREEN, PLASTIC, SOIC-8

ECAD模型

下载ECAD模型
$4.19 查看

相关推荐

电子产业图谱

嵌入式系统开发技术交流,软件开发的思路与方案共享,行业资讯的分享。