2 Heap Memory Management
补充知识
静态系统&动态系统:
- 静态系统:采用静态内存分配的方式
- 动态系统:采用动态内存分配的方式
静态内存分配&动态内存分配:
- 静态内存分配:程序启动时,就会分配固定大小的内存。内存大小固定,且不能改变。因此内存需求,必须事前就确定。(也叫简单内存分配[simple memory allocation],使用的最多,对于应用也比较简单)
- 有的地方也说 compile-time 分配内存,即编译期间就可以确定程序需要多少内存,某个变量的内存位置就可以确定(此处涉及到编译原理,可以加入学习计划,学一下)
- 特征
- 变量内存被永久分配
- 程序开始执行之前分配就已经结束
- 使用栈来实现静态内存分配
- 效率低(【我的理解】应该和内存不可重复使用说的是一回事,利用内存效率太低)
- 不能内存复用(【我的理解】程序执行期间有些内存没有使用时,也一直在占用,影响了程序复用)
- 【我的理解】内存固定分配,肯定不会访问到不属于本程序的内存,对于系统和其他程序来说其数据更安全,有点像是隔离的状态。
- 动态内存分配
2.1 介绍
2.1.1 动态内存分配及其与 freertos 的相关性
内核对象,例如 tasks、queues、semaphores 和 event groups 等。为了让 FreeRTOS 方便使用,这些内核对象并非编译器静态内存分配,而是运行时动态内存分配。每当内核对象创建,FreeRTOS 就为其分配 RAM 空间,每当内核对象被销毁,就释放其占用的 RAM。这样做减少了设计需要花费的精力,简化了 API,最小化了占用 RAM 的空间。
动态内存分配是 C 语言编程概念,不是 FreeRTOS 及其他多任务系统特定的概念。这里因为内核对象是动态分配的,所以和 FreeRTOS 相关。然而由通用编译器提供的动态内存分配,对实时应用来说不总是合适的。FreeRTOS v9.0.0 开始内核对象可以编译器静态分配也可以运行时动态分配。
动态内存分配不合适或不恰当的原因:
- 在一些小的嵌入式系统不总是可用的
- 他们的实现相对较大,占用代码空间
- 很少是线程安全的
- 不确定性,每次需要执行的时间可能都不同
- 收内存碎片的影响
- 使链接配置复杂(不太理解)
- 如果堆内存空间被允许增长到其他变量占用的空间,可能会让 debug 困难
2.1.2 动态内存分配的选项
早期的 FreeRTOS 使用内存池分配内存, 用编译期预先分配的不同大小内存块组成内存池。尽管他是实时系统中常用的方案,但是他被证明是许多 Support Requests 的来源(Support Request is a request by the Customer to the Service Provider to address Defects or general questions about the Service. 【我的理解】为此需要向系统添加很多支持或者处理意料之外问题的接口或方案),主要是因为他不能足够有效的利用 RAM 以使其适用于很小的嵌入式系统,因此该方案 dropped。
FreeRTOS 把内存分配视为 portable 层(与核心代码部分相反的部分,即移植层,根据不同的工具链、CPU 架构、系统需求进行选择配置,以完成适配工作)。这也承认了不同的嵌入式系统有不同的动态内存分配和时间的需求,因此一个动态内存分配算法,不可能适合一系列应用。从 core code base 中移除内存分配,也允许了其他人在合适的时候使用他们自己的实现。
FreeRTOS 不使用 malloc 和 free。使用 pvPortMalloc 和 vPortFree,他们的函数原型分别相同。且这两个函数是 public 的,应用程序的代码也可以调用。
FreeRTOS 提供了五种动态内存分配的实现,放在 FreeRTOS/Source/portable/MemMang 目录下。
2.2 内存分配方案的例子
2.2.1 Heap_1
小型专用嵌入式系统在调度器运行之前通常只会创建 tasks 和其他内核对象。在这种情况下,在程序执行任何实时功能之前,内存仅由内核动态分配,并且已分配的内存会保持分配状态,直到程序结束。这意味着不需要考虑复杂的内存分配问题,例如确定性和碎片化,相反只需要考虑例如代码量和简洁性等属性。
Heap_1.c 实现了非常基础的 pvPortMalloc,没有实现 vPortFree,从来不销毁任务、或者其他内核对象的应用可以选择使用 Heap_1。
一些禁止动态内存分配的商业关键和安全关键的系统也可能使用 Heap1。由于不确定性、内存碎片和分配失败带来的不确定性,关键系统经常禁止动态内存分配。但是 Heap1 总是确定的,不会使内存碎片化。
当 pvPortMalloc 调用时,heap1 分配算法细分一个数组为更小的块,这个数组叫 FreeRTOS 的堆。数组的大小(字节为单位)可以通过 FreRTOSConfig.h 中的 configTOTAL_HEAP_SIZE 来配置。每个创建的任务需要从堆中分配任务控制块(Task Control Block)和栈(Stack)。
heap1 pvPortMalloc:
- 检查申请的内存大小是否对齐,对齐后是否溢出
- 挂起所有任务
- 内存申请
- 有没有对堆起始地址进行过对齐操作,没有则对齐
- 检查申请内存是否合理,是否有足够空间,以及是否发生溢出
- 分配内存,修改可用偏移
- 恢复所有任务
- 返回申请内存空间的地址
void * pvPortMalloc( size_t xWantedSize )
{
// pvReturn 要返回的分配的内存地址
void * pvReturn = NULL;
// pucAlignedHeap 内存对齐后的堆起始地址
static uint8_t * pucAlignedHeap = NULL;
// 内存对齐单位不为 1,表示需要进行内存对齐操作
#if ( portBYTE_ALIGNMENT != 1 )
{
// 如果想要的内存不是内存对齐单位的整数倍,就对申请的空间大小进行对齐操作
if( xWantedSize & portBYTE_ALIGNMENT_MASK )
{
// 溢出检测,申请字节数 + 对齐为相关整数倍需要增加的字节数 是否溢出
if ( (xWantedSize + ( portBYTE_ALIGNMENT - ( xWantedSize & portBYTE_ALIGNMENT_MASK ) )) > xWantedSize )
{
xWantedSize += ( portBYTE_ALIGNMENT - ( xWantedSize & portBYTE_ALIGNMENT_MASK ) );
}
else
{
// 发生溢出,申请内存失败
xWantedSize = 0;
}
}
}
#endif
// 挂起所有任务
vTaskSuspendAll();
{
// 如果没有对堆起始地址进行过对齐操作,才会执行
if( pucAlignedHeap == NULL )
{
// 堆起始地址对齐,指针进行与运算,对齐为相关单位的整数倍。
// [0 - portBYTE_ALIGNMENT) 是为了内存对齐预留的空间。
// 内存对齐只会向低地址移动,原本预留内存之后的起始位置,
// 可能不是内存对齐单位的整数倍,
// 但是他向前移动的字节数一定小于 portBYTE_ALIGNMENT
pucAlignedHeap = ( uint8_t * ) ( ( ( portPOINTER_SIZE_TYPE ) & ucHeap[ portBYTE_ALIGNMENT - 1 ] ) & ( ~( ( portPOINTER_SIZE_TYPE ) portBYTE_ALIGNMENT_MASK ) ) );
}
// 检查申请内存是否合理,是否有足够空间,以及是否发生溢出
if( ( xWantedSize > 0 ) &&
( ( xNextFreeByte + xWantedSize ) < configADJUSTED_HEAP_SIZE ) &&
( ( xNextFreeByte + xWantedSize ) > xNextFreeByte ) )
{
// 计算要返回的地址,即起始地址 + 可用偏移
pvReturn = pucAlignedHeap + xNextFreeByte;
// 修改可用偏移
xNextFreeByte += xWantedSize;
}
// 没找到定义,猜测是进行记录工作
traceMALLOC( pvReturn, xWantedSize );
}
// 恢复所有任务
( void ) xTaskResumeAll();
// 如果设置了内存分配失败的回调函数,就执行
#if ( configUSE_MALLOC_FAILED_HOOK == 1 )
{
if( pvReturn == NULL )
{
extern void vApplicationMallocFailedHook( void );
vApplicationMallocFailedHook();
}
}
#endif
return pvReturn;
}
2.2.2 Heap_2
Heap2 保留在 FreeRTOS 中,主要是为了向后兼容。可以考虑 Heap4 替代,Heap4 实现了加强的功能。Heap2 仍然通过 configTOTAL_HEAP_SIZE 配置的数组进行细分来工作。其使用 best fit 算法来分配内存,和 heap1 不同,它允许内存被释放。Best fit 算法确保 pvPortMalloc 使用可用的大小最接近申请内存的空闲内存。例如:
- 有三个可用空闲内存块,分别是 5、25、100 字节
- 现在调用 pvPortMalloc 申请 20 字节
可以容纳申请字节数量的最小的可用 RAM 块是 25 字节。因此 pvPortMalloc 把 25 字节分为 20 字节和 5 字节两块。5 字节的内存块,再之后的调用中仍然可用。
与 heap4 相比,heap2 不会把相邻的空闲内存块合并,这使得 heap2 更容易内存碎片化。如果内存被分配和之后释放都是相同大小,内存碎片化也不是个问题。(【无法理解】我认为无非是五十步和一百步的问题)heap2 适合重复创建和销毁任务的应用,前提是分配给创建任务的栈大小相同。
- A:分配了三个任务,每个任务创建调用两次内存分配,分别分配给 TCB 和 Stack
- B:销毁了一个任务,因为一个任务分配两次内存,因此销毁后留下了两个内存块
- C:创建新任务的时候,由于分配给新任务的 TCB、Stack 大小和之前的任务相同,因此又把之前释放的内存块又利用上了。(TCB 结构固定,因此内存大小相同;Stack 估计是规定吧)
Heap2 是不确定的,但比大多数 malloc 和 free 的实现都要快。Heap2 初始化完成后的结构:(调整后的堆起始地址,指的是内存对齐后)
heap2 pvPortMalloc:
- 挂起所有任务
- 分配内存
- 检测(通过局部静态变量)堆是否完成了初始化,没有就初始化
- 申请内存合法检测、申请内存 + 对齐后节点信息大小是否溢出。检查申请总内存(申请内存 + 对齐后节点信息)对齐后是否溢出,没问题就完成申请总内存对齐。只要有地方不满足申请内存置为 0(申请内存大于 0 才是合法的申请)。
- 检查对齐后申请总内存是否合法,剩余空闲内存是否充足。如果没问题,开始正式申请内存
- 从前向后循环查找,找到空间足够的节点,并且不是 xEnd 节点
- 返回的内存地址为:当前节点地址 + 内存对齐后的节点信息所占字节数
- 前一个节点指向当前节点的下一个几点
- 如果当前节点剩余空间大于指定的最小节点大小,则进行拆分节点操作(否则就不拆分,拆分太小可能也用不到,就直接整块分配出去)
- 新节点地址 = 当前节点地址 + 申请总内存
- 新节点大小 = 当前节点大小 - 申请总内存大小
- 当前节点大小更改为申请总内存
- 从前向后遍历插入新的节点(以维持整个链表有序)
- 剩余空闲空间,减去当前节点大小
- trace
- 恢复所有任务
- 如果内存分配失败,且有相应回调(HOOK\钩子)函数就调用
- 返回 pvReturn
【一点点思考】内存分配,这样的重操作,一般不会频繁执行,如果非要考虑他的时间复杂度,可以采用二分 + 多级链表或者红黑树、AVL 树,由于内存分配只会在程序启动的时候调用(静态系统,为程序分配固定内存大小),因此和提升的效率相比,几乎每个节点都需要付出一定的内存空间的代价,浪费了内存空间且增加了代码复杂度,有些得不偿失。尤其是 FreeRTOS 作为实时内核用在一些嵌入式系统上,内存是相对宝贵一点的。
heap2 vPortFree:
- 回收内存的指针合法性检验(非空)
- 回收内存
- 指针向前退 heapSTRUCT_SIZE(对齐后的节点信息的所占字节数)
- 强转为节点类型指针
- 挂起所有任务
- 遍历空闲节点链表,插入该节点,空闲字节数 += 该节点大小,trace
- 恢复所有任务
2.2.3 Heap_3
使用标准库的 malloc 和 free 函数。堆的大小由链接器配置定义。 configTOTAL_HEAP_SIZE 没有作用。Heap3 通过暂时挂起任务调度器,来实现线程安全。
2.2.4 Heap_4
与 heap1、heap2 相同的是,heap4 仍然是把一个数组细分为更小的块,来分配内存。heap4 使用 first fit 算法,并且会把相邻的两块内存合并为一个更大的内存块,减少了碎片化的风险。
First fit 算法使用第一个足够大的空闲内存块来分配内存,例如:
- 三个空闲内存块,按在内存中的顺序依次是 5,200,100 字节
- pvPortMalloc 被调用来申请 20 字节
申请的字节数可以放进去的第一个空闲块是 200 字节。pvPortMalloc 则把 200 字节的内存块拆分成 20 + 180,然后返回 20 字节的内存块的地址。
heap4 把相邻的内存块合为一个大的内存块,减少了碎片化的风险,适合反复分配和释放不同大小内存的应用。
- A:有三个任务,以及分配好了空间
- B:绿色的任务被销毁,其 Stack 和 TCB 被释放,合并为一个大的空闲内存块
- C:FreeRTOS 创建内核对象 Queue。分配内存时使用 first fit 找到第一个可用的空间是先前被销毁的任务释放的空间
- D:用户直接调用 pvPortMalloc 而不是通过 FreeRTOS API
- E:内核对象 Queue 销毁
- F:用户申请的内存释放,之后和两边的空闲内存合并为一个大块的内存
heap4 初始化:
(Tips:[1] 数组蓝色空余为内存对齐后空余的位置。 [2] pxEnd 指向的节点不一定占满,可能有空余位置,空余位置来自:一,Block 节点内存总大小进行对齐后大于等于节点内字段总字节数;二,pxEnd 指向的位置是从数组的末尾向前找 AlignedBlockSize,找到的地址不一定是内存对齐的,因此需要对起始位置再进行一次内存对齐操作,即整个节点向前移动一些。)
heap4 pvPortMalloc:
- 挂起所有任务
- 如果未初始化堆,则初始化
- 开始分配
- 检查申请的字节数是否太大,以至于最高位为 1(heap4 用最高位表示某个节点是否分配出去,以及分配则为 1 否则为 0。申请的字节数和 xBlockSize 都是 size_t,size_t 定义为 unsigned long long 为 64 位,1T 为 $ 2^{40} $ Bytes,所以 size_t 可表示的内存范围很大,正常情况下,这里应该不是 1)。如果最高位为 1,显然输入数据是不合理的,因为场景中应该没有这么大的内存需求,因此把申请内存置为 0。
- 检查申请内存是否合法(大于 0 ),加上内存块的节点信息所占用的内存是否产生溢出。否,则申请总内存 = 申请内存 + 节点信息字节数
- 总大小是否内存对齐,未对齐,则检查加上对齐字节后是否溢出,否则对齐,溢出则置为 0。申请总内存 = 申请内存 + 节点信息字节数 + 字节对齐
- 检查申请总内存是否合法(大于 0 且小于剩余可用内存),合法则开始申请内存
- 遍历,找到第一个可以 fit into 的节点,并且不是尾节点(意味无法找到)
- 返回地址 = 内存块起始地址 + 对齐后节点信息所占内存大小
- 内存块剩余内存大小大于一个最小值,则应该拆分节点
- 新的可用节点 = 当前节点起始地址 + 申请总内存大小
- Assert 检查一下新的可用节点的起始地址是否对齐
- 新节点大小 = 总节点大小 - 分配出去的
- 分出去的节点大小 = 申请总内存
- 把拆分后的可用节点插入到链表中(这里用了插入函数,从头遍历,插入。其实这里是可以优化的地方,直接插入到当前位置不就好了。我猜测这里写一个插入函数其实是为了对插入、释放提供统一的操作,减少测试等其他方面的考虑,但是对于很长的链表来说,这里是性能瓶颈)
- 可用字节数 = 总可用字节数 - 分配出去的字节数
- xMinimumEverFreeBytesRemaining,统计一下运行过程中可用的最小字节数。(估计是用于分析或者衡量内存使用率之类的吧)xNumberOfSuccessfulAllocations 成功分配内存次数。
- 分配出去的内存块最高位置为 1,表示分配出去,next 指针设为 NULL
- 恢复所有任务
- 钩子函数
- Assert 检查一下返回的地址是不是对齐的,然后返回
heap4 vPortFree:
- 指针合法性检验(非空)
- 计算出节点起始地址(因为返回的是节点的可用地址)
- Assert 检查节点是否是真的分配出去了,否则(mtCOVERAGE_TEST_MARKER 猜测是不是为测试预留的位置?)
- 开始回收
- 最高位置 0
- 挂起所有任务
- 可用大小 += 节点大小
- traceFree(猜测是记录一下?)
- 调用插入函数,插入(会做合并)
- xNumberOfSuccessfulFrees (猜测是不是和上面的成功分配一起做信息统计)
- 恢复所有任务
heap4 vPortGetHeapStats:返回了上述过程中一些统计信息
heap4 同样不是确定的,但是比大多数 malloc 和 free 的实现都快。
为 heap 设置起始地址
使用场景:有时候应用的作者需要 heap 中使用的数组放在特定位置。例如,FreeRTOS task 使用的栈,可能需要 heap 数组放在更快的内部内存,而不是慢的外部内存。
默认情况下,使用的 heap 数组是 heap 源文件中定义的,他的地址一般由链接器自动设置。通过设置 configAPPLICATION_ALLOCATED_HEAP 为 1,则 heap 数组必须由应用作者定义,此时就可以设置其起始地址。
语法要求:必须是 uint8_t 数组,名字必须为 ucHeap,并且其长度由 TOTAL_HEAP_SIZE 配置
// GCC
uint8_t ucHeap[ configTOTAL_HEAP_SIZE ] __attribute__ ( ( section( ".my_heap" ) ) );
// IAR
uint8_t ucHeap[ configTOTAL_HEAP_SIZE ] @ 0x20000000;
2.2.5 Heap_5
heap5 的算法和 heap4 等同。但 heap4 必须使用一个连续分配的静态数组,heap5 则不受此限制。heap5 可以从多个离散的内存空间分配内存。当运行 FreeRTOS 的系统提供的 RAM 在系统的内存映射中不是以一个连续的内存块出现时,heap5 非常有用。
heap5 是所给的 heap 源文件中,唯一一个需要初始化才能分配空间的分配器。heap5 必须在任何内核对象创建之前使用 vPortDefineHeapRegions 进行初始化。
The vPortDefineHeapRegions API Function
该函数用来确定每个分散的内存地址的起始地址和大小并组成供 heap5 使用的内存。
每个分散的内存通过数据类型 HeapRegion_t 描述,所有可用内存的描述作为一个数组传入给该函数。
typedef struct HeapRegion {
uint8_t* pucStartAddress;
size_t xSizeInBytes;
} HeapRegion_t;
void vPortDefineHeapRegions( const HeapRegion_t * const pxHeapRegions );
pxHeapRegions:指向 HeapRegion_t 数组起始元素的指针。数组中的元素必须按起始地址从小到大排序。数组的结尾元素,其起始地址成员为 NULL。
// HeapRegion_t 数组的构建的例子 - 0
/* Define the start address and size of the three RAM regions. */
#define RAM1_START_ADDRESS ( ( uint8_t * ) 0x00010000 )
#define RAM1_SIZE ( 65 * 1024 )
#define RAM2_START_ADDRESS ( ( uint8_t * ) 0x00020000 )
#define RAM2_SIZE ( 32 * 1024 )
#define RAM3_START_ADDRESS ( ( uint8_t * ) 0x00030000 )
#define RAM3_SIZE ( 32 * 1024 )
/* Create an array of HeapRegion_t definitions,
with an index for each of the three RAM regions,
and terminating the array with a NULL address.
The HeapRegion_t structures must appear in start address order,
with the structure that contains the lowest start address appearing first.
*/
const HeapRegion_t xHeapRegions[] =
{
{ RAM1_START_ADDRESS, RAM1_SIZE },
{ RAM2_START_ADDRESS, RAM2_SIZE },
{ RAM3_START_ADDRESS, RAM3_SIZE },
{ NULL, 0 } /* Marks the end of the array. */
};
int main( void )
{
/* Initialize heap_5. */
vPortDefineHeapRegions( xHeapRegions );
/* Add application code here. */
}
上述代码正确的描述了一个例子,但不是可用的,因为他把所有的可用内存分配给了堆,没有为一些变量留下空间。
当一个工程构建时,构建过程的链接阶段会分配一些 RAM 地址给每个变量。链接器使用的 RAM 内存通常通过链接器配置文件描述,例如一个链接脚本。Figure 8 B 假设链接脚本的一些信息只需要放在 RAM1 中,因此如图 RAM1 中 0x01nnnn - 0x01FFFF 是 RAM1 可用的内存,可以分配给 heap。
如果用之前代码配置 HeapRegion_t 数组,分配给 heap 的空间将会和保存的变量有重合。为了避免这种情况,可以使用具体的详细地址 0x1nnnn,但是通常不推荐这么做,因为:
- 地址可能不好计算
- 在之后的构建可能会有变化,这个值可能需要经常更新
- 构建工具不知道,因此也不会给应用作者发出警告
一个更方便和可维护的方案。假设所有的变量会放到 RAM1 中,那么在 RAM1 中定义一个 ucHeap 数组,该数组也是一个普通变量,因此链接器也会在 RAM1 中为其分配内存。那么就可以把 ucHeap 交给内存管理模块,ucHeap 最大可以一直取到把 RAM1 用完。
/* Define the start address and size of
the two RAM regions not used by the linker. */
#define RAM2_START_ADDRESS ( ( uint8_t * ) 0x00020000 )
#define RAM2_SIZE ( 32 * 1024 )
#define RAM3_START_ADDRESS ( ( uint8_t * ) 0x00030000 )
#define RAM3_SIZE ( 32 * 1024 )
/* Declare an array that will be part of
the heap used by heap_5. The array will be placed in RAM1 by the linker. */
#define RAM1_HEAP_SIZE ( 30 * 1024 )
static uint8_t ucHeap[ RAM1_HEAP_SIZE ];
/* Create an array of HeapRegion_t definitions.
Whereas in Listing 6 the first entry described all of RAM1,
so heap_5 will have used all of RAM1,
this time the first entry only describes the ucHeap array,
so heap_5 will only use the part of RAM1 that
contains the ucHeap array.
The HeapRegion_t structures must still appear in start address order,
with the structure that
contains the lowest start address appearing first. */
const HeapRegion_t xHeapRegions[] =
{
{ ucHeap, RAM1_HEAP_SIZE },
{ RAM2_START_ADDRESS, RAM2_SIZE },
{ RAM3_START_ADDRESS, RAM3_SIZE },
{ NULL, 0 } /* Marks the end of the array. */
};
该方案的优点:
- 不必直接使用绝对起始地址
- RAM1 中交给内存管理的地址,由链接器自动设置,因此总是正确的,即使 RAM1 使用的变量所占内存有所变化
- 由链接器放到 RAM1 中的数据不会和分配给 heap 的内存发生重叠
- 如果 ucHeap 太大则程序不会链接(【我的理解】这里应该是说不会链接成功吧)
vPortDefineHeapRegions 初始化:
2.3 堆相关的工具函数(Utility Function)
xPortGetFreeHeapSize
返回调用时 heap 中可用的字节数。可以用来优化堆的大小,例如,所有的内核对象分配完之后,还剩 2000 字节,那么堆的大小可以减少 2000。heap3 不可用。
xPortGetMinmumEverFreeHeapSize
从 FreeRTOS 开始执行,剩余可用内存的最小值,指示了应用程序接近耗尽 heap 空间的程度。只有 heap4、heap5 可用。
Malloc Failed Hook Function(分配失败回调函数)
pvPortMalloc 可以被应用调用,也可以被 FreeRTOS 源码文件创建内核对象的适合调用。
和标准库的 malloc 相同,当一个请求的字节数大小的内存块不存在时,会返回 NULL。如果是因为应用程序创建一个内核对象,而发生的调用,那么内核对象不会被创建。
如果 configUSE_MALLOC_FAILED_HOOK 被设置为 1,那么应用必须提供如下函数原型的代码。
void vApplicationMallocFailedHook( void );