FreeRTOS的堆内存管理
- 简介
- 动态内存分配及其与 FreeRTOS 的相关性
- 动态内存分配选项
- 内存分配方案
- Heap_1
- heap_2
- Heap_3
- Heap_4
- 设置heap_4的起始地址
- Heap_5
- vPortDefineHeapRegions()
- 堆相关的函数
- xPortGetFreeHeapSize
- xPortGetMinimumEverFreeHeapSize
- Malloc调用失败的Hook函数
这篇文章先说原理,下一遍文章说代码的具体实现
简介
从FreeRTOS V9.0.0开始,FreeRTOS应用程序可以完全静态分配,无序包含堆内存管理器
动态内存分配及其与 FreeRTOS 的相关性
从FreeRTOS V9.0.0开始,内核对象可以在编译时静态分配,也可以在运行时动态分配,内核对象有任务、队列、信号量和事件组。为了使FreeRTOS尽可能容易使用,这些内核对象不是在编译时静态分配,而是在运行时动态分配。 FreeRTOS会在每次创建内核对象时分配RAM,并在每次删除内核对象时释放RAM。该策略减少了设计和规划工作,简化了API,并最大限度减少了RAM占用空间。
动态内存分配是一个C编程概念,而不是特定于FreeRTOS或多任务处理的概念。它与FreeRTOS相关,因为内核对象是动态分配的,通用编译器提供的动态内存分配方案不总是适合应用程序。可以使用C标准库malloc()和free()分配内存,但由于一些原因,它们不合适:
- 它们在小型嵌入式系统上并不总是可用。
- 它们的实现可能相对较大,占用宝贵的代码空间。
- 它们很少是线程安全的。
- 它们不是确定性的; 执行函数所花费的时间会因调用而异。
- 它们可能会受到碎片化 1 的影响。
- 它们会使链接器配置复杂化。
- 如果允许堆空间增长为其他变量使用的内存,它们可能是难以调试错误的根源。
动态内存分配选项
FreeRTOS现在将内存分配视为可移植层的一部分(而不是核心代码库的一部分)。这是因为不同的嵌入式系统具有不同的动态内存分配和时序要求,从核心代码库中删除动态内存分配使应用程序编写者能够在适当的时候提供他们自己的特定实现。
当FreeRTOS需要RAM时,它不会调用malloc(),而是调用pvPortMalloc(),当RAM释放内存时,内核不会调用free(),而是调用vPortFree()。pvPortMalloc()与标准C库malloc函数原型相同,vPortFree()与标准C库free()函数原型相同。
pvPortMalloc()和vPortFree()是公共函数,因此也可以从应用程序代码中调用。
FreeRTOS提供了pvPortMalloc()和vPortFree()的五个示例实现,FreeRTOS应用程序可以使用示例实现之一,也可以提供自己的实现。这五个实例分别定义在heap_1.c、heap_2.c、heap_3.c、heap_4.c、heap_5.c源文件中,均位于FreeRTOS/Source/portable/MemMang目录中。
内存分配方案
Heap_1
小型专用嵌入式系统通常只在调度程序启动前,只创建任务和其他内核对象,在这种情况下,内存仅在应用程序开始执行任何实时功能之前由内核动态分配,并且内存在应用程序的生命周期内保持分配状态。 这意味着所选择的分配方案不必考虑任何更复杂的内存分配问题,例如确定性和碎片,而可以只考虑代码大小和简单性等属性。 也就是说,只分配内存,其他像把两个相邻的空闲块整合到一块,这个是不管的。
Heap_1.c实现了一个非常基本的pvPortMalloc()版本,并没有实现vPortFree(),所以从不删除任务或其他内核对象的应用程序可以使用heap_1。当调用pvPortMalloc()时,heap_1分配方案将一个堆细分为更小的块,堆的总大小由FreeRTOSConfig.h中的宏configTOTAL_HEAP_SIZE设置,但是为字节。
每个创建的任务都要两个内存块,一个是任务控制块(TCB),另一个堆栈,这两个都从堆分配。下图演示来了heap_1如何在创建任务时细分堆
- A 显示创建任何任务之前的堆——整个堆都是空闲的。
- B 显示创建一项任务后的堆。
- C 显示创建三个任务后的堆。
heap_1适用于那些一旦创建好任务、信号量、队列和事件组就再也不会删除的应用。一些禁止使用动态内存分配的商业关键和安全关键系统也有可能使用 heap_1。由于与非确定性、内存碎片和分配失败相关的不确定性,关键系统通常禁止动态内存分配——但 Heap_1 始终是确定性的,不能对内存进行碎片化。heap_1代码实现和内存分配过程都很简单,内存是从一个静态数组(堆)分配到的,也就是适合那些不需要动态内存分配的应用
heap_2
为了向后兼容,FreeRTOS保留了Heap_21,但不建议使用它,使用heap_4,因为heap_4提供了增强的功能。
heap_2.c的大小也通过configTOTAL_HEAP_SIZE确定,它使用最佳拟合算法来分配内存,并且与heap_1不同,它允许释放内存。同样数组(堆)是静态声明的,因此会消耗大量的RAM,最佳拟合算法确保PVPortMalloc()使用大小与请求的字节数最接近的空闲内存块,比如,考虑以下场景:堆包含三个空闲内存块,分为为5字节、25字节和100字节,调用pvPortMalloc来请求20字节的RAM。可以容纳请求的字节数最小RAM空闲块是25字节,因此pvPortMalloc()将25字节块分为一个20字节块和一个5字节块,然后返回指向20字节的块的指针,新的5字节块依然可以被pvPortMalloc调用。
与heap_4不同,heap_2不会将相邻的空闲块合并为一个更大的块,因此容易发生碎片。 Heap_2适用于重复创建和删除任务的应用程序,前提是分配给创建的任务和堆栈大小不变。
下图演示了在创建、删除和再次创建任务时最佳拟合算法的工作原理:
- A 显示创建三个任务后的数组。 一个大的空闲块保留在阵列的顶部。
- B 显示其中一个任务被删除后的数组。 数组顶部的大空闲块仍然存在。 现在还有两个较小的空闲块,它们以前分配给已删除任务的 TCB 和堆栈。
- C 显示创建另一个任务后的情况。 创建任务导致对 pvPortMalloc() 的两次调用,一次分配 新的 TCB,一次分配任务堆栈。 任务是使用 xTaskCreate() API 函数创建的。 对 pvPortMalloc() 的调用发生在 xTaskCreate() 内部。
Heap_2 不是确定性的,但比 malloc() 和 free() 的大多数标准库实现要快。
Heap_3
Heap_3使用标准库malloc()和free()函数,因此堆的大小由链接器配置定义,configTOTAL_HEAP_SIZE设置对其没有影响,Heap_3通过暂时挂起FreeRTOS调度程序使malloc()和free()线程安全。
Heap_4
与heap_1和heap_2一样,heap_4的工作原理是将数组细分为更小的块,数组是静态分配的,并有configTOTAL_HEAP_SIZE确定尺寸,heap_4使用first fit算法来分配内存,与heap_2冉,heap_4将合并相邻的空闲内存块,然后组合成一个更大的块,从而最大限度的降低内存碎片的风险。
the first fit算法确保pvPortMalloc()使用第一个能够容纳请求字节数的空闲块,例如,考虑以下场景:
堆包含三个空闲内存块,按照它们在数组中出现的顺序,分别为5字节、200字节和100字节,调用pvPortMalloc()来请求20字节的RAM,第一个适合请求的字节数的空闲块是200字节,因此pvPortMalloc在返回指针之前将200字节拆分为一个20字节的块和一个180字节的块,返回的指针指向20字节的块,新的180字节块仍可以被pvPortMalloc调用。
Heap_4将合并相邻的空闲块组合成一个更大的块,最大限度的降低碎片的风险,并使其适用于重复分配和释放不同大小的RAM块的应用程序。
下图演示了具有内存合并的 heap_4 首次拟合算法的工作原理,如内存被分配和释放:
- A 显示创建三个任务后的数组。一个大的空闲块保留在阵列的顶部。
- B 显示其中一个任务被删除后的数组。数组顶部的大空闲块仍然存在。还有一个空闲块,其中先前分配了已删除任务的 TCB 和堆栈。请注意,与演示 heap_2 时不同,删除 TCB 时释放的内存和删除堆栈时释放的内存不会保留为两个单独的空闲块,而是组合在一起以创建更大的单个空闲块。
- C 显示了创建 FreeRTOS 队列后的情况。队列是使用 xQueueCreate() API 函数创建的。 xQueueCreate() 调用 pvPortMalloc() 来分配队列使用的 RAM。由于 heap_4 使用the first fit算法,pvPortMalloc() 将从第一个足够大以容纳队列的空闲 RAM 块分配 RAM,使用的是删除任务时释放的 RAM。然而,队列不会消耗空闲块中的所有 RAM,因此该块被分成两部分,未使用的部分仍然可用于未来对 pvPortMalloc() 的调用。
- D 显示了直接从应用程序代码调用 pvPortMalloc() 后的情况,而不是通过调用 FreeRTOS API 函数间接调用的情况。用户分配的块足够小,可以放入第一个空闲块中,该块是分配给队列的内存和分配给后续 TCB 的内存之间的块。
删除任务时释放的内存现在已拆分为三个单独的块;第一个块保存队列,第二个块保存用户分配的内存,第三个块保持空闲。 - E显示队列被删除后的情况,自动释放已分配给删除队列的内存。现在用户分配块的两侧都有空闲内存。
- F 显示用户分配的内存也被释放后的情况。用户分配块已使用的内存已与任一侧的空闲内存组合以创建更大的单个空闲块。
Heap_4 不是确定性的,但比 malloc() 和 free() 的大多数标准库实现要快。
设置heap_4的起始地址
有时,应用程序编写者需要将 heap_4 使用的数组放置在特定的内存地址。 例如,FreeRTOS 任务使用的堆栈是从堆分配的,因此可能有必要确保堆位于快速内部内存中,而不是位于慢速外部内存中。
默认情况下,heap_4 使用的数组在 heap_4.c 源文件中声明,其起始地址由链接器自动设置。 但是,如果 FreeRTOSConfig.h 中的 configAPPLICATION_ALLOCATED_HEAP 设置为 1,则该数组必须改为由使用 FreeRTOS 的应用程序声明。 如果数组被声明为应用程序的一部分,那么应用程序的编写者可以设置它的起始地址。
如果 configAPPLICATION_ALLOCATED_HEAP 在 FreeRTOSConfig.h 中设置为 1,则必须在应用程序的源文件之一中声明一个名为 ucHeap 并由 configTOTAL_HEAP_SIZE 设置大小的 uint8_t 数组。
Heap_5
heap_5用于分配和释放内存的算法和heap_4相同,不同的是heap_5不限于从单个静态声明的数组分配内存,heap_5可以从多个独立的内存空间分配内存,当运行 FreeRTOS 的系统提供的 RAM 没有在系统内存映射中显示为单个连续(没有空间)块时,Heap_5 很有用。
在撰写本文时,heap_5 是唯一提供的内存分配方案,必须在调用 pvPortMalloc() 之前显式初始化。 Heap_5 使用 vPortDefineHeapRegions() API 函数初始化。 使用 heap_5 时,必须在创建任何内核对象(任务、队列、信号量等)之前调用 vPortDefineHeapRegions()。
vPortDefineHeapRegions()
vPortDefineHeapRegions用于指定每个单独的内存区域的起始地址和大小,这些区域构成heap_5使用的总内存。
void vPortDefineHeapRegions( const HeapRegion_t * const pxHeapRegions );
每个单独的内存区域由HeapRegion_t类型的结构体描述**,所有可用内存区域的描述**作为 HeapRegion_t 结构数组传递到 vPortDefineHeapRegions()。
typedef struct HeapRegion
{
/* The start address of a block of memory that will be part of the heap.*/
uint8_t *pucStartAddress;
/* The size of the block of memory in bytes. */
size_t xSizeInBytes;
} HeapRegion_t;
pxHeapRegions 指向 HeapRegion_t 结构数组开头的指针。 数组中的每个结构都描述了内存的起始地址和长度,数组中的 HeapRegion_t 结构体必须按起始地址排序; 描述起始地址最低的内存区域的 HeapRegion_t 结构必须是数组中的第一个结构,描述起始地址最高的内存区域HeapRegion_t 结构必须是数组中的最后一个结构。数组的末尾由 HeapRegion_t 结构标记,该结构的 pucStartAddress 成员设置为 NULL。
例如,考虑下图中所示的假设内存映射,其中包含三个独立的RAM块:RAM1、RAM2和RAM3,假设可执行代码放置在只读寄存器中,没有显示。
下面的代码描述了整个RAM的三个块
/* 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 分配给堆,没有空闲 RAM 可供其他变量使用。
构建项目时,构建过程的链接阶段会为每个变量分配一个 RAM 地址。 可供链接器使用的 RAM 通常由链接器配置文件(例如链接描述文件)描述。假设链接描述文件包含关于 RAM1 的信息,但不包含关于 RAM2 或 RAM3 的信息。 因此,链接器将变量放置在 RAM1 中,只留下地址 0x0001nnnn 以上的 RAM1 部分可供 heap_5 使用。 0x0001nnnn 的实际值将取决于所链接的应用程序中包含的所有变量的组合大小。 链接器让所有 RAM2 和所有 RAM3 未使用,留下整个 RAM2 和整个 RAM3 可供 heap_5 使用。
如果使用上述 中所示的代码,分配给地址 0x0001nnnn 下的 heap_5 的 RAM 将与用于保存变量的 RAM 重叠。为了避免这种情况,xHeapRegions[] 数组中的第一个 HeapRegion_t 结构可以使用 0x0001nnnn 的起始地址,而不是 0x00010000 的起始地址。**但是,这不是推荐的解决方案,**因为:
- 起始地址可能不容易确定。
- 链接器使用的 RAM 量可能会在未来的构建中发生变化,因此需要更新 HeapRegion_t 结构中使用的起始地址。
- 如果链接器使用的 RAM 和 heap_5 使用的 RAM 重叠,构建工具将不知道,因此无法警告应用程序编写者。
下面 展示了一个更方便和可维护的示例。它声明了一个名为 ucHeap 的数组。 ucHeap 是一个普通变量,因此它成为链接器分配给 RAM1 的数据的一部分。 xHeapRegions 数组中的第一个 HeapRegion_t 结构描述了 ucHeap 的起始地址和大小,因此 ucHeap 成为 heap_5 管理的内存的一部分。 ucHeap 的大小可以增加,直到链接器使用的 RAM 耗尽所有 RAM1
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. */
};
堆相关的函数
xPortGetFreeHeapSize
xPortGetFreeHeapSize函数返回堆中的空闲字节数,它可用于优化堆大小。 例如,如果 xPortGetFreeHeapSize() 在所有内核对象创建后返回 2000,那么 configTOTAL_HEAP_SIZE 的值可以减少 2000。
size_t xPortGetFreeHeapSize( void );
xPortGetMinimumEverFreeHeapSize
xPortGetMinimumEverFreeHeapSize返回自FreeRTOS应用程序开始执行以来,堆中存在最小未分配字节数。xPortGetMinimumEverFreeHeapSize返回值表明应用程序接近耗尽堆空间的程序, 例如,如果 xPortGetMinimumEverFreeHeapSize() 返回 200,那么在应用程序开始执行后的某个时间,它会在 200 字节内耗尽堆空间。
xPortGetMinimumEverFreeHeapSize() 仅在使用 heap_4 或 heap_5 时可用。
size_t xPortGetMinimumEverFreeHeapSize( void );
Malloc调用失败的Hook函数
pvPortMalloc() 可以直接从应用程序代码中调用。每次创建内核对象时,它也会在 FreeRTOS 源文件中调用。内核对象的例子包括任务、队列、信号量和事件组。
像标准库 malloc() 函数一样,如果 pvPortMalloc() 因为请求大小的块不存在而无法返回 RAM 块,那么它将返回 NULL,则不会创建内核对象。
如果对 pvPortMalloc() 的调用返回 NULL,则所有示例堆分配方案都可以配置一个调用挂钩(或回调)函数。如果在 FreeRTOSConfig.h 中将 configUSE_MALLOC_FAILED_HOOK 设置为 1,那么应用程序必须提供一个 malloc 失败的钩子函数。该函数可以以任何适合应用程序的方式实现,该函数如下:
void vApplicationMallocFailedHook( void );