
上周调一个串口协议栈,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、一个按键、一个温湿度传感器------你搞个抽象工厂模式给谁看?
但状态机、环形缓冲区、回调解耦这三个,不管项目大小都能用上。它们不是为了"优雅"或者"先进",而是为了让你三个月后回来看代码时不用骂自己。
最近我在想:嵌入式设计模式的核心,其实就一条------把变化的东西和不变的东西分开。中断处理是变还是不变?数据解析是变还是不变?硬件接口是变还是不变?想清楚这个,用什么模式自然就知道了。
好,我得继续改那个协议栈去了。状态机已经重写完了,跑了俩礼拜没出过问题。可惜每次都要踩坑之后才真正记住应该先画图、再写代码。