FreeRTOS提供了几个内存堆管理方案,有复杂的也有简单的。其中最简单的管理策略也能满足很多应用的要求,比如对安全要求高的应用,这些应用根本不允许动态内存分配的。
FreeRTOS也允许你自己实现内存堆管理,甚至允许你同时使用两种内存堆管理方案。同时实现两种内存堆允许任务堆栈和其它RTOS对象放置到快速的内部RAM,应用数据放置到低速的外部RAM。
每当创建任务、队列、互斥量、软件定时器、信号量或事件组时,RTOS内核会为它们分配RAM。标准函数库中的malloc()和free()函数有些时候能够用于完成这个任务,但是:
- 在嵌入式系统中,它们并不总是可以使用的;
- 它们会占用更多宝贵的代码空间;
- 它们没有线程保护;
- 它们不具有确定性(每次调用执行的时间可能会不同);
因此,提供一个替代的内存分配方案通常是必要的。
嵌入式/实时系统具有千差万别的RAM和时间要求,因此一个RAM内存分配算法可能仅属于一个应用的子集。
为了避免这个问题,FreeRTOS在移植层保留内存分配API函数。移植层在RTOS核心代码源文件之外(不属于核心源代码),这使得不同的应用程序可以提供适合自己的应用实现。当RTOS内核需要RAM时,调用pvPortMallo()函数来代替malloc()函数。当RAM要被释放时,调用vPortFree()函数来代替free()函数。
FreeRTOS下载包中提供5种简单的内存分配实现,本文稍后会进行描述。用户可以适当的选择其中的一个,也可以自己设计内存分配策略。
FreeRTOS提供的内存分配方案分别位于不同的源文件(heap_1.c、heap_2.c、heap_3.c、heap_4.c、heap_5.c)之中,源文件位于下载包FreeRTOSSourceportableMemMang文件夹中。选择哪种类型管理直接在编译时把原文件加入(比如在 makefile SRC中加入)即可, 堆大小是 FreeRTOSConfig.h 中的常量 configTOTAL_HEAP_SIZE,默认是17*1024,即17KB。
FreeRtos 内存管理提供的主要接口:
* pvPortMalloc() 对应 malloc()
* vPortFree() 对应 free()
* xPortGetFreeHeapSize() 获取剩余可分配内存大小
为了适配不同平台、场合需求,对接口提供了不同的实现。
内存对齐
在 portmacro.h (Source/Portable/ + 对应编译器 + 平台 目录下) 的常量 portBYTE_ALIGNMENT 定义了字节对齐,对应的这个变量决定了 portable.h 中的一个常量 portBYTE_ALIGNMENT_MASK, 对应关系如下:
portBYTE_ALIGNMENT | portBYTE_ALIGNMENT_MASK |
---|---|
8(表示以8个字节对齐) | 0x0007 |
4(表示以4个字节对齐) | 0x0003 |
2(表示以2个字节对齐) | 0x0001 |
1(表示以1个字节对齐) | 0x0000 |
在堆管理中涉及了一些字节对齐,此处做准备。
Heap_1
这个版本的堆管理,如源码注释
The simplest possible implementation of pvPortMalloc(). Note that this implementation does NOT allow allocated memory to be freed again.
实现 pvPortMalloc() 用于内存分配,但是不支持回收,适用于一些比较小的嵌入式设备,在系统 boot 后申请内存运行任务,队列和信号量等,在程序生命期内一般没有释放的需求。对于一些安全型的系统,一般是不允许动态申请的,满足设计需求下,越简单越安全。 Heap_1是所有实现中最简单的一个。一旦分配内存之后,它甚至不允许释放分配的内存。尽管这样,heap_1.c还是适用于大部分嵌入式应用程序。这是因为大多数深度嵌入式(deeplyembedded)应用只是在系统启动时创建所有任务、队列、信号量等,并且直到程序结束都会一直使用它们,永远不需要删除。
当需要分配RAM时,这个内存分配方案只是简单的将一个大数组细分出一个子集来。大数组的容量大小通过FreeRTOSConfig.h文件中的configTOTAL_HEAP_SIZE宏来设置。
API函数xPortGetFreeHeapSize()返回未分配的堆栈空间总大小,可以通过这个函数返回值对configTOTAL_HEAP_SIZE进行合理的设置。
heap_1功能简介:
- 用于从不会删除任务、队列、信号量、互斥量等的应用程序(实际上大多数使用FreeRTOS的应用程序都符合这个条件)
- 执行时间是确定的并且不会产生内存碎片
- 实现和分配过程非常简单,需要的内存是从一个静态数组中分配的,意味着这种内存分配通常只是适用于那些不进行动态内存分配的应用。
调用函数 pvPortMalloc( size_t xWantedSize ) 申请内存时,按顺序完成如下工作:
* 字节对齐
* 分配内存
* 调用钩子函数
* 返回分配内存地址
初始化
#define configADJUSTED_HEAP_SIZE ( configTOTAL_HEAP_SIZE - portBYTE_ALIGNMENT ) /* Allocate the memory for the heap. */ static uint8_t ucHeap[ configTOTAL_HEAP_SIZE ]; static size_t xNextFreeByte = ( size_t ) 0;
configADJUSTED_HEAP_SIZE 定义了实际可用的堆大小,因为保证字节的对齐,所以减去一个对齐的长度。
ucHeap[ configTOTAL_HEAP_SIZE ] 和 xNextFreeByte 分别对应堆 的地址和已经分配的值,堆实际上就是一个静态分配的大数组。
以下代码,均是函数 pvPortMalloc() 的内容
字节对齐处理
/* Ensure that blocks are always aligned to the required number of bytes. */ #if portBYTE_ALIGNMENT != 1 if( xWantedSize & portBYTE_ALIGNMENT_MASK ) { /* Byte alignment required. */ xWantedSize += ( portBYTE_ALIGNMENT - ( xWantedSize & portBYTE_ALIGNMENT_MASK ) ); } #endif
if 判断了申请的内存大小是否符合字节对齐,如果不符合,则进行对齐处理。举个例子,设置8字节对齐,你本来申请的 xWantedSize == 12 个byte,与 mask & 的结果是4(0100B), 不对齐,为了对齐,系统会 ”强迫症“ 多给你4个字节。实际上你不应该用到,因为你申请了12bye。
分配内存
vTaskSuspendAll(); { if( pucAlignedHeap == NULL ) { /* Ensure the heap starts on a correctly aligned boundary. */ // 字节对齐 pucAlignedHeap = ( uint8_t * ) ( ( ( portPOINTER_SIZE_TYPE ) &ucHeap[ portBYTE_ALIGNMENT ] ) & ( ( portPOINTER_SIZE_TYPE ) ~portBYTE_ALIGNMENT_MASK ) ); } /* Check there is enough room left for the allocation. */ // 边界判断 if( ( ( xNextFreeByte + xWantedSize ) < configADJUSTED_HEAP_SIZE ) && ( ( xNextFreeByte + xWantedSize ) > xNextFreeByte ) )/* Check for overflow. */ { /* Return the next free byte then increment the index past this block. */ // 切块蛋糕一样把内存分配出来 pvReturn = pucAlignedHeap + xNextFreeByte; xNextFreeByte += xWantedSize; } traceMALLOC( pvReturn, xWantedSize ); } xTaskResumeAll();
- 系统调用了 vTaskSuspendAll() 挂起所有任务,保证线程安全, 避免分配时被切任务导致出错。
- 对堆的首地址作对齐处理
可能有人疑问堆首地址不就是上面提到那个数组的首地址&ucHeap[0]
, 为什么这里要使用&ucHeap[ portBYTE_ALIGNMENT ] & portPOINTER_SIZE_TYPE
?
其实这个地方处理还是考虑对齐问题,举个例子就明白了:
设置8byte对齐, 假如&ucHeap[0]
地址是 0x00000006, 为了保证对齐,FreeRtos 直接在这个地址加上一个对齐的长度(保证&后不会越界访问),然后和 mark &一下,对应的,&ucHeap[ portBYTE_ALIGNMENT ]
对应了0x000000014,& 上 mark 就是 0x00000008。由于做了这个调整后,实际的堆大小改变了,所以 configADJUSTED_HEAP_SIZE 表示实际可用的内存大小 - 分配内存
Heap_1 比较简单,按顺序分配,所以只需要判断剩下的内存够大,直接切出来,更新已分配大小的值,返回地址就可以了
钩子函数调用&返回地址
定义了configUSE_MALLOC_FAILED_HOOK == 1 后, 当申请失败的时候会调用钩子函数, 也可以自己添加其他处理代码。
#if( configUSE_MALLOC_FAILED_HOOK == 1 ) { if( pvReturn == NULL ) { extern void vApplicationMallocFailedHook( void ); vApplicationMallocFailedHook(); } } #endif return pvReturn; }
Heap_1 的 vPortFree 函数就不提了。
Heap_2
和方案1不同,这个方案使用一个最佳匹配算法,它允许释放之前分配的内存块。Heap_2 内存分配使用最佳匹配算法(best fit algorithm),比如我们申请25k的内存,而可申请内存中有三块对应大小30K, 50K 和 100 K,按照最小匹配,这时候会把30k进行分割并返回申请内存的地址,剩余部分插回链表留待下次申请。
但是值得注意的是,Heap_2 支持内存回收,但是不会把碎片合并,对于每次申请内存大小都比较固定的,这个方式是没有问题的。但是,当其他情况时, 它不会把相邻的空闲块合成一个更大的块(换句话说,这会造成内存碎片)。
有效的堆栈空间大小由位于FreeRTOSConfig.h文件中的configTOTAL_HEAP_SIZE宏来定义。
API函数xPortGetFreeHeapSize()返回剩下的未分配堆栈空间的大小(可用于优化设置configTOTAL_HEAP_SIZE宏的值),但是不能提供未分配内存的碎片细节信息。
heap_2功能简介:
- 可以用于重复的分配和删除具有相同堆栈空间的任务、队列、信号量、互斥量等等,并且不考虑内存碎片的应用程序。
- 不能用在分配和释放随机字节堆栈空间的应用程序
- 如果一个应用程序动态的创建和删除任务,并且分配给任务的堆栈空间总是同样大小,那么大多数情况下heap_2.c是可以使用的。但是,如果分配给任务的堆栈不总是相等,那么释放的有效内存可能碎片化,形成很多小的内存块。最后会因为没有足够大的连续堆栈空间而造成内存分配失败。在这种情况下,heap_4.c是一个很好的选择。
- 如果一个应用程序动态的创建和删除队列,并且在每种情况下队列存储区域(队列存储区域指队列项数目乘以每个队列长度)都是同样的,那么大多数情况下heap_2.c可以使用。但是,如果队列存储区在每种情况下并不总是相等,那么释放的有效内存可能碎片化,形成很多小的内存块。最后会因为没有足够大的连续堆栈空间而造成内存分配失败。在这种情况下,heap_4.c是一个很好的选择。
- 应用程序直接调用pvPortMalloc() 和 vPortFree()函数,而不仅是通过FreeRTOS API间接调用。
- 如果你的应用程序中的队列、任务、信号量、互斥量等等处在一个不可预料的顺序,则可能会导致内存碎片问题,虽然这是小概率事件,但必须牢记。
- 不具有确定性,但是它比标准库中的malloc函数具有高得多的效率。
heap_2.c适用于需要动态创建任务的大多数小型实时系统(smallreal time)。
开始和 Heap_1 差不多, 在内存中开辟了一个静态数组作为堆的空间,定义大小,字节对齐处理等。
建立链表
Heap_2 通过一个链表维护未分配的内存,链表节点定义:
typedef struct A_BLOCK_LINK { struct A_BLOCK_LINK *pxNextFreeBlock; /*<< The next free block in the list. */ size_t xBlockSize; /*<< The size of the free block. */ } BlockLink_t;
两个变量分别是指向下一块内存的地址指针 pxNextFreeBlock 以及自己的内存大小 xBlockSize。
在第一次申请内存的时候会调用初始化函数 prvHeapInit() 初始化列表。初始化包括链表头 xStart 和链表尾 xEnd (这两个节点不包含空闲内存),以及把整个堆作为一个完整的空闲节点。
每块内存(分配出的和未分配的)的结构如下 :
下一个空闲块地址 | 当前块大小 | 当前块可用内存 |
---|
每块的开头节点数据,提供了分配内存或者回收内存所需要的信息。
分配内存
当我们尝试申请内存的时候,除了和 Heap_1 一样进行对齐等处理外,系统会在我们申请内存大小 xWantedSize 的基础上增加一个 heapSTRUCT_SIZE (链表节点对齐后的大小)的链表节点,记录这块分配出去的内存的大小,供回收的时候使用。
从链表头开始遍历未分配内存链表,查找符合大小的内存块(链表按内存块大小排列,所以最先返回的的块最符合申请内存大小,所谓的最匹配算法就是这个意思来的)。返回该块 heapSTRUCT_SIZE 个字节后的地址给函数调用者, 前面预留的字节保留链表节点信息。
同时会判断当前这块内存是否有剩余(大于一个链表节点所需空间),如果有,就把剩余的内存再新建一个未分配内存块节点,插入到未分配链表中,供下次分配使用。其中 prvInsertBlockIntoFreeList() 这个宏函数是把节点按大小插入到链表中。
if( ( xWantedSize > 0 ) && ( xWantedSize < configADJUSTED_HEAP_SIZE ) ) { /* Blocks are stored in byte order - traverse the list from the start (smallest) block until one of adequate size is found. */ pxPreviousBlock = &xStart; pxBlock = xStart.pxNextFreeBlock; // 寻找匹配的内存块节点 while( ( pxBlock->xBlockSize < xWantedSize ) && ( pxBlock->pxNextFreeBlock != NULL ) ) { pxPreviousBlock = pxBlock; pxBlock = pxBlock->pxNextFreeBlock; } /* If we found the end marker then a block of adequate size was not found. */ if( pxBlock != &xEnd ) { /* Return the memory space - jumping over the BlockLink_t structure at its start. */ //返回给我们用地址,前面 heapSTRUCT_SIZE 保存链表节点数据 pvReturn = ( void * ) ( ( ( uint8_t * ) pxPreviousBlock->pxNextFreeBlock ) + heapSTRUCT_SIZE ); /* This block is being returned for use so must be taken out of the list of free blocks. */ pxPreviousBlock->pxNextFreeBlock = pxBlock->pxNextFreeBlock; /* If the block is larger than required it can be split into two. */ // 内存块比较大,拆分多余的内存,插回到链表 if( ( pxBlock->xBlockSize - xWantedSize ) > heapMINIMUM_BLOCK_SIZE ) { /* This block is to be split into two. Create a new block following the number of bytes requested. The void cast is used to prevent byte alignment warnings from the compiler. */ pxNewBlockLink = ( void * ) ( ( ( uint8_t * ) pxBlock ) + xWantedSize ); /* Calculate the sizes of two blocks split from the single block. */ pxNewBlockLink->xBlockSize = pxBlock->xBlockSize - xWantedSize; pxBlock->xBlockSize = xWantedSize; /* Insert the new block into the list of free blocks. */ // 按内存大小插入 prvInsertBlockIntoFreeList( ( pxNewBlockLink ) ); } xFreeBytesRemaining -= pxBlock->xBlockSize; } }
回收内存
回收内存, 拿到分配时返回的地址,向前索引到对应链表节点,取出这块返回内存块的信息,调用链表插入函数,将这个节点归还。(线程安全)
puc -= heapSTRUCT_SIZE; /* This unexpected casting is to keep some compilers from issuing byte alignment warnings. */ pxLink = ( void * ) puc; vTaskSuspendAll(); { /* Add this block to the list of free blocks. */ prvInsertBlockIntoFreeList( ( ( BlockLink_t * ) pxLink ) ); xFreeBytesRemaining += pxLink->xBlockSize; traceFREE( pv, pxLink->xBlockSize ); } xTaskResumeAll();
Heap_3
eap_3.c简单的包装了标准库中的malloc()和free()函数,包装后的malloc()和free()函数具备线程保护。
heap_3.c功能简介:
- 需要链接器设置一个堆栈,并且编译器库提供malloc()和free()函数。
- 不具有确定性
- 可能明显的增大RTOS内核的代码大小
注:使用heap_3时,FreeRTOSConfig.h文件中的configTOTAL_HEAP_SIZE宏定义没有作用。
void *pvPortMalloc( size_t xWantedSize ) { void *pvReturn; vTaskSuspendAll(); // 挂起任务 { pvReturn = malloc( xWantedSize ); } xTaskResumeAll(); return pvReturn; } void vPortFree( void *pv ) { if( pv != NULL ) { vTaskSuspendAll(); { free( pv ); } xTaskResumeAll(); } }
Heap_4
这个方案使用一个最佳匹配算法,但不像方案2那样。它会将相邻的空闲内存块合并成一个更大的块(包含一个合并算法)。为了实现这个合并算法,空闲内存链表是按内存地址大小进行存储的(Heap_2 是按照内存块大小进行存储)。
有效的堆栈空间大小由位于FreeRTOSConfig.h文件中的configTOTAL_HEAP_SIZE来定义。
API函数xPortGetFreeHeapSize()返回剩下的未分配堆栈空间的大小(可用于优化设置configTOTAL_HEAP_SIZE宏的值),但是不能提供未分配内存的碎片细节信息。
heap_4.c功能简介:
- 可用于重复分配、删除任务、队列、信号量、互斥量等等的应用程序。
- 可以用于分配和释放随机字节内存的情况,并不像heap_2.c那样产生严重碎片。
- 不具有确定性,但是它比标准库中的malloc函数具有高得多的效率。
heap_4.c还特别适用于移植层代码,可以直接使用pvPortMalloc()和 vPortFree()函数来分配和释放内存。
xEnd 的位置
不同 heap_2 中 用一个静态变量 xEnd 作为链表尾,heap_4 把链表尾放在了堆的最后位置,如源码:
// 堆地址最后往回推一个链表节点的空间 uxAddress = ( ( size_t ) pucAlignedHeap ) + xTotalHeapSize; uxAddress -= xHeapStructSize; uxAddress &= ~( ( size_t ) portBYTE_ALIGNMENT_MASK ); pxEnd = ( void * ) uxAddress; pxEnd->xBlockSize = 0; pxEnd->pxNextFreeBlock = NULL;
xBlockAllocatedBit 判断
另外,为了安全,增加一个位(xBlockSize 的最高位)标记检测 Free 时传入地址的正确性,在初始化的时候设置 xBlockAllocatedBit
的值, 一个 size_t 大小的值最高位置1, 分配出去的内存块链表节点的 xBlockSize 或上,回收的时候判断,如果最高位不是1, 说明出错。
/* Work out the position of the top bit in a size_t variable. */ xBlockAllocatedBit = ( ( size_t ) 1 ) << ( ( sizeof( size_t ) * heapBITS_PER_BYTE ) - 1 );
进行字节对齐和线程安全等的操作和前面两种方式差不多。
链表插入 (合并实现)
Heap_2 中的链表插入是通过宏实现的,按内存块大小进行插入,而 Heap_4 的插入操作是一个函数,该函数按内存块地址进行插入(低位前),这么做是为了实现内存块合并。
如下, 准备插入的内存块p, 系统查找到内存地址对应与其前面的内存块A, 判断 A 和 P 之间是否还有其他分配的块,如果没有,直接合并; 然后再判断和内存C 的位置关系,没有其他分配了的内存块的话,就直接合并。
内存块 .. | 内存块 A | 准备插入内存块 P | 内存块 C |
---|
下面对应看看源码 :
static void prvInsertBlockIntoFreeList( BlockLink_t *pxBlockToInsert ) { BlockLink_t *pxIterator; uint8_t *puc; // for 的目的是知道 pxIterator 指向内存块A // pxIterator->pxNextFreeBlock 指向内存块C // 插入内存块P 刚好夹在中间 for( pxIterator = &xStart; pxIterator->pxNextFreeBlock < pxBlockToInsert; pxIterator = pxIterator->pxNextFreeBlock ) { /* Nothing to do here, just iterate to the right position. */ } // 判断内存块A 能否与插入内存块P 合并 // 合并条件:头尾衔接刚好 puc = ( uint8_t * ) pxIterator; if( ( puc + pxIterator->xBlockSize ) == ( uint8_t * ) pxBlockToInsert ) { pxIterator->xBlockSize += pxBlockToInsert->xBlockSize; pxBlockToInsert = pxIterator; } else { mtCOVERAGE_TEST_MARKER(); } // 判断内存块C 能否与插入块P 合并 puc = ( uint8_t * ) pxBlockToInsert; if( ( puc + pxBlockToInsert->xBlockSize ) == ( uint8_t * ) pxIterator->pxNextFreeBlock ) { // 如果是内存块C 是最后一个节点 xEnd,不能合并 if( pxIterator->pxNextFreeBlock != pxEnd ) { pxBlockToInsert->xBlockSize += pxIterator->pxNextFreeBlock->xBlockSize; pxBlockToInsert->pxNextFreeBlock = pxIterator->pxNextFreeBlock->pxNextFreeBlock; } else { pxBlockToInsert->pxNextFreeBlock = pxEnd; } } else { // 内存块C 不能合并的话,让上一块指向他 pxBlockToInsert->pxNextFreeBlock = pxIterator->pxNextFreeBlock; } if( pxIterator != pxBlockToInsert ) { // 内存块A 不能合并,更新他指向刚插入的内存块P pxIterator->pxNextFreeBlock = pxBlockToInsert; } else { mtCOVERAGE_TEST_MARKER(); } }
分配内存
相比 Heap_2 差别不大,主要是在分配过程多了一个位标记防止出错,因为使用了 xBlockSize 的最高位做标记,所以实际传入的 xWantedSize 最高位不能为1,否则超出范围。
其他差别不大,此处不做赘述。
回收内存
相比 Heap_2, Heap_4 多了一些检查,更加安全。、
// 判断位标记,判断指向下一个节点是否 设置为 Null configASSERT( ( pxLink->xBlockSize & xBlockAllocatedBit ) != 0 ); configASSERT( pxLink->pxNextFreeBlock == NULL ); if( ( pxLink->xBlockSize & xBlockAllocatedBit ) != 0 ) { if( pxLink->pxNextFreeBlock == NULL ) { ...
最后清除 xBlockSize 的高位标记,调用插入函数归还内存。
获取历史堆剩余最小值
可用于最坏情况下,堆的使用情况, 在每次调用 pvPortMalloc() 中进行更新。
size_t xPortGetMinimumEverFreeHeapSize( void )
heap_5.c(V8.1.0新增)
这个方案同样实现了heap_4.c中的合并算法,并且允许堆栈跨越多个非连续的内存区。
Heap_5通过调用vPortDefineHeapRegions()函数实现初始化,在该函数执行完成前不允许使用内存分配和释放。创建RTOS对象(任务、队列、信号量等等)会隐含的调用pvPortMalloc(),因此必须注意:使用heap_5创建任何对象前,要先执行vPortDefineHeapRegions()函数。
vPortDefineHeapRegions()函数只需要单个参数。该参数是一个HeapRegion_t结构体类型数组。HeapRegion_t在portable.h中定义,如下所示:
typedef struct HeapRegion { /* 用于内存堆的内存块起始地址*/ uint8_t *pucStartAddress; /* 内存块大小 */ size_t xSizeInBytes; } HeapRegion_t;
这个数组必须使用一个NULL指针和0字节元素作为结束,起始地址必须从小到大排列。下面的代码段提供一个例子。MSVCWin32模拟器演示例程使用了heap_5,因此可以当做一个参考例程。
/* 在内存中为内存堆分配两个内存块.第一个内存块0x10000字节,起始地址为0x80000000, 第二个内存块0xa0000字节,起始地址为0x90000000.起始地址为0x80000000的内存块的 起始地址更低,因此放到了数组的第一个位置.*/ const HeapRegion_t xHeapRegions[] = { { ( uint8_t * ) 0x80000000UL, 0x10000 }, { ( uint8_t * ) 0x90000000UL, 0xa0000 }, { NULL, 0 } /* 数组结尾. */ }; /* 向函数vPortDefineHeapRegions()传递数组参数. */ vPortDefineHeapRegions( xHeapRegions );