FreeRTOS基础学习_Part2
7 同步互斥与通信
7.1 同步与互斥的概念
一句话理解同步与互斥:我等你用完厕所,我再用厕所。
1 | - 什么叫同步?就是:哎哎哎,我正在用厕所,你等会。 |
同步与互斥经常放在一起讲,是因为它们之的关系很大,“互斥”操作可以使用“同步”来实现。我“等”你用完厕所,我再用厕所。这不就是用“同步”来实现“互斥”吗?
相当于大丙老师多线程教学中的“线程同步”和“互斥锁”的概念吗?
7.2 有缺陷的同步与互斥示例
通过下面两个任务示例,就可以比较容易地看出同步和互斥想要解决的问题。
同步主要聚焦于两/多个任务有依赖关系,其中一个任务B需要另一个任务A的处理结果,所以任务B就需要等待任务A处理结束才能继续运行,所以任务/线程同步聚焦于解决如何让等待的线程B在等待期间尽量少地占用CPU资源。
互斥主要聚焦于两/多个任务共同访问同一个共享资源,这些任务不一定有依赖关系,可能是完全独立的任务,但是有些任务会写入共享资源,有些任务会读取共享资源,如果不加以处理,写入和读取就会乱套,导致程序Bug。
7.2.1 有缺陷的同步示例
假设现在有两个任务A、B, 任务A是通过上万次的循环累加一个较大的数值,任务B是在LCD屏幕上显示出任务A计算的数值,这就是一个任务/线程同步的例子,任务B必须使用任务A计算的结果,因此任务B必须等待任务A执行完毕,才能继续执行。
1 | // 全局变量,计算结束的标志,注意这里必须使用volatile,否则循环变量会被编译器优化而导致无法结束循环 |
7.2.2 有缺陷的互斥示例
假设现在创建了两个任务A、B,这两个任务相互独立没有依赖,只是执行单纯的打印任务LcdPrintTask()。
1 | void LcdPrintTask(void* args) |
现在简单分析一下这两个任务同时调用LcdPrintTask()函数会出现什么情况。
我们直到,OLED屏幕和单片机通信使用的是IIC协议,IIC协议要求有严格的时序,一旦被打乱,整个通信就打乱了。但偏偏IIC又是相对比较耗费时间的,这就会产出很明显的问题:任务A有可能IIC时序刚执行到一半,正好产生Tick中断,被切换到任务B执行了,B又重新发起IIC时序,直接就乱套了。
7.3 FreeRTOS提供的同步和互斥方法概览
FreeRTOS中能实现同步、互斥的内核方法有:任务通知(task notification)、队列(queue)、事件组(event group)、信号量(semaphoe)、互斥量(mutex)。
它们都有类似的操作方法:获取/释放、阻塞/唤醒、超时。比如:
- 任务A获取资源,用完后任务A释放资源
- 任务A获取不到资源则阻塞,任务B释放资源并把任务A唤醒
- 任务A获取不到资源则阻塞,并定个闹钟;A要么超时返回,要么因为任务B释放资源而被唤醒。
这些内核对象五花八门,记不住怎么办?我也记不住,通过对比的方法来区分它们。
- 能否传信息?还是只能传递状态?
- 为众生(所有任务都可以使用)?只为你(只能指定任务使用)?
- 我生产,你们消费?
- 我上锁,只能由我开锁
使用表格对比如下:
| 内核对象 | 生产者 | 消费者 | 数据/状态 | 说明 |
|---|---|---|---|---|
| 队列 | ALL | ALL | 数据:若干个数据 谁都可以往队列里写数据,谁都可以从队列里读数据 |
用来传递数据,发送者、接收者无限制,一个数据只能唤醒一个接收者 |
| 事件组 | ALL | ALL | 多个位:或、与 谁都可以设置(生产)多个位,谁都可以等待某个位、若干个位 |
用来传递事件(可以是N个事件),发送者、接受者无限制,可以唤醒多个接收者(像广播) |
| 信号量 | ALL | ALL | 数量:0~n 谁都可以增加一个数量,谁都可消耗一个数量 |
用来维持资源的个数,生产者、消费者无限制,1个资源只能唤醒1个接收者 |
| 任务通知 | ALL | 只有我 | 数据、状态都可以传输,使用任务通知时,必须指定接受者 | N对1的关系:发送者无限制,接收者只能是这个任务 |
| 互斥量 | 只能A开锁 | A上锁 | 位:0、1 我上锁:1 变为 0, 只能由我开锁:0 变为 1 |
就像一个空厕所,谁使用谁上锁,也只能由他开锁 |
使用图形对比如下:
- 队列:
- 里面可以放任意数据,可以放多个数据
- 任务、ISR都可以放入数据;任务、ISR 都可以从中读出数据
可以认为队列是一个流水线,图中左侧是生产者工人,右侧是消费者
- 事件组:
- 一个事件用一 bit 表示,1 表示事件发生了,0 表示事件没发生
- 可以用来表示事件、事件的组合发生了,不能传递数据
- 有广播效果:事件或事件的组合发生了,等待它的多个任务都会被唤醒
- 信号量:
- 核心是”计数值”
- 任务、ISR 释放信号量时让计数值加 1
- 任务、ISR 获得信号量时,让计数值减 1
PS:若信号量的计数值只设置为1,那么信号量就等价于互斥量。
- 任务通知:
- 核心是任务的TCB 里的数值会被覆盖
- 发通知给谁?必须指定接收任务
- 只能由接收任务本身获取该通知
- 互斥量:
- 数值只有 0 或1
- 谁获得互斥量,就必须由谁释放同一个互斥量
8 队列
队列中,数据的读写本质就是环形缓冲区,在这个基础上增加了互斥措施、阻塞-唤醒机制。
- 如果这个队列不传输数据,只调整”数据个数”,它就是信号量(semaphore)。
- 如果信号量中,限定”数据个数”最大值为1,它就是互斥量(mutex)。
8.1 队列的特性
8.1.1 常规操作
队列的简化操如入下图所示
- 从此图可知:
- 队列可以包含若干个数据:队列中有若干项,这被称为”长度”(length)
- 每个数据大小固定
- 创建队列时就要指定长度、数据大小
- 数据的操作采用先进先出的方法(FIFO,First In First Out):写数据时放到尾部,读数据时从头部读,但也可以强制写队列头部:覆盖头部数据
更详细的队列操作示例如下图所示:
8.1.2 传输数据的两种方法
使用队列传输数据时有两种方法:
- 值拷贝:把数据、把变量的值复制进队列里
- (地址)引用:把数据、把变量的地址复制进队列里
FreeRTOS 使用拷贝值的方法,这更简单,原因有如下几条:
- 局部变量的值可以发送到队列中,后续即使函数退出、局部变量被回收,也不会影响队列中的数据
- 无需分配 buffer 来保存数据,队列中有 buffer
- 局部变量可以马上再次使用
- 发送任务、接收任务解耦:接收任务不需要知道这数据是谁的、也不需要发送任务来释放数据
- 如果数据实在太大,你还是可以使用队列传输它的地址
- 队列的空间有 FreeRTOS 内核分配,无需任务操心
- 对于有内存保护功能的系统,如果队列使用引用方法,也就是使用地址,必须确保双方任务对这个地址都有访问权限。使用拷贝方法时,则无此限制:内核有足够的权限,把数据复制进队列、再把数据复制出队列。
8.1.3 队列的阻塞访问
只要知道队列的句柄,谁都可以读、写该队列。任务、ISR(Interrupt Service Routine, 中断服务例程)都可读、写队列。可以多个任务读写队列。
任务读写队列时,简单地说:如果读写不成功,则阻塞;可以指定超时时间。口语化地说,就是可以定个闹钟:如果能读写了就马上进入就绪态,否则就阻塞直到超时。 某个任务读队列时,如果队列没有数据,则该任务可以进入阻塞状态:还可以指定阻塞的时间。如果队列有数据了,则该阻塞的任务会变为就绪态。如果一直都没有数据,则时间到之后它也会进入就绪态。
既然读取队列的任务个数没有限制,那么当多个任务读取空队列时,这些任务都会进入阻塞状态:有多个任务在等待同一个队列的数据。当队列中有数据时,哪个任务会进入就绪态? 答案是按照如下两个原则:
- ① 若存在不同优先级的任务,则优先级最高的任务
- ② 如果大家的优先级相同,那等待时间最久的任务会进入就绪态
跟读队列类似,一个任务要写队列时,如果队列满了,该任务也可以进入阻塞状态:还可以指定阻塞的时间。如果队列有空间了,则该阻塞的任务会变为就绪态。如果一直都没有空间,则时间到之后它也会进入就绪态。
既然写队列的任务个数没有限制,那么当多个任务写”满队列”时,这些任务都会进入阻塞状态:有多个任务在等待同一个队列的空间。当队列中有空间时,哪个任务会进入就绪态?
- ① 若存在不同优先级的任务,则优先级最高的任务
- ② 如果大家的优先级相同,那等待时间最久的任务会进入就绪态
8.1.4 队列的核心
- 关中断:通过关中断实现对共享资源的访问,避免冲突;
- 环形缓冲区:通过环形缓冲区来保存队列中的数据;
- 链表:队列是一个结构体,里面会维护一个链表,通过链表来实现任务的休眠和唤醒。
8.2 队列相关函数
使用队列的流程:创建队列、写队列、读队列、删除队列。
8.2.1 创建队列
队列的创建有两种方法:动态分配内存、静态分配内存,
- 动态分配内存:
xQueueCreate(),队列的内存在函数内部动态分配,函数原型如下:
1 | /** |
- 静态分配内存:
xQueueCreateStatic(),队列的内存要事先分配好,函数原型如下:
1 | /** |
关于
pxQueueBuffer参数,再多说一嘴:形象理解这个参数:假设有一个 快递驿站(队列):
- pucQueueStorageBuffer:货架(存放快递包裹)
- pxQueueBuffer:驿站的台账本(记录哪个包裹被取走/新到货,谁在排队等快递)
该参数主要用于维护队列状态,其内部记录以下核心信息:
- 队列头尾指针(当前存取位置)
- 队列长度和项目大小
- 任务阻塞列表(等待读/写的任务)
- 队列状态(是否已满/空)
8.2.2 复位和删除队列
队列刚被创建时,里面没有数据;使用过程中可调用xQueueReset()把队列恢复为初始状态,此函数原型为:
1 | /** |
删除队列的函数为vQueueDelete(),只能删除使用动态方法创建的队列,它会释放内存。原型如下:
1 | /** |
8.2.3 写队列和读队列
- 写队列
可以把数据写到队列头部,也可以写到尾部,这些函数有两个版本:在任务中使用、在ISR 中使用。函数原型如下:
版本1:普通任务中使用的写队列函数
1 | /** |
版本2:中断服务程序(ISR)中使用的写队列函数
1 | /** |
参数
pxHigherPriorityTaskWoken的进一步说明:
- 该参数是一个指向
BaseType_t的指针,用于标记是否有更高优先级任务因当前操作而解除阻塞。- 若队列操作(如发送数据)唤醒了阻塞中的更高优先级任务,此参数会被设为
pdTRUE,提示中断退出后需触发任务切换。设计目的:解决中断中无法直接调用调度器的限制,通过延迟调度决策到中断退出时,避免实时性破坏
使用场景与示例,中断唤醒高优先级任务,假设一个温度监控系统:
- 低优先级任务:周期性读取传感器(阻塞在队列上等待数据)用于显示。
- 高优先级任务:处理报警(需立即响应)。
- 中断:定时器中断采集数据并发送到队列。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
QueueHandle_t xTempQueue;
// 定时器中断处理函数
void Timer_ISR()
{
// 初始化标志为 pdFALSE
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
// 读取温度数据
float temp = read_temperature();
// 发送温度数据发送到队列 xTempQueue 的尾部
if (xQueueSendToBackFromISR(xTempQueue, &temp, &xHigherPriorityTaskWoken)==pdPASS)
{
// 检查是否需要任务切换,若高优先级报警任务被唤醒,立即切换
if (xHigherPriorityTaskWoken == pdTRUE)
// 调用portYIELD_FROM_ISR请求任务切换,确保调度器在中断返回后立即执行高优先级任务
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
}
// 高优先级任务,用于触发报警(Alarm_Task)
void Alarm_Task(void *pvParameters)
{
while (1)
{
float criticalTemp;
if (xQueueReceive(xTempQueue, &criticalTemp, portMAX_DELAY) == pdPASS)
{
if (criticalTemp > 100.0) trigger_alarm();
}
}
}1. 上面程序的说明:
- 示例场景中:
Alarm_Task是一个高优先级任务,调用xQueueReceive(xTempQueue, ..., portMAX_DELAY)阻塞在队列上,等待数据。- 定时器中断触发后,调用
xQueueSendToBackFromISR向队列发送数据。- FreeRTOS 内核检测到队列中有数据可读,且
Alarm_Task正在阻塞等待该队列。- 内核将
Alarm_Task从阻塞态切换为就绪态,并将xHigherPriorityTaskWoken自动设置为pdTRUE。- 中断服务例程(ISR)根据
xHigherPriorityTaskWoken的值调用portYIELD_FROM_ISR,请求任务切换。关键点:
xHigherPriorityTaskWoken的值不是用户手动设置的,而是由 FreeRTOS 内核根据任务状态变化自动设置的。- 用户只需检查该参数的值,并在需要时调用
portYIELD_FROM_ISR,以确保高优先级任务及时运行。portYIELD_FROM_ISR的作用是标记当前中断上下文中需要触发任务切换,但不会立即切换任务。只有当中断例程函数ISR完全执行结束后,才会由调度器执行更高优先级的任务。2. 为什么不能在中断中立即切换任务?
中断的原子性:
- 中断服务例程(ISR)的执行必须完整且不可被打断,否则可能导致数据不一致或硬件状态错误。
- 在中断中直接切换任务会导致中断嵌套和不可预测的系统行为。
- 调度器的限制:
- FreeRTOS 的调度器不允许在中断中直接切换任务,因为中断上下文不支持完整的上下文保存和恢复操作。
- 任务切换需要保存当前任务的上下文(寄存器、堆栈等),这必须在安全的上下文中完成(如任务上下文或 PendSV 异常)。
解释:为什么ISR需要专用队列函数?——以”急诊室插队”为例
普通函数(如xQueueSend)设计用于任务环境,而ISR运行在中断环境,两者关键区别:
- 不可阻塞性:ISR必须立即执行完毕,不能等待(类似急诊医生不能停下手术去排队挂号)
- 无任务调度:ISR不能主动触发任务切换(类似急诊室无权指挥其他科室工作)
灾难性示例:在ISR中使用普通函数
假设有一个心率监测中断,每检测到心跳就通过队列发送数据:
1 | // 错误示范!在ISR中使用普通发送函数 |
会引发的问题:
- 系统死锁
- 若队列已满,
portMAX_DELAY会让ISR无限等待,但ISR无法被调度器挂起- ISR执行期间,CPU由硬件直接控制(不论此中断是硬件触发还是软件触发),调度器无权挂起或切换 ISR
- 调度器是 FreeRTOS 等 RTOS 的核心组件,裸机中不存在
- 后果:若 ISR 尝试阻塞(如调用
portMAX_DELAY),由于调度器无法介入,会导致CPU 资源持续占用→低优先级任务饿死→系统死锁 (类似急诊医生堵在挂号窗口导致全院停摆)
- 若队列已满,
- 数据丢失
- 即使设置
xTicksToWait=0,普通函数仍会执行不必要的调度检查 - 后果:增加中断延迟,可能丢失后续心跳信号(类似急诊医生浪费时间填表格导致病人死亡)
- 即使设置
- 优先级反转
- 普通函数的内部锁可能破坏RTOS的实时性(类似急诊护士被迫遵循普通门诊流程)
正确做法:使用FromISR函数
1 | // 正确的中断处理 |
- 读队列
使用xQueueReceive()函数读队列,读到一个数据后,队列中该数据会被移除。这个函数有两个版本:在任务中使用、在 ISR 中使用。函数原型如下:
版本1:普通任务中使用的写队列函数
1 | /** |
版本2:中断服务程序(ISR)中使用的写队列函数
1 | /** |
参考资料:FreeRTOS基础学习(三)-消息队列 - CSDN
8.2.4 查询队列
可以查询队列中有多少个数据、有多少空余空间。函数原型如下:
1 | /** |
8.2.5 覆盖/偷看队列
- 覆盖队列
当队列长度为1时,可以使用xQueueOverwrite()或xQueueOverwriteFromISR()来覆盖数据。注意,队列长度必须为 1。当队列满时,这些函数会覆盖里面的数据,这也意味着这些函数不会被阻塞。
版本1:普通任务中使用的覆盖队列函数
1 | /** |
版本2:中断服务程序(ISR)中使用的覆盖队列函数
1 | /** |
- 偷看队列
若想让队列中的数据供多方读取,也就是说读取时不要移除数据,要留给后来人。则可以使用”窥视”,也就是xQueuePeek()或xQueuePeekFromISR()。这些函数会从队列中复制出数据,但是不移除数据。这也意味着,如果队列中没有数据,那么”偷看”时会导致阻塞;一旦队列中有数据,以后每次”偷看”都会成功。
版本1:普通任务中使用的偷看队列函数
1 | /** |
版本2:中断服务程序(ISR)中使用的偷看队列函数
1 | /** |
8.3 示例: 邮箱(Mailbox)
本节代码为:FreeRTOS_11_queue_mailbox。 FreeRTOS的邮箱概念跟别的RTOS不一样,这里的邮箱称为”橱窗”也许更恰当:
- 它是一个队列,队列长度只有1
- 写邮箱:新数据覆盖旧数据,在任务中用
xQueueOverwrite(),在中断中用xQueueOverwriteFromISR()。既然是覆盖,那么无论邮箱中是否有数据,这些函数总能成功写入数据。 - 读邮箱:读数据时,数据不会被移除;在任务中使用
xQueuePeek(),在中断中使用QueuePeekFromISR()。 这意味着,第一次调用时会因为无数据而阻塞,一旦曾经写入数据,以后读邮箱时总能成功。
main()函数中创建了队列(队列长度为1)、创建了发送任务、接收任务:
- 发送任务的优先级为2,它先执行
- 接收任务的优先级为1
1 | /* 队列句柄, 创建队列时会设置这个变量 */ |
发送任务、接收任务的代码和执行流程如下:
- A:发送任务先执行,马上阻塞
- BC:接收任务执行,这时邮箱无数据,打印”Could not …”。在发送任务阻塞过程中,接收任务多次执行、多次打印
- D:发送任务从阻塞状态退出,立刻执行、写队列
- E:发送任务再次阻塞
- FG、HI、……:接收任务不断”偷看”邮箱,得到同一个数据,打印出多个”Get: 0”
- J:发送任务从阻塞状态退出,立刻执行、覆盖队列,写入 1
- K:发送任务再次阻塞
- LM、……:接收任务不断”偷看”邮箱,得到同一个数据,打印出多个”Get: 1”
运行结果如下图所示:
8.4 队列集
假设有2个输入设备:红外遥控器、旋转编码器,它们的驱动程序应该专注于“产生硬件数据”,不应该跟“业务有任何联系”。比如:红外遥控器驱动程序里,它只应该把键值记录下来、写入某个队列,它不应该把键值转换为游戏的控制键。在红外遥控器的驱动程序里, 不应该有游戏相关的代码,这样,切换使用场景时,这个驱动程序还可以继续使用。 把红外遥控器的按键转换为游戏的控制键,应该在游戏的任务里实现。
要支持多个输入设备时,我们需要实现一个“InputTask”,它读取各个设备的队列,得到数据后再分别转换为游戏的控制键。
InputTask如何及时读取到多个队列的数据?要使用队列集。 队列集的本质也是队列,只不过里面存放的是“队列句柄”。使用过程如下:
- 创建队列A,它的长度是n1
- 创建队列B,它的长度是n2
- 创建队列集S,它的长度是“n1+n2”
- 把队列A、B加入队列集S
- 这样,使用写队列的函数向队列A或队列B写数据的时候,会顺便把队列A或队列B的句柄写入队列集S
- InputTask先读取队列集 S,它的返回值是一个队列句柄,这样就可以知道哪个队列有有数据了;然后 InputTask再读取这个队列句柄得到数据。
8.4.1 队列集的相关函数
- 创建队列集,函数原型如下:
1 | /** |
- 把队列加入队列集 ,函数原型如下:
1 | /** |
- 读取队列集,函数原型如下:
1 | /** |
xQueueSelectFromSet是 FreeRTOS 提供的队列集合(Queue Set)功能的核心函数,用于从多个队列/信号量中等待任一成员就绪(即有数据可读或信号量可获取)。其本质是实现了多路复用的同步机制,允许任务同时监控多个通信对象而无需轮询
9 信号量(semaphore)
前面介绍的队列(queue)可以用于传输数据:在任务之间、任务和中断之间。 消息队列用于传输多个数据,但是有时候我们只需要传递状态,这个状态值需要用一个数值表示,比如:
1 | - 卖家:做好了 1 个包子!做好了 2 个包子!做好了3 个包子! |
在这种情况下我们只需要维护一个数值,使用信号量效率更高、更节省内存。
信号量用在多线程多任务同步的,一个线程完成了某一个动作就通过信号量告诉别的线程,别的线程再进行某些动作。信号量不一定是锁定某一个资源,而是流程上的概念,比如:有A,B两个线程,B线程要等A线程完成某一任务以后再进行自己下面的步骤,这个任务并不一定是锁定某一资源,还可以是进行一些计算或者数据处理之类。
9.1 信号量的特性
9.1.1 信号量的常规操作
信号量这个名字很恰当:
- 信号:起通知作用
- 量:可以用来表示资源的数量
- 当”量”没有限制时,它就是”计数型信号量“(Counting Semaphores)
- 当”量”只有0、1 两个取值时,它就是”二进制信号量“(Binary Semaphores)
- 支持的动作:
give给出资源,计数值加 1;take获得资源,计数值减 1
计数型信号量的典型场景是:
- 计数:事件产生时
give信号量,让计数值加1;处理事件时要先take信号量(即获得信号量,让计数值减1) - 资源管理:要想访问资源需要先
take信号量,让计数值减 1;用完资源后give信号量,让计数值加 1。
信号量的give、take双方并不需要相同,可以用于生产者——消费者场合:
- 生产者为任务A、B,消费者为任务C、D
- 一开始信号量的计数值为0,如果任务 C、D 想获得信号量,会有两种结果:
- 阻塞:买不到东西咱就等等吧,可以定个闹钟(超时时间)
- 即刻返回失败:不等
- 任务A、B 可以生产资源,就是让信号量的计数值增加1,并且把等待这个资源的顾客唤醒
- 唤醒谁?谁优先级高就唤醒谁,如果大家优先级一样就唤醒等待时间最长的消费者任务
9.1.2 两种信号量的对比
信号量的计数值都有限制:限定了最大值。如果最大值被限定为1,那么它就是二进制信号量;如果最大值不是 1,它就是计数型信号量。
差别列表如下:
| 计数型信号量 | 二进制信号量 |
|---|---|
| 被创建时初始值可以设定 | 被创建时初始值为0 |
| 其他操作是一样的 | 其他操作是一样的 |
9.2 信号量函数
使用信号量时,先创建、然后去添加资源、获得资源。使用句柄来表示一个信号量。
9.2.1 创建信号量
使用信号量之前,要先创建,得到一个句柄;使用信号量时,要使用句柄来表明使用哪个信号量。 对于二进制信号量、计数型信号量,它们的创建函数不一样。
- 创建二进制信号量的函数原型如下:
1 | /** |
- 创建计数型信号量的函数原型如下:
1 | /** |
9.2.2 删除信号量
对于动态创建的信号量,不再需要它们时,可以删除它们以回收内存。 二进制信号量、计数型信号量都可以用下面的函数删除,函数原型如下:
1 | /** |
9.2.3 信号量的give(释放)和take(获取)
二进制信号量、计数型信号量的give、take操作函数是一样的。这些函数也分为2个版本:给普通任务使用,给 ISR 使用。
- 版本1:普通
give、take操作函数
1 | /** |
进一步说明
xSemaphoreGive()函数返回失败的情况
- 如果二进制信号量的计数值已经是1,再次调用此函数则返回失败;
- 如果计数型信号量的计数值已经是最大,再次调用此函数则返回失败
- 版本2:用于中断的
give、take操作函数
1 | /** |
9.3 示例: 使用二进制信号量来同步
本节代码为: FreeRTOS_12_semaphore_binary 。
main()函数中创建了一个二进制信号量,然后创建2个任务:一个用于释放信号量,另一个用于获取信号量,代码如下:
1 | /* 二进制信号量句柄 */ |
发送任务、接收任务的代码和执行流程如下:
- A:发送任务优先级高,先执行。连续 3 次释放二进制信号量,只有第1次成功
- B:发送任务进入阻塞态
- C:接收任务得以执行,得到信号量,打印 OK;再次去获得信号量时,进入阻塞状态
- 在发送任务的
vTaskDelay()退出之前,运行的是空闲任务:现在发送任务、接收任务都阻塞了 - D:
vTaskDelay()退出,发送任务再次运行,连续3次释放二进制信号量,只有第1次成功 - E:发送任务进入阻塞态
- F:接收任务被唤醒,得到信号量,打印OK;再次去获得信号量时,进入阻塞状态
运行结果如下图所示,即使发送任务连续释放多个信号量,也只能成功1次。释放、获得信号量是一一对应的。
9.4 问题:优先级反转
正常情况:高优先级任务先运行,低优先级任务后运行。
优先级反转后:低优先级任务先运行,高优先级任务后运行。
这个问题的解决可以使用互斥量/锁。
10 互斥量/锁(mutex)
怎么独享厕所?自己开门上锁,完事了自己开锁。 你当然可以进去后,让别人帮你把门:但是,命运就掌握在别人手上了。
使用队列、信号量,都可以实现互斥访问,以信号量为例:
1 | - 信号量初始值为 1 |
可以看到,使用信号量确实也可以实现互斥访问,但是不完美。
使用互斥量可以解决这个问题,互斥量的名字取得很好:
- 量:值为 0、1
- 互斥:用来实现互斥访问
它的核心在于:谁上锁,就只能由谁开锁。 但很奇怪的是,FreeRTOS的互斥锁,并没有在代码上实现这点,也就是说在FreeRTOS中,即使任务A获得了互斥锁,任务B竟然也可以释放互斥锁。谁上锁、谁释放:只是约定。
互斥量本质上就是二值信号量,只不过互斥量用于资源保护,而且具有“优先级继承”的特性。
10.1 互斥量的使用场合
在多任务系统中,任务A正在使用某个资源,还没用完的情况下任务B也来使用的话,就可能导致问题。
比如对于串口,任务A正使用它来打印,在打印过程中任务B也来打印,结果就是A、B的信息混杂在一起。 这种现象很常见:
- 访问外设:刚举的串口例子
- 读、修改、写操作导致的问题
举例:对于同一个变量,比如int a,如果有两个任务同时写它就有可能导致问题。 因为对于变量的修改,C代码只有一条语句,比如:a=a+8;,但是它的内部实现分为3步:读出原值、 修改、写入。 在这三步其中的任意一步被打断都会造型变量a的值修改失败。
上述问题的解决方法是:任务A访问这些全局变量、函数代码时,独占它,就是上个锁。 这些全局变量、函数代码必须被独占地使用,它们被称为临界资源。
互斥量也被称为互斥锁,使用过程如下:
1 | - 互斥量初始值为1 |
再次强调:正常来说,在任务 A 占有互斥量的过程中,任务B、任务C等等,都无法释放互斥量。
但是FreeRTOS未实现这点:任务A占有互斥量的情况下,任务B也可释放互斥量。
- 任意时刻互斥锁/量的状态只有两种:开锁或闭锁。
- 当互斥量被任务持有时, 该互斥量处于闭锁状态,这个任务获得互斥量的所有权。
- 当该任务释放这个互斥量时,该互斥量处于开锁状态,任务失去该互斥量的所有权。
当一个任务持有互斥量时,其他任务将不能再对该互斥量进行开锁或持有。持有该互斥量的任务也能够再次获得这个锁而不被挂起,这就是递归访问,也就是递归互斥量的特性,这个特性与一般的信号量有很大的不同,在信号量中,由于已经不存在可用的信号量,任务递归获取信号量时会发生主动挂起任务最终形成死锁。
信号量和互斥量均能用于临界资源的保护:
- 但信号量会导致任务优先级翻转;
- 互斥量可以通过优先级继承算法,可以降低优先级翻转问题产生的影响
10.2 互斥量的优先级继承机制
优先级继承算法:暂时提高某个占有某种资源的低优先级任务的优先级,使之与在所有等待该资源的任务中优先级最高那个任务的优先级相等,而当这个低优先级任务执行完毕释放该资源时,优先级重新回到初始设定值。继承优先级的任务避免了系统资源被任何中间优先级的任务抢占。
10.2.1 优先级翻转的概念
假设有一个低优先级任务正在使用一个资源,然后一个高优先级的任务也想使用同一个资源,如果不进行give释放操作的话,这个高优先级任务是无法打断低优先级任务执行的,只能进入阻塞。这时候如果有一个中优先级的任务在执行代码,它会抢占低优先级任务的执行霸占CPU,这样就有一个情况:低优先级任务被阻塞,高优先级任务在等待资源也在执行不了,但中优先级的任务却在执行。也就是高优先级任务反而比中等优先级任务更晚执行,这就是优先级翻转。
10.2.2 优先级继承
为了解决优先级翻转的问题,互斥量具有一个它专属特性:优先级继承。
简单说:当一个高优先级任务被一个低优先级任务持有的互斥量阻塞时,低优先级任务临时提升到与高优先级任务相同的优先级。
这样做的目的是:
- 减少阻塞时间:确保低优先级任务可以快速完成对共享资源的使用,并释放互斥量。
- 让高优先级任务尽快执行:一旦低优先级任务释放了互斥量,高优先级任务就可以立即执行。
参考资料1:FreeRTOS笔记之互斥量 - CSDN
参考资料2:FreeRtos互斥量 - CSDN
参考资料3:FreeRTOS(CMSIS)-(4)-互斥量 - CSDN
10.3 互斥量函数
10.3.1 创建互斥量
互斥量是一种特殊的二进制信号量。 使用互斥量时,先创建、然后去获得、释放它。
特别要注意,要想使用互斥量,需要在配置文件FreeRTOSConfig.h中定义:
1 |
使用句柄来表示一个互斥量。 创建互斥量的函数有2种:动态分配内存,静态分配内存,函数原型如下:
1 | /** |
10.3.2 互斥量其他函数
特别要注意,互斥量不能在 ISR 中使用。 各类操作函数,比如删除、give/take,跟一般是信号量是一样的。
1 | /** |
10.4 示例: 互斥量基本使用
本节代码为: FreeRTOS_15_mutex。
使用互斥量时有如下特点:
- 刚创建的互斥量可以被成功
take(相当于加锁) take互斥量成功的任务,被称为holder,只能由它give互斥量;别的任务give不成功- 在ISR中不能使用互斥量
本程序创建2个发送任务:故意发送大量的字符。可以做 2 个实验:
- 不使用互斥量:任务 1、任务2 打印的字符串混杂在一起
- 使用互斥量:可以看到任务 1、任务2 打印的字符串没有混杂在一起
main.c函数代码如下:
1 | // 互斥量句柄 |
可以做两个实验:vSenderTask()函数的for循环中xSemaphoreTake和xSemaphoreGive这 2 句代码保留、不保留:
不保留:实验现象如下图右边,打印信息混杂在一起
保留:实验现象如下图左边,任务 1、任务2 的打印信息没有混在一起
10.5递归锁
10.5.1 死锁的概念
日常生活的死锁:我们只招有工作经验的人!我没有工作经验怎么办?那你就去找工作啊! 无解。
1 | - 假设有2个互斥量M1、M2,2个任务A、B: |
10.5.2 递归锁
怎么解决这类问题?可以使用递归锁(Recursive Mutexes),它的特性如下:
- 任务A 获得递归锁M后,它还可以多次去获得这个锁
take了N 次,要giveN 次,这个锁才会被释放
特别要注意,要想使用递归锁,需要在配置文件FreeRTOSConfig.h中确保 configSUPPORT_DYNAMIC_ALLOCATION=1
1 | /** |
11 事件组(event group)
上面介绍的(消息)队列、信号量、互斥量等,它们都有一个特点:生产者任务写入一个数据只能唤醒一个消费者任务,同样消费者消费一个数据后,只能唤醒一个任务。
11.1 事件组概念与操作
11.1.1 事件组的概念
事件组可以简单地认为就是一个整数:
- 整数除了高八位以外,剩下的每一位表示一个事件
- 每一位事件的含义由程序员决定,比如:Bit0表示用来串口是否就绪,Bit1表示按键是否被按下
- 这些位,值为1表示事件发生了,值为0表示事件没发生
- 一个任务、多个任务或 ISR 都可以去读/写这些位;
- 可以等待某一位、某些位中的任意一个,也可以等待多位
此外,事件组内部还会维护一个链表,这个链表里面存放的是正在等待的任务,当事件组整数的某些位为1时,与之相对应的事件就会被唤醒。当然唤醒的时候还有一些规则:
- 第一:事件组会去询问链表中这些等待的任务它们等待的是什么事件,这些任务中也会有一个整数,如果这个整数相应的位上为1,那么就是指这个任务想等待该事件的发生。
- 第二:链表中任务的整数中有多个位为1怎么办?这说明这个任务和多个事件的发生有关,但还需要确定的是这些事件有一个发生就可(或)唤醒任务还是需要这些事件都发生(与)才能唤醒任务。
- 可通过整数的高8位来确定任务对应的事件之间是与的关系还是或的关系
事件组用一个整数来表示,其中的高8位留给内核使用,只能用其他的位来表示事件,那么这个整数是多少位的?
- 如果
configUSE_16_BIT_TICKS = 1,那么这个整数就是 16 位的,低 8 位用来表示事件 - 如果
configUSE_16_BIT_TICKS = 0,那么这个整数就是 32 位的,低 24 位用来表示事件 configUSE_16_BIT_TICKS是用来表示 Tick Count 的,怎么会影响事件组? 这只是基于效率来考虑- 如果
configUSE_16_BIT_TICKS = 1,就表示该处理器使用 16 位更高效,所以事件组也使用 16 位 - 如果
configUSE_16_BIT_TICKS = 0,就表示该处理器使用 32 位更高效,所以事件组也使用 32 位
- 如果
11.1.2 事件组的操作
事件组和队列、信号量等不太一样,主要集中在 2 个地方:
- 唤醒谁?
- 队列、信号量:事件发生时,只会唤醒一个任务
- 事件组:事件发生时,会唤醒所有符号条件的任务,简单地说它有”广播”的作用
- 是否清除事件?
- 队列、信号量:是消耗型的资源,队列的数据被读走就没了;信号量被获取后就减少了
- 事件组:被唤醒的任务有两个选择,可以让事件保留不动,也可以清除事件
以上图为列,事件组的常规操作如下:
先创建事件组
任务C、D 等待事件:
① 等待什么事件?可以等待某一位、某些位中的任意一个、也可以等待多位,也即”或”、”与”的关系。
② 得到事件时,要不要清除?可选择清除、不清除。任务A、B 产生事件:设置事件组里的某一位、某些位
11.2 事件组函数
11.2.1 创建事件组
使用事件组之前,要先创建,得到一个句柄;使用事件组时,要使用句柄来表明使用哪个事件组。
有两种创建方法:动态分配内存、静态分配内存。函数原型如下:
1 | /** |
11.2.2 删除事件组
对于动态创建的事件组,不再需要它们时,可以删除它们以回收内存。 vEventGroupDelete()可以用来删除事件组,函数原型如下:
1 | /** |
11.2.3 设置事件
可以设置事件组的某个位、某些位,使用的函数有2个版本:
- 版本1:在普通任务中使用
xEventGroupSetBits() - 版本2:在ISR中使用
xEventGroupSetBitsFromISR()
有一个或多个任务在等待事件,如果这些事件符合这些任务的期望,那么任务还会被唤醒。 函数原型如下:
1 | /** |
值得注意的是,ISR中的函数,比如队列函数xQueueSendToBackFromISR()、信号量函数xSemaphoreGiveFromISR(),它们会唤醒某个任务,最多只会唤醒1个任务。
但是设置事件组时,有可能导致多个任务被唤醒,这会带来很大的不确定性。所以xEventGroupSetBitsFromISR()函数不是直接去设置事件组,而是给一个FreeRTOS后台任务(daemon task)发送队列数据,由这个任务来设置事件组。
若后台任务的优先级比当前被中断的任务优先级高,则参数pxHigherPriorityTaskWoken会被设置为pdTRUE。如果daemon task成功地把队列数据发送给了后台任务,那么xEventGroupSetBitsFromISR的返回值就是pdPASS。
11.2.4 等待/监听事件
xEventGroupWaitBits() 是 FreeRTOS 中用于等待事件组中指定事件位被设置的核心函数,它允许任务在阻塞状态下等待单个或多个事件的发生,支持”逻辑与”(全部位)和”逻辑或”(任意位)两种等待模式。当事件未发生时,调用任务会进入阻塞状态,直到事件发生或超时。
先引入一个概念:unblock condition。一个任务在等待事件发生时,它处于阻塞状态;当期望的事件发生时,这个状态就叫“unblock condition”,非阻塞条件,或称为”非阻塞条件成立”;当”非阻塞条件成立”后,该任务就可以变为就绪态。
函数原型如下:
1 | /** |
注意,清除事件位有两种方法:
① 你可以使用xEventGroupWaitBits()等待期望的事件,它发生之后再使用xEventGroupClearBits()来清除。但是这两个函数之间,有可能被其他任务或中断抢占,它们可能会修改事件组。
② 可以使用设置参数xClearOnExit=pdTRUE,使得对事件组的测试、清零都在xEventGroupWaitBits()函数内部完成,这是一个原子操作。
11.2.5 同步点
有一个事情需要多个任务协同,比如:
- 任务A:炒菜
- 任务B:买酒
- 任务C:摆台
- A、B、C 做好自己的事后,还要等别人做完;大家一起做完,才可开饭
使用xEventGroupSync()函数可以同步多个任务:
- 可以设置某位、某些位,表示自己做了什么事
- 可以等待某位、某些位,表示要等等其他任务
- 期望的时间发生后,
xEventGroupSync()才会成功返回 xEventGroupSync()成功返回后,会清除事件
xEventGroupSync 是FreeRTOS中用于同步多个任务到特定事件状态的复合操作函数,它原子性地完成以下三个动作:
- 设置指定事件位(
uxBitsToSet) - 阻塞等待目标事件位(
uxBitsToWaitFor) - 在满足条件或超时后返回
该函数常用于多任务协同场景,例如:任务A和任务B需同时完成初始化后,任务C才能继续执行。
1 | /** |
11.3 示例:等待多个事件
本节源码是FreeRTOS_20_event_group_wait_multi_events。 要使用事件组,代码中要有如下操作:
1 | /* 1. 工程中添加event_groups.c */ |
假设这样一个情景:大厨要等手下完成洗菜、生火才可以炒菜。 本程序创建3个任务: 任务1:洗菜 ,任务2:生火,任务3:炒菜。
1 | //(每个事件对应的位事先用宏定义了) |
这3个任务的代码和执行流程如下:
- A:”炒菜任务”优先级最高,先执行。它要等待的2个事件未发生:洗菜、生火,进入阻塞状态
- B:”生火任务”接着执行,它要等待的1个事件未发生:洗菜,进入阻塞状态
- C:”洗菜任务”接着执行,它洗好菜,发出事件:洗菜,然后调用F等待”炒菜”事件
- D:”生火任务”等待的事件满足了,从B处继续执行,开始生火、发出”生火”事件
- E:”炒菜任务”等待的事件满足了,从A出继续执行,开始炒菜、发出”炒菜”事件
- F:”洗菜任务”等待的事件满足了,退出F、继续执行C
要注意的是,代码 B 处等待到”洗菜任务”后并不清除该事件,如果清除的话会导致”炒菜任务”无法执行。
11.4 示例: 任务同步
本节代码是 FreeRTOS_21_event_group_task_sync。
假设A、B、C三人要吃饭,各司其职:A:炒菜、B:买酒、C:摆台,三人都做完后,才可以开饭。
1 | int main() |
要点在于xEventGroupSync()函数,它有3个功能:
设置事件:表示自己完成了某个、某些事件
等待事件:跟别的任务同步
成功返回后,清除”等待的事件”
运行结果如下图所示:
FreeRTOS中断管理(为什么FreeRTOS中很多操作要为中断单独编写API函数)
在 FreeRTOS 中,中断优先级始终高于任务调度,无论中断由硬件事件还是软件触发。 中断如同“急救车”,任务如同“普通车辆”——急救车(中断)无论何时出现,都会强制清空道路(抢占CPU),而交通调度员(FreeRTOS 调度器)只能管理普通车辆的通行顺序。
参考链接:FreeRTOS中断管理























