posts - 62,  comments - 19,  trackbacks - 0

Quto from http://blog.csdn.net/absurd

 



随着诸如代码重构和单元测试等方法引入实践,调试技能渐渐弱化了,甚至有人主张废除调试器。这是有道理的,原因在于调试的代价往往太大了,特别是调试系统集成之后的 BUG ,一个 BUG 花了几天甚至数周时间并非罕见。

 

而这些难以定位的 BUG 基本上可以归为两类:内存错误和并发问题。而又以内存错误最为普遍,即使是久经沙场的老手,也有时也难免落入陷阱。前事不忘,后世之师,了解这些常见的错误,在编程时就加以注意,把出错的概率降到最低,可以节省不少时间。

 

这些列举一些常见的内存错误,供新手参考。

 


1.          内存泄露。

大家都知道,在堆上分配的内存,如果不再使用了,应该把它释放掉,以便后面其它地方可以重用。在 C/C++ 中,内存管理器不会帮你自动回收不再使用的内存。如果你忘了释放不再使用的内存,这些内存就不能被重用,就造成了所谓的内存泄露。

 

把内存泄露列为首位,倒并不是因为它有多么严重的后果,而因为它是最为常见的一类错误。一两处内存泄露通常不至于让程序崩溃,也不会出现逻辑上的错误,加上进程退出时,系统会自动释放该进程所有相关的内存,所以内存泄露的后果相对来说还是比较温和的。当然了,量变会产生质变,一旦内存泄露过多以致于耗尽内存,后续内存分配将会失败,程序可能因此而崩溃。

 

现在的 PC 机内存够大了,加上进程有独立的内存空间,对于一些小程序来说,内存泄露已经不是太大的威胁。但对于大型软件,特别是长时间运行的软件,或者嵌入式系统来说,内存泄露仍然是致命的因素之一。

 

不管在什么情况下,采取比较谨慎的态度,杜绝内存泄露的出现,都是可取的。相反,认为内存有的是,对内存泄露放任自流都不是负责的。尽管一些工具可以帮助我们检查内存泄露问题,我认为还是应该在编程时就仔细一点,及早排除这类错误,工具只是用作验证的手段。

 


2.          内存越界访问。

内存越界访问有两种:一种是读越界,即读了不属于自己的数据,如果所读的内存地址是无效的,程度立刻就崩溃了。如果所读内存地址是有效的,在读的时候不会出问题,但由于读到的数据是随机的,它会产生不可预料的后果。另外一种是写越界,又叫缓冲区溢出。所写入的数据对别人来说是随机的,它也会产生不可预料的后果。

 

内存越界访问造成的后果非常严重,是程序稳定性的致命威胁之一。更麻烦的是,它造成的后果是随机的,表现出来的症状和时机也是随机的,让 BUG 的现象和本质看似没有什么联系,这给 BUG 的定位带来极大的困难。

 

一些工具可以够帮助检查内存越界访问的问题,但也不能太依赖于工具。内存越界访问通常是动态出现的,即依赖于测试数据,在极端的情况下才会出现,除非精心设计测试数据,工具也无能为力。工具本身也有一些限制,甚至在一些大型项目中,工具变得完全不可用。比较保险的方法还是在编程是就小心,特别是对于外部传入的参数要仔细检查。


 

3.          野指针。

野指针是指那些你已经释放掉的内存指针。当你调用 free(p) 时,你真正清楚这个动作背后的内容吗?你会说 p 指向的内存被释放了。没错, p 本身有变化吗?答案是 p 本身没有变化。它指向的内存仍然是有效的,你继续读写 p 指向的内存,没有人能拦得住你。

 

释放掉的内存会被内存管理器重新分配,此时,野指针指向的内存已经被赋予新的意义。对野指针指向内存的访问,无论是有意还是无意的,都为此会付出巨大代价,因为它造成的后果,如同越界访问一样是不可预料的。

 

