C语言陷阱与技巧第45节,怎样自己定义一套“动态内存”分配机制?

上一节主要讨论了C语言程序开发中,几种常用的内存分配方式的优缺点。

应明白,嵌入式C语言编程大都不同于互联网编程,因为 Java、Js 、Python 等编程语言已经有些像“自动档”汽车了,能将程序员照顾的很好。而C语言则更像“手动挡”汽车,要求程序员熟悉底层,并对内存等资源的使用把握到每一个字节(甚至每一个位)。

正如前面几节讨论的,事实上,对于 safety-critial(极度重视安全) 的C语言程序,一般都不会使用标准库提供的动态内存分配方法(malloc()/free() 等),而是会根据项目的具体情况,自定义一套更安全的动态内存分配方式。

静态内存预分配

如果C语言项目用不到复杂的堆特性,要是不想有产生内存碎片的风险,可以只申请内存分配,并且不释放。也就是说,C语言程序只在初始化时使用动态内存分配,之后就不再会有内存分配和释放过程了。这种需求可以借助标准库函数 malloc() 实现。

不过,C语言程序只在初始化阶段分配内存,无疑限制了程序的灵活性,使用这种规避内存碎片的方案,倒不如自定义实现一套内存分配方案了,自己实现的方案可以根据具体情况调整,但是总体来说,一般都能解决 malloc() 的固有问题:

  • 可以避免管理每个内存块的头索引开销。
  • 可以随时禁用,例如在初始化完成,不再涉及内存分配时,禁用相关C语言代码以减小开销,提升效率。

相比于全局声明定义的内存,自定义内存分配容易实现以下优势:

  • 不同的模块可以为不同目的分配不同的内存。
  • 尽量减少命名空间的“污染”。许多情况下,全局变量或者文件 static 变量的作用域和生命周期过长,很容易被一些不期望的C语言代码访问和修改。
  • 以后可以容易过度到 malloc/free 风格的C语言代码。

请看下面的C语言代码,这是一个简单的自定义内存分配示例,可以看出,只需少量的C语言代码,就可以实现自己的动态内存分配。

#define SALLOC_BUFFER_SIZE 90000

static unsigned char GS_sallocBuffer[SALLOC_BUFFER_SIZE];
static Boolean FS_enabled = TRUE;
int GS_sallocFree = 0;

void *salloc(int size)
{
   void *nextBlock;
   assert(FS_enabled);
   if((GS_sallocFree + size) > SALLOC_BUFFER_SIZE)
   {
      assert(FALSE);
   }
   nextBlock = &GS_sallocBuffer[GS_sallocFree];
   GS_sallocFree += size;
   return nextBlock;
}

void sallocDisable(void)
{
   FS_enabled = FALSE;
}

应该注意的是,对于多任务系统来说,上述C语言代码可能需要添加一个锁机制,以防止多个任务产生“竞争条件”,另外,在某些硬件平台上,上述C语言代码可能还需考虑内存对齐问题。

虽然与标准的 malloc() 函数分配堆内存相比,这种动态内存分配方法安全的多,但是由于没有设计类似于 free() 的释放函数,这种方法可能会消耗更多的内存。

不过,根据测试,很容易就可预先得到C语言项目需要的确切内存数,并且在C语言程序初始化完成后,调用 sallocDisable() 函数确保内存布局不再改动,这样一来,整个C语言程序就可以很安全的使用内存,且不会产生内存碎片,也不会浪费多少内存了。

这种通过测试,预先得到实际内存使用数,并且通过静态内存分配的方法,常被称作“静态内存预分配”法。

内存池

上面提到的自定义动态内存分配方案没有涉及到释放,不过如果在C语言程序开发中需要释放功能,可以考虑内存池机制。使用固定块大小的内存池,也是可以消除内存碎片问题的。一般来说,内存池是介于静态预分配和通用堆内存分配之间的折中方案,可以在设计时根据将要发出的请求内存大小做出适当调整。

许多嵌入式系统实际上只包含一个C语言程序,这时我们就完全可以对内存池做出精细的调整,在避免内存碎片的同时,尽可能的以低开销,高效率的完成任务。

每个内存池包含一组内存块,未使用的空闲块可以通过链表连接在一起,内存池本身可以声明为数组。这种机制避免了管理每个块的头索引开销,因为每个内存池的大小是固定的。

请看上图,每次内存申请请求都会被对应到大小相等的内存块(或者更大些的内存块,如果没有恰好相等的内存块的话)。如果读者在自己的C语言项目中,对每次申请内存的大小没有限制,那么建议每次申请大小为 2 的幂为宜(想想为什么?)。

使用内存池机制的一个主要动机是,它可以为分配和释放内存块提供固定的准确执行时间。而一般的通用堆内存管理总是涉及遍历若干大小不同的列表。

另外,通过在C语言程序中监控每个内存池的大小,并确认处于使用中的内存块数在长时间的运行中不再增长,C语言程序员便可确信程序没有内存泄漏问题。

在一些实际开发中,基于内存池的动态内存分配主要用于满足比较小的内存请求,对于大内存申请则仍然使用通用堆内存。

RTOS 内存分区

许多 RTOS 提供内存池机制,通被称为内存分区(对比于“磁盘分区”)。内存分区对于实现上述基于内存池的动态内存分配很有用,下面是一段C语言示例代码,请看:

block1Ptr = partitionGetBlock(partitionOfBlocksSized1000);
block2Ptr = partitionGetBlock(partitionOfBlocksSized200);

partitionFreeBlock(block1Ptr, partitionOfBlocksSized1000);
partitionFreeBlock(block2Ptr, partitionOfBlocksSized200);

这种格式的C语言代码是很多 RTOS 的典型格式。显然,C语言程序员的职责是确保将内存块返回到它原来所属的内存分区里。

在实现内存池时,可以隐藏对内存分区的C语言代码调用,将所需的内存大小传递给分配函数,由它决定要使用的分区,并通过指针传递给C语言程序员使用,之后再通过该指针释放到其所述内存分区。这样的更有利于写出便于维护的C语言代码,例如:

block1Ptr = myAlloc(1000);
block2Ptr = myAlloc(200);

myFree(block1Ptr);
myFree(block2Ptr);

多任务内存管理

在嵌入式C语言项目中,每个任务都必须有自己的栈,但是也有可能拥有自己的堆(不管该堆是基于静态预分配的,还是内存池,内存分区的)。相比于栈内存,堆内存的生命周期更长,所以可能出现这种应用场景:

任务 A 申请一段内存,该内存由任务 B 释放。这样的内存在不同任务间传递信息很有用,不过要小心避免多个任务同时释放同一段堆内存,或者没有任一任务释放使用完毕的内存。

小结

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