堆内存管理

本文详细介绍了FreeRTOS中的动态内存分配机制,包括Heap_1至Heap_5的不同内存管理方案。Heap_1适用于简单系统,Heap_2不建议用于新设计,Heap_3依赖标准库,Heap_4采用首次适应算法减少碎片,而Heap_5允许从多个内存区域分配。每个方案有其适用场景和优缺点,如碎片化、确定性和性能等。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

动态内存分配及其与FreeRTOS的相关性

用于动态内存分配的选项

内存分配方案的示例

Heap_1

Heap_2

Heap_3

Heap_4

设置 Heap_4 所使用数组的起始地址

Heap_5


动态内存分配及其与FreeRTOS的相关性

从FreeRTOS V9.0.0中,内核对象可以在编译时静态分配,或在运行时动态分配。

FreeRTOS 中有很多内核对象,如任务、队列、信号量和事件组。为了使FreeRTOS尽可能容易使用,这些内核对象不是在编译时静态分配,而是在运行时动态分配;FreeRTOS在每次创建内核对象时分配RAM,并在每次删除内核对象时释放RAM。此策略减少了设计和规划工作,简化了API,并最小化了RAM占用。

动态内存分配是一个C语言编程的概念,而不是一个特定于FreeRTOS或多任务处理的概念。它与FreeRTOS相关,因为内核对象是动态分配的,而且由通用编译器提供的动态内存分配方案并不总是适合于实时应用程序。

内存可以使用标准的C库malloc()和free()函数进行分配,但由于以下一个或多个原因,它们可能并不适用:

  • 它们并不总是在小型嵌入式系统上可用。
  • 它们的实现可能相对较大,占用了有宝贵的代码空间。
  • 它们缺少线程安全。
  • 它们不是确定性的;执行函数所花费的时间与调用不同。
  • 它们会造成内存碎片。
  • 它们可能会使连接器的配置复杂化。
  • 如果允许堆空间增长到其他变量使用的内存中,它们可能成为难以调试错误的来源。

如果堆中的空闲RAM被分解成彼此分离的小块,则认为该堆是碎片化的。如果堆是破碎的,然后尝试分配一个块,如果堆中没有一个自由块大到足以包含这个块,那么这次分配将会失败,即使堆中所有单独的自由块的总大小这个块是许多倍的大也无法成功分配。


用于动态内存分配的选项

FreeRTOS的早期版本使用了一种内存池分配方案,即在编译时预先分配不同大小的内存块的池,然后由内存分配函数返回。尽管这是实时系统中常用的方案,但它被证明是许多支持请求的来源,主要是因为它不能足够有效地使用 RAM 以使其适用于真正的小型嵌入式系统——因此该方案被放弃了。

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。

一些禁止使用动态内存分配的商业关键和安全关键系统也有可能使用 heap_1。 由于与非确定性、内存碎片和分配失败相关的不确定性,关键系统通常会禁止动态内存分配——但 Heap_1 始终是确定性的,并且不会碎片化内存。

当调用 pvPortMalloc() 时,heap_1 分配方案将一个简单的数组细分为更小的块。 该数组称为 FreeRTOS 堆。数组的总大小(以字节为单位)由 FreeRTOSConfig.h 中的定义的 configTOTAL_HEAP_SIZE 设置。 以这种方式定义一个大数组会使应用程序看起来消耗了大量 RAM——甚至在从数组分配任何内存之前也是如此。

每创建一个任务都需要从堆中分配一个任务控制块(TCB)和一个堆栈。下图演示了heap_1如何在创建任务时细分简单数组。

图1
  •  A 显示了在创建任何任务之前的数组——整个数组都是空闲的。
  • B 显示创建了一个任务后的数组。
  • C 显示创建了三个任务后的数组。

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_2 存储了堆区域内块大小的信息,所以两个拆分块的总和实际上将小于 25。

与 heap_4 不同,Heap_2 不会将相邻的空闲块组合成一个更大的块,因此它更容易产生碎片。 但是,如果分配和随后释放的块大小始终相同,则碎片不是问题。 Heap_2 适用于重复创建和删除任务的应用程序,前提是分配给创建的任务的堆栈大小不变。

图下图 演示了创建、删除然后再次创建任务时最佳适应算法的工作原理。 参考下图 :

图二
  • A 显示创建了三个任务后的数组。 一个大的空闲块保留在数组的顶部。
  • B 显示其中一项任务被删除后的数组。 数组顶部的大空闲块仍然存在。 现在还有两个较小的空闲块,它们以前分配给已删除任务的 TCB 和堆栈。
  • C显示了创建另一个任务后的情况。 创建任务导致两次调用 pvPortMalloc(),一次用于分配新的 TCB,一次用于分配任务堆栈。 任务是使用 xTaskCreate() API 函数创建的,对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() 线程安全。 线程安全和调度程序暂停都是资源管理中涵盖的主题。

Heap_4

与 heap_1 和 heap_2 一样,heap_4 通过将数组细分为更小的块来工作。 和以前一样,数组是静态声明的,并且由 configTOTAL_HEAP_SIZE 确定大小,因此即使在实际从数组分配任何内存之前,应用程序也会消耗大量 RAM。