释放内存后立即把对应指针置为空值,这是避免野指针常用的方法。这个方法简单有效,只是要注意,当然指针是从函数外层传入的时,在函数内把指针置为空值,对外层的指针没有影响。比如,你在析构函数里把 this 指针置为空值,没有任何效果,这时应该在函数外层把指针置为空值。


 

4.          访问空指针。

空指针在 C/C++ 中占有特殊的地址,通常用来判断一个指针的有效性。空指针一般定义为 0 。现代操作系统都会保留从 0 开始的一块内存,至于这块内存有多大,视不同的操作系统而定。一旦程序试图访问这块内存,系统就会触发一个异常。

 

操作系统为什么要保留一块内存,而不是仅仅保留一个字节的内存呢?原因是:一般内存管理都是按页进行管理的,无法单纯保留一个字节,至少要保留一个页面。保留一块内存也有额外的好处,可以检查诸如 p=NULL; p[1] 之类的内存错误。

 

在一些嵌入式系统 ( arm7) 中,从 0 开始的一块内存是用来安装中断向量的,没有 MMU 的保护,直接访问这块内存好像不会引发异常。不过这块内存是代码段的,不是程序中有效的变量地址,所以用空指针来判断指针的有效性仍然可行。

 

在访问指针指向的内存时,在确保指针不是空指针。访问空指针指向的内存,通常会导致程度崩溃,或者不可预料的错误。

 


5.          引用未初始化的变量。

未初始化变量的内容是随机的 ( VC 一类的编译器会把它们初始化为固定值,如 0xcc) ,使用这些数据会造成不可预料的后果,调试这样的 BUG 也是非常困难的。

 

对于态度严谨的程度员来说,防止这类 BUG 非常容易。在声明变量时就对它进行初始化,是一个编程的好习惯。另外也要重视编译器的警告信息,发现有引用未初始化的变量,立即修改过来。


6.          不清楚指针运算。

对于一些新手来说,指针常常让他们犯糊涂。

 

比如 int *p = …; p+1 等于 (size_t)p + 1

老手自然清楚,新手可能就搞不清了。事实上 , p+n 等于 (size_t)p + n * sizeof(*p)

 

指针是 C/C++ 中最有力的武器,功能非常强大,无论是变量指针还是函数指针,都应该掌握都非常熟练。只要有不确定的地方,马上写个小程序验证一下。对每一个细节都了然于胸,在编程时会省下不少时间。

 


7.          结构的成员顺序变化引发的错误。

在初始化一个结构时,老手可能很少像新手那样老老实实的,一个成员一个成员的为结构初始化,而是采用快捷方式,如:

Struct s

{

    int     l ;

    char * p ;

};

 

int main ( int argc , char * argv [])

{

    struct s s1 = {4, "abcd" };

    return 0;

}

 

以上这种方式是非常危险的,原因在于你对结构的内存布局作了假设。如果这个结构是第三方提供的,他很可能调整结构中成员的相对位置。而这样的调整往往不会在文档中说明,你自然很少去关注。如果调整的两个成员具有相同数据类型,编译时不会有任何警告,而程序的逻辑上可能相距十万八千里了。

 

正确的初始化方法应该是(当然,一个成员一个成员的初始化也行):

struct s

{

    int     l ;

    char * p ;

};

 

int main ( int argc , char * argv [])

{

    struct s s1 = {. l =4, . p = "abcd" };

    struct s s2 = { l :4, p : "abcd" };

 

    return 0;

}


8.          结构的大小变化引发的错误。

我们看看下面这个例子:

struct base

{

    int n ;

};

 

struct s

{

    struct base b ;

    int m ;

};

 

OOP 中,我们可以认为第二个结构继承了第一结构,这有什么问题吗?当然没有,这是 C 语言中实现继承的基本手法。

 

现在假设第一个结构是第三方提供的,第二个结构是你自己的。第三方提供的库是以 DLL 方式分发的, DLL 最大好处在于可以独立替换。但随着软件的进化,问题可能就来了。

 

