聊一聊嵌入式里的设计模式——我被状态机坑过的那些事

上周调一个串口协议栈,debug了三天,最后发现是我自己把状态机写成了意大利面条------一个case分支里嵌套了三层if-else,还顺手改了全局状态变量。说真的,那一刻我想把键盘吃了。

但这其实不怪我。刚做嵌入式那会儿,谁教过设计模式啊?大学课本就讲单片机IO口怎么配,中断怎么开,到了项目里就开始堆功能。今天我聊聊几个真正有用的嵌入式设计模式,都是自己踩坑踩出来的。

状态机:别再用全局flag硬堆了

之前做一台小设备,就是个按键切换显示模式+长按进入配置的事情。第一版我写了个flag大法:

复制代码
uint8_t mode = 0;
uint8_t config_flag = 0;
uint8_t long_press_detected = 0;
uint8_t display_mode = 0;
uint8_t button_state = 0;
// ... 二十几个全局变量

然后在按键中断里就是 if (mode == 0) {...} else if (mode == 1) {...},config_flag 和 long_press_detected 互相影响------长按检测到后想进配置模式,结果mode已经变了但config_flag还卡在上一个状态,mode切着切着就飞到不知道哪里去了。

后来老老实实画了状态转移图------就一张纸,四个状态,五条箭头,十分钟画完。然后用了最朴素的状态机实现:

复制代码
typedef enum {
    STATE_IDLE,
    STATE_DISPLAY,
    STATE_CONFIG,
    STATE_SLEEP
} system_state_t;

typedef struct {
    system_state_t current;
    void (*on_enter)(void);
    void (*on_exit)(void);
    system_state_t (*on_event)(uint32_t event, void *param);
} state_t;

每个状态自己管自己的入口、出口和事件响应。按键按下就发一个event丢进去,state machine dispatch 到对应状态去处理。代码量从400行缩到150行,bug归零。有人说状态机小题大做,我只能说------你是没被自己的全局flag坑过。

环形缓冲区:中断和主循环的和平协议

做UART接收不定长数据的时候,很多人在中断里直接处理。我也干过------结果就是中断里调了一个慢悠悠的parse函数,整个系统响应卡成幻灯片。

后来学到一招:中断只管把数据往里扔,主循环慢慢取。环形的设计天然解决了覆盖的问题。

复制代码
typedef struct {
    uint8_t *buffer;
    uint32_t head;
    uint32_t tail;
    uint32_t size;
} ring_buffer_t;

bool rb_write(ring_buffer_t *rb, uint8_t data) {
    uint32_t next = (rb->head + 1) % rb->size;
    if (next == rb->tail) return false;
    rb->buffer[rb->head] = data;
    rb->head = next;
    return true;
}

bool rb_read(ring_buffer_t *rb, uint8_t *data) {
    if (rb->head == rb->tail) return false;
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % rb->size;
    return true;
}

中断里调rb_write,主循环里调rb_read。两者之间不需要任何锁------只要保证单生产者单消费者,head和tail各写各的,天然安全。

我第一次用这个方案的时候还怀疑过:这么简单能有啥用?后来发现一个4KB的ring buffer能让系统同时处理GPS NMEA数据、蓝牙AT指令和调试日志输出,三个流互不干扰。从那以后UART RX中断里我从不处理数据,只管往buffer里塞。中断要快、要短,这是铁律,ring buffer就是帮你遵守这条铁律的工具。

回调函数与分层解耦

之前做一个项目,LCD驱动直接调了应用层的menu_draw()。结果客户说要换一块分辨率不同的屏幕------menu_draw的接口改了,驱动层也得跟着改。耦合得像热熔胶粘的两块板子,掰都掰不开。

后来改成这样:

复制代码
typedef struct {
    void (*init)(void);
    void (*draw_pixel)(uint16_t x, uint16_t y, uint32_t color);
    void (*fill_rect)(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint32_t color);
    void (*display_on)(void);
    void (*display_off)(void);
} lcd_driver_t;

extern const lcd_driver_t *g_lcd;

应用层只管调 g_lcd->draw_pixel(x, y, color),具体怎么画是驱动层的事。换屏幕?重新实现那五个函数指针就行。应用层一行不改。

说实话这个思想大学C语言课上就学过------qsort参数里不就有函数指针吗?但学是一回事,真在项目里用是另一回事。我第一次用的时候还特意翻了半天书确认语法,生怕函数指针写错了崩整个系统。

少即是多

我不喜欢那种"上个项目用XX设计模式重构了代码架构"的宏大叙事。很多小项目根本不需要什么模式。三个LED、一个按键、一个温湿度传感器------你搞个抽象工厂模式给谁看?

但状态机、环形缓冲区、回调解耦这三个,不管项目大小都能用上。它们不是为了"优雅"或者"先进",而是为了让你三个月后回来看代码时不用骂自己。

最近我在想:嵌入式设计模式的核心,其实就一条------把变化的东西和不变的东西分开。中断处理是变还是不变?数据解析是变还是不变?硬件接口是变还是不变?想清楚这个,用什么模式自然就知道了。

好,我得继续改那个协议栈去了。状态机已经重写完了,跑了俩礼拜没出过问题。可惜每次都要踩坑之后才真正记住应该先画图、再写代码。