云计算百科
云计算领域专业知识百科平台

嵌入式异步事件处理实战:从中断死锁到环形队列分发

上一篇聊了为什么该把 while(1) 轮询换成事件驱动,留了个钩子说"中断里到底不能干什么事,那才是事件驱动能跑稳的真正命门"。这篇就来填这个坑。

先说一个我亲身踩过的 Bug。按键 GPIO 中断回调里,我图省事直接调了 LCD 刷新函数,底层是 SPI + DMA。结果按键按几下系统就卡死,复位都来不及。查了两天才搞明白:中断嵌套死锁了。

这种 Bug 的可怕之处在于,它不是"功能写错了"那种一眼能看出的错,而是"代码能跑,但跑着跑着就死"。今天就把中断里能踩的雷、为什么踩、怎么彻底躲开,一次讲透。

那个卡死的Bug:按键ISR调LCD刷新导致中断嵌套死锁的时间线

那个卡死的 Bug,到底发生了什么

还原一下现场。按键接在 GPIO 上,配了下降沿中断。中断回调里我写了三行:读键值、刷新一下 LCD(把按键状态显示出来)、清标志。看起来人畜无害。

刷新 LCD 这个函数底层是 SPI 发一屏数据,用 DMA 搬。DMA 传输需要时间,函数里我等它完成才返回。问题就出在这个"等"上。

按键中断的优先级比 SPI 的 DMA 完成中断高。我进了按键 ISR,在里面等 DMA 完成,DMA 完成要触发自己的中断通知我,可那个中断优先级低,被按键 ISR 堵在外面进不来。DMA 完成中断进不来,完成标志就清不掉,我就一直等。死锁,系统卡死。

这是个典型的"中断里干重活"翻车现场。根子不在某一行代码写错,在于违背了中断上下文的一条铁律。

中断上下文的三条铁律

把规矩立清楚,后面所有设计都围着它转。

第一条,不能阻塞。 中断里不能有任何"等"的动作。等 DMA、等 SPI 总线空闲、等一个信号量、调一个会 sleep 的函数,全都不行。中断是抢占主循环跑的,你一等,主循环和所有低优先级中断全得陪你等,实时性当场崩塌。

第二条,快进快出。 进中断、干完该干的、立刻出去。理想的中断处理时间是几个微秒到几十微秒。判断标准很简单:你拿示波器量中断引脚,中断响应到返回的时间,别超过你系统能容忍的最小事件间隔。

第三条,资源受限。 中断里不能用任何"不可重入"的函数。malloc、printf、带锁的共享资源,这些在中断里碰了就是定时炸弹。栈空间也小,中断栈通常比任务栈小得多,递归调用几层就溢出。

这三条归结成一句话:中断只做最轻的活,重活全甩给主循环。

中断上下文三铁律:不能阻塞/快进快出/资源受限,每条配反面案例

异步事件处理:把"等"变成"通知"

既然中断里不能干重活,那重活谁来干?主循环。可中断和主循环怎么交接?总不能中断里直接调主循环的函数,那不又回到老路了。

答案是异步事件处理。思路一句话:生产者(中断)只管把"有事发生"这件事丢进队列就返回,消费者(主循环)从队列里取出来慢慢处理。

打个比方。你快递到了,快递员不会在你家门口等你签收完再走(那是同步,快递员被你阻塞了)。他按个门铃留个条就走了(投递事件),你回家看到条子再去取(处理事件)。快递员和你各干各的,谁也不等谁。

中断和主循环就是这个关系。中断是快递员,门铃条子就是事件,队列是信箱,主循环是你。这套机制让中断能真正做到快进快出,重活全在主循环的安全上下文里慢慢干。

下面动手实现一个,裸机就能跑,不到 100 行。

事件类型:先说清楚有哪些事

事件用一个枚举列清楚,别到处写魔法数字。

enum {
EVT_NONE = 0,
EVT_KEY, /* 按键事件,param = 键值 */
EVT_SENSOR_READY, /* 传感器就绪,param = 通道 */
EVT_UART_RX, /* 串口收到数据,param = 长度 */
EVT_TIMER_TICK, /* 软件定时器到点 */
EVT_LCD_REFRESH, /* LCD 待刷新(主循环消费) */
};

有人喜欢把事件类型和参数塞进一个 32 位整数里省空间,我不推荐。可读性比那几个字节重要得多,枚举加一个 param 字段,调试时一眼能看出是什么事件带了什么参数。

环形队列:单读单写,天然免锁

这是整个系统的咽喉。中断往里写,主循环从里读,两边并发访问,要不要加锁?

不需要。因为这是单生产者单消费者结构:只有中断这一个写者,只有主循环这一个读者。这种结构下,head 指针只有中断改,tail 指针只有主循环改,各管各的,天然没有竞争。

#define EVT_QUEUE_SIZE 32

typedef struct {
uint16_t type;
uint16_t param;
} event_t;

static event_t evt_queue[EVT_QUEUE_SIZE];
static volatile uint16_t q_head; /* 中断写,主循环只读 */
static volatile uint16_t q_tail; /* 主循环写,中断只读 */

