【编程基础】C语言内存使用的常见问题

时间:2022-05-04
本文章向大家介绍【编程基础】C语言内存使用的常见问题,主要内容包括一、 数据区内存、1内存越界、3 volatile修饰、二、 栈区内存、2 堆栈溢出、3 内存越界、4 返回栈内存地址、三、 堆区内存、2 内存分配失败、3 内存释放失败、4 内存分配与释放不配对、5 内存越界、6 内存泄露、基本概念、基础应用、原理机制和需要注意的事项等,并结合实例形式分析了其使用技巧,希望通过本文能帮助到大家理解应用这部分内容。

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

一、 数据区内存

1内存越界

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

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

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

写越界的主要原因有两种:

1) memset/memcpy/memmove等内存覆写调用;

2) 数组下标超出范围。

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

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

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

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

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

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

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

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

2 多重定义

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

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

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

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

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

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

3 volatile修饰

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

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

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

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

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

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

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

二、 栈区内存

1 内存未初始化

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

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

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

2 堆栈溢出

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

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

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

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

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

3 内存越界

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

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

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

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

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

4 返回栈内存地址

(被调)函数内的局部变量在函数返回时被释放,不应被外部引用。虽然并非真正的释放,通过内存地址仍可能访问该栈区变量,但其安全性不被保证。因为指针做为函数参数时,函数内部只能改变指针所指向地址的内容,并不能改变指针的指向。

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

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

三、 堆区内存

1 内存未初始化

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

【对策】 在malloc之后调用 memset 将内存初值清零

2 内存分配失败

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

1) 剩余内存空间不足;

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

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

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

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

3 内存释放失败

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

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

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

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

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

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

4 内存分配与释放不配对

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

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

5 内存越界

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

6 内存泄露

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

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

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

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

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

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

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

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

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

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

内存泄漏的主要原因有:

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

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

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

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

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

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

7 使用已释放堆内存

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

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

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

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

本文摘自博客园:clover_toeic