切换主题
Semaphore/Task Notification初识
任务之间怎么同步,以及中断怎么安全地把事情交给任务处理。 Queue负责传数据 + 唤醒任务,Semaphore主要解决不传复杂数据,只通知“某件事发生了”
1. 问题与背景
Queue适合:我有一份数据要交给你 按键事件结构体 电机命令结构体 Semaphore适合:我不一定要给你数据,我只是告诉你:事情发生了 DMA接收完成 某个资源可用了
FreeRTOS 里常见三种:
Binary Semaphore 二值信号量 Counting Semaphore 计数信号量 Mutex 互斥锁
2. Binary Semaphore 二值信号量
它只有两种状态: 0:没有信号 1:有信号
适合:事件同步
典型模型:
ISR / Task ↓ xSemaphoreGive()
等待任务 ↓ xSemaphoreTake()
如果没有信号,任务就 Blocked。 如果信号来了,任务被唤醒。
假设 D3Task:xSemaphoreTake(g_sem, portMAX_DELAY); 如果当前没有信号:D3Task Running ---> Blocked 它会等待这个 Semaphore 后来 D2Task:xSemaphoreGive(g_sem); 于是: D3Task Blocked ↓ Ready
如果 D3Task 优先级更高,就可能立刻抢占运行。
可见这和queue很像,只是等待对象不同,但调度逻辑类似
在 FreeRTOS 里,很多 Semaphore 底层其实是基于 Queue 实现的。
只不过: Queue:队列里存真正的数据元素 Semaphore:队列里不存用户数据,只维护“有没有信号/有几个信号”
所以 Semaphore 也会有: 等待发送列表 等待接收列表 任务阻塞 任务唤醒 Event List Ready List
Binary Semaphore 本质上是一个“长度为 1、元素大小为 0 的特殊 Queue”。 它不传数据,只维护一个“有没有信号”的状态。
3. Counting Semaphore:计数信号量
它不是只有 0/1,而是可以计数:0, 1, 2, 3...
适合:多次事件累计 资源数量统计
比如: 有 3 个空闲 buffer 有 5 次脉冲事件 有多个相同资源可用
它比 Binary Semaphore 多了一个“次数”的概念。
4. Mutex:互斥锁
Mutex 不是用来通知事件的。
它用于:保护共享资源
比如: I2C总线 SPI总线 Flash printf串口 OLED 全局共享结构体
模型是: 谁要用资源 ↓ 先拿锁
用完 ↓ 释放锁
代码形式:
xSemaphoreTake(i2c_mutex, portMAX_DELAY); /* 安全访问 I2C */ xSemaphoreGive(i2c_mutex);
注意这个区别:
Semaphore:等事件 Mutex:抢资源
5. FromISR 版本
普通任务里用 xSemaphoreGive(),中断里必须用 xSemaphoreGiveFromISR()
为什么 ISR 里面不能直接用普通 API(xSemaphoreGive(g_test_sem))? 因为Task 是一个正常任务执行流。 它可以:阻塞 等待 被调度器切换 有自己的任务栈
ISR 是中断服务函数,它不属于某个普通任务。 它应该:快速执行 不能阻塞 不能等待 不能长时间处理复杂业务
xSemaphoreGiveFromISR(g_test_sem, &xHigherPriorityTaskWoken); 作用是:在中断里释放一个信号量,并可能唤醒正在等待该信号量的任务。 第二个传参的意思:这次 Give 是否唤醒了一个比当前被中断任务优先级更高的任务。 portYIELD_FROM_ISR(xHigherPriorityTaskWoken);如果刚才唤醒了更高优先级任务,就在中断退出后请求一次任务切换。
也就是说: ISR ↓ GiveFromISR 唤醒高优先级任务 ↓ portYIELD_FROM_ISR 请求 PendSV ↓ ISR 退出 ↓ PendSV 执行上下文切换 ↓ 高优先级任务马上运行
中断里也可以用 Queue:xQueueSendFromISR(g_queue, &event, &xHigherPriorityTaskWoken);, 也可以用 Semaphore:xSemaphoreGiveFromISR(g_sem, &xHigherPriorityTaskWoken);
要传数据:用 Queue 按键事件 命令结构体 传感器采样结果 错误码
只通知事情发生:用 Semaphore 或 Task Notification DMA 完成 UART IDLE ADC 转换完成 定时器周期到
FreeRTOS 不是所有中断都能调用 FromISR API。 能调用 FreeRTOS API 的中断,优先级必须满足 FreeRTOS 的限制:中断优先级不能高于 configMAX_SYSCALL_INTERRUPT_PRIORITY STM32 里优先级数字越小,实际优先级越高。 优先级 0 最高,通常不能调用 FreeRTOS API
优先级 5、6、7... 较低,通常可以调用 FreeRTOS FromISR API
6. 我的结论
掌握这套模板 void XXX_IRQHandler_or_Callback(...) { BaseType_t xHigherPriorityTaskWoken = pdFALSE;
/* 清中断标志 / 获取必要信息 */
xSemaphoreGiveFromISR(g_sem, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
} 在 FreeRTOS 中,中断服务函数不应直接执行复杂业务逻辑,而应尽量短小。对于 DMA 完成、UART IDLE、外部中断等事件,可以在 ISR 中调用 xSemaphoreGiveFromISR() 或其他 FromISR API 通知任务。若该操作唤醒了更高优先级任务,xHigherPriorityTaskWoken 会被置位,随后通过 portYIELD_FROM_ISR() 请求在中断退出后进行任务切换。真正的上下文切换仍由 PendSV 完成。
7. 修正
LowTask 正在运行 ↓ UART / DMA / EXTI 中断打断 LowTask ↓ ISR 内 GiveFromISR ↓ 唤醒 HighTask ↓ xHigherPriorityTaskWoken = pdTRUE ↓ portYIELD_FROM_ISR() ↓ 请求 PendSV ↓ ISR 退出 ↓ PendSV 执行任务切换 ↓ HighTask 运行
Task 不会抢占 ISR ,因为中断优先级和任务优先级是俩套系统,不能直接比较 HighTask 抢占的是原来的 LowTask
8. Task Notification
Task Notification是FreeRTOS 里一个非常常用、非常轻量的机制 先这样理解: Task Notification 是内置在每个 TCB 里的“私人信号量/私人事件标志/私人计数器”
为什么有了 Semaphore,还需要 Task Notification? 因为很多场景里,我们只是想通知某一个固定任务: UART 收到一帧了 ADC DMA 完成了 定时器周期到了 外部中断触发了
用 Binary Semaphore 也可以,但它需要额外创建一个 Semaphore 对象 Task Notification 不需要额外 Queue/Semaphore 控制块,它直接用任务 TCB 里面已有的字段: Task TCB │ ├── ulNotifiedValue └── ucNotifyState
所以更轻量速度也更快
区别就是: Binary Semaphore: 通过一个独立 Semaphore 对象通知任务 Task Notification:直接往目标任务的 TCB 里写通知状态
Task Notification 它可以模拟好几种东西: 用法 类似机制 典型 API 简单唤醒任务 Binary Semaphore xTaskNotifyGive() / ulTaskNotifyTake() 累计事件次数 Counting Semaphore ulTaskNotifyTake(pdFALSE, ...) 事件位通知 Event Group xTaskNotify(..., eSetBits) 传一个 32 位值 小型消息 xTaskNotify(..., eSetValueWithOverwrite)
先来看:普通任务通知另一个任务:假设 D3Task 等通知,D2Task 每 500ms 通知一次。 通知必须知道目标任务是谁:必须保存被通知任务的句柄:TaskHandle_t D3TaskHandle;
ulTaskNotifyTake(pdTRUE, portMAX_DELAY); pdTRUE 表示收到通知后,把通知值清零 含义: 等待别人通知我 没有通知就 Blocked 有通知就继续运行
D2Task:发送通知 xTaskNotifyGive(D3TaskHandle); 通知 D3Task:有事件发生了
固定一对一通知,优先考虑 Task Notification。
ISR 里通知任务(实际工程最常用的写法)
比如 UART IDLE 中断通知 ProtocolTask: void USARTx_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken = pdFALSE;
/* 清中断标志,记录必要信息 */
vTaskNotifyGiveFromISR(ProtocolTaskHandle, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
任务里: void ProtocolTask(void *argument) { for (;😉 { ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
/* 被 ISR 唤醒后,处理 UART DMA buffer */
Protocol_ProcessRxBuffer();
}
}
什么时候用 Notification,什么时候用 Queue?
你现在先记这个判断:
场景 推荐 只通知固定一个任务 Task Notification ISR 唤醒固定任务 Task Notification 需要传结构体事件 Queue 多个任务都可能等待同一个资源 Semaphore / Event Group 需要保护 I2C/SPI/Flash Mutex 需要广播多个事件位 Event Group
一句话: Task Notification:快、轻,但主要适合“一个任务被通知” Queue:更通用,适合传数据和事件结构体
类似: Queue = 邮箱 Semaphore = 公共铃铛 Task Notification = 直接叫某个人
Task Notification 的核心价值: 在一对一通知场景下,Task Notification 可以替代 Binary Semaphore。它不需要额外创建内核对象,而是直接使用目标任务 TCB 中的通知字段,因此更轻量,特别适合 ISR 唤醒某个固定任务。
9. 源码剖析
xTaskNotifyGive((TaskHandle_t)D3TaskHandle); ulTaskNotifyTake(pdTRUE, portMAX_DELAY); 这俩个函数本质都是在操作等待函数TCB里的 ulNotifiedValue
xTaskNotifyGive() 的核心效果可以先粗略理解为:D3Task->ulNotifiedValue++ 所以如果连续通知: 0 → 1 → 2 → 3 → 4 它是可以累计的。
注意:ulTaskNotifyTake里的pdTRUE 有说法的,它表示收到通知后,把通知值清零。 但是如果是pdFalse:它表示收到通知后,把通知值减一。
xTaskNotifyGive() ↓ 修改目标Task TCB中的通知值 ↓ 必要时 Blocked → Ready
ulTaskNotifyTake() ↓ 没有通知就 Blocked ↓ 有通知就消费通知值
9. Task Notification 的 FromISR 用法
标准写法
任务里等待:
void ProtocolTask(void *argument) { for (;😉 { ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
Protocol_ProcessRxBuffer();
}
}
ISR 里通知:
void USARTx_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken = pdFALSE;
/* 清中断标志、记录必要信息 */
vTaskNotifyGiveFromISR(ProtocolTaskHandle,&xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
为什么 Notification 特别适合 UART DMA / IDLE? 假设 UART DMA 已经把数据放进:
uint8_t uart_rx_buf[256]; 那么 UART IDLE 中断真正需要告诉任务的,其实只有:
“Buffer 里有新数据了。”
数据本身已经在:
uart_rx_buf
里面。
所以如果你再用 Queue:
xQueueSendFromISR(queue, uart_rx_buf, ...);
可能意味着:
DMA 已经把数据写进 Buffer ↓ ISR 又把数据拷贝进 Queue ↓ Task 再从 Queue 拷贝出来
产生额外拷贝。
而 Notification:
vTaskNotifyGiveFromISR(...)
只是:
修改几个 TCB 字段 + 唤醒任务
非常轻。
Notification 解决什么,不解决什么?
它解决:
事件通知 任务阻塞 任务唤醒 调度联动
它不解决:
大数据存储 Buffer生命周期 多个消费者 共享资源互斥
这个边界一定要清楚。
9. Mutex 互斥锁 --- 并发问题最经典的一种:多个 Task 同时访问共享资源
Critical Section / 临界区 --- 应用级共享资源临界区
创建: SemaphoreHandle_t g_i2c_mutex; g_i2c_mutex = xSemaphoreCreateMutex(); configASSERT(g_i2c_mutex != NULL);
SensorTask: xSemaphoreTake(g_i2c_mutex, portMAX_DELAY); /* 临界资源 */ MPU6050_Read(); xSemaphoreGive(g_i2c_mutex);
DisplayTask: xSemaphoreTake(g_i2c_mutex, portMAX_DELAY); /* 同一个 I2C 总线 */ OLED_Update(); xSemaphoreGive(g_i2c_mutex);
重点: Take ↓ 访问共享资源 ↓ Give
Mutex 和 taskENTER_CRITICAL() 有什么区别? taskENTER_CRITICAL → 保护非常短的内核/共享变量原子操作 Mutex → 保护相对较长的Task级共享资源
taskENTER_CRITICAL(); HAL_I2C_Mem_Read(...); taskEXIT_CRITICAL();
意味着:一次 I2C 通信可能几十、几百微秒甚至更久,这段时间相关中断不能正常响应,严重影响实时性。
| 资源 | 常见保护方式
| | | 修改一个内核链表 | Critical Section
| 短小计数变量 | Critical Section / Atomic | I2C | Mutex
| SPI | Mutex
| Flash | Mutex / Service Task
| printf UART | Mutex / 专属Log Task
| 文件系统 | Mutex / 专属Storage Task
Mutex 特别适合 I2C / SPI 假设 I2C1 ├─ MPU6050 ├─ EEPROM └─ OLED 实际上所有设备共享: SCL SDA I2C控制器 HAL_I2C_Handle 虽然器件不同: MPU6050 OLED EEPROM
但底层资源只有一个:I2C1 因此真正应该锁的是:总线资源,而不是某一个设备。
10. Priority Inversion(优先级反转)
假设三个任务:有一个 I2C Mutex。 HighTask P5 MediumTask P3 LowTask P1
正常流程走: LowTask P1:先拿到 Mutex,Take Mutex,正在操作 I2C 此时,HighTask P5 醒了,因为优先级更高,且也需要IIC,所以xSemaphoreTake(i2c_mutex, portMAX_DELAY); 结果发现:Mutex Owner = LowTask 于是 HighTask:Running → Blocked 因为必须等 Low 释放锁。
这部分都没问题,现在的情况是 HighTask P5 Blocked 等 Mutex
LowTask P1 Ready,持有 Mutex
MediumTask P3 Ready
此时问题来了,Scheduler看:P3 > P1 于是运行MediumTask 结果: LowTask拿着High需要的Mutex 但Low又被Medium压着跑不了 High只能继续等
形成: High P5 ↓ 等待 Mutex ↑ 被Low持有 Low P1 ↑ 又被 Medium P3阻挡
形成了本来优先级最高的High间接被 Medium 拖住了 这就是优先级反转问题
解决方法:Priority Inheritance(优先级继承) LowTask P1持有 Mutex,HighTask P5来请求mutex,freertos发现High正在等Low释放资源,于是临时把 LowTask 的优先级:P1提到P5,也就是LowTask 临时继承 HighTask 的优先级,这就避免了Medium Task拖着HighTask的问题,Low释放 Mutex 后:临时P5恢复原P1
没有优先级继承:
Low P1 拿锁 ↓ High P5 等锁 ↓ Medium P3 抢占 Low ↓ Low久久不能释放锁 ↓ High也久久不能运行
= Priority Inversion
有优先级继承:
Low P1 拿锁 ↓ High P5 等锁 ↓ Low临时继承P5 ↓ Medium P3无法抢占Low ↓ Low快速完成 ↓ Low释放Mutex ↓ High获得Mutex ↓ Low恢复P1
TCB里面的俩个优先级就浮出水面了: uxBasePriority:任务原始优先级 uxPriority:当前实际调度优先级 uxMutexesHeld:当前Task持有几个Mutex
Mutex 最常见的坑 忘记 Give 临界区太长:锁住的代码越短越好。 重复拿同一把普通 Mutex
获得 Mutex:xSemaphoreTake() 成功,这个 Task 获得共享资源的使用权。 等待 Mutex:xSemaphoreTake() 时发现 Mutex 正被别人占用,于是当前 Task 进入 Blocked,等对方 Give。
为什么“重复获得同一把普通 Mutex”会把自己卡死? 普通 Mutex 不支持同一个 Task 重复获得同一个 Mutex。 void FunctionA(void) { xSemaphoreTake(g_mutex, portMAX_DELAY);
FunctionB();
xSemaphoreGive(g_mutex);
}
void FunctionB(void) { xSemaphoreTake(g_mutex, portMAX_DELAY);
/* 做事情 */
xSemaphoreGive(g_mutex);
} Self Deadlock,自死锁。
怎么避免ABBA? 整个项目规定统一的资源获取顺序 例如规定:
如果同时需要I2C和SPI
永远:
I2C Mutex ↓ SPI Mutex
那么 Task1:I2C → SPI Task2也必须:I2C → SPI 绝对不能:SPI → I2C
这样就破坏了死锁形成需要的“环形等待”。
怎么降低死锁风险? 你至少可以回答:统一锁获取顺序、缩短持锁时间、尽量避免嵌套锁,并根据场景设置合理的等待超时。
11. 要形成真正的并发意识:
看到:Task A Task B ISR DMA同时接触某个资源时, 你会主动问:
谁拥有这个资源?
谁可以写?
谁可以读?
操作过程中会不会被切换?
ISR会不会同时改?
Buffer什么时候失效?
需要Queue还是Notification?
需要Mutex还是Critical Section?
锁会不会导致Priority Inversion?
会不会死锁?
临界区是不是太长?