/* 中断里调:投递事件,O(1) */
void event_post(uint16_t type, uint16_t param)
{
uint16_t next = (q_head + 1) % EVT_QUEUE_SIZE;
if (next == q_tail) return; /* 满了直接丢,绝不阻塞中断 */
evt_queue[q_head].type = type;
evt_queue[q_head].param = param;
q_head = next;
}

/* 主循环里调:取事件 */
int event_get(event_t *out)
{
if (q_tail == q_head) return 0; /* 空 */
out->type = evt_queue[q_tail].type;
out->param = evt_queue[q_tail].param;
q_tail = (q_tail + 1) % EVT_QUEUE_SIZE;
return 1;
}

两个细节值得说。一是 volatile,head 和 tail 被中断和主循环交叉访问,必须加 volatile 防止编译器把它优化进寄存器。二是"满了直接丢",这是铁律的延伸,中断里绝对不能等队列空出来,丢了总比卡死强,丢的事件靠上层重传或超时重发兜底。

队列大小按事件突发量定。按键加串口这种慢设备 32 够用,高频传感器可能要开到 128。宁可多占点 RAM,也别在生产环境因为队列太小丢关键事件。

环形队列单读单写免锁:中断写head、主循环写tail、volatile标注、满则丢策略

函数指针表:O(1) 分发,谁关心谁注册

事件取出来了,谁处理?写一长串 if-else 又回到老路。用函数指针表:每个模块初始化时把自己关心的事件类型和回调函数注册进去,分发时按 type 查表回调。

#define MAX_HANDLERS 16
typedef void (*handler_t)(event_t *evt);

static handler_t handlers[MAX_HANDLERS];
static uint16_t handler_evt[MAX_HANDLERS];
static uint8_t handler_cnt;

void event_register(uint16_t type, handler_t fn)
{
if (handler_cnt < MAX_HANDLERS) {
handler_evt[handler_cnt] = type;
handlers[handler_cnt] = fn;
handler_cnt++;
}
}

void event_dispatch(event_t *evt)
{
for (uint8_t i = 0; i < handler_cnt; i++) {
if (handler_evt[i] == evt->type) {
handlers[i](evt);
}
}
}

注册在初始化时做一次。按键模块注册 EVT_KEY,显示模块注册 EVT_LCD_REFRESH。模块之间不直接调,靠事件解耦。改按键不会碰显示,因为显示根本不知道按键存在,它只认 EVT_LCD_REFRESH 这个事件。

这就是策略模式的 C 语言版:分发逻辑和具体处理逻辑分开,加新功能不用动分发器,注册个新 handler 就行。

主循环:三行代码加一个睡眠

三块积木拼起来,主循环干净得不像话。

int main(void)
{
system_init(); /* 各模块初始化 + 注册事件处理器 */
while (1) {
event_t evt;
if (event_get(&evt)) {
event_dispatch(&evt); /* 有事件就处理 */
} else {
__WFI(); /* 没事件就睡,等中断唤醒 */
}
}
}

对比那个卡死的 Bug,现在按键 ISR 里只做一件事:读键值、投递 EVT_KEY 事件、清标志,立刻返回。LCD 刷新变成了 EVT_LCD_REFRESH 事件,由主循环在安全上下文里慢慢调 SPI DMA。死锁的根(中断里等 DMA)被彻底拔掉了。

__WFI 是顺带的大礼包。没事件时 CPU 进低功耗,等任意中断唤醒。功耗从几十 mA 掉到几 μA,对一节电池跑半年的设备,这是能不能出货的差别。

改造前后对比:左按键ISR直接调LCD刷新死锁,右ISR只投递+主循环刷新解耦

ISR 改造后的样子

回到开头那个 Bug,改造后的按键 ISR 长这样:

void EXTI15_10_IRQHandler(void)
{
uint16_t key = read_key_gpio(); /* ① 读硬件寄存器 */
event_post(EVT_KEY, key); /* ② 投递事件 */
clear_exti_flag(); /* ③ 清中断标志 */
}

三件事,几条指令,微秒级返回。LCD 刷新交给主循环里注册的 key_handler 去触发 EVT_LCD_REFRESH,再由 display_handler 慢慢刷。中断不再等任何东西,DMA 完成中断随时能进来,死锁不复存在。

记住这条铁律:ISR 只投递,不干活。 守住它,能省掉一半的"玄学 Bug"。

再进一步:带优先级的事件

基础版是单队列 FIFO,先到先处理。但有些事件急,有些事件缓。急事件被一堆缓事件堵在队列后面,响应就慢了。

解法是开多个队列,按优先级分。比如高、中、低三个队列,主循环每次先看高优先级队列,空了再看中,再低。

static event_t hi_queue[16], mid_queue[32], lo_queue[16];

int event_get(event_t *out)
{
if (q_get(hi_queue, 16, &hi_head, &hi_tail, out)) return 1;
if (q_get(mid_queue, 32, &mid_head, &mid_tail, out)) return 1;
if (q_get(lo_queue, 16, &lo_head, &lo_tail, out)) return 1;
return 0;
}

