第三课:裸机环境下的简易事件驱动框架
手写一个最小原型,实现按键触发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