全部博文(132)
分类: C/C++
2009-04-07 12:34:32
C语言结合了汇编的所有威力,它的抽象程度碰巧既满足了程序员的要求, 又容易实现。 因其独特的灵活性和强大的可移植性,系统程序员和黑客们更是对它钟爱有加。无疑,C语言获得了空前的成功,在某种程度上甚至比UNIX还要成功。 然而,“C语言就像一把刻刀,简单,锋利,并且在技师手中非常有用。和任何锋利的工具一样,C会伤到那些不能驾驭它的人。” [8]C语言灵活性的背后,有着很多的要注意和避免的地方,一不小心你就会陷入bug的泥潭。 所以也无怪乎只有C语言才有像《The C Puzzle Book》,《Obfuscated C and Other Mysteries》,《C Traps and Pitfalls》之类的书。 下面的文章不是对那些书中提到的问题的重复,而是对其它一些细小的地方和部分C库函数进行讨论。 大部分都会集中到标准C上,参考的标准是ISO C99,一小部分可能会涉及到具体平台。
sizeof不是函数,而是一元操作符,没必要给它后面的表达式加括号。 但如果计算的是一种类型的大小,sizeof就需要加一个圆括号,但这不是说它是一个函数。 return不是函数,而只是关键字,也不需要圆括号。还要特别注意的是sizeof操作符“返回”的 是无符号整数(size_t),如果把它的“返回值”和一个int类型比较,先要把它转化成有符号整数。 另外,Unix系统调用read/write的返回类型是ssize_t,而不是size_t。 ssize_t代表signed size。
NULL表示的是空指针,它和任何非空指针值都不相等。而NUL就是'\0', 表示的是值为0的空字符。虽然0.0, 0, '\0', NULL都是完全由0比特组成,但是它们的长度不同。0.0是双精度, 通常是8个字节;'\0'是字符常量,通常只有1个字节;NULL表示空指针,它的长度由系统决定, 也就是系统中储存一个内存地址所需的字节数;0表示整数,一般是4个字节。它们的类型不同, 在用它们给变量赋值时应该小心,有必要时进行强制转换。 注意:C语言不保证char总是8个比特,也有7个或9个比特的字符! 虽然没有规定short是16个比特,int是32个比特,但是在目前的所有构架上都是如此。 long并不总和int一致,它与机器字长一致,也就是说在32位机上它是4个字节,而在64位机上是8个字节。 long long是扩展类型,并不是每个编译器都支持,在32位机上它是8个字节。
C语言在可移植性方面设计得非常好,而且又不失高效率,这也是GTK+和Linux Kernel 都青睐C的重要原因。但是,用C语言编写的程序并不能保证能“一次编写,到处运行”,即使你使用的是ANSI C。 原因很简单,因为C语言的设计初衷是为了编写UNIX方便,难免带点“底层特性”,不同机器上C的实现多少都有些不同。这实在让人不安。
历史上,关于此曾有一场圣战[9],最终以和平而告终。 所谓大尾就是最高有效位(MSB)先被放置, 依次再放其它。小尾恰好反过来。比如:数66563,被当做32位int储存时,在大尾和小尾机器上分别如下:
IA-32构架是小尾,而其它多数构架是大尾。下面的程序可以测试机器是大尾还是小尾。Address Big Endian Little Endian
0 00000000 00000011
1 00000001 00000100
2 00000100 00000001
3 00000011 00000000
字节顺序在网络传输中非常重要,因为网络上的机器有不同的字节顺序。 为了方便,TCP/IP套接字为整数类型定义了一个统一的网络字节顺序(大尾),也为此提供一套转换函数。所以, 要编写可移植的代码,不要假设任何特定的字节顺序。int x = 1;
if (*(char *)&x == 1)
/* little endian */
else
/* big endian */
“结构和联合中允许有定义为位字段(bit-field)的成员。位字段可以定义为int,signed int,unsigned int之一,并且可以
给出附加的宽度值。”[3]中的程序可以确定你的硬件系统和编译器处理位字段的方式:
#include在IA-32 GCC4.1.0上运行结果如下:
typedef struct DEMO{
unsigned int one:1;
unsigned int two:3;
unsigned int three:10;
unsigned int four:5;
unsigned int :2;
unsigned int five:8;
unsigned int six:8;
}demo_type;
int main(void){
int k;
unsigned char* bptr;
demo_type bit={1,5,513,17,129,0x81};
printf("\nsizeof demo_type =%u\n",sizeof(demo_type));
printf("initial values:bit=%u,%u,%u,%u,%u,%u\n",bit.one,bit.two,bit.three,bit.four,bit.five,bit.six);
bptr=(unsigned char *)&bit;
printf("hex dump of bit: %02x %02x %02x %02x %02x %02x %02x %02x \n",bptr[0],bptr[1],bptr[2],bptr[3],bptr[4],bptr[5],bptr[6],bptr[7]);
bit.three=1023;
printf("\nassign 1023 to bit.three: %u,%u,%u,%u,%u,%u\n",bit.one,bit.two,bit.three,bit.four,bit.five,bit.six);
k=bit.two;
printf("assign bit.two to k:k=%i\n",k);
return 0;
}
sizeof demo_type =8如上所示,GCC编译器给位字段分配内存时仍以字节为单位分配,所以分配了8个字节。 在储存位字段时,编译器是从右向左分配的,这当然会因机器不同而异。C语言中,关于编译器如何安排位字段的规定很少。 确实存在某些种类的分配单元,而且分配单元大小也取决于编译器,但编译器可以从高位或低位开始分配位字段。 要编写可移植的程序,不要假设位字段是怎样分配的。
initial values:bit=1,5,513,17,129,129
hex dump of bit: 1b 60 24 10 81 00 00 00
assign 1023 to bit.three: 1,5,1023,17,129,129
assign bit.two to k:k=5
大多数情况下,你不必去关注内存对齐,因为编译器已经为我们做好了。但是,如果你在编写编译器,对齐问题就是你关注的主要问题之一了。 因为对齐具有明显的优点──提高了内存访问速度,虽然有时会浪费一些空间。而且,有的RISC体系上要求严格对齐, 否则就会触发异常,这对内核程序员非常重要。即使在Intel这样的CISC体系上,一些 SSE、SSE2指令对对齐还是要求很严格的,比如:movdqa(SSE 指令) ,movapd(SSE2指令)。所以,我们还是“偷窥”一下内存对齐。
编译器通常会为我们做好对齐的工作,但是,当程序员在编译器预期之外使用指针 来访问数据时,对齐就会出问题。下面的程序[11]可能就不能正常工作。
char foo[10];因为指针p可能不是4的倍数。对齐问题在结构体中更为明显。
char *p = &foo[1];
unsigned long l = *(unsigned long *)p;
上面的结构体在内存中并不像你看到的那样储存,编译器会在其中填充空为来实现对齐,baz的偏移可能会是4而不是1! 解决的方法手工去填充或者重新安排结构体成员顺序。struct foo_struct {
char foo; /* 1 byte */
unsigned long baz; /* 4 bytes */
unsigned short bar; /* 2 bytes */
char foobar; /* 1 byte */
};
ISO C规定编译器绝不能改变结构体成员的顺序,这工作只能由程序员来做。struct foo_struct {
unsigned long baz; /* 4 bytes */
unsigned short bar; /* 2 bytes */
char foo; /* 1 byte */
char foobar; /* 1 byte */
};
大多数系统使用ASCII或者Unicode来编码字符,而Unicode是兼容ASCII的 。所以,如果你只使用英文字符,很多时候你没必要担心字符的编码问题。但是,确实存在其它字符集, 和上述两种字符集不兼容,比如:DBCS字符集和IBM的EBCDIC字符集。下面的程序在使用EBCDIC字符集的机器上 并不能正常工作!
for(i=0; i < strlen(foo); i++)C99只保证:所有位都是0的字节应该在字符集中存在,它是用来结束字符串的空字符;26个大写英文字母及其小写字母, 10个十进制数字,下面29个图形字符,
{if(foo[i]>=32 && foo[i] <=126) *(ptr++) = foo[i];}
空格字符,和表示垂直制表,水平制表,换页的控制符,应该在最基本的 源字符集(source character set)和执行字符集(execution character set)中; 0后面的数字字符的值都应该比它前面一个大;每个字符都应该是一个字节大小;源字符集中应该有表示行结束的字符; 执行字符集中应该有表示响铃,退格,回车和换行的字符。超出此范围的情况是未定义的。! " # % & ' ( ) * + , - . / :
; < = > ? [ \ ] ^ _ { | } ~
多年前,匈牙利程序员Charles Simonyi设计了一种在变量名中 加上特定的前缀来辨别变量类型的命名方法,它的优点很显然,就是可以直接通过变量名来辨认其类型, 而不用去查找它的定义。微软后来采用了这种思想,可以在VC中看到大量的这种表示。但这不仅使变量的名字很古怪, 很难记,而且还有个很大的缺点,那就是可能会使改变变量类型的工作变得十分艰巨。设想一下,我们在一个大型项目 中定义了一个全局变量,经过几十多个函数使用后,我们突然发现这种类型的字节数不够用,我们不得不去改变它的类型。 好了,你得从头开始把它的名字都得改一遍!现在很多程序员都放弃了它,除了一些顽固的Windows程序员。 关于变量的命名,Unix系统调用的“全部小写”风格或许很值得借鉴,简单而优雅;如果要使用大写字母,不妨 学一下Qt的命名风格。
我们知道,在预处理阶段,C预处理器把#include后面的头文件拷贝到源代码中去。
所以,在头文件中放入些什么内容应该值得注意。通常,我们把常量,类型和函数的声明都放入头文件中。
但要注意:千万不要把变量的声明也放进去了!否则可能会产生多次定义的变量!
如果可以,“头文件中最好不要再包含头文件。多重包含是系统编程的祸根。在一个C源文件中把文件包含了5次以上去编译并不少见。Unix的/usr/include/sys非常糟糕。
”[10]#ifdef可能会解决点问题,但是通常它也会被误用。
C的标准库里面有很多头文件都定义了标识自己的宏,在包含之前应该先用#ifdef去检查。
比如,stdio.h中有这样的宏:
#ifndef _STDIO_H这样,我们就可以通过宏来检查有没有包含stdio.h这个头文件了:
#define _STDIO_H
/*omit other code*/
#endif
#ifdef _STDIO_H不注意这一点的后果就是数百行无用的代码被包含进去,传递给词法分析器,消耗大量宝贵的编译时间。
/*if stdio.h has already been included...*/
#endif
...其实这也并不奇怪,你也不经常这样使用下面的语句吗?
scanf(...);
...
int x=9;表达式++x也有它的值,但是你也没有使用它的“返回”值(这里为10)!正如++x,scanf也是既有值,也有作用效果(把输入的值放入某块内存中,而++x的作用效果是把x的值加一),我们只是用到它们的作用效果,而不使用它们的值。为了明确地表示不使用返回值,最好这样来写:
++x;
...
...
(void)scanf(...);
...
"#include
int sprintf(char * restrict s,const char * restrict format,...);
sprintf函数等价于fprintf,除了输出是写入一个数组(记为参数s)而不是一个流。NUL字符被写入所写字符组的结尾;它不 被计入返回值。如果复制发生在重叠的对象之间,行为则是未定义的。"
sprintf(mystring, "%s%d%s%f", string, j, otherstring, d);很可惜,它并不能真正地将其它类型加入字符数组中(听起来像Matlab中的元胞数组),而是统统将它们转化成字符后又写入的。
char tempstring[50]={0};把一种其它类型的数据转换成字符数组储存,使用sprintf函数真的是一个不错的方式。然而,sprintf同样是一个问题函数。它致命的缺陷就是它并不知道写入的字节数是否超出了缓冲区的大小!
char* otherstring;
int d=5;
float j=3.14;
otherstring="other";
sprintf(tempstring,"%f%s%d",j,otherstring,d);
printf("%s\n",tempstring);
printf("%d\n",tempstring[13]);
int showmsg(int line, unsigned int err, char* msg){在上面的程序中,提示占了28个字节,line可能会占10个字节,err可能会占11个字节。也就是说,msg的大小不能超过50个字节!我们从sprintf的返回值中得不到任何buf是否够用的信息。这很危险,而且几乎没有安全的使用它的方式!
char buf[100];
if(msg==NULL)return -1;
else
sprintf(buf,"Err occurred in line %d, %u is %s.",line,err,msg);
return 0;
}
int _snprintf(char* buffer, size_t count, const char* format[,argument]...);与sprintf不同的是,当缓冲区不够用时,_snprintf会返回一个负数,但它并不保证目的缓冲区以NUL结尾,你必须手工去做。这倒是挺好用,遗憾的是,_snprintf并不是ISO C99的一部分。
strncpy(path, homedir,sizeof(path) - 1);[6]中推荐使用strlcpy和strlcat函数(关于这两个函数,可以查看在 ftp.openbsd.org服务器上/pub/OpenBSD/src/lib/libc/string的目录中的源代码以及相关手册),因为它们总 是保证以NUL结束字符串,它们都把目的字符数组的全部长度作为参数,而且它们返回的是程序员想得到的字符串的总长度。它们的原型是:
path[sizeof(path) - 1] = '\ 0';
strncat(path, "/",sizeof(path) - strlen(path) - 1);
strncat(path, ".foorc",sizeof(path) - strlen(path) - 1);
len = strlen(path);
size_t strlcpy(char *dst, const char *src, size_t size);上面的程序可以用strlcpy和strlcat来重写,非常方便。
size_t strlcat(char *dst, const char *src, size_t size);
strlcpy(path, homedir, sizeof(path));可惜,它们只是OpenBSD上的。希望未来的C标准委员会会把这两个方便的函数加入标准之中。
strlcat(path, "/", sizeof(path));
strlcat(path, ".foorc", sizeof(path));
len = strlen(path);
#include如果*stringp是NULL,strsep什么都不做,安静地返回NULL。否则,strsep在*stringp中搜寻第一个出现的限定符delim中的任意字符,并且用NUL来结束它之前的字符串,让*stringp指向它的后面一个字符。如果没发现任何字符,*stringp被设为NULL。它返回的是解析出的字符串。
char *strsep(char **stringp, const char *delim);
#include除了额外的参数lasts之外,strtok_r与strtok表现类似。lasts是用户提供的一个指针,指向strtok_r用来存放下一次解析的起始地址的那个单元。可它也不怎么好用。 总上,不要使用strtok函数,除非你对它非常了解。但在环境适宜时,它仍不失为一个优秀的工具。更进一步说,除非是在受控的情况下,不要使用静态变量。
char *strtok_r(char *restrict s, const char *restrict delim, char **restrict lasts);
Segmentation Fault,也就是分段错误,是我们编写C程序时很常见的错误, 尤其是在Linux上。然而很多人只是知道它与指针有关,却不知道引发错误的细节。我想在这里简单地说说我的理解。 我们知道操作系统管理虚拟内存有两种方式:一种是分段,另一种是分页。分页的意思是内存被分成相等大小的页面来管理, 每个页中包含有内存的字。分段的意思是每个进程都有一个所需大小的内存段,段与段之间以空白的存储块相间隔。 操作系统知道每一段的上界,并且每个段都以虚拟0地址开始。当程序访问一个内存块时,它调用的是虚拟地址, 但MMU(Memory Management Unit)会把它映射到一个真实的地址。 如果操作系统发现请求地址与有效的段地址都不匹配,它就会向进程发送一个终止信号。 Segmentation Fault也就这样产生了。如果Segmentation Fault发生, 说明你的程序中有错误指针,内存泄露,或者其它访问错误内存地址的错误。请你再仔细地检查你的指针, 数组是否正确,一般是使用malloc分配了多少内存就使用多少,不使用未分配内存的指针进行strcpy等操作。
在阅读这一节之前,你必须了解ISO C99中的几个概念。一个源文件及其包含的头文件或源文件叫做预处理翻译单元(preprocessing translation unit)。完成预处理后,之前的预处理翻译单元就改叫翻译单元(translation unit)。使在不同作用域中声明的,或在同一作用域中声明多次的一个标识符指向同一个目标或函数,叫做链接(linkage)。在构成整个程序的一系列的翻译单元和库中,同一个拥有外部链接(external linkage,外部指的是翻译单元的外部)的标识符(叫做外部名字(external name))的每次声明都代表同一个目标或函数;在一个翻译单元中,同一个拥有内部链接(internal linkage)的标识符的每次声明也都代表同一个目标或函数。宏名或没有外部链接的标识符叫做内部名字(internal name)。
ISO C99规定:至少内部名字的前31字符必须是唯一的,外部名字的前6个字符是必须唯一的,大小写可以不区分。所以,你在定义名字时一定要确保不与C预留的标识符冲突。否则行为是未定义的。所以,下面的外部名字看起来不同,但它们对编译器来说是可能是相同的:
cleared()和clearerr(),CallOccasionally()和calloc(),reallocation和realloc()
当然了,可能你的编译器能区分6个以上的字符,能区分大小写。但是上述规则还是值得遵循的。关于C和C++预留的标识符,[7]中作了详细的整理,是很好的参考。
标准库stdarg.h提供了一种可移植的方式,让程序员编写带可变参数的函数, 就像printf。可它究竟是怎么实现的呢?下面分析一下可变参数在IA-32平台上的具体实现。 一种实现如下:
内部宏va_rounded_size计算指定类型是int类型的多少倍,结果向上取整,因为IA-32中栈是4字节对齐的。 在宏va_start中,v是你声明的最后一个参数,va_start让ap指向它后面的参数,并且什么都不返回。 宏va_arg访问参数列表中的下一个元素,让ap先指向下一个,再返回它的前一个,即原来ap指向的参数。 va_end把ap置为零,什么都不返回。以这三个宏为接口,就能实现可变参数函数。#ifndef STDARG_H
#define STDARG_H
typedef char* va_list;
#define va_rounded_size(type) \
(((sizeof (type) + sizeof (int) - 1) / sizeof (int)) * sizeof (int))
#define va_start(ap, v) \
((void) (ap = (va_list) &v + va_rounded_size (v)))
#define va_arg(ap, type) \
(ap += va_rounded_size (type), *((type *)(ap - va_rounded_size (type))))
#define va_end(ap) ((void) (ap = 0))
#endif
C99中定义了三个类型修饰符(type qualifier),分别是:const,volatile,restrict。
const的使用并不像你想像得那么简单。不能简单的把const认为 就是constant。const真正的意思是只读(read-only),使用它是为了防止一些变量被修改,比如: 字符串拷贝时的源字符串。const的位置很重要,比如const int* x;是定义一个指向只读整数的指针,整数不能被修改而指针本身可以; 而int const* x;是定义一个指向一个整数的只读指针,整数可以被修改而指针不能。注意,“不能被修改” 不是说永远不能被修改,而是不能通过这个符号修改,而通过别的可以。下面的程序是正确的:
const int a=10;让人吃惊的是,const char**和char**并不兼容,[5]中很好地解释了原因,这里就不再赘述。
int *p;
p=(int*)&a;
(*p)++;
volatile关键字把变量标记为可以改变而且没有警告,它通知编译器每次遇到被标记 的变量都需要重新加载,而不是储存起来去访问它的拷贝。使用volatile的最好的例子莫过于处理硬件中断,寄存器,和 同步进程共享变量。
最难以理解的修饰符是restrict,C99标准第6.7.3.1中的对它的定义非常隐晦。 其实,restrict的作用就是限制一个指针对一块内存的访问,进一步说就是如果一块内存区域通过一个受限制 指针访问,那么它就不能通过另一个受限指针访问。可见,与前两个不同,restrict只能用于指针。 引入restrict的目的是确保同一块内存上没有其它引用,让编译器更好地优化指令,生成更有效的汇编代码。