3、事件驱动模型:事件标志组原理、事件等待与通知机制、事件驱动 vs 轮询模式,实战:按键事件驱动LED

大家好,欢迎来到第三章。

上一章我们聊了任务创建与状态切换,算是把 RTOS 的骨架搭起来了。但光有骨架还不够,任务之间怎么高效地传递信息?这才是嵌入式开发的灵魂。

这一章,我们来啃一块硬骨头——事件驱动模型。说白了,就是让任务学会“等信号”,而不是傻乎乎地“轮询”。

3.1 为什么需要事件驱动?

先问大家一个问题:你写单片机裸机程序时,怎么检测按键?

大概率是 while(1) 里不断读 GPIO 电平,对吧?这就是典型的轮询模式。CPU 像个勤快的保安,每秒钟绕着院子转几百圈,就为了看看门口有没有人按门铃。

轮询的缺点很明显:

  • 浪费 CPU:大部分时间都在做无用功。
  • 响应延迟:轮询周期决定了你能多快响应事件。
  • 代码耦合:所有逻辑都塞在一个大循环里,改一处可能崩一片。

事件驱动就不一样了。任务平时处于阻塞态,不消耗 CPU。一旦事件发生(比如按键按下),内核会立刻唤醒等待的任务。CPU 利用率高,响应也快。

核心思想:事件驱动 = “有事叫我,没事睡觉”。轮询 = “我不放心,我得一直盯着”。

3.2 事件标志组原理

事件驱动有很多实现方式,信号量、消息队列、事件标志组。我个人最常用的是事件标志组,因为它灵活,能表达“多条件组合”。

事件标志组,本质上就是一个 bitmap。每个 bit 代表一个事件。比如:

  • Bit 0:按键 A 按下
  • Bit 1:按键 B 按下
  • Bit 2:定时器超时

任务可以等待“任意一个事件发生”(逻辑或),也可以等待“所有事件都发生”(逻辑与)。

特性 事件标志组 信号量 消息队列
数据传递 仅传递事件标志(bit) 仅传递计数 传递数据块
多条件等待 支持(与/或) 不支持 不支持
适用场景 多事件触发 资源管理、同步 数据流传输

我在项目中遇到过一个问题:一个传感器采集任务,需要等待“定时器触发”和“DMA传输完成”两个条件同时满足才能处理数据。用信号量得等两个,用事件标志组一个 API 就搞定了。

3.3 事件等待与通知机制

事件标志组的 API 通常就三个:创建、等待、设置。

等待事件:任务调用等待函数时,如果事件没发生,任务进入阻塞态。内核会记录它等的是哪个 bit,以及是“与”还是“或”。

通知事件:其他任务或中断服务程序调用设置函数,把对应的 bit 置 1。内核检查有没有任务在等这个 bit,有的话就唤醒它。

我的习惯:中断里只做“设置事件”这件事,具体处理逻辑放在任务里。这样可以缩短中断服务时间,避免中断嵌套带来的麻烦。

这里有个坑,我曾经踩过:

我曾经在中断里直接调用了等待事件的 API,结果系统直接死机。记住,等待事件的 API 是阻塞的,不能在中断上下文调用。中断里只能设置事件,不能等待事件。

3.4 事件驱动 vs 轮询模式

我们来做个对比,这样更直观:

对比项 事件驱动 轮询模式
CPU 利用率 高(无事休眠) 低(空转)
响应延迟 低(事件触发即响应) 高(取决于轮询周期)
代码结构 清晰,任务独立 混乱,耦合度高
低功耗支持 天然支持(空闲时休眠) 难(需要手动休眠)
调试难度 中等(需要理解事件流) 低(顺序执行)

你想想看,如果你的系统需要低功耗,轮询模式几乎不可能做到。因为 CPU 一直在跑,功耗降不下来。事件驱动模式下,没事干的时候 CPU 可以进入深度睡眠,只有事件来了才唤醒。

3.5 实战:按键事件驱动 LED

光说不练假把式。我们写一个简单的例子:按键按下,LED 翻转。

任务设计:

  • 按键扫描任务:检测按键,检测到按下后设置事件标志。
  • LED 控制任务:等待事件标志,收到后翻转 LED。

代码示例(基于 FreeRTOS 风格):

// 事件标志组句柄
EventGroupHandle_t xEventGroup;

// 定义事件位
#define KEY_PRESS_EVENT  (1 << 0)

// 按键扫描任务
void vKeyScanTask(void *pvParameters) {
    for (;;) {
        if (GPIO_ReadPin(KEY_PIN) == 0) {  // 按键按下
            xEventGroupSetBits(xEventGroup, KEY_PRESS_EVENT);
            vTaskDelay(pdMS_TO_TICKS(50));  // 简单消抖
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

// LED 控制任务
void vLEDControlTask(void *pvParameters) {
    for (;;) {
        // 等待事件,清除标志位
        xEventGroupWaitBits(
            xEventGroup,
            KEY_PRESS_EVENT,
            pdTRUE,        // 退出时清除标志位
            pdFALSE,       // 任意一个事件即可
            portMAX_DELAY  // 一直等
        );
        GPIO_TogglePin(LED_PIN);
    }
}

void main(void) {
    // 创建事件标志组
    xEventGroup = xEventGroupCreate();

    // 创建任务
    xTaskCreate(vKeyScanTask, "KeyScan", 128, NULL, 1, NULL);
    xTaskCreate(vLEDControlTask, "LEDCtrl", 128, NULL, 2, NULL);

    // 启动调度器
    vTaskStartScheduler();
}

这段代码很简单,但体现了事件驱动的精髓。按键扫描任务只负责检测和设置事件,LED 控制任务只负责等待和处理。两个任务互不干扰,职责清晰。

注意:按键扫描任务里我加了一个 vTaskDelay,这是为了消抖,也为了让出 CPU。实际项目中,按键消抖最好用硬件定时器或者状态机来做,这里为了演示简化了。

3.6 事件驱动模型知识结构

下面这张图,是我自己总结的事件驱动模型知识结构,方便大家理解各个概念之间的关系:

事件驱动模型知识结构 事件驱动模型 事件标志组 本质:bitmap 支持:与 / 或 组合 等待与通知机制 等待:任务阻塞 通知:设置标志位 事件驱动 vs 轮询 CPU利用率:高 vs 低 功耗:低 vs 高 实战:按键事件驱动 LED

这张图把事件驱动模型的三个核心部分串起来了。左边是事件标志组的原理,中间是等待与通知的流程,右边是和轮询模式的对比。底部是实战,把理论落地。

3.7 小结

这一章我们讲了事件驱动模型的核心:事件标志组、等待与通知机制。也对比了事件驱动和轮询模式的优劣。

我个人觉得,事件驱动是 RTOS 的精髓之一。掌握了它,你的代码会变得清晰、高效、低功耗。下一章我们会继续深入,聊聊消息队列,那是另一种更强大的任务通信方式。

嗯,今天就到这里。大家回去把按键驱动 LED 的代码跑一遍,感受一下事件驱动的魅力。