天天看点

精通FreeRTOS实时时钟内核Chapter2

第二章 堆内存管理

从FreeRTOS V9.0.0开始,FreeRTOS可以完全静态配置,而不用包含一个堆内存管理器。

2.1章节介绍

前提

FreeRTOS是作为一系列C源码给出的,所以一个合格的C程序员是使用FreeRTOS的先决条件。因此,此章节假定读者都熟悉以下概念:

  • 一个C项目是如何编译的,包含不同的编译和链接步骤。
  • 什么是栈,什么是堆。
  • 标准C库malloc()和free()函数。

变化的内存分配和其对于FreeRTOS的现实意义

FreeRTOS V9.0.0内核中,内核对象可以在一段时间内分配为静态的,或在运行时作为动态的。本书的一下章节将会介绍内核对象例如任务,队列,信号和事件组。为了使FreeRTOS尽可能用起来简单,这些内核对象都不是在编译时间静态分配的,而是在运行时动态分配。FreeRTOS在内核对象被创建时分配RAM,在内核对象被删除时回收内存。这一原则减少了设计难度,简化了API,最小化内存占用。

本章讨论动态内存分配。动态内存分配时C语言编程的概念,并不是FreeRTOS或多任务处理特有的概念。它与FreeEROS相关是因为内存对象动态分配和动态内存分配方案是由目标编译器提供,并不总是适合于实时应用。

内存可以使用标准C库的 malloc() 和 free() 函数来分配,但在以下情况下可能并不适用:

  • 在小型嵌入式系统可能并不适用。
  • 实现方案可能会相当大,占用很多宝贵的代码空间。
  • 很少情况下线程安全。
  • 并不具有确定性,使用大多数时间执行这个函数将会与印象其确定性。
  • 受到碎片化影响:如果存放堆的空闲RAM如果被分成相互分裂的小块,则堆就被认为是碎片。如果堆是碎片,如果堆内单个空闲块不够大来包含这个块,即使所有分裂的空闲块总量是要分配的块的很多倍,尝试分配内存块也将会失败。
  • 使链接配置复杂化。
  • 如果堆空间允许使用他们自己的变量去增加内存,则有可能在调试时产生错误。

动态内存分配选项

FreeRTOS V9.0.0内核中,内核对象可以在一段时间内分配为静态的,或在运行时作为动态的。早版本的FreeRTOS使用内存池分配策略。借此,不同大小的内存卡在编译时预先分配,然后由内存分配函数返回。尽管在实时时钟系统中普遍这样使用,但它被证明使许多主要问题的原因,因为对于非常小的实时系统来说它不能足够有效的使用RAM。所以我们放弃了该策略。

Free RTOS目前把内存分配当作portable层的一个组件(与内核层相反)。不同的嵌入式系统有不同的内存分配策略和时间上的需求,这是一个普遍认知的事实。所以单一的动态内存分配算法将只适合一些应用。此外,把动态内存分配从核心代码中移除使得程序员可以在适当的时候使用他们独特的解决方案。

在FreeRTOS请求RAM时,不是调用 malloc() ,而是调用pvPortMalloc() 。当RAM被释放时,不使调用 free() 而是调用vPortFree() 。pvPortMalloc() 与标准C库的 malloc()有相同的原型函数,而 vPortFree() 同 free() 也一样。

pvPortMalloc() 和 vPortFree() 都是共有函数,所以在应用代码中可以被调用。

FreeRTOS带有5个舍利子来说明pvPortMalloc() 和 vPortFree()的应用,在本章中都有详细说明。

五个例子分别定义在 heap_1.c, heap_2.c, heap_3.c, heap_4.c, heap_5.c,这五个文件中,存放在FreeRTOS/Source/portable/MemMang目录下。

范围

本章旨在让读者在以下几个方面有更好的理解:

  • FreeRTOS什么时候分配RAM。
  • 五个示例内存分配方案使怎么在FreeRTOS上实现的。
  • 选择哪种内存分配方案。

