1. 内存分配方式
1.1 内存分配的几种方式
(1) 从静态存储区域分配。
内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static变量。
初始化的全局变量和静态变量在一块区域, 未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。
(2) 在栈上创建。
在执行函数时,函数的参数值,内局部变量的存储单元都可以在栈上创建。函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
(3) 从堆上分配,亦称动态内存分配。
程序在运行的时候用malloc或new申请任意多少的内存,程序员自己负责在何时用free或delete释放内存。动态内存的生存期由程序员决定,使用非常灵活,但问题也最多。
例子程序
//main.cpp
int a = 0; //静态存储区(初始化区域)
char *p1; //静态存储区(未初始化区域)
void example()
{
int b; //栈
char s[] = "abc"; //栈
char *p2; //栈
static int c =0; //静态存储区(初始化区域)
p1 = (char *)malloc(10);
p2 = (char *)malloc(20); //分配得来的10和20字节的区域就在堆上
}
另外,在嵌入式系统中有ROM和RAM两类内存,程序被固化进ROM,变量和堆栈设在RAM中,用const定义的常量也会被放入ROM。
注:用const定义常量可以节省空间,避免不必要的内存分配。
例如:
#define PI 3.14159 //常量宏
const double g_pi = 3.14159; //此时并未将Pi放入ROM中
......
double a = g_pi; //此时为Pi分配内存,以后不再分配!
double b =PI; //编译期间进行宏替换,分配内存
double c = g_pi; //没有内存分配
double d = PI; //再进行宏替换,又一次分配内存!
const定义常量从汇编的角度来看,只是给出了对应的内存地址,而不是象#define一样给出的是立即数,所以,const定义的常量在程序运行过程中只有一份拷贝,而#define定义的常量在内存中有若干个拷贝。
1.2 几种分配方式的内存生命期
(1) 静态分配的区域的生命期是整个软件运行期,就是说从软件运行开始到软件终止退出。只有软件终止运行后,这块内存才会被系统回收。
(2) 在栈中分配的空间的生命期与这个变量所在的函数、类和Block(即由{}括起来的部分)相关。如果是函数中定义的局部变量,那么它的生命期就是函数被调用时,如果函数运行结束,那么这块内存就会被回收。如果是类中的成员变量,则它的生命期与类实例的生命期相同。如果在Block中定义的局部变量,则它的生命期仅在Block内。
(3) 在堆上分配的内存,生命期是从调用new或者malloc开始,到调用delete或者free结束。如果不调用delete或者free,则这块空间只有到软件运行结束后才会被Windows系统回收。
2. 常见的内存错误及其对策
(1) 内存分配未成功,却使用了它。
犯下这种错误主要原因是没有意识到内存分配会不成功。
常用解决办法是,在使用内存之前检查指针是否为NULL。
如果指针p是函数的参数,那么在函数的入口处用assert(p!=NULL)进行检查。
如果是用new或者malloc来申请内存,应该用if(p==NULL) 或if(p!=NULL)进行防错处理。若指针为NULL,则应立即返回相应的错误码,说明内存不足而中止调用。
int Func(void)
{
char *p = (char *) malloc(100);
if(p == NULL)
{
return ERR_NO_MEMORY;
}
…
}
(2) 内存分配虽然成功,但是尚未初始化就引用它。
犯下这种错误主要原因有两个:
一是没有初始化的观念;
二是误以为内存的缺省初值全为零,导致引用初值错误(例如数组)。
内存的缺省初值究竟是什么并没有统一的标准。但是对于全局变量和静态变量如果没有手工初始化,编译器会将其初始化为零,而对栈内存和堆内存则不作任何处理。
另外,VC在Debug和Release状态下在初始化变量时所做的操作是不同的。Debug是将每个字节位都赋值成0xcc,以有利于调试。而Release的赋值是直接从内存中分配的,内容近似于随机。所以如果在没有初始化变量的情况下去使用它的值,就会导致问题发生。
无论用何种方式创建数组,都不要忘记赋初值,可以使用memset为数组赋零值。
#define AVP_STREAM_RCV_BUFFER_NUM (5)
#define AVP_STREAM_SND_BUFFER_NUM (5)
……
AVP_StreamRcvBuffer_t g_AVP_StreamRcvBufferList[AVP_STREAM_RCV_BUFFER_NUM];
AVP_StreamSndBuffer_t g_AVP_StreamSndBufferList[AVP_STREAM_SND_BUFFER_NUM];
……
memset( g_AVP_StreamRcvBufferList, 0, sizeof( g_AVP_StreamRcvBufferList ) );
memset( g_AVP_StreamSndBufferList, 0, sizeof( g_AVP_StreamSndBufferList ) );
……
此外,任何指针变量刚被创建时不会自动成为NULL指针,它的缺省值是随机的,所以,指针变量在创建的同时应当被初始化,要么将指针设置为NULL,要么让它指向合法的内存。
例如
char *p = NULL;
int *i = (int *) malloc(100);
(3) 内存分配成功并且已经初始化,但操作越过了内存的边界,导致缓冲区溢出。
例如在使用数组时经常发生下标“多1”或者“少1”的操作。特别是在for循环语句中,循环次数很容易搞错,导致数组操作越界。
越界?越谁的界?当然是内存。一个变量存放在内存里,想读的是这个变量,结果却读过头了,很可能读到了另一个变量的头上。这就造成了越界。
访问越界会出现什么结果?
首先,它并不会造成编译错误! 就是说,C/C++的编译器并不判断和指出代码“访问越界”了。此外,数组访问越界在运行时,它的表现是不定的,有时似乎什么事也没有,程序一直运行(当然,某些错误结果已造成);有时,则是程序一下子崩溃。
请看下面的例子:
让用户输入学生编号,查询学生的考试成绩。如果代码是这样:
int mark[100];
...
//让用户输入学生编号,设现实中学生编号由1开始:
cout << "请输入学生编号(在1~100之间):"
int i;
cin >> i;
//输出对应学生的考试成绩:
cout << info[i-1];
这段代码看上去没有什么逻辑错误。可是,某些用户会造成它出错。如果用户不输入1到100之间的数字,而是输入105,甚至是-1。这样程序就会去尝试输出:mark[104] 或 mark[-2],导致数组操作越界。
对于这类问题的解决办法就是,我们需要在输出时,做一个判断,发现用户输入了不在编号范围之内的数,则不输出或者提示用户重新输入合法值。这样就会避免错误出现。
以上是数组读操作的越界,同样地,在对一块缓冲区进行写操作时,如果向缓冲区内填充的数据位数超过了缓冲区本身的容量,便会发生缓冲区溢出。
当一个超长的数据进入到缓冲区时,超出部分就会被写入其他缓冲区,其他缓冲区存放的可能是数据、下一条指令的指针,或者是其他程序的输出内容,这些内容都被覆盖或者破坏掉。可见一小部分数据或者一套指令的溢出就可能导致一个程序或者操作系统崩溃。
请看下面的代码:
void DoSomething (char *cBuffSrc, DWORD dwBuffSrcLen)
{
char cBuffDest[32] ;
memcpy (cBuffDest, cBuffSrc, dwBuffSrcLen) ;
}
上面的函数在参数dwBuffSrcLen的实际值小于等于cBuffDest的长度时不会出现问题,但是如果dwBuffSrcLen的值大于cBuffDest的长度,当memcpy 将数据复制到 cBuffDest 中时,来自 DoSomething 的返回地址就会被更改,因为 cBuffDest 在函数的堆栈框架上与返回地址相邻。
如果将函数进行适当的修改,使 memcpy 的调用具有防御性,它将不会复制多于目标缓冲区存放能力的数据了。
void DoSomething (char *cBuffSrc, DWORD dwBuffSrcLen)
{
const DWORD dwBuffDestLen = 32 ;
char cBuffDest[dwBuffDestLen] ;
memcpy (cBuffDest, cBuffSrc, min(dwBuffDestLen, dwBuffSrcLen)) ;
}
(4) 分支处理不完整,错误处理不当,导致忘记释放内存,造成内存泄露。
我们常说的内存泄漏一般是指堆内存的泄漏。应用程序一般使用malloc,new等函数从堆中分配到一块内存,使用完后,程序必须负责相应的调用free或delete释放该内存块,否则,这块内存就不能被再次使用,我们就说这块内存泄漏了。
以下这段小程序演示了堆内存发生泄漏的情形:
void MyFunction(int nSize)
{
char* p= new char[nSize];
if( !GetStringFrom( p, nSize ) ){
MessageBox(“Error”);
return;
}
…//using the string pointed by p;
delete p;
}
当函数GetStringFrom()返回零的时候,指针p指向的内存就不会被释放。这是一种常见的发生内存泄漏的情形。程序在入口处分配内存,在出口处释放内存,但是C函数可以在任何地方退出,所以一旦对分支处理不完整或者错误处理不当的话,就会发生内存泄漏。虽然函数体内的局部变量在函数结束时自动消亡,但是局部的指针变量所指向的内存并不会被自动释放。
含有这种错误的函数每被调用一次就丢失一块内存。刚开始时系统的内存充足,可能看不到错误,但终有一次程序突然死掉,系统出现提示:内存耗尽。
动态内存的申请与释放必须配对,如果程序在入口处动态申请了内存,那么在程序的每个出口处都必须释放该内存空间。
(5) 释放了内存却继续使用它。
有三种情况:
(a) 程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。
(b) 函数的return语句写错了,注意不要返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。
(c) 使用free释放了内存后,没有将指针设置为NULL。导致产生“野指针”,即不是NULL指针,而是指向“垃圾”内存的指针。“野指针”是很危险的,因为使用if语句进行判断对它不起作用。
char *p = (char *) malloc(100);
strcpy(p, “hello”);
free(p); // p所指的内存被释放,但是p所指的地址仍然不变
…
if(p != NULL) // 没有起到防错作用
{
strcpy(p, “world”); // 出错
}
同样地,指针操作超越了变量的作用范围也造成“野指针”,示例程序如下:
class A
{
public:
void Func(void){ cout << “Func of class A” << endl; }
};
void Test(void)
{
A *p;
{
A a;
p = &a; // a 的生命期仅在Block内
}
p->Func(); // p是“野指针”
}
函数Test在执行语句p->Func()时,对象a已经消失,而p是指向a的,所以p就成了“野指针”。由于a所占据的内存并没有被覆盖,所以暂时不会出现问题。但是当堆栈发生变化后,如调用函数或者定义了新的局部变量,则将发生内存错误。
3. 指针与数组的对比
C/C++程序中,指针和数组在不少地方可以相互替换着用,让人产生一种错觉,以为两者是等价的。
数组要么在静态存储区被创建(如全局数组),要么在栈上被创建。数组名对应着(而不是指向)一块内存,其地址与容量在生命期内保持不变,只有数组的内容可以改变。
指针可以随时指向任意类型的内存块,它的特征是“可变”,所以我们常用指针来操作动态内存。指针远比数组灵活,但也更危险。
下面以字符串为例比较指针与数组的特性。
3.1 修改内容
//数组
char a[] = “hello”;
a[0] = ‘X’;
// 指针
char *p = “world”; // 注意p指向常量字符串
p[0] = ‘X’; // 编译器不能发现该错误
字符数组a的容量是6个字符,其内容为hello"0。a的内容可以改变,如a[0]= ‘X’。指针p指向常量字符串“world”(位于静态存储区,内容为world"0),常量字符串的内容是不可以被修改的。从语法上看,编译器并不觉得语句p[0]= ‘X’有什么不妥,但是该语句企图修改常量字符串的内容而导致运行错误。
3.2 内容复制与比较
不能对数组名进行直接复制与比较。
// 数组…
char a[] = "hello";
char b[10];
strcpy(b, a); // 不能用b = a;
if(strcmp(b, a) == 0) // 不能用if (b == a)
// 指针…
int len = strlen(a);
char *p = (char *)malloc(sizeof(char)*(len+1));
strcpy(p,a); // 不要用 p = a;
if(strcmp(p, a) == 0) // 不要用 if (p == a)
若想把数组a的内容复制给数组b,不能用语句 b = a ,否则将产生编译错误。应该用标准库函数strcpy进行复制。同理,比较b和a的内容是否相同,不能用if(b==a) 来判断,应该用标准库函数strcmp进行比较。
语句p = a 并不能把a的内容复制指针p,而是把a的地址赋给了p。要想复制a的内容,可以先用库函数malloc为p申请一块容量为strlen(a)+1个字符的内存,再用strcpy进行字符串复制。同理,语句if(p==a) 比较的不是内容而是地址,应该用库函数strcmp来比较。
3.3 计算内存容量
用运算符sizeof可以计算出数组的容量(字节数)。
char a[] = "hello world";
char *p = a;
sizeof(a) = ? (12字节, 注意别忘了’"0’)
sizeof(p) = ? (4字节)
sizeof(p)得到的是一个指针变量的字节数,相当于sizeof(char*),而不是p所指的内存容量。
注意:当数组作为函数的参数进行传递时,该数组自动退化为同类型的指针。
void Func(char a[100])
{
…
}
sizeof(a) = ? (4字节而不是100字节)
不论数组a的容量是多少,sizeof(a)始终等于sizeof(char *)。
4. 实例解析
² 不要用函数的指针参数去申请动态内存
void GetMemory(char *p, int num)
{
p = (char *)malloc(sizeof(char) * num);
}
void Test(void)
{
char *str = NULL;
GetMemory(str, 100); // str 仍然为 NULL
strcpy(str, "hello"); // 运行错误
}
解析:Test函数的语句GetMemory(str, 200)并没有使str获得期望的内存,str依旧是NULL。问题出在函数GetMemory中。编译器总是要为函数的每个参数制作临时副本,指针参数p的副本是 _p,编译器使 _p = p。如果函数体内的程序修改了_p的内容,就导致参数p的内容作相应的修改。这就是指针可以用作输出参数的原因。在本例中,_p申请了新的内存,只是把_p所指的内存地址改变了,但是p丝毫未变。所以函数GetMemory并不能输出任何东西。事实上,每执行一次GetMemory就会泄露一块内存,因为没有用free释放内存。
如果非得要用指针参数去申请内存,那么应该改用“指向指针的指针”。
void GetMemory2(char **p, int num)
{
*p = (char *)malloc(sizeof(char) * num);
}
void Test2(void)
{
char *str = NULL;
GetMemory2(&str, 100); // 注意参数是 &str,而不是str
strcpy(str, "hello");
printf(%s, str);
free(str);
}
² 不要返回临时变量的指针和引用
void loop();
void addr();
int main ()
{
addr();
loop();
}
long *p ;
void loop()
{
long i, j ;
j = 0;
for ( i = 0 ; i < 10 ; i++ ){
(*p)--;
j++;
}
}
void addr()
{
long k;
k = 0;
p = &k;
}
解析:这里的问题出现在保存临时变量的地址上。由于addr函数中的变量k在函数返回后就已经不存在了,但是在全局变量p中却保存了它的地址。在下一个函数loop中,试图通过全局指针p访问一个不存在的变量,而这个指针实际指向的却是另一个临时变量i,这就导致了死循环的发生。
看一下这个程序中局部变量的地址分配。addr()中的局部变量k,loop()中的局部变量i、j,它们的地址分配可以如下图所示:
j |
k/i |
p------à
可以理解为i和k占用同一个内存单元(因为他们都是局部变量,不可能同时出现在执行语句中而导致冲突)。在addr()函数中,系统为变量k安排了地址,并将指针p指向k所在的单元,当从addr()函数返回的时候,系统收回了分配给k的地址(这些实际上就是在栈里进行的)。在进入loop()函数以后,就一次为局部变量i,j分配地址,因为i的类型和k相同,所以它占用的空间大小和k相同,系统按序分配地址,很显然分配给i的地址就是在addr()中分配给k的地址。因为指针p是一个全局变量,它的值(此时即i所在的单元地址)未变,所以现在p所指的是现在的i所在的地址,故(*p)--实际上成了i--,所以i一直在-1和0之间变化,程序陷入死循环。
² 数组访问越界
int main ()
{
int i;
int a[10];
for(i=0; i<=10; ++i)
a[i] = 0;
return 0;
}
解析:在main中,i和数组a是采用静态存储分配策略的。它们所占的空间大小在编译时是确定的。但是从高地址开始分配空间的。如下所示:
a[0] |
a[1] |
a[2] |
a[3] |
a[4] |
a[5] |
a[6] |
a[7] |
a[8] |
a[9] |
i |
低地址
高地址
数组实际上就是一块内存,a就是数组的首地址,[]中的值是偏移值,所以a[10]实际上就是i,a[i] = 0就是i=0,导致死循环。
int i;
int a[10] ;
只要将上面两句交换一下位置,在这个程序中就不会死循环了。
当然,访问a[10]本来就是一个错误,数组越界,后果不堪设想,由于C对数组没有越界检查,所以编译没问题。
² 将一个数组赋值为等差数列,并将会在函数的外部使用它
int *GetArray( int n )
{
int *p = new int[n];
for ( int i = 0; i < n; i++ )
{ p[i] = i; }
return p;
}
解析:检查内存泄露的最好办法,就是检查完全配对的申请和释放,在函数中申请而在外部释放,将导致代码的一致性变差,难以维护。而且,一个人写的函数不一定是他自己使用的,这样的函数别人会不知道该怎么适当的使用。因此最好的解决办法就是在函数调用的外面将内存申请好,函数只对数据进行复制。
void GetArray( int *p, int n )
{
for ( int i = 0; i < n; i++ )
{ p[i] = i; }
}
² 写一个类封装对指针的申请内存、释放和其它一些基本操作
class A
{
public:
A( void ) {}
~A( void ) { delete []m_pPtr; }
void Create( int n ){ m_pPtr = new int[n]; }
private:
int *m_pPtr;
};
解析:不合理的代码就在于当重复调用Create的时候就会造成内存泄露,解决的办法就是在new之前判断一下指针是否为0。要能够有效的执行这个判断,则必须在构造的时候对指针进行初始化,并为这个类添加一个Clear函数来释放内存。
如果是C程序,可以使用自己的函数来封装malloc和free,虽然这样不能避免内存泄漏,但是至少可以使内存泄漏变得容易检查。
class A
{
public:
A( void ) : m_pPtr(0){}
~A( void ) { Clear(); }
bool Create( int n ){
if ( m_pPtr ) return false;
m_pPtr = new int[n];
return ture;
}
void Clear( void ) { delete []m_pPtr; m_pPtr = 0; }
private:
int *m_pPtr;
};
5. 小结
内存编程的几点规则:
规则1-用malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。
规则2-不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。
规则3-避免数组或指针的下标越界,特别要当心发生“多1”或者“少1”操作。
规则4-动态内存的申请与释放必须配对,防止内存泄漏。
规则5-用free或delete释放了内存之后,立即将指针设置为NULL,防止产生“野指针”。