Chapter 2 Heap Memory Management
2.1 章节介绍
先决条件
FreeRTOS 提供了一组 C 源文件,因此,精通 C 编程是使用 FreeRTOS 的先决条件,因此本章假设读者熟悉以下概念:
- 如何构建一个 C 项目,包括不同的编译和链接阶段。
- 栈和堆是什么。
- 标准 C 库中的 malloc() 和 free() 函数。
动态内存分配及其与 FreeRTOS 的关联
从 FreeRTOS V9.0.0 开始,内核对象可以在编译时静态分配,或者在运行时动态分配:本书的后续章节将介绍诸如任务、队列、信号量和事件组等内核对象。为了尽可能地简化 FreeRTOS 的使用,这些内核对象不是在编译时静态分配的,而是在运行时动态分配;每次创建内核对象时,FreeRTOS 都会分配 RAM,并在每次删除内核对象时释放 RAM。这种策略减少了设计和规划的工作量,简化了 API,并减小了 RAM 占用。
本章讨论了动态内存分配。动态内存分配是一个 C 编程概念,不是特定于 FreeRTOS 或多任务的概念。它与 FreeRTOS 有关,因为内核对象是动态分配的,而通用编译器提供的动态内存分配方案并不总是适用于实时应用。
可以使用标准 C 库中的 malloc() 和 free() 函数来分配内存,但由于以下原因,它们可能不适合或不合适:
-
在小型嵌入式系统上并不总是可用。
-
它们的实现可能相对较大,占用了宝贵的代码空间。
-
它们很少是线程安全的。
-
它们不具有确定性;执行这些函数所需的时间会因调用而异。
-
它们可能会遭受碎片化的问题。
-
它们可能会使链接器配置变得复杂。
-
如果允许堆空间增长到其他变量使用的内存中,它们可能会成为难以调试的错误来源。
动态内存分配的选项
从 FreeRTOS V9.0.0 开始,内核对象可以在编译时静态分配,或者在运行时动态分配:
早期版本的 FreeRTOS 使用了内存池分配方案,即在编译时预先分配不同大小的内存块池,然后由内存分配函数返回。尽管这是实时系统中常用的方案,但它被证明是许多支持请求的来源,主要是因为它无法以足够高的效率使用 RAM,以使其在真正小型的嵌入式系统中可行—因此该方案被放弃了。
现在,FreeRTOS 将内存分配视为可移植层的一部分(而不是核心代码库的一部分)。这是因为不同的嵌入式系统具有不同的动态内存分配和时间要求,因此单个动态内存分配算法仅对一部分应用程序有效。此外,将动态内存分配从核心代码库中移除使应用程序编写者能够在适当时提供自己的特定实现。
当 FreeRTOS 需要 RAM 时,它不是调用 malloc(),而是调用 pvPortMalloc()。当释放 RAM 时,它不是调用 free(),而是调用 vPortFree()。pvPortMalloc() 的原型与标准 C 库中的 malloc() 函数相同,vPortFree() 的原型与标准 C 库中的 free() 函数相同。
pvPortMalloc() 和 vPortFree() 是公共函数,因此也可以从应用程序代码中调用。
堆被认为是碎片化的,如果堆内的空闲 RAM 被分割成相互分离的小块。如果堆被碎片化,那么尝试分配一个块将会失败,如果堆中没有单个足够大以容纳该块的空闲块,即使堆中所有分离的空闲块的总大小远远大于无法分配的块的大小。
从 FreeRTOS V9.0.0 开始,内核对象可以在编译时静态分配,或者在运行时动态分配:FreeRTOS 提供了五个示例实现 pvPortMalloc() 和 vPortFree(),所有这些示例都在本章中有文档记录。FreeRTOS 应用程序可以使用其中一个示例实现,或者提供自己的实现。
这五个示例分别定义在 heap_1.c、heap_2.c、heap_3.c、heap_4.c 和 heap_5.c 源文件中,这些文件都位于 FreeRTOS/Source/portable/MemMang 目录中。
范围
本章旨在让读者充分了解:
- FreeRTOS 何时分配 RAM。
- FreeRTOS 提供的五种示例内存分配方案。
- 选择哪种内存分配方案。
2.2 示例内存分配方案
从 FreeRTOS V9.0.0 开始,FreeRTOS 应用程序可以完全静态分配,无需包含堆内存管理器。
Heap_1
在小型专用嵌入式系统中,通常只在调度程序启动之前创建任务和其他内核对象。在这种情况下,只有在应用程序开始执行任何实时功能之前内核才会动态分配内存,并且内存会在应用程序的生命周期内保持分配状态。这意味着所选择的分配方案不必考虑任何更复杂的内存分配问题,如确定性和碎片化,而只需考虑诸如代码大小和简单性等属性。
Heap_1.c 实现了一个非常基本的 pvPortMalloc() 版本,并不实现 vPortFree()。从不删除任务或其他内核对象的应用程序有可能使用 heap_1。
一些商业关键和安全关键系统,否则可能禁止使用动态内存分配,也有可能使用 heap_1。关键系统通常禁止使用动态内存分配,是因为与不确定性、内存碎片化和分配失败相关的不确定性,但是 Heap_1 总是确定性的,不会造成内存碎片化。
heap_1 分配方案将一个简单的数组划分为较小的块,当调用 pvPortMalloc() 时发生。该数组称为 FreeRTOS 堆。
数组的总大小(以字节为单位)由 FreeRTOSConfig.h 中的 configTOTAL_HEAP_SIZE 定义。以这种方式定义一个大数组可能会使应用程序看起来消耗大量 RAM —— 即使在数组中没有分配任何内存之前。
每个创建的任务都需要从堆中分配任务控制块(TCB)和堆栈。图 5 展示了当任务创建时 heap_1 如何将简单数组进行划分。
参考图 5:
-
A 显示了在创建任何任务之前的数组——整个数组都是空闲的
-
B 显示了创建一个任务后的数组。
-
C 显示了创建三个任务后的数组。图 5. 每次创建任务时从 heap_1 数组中分配 RAM
Heap_2
Heap_2 保留在 FreeRTOS 分发中以保持向后兼容性,但不建议在新设计中使用。请考虑使用 heap_4 替代 heap_2,因为 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适用于反复创建和删除任务的应用程序,前提是分配给创建的任务的堆栈大小不会改变。
Figure 6展示了当创建任务,删除任务,然后再次创建任务时最佳适配算法的工作原理。参考图6:
- A 显示了在创建了三个任务之后的数组情况。数组顶部仍然有一个大的空闲块。
- B 显示了在删除了其中一个任务后的数组情况。数组顶部的大的空闲块仍然存在。现在还有两个较小的空闲块,这些空闲块之前曾分配给已删除任务的TCB和堆栈。
- C 显示了另一个任务创建后的情况。创建任务导致了两次pvPortMalloc()调用,一次用于分配新的TCB,另一次用于分配任务堆栈。任务是使用xTaskCreate() API函数创建的,该函数在第3.4节中进行了描述。pvPortMalloc()的调用发生在xTaskCreate()内部。
每个任务控制块(TCB)的大小完全相同,因此最佳适配算法确保以前分配给已删除任务的TCB的RAM块被重用以分配新任务的TCB。新创建任务的堆栈大小与先前已删除任务分配的堆栈大小相同,因此最佳适配算法确保以前分配给已删除任务的堆栈的RAM块被重用以分配新任务的堆栈。数组顶部的较大未分配块保持不变。Heap_2不是确定性的,但比大多数标准库中malloc()和free()的实现更快。
Heap_3
Heap_3.c使用标准库的malloc()和free()函数,因此堆的大小由链接器配置定义,而configTOTAL_HEAP_SIZE设置不起作用。
Heap_3通过临时挂起FreeRTOS调度器使malloc()和free()线程安全。线程安全和调度器挂起都是第7章资源管理中涉及的主题。
Heap_4
Heap_4与heap_1和heap_2类似,通过将一个数组细分为较小的块来工作。与之前一样,该数组是静态声明的,并由configTOTAL_HEAP_SIZE定义,因此即使在实际从数组中分配任何内存之前,应用程序看起来也会消耗大量RAM。Heap_4使用首次适配算法来分配内存。与heap_2不同,heap_4将相邻的空闲内存块合并成一个较大的单一块,这最大程度地减少了内存碎片化的风险。首次适配算法确保pvPortMalloc()使用第一个足够容纳请求字节数的空闲内存块。例如,考虑以下情况:
-
堆中包含三个自由内存块,按照它们在数组中出现的顺序分别为5字节、200字节和100字节。
-
调用pvPortMalloc()请求20字节的RAM。能够容纳请求字节数的第一个空闲内存块是200字节块,因此pvPortMalloc()将200字节块分成一个20字节的块和一个180字节的块1,然后返回指向20字节块的指针。新的180字节块保留供未来调用pvPortMalloc()使用。Heap_4将相邻的空闲块合并成单个较大的块,最小化了碎片化的风险,使其适用于重复分配和释放不同大小的RAM块的应用程序。
这是一个过度简化的说法,因为heap_4在堆区域存储了关于块大小的信息,因此两个分裂块的总和实际上会小于200字节。
Figure 7显示了heap_4首次适配算法与内存合并的工作原理,当内存被分配和释放时。参考图7:
-
A显示了创建了三个任务后的数组情况。顶部仍然有一个大的空闲块。
-
B显示了删除其中一个任务后的数组情况。数组顶部的大空闲块保持不变。此外,还有一个自由块,之前分配了已删除任务的 TCB 和堆栈的内存。需要注意的是,与演示 heap_2 时不同,当删除 TCB 和删除堆栈时释放的内存不再作为两个单独的空闲块存在,而是合并成一个更大的单个空闲块。
-
C 显示了创建 FreeRTOS 队列后的情况。队列是使用 xQueueCreate() API 函数创建的,该函数在第 4.3 节中描述。xQueueCreate() 调用 pvPortMalloc() 来分配队列使用的 RAM。由于 heap_4 使用首次适配算法,pvPortMalloc() 将从第一个足够大的空闲 RAM 块中分配 RAM,该块在图7中是在删除任务时释放的 RAM。然而,队列并未占用空闲块中的所有 RAM,因此将该块分成两部分,未使用的部分保留供将来调用 pvPortMalloc() 使用。
-
D 显示了直接从应用代码中调用 pvPortMalloc(),而不是通过调用 FreeRTOS API 函数间接调用。用户分配的块足够小,可以放在第一个空闲块中,该块位于已分配给队列的内存和后续 TCB 的内存之间。当删除任务时释放的内存现在已分成三个单独的块;第一个块保存队列,第二个块保存用户分配的内存,第三个块保持空闲。
-
E 显示了删除队列后的情况,这将自动释放已分配给已删除队列的内存。现在在用户分配的块两侧都有空闲内存。
-
F 显示了释放了用户分配的内存后的情况。之前由用户分配的内存所使用的内存与两侧的空闲内存合并成一个更大的单个空闲块。
Heap_4 是非确定性的,但比大多数标准库中的 malloc() 和 free() 实现更快。
在使用 Heap_4 时,有时需要将其使用的数组放置在特定的内存地址上。
例如,FreeRTOS 任务使用的堆栈是从堆中分配的,因此可能需要确保堆位于快速内部内存中,而不是慢速外部内存。
默认情况下,Heap_4 使用的数组在 heap_4.c 源文件中声明,并且其起始地址由链接器自动设置。然而,如果在 FreeRTOSConfig.h 中将 configAPPLICATION_ALLOCATED_HEAP 编译时配置常量设置为 1,则数组必须由使用 FreeRTOS 的应用程序声明。如果数组是作为应用程序的一部分声明的,则应用程序的编写者可以设置其起始地址。
如果在 FreeRTOSConfig.h 中将 configAPPLICATION_ALLOCATED_HEAP 设置为 1,则必须在应用程序的一个源文件中声明一个名为 ucHeap 的 uint8_t 数组,并由 configTOTAL_HEAP_SIZE 设置确定其大小。
将变量放置在特定内存地址的语法取决于所使用的编译器,因此请参考您的编译器文档。以下是两个编译器的示例:
- 代码清单 2 展示了 GCC 编译器要求的语法,用于声明数组并将数组放置在名为 .my_heap 的内存段中。
- 代码清单 3 展示了 IAR 编译器要求的语法,用于声明数组并将数组放置在绝对内存地址 0x20000000 处。
Heap_5
在运行FreeRTOS的系统提供的RAM不以单个连续(无间隙)块的形式出现在系统的内存映射中时,Heap_5就会变得很有用。
在撰写本文时,heap_5是唯一需要在调用pvPortMalloc()之前显式初始化的提供的内存分配方案。Heap_5使用vPortDefineHeapRegions() API函数进行初始化。当使用heap_5时,必须在创建任何内核对象(任务、队列、信号量等)之前调用vPortDefineHeapRegions()。
**The vPortDefineHeapRegions() API **
函数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;
| Parameter Name/
Returned Value | Description |
---|---|
pxHeapRegions | 一个指向 HeapRegion_t 结构数组的起始地址的指针。数组中的每个结构描述了在使用 heap_5 时作为堆一部分的内存区域的起始地址和长度。 数组中的 HeapRegion_t 结构必须按照起始地址排序;描述具有最低起始地址的内存区域的 HeapRegion_t 结构必须是数组中的第一个结构,描述具有最高起始地址的内存区域的 HeapRegion_t 结构必须是数组中的最后一个结构。 数组的末尾由一个将其 pucStartAddress 成员设置为 NULL 的 HeapRegion_t 结构标记。 |
一个指向 HeapRegion_t 结构数组的
举例说明,考虑图 8 A 中显示的假设内存映射,其中包含三个单独的 RAM 块:RAM1、RAM2 和 RAM3。假设可执行代码放置在只读内存中,未显示在图中。
Listing 6 显示了一个 HeapRegion_t 结构数组,这些结构共同描述了整个三个 RAM 块。
尽管Listing 6 正确描述了 RAM,但它并未演示可用的示例,因为它将所有 RAM 都分配给了堆,没有留下 RAM 供其他变量使用。在构建项目时,构建过程的链接阶段将为每个变量分配一个 RAM 地址。可用于链接器的 RAM 通常由链接器配置文件描述,例如链接器脚本。在图 8 B 中,假设链接器脚本包含了 RAM1 的信息,但没有包含 RAM2 或 RAM3 的信息。因此,链接器将变量放置在 RAM1 中,仅留下了地址0x0001nnnn以上的 RAM1 部分可供 heap_5 使用。实际值0x0001nnnn将取决于所链接应用程序中包含的所有变量的组合大小。链接器将 RAM2 的所有部分和 RAM3 的所有部分都未使用,将 RAM2 的全部部分和 RAM3 的全部部分都留给了 heap_5 使用。如果使用清单 6 中显示的代码,则分配给 heap_5 的 RAM 将位于地址0x0001nnnn以下,与用于保存变量的 RAM 重叠。为了避免这种情况,xHeapRegions[] 数组中的第一个 HeapRegion_t 结构可以使用0x0001nnnn的起始地址,而不是0x00010000的起始地址。然而,这并不是一个推荐的解决方案,因为:
- 起始地址可能不容易确定。
- 链接器使用的 RAM 量在将来的构建中可能会发生变化,需要更新在 HeapRegion_t 结构中使用的起始地址。
- 构建工具不会知道,因此也无法警告应用程序编写者,如果链接器使用的 RAM 和 heap_5 使用的 RAM 重叠。
Listing 7 展示了一个更方便和易于维护的示例。它声明了一个名为 ucHeap 的数组。ucHeap 是一个普通变量,因此它成为链接器分配给 RAM1 的数据的一部分。xHeapRegions 数组中的第一个 HeapRegion_t 结构描述了 ucHeap 的起始地址和大小,因此 ucHeap 成为由 heap_5 管理的内存的一部分。ucHeap 的大小可以增加,直到链接器使用的 RAM 消耗了 RAM1 的所有部分,如图 8 C 所示。
Listing 7 中展示的技术的优点包括:
- 不需要使用硬编码的起始地址。
- 在 HeapRegion_t 结构中使用的地址将由链接器自动设置,因此即使链接器使用的 RAM 量在将来的构建中发生变化,它也始终是正确的。
- 分配给 heap_5 的 RAM 不可能与链接器放置到 RAM1 中的数据重叠。
- 如果 ucHeap 太大,应用程序将无法链接。
2.3 堆相关的实用函数
The xPortGetFreeHeapSize() API Function
xPortGetFreeHeapSize() API函数在调用时返回堆中的空闲字节数。它可以用于优化堆大小。例如,如果在创建了所有内核对象之后,xPortGetFreeHeapSize()返回2000,则configTOTAL_HEAP_SIZE的值可以减少2000。
当使用heap_3时,xPortGetFreeHeapSize()不可用。
size_t xPortGetFreeHeapSize( void );
| Parameter Name/
Returned Value | Description |
---|---|
Returned value | xPortGetFreeHeapSize()函数在调用时返回堆中尚未分配的字节数。 |
The xPortGetMinimumEverFreeHeapSize() API Function
xPortGetMinimumEverFreeHeapSize() API函数返回自FreeRTOS应用程序开始执行以来堆中曾经存在的未分配字节的最小数量。xPortGetMinimumEverFreeHeapSize()返回的值表示应用程序在堆空间接近耗尽时的情况。例如,如果xPortGetMinimumEverFreeHeapSize()返回200,则自应用程序开始执行以来,曾有一次堆空间仅剩200字节未分配。xPortGetMinimumEverFreeHeapSize()仅在使用heap_4或heap_5时可用。
size_t xPortGetMinimumEverFreeHeapSize( void );
| Parameter Name/
Returned Value | Description |
---|---|
Returned value | 自从 FreeRTOS 应用程序开始执行以来,堆中存在的未分配字节的最小数量。 |
Malloc Failed Hook Functions
pvPortMalloc() 可以直接从应用程序代码中调用。它还在每次创建内核对象时在 FreeRTOS 源文件中调用。内核对象的示例包括任务、队列、信号量和事件组,所有这些都在本书的后续章节中描述。
与标准库的 malloc() 函数一样,如果 pvPortMalloc() 无法返回一个由于请求的大小不存在而导致的 RAM 块,那么它将返回 NULL。如果 pvPortMalloc() 被执行是因为应用程序编写者正在创建一个内核对象,并且调用 pvPortMalloc() 返回 NULL,则该内核对象将不会被创建。
所有示例堆分配方案都可以配置为在调用 pvPortMalloc() 返回 NULL 时调用钩子(或回调)函数。
如果在 FreeRTOSConfig.h 中将 configUSE_MALLOC_FAILED_HOOK 设置为 1,则应用程序必须提供一个 malloc 失败的钩子函数,其名称和原型如下所示:
void vApplicatiuonMallocFailedHook(void);
该函数可以根据应用程序的需要以任何适当的方式实现。