2.2 内存分配方案

去掉堆内存管理器,FreeRTOS V9.0.0应用可以完全使用静态的内存分配方法。

Heap_1

在小型专业嵌入式系统中,在调度器启动之前只创建任务和其他内核对象使很普遍的现象。在此情况下,在应用开始实现实时系统功能后,内存才开始被内核动态分配。所以在应用程序的生命周期中,内存早已经被分配。这就意味着选择内存分配策略没有考虑到任何更复杂的内存分配事件,例如决定性和碎片化,而不是只考虑到想代码尺寸和简化的贡献。

Heap_1.c 实现了一个 pvPortMalloc() 非常基础的版本,并且没有实现vPortFree()。那些从不删除任务和其他内核组件的应用程序建议使用 heap_1 内存分配方案。

一些商业上重要的,和安全性重要的会禁止使用动态内存分配系统有使用 heap_1 的可能。因为非决定性和内存的碎片化和错误分配所导致的不确定性,重要的系统通常禁止动态内存分配。但 Heap_1 总是决定性的,并且不会使内存割裂。

heap_1 分配策略调用pvPortMalloc()把单一数组再分成更小的块。这个数组被称为FreeRTOS heap。

数组的总长(bytes)由FreeRTOSConfig.h中的 configTOTAL_HEAP_SIZE 定义。用此方法定义的大型数组看起来像是程序使用了很多RAM——即使数组中没有分配任何内存。

每个创建好的任务都有一个任务控制模块(TCB)和一个从堆中分配出来的栈。图5将说明heap_1使如何像应用创建一样细分单个数组的。

  • A 显示的是没有任何任务被创建时——整个数组是空的。
  • B 显示的是有一个任务被创建后的数组。
  • C 显示的是由3个任务被创建后的数组。
精通FreeRTOS实时时钟内核Chapter2

Heap_2

Heap_2保留在FreeRTOS发行包中是为了向后兼容。但它不建议使用在新设计中。建议使用heap_4而非heap_2,因为heap_4提供增强的设计。

Heap_2.c仍然通过再分配一个由configTOTAL_HEAP_SIZE定义大小的数组起作用。不像heap_2.c,它使用最佳适应算法去分配内存,它确实允许内存释放。

重申,数组都是静态申明,所以会让内存应用看起来消耗量很多RAM,即使数组中没有任何内存被占用。

最佳适应算法确保 pvPortMalloc() 使用最接近需求的大小分配内存。例如,考虑如下可能发生的情况:

  • 堆包含3个空闲内存块,分别是5bytes,25bytes和100bytes。
  • pvPortMalloc()被调用申请20bytes的RAM.

适合请求数目的最小的空闲内存块是25-bytes块,所以 pvPortMalloc() 把25-byte块撕成一个20bytes的块和一个5bytes的块。(这是一个简化说法,因为heap_2会在堆中存放块大小信息,所以分开后两个块实际可用大小会小于25)。在返回一个20bytes块的指针后。新的5-byte空间仍然在以后可以被pvPortMalloc() 函数调用。

不像 heap_4.c,heap_2.c不会把相邻的空闲块连接池一个更大的整体,所以它更容易收到碎片化的影响。然而,在块被分配并且随后总是以相同大小释放的情况下,碎片化并不是问题。Heap_2 适合于那些重复新建和删除任务并为任务提供相同大小堆栈的应用。

精通FreeRTOS实时时钟内核Chapter2

