3. 事件驱动编程模型
多任务系统里,任务之间怎么传数据?怎么协调谁先跑?怎么避免抢资源打架?
这些问题,说白了就是同步与通信要解决的。我刚开始用RTOS那会儿,总觉得信号量和队列差不多,结果项目里出了好几次优先级反转的坑。后来才明白,每个机制都有自己的脾气。
3.1 队列——任务间的数据管道
队列是RTOS里最常用的通信方式。它像一根管子,一个任务往里塞数据,另一个任务从另一头取。先进先出,不乱序。
核心要点:队列传递的是数据的副本,不是指针。发送方把数据拷贝进队列,接收方拷贝出来。这样避免了共享内存的竞态问题。
3.1.1 队列的工作模型
队列内部维护一个环形缓冲区,有读指针和写指针。每次发送,数据被写入写指针位置;每次接收,从读指针位置取出。队列满时发送方可以阻塞等待,队列空时接收方也可以阻塞等待。
3.1.2 队列的典型用法
- 数据流传递:传感器数据采集任务把数据发给处理任务
- 命令分发:主控任务把控制命令发给各个执行任务
- 日志输出:多个任务把日志消息发到日志打印任务
我的习惯:队列长度设置成2的幂次方,比如8、16、32。这样内部取模运算可以用位运算替代,效率高不少。在STM32上测过,能省几十个时钟周期。
3.2 信号量——任务执行的节拍器
信号量不是用来传数据的,它只传递一个数字——计数。二值信号量只有0和1,计数信号量可以有多个。
我经常把信号量比作停车场门口的计数器。车进去减1,车出来加1。没车位了,后来的车就得等着。
3.2.1 二值信号量 vs 计数信号量
| 特性 | 二值信号量 | 计数信号量 |
|---|---|---|
| 取值范围 | 0 或 1 | 0 ~ N(N为最大计数值) |
| 典型用途 | 任务同步、中断与任务握手 | 资源池管理、多实例保护 |
| 释放操作 | 计数值置1 | 计数值加1(不超过最大值) |
| 获取操作 | 计数值置0 | 计数值减1(不小于0) |
我曾经踩过的坑:在中断服务函数里调用信号量获取(take)操作。大多数RTOS不允许在ISR中阻塞等待。正确做法是:ISR里只做信号量释放(give),由任务层去获取。这个错误会导致系统死锁,我当时排查了整整两天。
3.2.2 信号量的典型场景
中断与任务同步:
// 中断服务函数
void UART_IRQHandler(void) {
// ... 接收数据 ...
xSemaphoreGiveFromISR(xSemUartRx, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
// 任务
void vUartRxTask(void *pvParameters) {
while(1) {
xSemaphoreTake(xSemUartRx, portMAX_DELAY);
// 处理接收到的数据
}
}
资源池管理:比如系统有5个DMA通道,初始化时信号量计数值设为5。每个任务使用DMA前先获取信号量,用完释放。这样最多5个任务同时使用DMA,不会冲突。
3.3 互斥锁——共享资源的门禁
互斥锁和二值信号量看起来很像,但有个关键区别:互斥锁有优先级继承机制。这个机制专门用来解决优先级反转问题。
优先级反转是什么?高优先级任务等低优先级任务释放锁,但低优先级任务被中等优先级任务抢占了CPU,导致高优先级任务干等。互斥锁的优先级继承能临时提升低优先级任务的优先级,让它尽快跑完释放锁。
3.3.1 互斥锁 vs 二值信号量
| 对比项 | 互斥锁 | 二值信号量 |
|---|---|---|
| 优先级继承 | 支持 | 不支持 |
| 递归获取 | 部分RTOS支持 | 不支持 |
| 谁获取谁释放 | 必须同一任务 | 任何任务/ISR均可 |
| 适用场景 | 保护共享资源(临界区) | 任务同步、事件通知 |
我的建议:保护共享资源时,优先用互斥锁。虽然它比二值信号量多一点点开销,但优先级继承机制能避免很多诡异的调度问题。我在一个电机控制项目里吃过亏,用了二值信号量保护共享数据,结果高优先级控制任务时不时超时,换成互斥锁就稳了。
3.3.2 互斥锁使用注意事项
- 不要在中段里使用互斥锁——互斥锁的释放需要任务上下文,ISR里没法做优先级继承
- 获取和释放必须在同一个任务——这是互斥锁的语义约束,违反会导致未定义行为
- 尽量缩短持有时间——只在真正需要访问共享资源时才加锁,操作完立刻释放
3.4 事件标志组——多条件触发的开关面板
事件标志组是一组二进制位,每个位代表一个事件是否发生。任务可以等待多个事件同时发生(与模式),或者等待其中任意一个发生(或模式)。
你想想看,一个任务要等三个条件都满足才能往下跑。用信号量你得等三次,用事件标志组一次搞定。
3.4.1 事件标志组的工作方式
3.4.2 事件标志组的典型用法
// 定义事件位
#define EVENT_BUTTON_PRESS (1 << 0)
#define EVENT_TIMER_EXPIRED (1 << 1)
#define EVENT_DATA_READY (1 << 2)
// 等待所有事件(与模式)
EventBits_t uxBits = xEventGroupWaitBits(
xEventGroup,
EVENT_BUTTON_PRESS | EVENT_TIMER_EXPIRED | EVENT_DATA_READY,
pdTRUE, // 获取后自动清除
pdTRUE, // 等待所有位(与模式)
portMAX_DELAY // 无限等待
);
// 等待任意事件(或模式)
EventBits_t uxBits = xEventGroupWaitBits(
xEventGroup,
EVENT_BUTTON_PRESS | EVENT_TIMER_EXPIRED | EVENT_DATA_READY,
pdTRUE,
pdFALSE, // 等待任意位(或模式)
portMAX_DELAY
);
我的经验:事件标志组特别适合状态机实现。每个状态转移条件对应一个事件位,状态机任务等待事件组合,然后执行转移。代码结构清晰,可读性也好。
3.5 四种机制的选型对比
项目里到底用哪个?我一般按这个思路来选:
| 需求场景 | 推荐机制 | 原因 |
|---|---|---|
| 传递数据块 | 队列 | 数据拷贝,天然隔离 |
| 中断通知任务 | 二值信号量 | 轻量,ISR中可释放 |
| 保护共享资源 | 互斥锁 | 优先级继承防反转 |
| 管理资源池 | 计数信号量 | 计数语义天然匹配 |
| 多条件同步 | 事件标志组 | 一次等待多个条件 |
重要提醒:不要在临界区里调用阻塞型的获取操作(比如等待队列、信号量)。临界区本身已经关了中断或调度器,再阻塞会导致系统卡死。我见过有人把xQueueReceive放在taskENTER_CRITICAL里,结果队列为空时系统直接死机。
嗯,这四种机制是RTOS同步与通信的基石。队列管数据,信号量管计数,互斥锁管资源,事件标志组管条件。理解透它们,多任务编程就稳了一大半。