c/c++中常见的错误

来源:互联网 发布:竹笛知乎 编辑:程序博客网 时间:2024/05/05 02:32

c/c++中常见的错误

   软件编程的过程中,程序员所犯的并不一定是重大错误,反而一些常见的错误屡见不鲜。这些错误严重影响到编程中测试和调试的时间。这一部分总结一下,时时提醒自己,告诫自己避免这些错误。

常见的错误有:

   1、内存泄露

   在c/c++中,内存管理器不会帮助你自动回收不再使用的内存,不管在什么情况下,采取谨慎的态度,杜绝内存泄露的出现,都是上策。尽管一些工具可以帮助我们检查内存泄露问题,但是编程时还是应该仔细一点,尽早排除这类错误,工具只是用作验证的手段。

   2、内存越界访问

1)读越界,即读了不属于自己的数据,如果所读的内存地址是无效的,程度立刻就崩溃了。如果所读内存地址是有效的,虽然读的时候不会出问题,但由于读到的数据是随机的,它会产生不可预料的后果。

2)写越界,也叫缓冲区溢出,所写入的数据对别人来说是随机的,它也会产生不可预料的后果。

内存越界访问造成的后果非常严重,是程序稳定性的致命威胁之一。更麻烦的是,它造成的后果是随机的,表现出来的症状和时机也是随机的,让BUG的现象和本质看似没有什么联系,这给BUG的定位带来极大的困难。一些工具可以够帮助检查内存越界访问的问题,但也不能太依赖于工具。内存越界访问通常是动态出现的,即依赖于测试数据,在极端的情况下才会出现,除非精心设计测试数据,工具也无能为力。工具本身也有一些限制,甚至在一些大型项目中,工具变得完全不可用。比较保险的方法还是在编程是就小心,特别是对于外部传入的参数要仔细检查。

c/c++中,数组中的越界问题尤其常见。例如:

---------------------------------------------------------------------------------------

#include <stdio.h>

#include <stdlib.h>
#include <string.h>

int main(int argc, char* argv[])
{
char str[10];
int array[10] = {0,1,2,3,4,5,6,7,8,9};

int data = array[10];// read error: array[10]
array[10] = data;

if(argc == 2)
{
strcpy(str, argv[1]);// write error: argv[1]的字符串可能字符个数超过10
}

return 0;
}
----------------------------------------------------------------------------------------

   3、野指针

   野指针是指已经释放了的内存指针。当调用free(p);p本身没有变化,它指向的内存仍然是有效的。释放掉的内存会被内存管理器重新分配,此时,野指针指向的内存已经被赋予新的意义。对野指针指向内存的访问,无论是有意还是无意的,都为此会付出巨大代价,因为它造成的后果,如同越界访问一样是不可预料的。

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

   4、访问空指针

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

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

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

   5、引用未初始化的变量

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

较好的习惯是:在声明变量时就对它进行初始化。另外也要重视编译器的警告信息,发现有引用未初始化的变量,立即修改过来。

   6、指针运算

   依照指针类型进行指针运算,指针偏移值。

   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"};

return 0;
}

-------------------------------------------------------------------------------------

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

   我们看看下面这个例子:

-------------------------------------------------------------------------------------

struct base
{
int n;

};

struct s
{
struct base b;
int m;
};

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

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

当第三方在第一个结构中增加了一个新的成员int k;,编译好后把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、大小端问题(字节顺序)

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

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

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

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

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

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

   19、忘记函数的返回值

   函数需要返回值,如果你忘记return语句,它仍然会返回一个值,因为在i386上,EAX用来保存返回值,如果没有明确返回,EAX最后的内容被返回,所以EAX的内容是随机的。

第三部分编写程序和检查程序的良好习惯

   第一阶段分析设计

   充分理解需求,设计软件框架时数据流,控制流尽可能完整;形成尽可能详尽的流程图;涉及到算法类的设计时,要将涉及到的数学公式或思想深入理解,如果需要采用快速算法的,可以参考经典的算法。参考网上的几篇博客和《高质量》

   第二阶段编写代码的习惯

   按照设计文档编,遵照良好的规范书写代码(文献【1】),时时刻刻牢记上面提到的二十种常见错误,争取第一次书写时就避免这样的错误。

   程序完成后,不急着编译,而是先仔细阅读:


1、第一遍阅读:重点关注语法错误、代码排版和命名规则等等问题,只要看不顺眼就修改它们。读完之后,你的代码很少有低级错误,看起来也比较干净清爽。

   2、第二遍阅读:重点关注第二部分的常见编程错误。

   3、模拟计算机执行:常见错误是比较死的东西,按照检查列表一条一条的做就行了。有些逻辑通常不是这么直观的,这时可以自己模拟计算机去执行,假想你自己是计算机,读入这些代码时你会怎么处理。这种方法能有效的完善我们的思路,考虑不同的输入数据,各种边界值,这能帮助我们想到一些没有处理的情况,让程序的逻辑更严谨。


至于读几遍合适,要根据情况而定,通常三遍是最佳的投资。

其他几项良好习惯:
1. 一组良好的接口,实现构造和析构函数。
2. 隐藏内部细节:内部函数尽量使用static声明,最好结构声明都不要放在头文件里,除非确实需要提供给外部。
3. 良好的编程风格:排版,布局,注释,命名需要规范。
4. 尽量满足同时供c/c++调用,条件声明成 extern“c”
---------------------------------------------------------------

#if defined(__cplusplus)
extern "C" {
#endif

#if defined(__cplusplus)
}
#endif

---------------------------------------------------------------
5. 需要带有自动测试程序,Demo程序。
6. 加上#ifndef/#define/#endif:防止重复include相同的头文件
0 0
原创粉丝点击