第三课:裸机环境下的简易事件驱动框架

手写一个最小原型,实现按键触发LED翻转

好,咱们直接动手。上一课讲了事件驱动的核心思想,这一课就来真的——在裸机环境下,手写一个最小的事件驱动框架。说白了,就是让按键按下时,LED能乖乖翻转。听起来简单,但这里面藏着事件驱动最本质的东西。

我个人习惯,学任何框架都要从最小原型开始。你想想看,如果连一个按键一个灯都搞不定,那后面搞复杂系统就是空中楼阁。我在项目中遇到过不少同事,一上来就想搞个高大上的调度器,结果连基本的事件传递都没理清楚,最后debug到崩溃。

1. 最小事件驱动框架的组成

一个事件驱动框架,再小也得有三个东西:

  • 事件源:谁产生事件?这里就是按键。
  • 事件队列:事件来了放哪里?一个简单的环形缓冲区。
  • 事件处理器:谁来处理事件?一个主循环里的调度函数。

嗯,就这么简单。别想复杂了,咱们先跑起来再说。

核心思想:事件驱动不是玄学,就是把「检测到按键按下」这件事,包装成一个结构体,扔到队列里,然后主循环取出来执行。就这么回事。

2. 定义事件结构体

事件总得有个载体。我习惯用一个简单的结构体:

// event.h
#ifndef EVENT_H
#define EVENT_H

#include <stdint.h>

typedef enum {
    EVT_NONE = 0,
    EVT_KEY_PRESS,
    EVT_KEY_RELEASE,
    EVT_TIMEOUT,
    // 以后可以继续加
} EventType_t;

typedef struct {
    EventType_t type;
    uint32_t    param;   // 携带参数,比如按键编号
} Event_t;

#endif

这里我用了枚举来定义事件类型。你可能会问,为什么不用宏?我个人习惯用枚举,因为调试的时候能看到名字,而不是一堆数字。这个坑我踩过——有一次项目里事件类型用宏定义,结果debug时看寄存器值,完全不知道是哪个事件,气得我连夜改成枚举。

3. 实现环形事件队列

队列是事件驱动的核心。说白了,就是个先进先出的缓冲区。我选环形队列,因为不用动态内存分配,裸机环境下最稳。

// queue.c
#include "queue.h"
#include <string.h>

#define QUEUE_SIZE 16

static Event_t queue[QUEUE_SIZE];
static uint8_t head = 0;
static uint8_t tail = 0;

uint8_t Queue_Push(Event_t *evt) {
    uint8_t next = (head + 1) % QUEUE_SIZE;
    if (next == tail) {
        return 0;  // 队列满了
    }
    memcpy(&queue[head], evt, sizeof(Event_t));
    head = next;
    return 1;
}

uint8_t Queue_Pop(Event_t *evt) {
    if (head == tail) {
        return 0;  // 队列空了
    }
    memcpy(evt, &queue[tail], sizeof(Event_t));
    tail = (tail + 1) % QUEUE_SIZE;
    return 1;
}

注意:环形队列有个经典陷阱——满和空都是 head == tail。解决办法是牺牲一个元素空间,或者加一个计数变量。我这里用了牺牲一个元素的方式,队列实际可用大小是 QUEUE_SIZE - 1。

我曾经在一个产品里用了计数变量的方式,结果多线程环境下计数被破坏,队列直接崩了。从那以后,裸机环境下我统一用牺牲元素法,简单可靠。

4. 按键检测与事件产生

按键检测,最基础的就是轮询加消抖。但咱们现在做事件驱动,检测到按键动作后,不是直接翻转LED,而是产生一个事件丢到队列里。

// key.c
#include "key.h"
#include "queue.h"

#define KEY_PORT   P1
#define KEY_PIN    BIT0

static uint8_t last_key_state = 1;  // 假设高电平为松开

void Key_Scan(void) {
    uint8_t current = (KEY_PORT & KEY_PIN) ? 1 : 0;
    
    // 检测到下降沿:按下
    if (last_key_state == 1 && current == 0) {
        Event_t evt;
        evt.type = EVT_KEY_PRESS;
        evt.param = 0;  // 按键编号0
        Queue_Push(&evt);
    }
    // 检测到上升沿:松开
    else if (last_key_state == 0 && current == 1) {
        Event_t evt;
        evt.type = EVT_KEY_RELEASE;
        evt.param = 0;
        Queue_Push(&evt);
    }
    
    last_key_state = current;
}

这里我没有做软件消抖。为什么?因为事件驱动框架下,消抖可以放在事件处理器里做,或者用定时器事件来做。你想想看,如果按键按下时产生一个事件,然后启动一个10ms的定时器,定时器到期再检查按键状态,这才是优雅的做法。

小技巧:实际项目中,我通常把消抖逻辑封装成一个独立模块,不跟按键检测混在一起。这样按键检测只管产生事件,消抖由事件处理器决定。职责单一,好维护。

5. LED控制与事件处理

LED翻转,这是最终的执行动作。在事件驱动框架里,它属于事件处理器的一部分。

// led.c
#include "led.h"

#define LED_PORT   P2
#define LED_PIN    BIT0

void LED_Init(void) {
    LED_PORT &= ~LED_PIN;  // 初始灭
}

void LED_Toggle(void) {
    LED_PORT ^= LED_PIN;
}

然后,主循环里的事件调度:

// main.c
#include "event.h"
#include "queue.h"
#include "key.h"
#include "led.h"

void Event_Dispatcher(void) {
    Event_t evt;
    if (Queue_Pop(&evt)) {
        switch (evt.type) {
            case EVT_KEY_PRESS:
                LED_Toggle();
                break;
            case EVT_KEY_RELEASE:
                // 可以在这里做点别的,比如记录按下时长
                break;
            default:
                break;
        }
    }
}

void main(void) {
    // 初始化硬件
    LED_Init();
    
    while (1) {
        Key_Scan();          // 检测按键,产生事件
        Event_Dispatcher();  // 处理事件
    }
}

你看,主循环里就两件事:扫描按键、派发事件。干净利落。这就是事件驱动框架的最小原型。

6. 这个框架能扩展吗?

当然能。这个最小原型虽然简单,但骨架已经有了。你可以:

  • 增加定时器事件:用硬件定时器产生周期性事件,实现延时、超时等功能。
  • 增加多个事件源:串口接收、ADC转换完成、外部中断,都可以产生事件。
  • 增加优先级:给事件加个优先级字段,高优先级事件插队处理。
  • 增加状态机:每个事件处理器内部可以是一个状态机,处理复杂逻辑。
组件 当前实现 可扩展方向
事件类型 枚举,3种 可增加到几十种
事件队列 环形缓冲区,16个 可改为链表,支持动态大小
事件处理器 switch-case 可改为函数指针表
事件源 按键轮询 可增加中断、定时器

总结一下:这个最小原型虽然只有几十行代码,但它包含了事件驱动的全部要素。你掌握了它,就等于拿到了通往复杂嵌入式系统的钥匙。下一课,我会在这个基础上加入定时器事件,让框架真正活起来。

嗯,今天就到这里。代码不多,但建议你亲手敲一遍,跑在开发板上看看效果。我曾经带过一个新人,他看完代码说「懂了」,结果自己写的时候队列指针搞反了,LED死活不亮。动手才是硬道理。


公众号:蓝海资料掘金营,微信deep3321