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

  • 创作内容快速变现
  • 行业影响力扩散
  • 作品版权保护
  • 300W+ 专业用户
  • 1.5W+ 优质创作者
  • 5000+ 长期合作伙伴
立即加入
  • 正文
    • 5、库函数
    • 6、预处理器
    • 7、可移植性缺陷
    • 8、建议与答案
  • 推荐器件
  • 相关推荐
  • 电子产业图谱
申请入驻 产业图谱

C陷阱与缺陷(下)

2023/06/12
2490
阅读需 44 分钟
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

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

目录

1、词法陷阱

2、语法陷阱

3、语义陷阱

4、连接

5、库函数

6、预处理器

7、可移植性缺陷

8、建议与答案

接上文 《C陷阱与缺陷(上)》,继续...

5、库函数

ANSI C 标准定义了一个包含大量标准库函数的集合,从理论上说,任何一个C 语言实现都应该提供这些标准库函数。有关库函数的使用,能给出的最好建议是尽量使用系统头文件,因为头文件中包括了库上数的参数类型以返回类型的声明。

5.1 返回整数的 getchar 函数

考虑下面的例子:

//微信公众号:嵌入式系统
#include <stdio.h>
main()
{
    char c;
    while ((c = getchar()) != EOF)
        putchar (c);
}

getchar 函数在一般情况下返回的是标准输入文件中的一个字符,当没有输入时返回 EOF(在头文件 stdio.h 中定义,不同的操作系统和编译器中EOF的值可能有所不同,但通常都是一个负整数,如-1 )。这个程序乍一看似乎是把标准输入复制到标准输出,实则不然。

原因在于程序中的变量 c 被声明为 char 类型,而不是 int 类型,这意味着 c 无法容下所有可能的字符,特别是可能无法容下 EOF。

最终存在三种可能,一种可能是某些合法的输入字符在被“截断”后使得 c 的取值与 EOF 相同,程序将在文件复制的中途终止。另一种可能是 c 根本不可能取到 EOF 这个值,程序将陷入死循环。第三种情况是程序表面上能够正常工作,但完全是因为巧合。尽管函数 getchar 的返回结果在赋给 char 类型的变量 c 时会发生“截断”操作,尽管 while 语句中比较运算的操作数不是函数 getchar 的返回值,而是被“截断”的值 c,然而令人惊讶地是许多编译器对上述表达式的实现并不正确。这些编译器确实对函数 getchar 的返回值作了“截断”处理,并把低端字节部分赋给了变量 c。但是,它们在比较表达式中并不是比较 c 与 EOF,而是比较 getchar函数的返回值与 EOF,编译器如果采取的是这种做法,上面的例子看上去就能够正常运行。

5.2 更新顺序文件

许多系统中的标准输入/输出库都允许程序打开一个文件, 同时进行写入和读出的操作:

FILE *fp;
fp = fopen(file, "r+");

上面的例子打开了变量 file 指定的文件,对于存取权限的设定表明,希望对这个文件进行输入和输出操作。编程者也许认为,程序一旦执行上述操作完毕,就可以自由地交错进行读出和写入的操作。遗憾的是,事实总难遂人所愿,为了保持与过去不能同时进行读写操作的程序的向下兼容性,一个输入操作不能随后直接紧跟一个输出操作,反之亦然。如果要同时进行输入和输出操作, 必须在其中插入 fseek 函数的调用。下面的程序片段似乎更新了一个顺序文件中选定的记录:

FILE *fp;
struct record rec;
while (freadl( (char *)&rec,sizeof(rec),1,fp) == 1) {
    /* 对rec 执行某些操作 */
    if (/* rec 必须被重新写入 */) {
        fseek(fp,-(1ong)sizecof(rec),1);
        fwrite( (char *)&rec,sizeof(rec),1,fp);
    }
}

