跳转至

FreeRTOS

前言

FreeRTOS是一款开源的实时操作系统,小巧,稳定,移植性强可运行在资源有限的嵌入式设备上,因此成为单片机系统的选择。

官方文档:FreeRTOS documentation - FreeRTOS™

实时性:Unix 操作系统给每个任务分配同样的运行时间,时间到了就切换到下一个任务。 RTOS 的任务调度器被设计为可预测的,优先级高的任务可以打断优先级低的任务得到及时执行。

文件

系统文件

系统主要由内核和组件组成,而内核的源文件主要内容如下,portable文件夹下存放的是针对不同平台的移植文件和动态内存分配文件。

名称 描述
include (文件夹) 内包含了 FreeRTOS 的头文件
portable (文件夹) 内包含了 FreeRTOS 的移植文件
croutine.c 协程相关文件
event_groups.c 事件相关文件
list.c 列表相关文件
queue.c 队列相关文件
stream_buffer.c 流式缓冲区相关文件
tasks.c 任务相关文件
timers.c 软件定时器相关文件

系统移植

系统移植需要将上述文件添到工程目录,需要注意的是portable文件夹中移植文件的选择,针对不同的硬件平台需要选用不同的移植文件。

硬件平台 移植文件
STM32F1(ARMCC) RVDS/ARM_CM3
STM32F4/ STM32G4(ARMCC) RVDS/ARM_CM4F
STM32F7(ARMCC) RVDS/ARM_CM7/r0p1
STM32H7(ARMCC) RVDS/ARM_CM7/r0p1
STM32H5(ARMClang) GCC/ ARM_CM33_NTZ

同时在MemMang文件夹下还存放着五个动态内存管理的文件,只需要选择一个使用即可。

文件 功能
heap1.c 最简单的方法,不允许释放内存。
heap2.c 允许释放内存,但不合并相邻的空闲块。
heap3.c 简单包装标准 malloc() 和 free() 以确保线程安全。
heap4.c 合并相邻的空闲块以避免碎片化。包括绝对地址放置选项。
heap5.c 在heap4.c的基础上,能够将堆跨越多个不相邻的内存区域。

系统配置

FreeRTOS支持通过宏定义设置通过预处理来“裁剪”系统,进而满足不同项目对系统的不同需求,这些宏的定义位置在FreeRTOSConfig.h文件内。FreeRTOSConfig.h这个文件需要自行配置,可以参照官方demo中的配置文件和官方文档中宏的作用自行修改。

配置文件:Customization - FreeRTOS™

中断管理

ARM Cortex-M 系列的 MCU 都有如下三个用于屏蔽中断的寄存器:

寄存器名称 作用
PRIMASK 只有0位可读可写,设置为1能够屏蔽除 NMI 和 HardFault 外的所有异常和中断。
FAULTMASK 只有0位可读可写,设置为 1 用于屏蔽除 NMI 外的所有异常和中断。
BASEPRI 只有低 8 位[7:0]可读可写,屏蔽优先级低于其设置的值的中断。

FreeRTOS就是通过BASEPRI寄存器来管理受其管理的中断的(优先级高于BASEPRI寄存器设置的值的中断不受管理)

我们知道ARM Cortex-M系列有256个中断,其中16个为系统中断,240个为外部中断,有两个重要的系统中断PendSVSysTick,需要设置为最低优先级。

  • SysTick: 系统滴答定时器中断,这个中断每隔1ms就触发一次用于判断当前所执行的任务是否为最高优先级是否需要进行任务切换,调用PenSV进行上下问切换。
  • PendSV: 可挂起的系统调用中断,这个中断的服务函数主要进行执行任务的切换。触发 PendSV 是在 SysTick 或 FromISR API 中主动写寄存器 实现的,当某个中断使得调度决策发生变化(有更高优先级任务就绪)时,该中断结束后会通过挂起 PendSV 来触发任务切换。

SVC和PendSV异常有什么用途?

临界区:

临界区是指那些必须完整运行的区域,在临界区中的代码必须完整运行不能被打断。在运行临界区的代码的时候就需要将 FreeRTOS 管理的中断关闭,也就是向BASEPRI寄存器写入受 FreeRTOS 管理的最高优先级的值,进而将受管理的中断屏蔽。

上述操作通过下列四个宏定义进行:

