FreeRTOS 介绍
概述
FreeRTOS 是实时操作系统(RTOS)的一种,它非常小巧,被专门设计运行在 MCU 上。FreeRTOS (kernel) 只提供核心的实时调度、内部任务通信、定时器和同步原语,所以更准确地说它是一个实时内核。它的一些特性包括:
- 支持40多种架构,为这些不同架构和开发工具提供同一的解决方案。
- 可靠,可裁减、简单易用。
- 包含丰富的特性和活跃的持续开发。
- 资源占用非常小。通常,一个二进制的 kernel image 只占用 6k-12k 字节。
- 非常简单小巧。内核的核心部分只包含 3 个 C 文件(
tasks.c、queue.c和list.c)。 - 完全 free 。
- 可以迁移到 SafeRTOS。
- 稳定且仍在增长的用户群。
- 详细完整的示例以及支持论坛。
下文部分参考 FreeRTOS - 官方文档。
基本原理(Fundamentals)
多任务(Multitasking)
多任务是现代操作系统的基本特性,它是一种降低系统设计复杂性的抽象方法:
- 多任务和任务间内部通信特性可以使得复杂应用被分解为更小、更易管理的子应用。
- 这种任务的划分便于进行软件测试,团队协作和代码重用。
- 任务间复杂的时序或执行顺序问题可以交给操作系统来完成。
调度(Scheduling)
调度模块是多任务 os 的核心,调度算法及实现的优劣直接影响系统的多方面性能,比如吞吐率、实时性、可靠性等。不同的场景可能会采取不同的调度策略(Policy)。对于非实时(no-realtime)多用户系统,每个任务被分配 接近公平 的CPU资源,高优先级的任务可以获得 软实时 能力,及操作系统会尽量优先执行该任务,但不保证满足该任务的时间延迟或者响应要求。RTOS 则提供 硬实时 特性,它保证满足任务的严格的时间约束、响应及可预测的行为。
上下文切换(Context Switching)
每个任务都是任务代码和任务环境的一种抽象,任务环境就是当前任务所占用的资源:包括寄存器、RAM(栈)、ROM、IO端口等。所以当一个任务被内核暂停(suspended)前,内核会负责保存任务的执行环境,当该任务重新调度执行时,再进行恢复。这种机制形成一种每个任务都独占计算资源的假象。切换的效率以及频率会影响系统的实时性能。
注意上下文切换与中断的区别,上下文切换可能由中断触发,例如SysTick,但在其他情况下,可由用户编写软件来完成,即它可以是一种软件特性。
实时应用
在现实世界,很多事件必须在严格时间内获得响应,这个时间称为 deadline,实时/嵌入式系统的调度策略则是必须满足事件的 deadline 。实时调度策略首先可以是基于 优先级的,高优先级任务可以抢占低优先级任务,而相同优先级的任务则进行 “公平” 调度。优先级的分配一般由用户完成。例如,EDF 调度算法是一种基本的实时调度算法,它优先调度 deadline 最早的任务,但该算法需要已知所有任务的 deadline。
实时调度
Tasks
FreeRTOS 的任务类似于非RTOS(例如Linux)中的线程,每个任务都有自己独立的栈,但可以同步访问共享数据(例如信号量、共享队列、互斥锁、事件组等)。内核调度器负责在不同任务间进行切换。每个任务在内核中都对应一个 TCB 数据结构,TCB中会包含任务的栈指针、优先级、状态、计数器等信息,这些信息可以被调度器使用,来进行任务切换。
状态:
任务的状态转换如下:
优先级:
任务的优先级范围是:0 - configMAX_PRIORITIES - 1,值越小优先级也越小。** idle task**的优先级为0。
要特别注意任务优先级和中断优先级的区别:
- 任务是一种软件特性,它的优先级由应用开发者指定,并由软件算法(调度器)来决定哪一个任务进入
Running状态。它和 正在运行 FreeRTOS 的硬件无关。 ISR虽然使用软件编写,但却是硬件特性,因为硬件将控制哪一个 ISR 运行以及什么时候运行。以 TM4C123GH6PM 为例,它包含很多中断事件,例如GPIO、UART、Timer、ADC/DAC、SysTick、PWM等,这些事件的中断优先级由 NVIC 等相关寄存器设置和管理。任何一个任务只有在当前没有ISR运行的情况下才能运行,即最低优先级的中断也可以抢占最高优先级的任务,反之不行。
调度:
单核下默认的调度策略:
- 优先级固定:任务的优先级一旦确认就不再改变;相比而言,Linux 内核采用一种
动态优先级策略,动态优先级有很多优点,但稍显复杂。 - 可抢占:优先级高的任务总是可以抢占优先级低的任务。
- 时间片:对于优先级相同的任务,调度器会在每个 tick 中断时进行切换。相邻 tick 中断的时间间隔称为一个时间片。
抢占策略容易产生饥饿问题,即低优先级任务可能长时间不能被调度。在Linux系统上,使用 Aged策略来解决饥饿问题,但 FreeRTOS 将问题的解决交给用户:高优先级的任务在等待事件时要主动放弃 CPU(Blocked),而不是忙等(Busy-loop-wait)。当事件发生时,可以利用任务间通信机制和同步原语来唤醒高优先级任务。这种模型称为事件驱动编程模型。
AMP下调度策略:每个 core 上运行单独的 FreeRTOS 实例,因此每个 core 的调度策略与上面相同。
SMP下调度策略:SMP下只有单个 FreeRTOS 实例,因此调度策略与上面大体相同,只是允许多个任务进入 Running 状态,这些任务或被分配到不同的 core 上运行。,高优先级任务和低优先级任务可能在不同 core 上并行执行。在SMP下,任务可以设置 configUSE_CORE_AFFINITY 来强制自己在特定 core 上运行。
空闲任务:
Idle Task 是 FreeRTOS 启动调度时自动生成的任务,确保当前至少有一个任务可以运行。该任务的优先级最低,它负责回收被删除任务的内存。其他任务也可以设定为与空闲任务相同的优先级,但这些任务的行为可以通过配置 configIDLE_SHOULD_YIELD 来调整。但很多情况下,尽量将任务的优先级设置高于空闲任务的优先级。如果用户想要一些应用运行在最低优先级,则有两种选择,一是使用空闲任务挂钩 void vApplicationIdleHook( void ),该回调函数不能阻塞;二是生成最小优先级的新任务,但这种做法更加消耗内存。
队列、互斥量和信号量
queue 是主要的任务间通信形式,可以把它看作线程安全的 FIFO。消息是被拷贝进入 queue 的,而不是仅仅在queue中放入消息数据的引用。
- 小的消息可以直接包含在 c 变量中,这些变量可以直接被发送到 queue 或者从 queue 中读取,不需要中间过程。
- 变量本身仍可以马上重用,因为它先前的数据已经拷贝到 queue 中。
- 当然,queue 也可以传递指针或者引用,特别是当指针指向的数据块较大时,例如 UDP 网络数据包。
- queue 内存的分配完全交由内核管理。
- 支持可变字节的消息,queue 支持特殊的结构体,该结构体中可以存放消息的具体大小。
- 支持不同类型的消息,这由特殊的结构体来支持。消息的解析也根据消息类型来进行。
- 这种实现适合在带有内存保护的环境中使用。通过 queue 可以在不同保护区内传递消息,因为 queue 工作在内核特权级别,它可以进入所有保护区。
- 使得 API 更简单。
任务读空 queue 或者写已经满的 queue 都会阻塞(进入 Blocked状态)。当有多个任务阻塞在 queue 上时,高优先级的任务在 queue 可访问时首先被唤醒。
二进制信号量可以用于任务间同步,更重要的是任务不会在信号量上忙等,如果信号量被占用,任务会在信号量上阻塞(Blcoked),进而不再占用CPU资源。计数信号量用于多个共享资源的访问,也可以用于特殊的读者-写者问题。
互斥体是包含优先级继承机制的二进制信号量,互斥体更适合用于资源的互斥访问。(互斥体的优先级集成机制可以解决可能发生的死锁问题。)即一个高优先级的任务在某个互斥体上阻塞,因为该互斥体已经被一个低优先级的任务占用,那么占用者的优先级会被临时提高到阻塞者的优先级。这样做可以尽量减少高优先级任务的阻塞时间,也可以尽量减少低优先级任务的“优先级反转”。
注意,queue 和二进制信号量也可以用于中断与任务间通信,但在中断中,同步原语 API 函数要使用以 FromISR 结尾的。互斥体不应该在中断中使用。`
任务通知
每个任务都有一组任务通知(task notifications),每个任务通知都有一个通知状态,pending或者no-pending。事件可以直接发送到任务通知,此时任务通知的状态变为pending,而不用通过中间机制,即如 queue。任务可以阻塞在某个通知上,直到该通知状态因事件而变为pending。注意,任务在任何时间只能阻塞在一个通知上。
任务通知相比其他中间机制有着性能优势,但使用时也有下面的限制:
- 事件的接收者只有一个。
- 当接收者阻塞在通知上时,发送者在发送未完成前不能阻塞。
在下面的场景,则不能使用任务通知:
- 向 ISR 发送事件或者数据
- 当有多个事件接收者时
流和消息缓存
Stream 缓存用于任务间或者任务与中断间通信,它被优化用于单写者-单读者场景。但对于多个写任务或者多个读任务,它不是线程安全的。流是面向字节的,即可以读写任意长度的字节,类似于socket。Stream 缓存使用任务通知,因此调用其 API 函数会使得任务阻塞,也会改变该任务的任务通知的状态。
Trigger Level被阻塞任务设置,如果一个读任务阻塞在空的 Stream 上,除非 Strem 中有这么多字节,该阻塞任务不会被唤醒。当然,如果任务在调用 API 函数时设置了超时值,超时后仍然没有满足Trigger Level,那么 API 返回当前已经可用的字节数据。
Message缓存工作在Stream缓存的上层,它是面向消息的,且消息的长度可变。
在中断中,API函数要使用以 "FromISR" 结尾的。
软定时器
FreeRTOS 的软定时器的回调函数不工作在中断上下文中,而是工作在 Timer service Task 的上下文中。这与很多定时器的机制不太相同。当然,回调函数不同阻塞。Timer 的很多 API 都是利用一个 queue 来向Timer service Task 发送命令,这个 queue 是私有的,其他任务不能直接访问。Timer 需要在使用前进行配置,比如 Timer service Task 的优先级、私有命令 queue 的长度、栈大小。one-shot定时器只执行一次,auto-loaded定时器是周期性定时器。
要特别注意软Timer和硬Timer的区别。
代码组织
定制
FreeRTOSConfig.h中包含所有的定制选项,任何应用都要包含该文件。这些配置选项包括:
- 抢占调度或者协作调度、任务最大优先级、时间片、内核中断优先级
- 一些回调函数:内存分配失败回调、Daemon任务启动回调、Tick回调
- 内部时钟频率以及 Tick 变量大小
- Tick 中断频率
- 软定时器、定时器 daemon 任务的优先级、定时器命令队列长度、Daemon任务的栈深度
- 最小的栈空间、栈溢出检查、栈深度类型、栈类型
- 使能任务通知、互斥体、递归互斥体、计数信号量、队列
- 静态内存分配(如果设置则必须手动为
Idle任务和Timer任务实现内存分配函数) - 动态内存分配、堆大小、堆类型(FreeRTOS 堆或者用户堆)
- SMP支持
- 面向特定架构的选项
- 其他
任何使用 FreeRTOS API 的中断都必须设置与内核相同的优先级(configKERNEL_INTERRUPT_PRIORITY)。
静态内存分配和动态内存分配
动态分配的特性:
- 可以很大程度上较少内存的使用
- 对象生成函数更加简单
- 用户不需要自己分配内存,对象由内核动态生成,如:task、软timer、队列、事件组、信号量、互斥体等。
- 可以动态监控堆大小进而进行优化
- 对象释放后,所占用的内存可以重用
- 可选的动态内存分配策略
静态分配的特性:
- 对象可以放置在特定的内存位置
- 最大内存空间可以在链接时决定
- 不需要处理内存分配错误的处理
- 其他
内存管理
FreeRTOS 的内存管理比较灵活,它没有设置单一的分配策略,而是将内存分配模块放到可移植层,即用户可以根据自己的实际情况使用不同的分配策略。
- heap_1.c:最简单,内存一旦分配不允许释放。适用于:不需要释放对象的应用;行为确定(执行时间总是很稳定)和不会导致内存碎片化的应用;不需要或者不允许动态内存分配到应用。
- heap_2.c:使用Best fit 算法,允许分配的内存被释放。适用于动态内存分配大小总是不变的任务(且重复生成和释放对象),否则如果内存分配大小比较随机或者不稳定,则很容易导致内存碎片化。这种实现并不是确定性(deterministic)的,但比一些标准的 malloc实现更有效。
- heap_3.c:malloc和free的包装器,使其线程安全。这种实现也不是确定性的,而且需要编译库(例如libc)的支持,也可能增加内核代码的大小。
- heap_4.c:使用first fit算法,而且它会自动将紧邻的空闲区进行合并(使用一些合并算法。)适用于重复性生成和释放对象的应用,与
heap_2.c中的实现相比,内存分配大小比较随机时,它的内存碎片化问题大大改善。它也不是确定性的,但比一些标准的 malloc 实现更有效。 - heap_5.c:使用first fit算法和合并算法,但允许堆分配在不连续的内存区域。
栈使用以及内存溢出检测
每个任务的栈可以动态分配或者静态分配。内存溢出检测可以在开发与测试阶段使用。方法:
- 由内核在上下文切换的时候检测任务的栈指针是否在合法的栈空间。如果超出了合法空间,一个回调函数将被调用。这种方法不保证可以捕获所有的栈溢出。
- 使用已知的值初始化栈,并且在上下文切换的时候检测栈空间的最后16个字节是否被重写,如果被重写,一个回调函数会被调用。这种方法类似于 金丝雀法。这种方法同样不保证可以捕获所有的栈溢出。
SMP支持
FreeRTOS API在单核和SMP下基本上是相同的,除了在SMP下增加了对core亲和性及抢占使能的API。在单核和SMP下编程,有些微妙的不同,例如在单核下可以通过设置不同的优先级来完成互斥访问,而在SMP下就不能成立了,因为不同优先级的任务可以并行运行,对共享资源的访问不再是互斥的。在这种情况下,用户可以采取的方法:一是使用同步原语进行互斥访问;二是强制相关任务不能并行,即让它们在同一个core上运行;三是配置FreeRTOS,使得只有相同优先级的任务才能并行,这样做或许使得某些core空闲而降低系统整体吞吐率。另外在SMP下,多个ISR或者ISR与任务间也可能并行,因此需要确保互斥访问。
内存保护
FreeRTOS为下面的架构提供官方的MPU单元:
- ARMv7-M (Cortex-M3,Cortex-M4 和 Cortex-M7 微控制器)
- ARMv8-M (Cortex-M23 and Cortex-M33 microcontroller) 核
MPU的使用可以使系统获得更多安全特性,但也使得系统设计变得复杂。使用MPU可以将信任的代码与不信任的第三方代码分别运行在不同的内存空间。FreeRTOS的MPU模块支持将任务分为特权任务和非特权任务,特权任务可以访问整个内存空间,而非特权任务只能访问自己的栈空间和用户指定的其他有限的空间
线程本地存储(TLS)
在单线程应用中,使用全局变量是方便的,但在多线程应用中,使用全局环境在很多时候是不适合的,每个线程都可能需要有自己独有的变量,这些变量可以存放在线程的控制块内。FreeRTOS使用线程本地存储指针数组来实现TLS。
延迟中断处理
延迟中断处理大都适用于大数据传输的场景,这种场景下,如果所有的数据传输均在ISR中完成,则会使得系统实时性变差,也会使得系统变得脆弱。延迟中断处理是指ISR会唤醒一个任务,并让该任务去执行繁重的工作,自己可以很快退出。这种机制类似于Linux中断处理的 top half 和 bottom half 之分。
延迟中断处理可以通过二进制信号量或者计数信号量等同步原语来实现。延迟中断处理可以使用单独的一个task或者使用 daemon task(使用 xTimerPendFunctionCallFromISR API)。注意如果要使用队列,则必须视情况而定,如果数据接收频率很快,则队列不太适用,此时应使用DMA或者RAM buffer来提高效率和性能。
*特别注意:在ISR中调用 _FromISR 函数可能会使得某个高优先级任务退出阻塞状态,这要不要进行上下文切换要视情况而定。注意使用 xHigherPriorityTaskWoken 参数。调用portYIELD_FROM_ISR 或者 portEND_SWITCHING_ISR函数,根据xHigherPriorityTaskWoken参数的值决定是否切换。
事件组(Event Groups)
Event Groups 有着和queue与信号量不同的特性:
- 允许阻塞任务等待一个或者多个事件的发生
- 允许同时对多个任务解除阻塞,当这些任务正在等待同一个事件或者同一组事件
这些特性对于多对多的模型很有用。另外,事件组可以减少内存消耗,比多个二进制信号量可以完全由一个事件组来替代。
QEMU 模拟器
以MPS2为例,参考下面的命令:
1 | $ qemu-system-arm -machine mps2-an385 -cpu cortex-m3 -kernel [path-to]/RTOSDemo.out -monitor none -nographic -serial stdio -s -S |
其中[path-to]RTOSDemo.out为编译 demo 生成的固件;-s 选项用于 gdb 调试,是选项-gdb tcp:1234的缩写形式。
然后安装 gdb-multiarch 包,安装后调用gdb-multiarch 打开新的会话:
1 | $ gdb-multiarch |