乍看上去毫无问题: &rec 在传入 fread 和 fwrite 函数时被小心地转换为字符指针类型,sizeof(rec)被转换为长整型 (fseek 函数要求第二个参数是 long 类型, 因为 int 类型的整数可能无法包含一个文件的大小; sizeof 返回一个unsigned 值,因此首先必须将其转换为有符号类型才有可能将其反号)。但是这段代码仍然可能运行失败,而且出错的方式非常难于察觉。

问题出在: 如果一个记录需要被重新写入文件,也就是说,fwrite 函数得到执行,对这个文件执行的下一个操作将是循环开始的 fread 函数。因为在 fwrite函数调用与 fread 函数调用之间缺少了一个 fseek 函数调用,所以无法进行上述操作。解决的办法是把这段代码改写为:

while (fread( (char *)&rec,sizeof(rec),1,fp) == 1) {
    /* 对rec 执行某些操作 */
   if (/* rec 必须被重新写入 */) {
       fseek(fp,-(1ong)sizecof(rec),1);
       fwrite( (char *)&rec,sizeof(rec),1,fp);
       fseek(fp,0L,1);
   }
}

第二个 fseek 函数虽然看上去什么也没做,但它改变了文件的状态,使得文件现在可以正常地进行读取了。

5.3 缓冲输出与内存分配

当一个程序生成输出时,是否有必要将输出立即展示给用户? 这个问题的答案根据不同的程序而定。假设一个程序输出到终端,向终端前的用户提问,要求用户回答,那么程序输出应该即时地显示给用户。另一种情况是,程序输出到一个文件或行式打印机,那么只要程序结果最后都全部输出到了目标就可以了。

程序输出有两种方式,一种是即时处理方式,另一种是先暂存起来,然后再大块写入的方式。前者往往造成较高的系统负担。C 语言实现通常都允许程序员进行实际的写操作之前控制产生的输出数据量。这种控制能力一般是通过库函数 setbuf 实现的。如果 buf 是一个大小适当的 字符数组,那么 setbuf (stdout ,buf); 语句将通知输入/输出库,所有写入到 stdout 的输出都应该使用 buf 作为输出缓冲区,直到 buf 缓冲区被填满或者程序员直接调用 fflush,将导致输出缓冲区的内容被实际地写入,buf 缓冲区中的内容才实际写入到 stdout 中。缓冲区的大小由系统头文件<stdio.h>中的BUFSIZ 定义。下面的程序是把标准输入的内容复制到标准输出中,演示了 setbuf 库函数最显而易见的用法;

#include <stdio.h>
fun()
{
    int c;
    char buf[BUFSIZ];
    setbuf (stdout,buf);
    while ((c = getchar()) != EOF)
        putchar(c):
}

遗憾的是这个程序是错误的,程序中对库函数 setbuf 的调用,通知了输入/输出库所有字符的标准输出应该首先缓存在 buf 中。要找到问题出自何处,不妨思考一下 buf 缓冲区最后一次被清空是在什么时候? 答案是在 fun函数结束之后,作为程序交回控制给操作系统之前 C 运行时库所必须进行的清理工作的一部分。但是,在此之前buf 字符数组已经被释放。要避免这种类型的错误有两种办法。第一种办法是让缓冲数组成为静态数组,既可以直接显式声明 buf 为静态,也可以把 buf 声明完全移到函数之外。static char buf[BUFSIZ]; 第二种办法是动态分配缓冲区,在程序中并不主动释放分配的缓冲区。setbuf (stdout,malloc(BUFSIZ) );

如果读者关心一些编程“小技巧” 也许会注意到这里其实并不需要检查malloc 函数调用是否成功。如果 malloc 函数调用失败, 将返回一个null 指针,setbuf函数的第二个参数取值可以为null,此时标准输出不需要进行缓冲。这种情况下,程序仍然能够工作,只不过速度较慢而已。

5.4 使用 errno 检测错误

很多库函数特别是与操作系统有关的,当执行失败时会通过 errno 的外部变量,通知程序该函数调用失败。下面的代码利用这一特性进行错误处理,然而却是错误的:

/* 调用库函数 */
if (errno)
   /* 处理错误 */

