C语言陷阱与技巧第44节,程序开发中几种常用的内存分配方式详解

在C语言程序开发中,涉及到内存分配时,无论程序员使用的是静态内存,还是基于堆栈的动态分配,都必须谨慎的进行。嵌入式C语言程序员永远不能忽视内存安全问题。

每一个C语言程序都会用到 RAM,但是使用的具体分配方式差异很大,本专栏接下来将讨论C语言开发中几种常用的内存分配方式,并研究堆实现如何影响内存碎片和实时性能,希望能够对读者选择内存分配方式时有所参考。

静态内存分配

如果C语言程序中所有的内存都是静态分配的,那么完全可以在编译阶段就确定C语言程序运行期间使用的 RAM 的每个字节。

这种方法在嵌入式系统中的优势在于,内存泄漏、故障、或者空指针误操作导致的内存相关错误,将不复存在。这种优势使得许多 8 位处理器(如 8051 单片机或 PIC)的编译器就被设计为静态内存分配。

C语言程序中的数据有如下几种类型:全局,文件静态,函数静态,函数局部。全局和静态数据被分配到一个固定位置,它们的生命周期会持续到整个C语言程序结束。

局部数据则存在于每一个C语言函数代码块中,只有当该函数被调用时,它的局部变量才真正的出现在内存中。当C语言函数不运行时,它的局部变量通常实际上不会出现在内存中的。

如果硬件无法直接提供堆栈的支持,C语言编译器常会使用上述内存分配方式。下图是一种没有堆和栈的内存组织方式,每个C语言函数只有全局块和局部快。

这种内存组织方式不能使用递归,或者其他任何需要提供重入C语言代码的机制。例如,中断处理程序不能调用 main() 函数也可能调用的函数。

毫无疑问,这样的内存分配方式丢失了一部分C语言程序的灵活性,不过作为回报,C语言程序员将不用再为程序运行时出现内存问题担忧。换句话说,这种静态的使用所有内存空间,C语言程序将牺牲一些灵活性和效率,以换取程序的健壮性。

一些优化过的C语言编译器可 能会限定两个指定的函数不能同时处于活动状态,这将允许两个函数相关联的内存块重叠,这种方法对不能使用函数指针的C语言代码做了额外的限制。

读者应该注意,为了完全利用静态内存分配方式的优势,如果有计划在静态环境中尝试实现动态内存分配(例如使用一块很大的全局内存,做不同的目的重用),一定要小心不会带来动态内存分配方式的固有问题。

另外值得说明的是,对于大型系统,完全的静态内存分配是不可取的,因为最终系统会需要大量的 RAM 来满足C语言程序的每一个可能的执行路径。

基于栈的内存管理

C语言程序运行时,函数被调用时总是需要申请一个内存块使用(这是肯定的,一切软件都运行在内存里),该内块通常存在于栈上,所以一般被程序员们称为栈帧。

栈帧随着C语言程序运行而增长(函数被调用时)或收缩(函数执行完毕返回时),对于许多C语言程序来说,编译时很难确定究竟会使用多少栈。只知道多任务系统会保证每个任务都有自己的独有栈,可能还会为中断分配一个栈。

为了C语言程序可以稳定工作,必须进行一些判断,以确保每个函数的栈足够大,否则栈溢出将导致程序崩溃。虚拟内存管理允许各个任务从公共内存池里申请内存使用,遗憾的是,大多数嵌入式系统都不支持虚拟内存管理。

鉴于没有办法严格的计算每个C语言程序需要的栈深度,这里提供一条经验法则:使每个栈深度比测试期间看到的最大深度达 50%。为了应用这条规则,C语言程序员必须知道测试过程中的栈大小。

一个比较简单的小技巧是用“绘制”的栈空间。如上图所示,系统初始化时,先对其写入已知的字符(例如0x55AA)。随着C语言程序的运行,系统使用栈时,会将过程数据写入,此时,原先标记的“0x55AA”会被实际数据覆盖,这种情况下,简单的判断就能确定实际使用的栈深度。

标记信息应不为零,因为实际C语言程序开发中,申请一段内存并分配给零的数据很常见。

许多 RTOS 提供栈大小跟踪功能,如果读者使用的 OS 没有该功能,那么根据上述原理,自己实现它也不是多么困难。该技术可以在测试阶段用于优化C语言程序使用的栈大小,也可在开发阶段对栈超出设计预期的情况发出警告。

使用上述小技巧,就无需再C语言程序开发中时刻监测栈使用情况了,毕竟这一过程的开销较大,势必会带来一定的性能损失。

基于堆的内存管理

C语言程序中,对象、结构、缓冲区的生命周期有时并不完全一致,比如某个对象需要在程序整个运行期间可用,而它所用的某个数据结构可能只需要在某一时间段可用就可以。