作用
taskENTER_CRITICAL() 用于在非中断中进入临界区
taskENTER_CRITICAL_FROM_ISR() 用于从中断中进入临界区
taskEXIT_CRITICAL() 用于从非中断中退出临界区
taskEXIT_CRITICAL_FROM_ISR(x) 用于从中断中退出临界区

PS.从中断中进入临界区的区别在于相关函数会先读取BASEPRI的值,并在函数的最后返回这个值,这是为了在后续从中断中退出临界区时恢复BASEPRI寄存器的值。

任务

任务状态

任务状态

创建任务分为静态创建和动态创建,静态创建堆栈大小固定编译前就已经确定,动态通过malloc函数创建任务堆栈。

动态创建任务的函数定义堆栈大小的参数单位为字

任务挂起函数并不支持嵌套,不论使用此函数重复挂起任务多少次,只需调用一次恢复任务的函数,那么任务就不再被挂起。

任务调度

空闲任务

任务通信

队列

队列用于消息在任务与任务,任务与中断之间的传递。队列中消息以环形缓冲区的形式存储,缓存区的大小在队列创建时确定,每种队列只能存储一种类型的消息。

函数 描述
xQueueCreate() 动态方式创建队列
xQueueCreateStatic() 静态方式创建队列
xQueueSend() 往队列的尾部写入消息
xQueueSendToBack() 同 xQueueSend()
xQueueSendToFront() 往队列的头部写入消息
xQueueOverwrite() 覆写队列消息(只用于队列长度为 1 的情况)
xQueueSendFromISR() 在中断中往队列的尾部写入消息
xQueueSendToBackFromISR() 同 xQueueSendFromISR()
xQueueSendToFrontFromISR() 在中断中往队列的头部写入消息
xQueueOverwriteFromISR() 在中断中覆写队列消息(只用于队列长度为 1 的情况)
xQueueReceive() 从队列头部读取消息,并删除消息
xQueuePeek() 从队列头部读取消息
xQueueReceiveFromISR() 在中断中从队列头部读取消息,并删除消息
xQueuePeekFromISR() 在中断中从队列头部读取消息
prvLockQueue() 队列上锁的函数
prvUnlockQueue() 队列解锁的函数

一个队列只允许传递同一种数据类型,如果需要传递不同类型的数据就需要设置多个队列,然而对每一个队列都进行轮询效率低下,因此可以通过监听队列集的方式来监视所有被添加到队列集中的队列。任务在监听队列集时,一旦队列集中有任何队列中存在可读消息xQueueSelectFromSet()函数都会返回这个可读队列,进而决定是否要读取这个队列。注意使用队列集功能,需要在 FreeRTOSConfig.h 文件中将配置项 configUSE_QUEUE_SETS 配置为 1。

函数 描述
xQueueCreateSet() 创建队列集
xQueueAddToSet() 将队列添加到队列集中,被添加队列必须为空
xQueueRemoveFromSet() 从队列集中移除队列,被移除队列必须为空
xQueueSelectFromSet() 获取队列集中有有效消息的队列
xQueueSelectFromSetFromISR() 在中断中获取队列集中有有效消息的队列

信号量

信号量有两个主要用处:

  • 实现任务之间,中断和任务的同步。
  • 保证公共资源的有序访问,防止出现冲突。

二值信号量

二值信号量是长度为1的队列,队列只有为空和满两种情况,对应信号量空闲和占有两种状态,也就对应标志位真假两种状态。主要用于实现同步。

函数 描述
xSemaphoreCreateBinary() 使用动态方式创建二值信号量
xSemaphoreCreateBinaryStatic() 使用静态方式创建二值信号量
xSemaphoreTake() 获取信号量
xSemaphoreTakeFromISR(). 在中断中获取信号量
xSemaphoreGive() 释放信号量
xSemaphoreGiveFromISR() 在中断中释放信号量
vSemaphoreDelete() 删除信号量

计数信号量

计数信号量是长度大于1的队列,用于做多个资源的标志位或者事件计数。

函数 描述
xSemaphoreCreateCounting() 使用动态方式创建计数型信号量
xSemaphoreCreateCountingStatic() 使用静态方式创建计数型信号量
xSemaphoreTake() 获取信号量
xSemaphoreTakeFromISR() 在中断中获取信号量
xSemaphoreGive() 释放信号量
xSemaphoreGiveFromISR() 在中断中释放信号量
vSemaphoreDelete() 删除信号量
uxSemaphoreGetCount() 获取计数型信号量资源数