出错原因在于,在库函数调用没有失败的情况下,并没有强制要求库函数一定要设置 errno 为 0, 这样 errno 的值就可能是前一个执行失败的库函数设置的值。下面的代码作了更正,似乎能够工作,很可惜还是错误的;

errno = 0;
/* 调用库函数 */
if (errnoy)
    /* 处理错误 */

库函数在调用成功时,既没有强制要求对 errno 清0,但同时也没有禁止设置 errno。既然库函数已经调用成功,为什么还设置 errno 呢? 要理解这一点,我们不妨假想一下库函数 fopen 在调用时,可能需要调用其他的库函数,以检测同名文件是否已经存在。因此,在调用库函数时,应该首先检测作为错误指示的返回值,确定程序执行已经失败。然后再检查 errno,搞清楚出错原因:

/* 调用库函数*/
  if (返回的错误信)
       检查 errno

5.5 库函数 signal

C 语言实现中都包括有 signal 库函数,作为捕获异步事件的一种方式。信号是真正意义上的“异步"。从理论上说,一个信号可能在 C 程序执行期间的任何时刻上发生。需要特别强调的是,信号甚至可能出现在某些复杂库函数(如 malloc) 的执行过程中。因此,从安全的角度考虑,信号的处理函数不应该调用上述类型的库函数。而且有一些从本质上而言具有不可移植的特性。解决这个问题我们最好采取“守势” ,让 signal 处理函数尽可能地简单,并将它们组织在一起,当需要适应一个新系统时,可以很容易地进行修改。

6、预处理器

编译开始之前,C 语言预处理器首先对程序代码作了必要的转换处理,预处理器使得编程者可以简化某些工作,它的重要性可以由两个主要的原因说明。

1、也许会遇到这样的情况,需要将某个特定数量如数组大小,在程序中出现的所有实例统统加以修改,希望在程序中只改动一处数值,然后重新编译就可以实现。预处理器做到这一点可以说是轻而易举,即使这个数值在程序中的很多地方出现。只需要将这个数值定义为一个显式常量 (manifest constant),然后在程序中需要的地方使用这个常量即可。

2、大多数 C 语言实现在函数调用时都会带来重大的系统开销。因此,希望有这样一种程序块,它看上去像一个函数,但却没有函数调 用的开销。一些小的功能代码被实现为宏,以避免在每次执行时,都要调用相应的函数而造成系统效率的下降。

虽然宏非常有用,不只是对程序的文本起作用,宏既可以使一段看上去完全不合语法的代码成为一个有效的 C 程序,也能使一段看上去无害的代码成为一个可怕的怪物。

6.1 不能忽视宏定义中的空格

一个函数如果不带参数,在调用时只需在函数名后加上一对括号即可加以调用,而一个宏如果不带参数,则只需要使用宏名即可,括号无关紧要,预处理器从宏定义中就可以知道宏调用时是否需要参数。与宏调用相比,宏定义显得“暗藏机关”。例如,下面的宏定义中f是否 带了一个参数呢?

#define f (x) ((x)-1)

答案可能有两种:f(x) 代表( (x) -1)或者f代表(x) ((x)-1)。在上述宏定义中,第二个答案是正确的,因为在f和后面的 (x) 之间多了一 个空格! 所以,如果希望定义f(x) 必须像下面这样写:

#define f(x) ((x)-1)

6.2 宏并不是函数

宏从表面上看其行为与函数非常相似,程序员有时会禁不住把两者视为完全等同。常常可以看到类似下面的写法;

//微信公众号:嵌入式系统
#define abs(x) (((x)>=0)?(x) : -(x))
#define max(a,b) ((a)>(b)?(a):(b))

请注意宏定义中出现的括号,它们的作用是预防引起与优先级有关的问题。例如,假设宏 abs 被定义成了这个样子:

#define abs(x) (((x)>=0)?(x) : -x