简单有效,别一上来就搞复杂的优先级调度算法。嵌入式场景里两三个队列分级足够覆盖绝大多数需求。注意别滥用高优先级,都急等于都不急,最后还是退化成 FIFO。

延迟事件:让事件"等一会"再处理

有时候你想让一个事件过 50ms 再处理,比如按键去抖。中断里不能 delay,主循环里硬 delay 又会阻塞别的事件。

解法是给事件加个"到期时间",放进一个延迟队列,主循环每次轮询时检查到没到期,到期了再搬到主队列。

typedef struct {
event_t evt;
uint32_t fire_tick; /* 到期的系统 tick */
} delayed_event_t;

static delayed_event_t delayed[8];

void event_post_delayed(uint16_t type, uint16_t param, uint32_t ms)
{
/* 找个空槽存起来,记录到期时间 */
for (uint8_t i = 0; i < 8; i++) {
if (delayed[i].evt.type == EVT_NONE) {
delayed[i].evt.type = type;
delayed[i].evt.param = param;
delayed[i].fire_tick = sys_tick() + ms_to_tick(ms);
return;
}
}
}

/* 主循环里调,把到期的事件搬到主队列 */
void event_tick(void)
{
uint32_t now = sys_tick();
for (uint8_t i = 0; i < 8; i++) {
if (delayed[i].evt.type != EVT_NONE &&
(int32_t)(now – delayed[i].fire_tick) >= 0) {
event_post(delayed[i].evt.type, delayed[i].evt.param);
delayed[i].evt.type = EVT_NONE;
}
}
}

判断到期用 (int32_t)(now – fire_tick) >= 0,这是处理 tick 回绕的标准写法,别直接比大小,跑个几十天 tick 溢出就出 Bug。

事件合并:高频事件别刷屏

传感器高频就绪事件,一秒钟触发几百次,每次都刷新 LCD 毫无意义,人眼根本看不出区别,还白白耗 CPU 和刷屏电流。

解法是合并。同一类型的事件在队列里只保留最新的,旧的丢弃。或者更简单:设个"脏标志",事件来了只置标志不立刻处理,主循环按固定节拍(比如 30Hz)检查标志,有才刷一次。

/* 高频传感器事件只更新数据,不触发刷新 */
void sensor_handler(event_t *evt)
{
sensor_data = read_sensor(evt->param);
lcd_dirty = 1; /* 只置标志 */
}

/* 主循环按节拍检查,30Hz 刷一次 */
void lcd_tick_handler(event_t *evt) /* EVT_TIMER_TICK */
{
if (lcd_dirty) {
lcd_refresh();
lcd_dirty = 0;
}
}

这样无论传感器事件多频繁,LCD 刷新始终是 30Hz,既不丢数据也不浪费。这是嵌入式 GUI 性能优化的常用招数。

事件改进三方向:带优先级多队列/延迟事件到期队列/事件合并脏标志

什么时候这套机制够用,什么时候该上 RTOS

说了这么多这套异步事件系统的好处,也得说它的边界。它本质是"协作式"调度:一个 handler 执行时,别的 handler 和主循环都得等。如果你的某个处理真的需要长时间阻塞(比如等网络响应),handler 里一阻塞,整个系统就卡住。

这种场景就该上 RTOS 了。RTOS 给每个任务独立栈和抢占式调度,一个任务阻塞不影响别的。代价是引入 RTOS 的复杂度、RAM 开销、学习成本。

判断标准:处理都是非阻塞的短任务,裸机事件系统够用且更轻;一旦出现需要长时间阻塞的处理,或者任务之间有复杂的同步需求,上 RTOS。别为了用 RTOS 而用 RTOS,几十行代码的玩具项目上 RTOS 是杀鸡用牛刀。

本质是三个设计模式

回头看这套机制,它不是凭空发明的,是三个经典设计模式在嵌入式的落地。

事件队列是生产者-消费者模式:中断生产事件,主循环消费事件,队列做缓冲解耦两边速度差。函数指针表分发是策略模式:分发逻辑和具体处理分开,运行时按类型选策略。中断只发事件、主循环做处理暗合命令模式:把"请求"封装成对象(事件),请求方(中断)和执行方(主循环)解耦。

嵌入式工程师很多人觉得设计模式是 Java 程序员的玩意,跟自己没关系。但你真把项目做大、做久了,绕不开的。与其每次踩坑现学,不如系统补一下。这套异步事件系统就是个现成的例子,三个模式揉在一起,解决了一个真实的工程问题。

下次写中断前问自己一句:这个 ISR 里,是不是干了不该干的事。

有用的话点个在看,让更多被中断卡死过的工程师看到。下一篇我们聊嵌入式 GUI 开发,页面一多就乱的毛病,怎么用一套页面管理方案治好。


标签:嵌入式 中断 异步事件 环形队列 函数指针 状态机 C语言 架构设计

赞(0)
未经允许不得转载:网硕互联帮助中心 » 嵌入式异步事件处理实战:从中断死锁到环形队列分发
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!