你翻开自己的主循环,数一数里面有多少个标志位。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 / 驱动 / 应用,这四层到底怎么切》 《嵌入式软件架构(二):事件驱动,怎么把主循环里的标志位地狱收拾干净》 (本篇) 《嵌入式软件架构(三):模块之间怎么通信,别再让全局变量满天飞》 《嵌入式软件架构(四):参数与配置管理,掉电不丢、改版不崩》 《嵌入式软件架构(五):错误处理与故障自恢复,现场没有复位键怎么活下来》
夜雨聆风