求值后会得到怎样的结果?表达式被展开为 a-b>0?a-b:-a-b 这里的子表达式-a-b 不是我们期望的-(a-b),无疑会得到错误的结果。因此,最好在宏定义中把每个参数都用括号括起来。同样,整个结果表达式也应该用括号括起来,以防止当宏用于一个更大的表达式中可能出现的问题。

即使宏定义中的各个参数与整个结果表达式都被括号括起来,也仍然还可能有其他问题存在,比如一个操作数如果在两处被用到,就会被求值两次。如在表达式 max(a,b)中,如果 a 大于 b,那么 a 将被求值两次,第一次是在 a与b 比较期间,第二次是在计算 max 应该得到的结果值时。这种做法不但效率低下,而且可能是错误的。

biggest = x[0];
i= 1;
while (i<n)
    biggest = max (biggest,x[i++]);

如果 max 是一个真正的函数,上面的代码可以正常工作,而如果 max 是一个宏,那么就不能正常工作。上面代码中的赋值语句将被扩 展为:

biggest = ((biggest)>(x[i++])?(biggest):(x[i++]));

变量 biggest 将与 x[i++]比较。i的值发生了变化,如果前面关系运算的结果为 false(假),后面 i++的副作用导致结果错误。解决这类问题的一个办法是,确保宏 max 中的参数没有副作用。

biggest = x[0];
for (i = 1; i<n ;i++)
  biggest = max (biggest, x[i]);

另一个办法是让 max 作为函数而不是宏。使用宏的一个危险是,宏展开可能产生非常庞大的表达式,占用的空间远远超过了编程者所期望的空间,这种情况下封装函数更合适。

6.3 宏并不是语句

编程者有时会试图定义宏的行为与表达式语句类似,例如assert 宏,它的参数是一个表达式,如果该表达式为0程序终止执行,并给出错误消息,可以在出错信息中包括文件名和断言失败处的行号;表达式为1 时什么也不做。第一次尝试:

#define assert (e) if (!e) assert_error (__FILE__, __LINE__ )

其中__FILE__, __LINE__是内建于 C 语言预处理器中的宏,它们会被扩展为所在文件的文件名和所处代码行的行号,实际一些难于察觉的错误:

if (x>0 && y>0)
  assert (x>y);
else
  assert (y>x):

上面的写法似乎很合理,但是它展开之后就是这个样子:

if (x>0 && y>0)
  if (!(x > Y))
      assert_error("too.c",37);
   else
      if (!(y>x))
         assert_error("too.c",39);

也许会想到在宏 assert 的定义中用大括号把宏体整个给“括”起来,就能避免这样的问题产生。

#define assert (e)  {if (!e) assert_error (__FILE__, __LINE__ );}

然而,这样做又带来了新的问题,上面提到的例子展开后就成了:

if (x>0 && y>0)
  {if (!(x > Y)) assett_error("too.c",37);};
else
  {if (!(y>x)) assett_error("too.c",39);};

这样在 else 之前的分号是一个语法错误。要解决这个问题,一个办法是对 assert 的调用后面都不再跟一个分号,但这样的用法显得有些“怪异”。宏 assert 的正确定义很不直观,编程者很难想到这个定义不是类似于一个语句,而是类似一个表达式:

#define assert (e) 
   (()void)((e))  ||  assert_error (__FILE__, __LINE__ )))

这个定义实际上利用了 || 运算符对两侧的操作数依次顺序求值的性质。如果e为 true(真),不用求其右侧表达式,如果e 为 false(假),右侧表达式的值必须求出,此时assert_error 将被调用。

一般推荐使用do-while

//微信公众号:嵌入式系统
#define assert (e) 
   do   
   {    
     if (!e) assert_error (__FILE__, __LINE__ ); 
   }while(0)

6.4 宏并不是类型定义

宏的一个常见用途是使多个不同变量的类型可在一个地方说明:

#define FOOTYPE struct foo
FOOTYPE a;
FOOTYPE b, c;

这样编程者只需在程序中改动一行代码,即可改变 a、b、c 的类型,但是,最好还是使用类型定义;

typedef struct foo FOOTYPE;

