首页 > 编程学习 > FreeRTOS解析:Mem - 内存管理

FreeRTOS解析:Mem - 内存管理

发布时间:2022/8/12 13:35:55

FreeRTOS解析:Mem - 内存管理

受博客限制,如果您想获得更好的阅读体验,请前往https://github.com/Nrusher/FreeRTOS-Book或者https://gitee.com/nrush/FreeRTOS-Book下载PDF版本阅读,如果您觉得本文不错也可前往star,以示对作者的鼓励。如发现问题欢迎交流。

FreeRTOS提供了5种不同的内存管理策略以应对不同的应用场景,本章将对这5种不同的内存管理策略的实现进行分析。(本章中部分图未画画出堆上字节对齐处理细节,请自行理解)

heap_1.c

heap_1.c所实现的内存管理方法十分简单,其可以使用pvPortMalloc()函数来申请内存,一旦申请成功了,便无法被释放。其实现大致可以用一句话概括,**在堆空间剩余时,按需分割出内存,并记录已用的内存大小。**heap_1.c使用的内存管理算法虽然简单,但对于许多嵌入式应用场景是适用且有效的。

在heap_1.c中,堆被定义为一个大数组,并使用变量xNextFreeByte记录已用内存大小

    static uint8_t ucHeap[ configTOTAL_HEAP_SIZE ];
    static size_t xNextFreeByte = ( size_t ) 0;

核心代码pvPortMalloc()函数实现如下

    void *pvPortMalloc( size_t xWantedSize )
    {
    void *pvReturn = NULL;
    static uint8_t *pucAlignedHeap = NULL;
    
        /* 如果对齐字节数不为1,则对请求的字节数做调整 */
        #if( portBYTE_ALIGNMENT != 1 )
        {
            if( xWantedSize & portBYTE_ALIGNMENT_MASK )
            {
                xWantedSize += ( portBYTE_ALIGNMENT - ( xWantedSize & portBYTE_ALIGNMENT_MASK ) );
            }
        }
        #endif

        /* 挂起任务 */
        vTaskSuspendAll();
        {
            if( pucAlignedHeap == NULL )
            {
                /* 确保堆的起始地址是按字节对齐的(与操作可能会使地址值减小,因此使用&ucHeap[portBYTE_ALIGNMENT]作为起始地址) */
                pucAlignedHeap = ( uint8_t * ) ( ( ( portPOINTER_SIZE_TYPE ) &ucHeap[ portBYTE_ALIGNMENT ] ) & ( ~( ( portPOINTER_SIZE_TYPE ) portBYTE_ALIGNMENT_MASK ) ) );
            }
    
            /* 检查是否有足够的空间分配 */
            if( ( ( xNextFreeByte + xWantedSize ) < configADJUSTED_HEAP_SIZE ) &&
                ( ( xNextFreeByte + xWantedSize ) > xNextFreeByte )	)/* 防止数值溢出 */
            {
                /* 返回首地址 */
                pvReturn = pucAlignedHeap + xNextFreeByte;
                /* 记录已分配空间大小 */
                xNextFreeByte += xWantedSize;
            }
        }
        /* 恢复任务 */
        ( void ) xTaskResumeAll();
    
        return pvReturn;
    }

需要注意的是,并未使用configTOTAL_HEAP_SIZE代表堆大小,而是用configADJUSTED_HEAP_SIZE表示堆大小,configADJUSTED_HEAP_SIZE定义如下

    #define configADJUSTED_HEAP_SIZE	( configTOTAL_HEAP_SIZE - portBYTE_ALIGNMENT )

这里简单粗暴的丢弃掉了对齐字节数个字节,以此来表示堆的起始地址对齐的操作中损失的字节数(最多不会损失掉对齐字节数个字节)。个人认为这里可以改进一下,节省使用内存,既计算出对齐字节操作具体损失的字节数计入后续运算中(其实也节省不了多少内存)。

heap_2.c

与heap_1.c不同,heap_2.c允许使用vPortFree()函数来释放申请的内存,其算法原理是将空闲堆分为若干个大小不等的内存块,并将其按大小排序,使用单向链表连接起来,申请内存时,便从这些链表中寻找最小可满足申请需求的内存块进行分配。分配过程分为两步,首先将原先的内存块的链表项从链表中删除,其次是对当前内存块进行分割,将多余申请数的那部分内存变为新的链表项重新插入到链表中。释放过程则更为简单,只需要将释放的内存块重新插入到链表中即可。

