23、事件循环架构:事件驱动、定时器管理、非阻塞IO
各位同学,今天我们来聊聊嵌入式开发里一个绕不开的话题——事件循环架构。
说实话,我早年刚入行时,写嵌入式程序就是典型的“超级循环”:while(1) 里轮询所有外设,谁有数据就处理谁。那时候觉得天经地义,直到有一次做多路传感器采集的项目……
三个传感器,一个串口屏,一个Wi-Fi模块。轮询一圈下来,串口屏的响应延迟肉眼可见。用户按一下按钮,屏幕要等半秒才动。那感觉,就像你喊一个人,他隔半天才回头看你一眼。嗯,从那以后,我开始认真研究事件循环。
什么是事件循环?
说白了,事件循环就是一个“永远在等,来了就干”的机制。
程序跑起来后,进入一个无限循环。循环里做两件事:
- 等事件——比如按键按下、串口收到数据、定时器超时
- 处理事件——调用对应的回调函数
你想想看,这和轮询有什么区别?
轮询是“我主动去问”:你好了没?你好了没?你好了没?
事件驱动是“你好了通知我”:好了叫我,没叫我我就歇着。
效率差距,就在这里。
核心思想:事件循环 = 事件源 + 事件队列 + 事件分发器 + 事件处理器。
事件循环的典型结构
我习惯把事件循环拆成四个部分。你看这张图就明白了:
这个结构,我在多个项目里用过。小到STM32的按键扫描,大到Linux下的libevent,本质都是这个套路。
定时器管理:别让delay()毁了你的系统
很多初学者喜欢用 HAL_Delay() 或 delay_ms()。我当年也这么干。但后来发现,delay 一多,系统就“卡死”了。
为什么?因为 delay 是忙等待。CPU 在那空转,什么事都干不了。
事件循环里的定时器,应该是软定时器。它不阻塞,只是记录一个“到期时间”。每次循环检查一下:有没有定时器到期了?到期了就触发事件。
来看一个简单的软定时器实现:
typedef struct {
uint32_t interval; // 定时周期(ms)
uint32_t last_tick; // 上次触发的时间戳
void (*callback)(void);
uint8_t repeat; // 1:周期触发 0:单次触发
} SoftTimer;
SoftTimer timers[MAX_TIMERS];
int timer_count = 0;
void timer_init(SoftTimer *t, uint32_t ms, void (*cb)(void), uint8_t rep) {
t->interval = ms;
t->last_tick = get_sys_tick_ms();
t->callback = cb;
t->repeat = rep;
}
void timer_poll(void) {
uint32_t now = get_sys_tick_ms();
for (int i = 0; i < timer_count; i++) {
if (now - timers[i].last_tick >= timers[i].interval) {
timers[i].callback(); // 触发回调
if (timers[i].repeat) {
timers[i].last_tick = now; // 重置
} else {
// 单次定时器,标记移除
timers[i].callback = NULL;
}
}
}
}
个人经验:我习惯把 timer_poll() 放在主循环的“空闲时刻”调用。这样既不影响紧急事件响应,又能保证定时精度。精度要求高的场景,可以用硬件定时器配合,但软定时器足够应付90%的日常需求。
非阻塞IO:别让读写卡住你的循环
事件循环最怕什么?怕阻塞。
你想想看,如果主循环里有一个 read() 函数,它要等数据来了才返回。那这期间,其他事件全被堵死了。按键按了没反应,定时器超时了没人管……
非阻塞IO,就是“有数据就读,没数据就返回”。
在嵌入式里,我常用的做法是:
- 串口:用中断接收,数据放到环形缓冲区。主循环里检查缓冲区有没有数据。
- SPI/I2C:用DMA传输,传输完成触发中断,中断里设置一个“传输完成”标志。
- 网络socket:设置
O_NONBLOCK标志,配合select()或poll()使用。
来看一个串口非阻塞读取的例子:
// 环形缓冲区
#define BUF_SIZE 256
static uint8_t rx_buf[BUF_SIZE];
static volatile uint16_t head = 0, tail = 0;
// 中断服务函数(硬件触发)
void USART_IRQHandler(void) {
if (USART->SR & USART_SR_RXNE) {
uint8_t data = USART->DR;
rx_buf[head] = data;
head = (head + 1) % BUF_SIZE;
}
}
// 非阻塞读取:有数据返回字节,无数据返回 -1
int uart_read_nonblock(void) {
if (head == tail) {
return -1; // 缓冲区空,不阻塞
}
uint8_t data = rx_buf[tail];
tail = (tail + 1) % BUF_SIZE;
return data;
}
// 主循环中调用
void main_loop(void) {
while (1) {
int ch = uart_read_nonblock();
if (ch != -1) {
process_byte(ch); // 处理收到的数据
}
timer_poll(); // 处理定时器
check_gpio(); // 检查按键
// ... 其他事件
}
}
注意:环形缓冲区的读写指针,一定要用 volatile 修饰。我在项目中遇到过编译器优化导致读不到最新数据的情况,排查了整整一个下午……后来发现是 volatile 漏了。
事件循环的调度策略
事件多了,怎么调度?我见过几种做法:
| 策略 | 做法 | 适用场景 |
|---|---|---|
| 先来先服务 | 按事件到达顺序处理 | 事件少,优先级不重要 |
| 优先级调度 | 高优先级事件插队 | 有实时性要求(如急停按钮) |
| 时间片轮转 | 每个事件分配固定时间片 | 多个同等重要的事件 |
我个人习惯用优先级 + 时间片的混合方式。高优先级事件来了立即处理,低优先级事件用时间片轮转,防止某个事件饿死。
避坑指南
我曾经在一个项目里,把事件处理函数写得特别长——里面有个循环等待传感器响应。结果呢?定时器全超时了,按键响应延迟到秒级。后来我把那个长函数拆成状态机,每次只执行一小步,问题就解决了。
所以,记住一条铁律:事件处理函数里,不要做耗时操作。如果必须做,拆成状态机,或者丢到后台任务里。
总结一下:
- 事件循环的核心是“等事件 → 分发 → 处理”
- 软定时器用时间戳差值实现,别用 delay
- 非阻塞IO靠中断 + 缓冲区,别让读写卡住循环
- 事件处理函数要短小精悍,耗时操作拆成状态机
好了,这一章就到这里。事件循环这个架构,说难不难,说简单也不简单。关键是要理解它的“非阻塞”本质。你回去可以试着把之前写的轮询程序改成事件驱动,感受一下差别。