QuecPython +内存管理:如何正确应用存储空间

概述

内存指的是程序运行过程中使用的存储空间,用来存储当前运行程序的指令和数据,存储的内容会实时变化。内存采用的存储介质为RAM(random access memory)随机存储器,可以读取数据也可以写入数据,存储的数据在掉电之后会丢失。内存管理是嵌入式处理系统中的重要部分,主要负责对内存资源进行有效的分配和使用。因为内存的存储容量较小,且存储的内容是动态变化的,可以采用时分复用的策略来满足空间使用的需求。

QuecPython内存管理机制

根据运行程序中的数据使用方式,可以将内存空间分为静态存储空间和动态存储空间。而内存管理针对的是动态存储空间。整个内存的分布如下图:

其中代码段和常量数据属于只读空间。全局变量属于静态存储空间。堆属于动态存储空间。而从全局变量里划分出来的GC内存又属于动态存储空间。从堆里分配出来的栈空间也属于动态存储空间。

静态内存

概述

静态存储空间,指的是存储的数据在内存中的位置即地址是固定不变的。包含的数据类型有:全局变量和静态变量,这里指的是C语言开发层的变量类型。

全局变量

即访问属性为全局的变量,程序中的所有函数均可访问。分为未初始化的和已初始化的变量,两者的区别是已初始化的变量类型需要在非易失性存储器如ROM或flash中存储一份相同的数据。因为内存中的数据在掉电后就会丢失,而已初始化的数据需要每次断电上电重启后仍然保持,就需要在非易失性存储器中保存一份。

静态变量

即使用static字段修饰定义的变量。GC管理的整块空间就属于该类型的空间。

动态内存

概述

动态存储空间,指的是存储的数据在内存中的位置即地址是变化的。包含的数据有:堆和栈空间中分配的数据,这里指的是C语言开发层中的堆和栈。

栈空间用来存放函数的局部变量和形参。栈空间在给定之后,具体的使用由系统自动分配和释放,开发人员不可控制。且每个线程会指定不同的栈空间,线程删除后会释放该资源,线程栈空间也是从堆空间申请的。从数据结构来讲,栈空间只是栈后进先出数据结构的一种应用,函数调用和退出比较符合这种数据结构特点,分配释放使用效率较高,适合系统使用。

堆是一块大的内存空间,由开发人员分配和释放。开发人员可以控制。从数据结构来讲一般采用链表的形式管理,可以随机存取,比较灵活,适合开发人员使用。

RTOS内存管理算法

概述

这里内存管理只针对堆内存空间。模组在开机运行后,会预留一大块内存空间作为堆内存。该堆内存空间大小会随着应用程序的启动和停止动态变化。不同的RTOS类型拥有不同的内存管理机制。当前模组使用的RTOS类型主要有threadx和freertos两种。

常用算法
First Fit (首次适应算法)

First Fit要求空闲分区链表以地址从小到大的顺序连接。分配内存时,从链表的第一个空闲分区开始查找,将最先能够满足要求的空闲分区分配给进程。

Next Fit (循环首次适应算法)

Next Fit由First Fit算法演变而来。分配内存时,从上一次刚分配过的空闲分区的下一个开始查找,直至找到能满足要求的空闲分区。

Best Fit (最佳适应算法)

从所有空闲分区中找出能满足要求的、且大小最小的空闲分区。为了加快查找速度,Best Fit算法会把所有空闲分区按其容量从小到大的顺序链接起来,这样第一次找到的满足大小要求的内存必然是最小的空闲分区。

Worst Fit (最坏适应算法)

从所有空闲分区中找出能满足要求的、且大小最大的空闲分区。Worst Fit算法按其容量从大到小的顺序链接所有空闲分区。

Two Level Segregated Fit (TLSF)

使用两层链表来管理空闲内存,将空闲分区大小进行分类,每一类用一个空闲链表表示,其中的空闲内存大小都在某个特定值或者某个范围内。这样存在多个空闲链表,所以又用一个索引链表来管理这些空闲链表,该表的每一项都对应一种空闲链表,并记录该类空闲链表的表头指针。

Buddy systems(伙伴算法)

Segregated Fit算法的变种,具有更好的内存拆分和回收合并效率。伙伴算法有很多种类,比如Binary Buddies,Fibonacci Buddies等。Binary Buddies是最简单也是最流行的一种,将所有空闲分区根据分区的大小进行分类,每一类都是具有相同大小的空闲分区的集合,使用一个空闲双向链表表示。Binary Buddies中所有的内存分区都是2的幂次方。

