嵌入式设计模式:从状态机到观察者,代码结构怎么搭才清爽

发布时间:2026/7/2 19:50:37
嵌入式设计模式:从状态机到观察者,代码结构怎么搭才清爽 写嵌入式程序的人很少会一开始就考虑设计模式这回事。毕竟MCU上资源有限RAM按KB算Flash按MB算哪有PC上那些花里胡哨的抽象继承多态。但干过三五个项目之后你会发现一个规律——代码写到后面最难维护的从来不是某个算法写不出来而是各个模块之间怎么组织、怎么通信、怎么扩展。不妨从最常用的一个模式说起。状态机不只是switch-case大部分人接触状态机都是从按键消抖开始的。但状态机真正的价值不在消抖而在——它把一个随时间变化的行为拆解成了当前状态 输入事件 → 下一个状态的确定性映射。来看一个实际的例子。假设我们要管理一个IoT设备的网络连接生命周期有这几个阶段未初始化、WiFi扫描中、正在连接、已连接、断连重试。typedef enum { NET_IDLE, NET_SCANNING, NET_CONNECTING, NET_CONNECTED, NET_RETRYING } net_state_t; typedef struct { net_state_t state; uint8_t retry_count; uint32_t last_event_time; void (*on_enter[NET_RETRYING 1])(void); } net_fsm_t;注意这里每个状态都挂了一个on_enter回调。这个设计的用意是——状态切换时自动触发进入动作而不是在事件处理的switch里到处塞代码。我们来看状态转移怎么组织void net_fsm_run(net_fsm_t *fsm, net_event_t evt) { net_state_t next fsm-state; switch (fsm-state) { case NET_IDLE: if (evt EVT_START) next NET_SCANNING; break; case NET_SCANNING: if (evt EVT_AP_FOUND) next NET_CONNECTING; else if (evt EVT_TIMEOUT) next NET_IDLE; break; case NET_CONNECTING: if (evt EVT_CONNECTED) next NET_CONNECTED; else if (evt EVT_FAILED) next NET_RETRYING; break; case NET_CONNECTED: if (evt EVT_DISCONNECTED) next NET_RETRYING; break; case NET_RETRYING: if (evt EVT_RETRY_OK) next NET_CONNECTING; else if (evt EVT_GIVE_UP) next NET_IDLE; break; } if (next ! fsm-state) { fsm-state next; if (fsm-on_enter[next]) fsm-on_enter[next](); } }这段代码的精髓在于——状态转移表是显式写在switch里的每个状态能响应哪些事件一目了然。后来改需求要加一个获取IP地址的中间状态直接在NET_CONNECTING和NET_CONNECTED之间插一个NET_DHCPING补上对应的事件处理和进入回调就行不会波及别的逻辑。这就是状态机的核心价值——把分支逻辑收敛到一处改一个状态不影响另外六个。有意思的是很多开发者一开始图省事直接把网络状态写成一堆if嵌套。三个状态以内还能撑住到五个以上那个嵌套的深度和分支的组合数看一眼就想重构。观察者模式松耦合的关键嵌入式里经常遇到这种场景一个传感器采集到了新数据好几个模块都要知道——LCD要刷新显示云端要上传本地日志要记录阈值判断要触发报警。最直接的做法是在采集完成的地方依次调用void sensor_data_ready(sensor_data_t *data) { lcd_update(data); cloud_upload(data); log_save(data); threshold_check(data); }这个写法的问题在于——采集模块和显示、上传、日志、阈值判断全都耦合在一起了。哪天要加一个数据保存到SD卡的功能得回来改采集模块的代码。哪天要关掉云上传又得回来改。观察者模式思路不同。采集模块不关心数据被谁用、怎么用它只管发布一个事件typedef struct { void (*on_data)(sensor_data_t *); struct subscriber_t *next; } subscriber_t; static subscriber_t *head NULL; void sensor_subscribe(subscriber_t *sub) { sub-next head; head sub; } void sensor_notify(sensor_data_t *data) { for (subscriber_t *p head; p; p p-next) { if (p-on_data) p-on_data(data); } }之后每个关心数据的模块各自实现一个回调函数然后在初始化阶段注册进来static void on_lcd_update(sensor_data_t *d) { /* ... */ } static void on_cloud_upload(sensor_data_t *d) { /* ... */ } subscriber_t lcd_sub { .on_data on_lcd_update }; subscriber_t cloud_sub { .on_data on_cloud_upload }; void app_init(void) { sensor_subscribe(lcd_sub); sensor_subscribe(cloud_sub); }这里写死了静态注册因为MCU上裸机代码动态分配内存总让人不放心。实际上观察者模式在嵌入式里最常用的变体就是这种链表静态变量的组合够用不引入malloc零碎开销可控。命令模式把操作变成数据回想一下你写过的串口命令解析。如果这样写void handle_uart_command(uint8_t cmd, uint8_t *args) { switch (cmd) { case CMD_SET_TEMP: set_temperature(args[0]); break; case CMD_SET_MODE: set_mode(args[0]); break; case CMD_GET_STATUS: send_status(); break; case CMD_REBOOT: system_reboot(); break; } }功能和状态机的switch看起来类似但这里有一个更本质的抽象——每个命令其实是一个可延迟执行的操作。命令模式的思想就是把一个操作封装成一个对象在C里就是一个结构体让它可以被排队、被记录、被撤销。typedef struct { uint8_t id; uint8_t args[8]; uint8_t arg_len; uint8_t priority; } command_t; #define CMD_QUEUE_SIZE 16 static command_t cmd_queue[CMD_QUEUE_SIZE]; static uint8_t head 0, tail 0; int cmd_enqueue(command_t *cmd) { uint8_t next (tail 1) % CMD_QUEUE_SIZE; if (next head) return -1; // 队列满 cmd_queue[tail] *cmd; tail next; return 0; } int cmd_dequeue(command_t *cmd) { if (head tail) return -1; *cmd cmd_queue[head]; head (head 1) % CMD_QUEUE_SIZE; return 0; }这样一来串口中断里只做一件事——把收到的命令塞进队列。主循环里再一条一条取出来执行。中断服务程序的时间降到了微秒级不会再因为执行某个耗时的命令导致丢帧。而命令表本身可以做成一张静态表格由不同模块各自注册互不干扰。这三个模式——状态机、观察者、命令队列——几乎是嵌入式项目里最通用的三种组织思路。状态机管行为观察者管通信命令队列管时序。三个组合起来一个中等复杂度的嵌入式系统代码结构就能理得相当清爽。当然设计模式不是银弹。一个几百行的传感器驱动硬套抽象层反而本末倒置。但在模块数量超过五六个、交互关系开始让人头疼的时候想一想这个状态转移能不能用状态机收拢这个通知关系能不能用观察者解耦这个时序问题能不能用命令队列缓冲——往往比硬撑着往下堆代码要省心得多。你在项目里用过哪些设计模式欢迎聊聊实际踩过的坑和摸索出的经验。