23、事件循环架构:事件驱动、定时器管理、非阻塞IO

各位同学,今天我们来聊聊嵌入式开发里一个绕不开的话题——事件循环架构。

说实话,我早年刚入行时,写嵌入式程序就是典型的“超级循环”:while(1) 里轮询所有外设,谁有数据就处理谁。那时候觉得天经地义,直到有一次做多路传感器采集的项目……

三个传感器,一个串口屏,一个Wi-Fi模块。轮询一圈下来,串口屏的响应延迟肉眼可见。用户按一下按钮,屏幕要等半秒才动。那感觉,就像你喊一个人,他隔半天才回头看你一眼。嗯,从那以后,我开始认真研究事件循环。

什么是事件循环?

说白了,事件循环就是一个“永远在等,来了就干”的机制。

程序跑起来后,进入一个无限循环。循环里做两件事:

  • 等事件——比如按键按下、串口收到数据、定时器超时
  • 处理事件——调用对应的回调函数

你想想看,这和轮询有什么区别?

轮询是“我主动去问”:你好了没?你好了没?你好了没?

事件驱动是“你好了通知我”:好了叫我,没叫我我就歇着。

效率差距,就在这里。

核心思想:事件循环 = 事件源 + 事件队列 + 事件分发器 + 事件处理器。

事件循环的典型结构

我习惯把事件循环拆成四个部分。你看这张图就明白了:

事件源 定时器 / GPIO / 串口 产生 事件队列 FIFO / 优先级队列 取出 事件分发器 查表 / switch-case 调用 事件处理器 回调函数 处理完成 while(1) { 等待事件 → 分发 → 处理 } 循环往复,永不退出

这个结构,我在多个项目里用过。小到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靠中断 + 缓冲区,别让读写卡住循环
  • 事件处理函数要短小精悍,耗时操作拆成状态机

好了,这一章就到这里。事件循环这个架构,说难不难,说简单也不简单。关键是要理解它的“非阻塞”本质。你回去可以试着把之前写的轮询程序改成事件驱动,感受一下差别。

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