别再写 while(1) 死循环了,嵌入式开发该换个活法

很多嵌入式工程师写了好几年代码,项目越做越复杂,但主循环里的东西也越塞越多。最后回头一看,main函数里几百行的 if-else,自己都不想维护了。问题出在哪?可能从一开始,我们就该换一种思路来组织代码。

标题先看一个熟悉的场景

做嵌入式开发的朋友,下面这段代码应该不陌生:

c 复制代码
int main(void)
{
    system_init();

    while (1) {
        if (key_pressed()) {
            handle_key();
        }
        if (uart_data_ready()) {
            handle_uart();
        }
        if (timer_timeout()) {
            handle_timer();
        }
        if (adc_convert_done()) {
            handle_adc();
        }
        // ... 还有十几个 if
    }
}

项目刚开始的时候,这样写没毛病,简单直接。但随着功能不断叠加,问题慢慢就暴露了:

• 主循环越来越臃肿,每加一个功能就多一个 if 分支

• 响应不及时,某个处理函数耗时一长,后面的全得排队等

• 耦合度太高,改一个功能容易牵连别的模块

• 不好测试,所有逻辑都黏在一起,单独拎出来测都费劲

说白了,这就是典型的轮询模式(Polling)------CPU 在主循环里不停地挨个问:"你有事吗?你有事吗?你呢?"。

效率低不说,代码结构也越来越难维护。

换个思路:让事件来驱动程序

既然轮询模式是"CPU 主动去问",那反过来想------能不能让事件主动来通知 CPU?

这就是事件驱动(Event-Driven) 的核心思想:

不是你去找事情做,而是事情来找你。

打个比方:

• 轮询模式就像你每隔5分钟看一次手机,检查有没有新消息。大部分时候是白看。

• 事件驱动就像手机来了消息会响铃通知你,你听到铃声再去处理就行。

下面这张图能更直观地说明两者的区别:

事件驱动架构长什么样

一个最小的事件驱动框架,核心就三样东西:

1)事件定义

首先得有一个统一的事件表示。不用搞多复杂,一个类型加一个数据就够了:

c 复制代码
typedef struct {
    uint16_t type;    /* 事件类型 */
    uint16_t param;   /* 附带参数 */
} even

事件类型用枚举来管理:

c 复制代码
enum {
    EVT_NONE = 0,
    EVT_KEY_PRESS,
    EVT_KEY_RELEASE,
    EVT_UART_RX,
    EVT_TIMER_TICK,
    EVT_ADC_DONE,
    // ...
};

2)事件队列

队列本质上就是一个环形缓冲区,中断里往里塞事件,主循环里取出来处理:

c 复制代码
#define EVT_QUEUE_SIZE  32

static event_t evt_queue[EVT_QUEUE_SIZE];
static volatile uint8_t head = 0;
static volatile uint8_t tail = 0;

/* 中断中调用:投递事件 */
void event_post(uint16_t type, uint16_t param)
{
    uint8_t next = (head + 1) % EVT_QUEUE_SIZE;
    if (next != tail) {          /* 队列没满 */
        evt_queue[head].type  = type;
        evt_queue[head].param = param;
        head = next;
    }
}

/* 主循环中调用:取出事件 */
bool event_get(event_t *evt)
{
    if (tail == head)
        return false;            /* 队列为空 */

    *evt = evt_queue[tail];
    tail = (tail + 1) % EVT_QUEUE_SIZE;
    return true;
}

3)事件分发

取出事件之后,怎么交给对应的处理函数?最简单的做法是用一张函数指针表:

c 复制代码
typedef void (*event_handler_t)(uint16_t param);

/* 处理函数注册表 */
static event_handler_t handler_table[EVT_MAX] = { NULL };

void event_register(uint16_t type, event_handler_t handler)
{
    if (type < EVT_MAX)
        handler_table[type] = handler;
}

void event_dispatch(event_t *evt)
{
    if (evt->type < EVT_MAX && handler_table[evt->type]) {
        handler_table[evt->type](evt->param);
    }
}

把这三部分组合起来,主循环就变得非常清爽了:

c 复制代码
int main(void)
{
    system_init();

    /* 注册各模块的事件处理函数 */
    event_register(EVT_KEY_PRESS,  on_key_press);
    event_register(EVT_UART_RX,    on_uart_receive);
    event_register(EVT_TIMER_TICK, on_timer_tick);
    event_register(EVT_ADC_DONE,   on_adc_done);

    event_t evt;
    while (1) {
        if (event_get(&evt)) {
            event_dispatch(&evt);
        } else {
            __WFI();  /* 没事件就睡觉,省电 */
        }
    }
}

