3. 事件驱动编程模型

多任务系统里,任务之间怎么传数据?怎么协调谁先跑?怎么避免抢资源打架?

这些问题,说白了就是同步与通信要解决的。我刚开始用RTOS那会儿,总觉得信号量和队列差不多,结果项目里出了好几次优先级反转的坑。后来才明白,每个机制都有自己的脾气。

3.1 队列——任务间的数据管道

队列是RTOS里最常用的通信方式。它像一根管子,一个任务往里塞数据,另一个任务从另一头取。先进先出,不乱序。

核心要点:队列传递的是数据的副本,不是指针。发送方把数据拷贝进队列,接收方拷贝出来。这样避免了共享内存的竞态问题。

3.1.1 队列的工作模型

队列内部维护一个环形缓冲区,有读指针和写指针。每次发送,数据被写入写指针位置;每次接收,从读指针位置取出。队列满时发送方可以阻塞等待,队列空时接收方也可以阻塞等待。

队列工作机制 发送任务 队列(环形缓冲区) D1 D2 D3 空 接收任务 数据从发送任务拷贝进队列,再从队列拷贝给接收任务 队列满时发送方阻塞,队列空时接收方阻塞 写指针→ 读指针→

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 事件标志组的工作方式

事件标志组——多条件触发 按键事件 定时器超时 数据就绪 事件标志组 Bit0 Bit1 Bit2 ... 每个bit代表一个事件 支持与模式(全部到位)和或模式(任意一个) 等待任务

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同步与通信的基石。队列管数据,信号量管计数,互斥锁管资源,事件标志组管条件。理解透它们,多任务编程就稳了一大半。