Heap_4 使用首次适应算法分配内存。 与 heap_2 不同,heap_4 将相邻的空闲内存块合并(合并)为一个更大的块,这最大限度地降低了内存碎片化的风险。

首次适应算法确保 pvPortMalloc() 使用第一个足够大的空闲内存块来容纳请求的字节数。 例如,考虑以下场景:

  • 堆包含三个空闲内存块,按照它们在数组中出现的顺序,分别为 5 字节、200 字节和 100 字节。
  • 调用 pvPortMalloc() 以请求 20 字节的 RAM。

请求的字节数适合的第一个空闲 RAM 块是 200 字节块,因此 pvPortMalloc() 将200字节块分成一个20字节的块和一个180字节1的块,然后返回指向20字节块的指针。新的180字节块仍然可用于pvPortMalloc() 未来的调用。

这是过于简单化了,因为 heap_4 存储了堆区域内块大小的信息,所以两个拆分块的总和实际上将小于 200 字节。

Heap_4 将相邻的空闲块合并(合并)成一个更大的块,最大限度地降低碎片化的风险,并使其适用于重复分配和释放不同大小的 RAM 块的应用程序。

下图演示了具有内存合并的 heap_4 首次适应算法如何进行分配和释放内存的工作。 参考下图:

图三
  • A 显示创建了三个任务后的数组。 一个大的空闲块保留在数组的顶部。
  • B 显示其中一项任务被删除后的数组。 数组顶部的大空闲块仍然存在。 还有一个空闲块,是之前分配了已删除任务的 TCB 和堆栈。 请注意,与演示 heap_2 时不同,删除 TCB 时释放的内存和删除堆栈时释放的内存不会保留为两个单独的空闲块,而是组合起来创建一个更大的单个空闲块。
  • C 显示了创建 FreeRTOS 队列后的情况。 队列是使用 xQueueCreate() API 函数创建的。 xQueueCreate() 调用 pvPortMalloc() 来分配队列使用的 RAM。 由于 heap_4 使用首次适应算法,pvPortMalloc() 将从第一个足够大以容纳队列的空闲 RAM 块分配 RAM,在上图中,这是删除任务时释放的 RAM。 但是,队列不会消耗空闲块中的所有 RAM,因此该块被分成两部分,未使用的部分仍可用于将来调用 pvPortMalloc()。
  • D 显示直接从应用程序代码调用 pvPortMalloc() 之后的情况,而不是通过调用 FreeRTOS API 函数间接调用。 用户分配的块足够小以适合第一个空闲块,这是分配给队列的内存和分配给后续 TCB 的内存之间的块。 删除任务时释放的内存现在已分为三个独立的块; 第一个块保存队列,第二个块保存用户分配的内存,第三个块保持空闲。
  • E显示队列被删除后的情况,自动释放分配给被删除队列的内存。 现在用户分配块的两侧都有空闲内存。
  • F显示了用户分配的内存也被释放后的情况。 用户分配块使用的内存已与两侧的空闲内存组合以创建更大的单个空闲块。

Heap_4 不是确定性的,但比 malloc() 和 free() 的大多数标准库实现更快。

设置 Heap_4 所使用数组的起始地址

本节包含高级级别的信息。使用Heap_4并不需要阅读或理解本节。

有时应用程序编写者需要将 heap_4 使用的数组放在特定的内存地址。 例如,FreeRTOS 任务使用的堆栈是从堆中分配的,因此可能需要确保堆位于快速内部存储器中,而不是慢速外部存储器中。

默认情况下,heap_4使用的数组在heap_4.c源文件内部声明,其起始地址由链接器自动设置。但是,如果在FreeRTOSConfig.h中将configAPPLICATION_ALLOCATED_HEAP设置为1,则数组必须由使用 FreeRTOS 的应用程序声明。 如果该数组被声明为应用程序的一部分,那么应用程序的编写者可以设置它的起始地址。

如果configAPPLICATION_ALLOCATED_HEAP在FreeRTOSConfig.h中被设置为1,那么必须在应用程序的一个源文件中声明一个名为configAPPLICATION_ALLOCATED_HEAPuint8_t数组,并由configTOTAL_HEAP_SIZE设置进行标注。

将变量放置在特定内存地址所需的语法取决于正在使用的编译器,因此请参阅编译器的文档。下面是两个编译器的示例:

  • 下面显示了GCC编译器声明该数组所需的语法,并将该数组放在一个名为.my_heap的内存部分中。
uint8_t ucHeap[ configTOTAL_HEAP_SIZE ] __attribute__ ( ( section( ".my_heap" ) ) );
  • 下面显示了IAR编译器声明该数组所需的语法,并将该数组放在绝对内存地址0x20000000处。
uint8_t ucHeap[ configTOTAL_HEAP_SIZE ] @ 0x20000000;

Heap_5

heap_5用于分配和空闲内存的算法与heap_4使用的算法相同。与heap_4不同,heap_5并不局限于从单个静态声明的数组中分配内存;heap_5可以从多个独立的内存空间中分配内存。当运行 FreeRTOS 的系统提供的 RAM 在系统内存映射中不存在单个连续块时,Heap_5 很有用。