首先分析heap_2.c是如何表示一个内存块并将内存块加入链表中的。内存块的链表项定义如下

    typedef struct A_BLOCK_LINK
    {
    	struct A_BLOCK_LINK *pxNextFreeBlock;	/* 指向下一个未被使用的内存块 */
    	size_t xBlockSize;						/* 内存块的大小(包括BlockLink_t头部大小) */
    } BlockLink_t;

将内存块插入链表的操作如下

    /* 按内存块大小排序,将内存块插入链表中,顺序是由小到大 */
    #define prvInsertBlockIntoFreeList( pxBlockToInsert )								\
    {																					\
    BlockLink_t *pxIterator;															\
    size_t xBlockSize;																	\
                                                                                        \
        xBlockSize = pxBlockToInsert->xBlockSize;										\
                                                                                        \
        /* 按内存块的大小查找插入的位置 */											    \
        for( pxIterator = &xStart; pxIterator->pxNextFreeBlock->xBlockSize < xBlockSize; pxIterator = pxIterator->pxNextFreeBlock )	\
        {																				\
                                                                                    	\
        }																				\
                                                                                        \
        /* 插入操作 */		                                                          \
        pxBlockToInsert->pxNextFreeBlock = pxIterator->pxNextFreeBlock;					\
        pxIterator->pxNextFreeBlock = pxBlockToInsert;									\
    }

BlockLink_t只描述了内存块的大小和内存块的链接关系,具体分配出的内存是如何表示的呢?其表示方式如下图所示

在这里插入图片描述
其实具体可分配的内存块是直接跟在BlockLink_t结构体后的,BlockLink_t相当于一个内存块的头部。若有一个BlockLink_t实例为Block2,则其表示的内存块地址范围为&Block2+heapSTRUCT_SIZE至&Block2+xBlockSize之间。为了字节对齐,heapSTRUCT_SIZE是指BlockLink_t字节对齐后的大小,其定义如下

    static const uint16_t heapSTRUCT_SIZE = ((sizeof(BlockLink_t) + (portBYTE_ALIGNMENT - 1)) & ~portBYTE_ALIGNMENT_MASK);

堆的初始化

heap_2.c的堆初始化函数如下

    static void prvHeapInit(void)
    {
        BlockLink_t *pxFirstFreeBlock;
        uint8_t *pucAlignedHeap;
    
        /* 字节对齐,调整堆的起始地址。 */
        pucAlignedHeap = (uint8_t *)(((portPOINTER_SIZE_TYPE)&ucHeap[portBYTE_ALIGNMENT]) & (~((portPOINTER_SIZE_TYPE)portBYTE_ALIGNMENT_MASK)));
    
        /* 初始化链表头xStart,其指向的下一个链表项是第一个普通的内存块,地址位于堆对齐操作后的起始地址 */
        xStart.pxNextFreeBlock = (void *)pucAlignedHeap;
        xStart.xBlockSize = (size_t)0;
    
        /* 初始化链表尾xEnd */
        xEnd.xBlockSize = configADJUSTED_HEAP_SIZE;
        xEnd.pxNextFreeBlock = NULL;
    
        /* 初始化第一个内存块,块大小为整个堆 */
        pxFirstFreeBlock = (void *)pucAlignedHeap;
        pxFirstFreeBlock->xBlockSize = configADJUSTED_HEAP_SIZE;
        pxFirstFreeBlock->pxNextFreeBlock = &xEnd;
    }

链表起始时只有三个内存块,一块是链表的头,被定义为xStart,内存块大小为0;另一块是链表的尾部,被定义为xEnd,内存块大小为整个堆大小configADJUSTED_HEAP_SIZE(configADJUSTED_HEA_SIZE是堆对齐操作后的大小)。和普通内存块相比,xStart和xEnd具有一些特殊性。

  • 不参与实际的内存分配操作。

  • xStart和xEnd都不存储在堆上。

最后一个内存块是实际用于内存分配的普通块,其被定义在堆对齐后的起始地址上,块大小为整个堆大小configADJUSTED_HEAP_SIZE。经初始化后,整个堆的状态可以用下图表示

在这里插入图片描述

内存分配

