(二)Freertos内存管理

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

动态内存分配是一个C编程概念,不是一个特定于FreeRTOS或多任务处理的概念。它与FreeRTOS相关,因为内核对象是动态分配的,而通用编译器提供的动态内存分配方案并不适用于所有实时应用程序。虽然可以使用标准C库malloc()和free()函数分配内存,但是会存在以下问题:

  1. 这两个函数在小型嵌入式系统中可能不可用。
  2. 这两个函数的具体实现可能会相对较大,会占用较多宝贵的代码空间。
  3. 这两个函数通常不具备线程安全特性。
  4. 这两个函数具有不确定性。每次调用时的时间开销都可能不同。
  5. 这两个函数会产生内存碎片。
  6. 这两个函数会使得链接器配置得复杂。
  7. 如果允许堆空间增长到由其他变量使用的内存中,则由此引起的错误难以调试。

动态内存分配方案范例

不同的嵌入式系统具有不同的内存配置和时间要求。所以单一的内存分配算法只可能适合部分应用程序。因此,FreeRTOS现在将内存分配作为可移植层的一部分(而不是核心代码的一部分)。此外,从核心代码库中删除动态内存分配使应用程序开发者能够提供适合自己的特定实现。

当内核请求内存时,调用 pvPortMalloc()而不是直接调用 malloc();当释放内存时,调用 vPortFree()而不是直接调用 free()。 pvPortMalloc()具有与 malloc()相同的函 数原型; vPortFree()也具有与 free()相同的函数原型。pvPortMalloc()和vPortFree()是公共函数,所以也可以从应用程序代码调用。FreeRTOS提供了5个实现pvPortMalloc()和vPortFree()的示例。FreeRTOS应用程序可以使用其中一个示例实现,也可以自己去实现。

Heap_1

Heap_1.c 实现了一个非常基本的 pvPortMalloc()版本,而且没有实现 vPortFree()。如果应用程序不需要删除任务,队列或者信号量,则具有使用 heap_1 的潜质。 Heap_1总是具有确定性。这种分配方案是将 FreeRTOS 的内存堆空间看作一个简单的数组。当调用pvPortMalloc()时,则将数组又简单地细分为更小的内存块。数组的总大小(字节为单位)在 FreeRTOSConfig.h 中由 configTOTAL_HEAP_SIZE定义。以这种方式定义一个巨型数组会让整个应用程序看起来耗费了许多内存——即使是在数组没有进行任何实际分配之前。

需要为每个创建的任务在堆空间上分配一个任务控制块(TCB)和一个栈空间。 下图展示了 heap_1 是如何在任务创建时细分这个简单数组的。从下图中可以看到:

    A 表示数组在没有任何任务创建时的情形,这里整个数据是空的。 B 表示数组在创建了一个任务后,B显示array。 C 表示数组在创建了三个任务后的情形

Heap_2

Heap_2.c 也是使用了一个由 configTOTAL_HEAP_SIZE 定义大小的简单数组。不同于 heap_1 的是, heap_2 采用了一个最佳匹配算法来分配内存,并且支持内存释放。由于声明了一个静态数组,所以会让整个应用程序看起来耗费了许多内存——即使是在数组没有进行任何实际分配之前。最佳匹配算法保证 pvPortMalloc()会使用最接近请求大小的空闲内存块。比如,考虑以下情形:

    堆空间中包含了三个空闲内存块,分别为 5 字节, 25 字节和 100 字节大小。 pvPortMalloc()被调用以请求分配 20 字节大小的内存空间。

匹配请求字节数的最小空闲内存块是具有25字节大小的内存块——所以pvPortMalloc()会将这个 25 字节块再分为一个 20 字节块和一个 5 字节块 ,然后返回一个指向 20 字节块的指针。剩下的 5 字节块则保留下来,留待以后调用 pvPortMalloc()时使用。Heap_2.c 并不会把相邻的空闲块合并成一个更大的内存块,所以会产生内存碎片 ——如果分配和释放的总是相同大小的内存块,则内存碎片就不会成为一个问题。Heap_2.c 适合用于那些重复创建与删除具有相同栈空间任务的应用程序。