互斥锁

互斥信号量(互斥锁)和二值信号量一样都是长度为一的队列,不同的是互斥信号量拥有优先级继承的机制。主要用于资源的有序访问。

优先级继承是指,当底优先级的任务获取到信号量之后,高优先级任务再去获取信号量会被阻塞,同时低优先级的任务的优先级会被提升到和高优先级任务一样的程度。这样做的目的是为了最大程度上避免优先级反转,即低优先级任务在获取信号量之后被中等优先级的任务抢占执行,导致高优先级的任务一直无法获取到信号量而被一直阻塞。

函数 描述
xSemaphoreCreateMutex() 使用动态方式创建互斥信号量
xSemaphoreCreateMutexStatic() 使用静态方式创建互斥信号量
xSemaphoreTake() 获取信号量
xSemaphoreGive() 释放信号量
vSemaphoreDelete() 删除信号量

请牢记优先级继承机制的这些特定行为:

  • 如果一个任务在占用一个互斥锁时没有先释放它已占用的互斥锁, 则可以进一步提升其继承优先级。
  • 任务在释放其占有的所有互斥锁之前,一直保持最高继承优先级。 这与释放互斥锁的顺序无关。
  • 如果多个互斥锁被占用,无论在任何一个被占用的互斥锁上等待的任务是否完成等待(超时), 则任务将保持最高继承优先级 。

需要注意的是中断中不能申请互斥信号量,原因如下:

  • 互斥锁使用的优先级继承机制要求 从任务中(而不是从中断中)拿走和放入互斥锁。
  • 中断无法保持阻塞来等待一个被互斥锁保护的资源 变得可用。

递归互斥锁

递归互斥信号量是特殊的互斥信号量,在具备互斥信号量的优先级继承特性的同时它允许信号的持有任务多次申请获取信号量,在释放信号量的时候也需要释放相同次数才能真正释放信号量。

函数 描述
xSemaphoreCreateRecursiveMutex() 使用动态方式创建递归互斥信号量
xSemaphoreCreateRecursiveMutexStatic() 使用静态方式创建递归互斥信号量
xSemaphoreTakeRecursive() 获取递归互斥信号量
xSemaphoreGiveRecursive() 释放递归互斥信号量
vSemaphoreDelete() 删除信号量

直接任务通知

描述

每个 RTOS 任务内部都有一个_任务通知_数组。每个任务_都有“挂起”或“非挂起”的通知状态和一个 32 位的_通知值。直达任务通知是直接发送到任务的事件,而不是通过中间对象 (如队列、事件组或信号量)间接发送至任务的事件。其具有高度灵活性, 使得它们可以在某些情况下替代传统队列、事件组或信号量。通过直接通知解除 RTOS 任务阻塞状态的速度快 45%使用的 RAM 也更少。

但直接任务通知也有两点局限:

  • 只能一个特定任务对另一个特定任务,无法实现群通知。
  • 非对称阻塞,接收任务可在阻塞状态下等待通知时,发送任务不能在阻塞状态下等待发送完成。

如何实现模拟替换

​ FreeRTOS为每个任务的 TCB 结构体悄悄塞进了两个极其关键的数组:

  • ucNotifyState 是一个状态机,它决定了任务是挂起休眠还是被唤醒;*
  • ulNotifiedValue 是一个 32位的数据载体,各种模拟操作全靠对这个 32 位变量进行“加减、按位或、直接赋值、清零”等内存操作手法来实现。

    直接任务通知所有的对计数值,事件组等的模拟的操作都是基于对这两个变量的操作处理。

1. 作为二值信号量
  • 当使用任务通知代替二进制信号量时,接收任务的通知值会用于替代二进制信号量的计数值,每次获取通知时计数值均归零 ——模拟二进制信号量。
  • 接收端的第一个参数必须是 pdTRUE。这代表任务被唤醒后,会把底层的通知值强制清零,模拟二值信号量的特性。
2. 作为计数信号量
  • 当使用任务通知代替计数信号量时,接收任务的通知值会用于替代计数信号量的计数值,每次接收通知时,计数值只会递减(而不是清除), 模拟计数信号量。
  • 发送端每次调用,内部的 32 位变量会自动加 1。接收端的第一个参数必须改为 pdFALSE。这代表任务醒来后,底层的通知值只会递减 1,直到减到 0 才会再次阻塞。
