FreeRTOS中的链表 | 堆的管理
堆的管理
很多人把"堆栈"相提并论,其实"堆"、"栈"是完全没有联系。"栈"的作用我们前面已经讲了很多,"堆"是什么?
-
在汇编代码里指定一个AREA:在汇编代码里,使用SPACE命令可以分配一段空间,这段空间就是堆:
-
在C代码里,定义一个全局数组:该数组的大小是17KB,这段空间就是堆:
"堆"就是一块或者多块内存,我们可以从中申请一小块内存来使用,使用完毕后可以释放这一小块内存。
简单地说,一开始,"堆"是一些空闲内存,我们可以:
-
使用malloc函数从中申请、获得一小块内存
-
使用free函数释放这一小块内存
-
这些malloc、free函数就是用来管理这些内存的
-
malloc、free函数可以有其他名称,比如FreeRTOS里是pvPortMalloc、vPortFree。
在FreeRTOS的源码中,默认提供了5个文件,对应内存管理的5种方法:
文件 | 优点 | 缺点 |
heap_1.c | 分配简单,时间确定 | 只分配、不回收 |
heap_2.c | 动态分配、最佳匹配 | 碎片、时间不定 |
heap_3.c | 调用标准库函数 | 速度慢、时间不定 |
heap_4.c | 相邻空闲内存可合并 | 可解决碎片问题、时间不定 |
heap_5.c | 在heap_4基础上支持分隔的内存块 | 可解决碎片问题、时间不定 |
只能使用上诉五种方式的中的一种来管理堆,其中heap_3.c
由于使用的是标准库函数中的malloc
和free
,速度比较慢,所以本喵这里也不做介绍,只介绍其他四种。
heap_1.c
如上图所示在堆区上申请空间的函数pvPortMalloc
,其中xWantedSize
是要申请的空间大小,申请过程主要分为四步:
-
创建返回地址
pvReturn
和对齐地址pucAlignedHeap
,让这两个指针都为0。 -
对申请的空间大小进行对齐。
如上图所示宏定义#define portBYTE_ALIGNMENT 8
规定了8字节对齐,什么是字节对齐呢?
如上图所示,假设现在有9个字节的空间在内存中连续排列,而我们的CPU是32位的,也就是说一次读取或者写入数据必须操作的是4字节大小的空间。
现在一共9字节,操作两次以后还剩下一个字节,再操作时仍然是4字节,所以要想操作第9个字节,就会额外多操作三个字节,此时就会导致效率低下。
甚至有的CPU并不支持这种不对齐访问。
由于是8字节对齐,所以申请的空间大小必须是8的整数倍,如果不符合8字节对齐,就需要向上对齐,如申请100个字节,实际上申请的是对齐后的104字节。
-
找到堆区的对齐地址
pucAlignedHeap
如上图所示,假设整个堆区ucHeap
在内存上的起始地址是0x20000001
,按照CPU每次操作4字节的规律,该地址无论如何也无法作为单次CPU的起始地址,所以操作起来不方便。
按照代码中所写,所以对齐地址求出来就是0x20000008
,该地址一定是CPU操作时4字节中的起始地址。
此时对齐地址pucAlignedHeap
就是指向这里,而且让xNextFreeByte + = xWantedSize
,得到此次申请空间大小。
-
获取申请空间
如上图所示,如果是第一次申请空间,那么最后返回的就是对齐地址pucAlignedHeap
所指向的地址,如果不是第一次申请,那么在pucAlignedHeap
基础上增加上次空间大小的偏移量pxNextFreeByte
得到的就是返回地址。
简单来说,heap_1
管理堆的方式就是,通过pucAlignedHeap
和pxNextFreeByte
两个全局变量,每申请一次,就返回上一次申请后剩余空间的起始位置。
如上图释放动态空间函数vPortFree
,并不支持释放空间。
heap_1.c里,只能用pvPortMalloc函数来申请空间,无法使用vPortFree函数来释放空间。
heap_2.c
heap_2.c里,使用链表来管理内存。链表结构体为:
这个结构体用来表示空闲块:
-
pxNextFreeBlock
:指向下一个空闲块 -
xBlockSize
:当前空闲块的内存大小
如上图所示,在这个结构体后面,紧跟着空闲内存。
在堆空间
static uint8_t ucHeap[ configTOTAL_HEAP_SIZE ]
上,空闲空间往前偏移8个字节就得到BlockLink_t
结构体的起始地址。
初始化:
如上图所示堆的初始化函数prvHeapInit
,在该文件中定义了两个BlockLink_t
类型的全局变量,一个是链表头xStart
,另一个是链表尾xEnd
,在该函数中主要进行四步操作:
-
从整个堆空间上计算对齐地址
pucAlignedHeap
。 -
让链表头
xStart
中的pxNextFreeBlock
指向对齐地址,并且将链表头中的xBlockSize = 0
,因为链表头并不管理空闲内存。 -
让链表尾
xEnd
中的pxNextFreeBlock
指向空,xBlockSize = configADJUSTED_HEAP_SIZE
,这是堆的最大值,为了排序时方便才这样设计的。 -
让
pxFirstFreeBlock
指向对齐地址,并且构造BlockLink_t
中的成员,让xBlockSize = configADJUSTED_HEAP_SIZE
,pxNextFreeBlock
指向链表尾。
如上图所示初始化完成的结果,此时在整块堆空间的中,最前面的8字节就是BlockLink_t
结构体,包含该空间的大小,并链入了管理空闲堆空间的链表中。
分配内存:
如上图所示分配空间函数pvPortMalloc
的部分代码,主要进行了四步操作:
-
如果是第一次调用
pvPortMalloc
,则先对整个堆区进行初始化,让整个堆区作为空闲空间链入到空闲空间管理链表xStart
和xEnd
之间。 -
对申请的空间大小进行对齐计算。
-
从空闲空间管理链表中寻找合适的空间块。
-
假设此时链表中存在多个含有
BlockLink_t
的空闲块,所以根据要申请空间的大小迭代找到比申请空间大的空闲块。
-
-
通过链表找到的是空闲块的起始地址,包含
BlockLink_t
,所以向后偏移8个字节,得到该空闲块的对齐地址作为返回值供申请者使用。
如上图,返回的是该空闲块中红色箭头指向的地址。
如上图所示的紧接着前面pvPortMalloc
函数剩下的代码,主要有四步操作,本喵这里接着前面从标号5开始讲解:
-
将寻找到的空闲块从空闲链表中移除,也就是修改该块前后的指向关系。
-
如果找到的这块空间大于所申请的字节数,将用不了的部分赋值给
pxNewBlockLink
。 -
构造用不了的那部分块中的
BlockLink_t
结构体。 -
调用
prvInsertBlockIntoFreeList
将这个剩余的块作为空闲块插入到管理链表中。
如上图所示prvInsertBlockIntoFreeList
宏函数,用来插入空闲内存块到管理链表中,主要操作分为三步:
-
获取插入内存块的大小
xBlockSize
。 -
根据大小
xBlcokSize
迭代按照升序找到合适的插入位置pxIterator
。 -
将内存块
pxBlockToInsert
插入到pxIterator
之后。
如上图所示是最终分配效果,Block1
从管理链表中移除,供申请者使用,Blcok2
是用不了剩下的部分,重新链入到管理链表中。
释放内存:
如上图所示释放内存函数vPortFree
,主要操作有两步:
-
由于传入的
pv
是申请者使用的地址,所以向前偏移8个字节得到该内存块的BlcokLink_t
地址。 -
调用
pxBlockToInsert
将该内存块链入到管理链表中。
如上图所示,假设释放的是Block1
内存块,此时该内存块和原本的Block2
一起在管理链表中。
heap_2
管理的链表,支持内存的申请和释放。由于释放的内存块直接链入到管理链表,所以如果再次申请的内存比上次释放的大,那么释放的这块就无法再次利用,就会存在内存碎片。
所以
heap_2
适合用在频繁申请和释放固定大小内存的场景。
heap_4.c
初始化:
如上图所示是使用prvHeapInit
初始化完的示意图,heap_4
整体上和heap_2
的管理方式一样,也是通过链表和BlockLink_t
来管理空闲内存块,但是不一样的是:
-
链表尾
xEnd
位于整个堆空间的末尾,它并不是独立的一个变量,而是依附在空闲空间上。 -
链表尾
xEnd
中的xBlockSize = 0
,和heap_2
中的configADJUSTED_HEAP_SIZE
不一样。
分配内存:
如上图所示,分配内存时和heap_2
一样,将Block1
中管理链表中移除,将用不完的Block2
部分再次链入到链表中,返回值也是Block1
的对齐地址。
在将用不了的内存块重新链入到管理链表中时,也会调用prvInsertBlockIntoFreeList
函数,heap_4
和heap_2
的区别就在这个函数中。
释放内存:
释放内存时,和heap_2
一样,也会调用到prvInsertBlockIntoFreeList
函数来将释放的内存块链入到管理链表,来分析一下这个函数:
如上图所示prvInsertBlockIntoFreeList
的部分代码,这里进行的操作就是寻找合适的插入位置。
-
没有获取插入内存块的大小。
-
迭代时按照内存块的地址寻找,当插入内存块
pxBlockToInsert
的地址小于等于pxIterator->pxNextFreeBlock
时,将内存块插入到pxIterator
后面。
如上图所示prvInsertBlockIntoFreeList
中紧接前面部分的后续代码,进行的操作主要分为3步:
-
在寻找到插入位置
pxIterator
以后,先判断插入内存块pxBlockToInsert
和其前面的pxIterator
是否紧挨着(pxIterator
的地址 + 偏移量 ==pxBlockToInsert
的地址)。-
如果相等,说明
pxBlockToInsert
和pxIterator
紧挨着,可以进行合并,此时改变pxBlockToInsert
的大小xBlockSize
和pxNextFreeBlock
覆盖pxIterator
。
-
-
再判断判断插入内存块
pxBlockToInsert
和其后面的pxIterator->pxNextFreeBlock
是否紧挨着(pxBlockToInsert
的地址 + 偏移量 ==pxIterator->pxNextFreeBlock
的地址)。-
如果相等,说明
pxBlockToInsert
和pxIterator->pxNextFreeBlock
是紧挨着,可以进行合并,同样仅修改pxBlockToInsert
的BlockLink_t
覆盖pxIterator->pxNextFreeBlock
即可。
-
这里的偏移量就是块的大小
pxBlockToInsert->xBlockSize
。
-
如果找到插入位置
pxIterator
后,发现pxBlockToInsert
和它前面以及后面的内存块都不挨着,就无法实现合并,只能和heap_2
一样将pxBlockToInsert
插入到管理链表中。
如上图所示是在分配内存后将用不了的内存块插入管理链表和释放内存时将释放的内存块插入管理链表时,没有进行合并的结果示意图,此时和heap_2
没有区别。
如上图所示是将插入的内存块与管理链表中的其他内存块合并后的结果示意图。
管理链表中的内存块合并以后变大了,再次申请比上一次释放掉的更大内存块时,合并后的内存可以继续被利用。
克服了
heap_2
再次申请更大空间时会有内存碎片的问题。
heap_5.c
heap_5
和heap_4
机会一样,只是heap_5
支持多个不连续区域作为堆。
初始化:
如上图所示结构体HeapRegion_t
是用来描述用作堆的区域的,pucStartAddress
表示该区域的起始地址,xSizeInbytes
表示该区域的大小。
如上图所示,在使用heap_5
之前,需要先定义一个全局的HeapRegion_t
类型的数组array
,该数组中存放的就是多个连续的堆区,然后调用vPortDefineHeapRegions
来初始化所有的堆。
数组中的最后一项是
{NULL, 0}
,当发现某一个堆区的地址是NULL
的时候,说明就遍历到了数组中的最后一项。
如上图所示vPortDefineHeapRegions
函数,该函数内部主要进行了五步操作:
-
创建
while
循环来遍历所有不连续的堆区域。 -
求每个堆区域的对齐地址和对齐大小。
-
处理第一个堆区时,将其链入到
xStart
链表头。 -
在每个堆区域末尾构造
BlockLink_t
。 -
将所有堆区域首尾相连在一起。
如上图所示是将两个不连续堆区初始化后首尾相连在一起的结果示意图。
其他操作:
在初始化以后,就可以将这些不连续的堆看成是一个堆,其他操作完全按照heap_4
来就可以。
总结
对于FreeRTOS的链表操作,一定要理解透彻,因为后面的任务TCB等结构体完全依赖于链表操作,尤其注意尾部插入时不能破坏原本链表的公平性。
对于多种堆的管理方式,要知道它们适用的场合和大致的原理,根据适用场景适用合适的管理方式。