内存算法优点缺点
First Fit高地址空间大空闲块被保留低地址空间被不断拆分,造成碎片;每次都从第一个空闲分区开始查找,增加了查找时的系统开销
Next Fit空闲分区分布比较均匀,算法开销小缺乏大内存空闲块
Best Fit用最小内存满足要求,保留大内存空闲块每次分配后所拆分出来的剩余空闲内存总是最小的,造成许多小碎片,算法开销大
Worst Fit每次分配后所拆分出来的剩余空闲内存仍较大,减小碎片产生缺乏大内存空闲块,算法开销大
TLSF查找效率高,时间复杂度小,碎片问题表现良好内存回收时算法复杂,系统开销大
Buddy systems内部碎片比较严重外部碎片较少
threadx
分配

内存分配使用首次适应(first-fit)算法,每次从第一个空闲内存块开始查找,直到找到大小合适的内存则返回成功。

内存块的管理方式采用的是链表方式,地址按从低到高排列。具体链接方式为每一块内存的前面8个字节是控制字段,其中前4字节存储下一块内存的起始地址,随后4个字节如果是空闲内存则存储TX_BYTE_BLOCK_FREE特定值,如果是已分配的内存则存储内存池管理结构体的起始地址。

初始化时将整个内存空间分成了2块,第一块和最后一块。第一块中的前4个字节存储最后一块的起始地址,后四个字节设置为TX_BYTE_BLOCK_FREE,因为还没有使用,标记为空闲块。最后一块的前4个字节存放内存池管理结构体的起始地址,后4字节标记为TX_BYTE_BLOCK_ALOC,表示最后一块。

下图为第一次分配内存后,最初的第一块内存被分为了两块:第一块和第二块。第一块返回给应用程序,第一块前面8个字节由于是控制字段,所以返回应用程序的内存起始地址memory_ptr跨过了前面8个字节,指向分配的地址。第一块的前4字节存储第二块内存起始地址,随后4个字节指向了内存池管理结构,标志着第一块内存已经被占用,不是空闲内存了。第二块内存前4个字节指向第三块内存(最后一块内存),继续构成单向链表。随后4个字节存储TX_BYTE_BLOCK_FREE,表示还是空闲内存块。

释放

内存释放时,根据释放内存首地址减8,计算出内存块控制字段的首地址,通过前四字节找到内存池管理结构体起始地址,可以进行内存释放操作。释放后,4到8字节存储TX_BYTE_BLOCK_FREE表示为空闲内存块。

这时如果内存是连续的,并不会进行合并或内存整理。等到申请内存时,发现请求大小大于当前块的大小,开始查找下一块时,这时发现当前块和下一块内存连续,就把当前和下一块合并为—块。

释放内存后,会检查挂起链表中是否有线程,如果有尝试分配内存并恢复线程执行。线程恢复是按照FIFO顺序恢复,并没有按照线程优先级高低顺序。但是可以在线程释放前调用t_byte_pool_prioritize,把最高优先级线程移动到挂起链表最前面,从而先恢复最高优先级线程。

碎片处理

内存在多次分配和释放后,可能会出现大量小的内存块,这种现象称为内存碎片化。

当需要分配一个较大内存时,每次可能需要先遍历大量小内存,这样会使查找开销增加,算法性能下降。由于每个内存块都占用8个字节的控制字段,大量小内存会导致内存的浪费。查找过程中,发现两个邻居内存块是地址连续的,那么把这两个内存块合并成为一个内存块,称为内存整理。
下图为多次内存分配和释放后的结构,第一块被占用,第二块,第三块,第四块为空闲内存块。第二块大小为64字节,第三块大小为64字节,第四块内存大小为256字节。假如现在申请分配内存大小为128字节,在分配查找过程中,发现第二块太小不满足,但第二块和第三块地址连续,于是把第二块和第三块合并为一块,并且发现合并后正好满足请求,返回给应用程序,如下面第二个图。

freertos

FreeRTOS提供了5种内存分配方法,FreeRTOS使用者可以选择其中的某一个方法,或者使用自己的内存分配方法。这5种方法对应FreeRTOS源码中的5个文件,分别为:heap_1.c、 heap_2.c、heap_3.c、 heap_4.c和heap_5.c。每种方法适用的场景如下:

方法适用场景
heap_1支持内存分配,但是不支持回收。适用于一些比较小的嵌入式设备,系统启动后申请内存,在程序生命期内一般没有释放的需求。
heap_2支持内存回收,但是不会把碎片合并。使用最佳适应算法(best fit)。适用于每次申请内存大小都比较固定的场景。
heap_3直接在标准库的malloc和free接口上加上线程安全。
heap_4支持内存分配,支持内存回收,支持内存碎片合并。适用于频繁分配释放不确定大小内存的场景。
heap_5支持多个不连续的区域组成堆,适用于一些内存分布不连续的嵌入式设备。

模组里使用的是heap_4方法,这里主要介绍heap_4的算法实现。

分配

内存分配使用首次适应(fisrt-fit)算法,每次从头开始查找空闲内存块,找到大小合适内存,返回成功。

通过一个链表维护未分配的内存。链表节点定义:

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。申请内存的时候,系统会在申请内存大小xWantedSize的基础上加上heapSTRUCT_SIZE(链表节点的大小)作为最终申请的大小。然后从链表头开始遍历未分配内存链表,查找符合大小的内存块。同时会判断当前这块内存是否有剩余(大于所申请大小),如果有,就把剩余的内存再新建一个未分配内存块节点,插入到未分配链表中,供下次分配使用。整体算法和上述threadx类似。

释放

内存释放时,根据释放的内存地址,向前索引到对应链表节点,取出该内存块的信息,调用链表插入函数,将这个节点归还。

碎片处理

通过上述分配过程看到,内存在多次分配和释放后,同样会产生内存碎片。所以需要把内存碎片合并成大块内存,合并算法和上述threadx类似。为了实现这个合并算法,空闲内存链表是按内存地址大小进行存储的。例如, 准备插入的内存块P, 系统查找到内存地址对应于其前面的内存块A,判断 A 和 P 之间是否还有其他被分配的块,如果没有,直接合并。 然后再判断内存地址对应于其后面的内存C 的位置关系,P和C之间没有其他被分配的内存块的话,就直接合并。

应用场景

底层动态内存申请和释放

底层程序运行过程中需要内存空间存储数据时就会调用malloc接口从堆内存中申请一定大小的内存。当业务执行结束不再需要该块空间时就会调用free接口释放内存并还原到堆内存中。

创建线程

程序运行过程中需要创建新的线程时,需要给该线程分配栈空间,该栈空间也是从堆内存中申请。当线程执行结束被删除时,会释放栈空间并还原到堆内存中。底层和应用层的程序都有可能创建线程。

常见问题:

如何避免内存碎片

内存管理算法可以一定程度上避免内存碎片,但是没法完全避免。少用动态内存分配的函数(尽量使用栈空间)。分配内存和释放内存尽量在同一个函数。尽量一次性申请较大的内存,而避免反复申请小内存(减少内存分割)。自行设计内存池管理内存。

堆安全剩余量

虽然堆空间是动态分配释放的,但是需要保证堆空间有足够的剩余以保证复杂业务时的申请需求。堆空间的总量是由底层决定的,固件生成后该值就确定了。调用接口_thread.get_heap_size可以查看当前堆空间剩余量,具体接口用法参考wiki描述

内存泄露

常见内存泄漏的场景有:

  • 分配和释放内存的操作不成对,即业务开始运行时申请的内存在业务结束时没有进行释放,然后下次业务再次运行时又重新申请内存,导致内存不断减少,最终可能导致整个内存被耗尽,进而导致整个程序申请不到内存而崩溃。
  • 重复创建线程,即底层或者应用层创建线程和删除线程处理不成对,导致重复创建相同业务的线程,而每创建一个线程都要从堆中分配一个栈空间,最终导致整个堆空间被耗尽。
内存越界

常见内存越界的场景有:

  • 数组越界,C语言实现层在使用一个数组类型的变量时,操作的索引号超过了实际分配的数组大小,就会导致内存越界到其他内存空间。可能会将其他内存空间的数据破坏导致系统程序异常。

  • 栈溢出,即应用层创建线程时传入的栈空间大小小于业务实际运行需要的空间。这样可能导致业务运行过程的数据操作越界到其他内存空间,将其他内存空间的数据破坏导致系统程序异常。

内存申请失败异常信息

底层内存申请失败通常会触发系统crash异常。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值