heap_2.c的内存申请函数如下

    void *pvPortMalloc(size_t xWantedSize)
    {
        BlockLink_t *pxBlock, *pxPreviousBlock, *pxNewBlockLink;
        static BaseType_t xHeapHasBeenInitialised = pdFALSE;
        void *pvReturn = NULL;
    
        vTaskSuspendAll();
        {
            /* 未进行堆初始化则进行堆初始化 */
            if (xHeapHasBeenInitialised == pdFALSE)
            {
                prvHeapInit();
                xHeapHasBeenInitialised = pdTRUE;
            }
    
            if (xWantedSize > 0)
            {
                /* 重新计算所需块大小 */
                xWantedSize += heapSTRUCT_SIZE;
    
                /*字节对齐操作,调整实际申请字节数 */
                if ((xWantedSize & portBYTE_ALIGNMENT_MASK) != 0)
                {
                    xWantedSize += (portBYTE_ALIGNMENT - (xWantedSize & portBYTE_ALIGNMENT_MASK));
                }
            }
    
            if ((xWantedSize > 0) && (xWantedSize < configADJUSTED_HEAP_SIZE))
            {
                /* 查找合适的内存块进行分配,链表有序,由小到大依次查找即可 */
                pxPreviousBlock = &xStart;
                pxBlock = xStart.pxNextFreeBlock;
                while ((pxBlock->xBlockSize < xWantedSize) && (pxBlock->pxNextFreeBlock != NULL))
                {
                    pxPreviousBlock = pxBlock;
                    pxBlock = pxBlock->pxNextFreeBlock;
                }
    
                /* 如果找到匹配的内存块,进行内存分配操作 */
                if (pxBlock != &xEnd)
                {
                    /* 返回首地址值(跳过heapSTRUCT_SIZE) */
                    pvReturn = (void *)(((uint8_t *)pxPreviousBlock->pxNextFreeBlock) + heapSTRUCT_SIZE);
    
                    /* 从链表中移除对应的内存块 */
                    pxPreviousBlock->pxNextFreeBlock = pxBlock->pxNextFreeBlock;
    
                    /* 如果内存块较大,将剩余的内存作为一个新的内存块插入到链表中 */
                    if ((pxBlock->xBlockSize - xWantedSize) > heapMINIMUM_BLOCK_SIZE)
                    {
                        /* 初始化相应的块头部BlockLink_t结构体 */
                        pxNewBlockLink = (void *)(((uint8_t *)pxBlock) + xWantedSize);
    
                        /* 更新相应的块地址大小 */
                        pxNewBlockLink->xBlockSize = pxBlock->xBlockSize - xWantedSize;
                        pxBlock->xBlockSize = xWantedSize;
    
                        /* 将分割出的新块插入链表中 */
                        prvInsertBlockIntoFreeList((pxNewBlockLink));
                    }
                    /* 记录堆剩余字节数 */
                    xFreeBytesRemaining -= pxBlock->xBlockSize;
                }
            }
        }
        (void)xTaskResumeAll();
    
        return pvReturn;
    }

阅读以上代码,可以总结出内存分配主要分为以下几个步骤

  1. 调整实际需要申请的字节数。

  2. 检测申请字节数是否合法,若合法,则寻找合适的内存块进行分配。

  3. 将分配出去的内存块从链表中移除,若剩余内存大于最小内存块大小,则将剩余的内存块重新添加回链表中。

  4. 记录剩余字节数,返回分配内存空间的地址。

堆在初始状态下,进行一次成功的内存分配后,其状态如下图所示
在这里插入图片描述

内存释放

根据前文的铺垫,很容易便可以想到,想要释放被占用的内存,只需要将对应的内存块重新添加到链表中即可。heap_2.c的vPortFree()函数的实现如下

    void vPortFree(void *pv)
    {
        uint8_t *puc = (uint8_t *)pv;
        BlockLink_t *pxLink;
    
        /* 判定释放地址是否合法 */
        if (pv != NULL)
        {
            /* 定位内存块头部 */
            puc -= heapSTRUCT_SIZE;
    
            /* 注:FreeRTOS原注释为 This unexpected casting is to keep some compilers from
              issuing byte alignment warnings. 笔者并不懂其具体作用*/
            pxLink = (void *)puc;
    
            vTaskSuspendAll();
            {
                /* 将内存块重新添加到链表中 */
                prvInsertBlockIntoFreeList(((BlockLink_t *)pxLink));
                xFreeBytesRemaining += pxLink->xBlockSize;
            }
            (void)xTaskResumeAll();
        }
    }

