1. 课程导入
各位同学,咱们今天聊聊嵌入式系统里最基础、也最关键的一个思维转变——从轮询到中断。说白了,就是让CPU从“傻等”变成“被叫醒”。
我刚开始做嵌入式那会儿,用的还是8位单片机。那时候写程序,最常用的套路就是在一个大循环里挨个查标志位。你想想看,CPU明明可以干别的活,却非得在那儿一遍遍地问:“按键按了吗?数据到了吗?定时器到点了吗?”——这不就跟你在公司门口每隔5秒就问前台“有我的快递吗”一样傻吗?
后来接触了事件驱动架构,才明白什么叫“让硬件来找你,而不是你去找硬件”。这一章,咱们就把这个转变彻底讲透。
1.1 轮询模型:最直观,但最浪费
轮询(Polling)的思路很简单:CPU主动去检查每个外设或事件源的状态。代码写起来也直白:
// 典型的轮询式按键检测
while (1) {
if (GPIO_ReadPin(BUTTON_PIN) == PRESSED) {
handle_button_press();
}
if (UART_DataAvailable()) {
handle_uart_data();
}
// 其他任务...
}
这段代码,我估计刚入行的朋友都写过。它有什么问题?
- CPU占用率100%——哪怕没有任何事件发生,CPU也在空转。
- 响应延迟不可控——如果轮询列表里有10个设备,第10个设备的事件可能要等前面9个都查完才能被处理。
- 功耗高——CPU没法进入休眠模式,因为要一直跑循环。
1.2 中断模型:让硬件主动通知你
中断(Interrupt)的思路正好反过来:CPU平时该干嘛干嘛,甚至可以睡觉。当某个事件发生时,硬件会通过中断信号线通知CPU,CPU暂停当前工作,跳去执行中断服务程序(ISR),处理完再回来继续原来的事。
这就像你坐在工位上写代码,快递到了前台喊你一声,你放下键盘去拿,拿完回来继续写。而不是每隔5秒就跑前台问一次。
// 中断方式:按键按下时触发
void EXTI0_IRQHandler(void) {
if (EXTI_GetITStatus(EXTI_Line0) != RESET) {
// 设置事件标志,通知主循环处理
event_flags |= EVENT_BUTTON_PRESS;
EXTI_ClearITPendingBit(EXTI_Line0);
}
}
int main(void) {
while (1) {
if (event_flags & EVENT_BUTTON_PRESS) {
handle_button_press();
event_flags &= ~EVENT_BUTTON_PRESS;
}
// CPU可以进入低功耗模式,等待中断唤醒
__WFI(); // Wait For Interrupt
}
}
看到区别了吗?主循环里不再轮询了,而是用__WFI()让CPU休眠。中断来了,CPU自动唤醒,设置标志位,然后主循环处理。CPU只在有事件时才工作,没事件时就休息。
1.3 从中断到事件驱动:还需要一层抽象
中断虽然好,但直接用中断也有麻烦。我见过不少项目,ISR里写了大量代码,结果系统跑着跑着就卡死了。为什么?因为ISR里不能做耗时操作,比如调用printf()、操作互斥锁、或者等待某个资源。
所以,真正的事件驱动架构,会在中断和任务处理之间加一层事件队列。ISR只做最轻量的事——把事件信息塞进队列,然后立刻退出。主循环或调度器从队列里取出事件,再调用对应的处理函数。
这样设计的好处很明显:
- ISR执行时间极短——不会阻塞其他中断。
- 事件处理顺序可控——队列可以按优先级排序。
- 便于调试和扩展——加一个新事件,只需要定义事件类型和注册处理函数。
// 事件队列的简化实现
typedef enum {
EVT_NONE,
EVT_BUTTON_PRESS,
EVT_UART_RX,
EVT_TIMER_TICK,
// ...
} event_id_t;
typedef struct {
event_id_t id;
uint32_t data; // 事件附带的数据
} event_t;
// 事件队列(环形缓冲区)
#define EVENT_QUEUE_SIZE 16
static event_t event_queue[EVENT_QUEUE_SIZE];
static uint8_t head = 0, tail = 0;
// ISR中调用:只入队,不处理
void post_event(event_id_t id, uint32_t data) {
uint8_t next = (head + 1) % EVENT_QUEUE_SIZE;
if (next != tail) { // 队列未满
event_queue[head] = (event_t){.id = id, .data = data};
head = next;
}
}
// 主循环中调用:取出事件并分发
void event_dispatcher(void) {
while (head != tail) {
event_t evt = event_queue[tail];
tail = (tail + 1) % EVENT_QUEUE_SIZE;
switch (evt.id) {
case EVT_BUTTON_PRESS: handle_button(evt.data); break;
case EVT_UART_RX: handle_uart(evt.data); break;
case EVT_TIMER_TICK: handle_timer(); break;
default: break;
}
}
}
1.4 三种模型的对比
为了让你更直观地理解,我把轮询、中断裸奔、事件驱动这三种模型放在一起比一比:
| 对比维度 | 轮询模型 | 中断裸奔 | 事件驱动 |
|---|---|---|---|
| CPU利用率 | 100%(空转) | 按需工作 | 按需工作 |
| 响应延迟 | 取决于轮询周期 | 微秒级(硬件触发) | 微秒级 + 队列延迟 |
| 功耗 | 高(无法休眠) | 低(可休眠) | 低(可休眠) |
| 代码复杂度 | 低 | 中(ISR易出错) | 中高(需队列管理) |
| 可扩展性 | 差(加一个事件就要加轮询) | 中(ISR多了会冲突) | 好(注册新事件即可) |
| 典型场景 | 简单控制、低实时性 | 高实时、少量事件 | 复杂系统、多事件源 |
从这张表能看出来,事件驱动在大多数场景下都是最优解。它既保留了中断的低延迟和低功耗,又通过队列隔离了ISR和业务逻辑,让系统更健壮、更容易维护。
1.5 事件驱动架构的核心优势
总结一下,事件驱动架构为什么值得你花时间学习?
- 解耦——事件的生产者和消费者互不依赖。按键模块只管发事件,至于谁处理、怎么处理,它不关心。
- 异步——事件发生后,处理可以延后。ISR里只做最紧急的事,耗时操作放到主循环或任务里做。
- 可预测——通过事件队列和优先级,你可以精确控制每个事件的处理时机和顺序。
- 低功耗——CPU可以在空闲时进入休眠,只有事件到来时才唤醒。这对电池供电的设备至关重要。
- 易测试——每个事件处理函数都是独立的,可以单独写单元测试。你甚至可以模拟事件队列来验证系统行为。
1.6 本章知识结构图
下面这张SVG图,把三种模型的演进关系和事件驱动的核心流程画出来了。你可以对照着理解:
从这张图可以看得很清楚:轮询模型是CPU主动去“找”事件,中断模型是事件主动“通知”CPU,而事件驱动模型则在中间加了一个队列,让通知和处理解耦。每一步演进,都是在解决前一个模型的痛点。
1.7 什么时候不该用事件驱动?
说了这么多好处,我也得泼盆冷水。事件驱动不是万能的,有些场景下它反而会添乱:
- 极端高实时性要求——比如电机控制PWM周期在微秒级,事件队列的入队和出队操作本身就有延迟,不如直接在ISR里处理。
- 资源极度受限——只有几百字节RAM的MCU,一个事件队列可能就占掉一半内存。这时候裸中断反而更实在。
- 逻辑极其简单——比如一个LED闪烁程序,用轮询写10行代码搞定,非要上事件驱动就是过度设计了。
好了,这一章的内容就到这里。事件驱动架构的核心思想其实不复杂:别让CPU去等事件,让事件来找CPU。后面的章节,我们会在这个基础上,一步步搭建完整的协议栈适配框架,并深入性能调优的细节。你先把这一章的概念消化透,后面学起来会轻松很多。