上图展示了当任务创建,删除以及再创建过程中,最佳匹配算法是如何工作的。从上图可以看出:

    A 表示数组在创建了三个任务后的情形。数组的顶部还剩余一个大空闲块。 B 表示数组在删除了一个任务后的情形。顶部的大空闲块保持不变,并多出了两个小的空闲块,分别是被删除任务的 TCB 和任务栈。 C 表示数组在又创建了一个任务后的情形。创建一个任务会产生两次调用pvPortMalloc(),一次是分配 TCB,一次是分配任务栈(调用 pvPortMalloc()发生在xTaskCreate() API 函数内部)。每个 TCB 都具有相同大小,所以最佳匹配算法可以确保之前被删除的任务占用的 TCB 空间被重新分配用作新任务的 TCB 空间。新建任务的栈空间与之前被删除任务的栈空间大小相同,所以最佳匹配算法会保证之前被删除任务占用的栈空间会被重新分配用作新任务的栈空间。数组顶部的大空闲块依然保持不变。

Heap_2.c 虽然不具备确定性,但是比大多数标准库实现的 malloc()与 free()更有效率。

Heap_3

简单地调用了标准库函数 malloc()和 free(),但是通过暂时挂起调度器使得函数调用备线程安全特性。此时的内存堆空间大小不受 configTOTAL_HEAP_SIZE 影响,而是由链接器配置决定。

Heap_4

与heap_1和heap_2一样,heap_4的工作原理是将数组细分为更小的块。与前面一样,该数组是静态声明的,并由configTOTAL_HEAP_SIZE进行维数划分,因此会使应用程序在从该数组实际分配任何内存之前就看起来消耗了大量RAM。

Heap_4使用first fit算法分配内存。与heap_2不同,heap_4将相邻的空闲内存块结合(合并)成一个更大的块,从而最大限度地减少内存碎片的风险,并使其适用于重复分配和释放不同大小RAM块的应用程序。first fit算法确保pvPortMalloc()使用第一个足够大的空闲内存块来容纳请求的字节数。例如,考虑以下场景:

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

第一个可以容纳请求字节数的空闲RAM块是200字节的块,因此pvPortMalloc()将200字节的块分割为一个20字节的块和一个180字节的块,然后返回一个指向20字节块的指针。新的180字节块仍然可以用于以后对pvPortMalloc()的调用。

上图演示了在分配和释放内存时,使用内存合并的heap_4的 first fit算法是如何工作的。如上图所示:

  1. A显示创建了三个任务后的数组。一个大的空闲块仍然在数组的顶部。
  2. B显示其中一个任务被删除后的数组。除了数组顶部的大空闲块,还有一个空闲块--先前在其中分配了已删除的任务的TCB和堆栈。注意,与演示heap_2时不同,删除TCB时释放的内存和删除堆栈时释放的内存并不是作为两个单独的空闲块保留,而是合并起来创建一个更大的空闲块。
  3. C显示创建FreeRTOS队列后的情况。队列使用xQueueCreate() API函数创建,xQueueCreate()调用pvPortMalloc()来分配队列使用的RAM。由于heap_4使用一种first fit算法,pvPortMalloc()将从第一个足够容纳队列的空闲RAM块分配RAM,这是刚才删除任务时释放的RAM。然而,队列并没有消耗空闲块中的所有RAM,所以块被分成两个,未使用的部分将来仍然可以被pvPortMalloc()调用。
  4. D显示了从应用程序代码直接调用pvPortMalloc()后的情况,而不是通过调用FreeRTOS API函数间接调用。用户分配的块足够小,可以容纳第一个空闲块,这是分配给队列的内存和分配给下一个TCB的内存之间的块。删除任务时释放的内存现在被分成三个独立的块;第一个块保存队列,第二个块保存用户分配的内存,第三个块保持空闲状态。
  5. E显示队列被删除后的情况,自动释放已经分配给被删除队列的内存。现在在用户分配的块的两边都有空闲的内存。
  6. F显示用户分配的内存也被释放后的情况。已经被用户分配的块所使用的内存与任意一边的空闲内存相结合,以创建一个更大的空闲块。

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

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()