3. 作为事件标志组
  • 当使用任务通知代替事件组时,使用接收任务的通知值代替事件组,接收任务通知值中的位被用作事件标志
4. 作为邮箱 (长度为1的队列)
  • 当使用任务通知代替队列时,使用接收任务的通知值代替传递的消息。
  • 如果参数eAction 设置为 eSetValueWithOverwrite, 会强制覆盖旧数据。如果 eAction 设置为 eSetValueWithoutOverwrite, 则不会覆盖旧数据,只有在旧数据已被读走时,才会成功写入。

API使用

常用API:
模拟目标 发送动作 (工具) 接收动作 (消费方式)
二值信号量 xTaskNotifyGive() ulTaskNotifyTake( pdTRUE, ... )
计数信号量 xTaskNotifyGive() ulTaskNotifyTake( pdFALSE, ... )
事件标志组 xTaskNotify(..., eSetBits) xTaskNotifyWait(..., &ulValue, ...)
邮箱 (数据传递) xTaskNotify(..., eSetValueWithOverwrite) xTaskNotifyWait(..., &ulValue, ...)

可看到API函数大值相同,只是参数不同。表中是基本常用的API,还有两种类型如

  • vTaskNotifyGiveFromISR(),在中断中使用。
  • xTaskNotifyGiveIndexed(),索引版。任务内部是任务通知数组,无Indexed后缀的API默认在数组索引为 0 的任务通知上进行操作,有后缀的即可指定索引数组。常量 configTASK_NOTIFICATION_ARRAY_ENTRIES设置任务通知数组中的索引数量。在 FreeRTOS V10.4.0 版本前,任务只有单条任务通知, 而无通知数组。

详细API参考:https://www.freertos.org/zh-cn-cmn-s/Documentation/02-Kernel/04-API-references/05-Direct-to-task-notifications/00-RTOS-task-notifications


流缓冲区

  • 专业定义:一种用于传递连续字节流的数据结构,数据通过拷贝方式进出。它没有消息边界的概念,写入和读取的长度完全自由
  • 使用:通过流缓冲区,可以将字节流从中断服务程序传递到任务, 也可以将其从一项任务传递到另一项任务,或从双核 CPU 上的一个微控制器内核传递到另一个微控制器内核。

单工机制和触发级别

  1. 触发级别
    • 这是流缓冲区独有的机制,决定了积攒多少数据才唤醒接收任务。如果设为 10:必须等管子里攒够 10 个字节(或者等待超时),接收任务才会被唤醒。
    • 流缓冲器的触发级别是在创建时设置的, 可以使用 xStreamBufferSetTriggerLevel()API 函数进行更改。
  2. 单工机制
    • 单生产者-单消费者确保只有一项任务或中断会写入,且只有一项任务或中断会读取。拥有多个不同的写入器或读取器是不安全的。因为官方为了把流缓冲区执行速度拉满,直接删掉了其相关函数内部大部分的并发保护锁机制。
    • 如果非要让多个任务共用同一个流缓冲区,开发者必须自己手动在外部加锁,及应用程序编写者必须 将对读取 API 函数的每次调用置于临界区内, 并将接收阻塞时间设置为 0。

详细API参考:https://www.freertos.org/zh-cn-cmn-s/Documentation/02-Kernel/04-API-references/08-Stream-buffers/00-RTOS-stream-buffer-API

发送和接收完成回调

流缓冲区和消息缓冲区会在每次发送和接收操作完成后执行回调:

  • sbSEND_COMPLETED()(和 sbSEND_COMPLETED_FROM_ISR()):默认情况下, sbSEND_COMPLETED() 会检查流缓冲区上是否存在等待数据的阻塞任务; 如果存在,则将该任务从阻塞状态中移除。
  • sbRECEIVE_COMPLETED()(和 sbRECEIVE_COMPLETED_FROM_ISR()):默认情况下, 该宏会检查流缓冲区上是否存在等待缓冲区内空间可用的阻塞任务; 如果存在,则将该任务 从阻塞状态中移除。

    一般情况我们不需要处理这两个回调,但当有特殊需求,或涉及多核之间通信时,它们能发挥很大作用。


