【FreeRTOS】堆内存管理
动态内存分配及其与FreeRTOS的相关性
为了使FreeRTOS更易用,内核对象(如任务、队列、信号量、事件组)不在编译期静态分配,而是在运行时动态分配,FreeRTOS在内核对象创建时分配RAM,删除内核对象时释放RAM。
这种策略降低了设计难度,更简单的API,最小化内存占用。动态内存分配时C编程的概念,而非特定于FreeRTOS或多任务的概念,只是FreeRTOS的内核对象是动态分配的,一般编译器提
供的动态内存分配方案并不总是适用于实时应用。
可以使用标准C库函数malloc
和free
进行内存分配,但他们并不总是适用的,主要原因如下:
- 在小型嵌入式系统上并不总是可用
- 他们实现通常很大,占据昂贵的代码空间
- 他们几乎不是线程安全的
- 他们不是确定性的,执行函数所花费的时间因调用而异
- 导致内存碎片化严重
- 导致链接配置更复杂
- 无法调试
动态内存分配选项
早期的FreeRTOS使用内存池分配方案,不同大小的内存池是在编译期预先分配的,尽管这是一个实时系统的通用方案,但它不能有效的使用RAM,在非常小的嵌入式系统无法使用,因此被放弃了。
FreeRTOS现在将内存分配视作可移植层的一部分而非核心代码,这是因为不同嵌入式系统对动态内存分配和时间要求不同,因此单一动态内存分配算法值适用某一种应用,所以将动态内存分配代码
从核心代码中删除,使得使用者可以在特定的应用使用合适的动态内存分配算法。
FreeRTOS分配内存使用接口pvPortMalloc
而非malloc
,释放内存使用vPortFree替代free。
FreeRTOS提供5种内存分配方案,接口统一使用pvPortMalloc
和vPortFree
,用户可以使用者5种方案,也可以自己实现。这5种内存分配方案实现在目录FreeRTOS/Source/portable/MemMang
下,
命名分别是heap_1.c、heap_2.c、heap_3.c、heap_4.c、heap_5.c.
内存分配方案介绍
Heap_1
小型专用嵌入式系统在调度器运行前只创建任务和内核对象是很常见的,也就是说在应用程序执行前进行动态内存分配,而后一直保持直到应用程序生命周期结束,意味着此方案无需考虑复杂的内存
分配问题,比如确定性和碎片化,只需考虑代码大小即可。
heap_1.c实现基础版内存分配接口pvPortMalloc
,未实现内存释放接口vPortFree
,使用该算法的应用程序永不删除任务、内核对象。
一些商用型安全关键的系统禁止使用动态内存分配,故选用heap_1方案,以避免内存分配的不确定性。
pvPortMalloc
使用heap_1方案,将从数组中分配一个小内存块,这个数组就是FreeRTOS堆内存,数组大小由宏configTOTAL_HEAP_SIZE
配置,创建的每个任务都有一个任务控制块和任务栈,图
Figure-5演示了任务创建时聂村的分配过程:
- A显示任务创建前整个内存数组是空的
- B显示创建1个任务后的内存数组布局
- C显示创建3个任务后的内存数组布局
Heap_2
为了向后兼容,heap_2在FreeRTOS发行版中被保留,但不建议在新设计中使用它,可以使用heap_4替代,因为heap_4提供了增强版的功能。heap_2使用最佳匹配算法进行内存分配和释放。
最佳匹配算法确保pvPortMalloc
使用大小最接近请求字节数的空闲内存块。
示例考虑以下场景:
- 堆内存包含3个空闲内存块,分别是5Byte、25Byte、100Byte
pvPortMalloc
请求分配20Bytes内存
与请求20Byte内存大小最接近的是25Byte空闲内存块,pvPortMalloc
将25Byte内存块分成20Byte和5Byte,并返回20Byte内存块指针,剩余的5Byte以供将来分配使用。
heap_2方案不会将相邻的空闲内存块合并为更大的空闲内存块,所以更容易产生内存碎片。当然每次分配的内存大小与随后释放的内存大小一致,则就不会存在内存碎片问题。
heap_2方案适合重复创建和删除任务的应用,前提是分配给创建任务的堆栈大小不改变。
图Figure-6演示了最佳匹配算法的工作过程,任务创建、删除、再次创建
- A显示创建了3个任务,数组顶部还剩余大块空闲内存
- B显示删除了1个任务,当前多出了2块小内存,即之前创建任务的任务控制块和任务栈
- C显示新创建1个任务,创建任务调用2次
pvPortMalloc
,分别是分配任务控制块和任务栈
每个任务控制大小都一样,所以最佳匹配算法确保在先前释放的任务控制块内存位置重复分配新任务的任务控制块,新创建任务的任务栈与之前释放的任务栈大小相同,所以最佳匹配算法仍在原位置分配任务栈,
数组顶部的未分配的内存自始至终都未触碰
heap_2方案不是确定性的,但比标准库函数malloc
和malloc
执行快
Heap_3
heap_3方案使用标准库函数malloc
和free
,堆大小在链接文件中分配,宏configTOTAL_HEAP_SIZE
配置的修改无影响。
heap_3方案通过挂起调度器实现函数malloc
和free
的线程安全。
Heap_4
跟heap_1、heap_2一样,heap_4也是从数组中分出小块内存,堆内存大小也是宏configTOTAL_HEAP_SIZE
配置的。
heap_4使用第一次匹配算法进行内存分配,相比heap_2,heap_4会合并相邻的空闲内存块,将内存碎片风险最小化。第一次匹配算法确保pvPortMalloc
分配内存时,总是找到第一个可以满足请求内存大小的
空闲内存块。
示例考虑如下场景:
- 堆内存包含3个空闲内存块,分别是5Byte、200Byte、100Byte的顺序
pvPortMalloc
请求分配20Bytes内存
第一次匹配算法就会找到200Byte内存块分配内存,pvPortMalloc
将200Byte内存块分成20Byte和180Byte两个内存块,并返回20Byte内存块指针,新的180Byte内存供后续分配使用。
heap_4方案能够合并相邻空闲内存块,最小化内存碎片化风险,并重复分配释放不同大小的内存。
图Figure-7演示了在内存分配分配释放时heap_4第一次匹配算法和合并是如何工作的:
- A显示创建3个任务后的内存数组布局
- B显示删除1个任务后的内存数组布局,空闲出了删除任务的任务控制块和任务栈空间,并合并为更大的空闲内存块
- C显示创建1个FreeRTOS队列,因heap_4的第一次匹配算法机制,
pvPortMalloc
将队列所需内存分配至刚删除任务释放出的位置,但未占用完有剩余 - D显示用户代码直接调用
pvPortMalloc
分配内存,按heap_4的第一次匹配算法机制,之前释放位置重新分配后还有剩余 - E显示刚创建的队列被删除了,空闲内存分布在用户申请的内存两侧
- F显示用户代码申请的内存释放了,分散的内存块又合并成大的空闲内存块
heap_4方案不是确定性的,但比标准库函数malloc
和free
执行快
heap_4方案内存数组起始地址设置:
有时会指定堆内存的位置,任务栈是从堆内存分配的,所以为了快速会将堆内存放在内部RAM而非外部RAM,默认情况下heap_4方案的堆内存是在链接文件中配置的,但若开启了宏
configAPPLICATION_ALLOCATED_HEAP
,那内存数组就由应用程序提供,命名是uint8_t ucHeap
,大小由宏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_5可以在多段独立的内存空间分配,而heap_4只能用于单个内存空间。heap_5方案使用场景是,运行FreeRTOS的系统提供的堆内存并非连续的一片内存。
在编写时,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
{
/* 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;
示例说明heap_5的多段内存使用过程
在构建项目时,构建过程的链接阶段会为每个变量分配一个RAM地址。可由链接器使用的RAM通常由链接器配置文件(如链接器脚本)描述。在Figure-8 B中,假定链接器脚本包含RAM1上的信息,但不包含RAM2或RAM3上
的信息。因此链接器在RAM1中放置了变量,只留下RAM1地址0x0001nnnn以上的部分可以通过heap_5使用。0x0001nnnn的实际值取决于被链接的应用程序中包含的所有变量的大小。链接器让所有RAM2和RAM3都未使用,
让整个RAM2和RAM3都可以被heap_5使用。
/* 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. */
}
如果使用上述所示的代码,分配给地址0x0001nnnn下的heap_5的RAM将与用于保存变量的RAM重叠。为了避免这种情况,xHeapRegions
[]数组中的第一个HeapRegion_t
结构体可以使用0x0001nnnn的起始地址,而不是0x00010000的起始地址。
但是,这不是一个推荐的解决方案,因为:
- 起始地址不易确定
- 链接脚本文件会不定期修改,导致结构
HeapRegion_t
起始地址也要不停修改 - 如果链接器使用的RAM和heap_5使用的RAM重叠,构建工具将不知道,因此不能警告应用程序编写器
为解决这种场景,代码实现如下:
第一块内存使用静态数组ucHeap
,它是分配在链接文件指定的RAM1中,结构HeapRegion_t
起始地址使用ucHeap
,所以它将是heap_5管理内存的一部分,这样就避免了频繁的修改,如图Figure-8 C所示
/* 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. */
};
这种技术的优势是:
- 不必写死内存起始地址
- 结构
HeapRegion_t
中的起始地址是通过链接文件自动设置的,省去了频繁修改的烦恼 - heap_5管理的内存与链接文件分配的内存不可能重叠
ucHeap
过大则应用程序link失败
堆内存相关的实用函数
- 获取空闲内存的大小,可用于优化堆内存大小
size_t xPortGetFreeHeapSize( void );
- 获取FreeRTOS应用程序自开始执行以来堆中存在的最小未分配字节数,可用于堆内存溢出检查
size_t xPortGetMinimumEverFreeHeapSize( void );
- 内存分配失败后调用的钩子函数,需要设置宏configUSE_MALLOC_FAILED_HOOK,可用于代码鲁棒性检查
void vApplicationMallocFailedHook( void );