乐于分享
好东西不私藏

嵌入式软件架构(二):事件驱动,怎么把主循环里的标志位地狱收拾干净

嵌入式软件架构(二):事件驱动,怎么把主循环里的标志位地狱收拾干净

你翻开自己的主循环,数一数里面有多少个标志位。key_flag、timeout_flag、door_flag、pay_flag、err_flag。它们互相牵制,一个置位要先看另外几个的脸色。加个功能,得在五个地方补 if。改一处,崩三处。这不是你手潮,是代码还停在轮询标志位的年代,没上事件驱动。

《嵌入式软件架构(一):HAL / BSP / 驱动 / 应用,这四层到底怎么切》讲的是把工程竖着切成四层。这篇换个方向,讲同一层内部的模块之间,怎么把满地的标志位收拾成一条干净的事件流。

先看标志位是怎么把人逼疯的

拿个例子。做一台快递柜的主控。

它要管的事不少。扫码枪读单号,按键选格口,开锁到位检测,关门检测,支付回调,超时提醒。

最省事的写法,是每件事给一个标志位,主循环里一路 if 下来。

while (1) {
    if (scan_flag)    { ... scan_flag = 0; }
    if (key_flag)     { ... key_flag = 0; }
    if (door_flag)    { ... door_flag = 0; }
    if (pay_flag)     { ... pay_flag = 0; }
    if (timeout_flag) { ... timeout_flag = 0; }
}

刚开始好好的。事情一多就开始打架。

扫码成功后要等支付,支付没到不能开锁。于是你在 pay_flag 里加判断,得先看 scan_flag 之前存的单号对不对。开锁了要等关门,关门检测里又得看是不是刚开的那个格口。

标志位之间开始互相引用。一个 flag 的意义,取决于另外几个 flag 现在是什么值。

到最后没人敢动这段代码。因为你根本说不清,这一堆 flag 的组合里,哪些是合法状态,哪些是不该出现的。

事件驱动换了个思路

事件驱动的想法很简单。别用标志位记录"发生过什么",改成把"发生了什么"打包成一条消息,扔进一个队列里,慢慢处理。

一个事件,就是一条消息。它带两样东西,类型和数据。

typedef struct {
    uint8_t type;      /* 什么事:扫码/按键/到位/支付 */
    uint32_t param;    /* 附带数据:哪个格口、什么码 */
event_t;

扫码枪读到单号,不再去置 scan_flag,而是造一条事件塞进队列。

event_t e = { EVT_SCAN, code };
queue_push(&e);

主循环不再是一排 if,而是从队列里一条一条取,交给一个分发函数。

while (1) {
    event_t e;
    if (queue_pop(&e))
        dispatch(&e);
}

主循环一下子清爽了。它只干一件事,取事件,然后派发。

事件队列,就是一个环形缓冲

队列本身不神秘,就是一个环形缓冲。

一头写,一头读。写的人是各种中断和检测,读的人是主循环。

static event_t buf[16];
static uint8_t head, tail;

void queue_push(event_t *e) {
    buf[head] = *e;
    head = (head + 1) & 15;   /* 到顶绕回开头 */
}

int queue_pop(event_t *e) {
    if (head == tail) return 0;   /* 空了 */
    *e = buf[tail];
    tail = (tail + 1) & 15;
    return 1;
}

这么写有个大好处。中断里只管把事件扔进来,一句话的事,立刻返回。真正费时的处理,全挪到主循环里慢慢做。

中断短了,系统的实时性就稳了。这一点,比标志位那套强太多。

状态机负责判断,事件负责触发

有人会问,那事件之间的先后关系呢,支付要在扫码之后,这些约束靠谁管。

靠状态机。

事件是触发,状态机是判断。分发函数里,先看当前在哪个状态,再看来了什么事件,两者一撮合,才决定干什么。

void dispatch(event_t *e) {
    switch (cur_state) {
    case ST_IDLE:
        if (e->type == EVT_SCAN) cur_state = ST_WAIT_PAY;
        break;
    case ST_WAIT_PAY:
        if (e->type == EVT_PAY)  { open_lock(); cur_state = ST_WAIT_DOOR; }
        break;
    case ST_WAIT_DOOR:
        if (e->type == EVT_DOOR) cur_state = ST_IDLE;
        break;
    }
}

你看这段,原来那些"pay 要看 scan"的隐形约束,现在全变成明面上的东西了。

在等支付的状态,只有支付事件有用,其它事件进来直接被无视。合法的流转,一眼看得清。

这就是事件加状态机的组合拳。《单片机代码怎么才算工程化?时基、解耦、状态机,这三步走完就不一样了》里单说过状态机,这里把它和事件队列接到一起,才凑成完整的事件驱动。

一个模块只管发事件,不管谁来收

事件驱动还带来一个隐形的好处,解耦。

扫码模块造完事件就扔进队列,它根本不关心谁来处理,也不关心处理完会不会去开锁。它的活到扔进队列就结束了。

这跟标志位那套完全不同。标志位是"我置位,你记得来看",发的人和收的人绑在一起。事件是"我广播,谁爱理谁理",两边彻底断开。

哪天你要加一个语音播报,扫码成功就念一句。你不用去动扫码模块,只要在分发里给扫码事件多挂一个处理就行。

发事件的地方一个字不改,就能加功能。这是标志位那套做不到的。

几个容易翻车的地方

队列开太小。事件来得比处理得快,缓冲一满,新事件就丢了。宁可开大点,也别丢事件。

在中断里干重活。事件驱动的精髓就是中断只投递、主循环才处理。你要是在 push 之前先把活干完,等于白搭这套架构。

事件里塞指针。事件是要在队列里躺一会儿的,你塞个指向局部变量的指针,等主循环取出来,那块栈早被人覆盖了。要传数据,拷值进去,别传地址。

head 和 tail 不做保护。push 在中断里,pop 在主循环里,两边同时动同一组指针会出乱子。要么关中断保护那几行,要么保证只有一头写、一头读。

小结

事件驱动,就是把"发生了什么"打包成消息,用一条队列串起来,让主循环安安静静地取和派。

中断只投递,处理挪到主循环,实时性就稳了。发事件的和收事件的彻底断开,加功能就不用去动老代码。至于谁在什么时候该响应什么,交给状态机去判断。

标志位记的是"状态碎片",事件记的是"发生的事实"。前者越攒越乱,后者越用越清。

回头看看你那个主循环,还有几个互相看脸色的 flag,是时候把它们收进队列里了。你打算先从哪一个下手?


嵌入式软件架构 · 系列五篇

  • 《嵌入式软件架构(一):HAL / BSP / 驱动 / 应用,这四层到底怎么切》
  • 《嵌入式软件架构(二):事件驱动,怎么把主循环里的标志位地狱收拾干净》  (本篇)
  • 《嵌入式软件架构(三):模块之间怎么通信,别再让全局变量满天飞》
  • 《嵌入式软件架构(四):参数与配置管理,掉电不丢、改版不崩》
  • 《嵌入式软件架构(五):错误处理与故障自恢复,现场没有复位键怎么活下来》