这个语句定义了 FOOTYPE 为一个新的类型,与 struct foo 完全等效。两种命名类型的方式似乎差不多, 但是使用 typedef 的方式要更加通用。例如,考虑下面的代码:

#define T1 struct foo *
typedef struct foo *T2;

表面来看,T1 和 T2 从概念上完全符同,都是指向结构 foo 的指针。但是,当试图用它们来声明多个变量时,问题就来了:

T1 a,b;
T2 a,b;

第一个声明被扩展为, struct foo *a, b;

这个语句中 a 被定义为一个指向结构的指针,而 b 却被定义为一个结构,而不是指针。第二个声明则不同,它定义了a 和 b都是指向结构的指针,因为这里T2 的行为完全与一个真实的类型相同。

7、可移植性缺陷

C 语言在许多不同的系统平台上都有实现,能够方便地在不同的编程环境中移植。然而, 由于C 语言实现是如此之多, 各个实现之间有着或多或少的细微差别,以至于没有两个实现是完全相同的。程序员如果希望自己写的程序在另一个编程环境也能够工作,就必须了解许多这类细小的差别。

7.1 应对 C 语言标准变更

C标准的多个版本,不同C编译器的支持不同,导致代码在不同的环境下表现不同,许多有关可移植性的语法都有类似的特点。比如

//微信公众号:嵌入式系统
int i;
for ( int j = 0 ; j<100; j++ ) 
{
    //...
}

int j =0 在C99标准合法,但早期版本会提示错误。

7.2 标识符名称的限制

某些 C 语言实现把一个标识符中出现的所有字符都作为有效字符处理,而另一些 C 实现却会自动地截断一个长标识符名称的尾部。(C语言标识符支持大小写区分,现在C编译系统允许变量名的最大长度为32个字符甚至更多)。

7.3 整数的大小

C 语言提供了 3 种不同长度的整数: short 型、int 型和long 型,char行为与小整数相似。

C 语言对各种不同类型整数的相对长度作了一些规定:三种类型的整数其长度是非递减的,也就是说,short 型整数容纳的值肯定能够被 int 型整数容纳,int 型整数容纳的值也肯定能够被 long 型整数容纳。对于一个特定的 C 语言实现来说,不会让 short 型整数大于 int 型整数,而int 型整数大于 long 型整数。ANSI 标准要求 long 型整数的长度至少应该是 32 位, 而 short 型和 int 型整数的长度至少应该是 16 位。

这种情况下定义一个“新的”类型无疑更为清晰:

//微信公众号:嵌入式系统
typedef char int8_t;
typedef short int16_t;
typedef int int32_t;

typedef unsigned char uint8_t;
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;

程序员可以用这个新类型来声明所有此类变量,最坏的情形也不过是只需要改动类型定义,所有这些变量的类型就自动变为正确的。

7.4 字符是有符号整数还是无符号整数

编译器在转换 char 类型到 int 类型时,需要做出选择: 应该将字符作为有符号数还是应该无符号数处理? 如果是前一种情况,编译器在将 char 类型的数扩展到 int类型时,应该同时复制符号位;而如果是后一种情况,编译器只需在多余的位上直接填充0 即可。

char是 8 位字符,取值范围是从-128 到 127,还是从0 到 255?在某些编译器上可能会作为有符号数处理,在另一些编译器上又会作为无符号数处理。如果编程者关注最高位是 1 的字符其数值究竟是正还是负,可以将这个字符声明为无符号字符 (unsigned char)。

7.5 移位运算符

使用移位运算符的程序员经常对两个问题:

    在向右移位时,空出的位是由0填充,还是由符号位的副本填充?移位计数(即移位操作的位数) 允许的取值范围是什么?

第一个问题的,与具体的 C 语言实现有关。如果被移位的对象是无符号数, 那么空出的位将被 0 填充;如果被移位的对象是有符号数,那么C 语言实现既可以用0填充空出的位,也可以用符号位的副本填充空出的位。编程者如果关注向右移位时空出的位,那么可以将操作的变量声明为无符号类型,那么空出的位都会被设置为0。