当第三方在第一个结构中增加了一个新的成员 int k; ,编译好后把 DLL 给你,你直接给了客户了。程序加载时不会有任何问题,在运行逻辑可能完全改变!原因是两个结构的内存布局重叠了。解决这类错误的唯一办法就是全部重新相关的代码。

 

解决这类错误的唯一办法就是重新编译全部代码。由此看来, DLL 并不见得可以动态替换,如果你想了解更多相关内容,建议阅读《 COM 本质论》。

   


9.          分配 / 释放不配对。

大家都知道 malloc 要和 free 配对使用, new 要和 delete/delete[] 配对使用,重载了类 new 操作,应该同时重载类的 delete/delete[] 操作。这些都是书上反复强调过的,除非当时晕了头,一般不会犯这样的低级错误。

 

而有时候我们却被蒙在鼓里,两个代码看起来都是调用的 free 函数,实际上却调用了不同的实现。比如在 Win32 下,调试版与发布版,单线程与多线程是不同的运行时库,不同的运行时库使用的是不同的内存管理器。一不小心链接错了库,那你就麻烦了。程序可能动则崩溃,原因在于在一个内存管理器中分配的内存,在另外一个内存管理器中释放时出现了问题。


 

10.      返回指向临时变量的指针

大家都知道,栈里面的变量都是临时的。当前函数执行完成时,相关的临时变量和参数都被清除了。不能把指向这些临时变量的指针返回给调用者,这样的指针指向的数据是随机的,会给程序造成不可预料的后果。

 

下面是个错误的例子:

char * get_str( void )

{

    char str [] = { "abcd" };

 

    return str ;

}

 

int main( int argc, char * argv[])

{

    char * p = get_str();

 

    printf ( "%s\n" , p );

 

    return 0;

}

 

 

下面这个例子没有问题,大家知道为什么吗?

char * get_str ( void )

{

    char * str = { "abcd" };

 

    return str ;

}

 

int main ( int argc , char * argv [])

{

    char * p = get_str ();

 

      printf ( "%s\n" , p );

 

    return 0;

}


 

11.      试图修改常量

在函数参数前加上 const 修饰符,只是给编译器做类型检查用的,编译器禁止修改这样的变量。但这并不是强制的,你完全可以用强制类型转换绕过去,一般也不会出什么错。

 

而全局常量和字符串,用强制类型转换绕过去,运行时仍然会出错。原因在于它们是是放在 .rodata 里面的,而 .rodata 内存页面是不能修改的。试图对它们修改,会引发内存错误。

 

下面这个程序在运行时会出错:

int main ( int argc , char * argv [])

{

    char * p = "abcd" ;

 

    * p = '1' ;

 

    return 0;

}

 


 

12.      误解传值与传引用

C/C++ 中,参数默认传递方式是传值的,即在参数入栈时被拷贝一份。在函数里修改这些参数,不会影响外面的调用者。如:

 

#include <stdlib.h>

#include <stdio.h>

 

void get_str(char* p)

{

    p = malloc(sizeof("abcd"));

    strcpy(p, "abcd");

 

    return;

}

 

int main(int argc, char* argv[])

{

    char* p = NULL;

 

    get_str(p);

 

    printf("p=%p\n", p);

 

    return 0;

}

 

main 函数里, p 的值仍然是空值。


 

13.      重名符号。

无论是函数名还是变量名,如果在不同的作用范围内重名,自然没有问题。但如果两个符号的作用域有交集,如全局变量和局部变量,全局变量与全局变量之间,重名的现象一定要坚决避免。 gcc 有一些隐式规则来决定处理同名变量的方式,编译时可能没有任何警告和错误,但结果通常并非你所期望的。

 

下面例子编译时就没有警告:

t.c

#include <stdlib.h>

#include < stdio .h>

 

int count = 0;

 

int get_count ( void )

{

    return count ;

}

 

 

main.c

#include <stdio.h>

 

extern int get_count(void);

 

int count;

 

int main(int argc, char* argv[])

