写嵌入式程序的人,很少会一开始就考虑"设计模式"这回事。毕竟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; }这样一来,串口中断里只做一件事——把收到的命令塞进队列。主循环里再一条一条取出来执行。中断服务程序的时间降到了微秒级,不会再因为执行某个耗时的命令导致丢帧。而命令表本身可以做成一张静态表格,由不同模块各自注册,互不干扰。
这三个模式——状态机、观察者、命令队列——几乎是嵌入式项目里最通用的三种组织思路。状态机管行为,观察者管通信,命令队列管时序。三个组合起来,一个中等复杂度的嵌入式系统,代码结构就能理得相当清爽。
当然,设计模式不是银弹。一个几百行的传感器驱动硬套抽象层,反而本末倒置。但在模块数量超过五六个、交互关系开始让人头疼的时候,想一想"这个状态转移能不能用状态机收拢""这个通知关系能不能用观察者解耦""这个时序问题能不能用命令队列缓冲"——往往比硬撑着往下堆代码要省心得多。
你在项目里用过哪些设计模式?欢迎聊聊实际踩过的坑和摸索出的经验。