严谨的程序员

在C语言中,内存错误是最为人诟病的。这些错误让项目延期或者被取消,引起无数的安全问题,甚至出现人命关天的灾难。抛开这些大道理不谈,它们确实浪费了咱们大量时间,这些错误引起的是随机现象,即便有一些先进工具的帮助,为了找到重现的路径,花上几天时间也不足为怪。若是可以在编写代码的时候避免这些错误,开发效率至少提升一倍以上,质量能够提升几倍了。这里列举一些常见的内存错误,供新手参考。编程

o 内存泄露小程序

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

把内存泄露列为首位,倒并非由于它有多么严重的后果,而由于它是最为常见的一类错误。一两处内存泄露一般不至于让程序崩溃,也不会出现逻辑上的错误,加上进程退出时,系统会自动释放该进程全部相关的内存(共享内存除外),因此内存泄露的后果相对来讲仍是比较温和的。可是,量变会致使质变,一旦内存泄露过多以至于耗尽内存,后续内存分配将会失败,程序可能所以而崩溃。安全

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

无论在什么状况下,采起谨慎的态度,杜绝内存泄露的出现,都是可取的。相反,认为内存有的是,对内存泄露听任自流都不是负责的。尽管一些工具能够帮助咱们检查内存泄露问题,我认为仍是应该在编程时就仔细一点,及早排除这类错误,工具只是用做验证的手段。多线程

o 内存越界访问并发

内存越界访问有两种:一种是读越界,即读了不属于本身的数据,若是所读的内存地址是无效的,程度马上就崩溃了。若是所读内存地址是有效的,在读的时候不会出问题,但因为读到的数据是随机的,它会产生不可预料的后果。另一种是写越界,又叫缓冲区溢出,所写入的数据对别人来讲是随机的,它也会产生不可预料的后果。函数

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

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

咱们来看一个例子:

#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];
array[10] = data;

if(argc == 2)
{
strcpy(str, argv[1]);
}

return 0;
}

这个例子中有两个错误是新手常犯的:

其一:int array[10] 定义了10个元素大小的数组,因为C语言中数组的索引是从0开始的,因此只能访问array[0]到array[9],访问array[10]就形成了越界错误。

其二:strcpy(str, argv[1]);这里是否存在越界错误依赖于外部输入的数据,这样的写法在正常下可能没有问题,但受到一点恶意攻击就完蛋了。除非你肯定输入数据是在你控制内的,不然不要用strcpy、strcat和sprintf之类的函数,而要用strncpy、strncat和snprintf代替。

o 野指针。

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

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

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

o 访问空指针。

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

操做系统为何要保留一块内存,而不是仅仅保留一个字节的内存呢?缘由是:通常内存管理都是按页进行管理的,没法单纯保留一个字节,至少要保留一个页面。保留一块内存也有额外的好处,能够检查诸如p=NULL; p[1]之类的内存错误。

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

o 引用未初始化的变量。

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

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

在下面这个例子中,全局变量g_count是肯定的,由于它在bss段中,自动初始化为0了。临时变量a是没有初始化的,堆内存str是没有初始化的。但这个例子有点特殊,由于程序刚运行起来,不少东西是肯定的,若是你想把它们看成随机数的种子是不行的,由于它们还不够随机。

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

int g_count;

int main(int argc, char* argv[])
{
int a;
char* str = (char*)malloc(100);

return 0;
}

o 不清楚指针运算。

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

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

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

指针是C/C++中最有力的武器,功能很是强大,不管是变量指针仍是函数指针,都应该很是熟练的掌握。只要有不肯定的地方,立刻写个小程序验证一下。对每个细节了然于胸,在编程时会省下很多时间。

o 结构的成员顺序变化引起的错误。

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

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

(有的编译器可能不支持新标准)

o 结构的大小变化引起的错误。

咱们看看下面这个例子:

struct base
{
int n;

};

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

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

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

当第三方在第一个结构中增长了一个新的成员int k;,编译好后把DLL给你,你直接把它给了客户了,让他们替换掉老版本。程序加载时不会有任何问题,在运行逻辑可能彻底改变!缘由是两个结构的内存布局重叠了。

解决这类错误的惟一办法就是从新编译所有代码。由此看来,动态库并不见得能够动态替换,若是你想了解更多相关内容,建议你阅读《COM本质论》。

o 分配/释放不配对。

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

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

o 返回指向临时变量的指针

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

下面是个错误的例子:

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

o 试图修改常量

在函数参数前加上const修饰符,只是给编译器作类型检查用的,编译器禁止修改这样的变量。但这并非强制的,你彻底能够用强制类型转换绕过去,通常也不会出什么错。

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

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

int main(int argc, char* argv[])
{
char* p = "abcd";
*p = '1';

return 0;
}

o 误解传值与传引用

在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的值仍然是空值。固然在函数里修改指针指向的内容是能够的。

o 重名符号。

不管是函数名仍是变量名,若是在不一样的做用范围内重名,天然没有问题。但若是两个符号的做用域有交集,如全局变量和局部变量,全局变量与全局变量之间,重名的现象必定要坚定避免。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’。它的隐式规则比较奇妙吧,因此仍是不要依赖它为好。

o 栈溢出。

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

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

o 误用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

o 字节对齐。

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

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

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

o 字节顺序。

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

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

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

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

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

关键字valotile的做用是告诉编译器,不要把变量优化到寄存器里。在开发多线程并发的软件时,若是这些线程共享一些全局变量,这些全局变量最好用valotile修饰。这样能够避免由于编译器优化而引发的错误,这样的错误很是难查。

o 忘记函数的返回值

函数须要返回值,若是你忘记return语句,它仍然会返回一个值,由于在i386上,EAX用来保存返回值,若是没有明确返回,EAX最后的内容被返回,因此EAX的内容是随机的。