在C语言程序中,堆管理一般主要由 malloc() 和 free() 函数执行。malloc() 负责从堆中申请内存块,并将该内存块的首地址返回给程序员使用,free() 则允许程序员在使用堆内存完毕后,将其返回到堆中。

因为堆内存完全由程序员管理,如果某段内存没有被 free() ,即使函数执行完毕,这段内存也不会被系统回收,所以程序员在使用堆内存时必须小心,否则可能会引入比较严重的 bug。

例如,在C语言代码的某处,要是您不能确定某个内存块是否仍然有用,并且将其 free()。如果仍有其他代码访问和使用这段内存块,那么您的C语言程序可能会正常工作,但是下一次 malloc() 时,这段已经被 free() 的内存块可能会被分配给其他逻辑使用,这就会造成内存冲突,此时C语言程序崩溃退出尚属好事,万一程序没有退出,而是输出了错误结果就麻烦了。

这类错误一般隐藏较深,较难调试。

另外,如果您忘记 free() 已经使用完毕,本该被释放的内存,还会导致内存泄漏。在常规桌面系统 PC 机上的C语言程序中,如果内存泄漏比较少尚可接受,毕竟开关机重启系统就会将泄漏的内存回收。

不过,对于嵌入式系统来说,C语言程序的寿命常常是没有上限(如果可能,一些设备从启动开始,一直工作到其报废,都不会关机)的,因此哪怕一点点内存泄漏都是不允许的,应该有详尽的测试,修改错误的代码逻辑避免。

使用堆内存管理,除了冲突和泄漏,还可能会有“碎片”问题,这个问题是 malloc() 的大多数实现固有的——多次分配释放后,完整的内存块会被分解为许多小块,这将影响大内存分配,以及造成内存浪费。

既然使用堆内存又这么多潜在问题,是否在嵌入式C语言程序开发中就不能使用 malloc() 和 free() 了呢?也不是,只不过要遵守一些限制,或者按照我之前的文章介绍过的,编写自己限制版本的 malloc() 和 free()。

为了更好的理解使用堆内存的限制,下面将研究 malloc() 是如何工作的。

如上图所示,C语言程序中的堆是一个很大的内存块,由已使用内存块和空闲内存块组成。空闲块和已被分配块都有一个头。空闲列表指针始终指向第一个可用块,当有内存分配请求时,程序将迭代搜索词列表,并返回搜索到的空闲块。

理想情况下,空闲内存块可以提供与申请大小一致的内存块,但是如果没有恰好大小一致的内存块,程序会将一些较大的内存块拆成若干子块,并从中挑选合适大小的子块返回。这样一来,大块的堆可以分解成许多小内存块,这些小内存块则可能散布给C语言程序的各个子模块使用。

上图是一个经过多次分配的堆,左侧的空闲列表只包含一个单元。释放其中一个内存块后,空闲列表就有两个单元了,请看上图右侧。

上图可用内存块的大小为 15 字节,如果进行了 10 个字节的内存分配,15 字节块可以分解为 10 字节块和剩余的 5 字节内存块。5 字节的内存块已经很小了,基本不会满足C语言程序开发的需求了,虽然它周边的内存块被释放后可以与之连续,但是仍然可能永远都不会被分配出去了。

在分配和释放堆内存时,可以使用适当的策略减少内存碎片,下面是两种常用的分配策略:

  • 首次匹配法:每次分配只要找到第一个满足需求大小的内存,即使找到的是一个需要拆分的过大内存块。
  • 最佳匹配法:彻底搜索所有空闲内存块,尽量找到与需求大小吻合的内存块。

显然,最佳匹配法会尽量避免碎片产生,但是相比于首次匹配法,开销也更大,读者应对开销和碎片量做出恰当取舍。事实上,以 Unix 为例,如果仔细设计堆内存分配机制,应用程序中的内存碎片损失可以做到仅为 1%,但是相应的开销也会增大,影响整个系统的效率。

小结

本节主要讨论了C语言程序开发中的几种内存分配机制,希望能够抛砖引玉,为读者在程序开发中选择内存分配方式时提供参考。实际上,还有其他几种内存分配或者管理方式,限于篇幅,下一节有机会再说了。

阅读更多:   C语言
添加新评论

icon_redface.gificon_idea.gificon_cool.gif2016kuk.gificon_mrgreen.gif2016shuai.gif2016tp.gif2016db.gif2016ch.gificon_razz.gif2016zj.gificon_sad.gificon_cry.gif2016zhh.gificon_question.gif2016jk.gif2016bs.gificon_lol.gif2016qiao.gificon_surprised.gif2016fendou.gif2016ll.gif