第二个问题,如果被移位的对象长度是n 位,那么移位计数必须大于或等于0,而严格小于 n。

举例来说,如果一个 int 型整数是 32 位,n 是一个 int 型整数,那么 n<<31 和 n<<0 这样写是合法的,而 n<<32 和 n<<-1 这样写是非法警告。

需要注意的是,即使 C 实现将符号位复制到空出的位中,有符号整数的向右移位运算也并不等同于除以 2 的某次守。要证明这一点,考虑(-1)>>1,这个操作的结果一般不可能为0,但是(-1)/2 在大多数 C 实现上求值结果都是0。

但除法运算来代替移位运算,可能导致程序运行速度大大减慢。如果已知下面表达式中的 low+high 为非负,那么 mid = (low + high) >> 1; 与 mid = (low + high) / 2; 完全等效,但前者的执行速度也要快得多。

7.6 内存位置 0

NULL指针并不指向任何对象。除非是用于赋值或比较运算,出于其他目的使用 null 指针都是非法的。如果 p 或 q 是一个 null 指针,那么strcmp(p, q)的值就是未定义的,在这种情况下究竟会得到什么结果呢? 不同的编译器有不同的结果。

某些C语言实现对内存位置 0 强加了硬件级的读保护,在其上工作的程序如果错误使用了 null 指针, 将立即终止执行。其他一些 C 语言实现对内存位置0 只允许读,不允许写。在这种情况下,一个 null 指针似乎指向的是某个字符串,但其内容通常不过是一堆“垃圾信息”。还有一些 C 语言实现对内存位置 0 既允许读,也允许写,在这种实现上面工作的程序如果错误使用了null 指针,则很可能履盖了操作系统的部分内容,造成彻底的灾难!

严格说来,在所有的 C 程序中,误用 null 指针的效果都是未定义的。

7.7 除法运算时发生的截断

假定我们让 a 除以b(假定b 大于 0),商为 q,余数为r

q=a/b;
r=a%b;

希望 a、b、q、r之间维持怎样的关系呢?

    最重要的一点,q*b +r == a,因为这是定义余数的关系。如果改变 a 的正负号,希望改变 q 的符号,但不改变q的绝对值。当 b>0 时, 希望保证 r>=0 且 r<b。

这三条性质是整数除法和余数操作所应该具备的。很不幸的是,它们不可能同时成立。

考虑一个简单的例子: 3/2,商为1,余数也为 1。此时第 1 条性质得到了满足。(-3)/2 的值应该是多少呢? 如果要满足第 2 条性质,答案应该是-1,但如果是这样,余数就必定是-1,这样第 3 条性质就无法满足了。如果首先满足第3 条性质,即余数是 1,这种情况下根据第 1 条性质则商是-2,那么第 2 条性质又无法满足了。因此,C 语言在实现整数除法截断运算时,必须放弃上述三条原则中的至少一条。大多数程序设计语言选择了放弃第 3 条,而改为要求余数与被除数的正负号相同。

假定一个数 n代表经过某种运算后的结果,希望通过除法运算得到 h,满足 0<=h< HASHSIZE。又如果已知 n 恒为非负,那么只需要像下面一样简单地写: h=n % HASHSIZE; 然而,如果n 有可能为负数,而此时h 也有可能为负,那么这样做就不一定总是合适的了。不过已知 h>-HASHSIZE,可以这样写:

h=n % HASHSIZE;
if (h< 0)
    h += HASHSIZE;

更好的做法是程序在设计时就应该避免n值为负的情形,并且声明n 为无符号数。

7.8 随机数的大小

最早的C 语言提供了一个称为 rand 的函数,该函数的作用是产生一个(伪) 随机非负整数,当时计算机上的整数长度为16 位,后来机器上整数的长度为 32 位,那 rand 函数的返回值范围应该是多少呢?