图6说明了最佳适应算法在创建任务,删除任务和重新创建任务时时怎么工作的。参考图6

  1. A 显示了在3个任务被创建后,A的空闲块留在了数组的顶部。
  2. B 显示了在一个任务被删除后,空闲块在数组顶部。还有两个小的空闲块在之前分配给被删除的TCB和栈位置。
  3. C 显示了在有一个任务被新建情况下的占用。创建任务使得调用了两次pvPortMalloc(),一个分配了新的TCB,而另一个给任务分配了堆栈。通过使用xTaskCreate() API创建任务,在3.4章节会详细介绍。在xTaskCreate()中会调用pvPortMalloc()。每个TCB都是相同大小,所以最佳适应算法确保之前分配的RAM块可以被新任务的TCB重新利用。被分配给新任务的堆栈的大小和之前被删除任务的大小完全一样,所以最佳适应算法把已删除的任务使用过的堆栈分配给新任务。顶部的最大未分配块并未被使用。

heap_2 并不是确定性的,但是他比标准库的 malloc() 和 free() 要更快。

Heap_3

Heap_3.c使用标准库的 malloc() 和 free()函数,所以堆大小是由连接器配置,此时configTOTAL_HEAP_SIZE设置无效。

Heap_3通过暂时挂起FreeRTOS调度器使得 malloc() 和 free()线程安全。线程安全和调度器挂起都是第七章资源管理中要谈到的要点。

Heap_4

像heap_1和heap_2一样,heap_4也是通过给数组再划分成更小的块来工作的。正如之前的一样,数组是静态申明,长度由configTOTAL_HEAP_SIZE定义,所以也会导致看起来程序消耗了大部分RAM,实际上在内存没有再分配之前都是空的。

heap_4使用首次适应算法分配内存。不像heap_2一样,heap_4把相邻的空闲块连接成单个更大的块,这使得内存碎片化的风险最小化。

首次适应算法使得 pvPortMalloc() 使用最前面的足够大满去要求的内存空闲块。例如,考虑如下可能发生的情况:

  • 堆含有3个空闲块,从他们再数组中出现的顺序看,分别是5bytes,200bytes和100bytes的大小。
  • pvPortMalloc()被调用请求20bytes的内存。

第一个能满足RAM请求大小的空闲块就是200bytes大小的块,所以pvPortMalloc()把200-byte那一块撕裂成一个20bytes的块和一个180bytes的块。(这里简化处理,因为heap_4再堆中存储了块大小信息,所以实际两块能用的内存总和会小于200bytes)返回20bytes块指针后,新生成的180bytes块在以后仍能被 pvPortMalloc() 请求调用。

heap_4会把相邻的未占用的块拼成一个更大的块,来最小化碎片化的风险,并使其更容易被应用重复分配,并且释放不同大小的空间。

精通FreeRTOS实时时钟内核Chapter2

图7说明了heap_4的首次适应算法怎么完成内存拼接工作,内存是怎么分配和释放的。参考图7:

  1. A 显示了在3个任务被创建后数组的占用。在数组的顶部有一个大的空块。
  2. B 显示了一个任务被删除后数组的情况。数组顶部的打孔快保留。此外被删除的任务的TCB和堆栈所占用的地方也出现了一个空块。注意到,不像heap_2中解释的一样,内存在TCB和堆栈被删除时就释放了,没有留下两个分开的空块,而是组合成一个更大的空块。
  3. C 显示了在FreeRTOS队列被创建后的情况。队列由xQueueCreate() API函数创建,在4.3章节将会详细讲述该API。xQueueCreate()调用pvPortMalloc() 来分配RAM为队列使用。由于heap_4使用了首次适应算法,pvPortMalloc() 将会从之前没足够的的空内存块中创建队列。而在图7中,就是任务被删除留下的空块。队列没有占用掉该块的所有空间,故该空间被分裂成两个部分。未被占用的部分仍可调用pvPortMalloc()进行分配。
  4. D 显示了pvPortMalloc()直接被用户代码调用而不是被FreeRTOS的API函数调用后的情况。用户分配的块足够小来适合第一个空块,这个空块位于队列和TCB的空间。
  5. E 显示了随后队列被删除的情况,队列所占用到的内存被自动删除。现在,用户分配的块两边都是空闲内存。
  6. F 显示了用户分配内存被释放后的情况,刚刚被用户用过的内存和两边的空内存组合成了一个大的空块。