对比一下最开始那个版本,是不是清爽多了?主循环不再关心具体业务,只负责取事件和派发事件。 每个模块各管各的处理函数,互不干扰。

实际工程中的几点经验

纸上得来终觉浅,真正在项目里用事件驱动,还有些坑值得说一说。

  1. 中断里不要做重活
    中断服务函数(ISR)只做一件事:投递事件,然后赶紧退出。
c 复制代码
/* ✗ 错误示范:中断里做太多事 */
void USART1_IRQHandler(void)
{
    uint8_t data = USART1->DR;
    parse_protocol(data);      // 耗时操作
    update_display();          // 更耗时
}

/* ✓ 正确做法:中断里只投递事件 */
void USART1_IRQHandler(void)
{
    uint8_t data = USART1->DR;
    event_post(EVT_UART_RX, data);  // 投递完就走
}

道理很简单:中断占用时间越长,其他中断响应就越慢,系统实时性就越差。

  1. 队列大小要结合实际

队列太小,高频事件容易丢;太大,又浪费 RAM。我的经验是:

• 一般项目:32 个就够

• 通信密集型(比如同时跑多路串口):可以到 64 甚至 128

• 关键事件可以设置优先级队列,保证不被低优先级事件挤掉

  1. 考虑加入状态机
    事件驱动解决了"什么时候做"的问题,但"该做什么"往往取决于当前状态。这时候把状态机和事件驱动结合起来,效果非常好:

    比如一个智能门锁,收到按键事件时:

• 待机状态下 → 点亮屏幕,进入输入密码状态

• 输入密码状态下 → 记录按键值

• 报警状态下 → 忽略按键

同一个事件,不同状态下行为完全不同。如果不用状态机来管理,很快就会陷入 if-else 的泥潭。

  1. 模块间通信用事件解耦
    传统做法里,模块 A 要通知模块 B,往往是直接调用 B 的函数。这样一来 A 就依赖了 B,耦合就产生了。

用事件驱动的方式:A 只管发事件,谁关心谁来注册处理。 A 根本不需要知道 B 的存在。

这种解耦带来的好处是实实在在的:加一个新模块,只要注册对应事件的处理函数就行,完全不用动已有代码。

事件驱动到底带来了什么

回过头来总结一下,从轮询切换到事件驱动,我们到底收获了什么:

当然,事件驱动也不是银弹。对于特别简单的项目(就几个 GPIO 翻转),上事件驱动框架反而是杀鸡用牛刀。工程选择没有绝对的好坏,只有合不合适。

写在最后

写到这里,你可能已经发现了一些有意思的东西:

事件驱动里的事件队列,本质上就是生产者-消费者模式;函数指针注册表,其实就是观察者模式的雏形;事件驱动配合状态机,就是状态模式的典型应用。

这些东西有一个共同的名字------设计模式。

很多嵌入式工程师觉得设计模式是搞 Java、搞后端那帮人的东西,跟单片机没关系。但实际上,你在项目里踩过的坑、总结出的经验,很多都能在设计模式里找到对应的解法。区别只是,别人早就把这些经验提炼成了可复用的套路。

与其每次都从零开始摸索,不如站在前人的肩膀上。系统地学一遍设计模式,你会发现很多以前觉得"想不到"的方案,其实早就有人替你想好了。

相关推荐
bucenggaibian1 小时前
为什么有这么多以字母 “C” 为开头的编程语言?
c语言·编程语言·历史·发展·家族
LCG元1 小时前
STM32实战:基于STM32F103的智能停车场车位引导系统
stm32·单片机·嵌入式硬件
bucenggaibian1 小时前
C语言超级全面的学习平台
c语言·sqlite·easylogger·pat练习·tencentos-tiny
WYH2871 小时前
【STM32 串口完全指南】从轮询到中断再到 DMA,一步步教你搞定串口收发!
stm32·单片机·嵌入式硬件
hrw_embedded1 小时前
STM32单片机增加全局内存增大导致ADC数据丢失,明明两个不相干的两个部分,为什么会相互干扰?
stm32·单片机·嵌入式硬件
余生皆假期-2 小时前
YuanHub 源码分析【六】MIT 模式
笔记·单片机·嵌入式硬件
50万马克的面包2 小时前
三子棋小游戏(C语言详解)
c语言·开发语言·算法
玩转单片机与嵌入式2 小时前
别再只把 MCU 当控制器:新一代芯片正在把 AI 推理搬到设备端
人工智能·单片机·嵌入式硬件