在vPortFree()的实现上,笔者认为在判定释放地址是否合法这一步上可以进一步提高安全性,**既判断pv值是否为堆的字节对齐地址,或则直接查询根据该地址定位出是否是某一空闲内存块的下一地址。**对上一节分配的内存进行释放后,堆的状态如下图所示
在这里插入图片描述
从上图可以看出,随着申请-释放的次数增加,heap_2.c将使得内存块被越分越小,这会导致以下两个问题

  1. 当需要再次请求一个大的内存块时,即使xFreeBytesRemaining大于请求的内存块,其也无法进行分配了。

  2. 大量的内存被BlockLink_t头部占用,导致堆的利用率降低。

从上图中也可以得到解决方案,将相邻的内存块合并便可以缓解碎片化的问题,FreeRTOS也提供了对应的实现heap_4.c。

heap_3.c

heap_3.c只是对stdlib.h中的malloc()函数做了简单的封装,这里不做分析。

heap_4.c

相比heap_2.c,heap_4.c可以实现相邻小内存块的合并,在一定程度上缓解内存碎片化的问题。

内存块链表的插入

在pvPortMalloc()、pvPortFree()、prvHeapInit()的实现方式与heap_2.c的实现思路大致相同,内存块合并算法主要是在prvInsertBlockIntoFreeList()函数中实现。与heap_2.c中按内存块大小顺序插入不同,heap_4.c是按地址大小的顺序插入,这样便于合并相邻的内存块。插入过程分为以下两步

  1. 查找链表中链接的下一个内存块地址大于待插入内存块地址的第一个内存块(也就是与待插入内存块相邻的且地址较低的那一个内存块)的地址。

  2. 检测待插入内存块是否能与相邻的内存块合并。若能与低地址的相邻内存块合并,直接在低地址相邻的内存块大小上加上待插入内存块大小;若能与高地址的相邻内存块合并或可以同时将高低地址邻近内存块相连,则需要同时调整链表指针与内存块大小。

例如,想要将如下的使用中的内存块归还给堆
在这里插入图片描述
其会先遍历链表寻找LowAddressAdjacentBlock,然后判断,若BlockToInsert仅能和LowAddressAdjacentBlock合并,则将LowAddressAdjacentBlock的块大小更改为LowAddressAdjacentBlock与BlockToInsert大小的和;若BlockToInsert仅能和HighAddressAdjacentBlock合并,则用BlockToInsert替换HighAddressAdjacentBlock在链表中的位置,并修改块大小为两者之和;若BlockToInsert能将LowAddressAdjacentBlock与HighAddressAdjacentBlock连接成一整块,则从链表中删除HighAddressAdjacentBlock,并将LowAddressAdjacentBlock的块大小变为三者之和;若BlockToInsert是一个孤立的内存块则将其正常的插入到LowAddressAdjacentBlock与HighAddressAdjacentBlock之间

prvInsertBlockIntoFreeList()函数的具体实现如下

    static void prvInsertBlockIntoFreeList(BlockLink_t *pxBlockToInsert)
    {
        BlockLink_t *pxIterator;
        uint8_t *puc;
    
        /* 找到地址较低的邻近内存块地址 */
        for (pxIterator = &xStart; pxIterator->pxNextFreeBlock < pxBlockToInsert; pxIterator = pxIterator->pxNextFreeBlock)
        {

        }
    
        /* 检验待插入内存块是否紧接在低地址邻近内存块后 */
        puc = (uint8_t *)pxIterator;
        if ((puc + pxIterator->xBlockSize) == (uint8_t *)pxBlockToInsert)
        {
            /* 如果是,改变低地址邻近内存块的内存块大小 */
            pxIterator->xBlockSize += pxBlockToInsert->xBlockSize;
            /* 改变待插入内存块地址,将插入内存块与低地址邻近内存块邻近内存块合并后的块看作是新的待插入块 */
            pxBlockToInsert = pxIterator;
        }
    
        /* 检验待高地址邻近内存块是否紧接在待插入内存块(或待插入内存块与低地址邻近内存块邻近内存块
        合并后的块)后 */
        puc = (uint8_t *)pxBlockToInsert;
        if ((puc + pxBlockToInsert->xBlockSize) == (uint8_t *)pxIterator->pxNextFreeBlock)
        {
            if (pxIterator->pxNextFreeBlock != pxEnd)
            {
                /* 计算合并的内存块大小 */
                pxBlockToInsert->xBlockSize += pxIterator->pxNextFreeBlock->xBlockSize;
                /* 调整链表链接位置 */
                pxBlockToInsert->pxNextFreeBlock = pxIterator->pxNextFreeBlock->pxNextFreeBlock;
            }
            else
            {
                /* pxEnd特殊处理 */
                pxBlockToInsert->pxNextFreeBlock = pxEnd;
            }
        }
        else
        {
            /* 如果待插入内存块与高地址邻近内存块不能合并,调整待插入内存块的下一链接为高地址邻近内存块 */
            pxBlockToInsert->pxNextFreeBlock = pxIterator->pxNextFreeBlock;
        }
    
        /* 如果pxIterator与pxBlockToInsert值不等,意味着低地址邻近内存块的内存块与待插入内存块并未合并,因此需要将待插入内存块挂接在pxIterator后面 */
        if (pxIterator != pxBlockToInsert)
        {
            pxIterator->pxNextFreeBlock = pxBlockToInsert;
        }
    }