heap_4并不具有确定性,但它仍比标准库的malloc() 和free()要块。

为heap_4用到的数组设定起始地址

这部分包含了更深一层次的信息。对于只使用heap_4的读者来说可以跳过不读,不必深入理解。

有些时候对于应用程序来说在一个特定的内存地址,通过heap_4写一个数列是必须的。例如,一个FreeRTOS由任务从堆中分配出来的堆栈,有可能需要曲儿堆位于内部的高速内存,而不是外部的低速内存。

默认情况下,heap_4使用的数组是申明在heap_4.c源文件中的,并且其起始地址自动由连接器设定。然而,如果FreeRTOSConfig.h中的configAPPLICATION_ALLOCATED_HEAP宏定义常量在编译时被设定成1,那么数组必须被应用申明由FreeRTOS使用。如果数组由应用申明,那么开发者就可以为其设置起始地址。

如果FreeRTOSConfig.h中的configAPPLICATION_ALLOCATED_HEAP宏定义常量在编译时被设定成1,那么uint8_t数组以ucHeap形式调用,由configTOTAL_HEAP_SIZE设置大小,必须在应用代码中申明。

语法要求把变量放在具体的内存地址取决于所使用的编译器,所以需要参考你的编译器文档。以下给出两个编译器的例子:

  • 表2给出了GCC编译器申明数组的语法,并且指定数组存放在 .my_heap 的内存块中。
  • 表3给出了IAR编译器申明数组的语法,并且把数组放在绝对地址0x20000000。
    精通FreeRTOS实时时钟内核Chapter2

Heap_5

heap_5中使用的分配和释放内存的算法与heap_4中的完全一致。区别于heap_4,heap_5不限定从一个静态申明的数组中分配内存。heap_5可以从许多个分散开的内存块中分配内存。当不像是FreeRTOS运行在系统的非单个内存块中时,适用于heap_5分配方案。

在写程序时,heap_5是唯一在pvPortMalloc()被调用前需要预先明确初始化的内存分配方案。heap_5通过调用vPortDefineHeapRegions() API函数初始化。当用到heap_5内存分配方案时,vPortDefineHeapRegions()必须在任何内核对象(任务 队列 信号 等)前被调用。

vPortDefineHeapRegions() API函数

vPortDefineHeapRegions()被用于设定每个分散的内存区域的起始地址和块大小然后打包一起给heap_5使用。

精通FreeRTOS实时时钟内核Chapter2

每个分开的内存区域都由一个HeapRegion_t类型的结构体描述。所有可用的内存区域作为一个HeapRegion_t结构体数组传入vPortDefineHeapRegions()。

精通FreeRTOS实时时钟内核Chapter2
参数名/返回值 描述
pxHeapRegions 一个指向HeapRegion_t结构体数组起始地址的指针。数组中的每个结构体都描述了一段内存区域的起始地址和空间大小,而这段内存就可以被heap_5所用到。数组中的HeapRegion_t结构体必须通过起始地址排序;低字节的内存区域所对应的HeapRegion_t结构体必须放在数列的前面。HeapRegion_t结构体数组的末位结构体的pucStartAddress必须被设置成NULL。

顺便说下例子,假设内存表像图8A 显示的一样,包含3块分开的内存块:RAM1,RAM2,RAM3。并且假设执行代码是以只读的形式存放在内存中并且不显示的。

精通FreeRTOS实时时钟内核Chapter2

清单6显示了HeapRegion_t结构体一起描述3块RAM作为一个整体。

精通FreeRTOS实时时钟内核Chapter2

尽管清单6正确描述了RAM,但并不是作为一个可以直接用的例子。因为它把所有RAM都留给了堆,没有留任何空间给其他变量。

