4. 事件驱动架构:让系统学会“响应”而非“轮询”

好,我们进入第四章。这一章我要跟你聊聊事件驱动架构。说实话,这是嵌入式系统里一个非常核心的思维转变。很多新手工程师写代码,习惯让CPU不停地去“问”外设:“你好了没?你好了没?”——这叫轮询。但真正成熟的系统,应该是外设主动“告诉”CPU:“我好了,快来处理我!”——这就是事件驱动。

我个人习惯把事件驱动比作一个高效的秘书。轮询就像你每隔五分钟就去问秘书有没有电话,而事件驱动是电话铃一响,秘书就通知你。哪个效率高?不言而喻。在剃须刀这种对功耗和响应速度都有要求的设备里,事件驱动几乎是必然的选择。

4.1 事件驱动模型介绍

事件驱动模型,说白了就是“发生什么事,就处理什么事”。系统里有一个核心的循环,它不主动干活,而是等着“事件”来找它。一旦有事件发生,比如按键按下、电量低、电机堵转,系统就跳转到对应的事件处理函数里去执行。

这个模型有几个关键角色:

  • 事件源:谁产生事件?比如按键、定时器、传感器中断。
  • 事件队列:事件来了先排队,防止丢失。
  • 事件处理器:真正干活的人,处理具体事件。
  • 事件调度器:核心循环,负责从队列里取事件并分发。

你想想看,如果不用事件驱动,会是什么场景?主循环里一堆if语句,轮询按键、轮询电量、轮询电机状态。代码耦合度高,改一个地方可能牵一发动全身。而且CPU大部分时间都在空转,浪费电。

我在项目中遇到过最典型的反面教材:一个同事用轮询方式写了个小家电,结果电池续航比预期少了30%。后来改成事件驱动,续航直接达标。嗯,这就是架构的力量。

核心要点:事件驱动架构的核心是“异步”和“解耦”。事件产生者不需要知道谁会处理它,事件处理器也不需要关心事件是怎么来的。各司其职,互不干扰。

4.2 剃须刀按键事件处理

按键,是剃须刀最直接的人机交互接口。别看它简单,处理不好会出大问题。比如按键抖动、长按短按混淆、连击误触发——这些都是我在实际调试中踩过的坑。

按键事件处理,一般分三步:

  1. 硬件消抖:按键按下瞬间会有机械抖动,通常用10-20ms的延时或滤波电路处理。
  2. 状态识别:判断是短按、长按还是双击。这需要记录按键按下的时间戳。
  3. 事件上报:把识别结果封装成事件,扔进事件队列。

我给你们看一段我常用的按键处理代码框架。注意,这不是完整代码,但思路很清楚:

// 按键状态机
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个。留点余量,但别太奢侈。

最后,事件队列的访问要注意互斥。如果入队操作在中断里,出队操作在主循环里,那就需要关中断保护。否则,中断和主循环同时操作队列,数据就乱了。我习惯在入队时关中断,出队时不用关——因为出队只在主循环里执行,不会被中断打断。

嗯,这一章的内容就到这里。事件驱动架构看起来简单,但真正用好需要很多实践。下一章我们会讲状态机,你会发现事件驱动和状态机是天生一对。到时候再细聊。