{

    count = 10;

 

    printf("get_count=%d\n", get_count());

 

    return 0;

}

 

如果把 main.c 中的 int count; 修改为 int count = 0; gcc 就会编辑出错,说 multiple definition of `count' 。它的隐式规则比较奇妙吧,所以还是不要依赖它为好。


 

14.      栈溢出。

我们在前面关于堆栈的一节讲过,在 PC 上,普通线程的栈空间也有十几 M ,通常够用了,定义大一点的临时变量不会有什么问题。

 

而在一些嵌入式中,线程的栈空间可能只 5K 大小,甚至小到只有 256 个字节。在这样的平台中,栈溢出是最常用的错误之一。在编程时应该清楚自己平台的限制,避免栈溢出的可能。

 


15.      误用 sizeof

尽管 C/C++ 通常是按值传递参数,而数组则是例外,在传递数组参数时,数组退化为指针(即按引用传递),用 sizeof 是无法取得数组的大小的。

 

从下面这个例子可以看出:

void test ( char str [20])

{

    printf ( "%s:size=%d\n" , __func__, sizeof ( str ));

}   

 

int main ( int argc , char * argv [])

{

    char str [20]  = {0};

 

    test ( str );

   

    printf ( "%s:size=%d\n" , __func__, sizeof ( str ));

    

    return 0;

}

[root@localhost mm]# ./t.exe

test:size=4

main:size=20


 

16.      字节对齐。

字节对齐主要目的是提高内存访问的效率。但在有的平台 ( arm7) 上,就不光是效率问题了,如果不对齐,得到的数据是错误的。

 

所幸的是,大多数情况下,编译会保证全局变量和临时变量按正确的方式对齐。内存管理器会保证动态内存按正确的方式对齐。要注意的是,在不同类型的变量之间转换时要小心,如把 char* 强制转换为 int* 时,要格外小心。

 

另外,字节对齐也会造成结构大小的变化,在程序内部用 sizeof 来取得结构的大小,这就足够了。若数据要在不同的机器间传递时,在通信协议中要规定对齐的方式,避免对齐方式不一致引发的问题。


17.      字节顺序。

字节顺序历来是设计跨平台软件时头疼的问题。字节顺序是关于数据在物理内存中的布局的问题,最常见的字节顺序有两种:大端模式与小端模式。

 

大端模式是高位字节数据存放在低地址处,低位字节数据存放在高地址处。

小端模式指低位字节数据存放在内存低地址处,高位字节数据存放在内存高地址处;

 

   比如 long n = 0x11223344

   

模式

1 个字节

2 个字节

3 个字节

4 个字节

大端模式

0x11

0x22

0x33

0x44

小端模式

0x44

0x33

0x22

0x11

 

在普通软件中,字节顺序问题并不引人注目。而在开发与网络通信和数据交换有关的软件时,字节顺序问题就要特殊注意了。

 


18.      多线程共享变量没有用 valotile 修饰。

在关于全局内存的一节中,我们讲了 valotile 的作用,它告诉编译器,不要把变量优化到寄存器中。在开发多线程并发的软件时,如果这些线程共享一些全局变量,这些全局变量最好用 valotile 修饰。这样可以避免因为编译器优化而引起的错误,这样的错误非常难查。

 

可能还有其它一些内存相关错误,一时想不全面,这里算是抛砖引玉吧,希望各位高手补充。

~~end~~




 

posted on 2006-11-13 10:14 乔栋 阅读(455) 评论(1)  编辑 收藏 引用

FeedBack:
# re: 常见内存错误
2012-11-29 09:39 | 谢谢
很全啊!  回复  更多评论
  

只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理


<2007年4月>
25262728293031
1234567
891011121314
15161718192021
22232425262728
293012345

常用链接

留言簿(6)

随笔分类

随笔档案

文章分类

文章档案

搜索

  •  

最新评论

阅读排行榜

评论排行榜


My blog is worth $0.00.
How much is your blog worth?