首页 > 代码库 > C语言内存使用的常见问题及解决之道

C语言内存使用的常见问题及解决之道

 

一  前言

    本文所讨论的“内存”主要指(静态)数据区、堆区和栈区空间(详细的布局和描述参考《Linux虚拟地址空间布局》一文)。数据区内存在程序编译时分配,该内存的生存期为程序的整个运行期间,如全局变量和static关键字所声明的静态变量。函数执行时在栈上开辟局部自动变量的储存空间,执行结束时自动释放栈区内存。堆区内存亦称动态内存,由程序在运行时调用malloc/calloc/realloc等库函数申请,并由使用者显式地调用free库函数释放。堆内存比栈内存分配容量更大,生存期由使用者决定,故非常灵活。然而,堆内存使用时很容易出现内存泄露、内存越界和重复释放等严重问题。

    本文将详细讨论三种内存使用时常见的问题及其对策,并对各种内存问题给出简单的示例代码。示例代码的运行环境如下:

 

 

二  内存问题

2.1 数据区内存

2.1.1 内存越界

     内存越界访问分为读越界和写越界。读越界表示读取不属于自己的数据,如读取的字节数多于分配给目标变量的字节数。若所读的内存地址无效,则程序立即崩溃;若所读的内存地址有效,则可读到随机的数据,导致不可预料的后果。写越界亦称“缓冲区溢出”,所写入的数据对目标地址而言也是随机的,因此同样导致不可预料的后果。

     内存越界访问会严重影响程序的稳定性,其危险在于后果和症状的随机性。这种随机性使得故障现象和本源看似无关,给排障带来极大的困难。

     数据区内存越界主要指读写某一数据区内存(如全局或静态变量、数组或结构体等)时,超出该内存区域的合法范围。

     写越界的主要原因有两种:1) memset/memcpy/memmove等内存覆写调用;2) 数组下标超出范围。

 1 #define NAME_SIZE  5 2 #define NAME_LEN   NAME_SIZE-1/*Terminator*/ 3 char gszName[NAME_SIZE] = "Mike"; 4 char *pszName = "Jason"; 5 int main(void) 6 { 7     memset(gszName, 0, NAME_SIZE+1); //越界1 8     gszName[NAME_SIZE] = 0;          //越界2 9       10     if(strlen(pszName) <= NAME_SIZE)  //越界3(注意‘=‘号)11         strcpy(gszName, pszName);12   13     int dwSrcLen = strlen(pszName);14     if(dwSrcLen < NAME_SIZE)15         memcpy(gszName, pszName, dwSrcLen); //未拷贝结束符(‘\0‘)16 17     return 0;18 }

     使用数组时,经常发生下标“多1”或“少1”的操作,特别是当下标用于for循环条件表达式时。此外,当数组下标由函数参数传入或经过复杂运算时,更易发生越界。

 1 void ModifyNameChar(unsigned char ucCharIdx, char cModChar) 2 { 3     gszName[ucCharIdx] = cModChar;  //写越界 4 } 5 int main(void) 6 { 7     ModifyNameChar(5, L); 8     unsigned char ucIdx = 0; 9     for(; ucIdx <= NAME_SIZE; ucIdx++)  //‘=‘号导致读越界10         printf("NameChar = %c\n", gszName[ucIdx]);11     12     return 0;13 }

     对于重要的全局数据,可将其植入结构体内并添加CHK_HEAD和CHK_TAIL进行越界保护和检查:

 1 #define CODE_SIZE       4  //越界保护码的字节数 2 #if (1 == CODE_SIZE) 3     #define CODE_TYPE   char 4     #define CHK_CODE    0xCC       //除0外的特殊值 5 #elif (2 == CODE_SIZE) 6     #define CODE_TYPE   short 7     #define CHK_CODE    0xCDDC     //除0外的特殊值 8 #else 9     #define CODE_TYPE   int10     #define CHK_CODE    0xABCDDCBA //除0外的特殊值11 #endif12 #define CHK_HEAD    CODE_TYPE ChkHead;13 #define CHK_TAIL    CODE_TYPE ChkTail;14 #define INIT_CHECK(ptChkMem) do{ 15     (ptChkMem)->ChkHead = CHK_CODE; 16     (ptChkMem)->ChkTail = CHK_CODE; 17 }while(0)18 #define CHK_OVERRUN(ptChkMem) do{ 19     if((ptChkMem)->ChkHead != CHK_CODE || (ptChkMem)->ChkTail != CHK_CODE) { 20         printf("[%s(%d)<%s>]Memory Overrun(ChkHead:0x%X,ChkTail:0x%X)!\n", __FILE__, __LINE__, FUNC_NAME, 21         (ptChkMem)->ChkHead, (ptChkMem)->ChkTail); 22     } 23 }while(0)24 typedef struct{25     CHK_HEAD;  26     char szName[NAME_SIZE];27     CHK_TAIL;  28 }T_CHK_MEM;29 T_CHK_MEM gtChkMem;30 int main(void)31 {32     memset(&gtChkMem, 0, sizeof(T_CHK_MEM));33     INIT_CHECK(&gtChkMem);34     35     memset(&gtChkMem, 11, 6);36     CHK_OVERRUN(&gtChkMem);37     strcpy(gtChkMem.szName, "Elizabeth");38     CHK_OVERRUN(&gtChkMem);39 40     return 0;41 }

     执行结果如下,可见被检查的szName数组其头尾地址均发生越界:

1 [test.c(177)<main>]Memory Overrun(dwChkHead:0xB0B0B0B,dwChkTail:0xABCDDCBA)!2 [test.c(179)<main>]Memory Overrun(dwChkHead:0xB0B0B0B,dwChkTail:0xABCD0068)!

     若模块提供有全局数据的访问函数,则可将越界检查置于访问函数内:

 1 #ifdef CHK_GLOBAL_OVERRUN 2     #define CODE_SIZE       4  //越界保护码的字节数 3     #if (1 == CODE_SIZE) 4         #define CODE_TYPE   char 5         #define CHK_CODE    (CODE_TYPE)0xCC       //除0外的特殊值 6     #elif (2 == CODE_SIZE) 7         #define CODE_TYPE   short 8         #define CHK_CODE    (CODE_TYPE)0xCDDC     //除0外的特殊值 9     #else10         #define CODE_TYPE   int11         #define CHK_CODE    (CODE_TYPE)0xABCDDCBA //除0外的特殊值12     #endif13     #define CHK_HEAD        CODE_TYPE ChkHead14     #define CHK_TAIL        CODE_TYPE ChkTail15     #define HEAD_VAL(pvGlblAddr)             (*(CODE_TYPE*)(pvGlblAddr))16     #define TAIL_VAL(pvGlblAddr, dwGlbSize)  (*(CODE_TYPE*)((char*)pvGlblAddr+dwGlbSize-sizeof(CODE_TYPE)))17 18     #define INIT_CHECK(pvGlblAddr, dwGlbSize) do{19         HEAD_VAL(pvGlblAddr) = TAIL_VAL(pvGlblAddr, dwGlbSize) = CHK_CODE;}while(0)20     #define CHK_OVERRUN(pvGlblAddr, dwGlbSize, pFileName, dwCodeLine) do{21         if((HEAD_VAL(pvGlblAddr) != CHK_CODE) || (TAIL_VAL(pvGlblAddr, dwGlbSize) != CHK_CODE)) {22             printf("[%s(%d)]Memory Overrun(ChkHead:0x%X,ChkTail:0x%X)!\n", pFileName, dwCodeLine, 23             HEAD_VAL(pvGlblAddr), TAIL_VAL(pvGlblAddr, dwGlbSize)); 24         }}while(0)25 26     #define INIT_GLOBAL(pvGlblAddr, dwInitVal, dwGlbSize) 27             InitGlobal(pvGlblAddr, dwInitVal, dwGlbSize, __FILE__, __LINE__)28     #define SET_GLOBAL(pvGlblAddr, pvGlblVal, dwGlbSize) 29             SetGlobal(pvGlblAddr, pvGlblVal, dwGlbSize, __FILE__, __LINE__)30     #define GET_GLOBAL(pvGlblAddr, pvGlblVal, dwGlbSize) 31             GetGlobal(pvGlblAddr, pvGlblVal, dwGlbSize, __FILE__, __LINE__)32 #else33     #define CHK_CODE    034     #define CHK_HEAD35     #define CHK_TAIL36     #define HEAD_VAL(pvGlblAddr)               037     #define TAIL_VAL(pvGlblAddr, dwGlbSize)    038     #define INIT_CHECK(pvGlblAddr, dwGlbSize)39     #define CHK_OVERRUN(pvGlblAddr, dwGlbSize, pFileName, dwCodeLine)40 41     #define INIT_GLOBAL(pvGlblAddr, dwInitVal, dwGlbSize) do{42             memset(pvGlblAddr, dwInitVal, dwGlbSize);}while(0)43     #define SET_GLOBAL(pvGlblAddr, pvGlblVal, dwGlbSize) do{44             memcpy(pvGlblAddr, pvGlblVal, dwGlbSize);}while(0)45     #define GET_GLOBAL(pvGlblAddr, pvGlblVal, dwGlbSize) do{46             memcpy(pvGlblVal, pvGlblAddr, dwGlbSize);}while(0)47 #endif48 49 void InitGlobal(void* pvGlblAddr, int dwInitVal, unsigned int dwGlbSize,50                 const char* pFileName, INT32U dwCodeLine)51 {52     if(NULL == pvGlblAddr)53     {54         printf("[%s(%d)]Null Pointer!\n", pFileName, dwCodeLine);55         return;56     }57 58     memset(pvGlblAddr, dwInitVal, dwGlbSize);59     INIT_CHECK(pvGlblAddr, dwGlbSize);60 }61 void SetGlobal(void* pvGlblAddr, void* pvGlblVal, unsigned int dwGlbSize,62                const char* pFileName, INT32U dwCodeLine)63 {64     if((NULL == pvGlblAddr) || (NULL == pvGlblVal))65     {66         printf("[%s(%d)]Null Pointer: (%p), (%p)!\n", pFileName, dwCodeLine, pvGlblAddr, pvGlblVal);67         return;68     }69 70     memcpy(pvGlblAddr, pvGlblVal, dwGlbSize);71     CHK_OVERRUN(pvGlblAddr, dwGlbSize, pFileName, dwCodeLine);72 }73 void GetGlobal(void* pvGlblAddr, void* pvGlblVal, unsigned int dwGlbSize,74                const char* pFileName, INT32U dwCodeLine)75 {76     if((NULL == pvGlblAddr) || (NULL == pvGlblVal))77     {78         printf("[%s(%d)]Null Pointer: (%p), (%p)!\n", pFileName, dwCodeLine, pvGlblAddr, pvGlblVal);79         return;80     }81 82     memcpy(pvGlblVal, pvGlblAddr, dwGlbSize);83     CHK_OVERRUN(pvGlblAddr, dwGlbSize, pFileName, dwCodeLine);84 }85 86 int main(void)87 {88     INIT_GLOBAL(&gtChkMem, 0, sizeof(T_CHK_MEM));89     printf("[%d]ChkHead:0x%X,ChkTail:0x%X!\n", __LINE__, HEAD_VAL(&gtChkMem), TAIL_VAL(&gtChkMem, sizeof(T_CHK_MEM)));    90     T_CHK_MEM tChkMem;91     GET_GLOBAL(&gtChkMem, &tChkMem, sizeof(T_CHK_MEM));92 93     strcpy(tChkMem.szName, "Elizabeth");94     SET_GLOBAL(&gtChkMem, &tChkMem, sizeof(T_CHK_MEM));95 96     return 0;97 }

     其中,TAIL_VAL宏假定系统为1字节对齐(否则请置CODE_SIZE为4字节)。因0xCC默认为四字节(对应于0xFFFFFFCC),故需用(CODE_TYPE)0xCC做类型转换,否则CHK_OVERRUN宏内if判断恒为真。

     该检查机制的缺点是仅用于检测写越界,且拷贝和解引用次数增多,访问效率有所降低。读越界后果通常并不严重,除非试图读取不可访问的区域,否则难以也不必检测。

     数据区内存越界通常会导致相邻的全局变量被意外改写。因此若已确定被越界改写的全局变量,则可通过工具查看符号表,根据地址顺序找到前面(通常向高地址越界)相邻的全局数据,然后在代码中排查访问该数据的地方,看看有哪些位置可能存在越界操作。

     有时,全局数据被意外改写并非内存越界导致,而是某指针(通常为野指针)意外地指向该数据地址,导致其内容被改写。野指针导致的内存改写往往后果严重且难以定位。此时,可编码检测全局数据发生变化的时机。若能结合堆栈回溯(Call Backtrace),则通常能很快地定位问题所在。

     修改只读数据区内容会引发段错误(Segmentation Fault),但这种低级失误并不常见。一种比较隐秘的缺陷是函数内试图修改由指针参数传入的只读字符串,详见《关于Linux系统basename函数缺陷的思考》一文。

     因其作用域限制,静态局部变量的内存越界相比全局变量越界更易发现和排查。

    【对策】某些工具可帮助检查内存越界的问题,但并非万能。内存越界通常依赖于测试环境和测试数据,甚至在极端情况下才会出现,除非精心设计测试数据,否则工具也无能为力。此外,工具本身也有限制,甚至在某些大型项目中,工具变得完全不可用。

     与使用工具类似的是自行添加越界检测代码,如本节上文所示。但为求安全性而封装检测机制的做法在某种意义上得不偿失,既不及Java等高级语言的优雅,又损失了C语言的简洁和高效。因此,根本的解决之道还是在于设计和编码的审慎周密。相比事后检测,更应注重事前预防。

     编程时应重点走查代码中所有操作全局数据的地方,杜绝可能导致越界的操作,尤其注意内存覆写和拷贝函数memset/memcpy/memmove和数组下标访问。

     在内存拷贝时,必须确保目的空间大于或等于源空间。也可封装库函数使之具备安全校验功能,如:

 1 /****************************************************************************** 2 * 函数名称:  StrCopy 3 * 功能说明:  带长度安全拷贝字符串 4 * 输入参数:  dwSrcLen : 目的字符串缓冲区长度 5             pSrcStr  : 源字符串 6             dwSrcLen : 源字符串长度(含终止符‘\0‘) 7 * 输出参数:  pDstStr  : 目的字符串缓冲区 8 * 返回值  :  成功: ptDest; 失败: "Nil" 9 * 用法示例:  char *pSrcStr = "HelloWorld"; char szDstStr[20] = {0};10             StrCopy(szDstStr, sizeof(szDstStr), pSrcStr, strlen(pSrcStr))+1);11 * 注意事项:  拷贝长度为min(dwDstLen, dwSrcLen) - 1{Terminator}12 ******************************************************************************/13 char *StrCopy(char *pDstStr, int dwDstLen, char *pSrcStr, int dwSrcLen)14 {15     if(((NULL == pDstStr) || (NULL == pSrcStr)) ||16        ((0 == dwDstLen) || (0 == dwSrcLen)))17         return (char *)"Nil";18     19     int dwActLen = (dwDstLen <= dwSrcLen) ? dwDstLen : dwSrcLen;20     pDstStr[dwActLen - 1] = \0;21     22     return strncpy(pDstStr, pSrcStr, dwActLen - 1);23 }

     在使用memcpy和strcpy拷贝字符串时应注意是否包括结束符(memcpy不自动拷贝’\0’)。

     按照下标访问数组元素前,可进行下标合法性校验:

1 /* 数组下标合法性校验宏 */2 #define CHECK_ARRAY_INDEX(index, maxIndex) do{3     if(index > maxIndex) { 4      printf("Too large "#index": %d(Max: %d)!!!\n\r", index, maxIndex); 5      index = maxIndex; 6     } 7 }while(0)

2.1.2 多重定义

     函数和定义时已初始化的全局变量是强符号;未初始化的全局变量是弱符号。多重定义的符号只允许最多一个强符号。Unix链接器使用以下规则来处理多重定义的符号:

     规则一:不允许有多个强符号。在被多个源文件包含的头文件内定义的全局变量会被定义多次(预处理阶段会将头文件内容展开在源文件中),若在定义时显式地赋值(初始化),则会违反此规则。

     规则二:若存在一个强符号和多个弱符号,则选择强符号。

     规则三:若存在多个弱符号,则从这些弱符号中任选一个。

     当不同文件内定义同名(即便类型和含义不同)的全局变量时,该变量共享同一块内存(地址相同)。若变量定义时均初始化,则会产生重定义(multiple definition)的链接错误;若某处变量定义时未初始化,则无链接错误,仅在因类型不同而大小不同时可能产生符号大小变化(size of symbol `XXX‘ changed)的编译警告。在最坏情况下,编译链接正常,但不同文件对同名全局变量读写时相互影响,引发非常诡异的问题。这种风险在使用无法接触源码的第三方库时尤为突出。

     下面的例子编译链接时没有任何警告和错误,但结果并非所愿:

 1 //test.c 2 int gdwCount = 0; 3 int GetCount(void) 4 { 5     return gdwCount; 6 } 7  8  9 //main.c10 extern int GetCount(void);11 int gdwCount;12 int main(void)13 {14     gdwCount = 10;15     printf("GetCount=%d\n", GetCount());16 return 0;17 }

     编码者期望函数GetCount的返回值打印出来是0,但其实是10。若将main.c中的int gdwCount语句改为int gdwCount = 0,编译链接时就会报告multiple definition of ‘gdwCount‘的错误。因此尽量不要依赖和假设这种符号规则。

     关于全局符号多重定义的讨论,详见《C语言头文件组织与包含原则》一文。

    【对策】尽量避免使用全局变量。若确有必要,应采用静态全局变量(无强弱之分,且不会和其他全局符号产生冲突),并封装访问函数供外部文件调用。

2.1.3 volatile修饰

     关键字volatile用于修饰易变的变量,告诉编译器该变量值可能会在任意时刻被意外地改变,因此不要试图对其进行任何优化。每次访问(读写)volatile所修饰的变量时,都必须从该变量的内存区域中重新读取,而不要使用寄存器(CPU)中保存的值。这样可保证数据的一致性,防止由于变量优化而出错。

     以下几种情况通常需要volatile关键字:

  • 外围并行设备的硬件寄存器(如状态寄存器);
  • 中断服务程序(ISR)中所访问的非自动变量(Non-automatic Variable),即全局变量;
  • 多线程并发环境中被多个线程所共享的全局变量。

     变量可同时由const和volatile修饰(如只读的状态寄存器),表明它可能被意想不到地改变,但程序不应试图修改它。指针可由volatile修饰(尽管并不常见),如中断服务子程序修改一个指向某buffer的指针时。又如:

1 //只读端口(I/O与内存共享地址空间,非IA架构)2 const volatile char *port = (const volatile char *)0x01F7

     误用volatile关键字可能带来意想不到的错误,例如:

1 int CalcSquare(volatile int *pVal)2 {3     return (*pVal) * (*pVal);4 } //deficient

     函数CalcSquare返回指针pVal所指向值的平方,但由于该值被volatile修饰,编译器将产生类似下面的代码:

1 int CalcSquare(volatile int *pVal)2 {3     int dwTemp1, dwTemp2;4     dwTemp1 = *pVal;5     dwTemp2 = *pVal;6     return dwTemp1 * dwTemp2;7 }//deficient

     多线程环境下,指针pVal所指向值在函数CalcSquare执行时可能被意想不到地该变,因此dwTemp1和dwTemp2的取值可能不同,最终未必返回期望的平方值。

     正确的代码如下(使用全局变量的拷贝也是提高线程安全性的一种方法):

1 long CalcSquare(volatile int *pVal)2 {3     int dwTemp;4     dwTemp = *pVal;5     return dwTemp * dwTemp;6 }//deficient

     再举一例:

1 #define READ(val, addr)  (val = *(unsigned long *)addr)

     编译器优化这段代码时,若addr地址的数据读取太频繁,优化器会将该地址上的值存入寄存器中,后续对该地址的访问就转变为直接从寄存器中读取数据,如此将大大加快数据读取速度。但在并发操作时,一个进程读取数据,另一进程修改数据,这种优化就会造成数据不一致。此时,必须使用volatile修饰符。

    【对策】合理使用volatile修饰符。

 

2.2 栈区内存

2.2.1 内存未初始化

     未初始化的栈区变量其内容为随机值。直接使用这些变量会导致不可预料的后果,且难以排查。

     指针未初始化(野指针)或未有效初始化(如空指针)时非常危险,尤以野指针为甚。

    【对策】在定义变量时就对其进行初始化。某些编译器会对未初始化发出警告信息,便于定位和修改。

2.2.2 堆栈溢出

     每个线程堆栈空间有限,稍不注意就会引起堆栈溢出错误。注意,此处“堆栈”实指栈区。

1 #define MAX_SIZE  3200000  //系统不同该值不同(ulimit –s: 10240kbytes)2 int main(void){3     int aStackCrasher[MAX_SIZE] = {0};  //可能导致Segmentation fault4     aStackCrasher[0] = 1;5     return 0;6 }

     堆栈溢出主要有两大原因:1) 过大的自动变量;2) 递归或嵌套调用层数过深。

     有时,函数自身并未定义过大的自动变量,但其调用的系统库函数或第三方接口内使用了较大的堆栈空间(如printf调用就要使用2k字节的栈空间)。此时也会导致堆栈溢出,并且不易排查。

     此外,直接使用接口模块定义的数据结构或表征数据长度的宏时也存在堆栈溢出的风险,如:

1 typedef struct{2     unsigned short wVid;3     unsigned char aMacAddr[6];4     unsigned char ucMacType;5 }T_MAC_ADDR_ENTRY;6 typedef struct{7     unsigned int dwTotalAddrNum;8     T_MAC_ADDR_ENTRY tMacAddrEntry[MAX_MACTABLE_SIZE];9 }T_MAC_ADDR_TABLE;

     上层模块在自行定义的T_MAC_ADDR_TABLE结构中,使用底层接口定义的MAX_MACTABLE_SIZE宏指定MAC地址表最大条目数。接口内可能会将该宏定义为较大的值(如8000个条目),上层若直接在栈区使用TABLE结构则可能引发堆栈溢出。

     在多线程环境下,所有线程栈共享同一虚拟地址空间。若应用程序创建过多线程,可能导致线程栈的累计大小超过可用的虚拟地址空间。在用pthread_create反复创建一个线程(每次正常退出)时,可能最终因内存不足而创建失败。此时,可在主线程创建新线程时指定其属性为PTHREAD_CREATE_DETACHED,或创建后调用pthread_join,或在新线程内调用pthread_detach,以便新线程函数返回退出或pthread_exit时释放线程所占用的堆栈资源和线程描述符。

    【对策】应该清楚所用平台的资源限制,充分考虑函数自身及其调用所占用的栈空间。对于过大的自动变量,可用全局变量、静态变量或堆内存代替。此外,嵌套调用最好不要超过三层。

2.2.3 内存越界

     因其作用域和生存期限制,发生在栈区的内存越界相比数据区更易发现和排查。

     下面的例子存在内存越界,并可能导致段错误:

1 int bIsUniCommBlv = 1;2 int main(void)3 {4     char szWanName[] = "OAM_WAN_VOIP";5     if(bIsUniCommBlv)6         strcpy(szWanName, "OAM_WAN_MNGIP");7 8     return 0;9 }

     但该例的另一写法则更为糟糕:

 1 int bIsUniCommBlv = 1; 2 int main(void) 3 { 4     char szWanName[] = ""; //字符数组szWanName仅能容纳1个元素(‘\0‘)! 5     if(bIsUniCommBlv) 6         strcpy(szWanName, "OAM_WAN_MNGIP"); 7     else 8         strcpy(szWanName, " OAM_WAN_VOIP"); 9 10     return 0;11 }

     函数传递指针参数时也可能发生内存越界:

 1 typedef struct{ 2     int dwErrNo; 3     int aErrInfo[6]; 4 }T_ERR_INFO; 5 int PortDftDot1p(int dwPort, int dwDot1p, void *pvOut) 6 { 7     int dwRet = 0; 8     T_ERR_INFO *ptErrInfo = (T_ERR_INFO *)pvOut; 9     //dwRet = DoSomething();10     ptErrInfo->dwErrNo     = dwRet;11     ptErrInfo->aErrInfo[0] = dwPort;12     return dwRet;13 }14 15 int main(void)16 {17     int dwOut = 0;18     PortDftDot1p(0, 5, &dwOut);19     return 0;20 }

     上例中,接口函数PortDftDot1p使用T_ERR_INFO结构向调用者传递出错信息,但该结构并非调用者必知和必需。出于隐藏细节或其他原因,接口将出参指针声明为void*类型,而非T_ERR_INFO*类型。这样,当调用者传递的相关参数为其他类型时,编译器也无法发现类型不匹配的错误。此外,接口内未对pvOut指针判空就进行类型转换,非常危险(即使判空依旧危险)。从安全和实用角度考虑,该接口应该允许pvOut指针为空,此时不向调用者传递出错信息(调用方也许并不想要这些信息);同时要求传入pvOut指针所指缓冲区的字节数,以便在指针非空时安全地传递出错信息。

     错误的指针偏移运算也常导致内存越界。例如,指针p+n等于(char*)p + n * sizeof(*p),而非(char*)p + n。若后者才是本意,则p+n的写法很可能导致内存越界。

     栈区内存越界还可能导致函数返回地址被改写,详见《缓冲区溢出详解》一文。

     两种情况可能改写函数返回地址:1) 对自动变量的写操作超出其范围(上溢);2) 主调函数和被调函数的参数不匹配或调用约定不一致。

     函数返回地址被改写为有效地址时,通过堆栈回溯可看到函数调用关系不符合预期。当返回地址被改写为非法地址(如0)时,会发生段错误,并且堆栈无法回溯:

1 Program received signal SIGSEGV, Segmentation fault.2 0x00000000 in ?? ()

     这种故障从代码上看特征非常明显,即发生在被调函数即将返回的位置。

    【对策】与数据区内存越界对策相似,但更注重代码走查而非越界检测。

2.2.4 返回栈内存地址

     (被调)函数内的局部变量在函数返回时被释放,不应被外部引用。虽然并非真正的释放,通过内存地址仍可能访问该栈区变量,但其安全性不被保证。详见《已释放的栈内存》一文。

 1 const static char *paMsgNameMap[] = { 2     /* 0 */     "0", 3     /* 1 */     "1", 4     /* 2 */     "2", 5     /* 3 */     "3", 6     /* 4 */     "Create", 7     /* 5 */     "5", 8     /* 6 */     "Delete", 9     /* 7 */     "7",10     /* 8 */     "Set",11     /* 9 */     "Get",12     //... ...13     /*28 */     "GetCurData",14     /*29 */     "SetTable"15 };16 const static unsigned char ucMsgNameNum = sizeof(paMsgNameMap) / sizeof(paMsgNameMap[0]);17 18 char *ParseOmciMsgType(unsigned char ucMsgType)19 {20     if(ucMsgType < ucMsgNameNum)21         return paMsgNameMap[ucMsgType];22 23     char szStrMsgType[sizeof("255")] = {0};  /* Max:"255" */24     sprintf(szStrMsgType, "%u", ucMsgType);25     return szStrMsgType;  //编译警告:26 }

     编译上述代码,函数ParseOmciMsgType在返回szStrMsgType处产生function returns address of local variable的警告。可将szStrMsgType定义为静态变量:

1 char *ParseOmciMsgType(unsigned char ucMsgType)2 {3     if(ucMsgType < ucMsgNameNum)4         return paMsgNameMap[ucMsgType];5 6     static char szStrMsgType[sizeof("255")] = {0}; /* Max:"255" */7     sprintf(szStrMsgType, "%u", ucMsgType);8     return szStrMsgType;9 }

     若将结果通过函数参数而非返回值传递,则代码会更为安全:

1 void ParseOmciMsgType(unsigned char ucMsgType, char *pszMsgType)2 {3     if(ucMsgType < ucMsgNameNum)4         strcpy(pszMsgType, paMsgNameMap[ucMsgType]);5     else6         sprintf(pszMsgType, "%u", ucMsgType);7 }

     注意,不可采用下面的写法:

1 void ParseOmciMsgType(unsigned char ucMsgType, char *pszMsgType)2 {3     if(ucMsgType < ucMsgNameNum)4         pszMsgType = paMsgNameMap[ucMsgType];5     else6         sprintf(pszMsgType, "%u", ucMsgType);7 }

     因为指针做为函数参数时,函数内部只能改变指针所指向地址的内容,并不能改变指针的指向。

     若线程在自身栈上分配一个数据结构并将指向该结构的指针传递给pthread_exit,则调用pthread_join的线程试图使用该结构时,原先的栈区内存可能已被释放或另作他用。

    【对策】不要用return语句返回指向栈内变量的指针,可改为返回指向静态变量或动态内存的指针。但两者都存在重入性问题,而且后者还存在内存泄露的危险。

 

2.3 堆区内存

2.3.1 内存未初始化

     通过malloc库函数分配的动态内存,其初值未定义。若访问未初始化或未赋初值的内存,则会获得垃圾值。当基于这些垃圾值控制程序逻辑时,会产生不可预测的行为。

    【对策】在malloc之后调用 memset 将内存初值清零,或使用 calloc代替malloc。

1 char *pMem = malloc (10);2 memset(pMem, 0, 10); // memset前应对申请的动态内存做有效性检查3 //Or4 char *pMem = calloc (10, 1);

2.3.2 内存分配失败

     动态内存成功分配的前提是系统具有足够大且连续可用的内存。内存分配失败的主要原因有:

     1) 剩余内存空间不足;

     2) 剩余内存空间充足,但内存碎片太多,导致申请大块内存时失败;

     3) 内存越界,导致malloc等分配函数所维护的管理信息被破坏。

     剩余内存空间不足的情况相对少见,通常发生在申请超大块内存时。例如:

 1 #include <stdlib.h> 2 #include <errno.h> 3 #define ALLOC_BYTES   (1024*1024*1024) 4 int main(void){ 5     unsigned int dwRound = 0; 6     while(1){ 7         char *pMem = malloc(ALLOC_BYTES); 8         if(NULL == pMem){ 9             printf("Alloc failed(%s)!\n", strerror(errno));10             return -1; 11         } 12         printf("%d -> 0x%p\n", dwRound, pMem); 13         dwRound++;14     }15     return 0;16 }

     执行后产生内存分配失败的错误:

1 0 -> 0x77f6b0082 1 -> 0x37f6a0083 Alloc failed(Cannot allocate memory)!

     内存越界导致内存分配失败的情况更为常见。此时,可从分配失败的地方开始回溯最近那个分配成功的malloc,看附近是否存在内存拷贝和数组越界的操作。

    【对策】若申请的内存单位为吉字节(GigaByte),可考虑选用64位寻址空间的机器,或将数据暂存于硬盘文件中。此外,申请动态内存后,必须判断内存是否是为NULL,并进行防错处理,比如使用return语句终止本函数或调用exit(1)终止整个程序的运行。

2.3.3 内存释放失败

     内存释放失败的主要原因有:

     1) 释放未指向动态内存的指针;

     2) 指向动态内存的指针在释放前被修改;

     3) 内存越界,导致malloc等分配函数所维护的管理信息被破坏;

     4) 内存重复释放(Double Free)。

     情况1属于低级错误,即指针并未执行malloc分配,却调用free释放该指针指向的内存。

1 int main(void)2 {3     int dwMem = 0; //具有迷惑性的变量名4     int *pBuf = &dwMem;5     free(pBuf);6 7     return 0;8 }9 //执行后报错:*** glibc detected *** ./test: free(): invalid pointer: 0xbf84b35c ***

     情况2多发生在从申请内存到最后释放跨越多个模块历经大量处理逻辑时,指针初始值被修改掉。简单示例如下:

 1 int main(void) 2 { 3     char *pMem = malloc(10); 4     if(NULL == pMem) 5         return -1;  6  7     pMem++; 8     free(pMem); 9 10     return 0;11 }12 //执行后报错:*** glibc detected *** ./test: free(): invalid pointer: 0x082b5009 ***

     内存越界也可能导致内存释放失败:

 1 int main(void) 2 { 3     char *pMem = malloc(2); 4     if(NULL == pMem) 5         return -1;  6  7     memset(pMem, 0, sizeof(int)*10); 8     free(pMem); 9     return 0;10 }11 //执行后报错:*** glibc detected *** ./test: free(): invalid next size (fast): 0x09efa008 ***

     内存重复释放最简单但最不可能出现的示例如下:

 1 int main(void) 2 { 3     char *pMem = malloc(10); 4     if(NULL == pMem) 5         return -1;  6  7     free(pMem); 8     free(pMem); 9 10     return 0;11 }12 //执行后报错:*** glibc detected *** ./test: double free or corruption (fasttop): 0x09709008 ***

     通常,编码者会封装接口以更好地管理内存的申请和释放。若释放接口内部在释放前未判断指向动态内存的指针是否为空,或释放后未将指向该内存的指针设置为空。当程序中调用关系或处理逻辑过于复杂(尤其是对于全局性的动态内存),难以搞清内存何时或是否释放,加之接口未作必要的防护,极易出现内存重复释放。

     此外,当程序中存在多份动态内存指针的副本时,很容易经由原内存指针及其副本释放同一块内存。

 1 int main(void) 2 { 3     char *pMem = malloc(sizeof(char)*10); 4     if(NULL == pMem) 5         return -1; 6  7     char *pMemTemp = pMem; 8     //Do Something... 9 10     free(pMem);11     free(pMemTemp);12     return 0;13 }

     上例中仅需释放pMem或pMemTemp其一即可。

    【对策】幸运的是,内存释放失败会导致程序崩溃,故障明显。并且,可借助静态或动态的内存检测技术进行排查。

     对于重复释放,可仿照《C语言通用双向循环链表操作函数集》一文中介绍的SAFE_FREE宏,尽可能地“规避”其危害(但当内存指针存在多个副本时无能为力)。

1 #define SAFE_FREE(pointer)   SafeFree(&(pointer))  //与SAFE_ALLOC入参指针形式一致2 void SafeFree(void **pointer)3 {4     if(pointer != NULL)5     {6         free(*pointer);7         *pointer = NULL;8     }9 }

     此外,应在设计阶段保证数据结构和流程尽量地简洁合理,从根本上解决对象管理的混乱。

2.3.4 内存分配与释放不配对

     编码者一般能保证malloc和free配对使用,但可能调用不同的实现。例如,同样是free接口,其调试版与发布版、单线程库与多线程库的实现均有所不同。一旦链接错误的库,则可能出现某个内存管理器中分配的内存,在另一个内存管理器中释放的问题。此外,模块封装的内存管理接口(如GetBuffer和FreeBuffer)在使用时也可能出现GetBuffer配free,或malloc配FreeBuffer的情况,尤其是跨函数的动态内存使用。

    【对策】动态内存的申请与释放接口调用方式和次数必须配对,防止内存泄漏。分配和释放最好由同一方管理,并提供专门的内存管理接口。

2.3.5 内存越界

     除明显的读写越界外,关于动态内存还存在一种sizeof计算错误导致的越界:

 1 int main(void) 2 { 3     T_CHK_MEM *pMem = malloc(sizeof(pMem)); 4     if(NULL == pMem) 5         return -1; 6  7     memset(pMem, 0, sizeof(T_CHK_MEM)); 8     free(pMem); 9     return 0;10 }11 //执行后报错:*** glibc detected *** ./test: free(): invalid next size (fast): 0x09239008 ***

     这种越界也是内存释放失败的一个原因。正确的内存申请写法应该是:

1 T_CHK_MEM *pMem = malloc(sizeof(*pMem));2 //Or3 T_CHK_MEM *pMem = malloc(sizeof(T_CHK_MEM));

    【对策】当模块提供动态内存管理的封装接口时,可采用“红区”技术检测内存越界。例如,接口内每次申请比调用者所需更大的内存,将其首尾若干字节设置为特殊值,仅将中间部分的内存返回给调用者使用。这样,通过检查特殊字节是否被改写,即可获知是否发生内存越界。其结构示意图如下:

 

2.3.6 内存泄露

    内存泄漏指由于疏忽或错误造成程序未能释放已不再使用的内存。这时,内存并未在物理上消失,但程序因设计错误导致在释放该块内存之前就失去对它的控制权,从而造成内存浪费。只发生一次的少量内存泄漏可能并不明显,但内存大量或不断泄漏时可能会表现出各种征兆:如性能逐渐降低、全部或部分设备停止正常工作、程序崩溃以及系统提示内存耗尽。当发生泄漏的程序消耗过多内存以致其他程序失败时,查找问题的真正根源将会非常棘手。此外,即使无害的内存泄漏也可能是其他问题的征兆。

     短暂运行的程序发生内存泄漏时通常不会导致严重后果,但以下各种内存泄漏将导致较严重的后果:

  • ?   程序运行后置之不理,并随着时间流逝不断消耗内存(如服务器后台任务,可能默默运行若干年);
  • ?   频繁分配新的内存,如显示电脑游戏或动画视频画面时;
  • ?   程序能够请求未被释放的内存(如共享内存),甚至在程序终止时;
  • ?   泄漏发生在操作系统内部或关键驱动中;
  • ?   内存受限,如嵌入式系统或便携设备;
  • ?   某些操作系统在程序运行终止时并不自动释放内存,且一旦内存丢失只能通过重启来恢复。

     通常所说的内存泄漏指堆内存的泄漏。广义的内存泄漏还包括系统资源的泄漏(Resource Leak),而且比堆内存的泄漏更为严重。

     内存泄漏按照发生频率可分为四类:

     1) 常发性内存泄漏。即发生内存泄漏的代码被多次执行,每次执行都会泄漏一块内存。

     2) 偶发性内存泄漏。即发生内存泄漏的代码只发生在特定环境或操作下。特定的环境或操作下,偶发性泄漏也会成为常发性泄漏。

     3) 一次性内存泄漏。即发生内存泄漏的代码只执行一次,导致有且仅有一块内存发生泄漏。例如:

 1 char* gpszFileName = NULL; 2 void SetFileName(const char* pszFileName) 3 { 4     if(gpszFileName != NULL) 5         free(gpszFileName); 6  7     gpszFileName = strdup(pszFileName); 8 } 9 int main(void)10 {11     SetFileName("test.c");12     SetFileName("test.h");13     return 0;14 }

     若程序结束时未释放gpszFileName指向的字符串,则即使多次调用SetFileName函数,也总有且仅有一块内存发生泄漏。

     4) 隐式内存泄漏。即程序在运行过程中不停地分配内存,但直到结束时才释放内存。例如,一个线程不断分配内存,并将指向内存的指针保存在一个数据存储(如链表)中。但在运行过程中,一直没有任何线程进行内存释放。或者,N个线程分配内存,并将指向内存的指针传递给一个数据存储,M个线程访问数据存储进行数据处理和内存释放。若N远大于M,或M个线程数据处理的时间过长,则分配内存的速度远大于释放内存的速度。严格地说这两种场景下均未发生内存泄漏,因为最终程序会释放所有已申请的内存。但对于长期运行(如服务器)或内存受限(如嵌入式)的系统,若不及时释放内存可能会耗尽系统的所有内存。

     内存泄漏的真正危害在于其累积性,这将最终耗尽系统所有的内存。因此,一次性内存泄漏并无大碍,因为它不会累积;而隐式内存泄漏危害巨大,因其相比常发性和偶发性内存泄漏更难检测。

     内存泄漏的主要原因有:

     1) 指向已申请内存的指针被挪作他用并被改写;

     2) 因函数内分支语句提前退出,导致释放内存的操作未被执行;

     3) 数据结构或处理流程复杂,导致某些应该释放内存的地方被遗忘;

     4) 试图通过函数指针参数申请并传递动态内存;

     5) 线程A分配内存,线程B操作并释放内存,但分配速度远大于释放速度。

     情况1属于低级错误,通常发生在同时管理多块动态内存时。

 1 int main(void) 2 { 3     char *pPrevMem = malloc(sizeof(char)*10); 4     if(NULL == pPrevMem) 5         return -1; 6     char *pNextMem = malloc(sizeof(char)*10); 7     if(NULL == pNextMem) 8         return -1; 9 10     pNextMem = pPrevMem;11     free(pNextMem);12     return 0;13 }

     上例将指针pPrevMem赋值给指针pNextMem,从而导致pNextMem以前所指向的动态内存无法释放,因为已经丢失指向该位置的引用。

     情况2是最为常见的内存泄漏案例,尤其是在分支语句为异常和错误处理时。

 1 int IsSthElseValid(void) {return 0; /*dummy*/}  2 int main(void) 3 { 4     char *pMem = malloc(sizeof(char)*10); 5     if(NULL == pMem) 6         return -1; 7  8     if(!IsSthElseValid()) 9         return -2;10     11     free(pMem);12     return 0;13 }

     上例当函数IsSthElseValid()返回值不为真时,指针pMem指向的内存将就不被释放。通常程序在入口处分配内存,在出口处释放内存。但C函数可在任何地方退出,一旦某个出口处未释放应该释放的内存,就会发生内存泄漏。

     与之相似的是,为完成某功能需要连续申请一系列动态内存。但当某次分配失败退出时,未释放系列中其他已成功分配的内存。

     情况3多发生在内存挂接(分配的动态内存中某些元素又指向其他动态内存)时,容易出现仅释放父内存或释放父内存后释放子内存的错误。

 1 struct book{ 2     char  szTitle[50]; 3     char  szAuthor[40]; 4     float  fPrice; 5     int   dwMask[4]; 6 }gtRef = { .fPrice = 11.62, 7         .szAuthor = "F. Scott Fitzgerald", 8         .szTitle = "The Great Gatsby", 9         .dwMask[0 ... 3] = 1};10 11 typedef struct{12     int dwDataLen;13     char *pData;14 }T_DATA_BUF;15 16 int main(void)17 {18     T_DATA_BUF *ptBuf = (T_DATA_BUF *)calloc(sizeof(T_DATA_BUF), 1);19     ptBuf->dwDataLen = sizeof(struct book);20     ptBuf->pData = http://www.mamicode.com/(char *)calloc(ptBuf->dwDataLen, 1);21     memcpy(ptBuf->pData, &gtRef, ptBuf->dwDataLen);22 23     struct book *ptBook = (struct book *)ptBuf->pData;24     printf("Reference: ‘%s‘ by %s, $%.2f, [%d-%d-%d-%d].\n", ptBook->szTitle,25             ptBook->szAuthor, ptBook->fPrice, ptBook->dwMask[0],26             ptBook->dwMask[1], ptBook->dwMask[2], ptBook->dwMask[3]);27 28     free(ptBuf->pData);29     free(ptBuf);30     return 0;31 }

     若只执行free(ptBuf)语句,则pData指向的子内存泄露;若先执行free(ptBuf)后执行free(ptBuf->pData),则释放ptBuf所指内存后,该内存无效且ptBuf成为迷途指针,无法保证能通过pData释放子内存。当分配的挂接内存提供给外部使用时,很难保证调用者进行两次释放操作,并且顺序正确。

     在消息驱动通信中,同一消息的处理往往跨越多个模块。处于消息接收末端的模块,需要释放消息内的消息体。一旦忘记释放,在消息转发频繁时将不断泄露内存。这种错误从代码层面很难发现,需要设计时对流程有很强的理解。

     情况4根源在于对C语言函数参数传递方式(传值调用)的误解。

 1 void GetMemory(char *pMem, int dwMemBytes) 2 { 3     pMem = (char *)malloc(sizeof(char) * dwMemBytes); 4 } 5 int main(void) 6 { 7     char *pStr = NULL; 8     GetMemory(pStr, 100);  //pStr仍为NULL 9     //strcpy(pStr, "hello");  //Segmentation fault10     free(pStr);11     return 0;12 }

     编译器为函数GetMemory的每个参数制作临时副本。假设指针参数pMem的副本是_ pMem,则编译器使_ pMem初值等于pMem。在函数体内修改_pMem的值(即所指的内存地址),并不会影响到pMem的取值。因此pMem仍为空指针,自然无法借此释放函数GetMemory内所申请的动态内存。若函数main中循环调用GetMemory ,则内存将不断泄露。

     若非要用指针参数申请内存,可改用指向指针的指针,或用函数返回值来传递动态内存。

1 void GetMemory(char **ppMem, int dwMemBytes){2     *ppMem = (char *)malloc(sizeof(char) * dwMemBytes);3 } //调用方式:GetMemory(&pStr, 100);4 //Or5 void GetMemory(char *pMem, int dwMemBytes){6     pMem = (char *)malloc(sizeof(char) * dwMemBytes);7     return pMem;8 } //调用方式:pStr = GetMemory(pStr, 100);

    【对策】设计时应规范各动态内存的用途及申请释放的流程,避免指针多用和忘记释放。

     函数内部若存在退出分支,则每个返回之前都应确保释放已成功分配的内存。

     对于挂接内存,应按照分配顺序反向遍历释放各子内存,最后释放父内存(最好能为其提供专门的分配和释放接口)。也可借助柔性数组特性来简化释放操作,尤其是当挂接内存提供给外部调用者使用时:

 1 typedef struct{ 2     int dwDataLen; 3     char   aData[0]; //Or ‘char aData[]‘ 4 }T_DATA_BUF2; 5 int main(void) 6 { 7     T_DATA_BUF2 *ptBuf = (T_DATA_BUF2 *)calloc(sizeof(T_DATA_BUF)+sizeof(struct book), 1); 8     ptBuf->dwDataLen = sizeof(struct book); 9     memcpy(ptBuf->aData, &gtRef, ptBuf->dwDataLen);10 11     struct book *ptBook = (struct book *)ptBuf->aData;12     printf("Reference: ‘%s‘ by %s, $%.2f, [%d-%d-%d-%d].\n", ptBook->szTitle,13             ptBook->szAuthor, ptBook->fPrice, ptBook->dwMask[0],14             ptBook->dwMask[1], ptBook->dwMask[2], ptBook->dwMask[3]);15 16     free(ptBuf);17     return 0;18 }

     这种写法分配的内存连续,而且只需一次free即可释放,易用性更好。

     消息通信过程中,消息处理结束时必须释放消息内的消息体,异步通信时可由接收末端的模块释放,同步通信时可由发送前端的模块释放。这需要设计时规范消息的转发和处理流程。

     不要试图通过函数指针参数申请并传递动态内存,可改由二级指针或函数返回值传递。

     当程序代码庞杂且逻辑复杂时,可考虑增加内存泄漏检测机制。其基本原理是截获对内存分配和释放函数的调用,从而跟踪每块内存的生命周期。例如,每次成功分配一块内存后,将指向它的指针以及内存分配信息(如文件名、函数名、行号和申请字节数等)加入一个全局链表中;每当释放一块内存时,再把指向它的指针从链表中删除。这样,当程序结束时,链表中剩余的指针就指向那些未被释放的内存。详细算法见《基于链表的C语言堆内存检测》一文。

     对于隐式内存泄露,可在程序运行过程中监控当前内存的总使用量和分配释放情况。以分配内存时的文件名和行号为索引,遍历链表结点即可计算出各处已分配但未释放的内存总量。若在连续多个时间间隔内,某文件中某行所分配的内存总量不断增长,则基本可确定属于隐式内存泄露(尤其是多线程引起的)。

     最后,频繁地调用库函数申请和释放内存效率较低,且易产生内存碎片。可采用内存池技术,以高效地管理和检测内存。设计和编码时应仔细分析需求,以减少不必要的动态内存使用。例如,解析定长的短消息内容时,就无需分配动态内存,定义固定长度的数组即可。

2.2.7 使用已释放堆内存

     动态内存被释放后,其中的数据可能被应用程序或堆分配管理器修改。不要再试图访问这块已被释放的内存,否则可能导致不可预料的后果。

 1 int main(void) 2 { 3     char *pStr = (char *)malloc(100); 4     strcpy(pStr, "hello"); 5     free(pStr);         6     if(pStr != NULL)  //出现迷途指针 7     { 8         strcpy(pStr, "world");     9         printf("%s\n", pStr);10     }11     return 0;12 }

     上例通常不会导致程序异常。但若使用迷途指针时,已释放的动态内存恰好被重新分配给其他数据,则strcpy语句可能造成意想不到的错误。除非法访问外,迷途指针还可能导致重复释放内存等故障。

     在多线程环境下,线程A通过异步消息通知线程B操作某块全局动态内存,通知后稍等片刻(以便线程B完成操作)再释放该内存。若延时不足无法保证其先操作后释放的顺序,则可能因访问已释放的动态内存而导致进程崩溃。

    【对策】务必保证已分配的内存块被且仅被释放一次,禁止访问执行已释放内存的指针。若该指针还存在多个副本,则必须保证当它所指向的动态内存被释放后,不再使用所有其他副本。

     避免上述错误发生的常用方法是释放内存后立即将对应的指针设置为空(NULL)。

 

 

三  总结

     本文已详细讨论了三种内存使用时常见的问题及其对策。除设计和编码时加以注意外,还可借助内存检测工具(如Valgrind等)静态或动态地检查代码中的内存缺陷。但对于用户终端或大型工程,外部工具往往不可用,此时内置的内存检测代码就可派上用场。

     除本文所述内容外,设备或模块间通信还涉及内存对齐和字节顺序等问题。某一方(尤其是DLL库)增删接口结构体内成员或调整成员顺序时,若另一方忘记同步更新,则必然导致解析错误。