当时有两组人员同时分别实现 C 语言,做出的选择互不相同。一组人员认为 rand 函数的返回值范围应该包括所有可能的非负整数取值, 因此们设计的 rand 函数返回一个介于0到2^32 -1的整数。

另一组人员地 rand 函数返回值范围与早期计算机上的一样,即介于0到2^15-1之间的整数,这样造成的后果是,如果程序中用到了 rand 函数,在移植时就必须根据特定的 C 语言实现作出裁剪, ANSI C 标准中定义了一个常数 RAND_MAX,它的值等于随机数的最大取值。

7.9 大小写转换

库函数 toupper 和 tolower 也有与随机数类似的历史。它们起初被实现为宏:

#define touppetrt(c) ((c)+'A'-'a')
#define tolower(c) ((c)+'a'-'A')

当给定一个小写字母作为输入,toupper 将返回对应的大写字母,而 tolower的作用正好相反。两个宏都依赖于特定字符集的性质,即所有的大写字母与相应的小写字母之间的差值是一个常量。这个假定对 ASCII 字符集来说都是成立的。然而,这些宏有一个不足之处, 如果输入的字母大小写不对,那么返回的就都是无用的垃圾信息。

大多数toupper和 tolower 的使用,都需要先进行检查以保证参数是合法的,后来两个宏重写如下:

#define toupper(c) ((c) >= 'a'&& (c) <= 'z'? (C) + 'A' -'a' : (c))
#define tolower(c) ((c) >= 'A'&& (c) <= 'Z'? (c) + 'a' -'A' : (c))

但这样做有可能在每次宏调用时,致使被求值 1 到 3 次。如果遇到类似 toupper(*p++)这样的表达式,可能造成不良后果。因此又重写 toupper和 tolower 为函数,重写后的 toupper 函数看上去大致这样:

int toupper (int c)
{
  if (c >= 'a' && c <= 'z')
       return c+'A' -'a';
   return c;
}

重写后的 tolower 函数也与此类似。改动之后程序的健壮性得到了增强,而代价是每次使用时引入了函数调用的开销,某些人也许不愿意付出效率方面损失的代价,因此又重新引入了这些宏,不过使用了新的宏名:

#define _toupper(c) ((c)+'A'- 'a')
#define _tolower(c) ((c)+'a'- 'A')

宏的使用者就可以在速度与方便之间自由选择,但是这意味着使用 toupper 和 tolower 时,传入一个大小写不合适的字母作为参数,在其他一些 C 语言实现上有可能无法运行。如果编程者不了解这段历史,要跟踪这类程序失败就很困难。

7.10 首先释放,然后重新分配

大多数 C 语言提供了 3 个内存分配函数: malloc,realioc 和free。调用 malloc(n)将返回一个指针,指向一块新分配的可以容纳 n 个字节的内存。把 malloc 返回的指针作为参数传给 free 函数,就释放了这块内存,这样就可以重新利用。调用 realloc 函数时,需要把指向一块已分配内存的区域指针以及这块内存新的大小作为参数传入,就可以调整(扩大或缩小) 这块内存区域为新的大小,这个过程中有可能涉及到内存的拷贝。

realloc 函数把指针 ptr 所指向内存块的大小调整为 size 字节,并返回一个指向调整后内存块 (可能该内存块已经被移动过了 ) 的指针。假定这块内存原来大小为 oldsize,新的大小为 newsize,这两个数之间较小者为 min(oldsize,newsize),那么内存块中 min(oldsize, newsize)部分存储的内容将保持不变。

如果 ptr 指向的是一块最近一次调用 malloc,realloc 或 calloc 分配的内存,即使这块内存已被释放, realloc 函数仍然可以工作。因此, 可以通过调节 free, malloc和 realloc 的调用顺序,充分利用 malloc 函数的搜索策略来压缩存储空间。也就是说,这一实现允许在某内存块被释放之后重新分配其大小,前提是内存重分配 〈reallocation) 操作执行得必须足够早。因此,在符合描述的系统中,下面的代码就是合法的,

free (p) ; p = realloc (p,newsize);