消息缓冲区

  • 专业定义:基于流缓冲区构建,专门用于传递长度可变的离散数据包。每一次写入的整包数据,读取时必须一次性完整读出,不可分割
  • 使用:消息缓冲区允许长度可变的离散消息从中断服务程序传递至 一个任务,或从一个任务传递至另一个任务。。

消息缓冲区大小机制:

  • 为了记录每个包裹的大小,FreeRTOS 会在每次写入真实数据前,自动在帧头中强制插入一个变量,用于记录该帧数据长度。
  • 插入变量默认是 size_t 类型,其类型由 configMESSAGE_BUFFER_LENGTH_TYPE 常量 (位于 FreeRTOSConfig.h 中)设置。

单工机制和消息完整性

1.消息完整性

  • 读取操作必须具备原子性。写入一条长度为 N 字节的消息,接收端必须提供一个大于等于 N 字节的数组,不能分次、分段读取。

2.单工读取

​ 同流缓冲区

发送和接收完成回调

同流缓冲区

详细API参考:https://www.freertos.org/zh-cn-cmn-s/Documentation/02-Kernel/04-API-references/09-Message-buffers/00-RTOS-message-buffer-API


事件组

事件位与事件组

  • 事件位 (Event Bit/Flag):用于指示特定事件是否发生的布尔型标志(置 1 表示发生,清 0 表示未发生)。
  • 事件组 (Event Group):一组事件位的集合。

数据类型

  • 事件组中的所有位统一存储在一个 EventBits_t 类型的无符号整数变量中。其实际可用位数受 FreeRTOSConfig.h 中的 configUSE_16_BIT_TICKS 宏控制:
    • 设为 1 时:提供 8 个可用事件位。
    • 设为 0 时:提供 24 个可用事件位。
  • 事件组由EventGroupHandle_t类型的变量引用

注意:

​ 在修改或读取事件组状态时,必须调用官方 API 函数(如 xEventGroupSetBits(),严禁自行使用 C 语言的位操作符去直接操控底层的全局变量。 ​ 因为C 语言的位赋值指令在汇编层会被拆分为“读-改-写”三步。若在此期间被硬件中断抢占并修改了该全局变量,任务恢复后写回的旧寄存器值将直接覆盖中断产生的新标志,导致事件丢失。官方 API 通过调度器锁等底层机制,将位操作强制封装为不可打断的原子操作。

详细API参考:https://www.freertos.org/zh-cn-cmn-s/Documentation/02-Kernel/04-API-references/12-Event-groups-or-flags/00-Event-groups


软件定时器

描述

  • 专业定义:软件定时器是 FreeRTOS 的可选附加功能,完全由软件(基于系统的 Tick 时钟)实现。它的核心由一个后台隐藏的定时器服务任务(守护进程)和一个定时器命令队列组成。
  • 执行方式:用户无法直接操作定时器的底层计数器。当我们调用启动、复位等 API 时,实际上是向定时器命令队列发送了一条指令消息,随后由守护进程任务从队列中读取并执行。

模式

  1. 单次定时器 (One-shot Timer)
    • 特性:启动后,时间一到只执行一次回调函数,随后自动进入休眠状态,除非你再次手动启动或复位它。
  2. 周期定时器 (Auto-load Timer)
    • 特性:时间一到,执行完回调函数后,系统会自动为其重新装载计数值并再次启动,无限循环。

执行逻辑

操作软件定时器时,用户无法直接操作定时器的底层计数器。当我们调用启动、复位等 API 时,实际上是向定时器命令队列发送了一条指令消息,随后由守护进程任务从队列中读取并执行。因为全系统所有的定时器回调都由这唯一的一个守护任务串行执行,所以定时器回调函数中不能出现任何阻塞行为。

宏配置说明

FreeRTOSConfig.h 中明确定义以下 4 个核心宏常量:

  • configUSE_TIMERS:当设置为 1 时,RTOS 定时器服务任务将在调度器启动时自动创建。
  • configTIMER_TASK_PRIORITY:设置定时器服务任务的优先级。
  • configTIMER_QUEUE_LENGTH:这设置了定时器命令队列在任一时间可以容纳的未处理命令的最大数量
  • configTIMER_TASK_STACK_DEPTH:设置分配给定时器服务任务的堆栈大小(以字为单位,而不是以字节为单位)

详细API参考:https://www.freertos.org/zh-cn-cmn-s/Documentation/02-Kernel/04-API-references/11-Software-timers/00-FreeRTOS-Software-Timer-API-Functions