干货 | FreeRTOS 学习笔记——FreeRTOS的软件结构
EEWorld
电子资讯 犀利解读
技术干货 每日更新
我是从 FreeRTOS 官方的文档《Mastering the FreeRTOS Real Time Kernel》开始学习它的,代码和参考手册都用的 9.0.0 版本。我还没有用过其它的 RTOS, 所以也无意评价它的优缺点。当然,它无疑是一个优秀而且很流行的嵌入式 RTOS. 要上手也很快,本篇我就记录一下如何将 FreeRTOS 的代码加到已有的工程里面,作为一个备忘参考(网上也能随便搜到很多关于怎么使用 FreeRTOS, 怎么创建任务等等的文章。在我的学习笔记系列里面这部分内容倒不是首要的,因为我想分享的是从我对 FreeRTOS 代码的分析和实践了解到它是怎么工作的,带来了什么好处)。
文件组成
从 freertos.org 网站上可以找到下载源代码包的链接。以我用的 v9.0.0 代码为参考,它的根目录下有6个C源文件:
croutine.c
event_groups.c
list.c
queue.c
tasks.c
timers.c
这些文件是 FreeRTOS 的核心代码,有的还是可选的。然后是两个子目录:include 和 portable. include 目录下的头文件包含了系统核心用到的宏定义,以及编程用到的 API 数据结构、函数原型等。在 portable 目录下的文件提供一些会被 FreeRTOS 核心代码调用的函数,这些函数的实现与运行环境有关系,或者是存在多种实现方式。比如,因为 FreeRTOS 支持多种硬件平台,与平台实现密切方式相关的代码(例如汇编语言编写的函数)就放到编译器、CPU类型对应的子目录下。portable 目录下的文件不是系统核心,除了 FreeRTOS 代码包里提供的这些文件,用户还可以根据自己的环境编写函数。
生成目标模块的文件里面,按照能运行起来的最小需求,tasks.c 和 list.c 必不可少(因为 FreeRTOS 的内部数据结构用了链表,所以需要 list.c)。此外还必须有一个硬件实现相关的模块,例如我用于 STM32 就选用了 /portable/GCC/ARM_CM3/port.c
在自己写的程序源文件里面,至少需要包含两个头文件:FreeRTOS.h 和 task.h . 如果用了其它可选的功能 API,就要将相应的头文件包含进来,具体看手册说明。如果编译时出现警告或错误说什么什么没有定义,就先检查一下是否缺了头文件。
配置 (可选项)
FreeRTOS 的灵活性很大,许多功能不需要则可裁减掉。为了减少对内存的浪费,某些数据结构长度、数据位宽也是可以指定的。因此 FreeRTOS 要求用户编写一个 FreeRTOSConfig.h 头文件,定义若干参数,对系统的功能和特性进行配置。
初次使用 FreeRTOS, 也不必自己书写这个配置文件,只须从代码包的 Demo 目录下找一个和自己的环境相近的工程,将其中的 FreeRTOSConfig.h 拷贝出来,根据需要稍微调整一下就可以了。如果用的是 STM32, 还可以从 ST 官方的(比如 CubeF4 里面) FreeRTOS 例子工程里面借用一个。
有几个与系统资源关系密切的宏定义要留意一下:
#define configCPU_CLOCK_HZ ( SystemCoreClock )
#define configTICK_RATE_HZ ( ( TickType_t ) 1000 )
#define configMAX_PRIORITIES ( 5 )
#define configMINIMAL_STACK_SIZE ( ( unsigned short ) 130 )
#define configTOTAL_HEAP_SIZE ( ( size_t ) ( 75 * 1024 ) )
configCPU_CLOCK_HZ 是 CPU 的时钟频率,FreeRTOS 需要知道它才能正确配置硬件定时器。configTICK_RATE_HZ 是指定 FreeRTOS 用的时钟中断频率,也就是决定所用时间间隔的单位。configMAX_PRIORITIES 指定存在多少种任务优先级,在够用的前提下应尽量少。configMINIMAL_STACK_SIZE 是给任务分配堆栈的最小值,FreeRTOS 会按这个值给 Idle 任务分配堆栈。configTOTAL_HEAP_SIZE 是决定分配多少内存给 FreeRTOS 自己管理,在动态创建任务和其它一些数据结构的时候使用,见下一节说明。
务必注意决定任务调度特点的两个宏定义:
configUSE_PREEMPITON 指定是否使用抢占式任务调度——即是否运行中的任务不主动交出控制权也允许被调度。
configUSE_TIME_SLICING 指定是否针对同一优先级下的任务使用时间片调度(在抢占式调度前提下)。
有些可选的功能需要通过宏指定开启,例如 configUSE_QUEUE_SETS, configUSE_TIMERS, configUSE_COUNTING_SEMAPHORES, configUSE_MUTEXES,
configUSE_TASK_NOTIFICATIONS 等。没有指定则 FreeRTOS 会使用默认值,需要留意手册中的说明。
也可以直接察看 FreeRTOS.h 中的条件编译语句对未定义 configXXX_XXXX 宏的处理,大多数是默认定义为0, 也有少数定义为1的。另有几不提供默认值的宏若没有在 FreeRTOSConfig.h 中定义,就直接报错了。
内存管理
前面我提到过,FreeRTOS 的每个任务都需要分配内存作为堆栈和TCB数据结构。有两种内存分配方式——静态分配,在编译时决定;和动态分配,在运行时决定。通常我们会使用动态分配内存,这时候要让 FreeRTOS 核心能够分次申请一定大小的内存,类似于提供C语言的 malloc() free() 机制。
那么为什么 FreeRTOS 不直接使用C标准库里面的 malloc() 和 free() 函数呢?文档里面提到几方面的考虑:(1)多任务调用, C库函数是否可重入的问题。(2)实现的复杂度和代码效率。(3)增加程序链接、调试的复杂度。
FreeRTOS 使用的内存分配和释放函数是 pvPortMalloc() 及 vPortFree(). 在 /portable/MemMang 目录下面有几个版本的文件,各自实现了这两个函数:
heap_1.c 只分配内存,不可释放。用在只创建而不销毁的场合,不存在内存碎片问题。
heap_2.c 可以释放分配的内存,使用最适匹配原则。
heap_3.c 借助标准库的 malloc() 和 free() 函数管理内存,额外增加代码来避免重入。
heap_4.c 是在 heap_2 基础上的改进,可以将邻接的空闲内存块合并。
heap_5.c 可以管理地址不连续的几块SRAM区域。
对于 heap_1.c, heap_2.c, heap_4.c 三种实现都需要配置文件中定义好 configTOTAL_HEAP_SIZE 这个宏,即在编译时分配一大块固定位置的内存,给 FreeRTOS 作为堆(Heap)使用。所有任务申请的内存都出自这一块预定的内存里面。heap_3.c 是由 C 库函数负责内存管理,就不需要这个宏定义了。而针对 heap_5.c 显然这一描述不够,需要提供每块 SRAM 区域分别的地址和大小的信息,所以它要求用户调用 vPortDefineHeapRegions() 函数来指定内存详情。
如果对这几个还不满意,当然也可以自己写内存管理的函数了(这是 portable 的部分,结合具体需求定制是它的初衷)。
其实对于小的很确定的应用,比如就创建一两个任务来跑,动态分配内存都不是必须的。只要在创建任务的时候麻烦一点,手动指定作为堆栈和TCB的内存地址,就一样能工作。这便是 FreeRTOS 支持的静态创建方式。从 9.0.0 版本开始,已经可以完全去掉堆内存管理的代码。要在配置文件中定义 configSUPPORT_STATIC_ALLOCATION 宏值为1才能支持使用静态分配的内存创建对象。同样还有一个宏 configSUPPORT_DYNAMIC_ALLOCATION (默认值为1)是决定是否支持动态分配内存的。
用静态分配内存创建对象,要调用函数名以 Static 结尾的 API. 例如 xQueueCreateStatic(), 其原型如下:
QueueHandle_t xQueueCreateStatic(
UBaseType_t uxQueueLength,
UBaseType_t uxItemSize,
uint8_t *pucQueueStorageBuffer,
StaticQueue_t *pxQueueBuffer
);
而使用动态分配内存的常规版本 API 函数是 xQueueCreate():
QueueHandle_t xQueueCreate(
UBaseType_t uxQueueLength,
UBaseType_t uxItemSize
);
可见静态内存方式需要用户以参数形式提供对象使用的内存地址,可能还不止一块。使用动态内存就直接按需要从堆中分配了,程序写起来简单。
此外,若支持静态内存的选项打开,FreeRTOS 还要求用户提供一个 vApplicationGetIdleTaskMemory() 函数,用来给系统获取 Idle 任务的堆栈内存地址、TCB内存地址和堆栈大小。因为系统会用 xTaskCreateStatic() 来创建 Idle 任务。同样的原因,在使用 Timer 功能时,要提供 vApplicationGetTimerTaskMemory() 函数。
系统自带任务
FreeRTOS 总有一个任务在运行状态,若当前无事可做,那么就让一个代表“系统空闲”的任务运行——这就是 Idle 任务。当 FreeRTOS 调度器启动时,会隐含地创建这个任务。Idle 任务具有最低的优先级,它必须要让位于任何更重要的任务,除非它们都阻塞或挂起了。
Idle 任务的一个用途是管理硬件的低功耗模式,我将另开一篇来讨论。
除了 Idle 任务,FreeRTOS 还带有一个 Timer 任务,也就是 Daemon Task, 用来辅助完成一些功能,例如软件定时器。这个任务是可选的,在配置选项 configUSE_TIMERS 为1时才打开。它的代码在 timers.c 中。Timer 任务的作用是处理跟时间有关的事务,但这些事务又不能放到时钟中断 ISR 里去处理(并非响应硬件请求,没有那么高实时性要求), 还要接受调度器管理。
例如,创建软件定时器的时候指定一个回调函数:
TimerHandle_t xTimerCreate(const char * const pcTimerName,
const TickType_t xTimerPeriodInTicks,
const UBaseType_t uxAutoReload,
void * const pvTimerID,
TimerCallbackFunction_t pxCallbackFunction
);
在定的时间到以后,pxCallbackFunction() 将被 Timer 任务调用,即是在 Timer 任务的上下文中执行,而不是创建这个软件定时器的任务上下文中执行。
Timer 任务另一功能是执行 xTimerPendFunctionCall(), xTimerPendFunctionCallFromISR() 指定的函数调用,如我在第(5)篇笔记中写的。这些被指定的函数,连同软件定时器的回调函数,是一个个顺序执行的,都使用 Timer 任务的堆栈。
系统使用的中断
为了实现与时间有关的功能,硬件定时器是必须要用到的。FreeRTOS 有一个函数 xTaskIncrementTick() 是给硬件定时器的 ISR 调用的。因为核心代码与硬件平台无关,无法写成中断形式,故采用这种方式。实际上也相当于系统使用了一个定时器中断,不过 ISR 属于 portable 层面,用户可以自由设计,这个中断也不一定 FreeRTOS 独占,例如可以再做一些硬件方面的操作,只要定期及时调用 xTaskIncrementTick() 就可以了。为了加深理解,可以看看不同硬件平台的 port.c 中如何处理。
FreeRTOS 在 ARM Cortex-m0/m3/m4/m7 平台的实现中,还使用了 PendSV 中断和 SVC 中断,这是软件产生的中断,其 ISR 是调度器的一部分。但是在其它硬件平台的实现中,未必有类似的软中断可用。
初始化和任务创建
在一个用 FreeRTOS 的工程里面,几乎必然用到的是创建任务,哪怕只有一个任务。比如可以在 main() 里面创建首先要运行的任务,使用 xTaskCreate() 这个 API 函数:
BaseType_t xTaskCreate(TaskFunction_t pxTaskCode,
const char * const pcName,
const uint16_t usStackDepth,
void * const pvParameters,
UBaseType_t uxPriority,
TaskHandle_t * const pxCreatedTask);
第一个参数 pxTaskCode 是任务的主函数(入口地址),然后 pcName 是代表任务名称的一个字符串。usStackDepth 很重要,是决定给任务分配多少内存作为堆栈。pvParameters 是向任务函数传递参数用的,uxPriority 是初始的任务优先级,pxCreatedTask 用来保存任务 handle, 也就是TCB的地址。当创建成功,这个函数返回 pdTRUE, 然后任务处于 ready 状态,随时可以运行。
实际上要等到调度器起用以后,前面所创建的任务才能运行。当主程序完成了系统的初始化工作,比如配置片上设备,创建好必需的任务、各种通信对象,就可以调用 vTaskStartScheduler() 来起用调度器。一般来说这个函数并不返回,因此余下运行的就是各个任务的函数了。vTaskStartScheduler() 还会自动创建 Idle 任务和 Timer 任务,然后选择最高优先级的一个任务开始运行。
几个简单常用的 API
在一个新平台上使用 FreeRTOS 功能的时候,可以用一些 API 实现简单的多任务运行,下面列举几个,都不涉及任务间通信。
vTaskDelay() 最常用来产生延时。调用它的任务立即被阻塞,等到经过要求的若干 Timer Tick 过后再恢复到就绪状态。注意,下一次 Tick 的时刻(也就是硬件定时器中断发生时刻)和调用 vTaskDelay() 的时刻有一段不确定的间隔,因此要求精确的延迟时要考虑这个函数是否满足需求。
vTaskDelayUntil() 功能类似,只不过它是指定一个绝对时间,而 vTaskDelay() 是从调用时刻计的相对时间。
xTaskGetTickCount() 返回调度器已运行的 Tick 数,也就是总执行时间。
vTaskSuspend() 和 vTaskResume(), 这两个函数可以将某一任务挂起,以及使挂起的任务恢复。
vTaskSuspendAll() 和 vTaskResumeAll(): 这两个表面上看起来是上两个 API 功能的扩充,实际上原理完全不同。vTaskSuspendAll() 将调度器禁止,当前任务继续执行,中断也是允许的。限制是此时不能调用其它的 API, 直到用 vTaskResumeAll() 恢复调度器之后。和关键区域(critical section)的用法不同,关键区域是硬件上屏蔽中断保证不会发生任务切换,API 还是可以使用的。
vTaskList() 接收一个字符缓冲区,生成文本格式的所有任务的列表,包括状态信息。
小结:FreeRTOS入门运用不难,只需要把几个文件添加进现有的工程里面,就能改成多任务的。必要的步骤是决定配置选项,编写 FreeRTOSConfig.h 文件,以及决定内存管理方式。