在编程时,heap_5 是唯一必须在调用 pvPortMalloc() 之前显式初始化的内存分配方案。 Heap_5 使用 vPortDefineHeapRegions() API 函数进行初始化。 使用 heap_5 时,必须在创建任何内核对象(任务、队列、信号量等)之前调用 vPortDefineHeapRegions()。

vPortDefineHeapRegions() 用于指定每个单独的内存区域的起始地址和大小,它们共同构成了 heap_5 使用的总内存。

void vPortDefineHeapRegions( const HeapRegion_t * const pxHeapRegions );

每个单独的内存区域都由 HeapRegion_t 类型的结构描述。 所有可用内存区域的描述作为 HeapRegion_t 结构的数组传递到 vPortDefineHeapRegions() 中。

typedef struct HeapRegion
{
 /*将成为堆的内存块的起始地址*/
 uint8_t *pucStartAddress;
 /* 以字节为单位的内存块的大小 */
 size_t xSizeInBytes;
} HeapRegion_t;
vPortDefineHeapRegions() 的参数
参数名/返回值描述
pxHeapRegions

指向 HeapRegion_t 结构数组开头的指针。 数组中的每个结构都描述了使用 heap_5 时将成为堆一部分的内存区域的起始地址和长度。

数组中的 HeapRegion_t 结构体必须按起始地址排序; 描述起始地址最低的内存区域的HeapRegion_t结构体必须是数组的第一个成员,描述起始地址最高的内存区域的HeapRegion_t结构体必须是数组的最后一个成员。

数组的末尾由 HeapRegion_t 结构标记,该结构体的 pucStartAddress 成员设置为 NULL。

图四

例如,考虑上图 A 中所示的假设内存映射,它包含三个独立的 RAM 块:RAM1、RAM2 和 RAM3。 假定可执行代码位于只读存储器中,未显示。

下面的代码显示了一组 HeapRegion_t 结构,它们一起完整地描述了三个 RAM 块。

/*定义RAM区域的起始地址和大小*/
#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 )
/* 创建一个 HeapRegion_t 定义数组,
三个 RAM 区域中的每一个都有一个索引,
并以 NULL 地址终止该数组。 
HeapRegion_t 结构必须以起始地址顺序出现,
包含最低起始地址的结构最先出现。 */
const HeapRegion_t xHeapRegions[] =
{
 { RAM1_START_ADDRESS, RAM1_SIZE },
 { RAM2_START_ADDRESS, RAM2_SIZE },
 { RAM3_START_ADDRESS, RAM3_SIZE },
 { NULL, 0 } /* 标记数组结尾. */
};
int main( void )
{
 /* 初始化 heap_5. */
 vPortDefineHeapRegions( xHeapRegions );
 /* 在这里添加应用程序代码 */
}

上述代码虽然正确地描述了 RAM,但它没有演示一个可用的示例,因为它将所有 RAM 分配给堆,没有留下任何 RAM 供其他变量使用。

构建项目时,构建过程的链接阶段会为每个变量分配一个 RAM 地址。 可供链接器使用的 RAM 通常由链接器配置文件(例如链接描述文件)描述。 在图四 B 中,假设链接描述文件包含有关 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,如上图 C 所示。
/* 定义链接器未使用的两个 RAM 区域的起始地址和大小 */
#define RAM2_START_ADDRESS ( ( uint8_t * ) 0x00020000 )
#define RAM2_SIZE ( 32 * 1024 )
#define RAM3_START_ADDRESS ( ( uint8_t * ) 0x00030000 )
#define RAM3_SIZE ( 32 * 1024 )

/* 声明一个数组,它将成为 heap_5 使用的堆的一部分。 该数组将由链接器放置在 RAM1 中 */
#define RAM1_HEAP_SIZE ( 30 * 1024 )
static uint8_t ucHeap[ RAM1_HEAP_SIZE ];

/* 创建一个 HeapRegion_t 定义数组。 而在之前的代码中,
第一个条目描述了所有 RAM1,因此 heap_5 将使用所有 RAM1,
这次第一个条目仅描述 ucHeap 数组,因此 heap_5 将仅使用 
RAM1 中包含 ucHeap 数组的部分。HeapRegion_t 结构 
必须仍然以起始地址顺序出现,包含最低起始地址的结构最先出现。 */

const HeapRegion_t xHeapRegions[] =
{
 { ucHeap, RAM1_HEAP_SIZE },
 { RAM2_START_ADDRESS, RAM2_SIZE },
 { RAM3_START_ADDRESS, RAM3_SIZE },
 { NULL, 0 } /* 标记数组结尾 */
};

上面的代码中展示的技术的优点包括:

  • 没有必要使用硬编码的起始地址。
  • HeapRegion_t 结构中使用的地址将由链接器自动设置,因此始终是正确的,即使链接器使用的 RAM 量在未来的构建中发生变化。
  • 分配给 heap_5 的 RAM 不可能与链接器放入 RAM1 的数据重叠。
  • 如果 ucHeap 太大,应用程序将无法链接。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值