小心C语言的定义与声明
注:为便于说明问题,文中提及的变量和函数都被简化。
一、起源
DBProxy在测试过程中,发现对其执行某步管理操作后,程序有时会崩溃,但不是每次都出现。
二、GDB跟踪
反复多次测试,然后用GDB打开core dump文件,查看程序崩溃时的堆栈,发现可能的崩溃只有两处,这两处的共同点是前面都调用了一个函数get_pointer得到一个指针,如下图所示:
然后在使用该指针进行下步操作时程序崩溃。
查看该指针的值,发现其指向一个无效地址,所以操作该地址产生了段错误,如下图所示:
三、无效地址的产生原因
函数get_pointer的原型是char *get_pointer(void),函数体内只是通过简单的malloc操作获取一个char*类型的指针,然后返回给调用者。
malloc()结果只有两种:
- 成功,返回一个指向合法地址的指针
- 失败,返回NULL
为什么会返回一个无效地址呢?
在get_pointer函数返回前加入一个printf语句,打印即将返回给调用者的指针值:
再在调用处之后也加入一个printf语句,打印调用者接收到的指针值:
重新编译,反复进行多次测试,发现打印出的两个值有时相同有时不同,相同时程序正常运行,不同时程序必然崩溃。
更重要的是,这些值看上去很有规律:
p=1c34abd0
pointer=1c34abd0
正常运行
p=38ac7bda1690
pointer=7bda1690
接下来崩溃
p=a5ef6824
pointer=ffffffffa5ef6824
接下来崩溃
……
观察到一个现象:如果p值是4字节(即高4字节为0)且低4字节的最高位为0,则二者相同,否则二者必定不同。而pointer值的高4字节只有两种情况,一种是0×00000000,一种是0xFFFFFFFF,再结合低4字节的首位,可以看出pointer值的高4字节是由p值的低4字节补全了高4字节形成的。
四、遗漏的头文件
注意到编译时有如下信息输出“警告:初始化时将整数赋给指针,未作类型转换”。当
初察看该函数,发现返回的是指针,认为是编译器的误报,未作处理。重新审视该警告信息,结合调用处的源文件,发现该文件没有包含声明get_pointer函数的头文件。在源文件头部添加#include语句,包含该头文件后,重新编译,警告消失,反复测试,程序不再崩溃,非常稳定。看来问题产生的原因是因为漏包含了头文件,那么为什么不包含也可以编译通过呢?
五、一个简单的试验
/**********a.c**********/
#include <stdio.h>
int main()
{
void *p = func();
printf(“p=%lx ”, p);
return 0;
}
/**********b.c**********/
void *func()
{
void *p = (void*)0x1234567890ABCDEF;
return p;
}
gcc –c a.c b.c,系统提示:
虽然有警告,但是编译却成功了。
我们再来链接一下,gcc –o all a.o b.o,链接也成功了。
运行程序,./all,输出结果是p=FFFFFFFF90ABCDEF,而我们期待的值是1234567890ABCDEF,问题重现了。
加上-Wall选项后重新编译,发现系统多了一行输出“警告:隐式声明函数 ‘func’”,
查阅了隐式声明的相关资料得知,原来,编译器会将所有隐式声明的函数的返回值类型都认定为int。
如此一来原因就比较清楚了,在b.c里定义的func函数返回的确实是指针类型,而在a.c里认为func返回的是int类型,程序运行时会将func返回的指针类型值强制转换为int,然后再强制转换为void*,赋给变量p。在64位机上,指针为8字节,int为4字节,在由指针转换为int时,高4字节被丢弃,值由0x1234567890ABCDEF变为0x90ABCDEF,然后在由int转换为指针的过程中,根据有符号数的补齐原则,按照int最高位是0还是1,将高4字节每一位全部补全为0或1。0x90ABCDEF的最高位是1,所以高4字节每一位都补全为1,最终形成了结果0xFFFFFFFF90ABCDEF。
那为什么实际运行时不是每次都崩溃呢?这是因为被调用的函数所返回的指针是动态分配的,其值事先不固定,如果被初始化的指针地址的高4字节和低4字节的最高位原本就是0,如0×0000000012345678,那么在将强制转换为int时丢弃高4字节对其就没有任何影响了,值还是0×12345678,然后再由int转为指针,高4字节补0,值为0×0000000012345678,所以程序可以正常运行下去。
六、编译与链接、定义与声明
在编译阶段,各个源文件独立编译,所以a.c和b.c是分开编译的,a.c里调用了func
函数而没有包含其声明(声明一般使用#include “b.h”,也可以使用extern函数原型的形式),编译器会认为func函数为隐式声明,将其返回值类型定为int。所以编译虽然有警告,但却成功了。
编译阶段是不需要函数的定义的。我们把b.c里的func函数注释掉,gcc –c a.c b.c一样可以执行成功。
在链接阶段,链接器将所有源文件编译得到的二进制文件以及调用的库链接到一个可执行文件中,此时链接器会去找func函数的具体定义,以供main函数调用。因为func函数确实有定义,所以链接也会成功。
链接阶段必须有函数的定义,否则链接器会报错。我们还是注释掉func,编译后再执行gcc –o all a.o b.o,系统输出如下:
在运行阶段,因为a.c在编译时认为func返回int类型,所以func的返回值(8字节指针)被截断为4字节的int,然后再进行高4字节的扩展,最后赋给了main函数里的变量p。在这两次类型转换中,p的值就有可能与func的返回值不相同了,p实际上已经成为一个野指针。
我们换用g++来编译看下效果:
看来g++对语法要求更严格,不允许隐式声明func函数。
返回值有可能因为隐式声明而不符合我们的期望,那么函数的参数呢?我们再来尝试一下。
/**********c.c**********/
extern void func(long);
int main()
{
func(0x1234567890ABCDEF);
return 0;
}
/**********d.c**********/
#include <stdio.h>
void func(int a)
{
printf(“a=%x ”, a);
}
gcc -c c.c d.c -Wall,成功。
gcc -o all c.o d.o -Wall,成功。
运行程序./all,输出a=90abcdef,这显然不是我们想要的结果,但在编译和链接时却没有任何错误或警告报出。
使用g++编译,无法通过。
如果我们新建一个头文件d.h,将func函数的原型在d.h里声明,然后在c.c和d.c里都包含d.h,就可以避免参数或返回值可能的不一致了。
七、总结
- 在开发过程中,应该严格遵循先声明后定义、先声明后使用的原则,一方面保持良好的编码风格,另一方面也能避免很多潜在的错误;
- 从参数不一致造成的问题来看,最好不要使用extern声明函数,而应该使用包含头文件的形式;
- 编译时打开-Wall选项,对于编译过程中输出的每个WARNING都要仔细检查,防止出现各种匪夷所思的bug;
- 在某些场合,使用g++代替gcc可以获得更好的安全性。