4. 事件驱动架构:让系统学会“响应”而非“轮询”
好,我们进入第四章。这一章我要跟你聊聊事件驱动架构。说实话,这是嵌入式系统里一个非常核心的思维转变。很多新手工程师写代码,习惯让CPU不停地去“问”外设:“你好了没?你好了没?”——这叫轮询。但真正成熟的系统,应该是外设主动“告诉”CPU:“我好了,快来处理我!”——这就是事件驱动。
我个人习惯把事件驱动比作一个高效的秘书。轮询就像你每隔五分钟就去问秘书有没有电话,而事件驱动是电话铃一响,秘书就通知你。哪个效率高?不言而喻。在剃须刀这种对功耗和响应速度都有要求的设备里,事件驱动几乎是必然的选择。
4.1 事件驱动模型介绍
事件驱动模型,说白了就是“发生什么事,就处理什么事”。系统里有一个核心的循环,它不主动干活,而是等着“事件”来找它。一旦有事件发生,比如按键按下、电量低、电机堵转,系统就跳转到对应的事件处理函数里去执行。
这个模型有几个关键角色:
- 事件源:谁产生事件?比如按键、定时器、传感器中断。
- 事件队列:事件来了先排队,防止丢失。
- 事件处理器:真正干活的人,处理具体事件。
- 事件调度器:核心循环,负责从队列里取事件并分发。
你想想看,如果不用事件驱动,会是什么场景?主循环里一堆if语句,轮询按键、轮询电量、轮询电机状态。代码耦合度高,改一个地方可能牵一发动全身。而且CPU大部分时间都在空转,浪费电。
我在项目中遇到过最典型的反面教材:一个同事用轮询方式写了个小家电,结果电池续航比预期少了30%。后来改成事件驱动,续航直接达标。嗯,这就是架构的力量。
核心要点:事件驱动架构的核心是“异步”和“解耦”。事件产生者不需要知道谁会处理它,事件处理器也不需要关心事件是怎么来的。各司其职,互不干扰。
4.2 剃须刀按键事件处理
按键,是剃须刀最直接的人机交互接口。别看它简单,处理不好会出大问题。比如按键抖动、长按短按混淆、连击误触发——这些都是我在实际调试中踩过的坑。
按键事件处理,一般分三步:
- 硬件消抖:按键按下瞬间会有机械抖动,通常用10-20ms的延时或滤波电路处理。
- 状态识别:判断是短按、长按还是双击。这需要记录按键按下的时间戳。
- 事件上报:把识别结果封装成事件,扔进事件队列。
我给你们看一段我常用的按键处理代码框架。注意,这不是完整代码,但思路很清楚:
// 按键状态机
typedef enum {
KEY_STATE_IDLE, // 空闲
KEY_STATE_PRESSED, // 按下
KEY_STATE_LONG_PRESS // 长按
} key_state_t;
// 按键事件结构
typedef struct {
uint8_t key_id; // 按键编号
uint8_t event_type; // 短按/长按/双击
uint32_t timestamp; // 事件发生时间
} key_event_t;
// 按键扫描函数(在定时器中断中调用,每10ms一次)
void key_scan(void) {
static key_state_t state = KEY_STATE_IDLE;
static uint32_t press_time = 0;
uint8_t level = gpio_read(KEY_PIN);
switch(state) {
case KEY_STATE_IDLE:
if(level == 0) { // 按下
state = KEY_STATE_PRESSED;
press_time = get_tick();
}
break;
case KEY_STATE_PRESSED:
if(level == 1) { // 松开
state = KEY_STATE_IDLE;
if((get_tick() - press_time) < LONG_PRESS_THRESHOLD) {
// 短按事件
key_event_t evt = {KEY_ID_POWER, KEY_EVT_SHORT_PRESS, get_tick()};
event_queue_push(&evt);
}
} else {
// 检查是否达到长按阈值
if((get_tick() - press_time) >= LONG_PRESS_THRESHOLD) {
state = KEY_STATE_LONG_PRESS;
key_event_t evt = {KEY_ID_POWER, KEY_EVT_LONG_PRESS, get_tick()};
event_queue_push(&evt);
}
}
break;
case KEY_STATE_LONG_PRESS:
if(level == 1) { // 长按后松开
state = KEY_STATE_IDLE;
}
break;
}
}
我的经验:按键消抖时间别设太死。我见过有人用5ms消抖,结果在强电磁干扰环境下频繁误触发。建议至少10ms,如果环境恶劣,20ms更稳妥。另外,长按阈值我一般设2秒,太短容易误触,太长用户体验差。
4.3 事件队列实现
事件队列,是整个事件驱动架构的“心脏”。所有事件都在这里排队,等待被处理。队列设计得好不好,直接决定了系统的实时性和稳定性。
我常用的队列实现有两种:
| 类型 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 环形缓冲区 | 实现简单,内存固定 | 队列满时丢事件 | 事件频率可控,丢失可接受 |
| 链表队列 | 动态分配,几乎不丢事件 | 实现复杂,可能有内存碎片 | 事件突发性强,不能丢失 |
在剃须刀这种资源受限的MCU上,我强烈建议用环形缓冲区。为什么?因为链表需要动态内存分配,而嵌入式系统最怕的就是malloc和free——你不知道什么时候会内存碎片化,导致系统崩溃。
我曾经在一个项目里用了链表队列,结果跑了三天后系统突然死机。查了两天才发现是内存碎片导致分配失败。从那以后,只要不是必须动态分配的场景,我都用环形缓冲区。
来看一个环形缓冲区的实现示例:
#define EVENT_QUEUE_SIZE 16
typedef struct {
event_t buffer[EVENT_QUEUE_SIZE];
uint8_t head; // 读指针
uint8_t tail; // 写指针
uint8_t count; // 当前元素个数
} event_queue_t;
// 初始化队列
void event_queue_init(event_queue_t *q) {
q->head = 0;
q->tail = 0;
q->count = 0;
}
// 入队
int event_queue_push(event_queue_t *q, event_t *evt) {
if(q->count >= EVENT_QUEUE_SIZE) {
return -1; // 队列满
}
q->buffer[q->tail] = *evt;
q->tail = (q->tail + 1) % EVENT_QUEUE_SIZE;
q->count++;
return 0;
}
// 出队
int event_queue_pop(event_queue_t *q, event_t *evt) {
if(q->count == 0) {
return -1; // 队列空
}
*evt = q->buffer[q->head];
q->head = (q->head + 1) % EVENT_QUEUE_SIZE;
q->count--;
return 0;
}
注意:环形缓冲区的大小要仔细评估。太小了,事件突发时会丢事件;太大了,浪费宝贵的内存。我一般按“最坏情况下1秒内可能产生的事件数”乘以2来定。比如按键事件最多每秒10个,那就设20个。留点余量,但别太奢侈。
最后,事件队列的访问要注意互斥。如果入队操作在中断里,出队操作在主循环里,那就需要关中断保护。否则,中断和主循环同时操作队列,数据就乱了。我习惯在入队时关中断,出队时不用关——因为出队只在主循环里执行,不会被中断打断。
嗯,这一章的内容就到这里。事件驱动架构看起来简单,但真正用好需要很多实践。下一章我们会讲状态机,你会发现事件驱动和状态机是天生一对。到时候再细聊。