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 事件驱动模型知识结构
下面这张图,是我自己总结的事件驱动模型知识结构,方便大家理解各个概念之间的关系:
这张图把事件驱动模型的三个核心部分串起来了。左边是事件标志组的原理,中间是等待与通知的流程,右边是和轮询模式的对比。底部是实战,把理论落地。
3.7 小结
这一章我们讲了事件驱动模型的核心:事件标志组、等待与通知机制。也对比了事件驱动和轮询模式的优劣。
我个人觉得,事件驱动是 RTOS 的精髓之一。掌握了它,你的代码会变得清晰、高效、低功耗。下一章我们会继续深入,聊聊消息队列,那是另一种更强大的任务通信方式。
嗯,今天就到这里。大家回去把按键驱动 LED 的代码跑一遍,感受一下事件驱动的魅力。