这样每次插入时,便可自动的合并掉相邻的内存块,以生成更大的内存块。这是否意味着内存的碎片化问题被解决了呢?并没有,可以看以下的一个示例,当其中的Used2被释放,是其仍然会产生内存碎片,除非Used1或Used3被释放,其才可能被拼接成较大的内存块。
在这里插入图片描述
虽然未能彻底解决内存碎片化的问题,但这一方法相比heap_2.c有了很大的改进。

堆的初始化

heap_4.c的堆初始化与heap_2.c的初始化大同小异,不同点有以下两点

  1. 其使用BlockLink_t结构体成员xBlockSize的最高位来标记一个内存块是否被使用,1表示在使用,0表示空闲。

  2. 原本的xEnd被定义在了堆上,且是堆的尾部,用pxEnd指向其地址。

heap_4.c初始化堆后,堆状态为
在这里插入图片描述
对于其具体实现代码不做过多解释,可以参照之前的初始化代码来理解。这里将xEnd放到堆中是为了对链表尾部的内存块做合并处理,如果不将xEnd放到堆的尾部,那么若编译器分配xEnd地址时,将xEnd分配到一个堆前的地址,则此时堆底部的内存块想要进行释放操作时,按之前的插入算法其将不能被正确处理。那么xStart是否有必要也放到堆首呢?分析可知这是无必要的,xStart的地址对于插入合并算法的执行没有影响。

内存的申请与释放

heap_4.c的内存的申请与释放过程与heap_2.c相比除了增加了对xBlockSize最高位的处理外,没有太大的不同,这里便不加以赘述。

heap_5.c

之前的heap_1.c,heap_2.c和heap_4.c都将堆定义成了一个大数组,这意味着堆的地址必须是连续的,但在实际使用时,有有时需要管理两大块或更多的不连续内存,这时便可以使用heap_5.c这一实现。其实在之前的heap_2.c和heap_4.c中,已经实现了对不连续内存的管理,与heap_4.c相比,heap_5.c的改变仅仅是在堆的初始化上,其将多个堆内存块加入了链表而已。

堆的初始化

heap_5.c的堆初始化由vPortDefineHeapRegions()这一函数实现,其传入参数是一个具有特定格式的结构体数组。

    typedef struct HeapRegion
    {
    	uint8_t *pucStartAddress;
    	size_t xSizeInBytes;
    } HeapRegion_t;

    HeapRegion_t xHeapRegions[] =
    {
        /* 起始地址为0x80000000,大小为0x10000的内存块 */
        {(uint8_t *)0x80000000UL, 0x10000}, 
        /* 起始地址为0x90000000,大小为0xa0000的内存块,地址递增排序 */
        {(uint8_t *)0x90000000UL, 0xa0000}, 
        /* 结束标识符 */
        {NULL, 0}                           
    };

vPortDefineHeapRegions()所做的工作就是读取结构体数组中的每一个内存块信息,并将其编入链表中。以以上参数为例,初始化后堆的状态如下图所示

在这里插入图片描述
具体vPortDefineHeapRegions()的代码实现较为简单,这里不进行具体分析。

内存分配、释放等

参照heap_4.c的实现原理即可。

Copyright © 2010-2022 ngui.cc 版权所有 |关于我们| 联系方式| 豫B2-20100000