小心C语言的定义与声明

转自360博客

注:为便于说明问题,文中提及的变量和函数都被简化。

一、起源

DBProxy在测试过程中,发现对其执行某步管理操作后,程序有时会崩溃,但不是每次都出现。

二、GDB跟踪

反复多次测试,然后用GDB打开core dump文件,查看程序崩溃时的堆栈,发现可能的崩溃只有两处,这两处的共同点是前面都调用了一个函数get_pointer得到一个指针,如下图所示:

然后在使用该指针进行下步操作时程序崩溃。

查看该指针的值,发现其指向一个无效地址,所以操作该地址产生了段错误,如下图所示:

三、无效地址的产生原因

函数get_pointer的原型是char *get_pointer(void),函数体内只是通过简单的malloc操作获取一个char*类型的指针,然后返回给调用者。
malloc()结果只有两种:

  1. 成功,返回一个指向合法地址的指针
  2. 失败,返回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\n”, 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\n”, 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,就可以避免参数或返回值可能的不一致了。

七、总结

  1. 在开发过程中,应该严格遵循先声明后定义、先声明后使用的原则,一方面保持良好的编码风格,另一方面也能避免很多潜在的错误;
  2. 从参数不一致造成的问题来看,最好不要使用extern声明函数,而应该使用包含头文件的形式;
  3. 编译时打开-Wall选项,对于编译过程中输出的每个WARNING都要仔细检查,防止出现各种匪夷所思的bug;
  4. 在某些场合,使用g++代替gcc可以获得更好的安全性。

小心C语言的定义与声明的更多相关文章

  1. C语言指针与数组的定义与声明易错分析

    部分摘自<C语言深度解剖> 1.定义为数组,声明为指针 在文件1中定义: char a[100]; 在文件2中声明: extern char *a; //这样是错误的 这里的extern告 ...

  2. C语言全局变量的定义与声明

    C语言中全局变量的定义与声明困扰着许多C语言初学者.本文讲述了全局变量定义与声明的用法,而且本为也将阐述这种用法的内在原理.我们先从两个错误例子引入,以下两个例程都在vc6.0平台上测试. 两种错误例 ...

  3. 详解keil采用C语言模块化编程时全局变量、结构体的定义、声明以及头文件包含的处理方法

    一.关于全局变量的定义.声明.引用: (只要是在.h文件中定义的变量,然后在main.c中包含该.h文件,那么定义的变量就可以在main函数中作为全局变量使用) 方法1: 在某个c文件里定义全局变量后 ...

  4. keil采用C语言模块化编程时全局变量、结构体的定义、声明以及头文件包含的处理方法

    以前写单片机程序时总是把所用函数和变量都写在一个c文件里,后来遇到大点的项目,程序动则几千行,这种方式无疑会带来N多麻烦,相信大家都有所体验吧! 后来学会了在keil里进行模块化编程,即只把功能相同或 ...

  5. c语言函数定义、函数声明、函数调用以及extern跨文件的变量引用

    1.如果没有定义,只有声明和调用:编译时会报连接错误.undefined reference to `func_in_a'2.如果没有声明,只有定义和调用:编译时一般会报警告,极少数情况下不会报警告. ...

  6. C语言,函数的声明与定义

    函数声明与定义 变量: 在讲变量前,先讲一下变量的声明和定义这两个概念. 声明一个变量,意味着向编译器描述变量的类型,但不为变量分配存储空间. 定义一个变量,意味着在声明变量的同时还要为变量分配存储空 ...

  7. 【转】c语言中的定义和声明

    1. 变量的定义.声明 变量的声明有两种情况: 一种是需要建立存储空间的.例如:int  a.在声明的时候就已经建立了存储空间.这种声明是"定义性声明(defining declaratio ...

  8. 1029 C语言文法定义与C程序的推导过程

    1 阅读并理解提供给大家的C语言文法文件. 2 参考该文件写出一个自己好理解版的现实版的完整版的C语言文法. 3 给出一段C程序,写出用上述文法产生这段C程序的推导过程. program → exte ...

  9. 定义与声明、头文件与extern总结

     用#include可以包含其他头文件中变量.函数的声明,为什么还要extern关键字? 如果我想引用一个全局变量或函数a,我只要直接在源文件中包含#include<xxx.h> (xxx ...

随机推荐

  1. 【Unity Shaders】学习笔记——SurfaceShader(一)认识结构

    [Unity Shaders]学习笔记——SurfaceShader(一)认识结构 转载请注明出处:http://www.cnblogs.com/-867259206/p/5595747.html 写 ...

  2. Objective-C的内存管理

    一.Objective-C内存管理的对象 1. 值类型:比如int.float.struct等基本数据类型. 值类型会被放入栈中,在内存中占有一块连续的内存空间,遵循先进后出的原则,故不会产生碎片. ...

  3. DEDECMS如何修改数据库密码以及忘记了后台密码怎么办

    忘记后台密码1.登录PHPmyadmin登录phpmyadmin,找到忘记密码网站所在的数据库并打开2.打开dede_admin数据表:找到dede_admin这项如图,pwd下的值就是你的密码,织梦 ...

  4. Oracle笔记 十二、PL/SQL 面向对象oop编程

    ------------------------抽象数据类型----------- --创建地址类型,一定要加as object,还可以在类型中加过程或方法 create or replace typ ...

  5. vs2010 编译curl-7.42.1

    curl是一个sftp ssl 等功能工具. dos 进入到curl-7.42.1\lib下来:逐步执行以下操作 call "C:/Program Files/Microsoft Visua ...

  6. 1.4Linux内核版本号的定义规则

    Linux内核版本号的组成: (1)主版本号: (2)次版本号: (3)修订版本号: (4)微调版本号: (5)为特定的Linux系统特别调校的描述: 例子:2.6.29.7-flykernel-12 ...

  7. 二十、ValueStack的常用方法

    二十.ValueStack的常用方法 void set(String key,Object value):先获取根栈栈顶的Map,如果不存在,压入一个新的Map public String execu ...

  8. 在WP8项目中使用ARMASM

    由于之前项目中某些密集运算优化的需要,涉及到ARMASM相关的内容, 所以有幸可以在此分享一下自己的经验. 先铺垫一些知识: 1. ARM处理器有两种指令ARM.THUMB, 在WP8下默认是THUM ...

  9. Ubuntu 14.04下java开发环境的搭建--1--JDK的安装

    说明:以下内容均是本人个人经验,接触ubuntu系统是从10.04开始,转眼转眼之间已经四年了,经常浏览各种相关论坛,发现从我刚开始基础到现在,论坛上还有很多人在问关于JAVA环境配置的相关问题.所以 ...

  10. Javascript中的一种深复制实现

    在javascript中,所有的object变量之间的赋值都是传地址的,可能有同学会问哪些是object对象.举例子来说明可能会比较好: typeof(true) //"boolean&qu ...