vPortDefineHeapRegions()用于指定开始地址和每个单独的内存区域的大小,这些区域共同构成了heap_5使用的总内存.

void vPortDefineHeapRegions( const HeapRegion_t * const pxHeapRegions );

参数说明:pxHeapRegions 

    指向HeapRegion_t结构数组开头的指针。数组中的每个结构描述了使用heap_5时将成为堆一部分的内存区域的起始地址和长度。
    数组中的HeapRegion_t结构必须按起始地址排序;最低起始地址的内存区域的HeapRegion_t结构必须是数组中的第一个结构,描述具有最高起始地址的内存区域的HeapRegion_t结构
必须是数组中的最后一个结构。
    数组的结尾由HeapRegion_t结构标记,该结构的pucStartAddress成员设置为NULL。

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

typedef struct HeapRegion { /* 堆一部分的内存块的起始地址.*/ uint8_t *pucStartAddress; /*内存块的大小,以字节为单位 */ size_t xSizeInBytes; } HeapRegion_t;

举例来说,考虑下图中所示的假设内存映射,它包含三个独立的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区域都有一个索引,并用空地址结束数组。HeapRegion_t结构必须按起始地址顺序出现,最低起始地址优先出现。 */ 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通常,例如链接器脚本。在上图 B中,假定链接器脚本包含了RAM1上的信息,但不包含RAM2或RAM3上的信息。因此,连接器在RAM1中放置了变量,只留下RAM1中地址0x0001nnnn以上的部分供heap_5使用。0x0001nnnn的实际值将取决于所链接的应用程序中包含的所有变量的组合大小。链接器未使用RAM2和RAM3部分,让heap_5可以使用RAM2和RAM3。

如果使用以上代码,则分配给heap_5的RAM地址在0x0001nnnn之下将与用于保存变量的RAM重叠。为了避免这种情况,首先数组中的HeapRegion_t结构可以使用的起始地址为0x0001nnnn,而不是起始地址0x00010000。然而,这不是一个最佳的方案,因为:

  1. 起始地址可能不容易确定。
  2. 链接器所使用的RAM数量可能会在以后的构建中发生变化,因此需要更新HeapRegion_t结构中使用的起始地址。
  3. 如果链接器使用的RAM和heap_5使用的RAM重叠,构建工具是不会知道的,因此将不会发出警告信息。

以下代码演示了一个更方便和可维护的示例。它声明了一个ucHeap的数组。ucHeap是一个普通变量,所以它成为链接器分配给RAM1的数据的一部分。xHeapRegions数组中的第一个HeapRegion_t结构描述了ucHeap的起始地址和大小,因此ucHeap成为heap_5管理的内存的一部分。如上图c所示,ucHeap的大小可以增加,直到连接器使用的RAM消耗掉所有的RAM1。

/* 定义两个未被链接器使用的RAM区域的起始地址和大小. */ #define RAM2_START_ADDRESS #define RAM2_SIZE ( ( uint8_t * ) 0x00020000 ) ( 32 * 1024 ) #define RAM3_START_ADDRESS #define RAM3_SIZE ( ( uint8_t * ) 0x00030000 ) ( 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 } /* Marks the end of the array. */ };

这样做的优点在于:

  1. 没有必要使用硬编码的起始地址。
  2. 在HeapRegion_t结构中使用的地址将由链接器自动设置,即使链接器使用的RAM数量在以后的构建中发生变化,也不用担心。
  3. 分配给heap_5的RAM不会与链接器放置到RAM1中的数据重叠。
  4. 如果ucHeap太大,应用程序就会链接失败。

一些与堆内存相关的函数

xPortGetFreeHeapSize()

返回在调用该函数时堆中的空闲字节数。它可以用来优化堆大小。例如,如果xPortGetFreeHeapSize()在创建所有内核对象之后返回2000,那么configTOTAL_HEAP_SIZE的值可以减少2000。

xPortGetMinimumEverFreeHeapSize()

返回的值表明应用程序已经接近耗尽堆空间。例如,如果xPortGetMinimumEverFreeHeapSize()返回200,那么表示在应用程序开始执行后的某个时间,它距离堆空间耗尽不到200个字节。

经验分享 程序员 微信小程序 职场和发展