在一个有这样特殊性质的系统中,可以用下面这个多少有些“怪异”的办法,来释放一个链表中的所有元素:

for (p = head; p != NULL: p= p->next)
       free ((char *)p );

不必担心调用 free 之后会使 p->next 变得无效,当然,这种技巧不值得推荐,因为并非所有的 C 实现在某块内存被释放后还能较长时间的保留。

8、建议与答案

C 语言是一个强大灵活的工具,而一旦使用不慎又容易导致错误,那怎样才能避免这些问题呢? 也许最重要的规避技巧就是,知道自己在做什么。编程者几乎都有过这样的经历,在调试程序很长时间之后,开始漫无目的地瞎碰,这里试一下,那里改一点,如果碰巧程序似乎可以运行了,便万事大吉,这种工作方式往往最后导致一场灾难!

8.1 建议

关于如何减少程序错误,下面有一些通用的建议:

不要说服自己相信“皇帝的新装”。有的错误极具伪装性和欺骗性。例如前面的例子:

while (c == 't' || c = ' ' || c=='n')
       c = getc(f);

这个例子在 C 语言中是非法的,=与==混淆。

直截了当地表明意图。编写代码的本意是希望表达某个意思,但有可能被误解为另一种意思时,请使用括号或者其他方式让意图尽可能的清楚明了。这样不仅有助于日后重读程序时能够更好地理解自己的用意,也方便了其他程序员日后维护。

有时候还应该预料哪些错误有可能出现,在代码的编写方式上做到事先预防,一旦错误真正发生时能马上捕获。例如把常量放在判断相等的比较表达式的左侧:

while ('t'=c || ' '==c || 'n'==c)
       c = getc(f);

这样,如果不小心把比较运算符==写成了赋值运算符= ,编译器将会捕获到这种错误,并给出诊断信息:试图给字符常量 t 赋值是非法的。

考查最简单的特例。无论是构思程序的工作方式,还是测试程序的工作情况,这一原则都是适用的。当部分输入数据为空或者只有一个元素时(临界点),很多程序都会执行失败,这些情况应提前就考虑到。

使用不对称边界。C 语言中数组下标取值从 0 开始, 各种计数错误的产生与这一点或多或少有关系。一有旦理解了这个事实,处理这些计数错误就变得不那么困难。

注意潜伏在暗处的 Bug。各种 C 语言实现存在着或多或少的细微差别,应该坚持只使用 C 语言中众所周知的部分,避免使用“生僻”的特性。这样能方便地将程序移植到新的机器或编译器,减少编译问题。

防御性编程。对程序运行或编译器实现的假设不要过多,再怎么不可能发生的事情,某些时候还是有可能发生的,一个健壮的程序应该预先考虑到这些异常情况。可参考《动态内存管理及防御性编程》。

结束...

《C陷阱与缺陷》是C开发的经典书籍,属于技术层面,也可参考《高质量嵌入式软件的开发技巧》、《C语言关键字应用技巧》等,再往上的思想层面,可参考《嵌入式软件的设计模式(上)》、《嵌入式软件的设计模式(下)》、《基于RTOS的软件开发理论》、《嵌入式软件分层隔离的典范》等。更多嵌入式系统的知识,请关注微信公众号:嵌入式系统

 

推荐器件

更多器件
器件型号 数量 器件厂商 器件描述 数据手册 ECAD模型 风险等级 参考价格 更多信息
KSZ9021RNI 1 Microchip Technology Inc DATACOM, ETHERNET TRANSCEIVER, QCC48

ECAD模型

下载ECAD模型
$5.6 查看
ABM8-24.000MHZ-B2-T 1 Abracon Corporation CRYSTAL 24.0000MHZ 18PF SMD

ECAD模型

下载ECAD模型
$1.28 查看
HFBR-1531Z 1 Broadcom Limited Transmitter, Through Hole Mount, ROHS COMPLIANT PACKAGE

ECAD模型

下载ECAD模型
$17.74 查看

相关推荐

电子产业图谱

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