2. 嵌入式基础回顾
好,我们正式开始。上一章聊了为什么裸机也需要架构,这一章咱们把地基打牢。
事件驱动,说白了就是四个东西在转:事件、事件处理器、事件队列、事件循环。你把这四个概念吃透了,后面写代码就是搭积木。
1. 事件(Event)
什么是事件?我习惯这么定义:系统中发生的一件事,需要被处理。
按键按下,是一个事件。定时器溢出,是一个事件。串口收到一个字节,也是一个事件。甚至你踩到狗尾巴,狗叫了一声——对狗来说,这也是一个事件。
在嵌入式里,事件通常用一个结构体表示。我见过很多新手把事件定义成纯数字,比如 #define EVT_KEY_PRESS 1。嗯,这样也行,但不够灵活。
我个人推荐的事件结构体:
typedef struct {
uint16_t id; // 事件ID,比如 EVT_KEY_PRESS
uint16_t param; // 参数,比如按键编号
uint32_t timestamp; // 时间戳,记录事件发生时刻
} Event_t;
为什么加 timestamp?我在一个项目中遇到过这样的坑:两个事件几乎同时发生,处理顺序全靠队列先进先出。但后来发现,有些事件需要按实际发生顺序处理,而不是入队顺序。加上时间戳,你就能做排序或超时判断。
小技巧:事件ID用枚举定义,别用宏。枚举在调试器里能直接看到名字,宏只能看到数字。
2. 事件处理器(Event Handler)
事件处理器,就是处理事件的函数。每个事件对应一个处理器,或者一类事件对应一个处理器。
我早期做项目时,喜欢写一个巨大的 switch-case:
void event_handler(Event_t *evt) {
switch(evt->id) {
case EVT_KEY_PRESS: handle_key(evt); break;
case EVT_TIMER: handle_timer(evt); break;
case EVT_UART_RX: handle_uart(evt); break;
// ... 50个case
}
}
后来项目大了,这个函数变得又臭又长。你想想看,每次加一个新事件,都要改这个文件,编译时所有依赖这个文件的模块都得重编。烦不烦?
更好的做法是用函数指针表:
typedef void (*EventHandler)(Event_t *evt);
EventHandler handler_table[EVT_MAX] = {
[EVT_KEY_PRESS] = handle_key,
[EVT_TIMER] = handle_timer,
[EVT_UART_RX] = handle_uart,
// 新增事件只需在这里加一行
};
这样,事件分发就变成了:
void dispatch(Event_t *evt) {
if (evt->id < EVT_MAX && handler_table[evt->id]) {
handler_table[evt->id](evt);
}
}
干净、利落、可扩展。我曾经在一个项目里用这个模式,后期加了20多个新事件,没改过一行分发代码。
3. 事件队列(Event Queue)
事件队列,就是事件的缓冲区。为什么需要队列?因为事件产生和处理往往是异步的。
中断里产生事件,主循环里处理事件。如果中断里直接调用处理器,那中断服务程序会变得很长,影响系统实时性。所以,中断里只做一件事:把事件丢进队列。
队列的实现方式有很多种:
- 环形缓冲区:最常用,固定大小,无动态内存分配
- 链表队列:灵活,但需要内存管理,裸机上慎用
- 双缓冲:一个写缓冲区,一个读缓冲区,适合高频场景
注意:中断里往队列写数据时,一定要考虑原子操作。如果队列是全局变量,中断和主循环可能同时访问它。我见过一个案例,中断写了一半,主循环读走了半个事件,系统直接崩溃。
一个简单的环形缓冲区队列实现:
#define QUEUE_SIZE 16
typedef struct {
Event_t buffer[QUEUE_SIZE];
volatile uint8_t head;
volatile uint8_t tail;
} EventQueue_t;
bool queue_push(EventQueue_t *q, Event_t *evt) {
uint8_t next = (q->head + 1) % QUEUE_SIZE;
if (next == q->tail) return false; // 队列满
q->buffer[q->head] = *evt;
q->head = next;
return true;
}
bool queue_pop(EventQueue_t *q, Event_t *evt) {
if (q->head == q->tail) return false; // 队列空
*evt = q->buffer[q->tail];
q->tail = (q->tail + 1) % QUEUE_SIZE;
return true;
}
注意 volatile 关键字。head 和 tail 会被中断和主循环同时访问,不加 volatile,编译器优化可能让你怀疑人生。
4. 事件循环(Event Loop)
事件循环,就是那个永远在跑的 while(1)。它的工作很简单:从队列里取事件,交给处理器。
void event_loop(void) {
Event_t evt;
while (1) {
if (queue_pop(&g_event_queue, &evt)) {
dispatch(&evt);
} else {
// 队列为空,可以进入低功耗模式
__WFI(); // ARM 的等待中断指令
}
}
}
这里有个细节:队列为空时,别空转。我早期做的一个项目,CPU 在空转时功耗 50mA,加上 __WFI() 后降到 5mA。省下来的电,够设备多跑好几天。
事件循环的变体有很多:
- 优先级循环:高优先级事件先处理
- 时间片轮转:每个事件分配固定处理时间
- 混合模式:紧急事件立即处理,普通事件排队
我个人习惯用最简单的先进先出。除非你明确知道需要优先级,否则别过度设计。你想想看,一个裸机系统,事件量通常不大,FIFO 完全够用。
5. 四个概念的关系
这四个东西怎么配合?我画了一张图,你看一眼就明白了。
流程很清楚:事件源产生事件 → 入队 → 事件循环出队 → 分发到处理器。这个闭环,就是事件驱动架构的心脏。
6. 避坑指南
最后,分享几个我踩过的坑:
- 队列大小设太小:中断密集时丢事件。我曾经设了8个,结果按键连发时丢了一半。后来改成32,再没丢过。
- 处理器里做耗时操作:比如在事件处理器里调用延时函数。这会导致事件循环卡住,后续事件无法处理。记住:处理器里别阻塞。
- 忘记关中断:在队列操作时,如果中断可能打断,一定要关中断或使用临界区。我见过一个同事,队列 push 写到一半被中断打断,pop 读走了脏数据。