在一个工程编译时,在编译的链接阶段会给每个变量分配一个RAM地址。在连接器能通过一个链接配置文档描述时,例如一个链接脚本,RAM就能正常使用了。在图8B假定链接脚本包含了RAM1中的信息,但是没有包含RAM2和RAM3的信息。因此连接器把变量都放在了RAM1中,把RAM1里0x0001nnnn以上的地址留给heap_5用。0x0001nnnn实际的值将取决于连接器实际连接的所有变量大小。并且连接器没有用到RAM2和RAM3,那么整个RAM2区域和RAM3区域对于heap_5都是可用的。

如果使用清单6中的代码,由heap_5分配的地址在0x0001nnnn以下的内存就会与静态变量重叠。为了避免这一现象,结构体数组的第一个HeapRegion_t结构体可以使用0x0001nnnn作为起始地址,而不是使用0x00010000。但是不推荐这样做,因为:

  • 起始地址不那么容易确定。
  • 连接器使用到的RAM的数目有可能在以后的版本中变化,每次都必须更新HeapRegion_t结构体中的起始地址。
  • 编译方式未知,因此不能提醒开发者,RAM是不是被连接器和heap_5重复调用。

清单7给出了一种更方便和更易于维护的例子。它申明了一个ucHeap的数组。ucHeap是一个一般变量,所以它会变连接器成分配在RAM1中的数据。结构体数组的第一个HeapRegion_t结构体描述了起始地址和ucHeap的尺寸。所以,ucHeap变成了heap_5管理的内存的一部分。ucHeap的大小可以被增加,直到连接器调用的内存吃完了整个RAM1,正如图8C中显示的一样。

精通FreeRTOS实时时钟内核Chapter2

清单7中用到的技术的优点:

  1. 没必要使用一个硬编码作为起始地址。
  2. 在HeapRegion_t结构体使用的地址将会被连接器自动设置,并且总是正确的,即使在后续的版本中RAM使用数量发生变化。
  3. 对于RAM来说,heap_5和连接器重复使用RAM将变得不可能。
  4. 在ucHeap过大的时候,程序将不会链接。

2.3 与堆相关的实用函数

xPortGetFreeHeapSize() API函数

xPortGetFreeHeapSize() API函数在被调用时,会返回堆中空闲字节的数量。它可以用来充分利用堆空间。例如,如果在内核对象被创建后xPortGerFreeHeapSize()返回2000,那么configTOTAL_HEAL_SIZE就可以被减少到2000。

在使用heap_3时,该函数不可用。

精通FreeRTOS实时时钟内核Chapter2

xPortGetMinimumEverFreeHeapSize() API函数

xPortGetMinimumEverFreeHeapSize() API函数返回从FreeRTOS应用程序开始执行以来,未被分配的RAM的最小值。

该函数返回的值用于指示应用程序占用了多少堆空间。例如,如果自从应用程序启动以来,xPortGetMinimumEverFreeHeapSize()返回值为200,那就说明应用程序距离花光heap空间还差200bytes。

xPortGetMinimumEverFreeHeapSize()只在heap_4和heap_5中可用。

精通FreeRTOS实时时钟内核Chapter2
精通FreeRTOS实时时钟内核Chapter2

分配内存失败钩子函数

pvPortMalloc()可以被应用程序代码直接调用。它在每次新建一个内核对象时被FreeRTOS源代码调用。内核对象例如任务,队列,信号和事件组——在本书的后续章节都会讲到。

就像标准库malloc()函数一样,如果pvPortMalloc()因为请求大小的块不存在而不能返回块地址的话,那就会返回一个NULL。如果因为开发者创建内核对象时候pvPortMalloc()被调用执行并返回一个NULL,那么这个内核对象就没能被创建。

所有给出的heap分配方案都能配置在pvPortMalloc()返回NULL时调用一个钩子(回调)函数。

如果FreeRTOSConfig.h中的configUSE_MALLOC_FAILED_HOOK 被设定成1,那么应用必须提供一个申请内存失败钩子函数,其函数名和原型在清单10中给出。在应用的合适地方都能被正常执行。

精通FreeRTOS实时时钟内核Chapter2