说实话,我早年做嵌入式开发时,对“设计模式”这四个字是有些排斥的。那会儿总觉得这是搞纯软件的同事才需要琢磨的事儿——我们写单片机程序,讲究的是直接、省资源、能跑就行,搞什么模式?直到有一次接手一个维护了三年多的传感器采集项目,代码逻辑像一团缠死的耳机线,改一个超限报警功能,牵扯出四五个模块的耦合关系,光看代码就看了两天。从那天起,我才意识到:嵌入式系统不是不需要设计模式,而是需要“入乡随俗”的设计模式。在资源受限、实时性敏感、硬件耦合强的环境里,模式能帮你理清结构,但用错了方式,它也能让你的系统变得比不用模式还糟。
这篇文章我想跟你聊聊,嵌入式系统里设计模式到底该怎么用、哪些模式最值得投入、哪些必须绕开,以及我在实际项目中踩过的坑和最终落地的思路。内容会尽量接地气,按工程实践来讲,而不是背设计模式教科书的定义。适合正在做嵌入式底层开发、RTOS应用开发,或者被大型C工程代码结构困扰的朋友参考。
1. 嵌入式系统的特殊约束,决定了模式必须“挑着用”
1.1 资源受限是第一道门槛
嵌入式系统和台式机、服务器最大的区别就是资源瓶颈特别明显。我经常跟刚入行的同事打比方:你把PC端的一套面向对象设计思路搬进单板机,就像让一个习惯大厨房的厨师,突然在一个只有两个灶眼的流动餐车里复刻整套后厨流程——不是不能做,但必须砍掉大量对他来说理所当然的东西。
具体来说,常见的嵌入式MCU,RAM可能只有16K到512K,Flash从几十K到几MB不等,CPU主频可能几十MHz到几百MHz。这意味着每一个设计模式的引入,都要问问自己:
- 这个模式会不会带来额外的内存开销?
- 这个模式会不会增加函数调用的层级,导致栈溢出风险?
- 这个模式是否会引入大量动态内存分配,导致堆碎片化?
- 这个模式在极端情况下(比如中断风暴、任务饱和)还能不能保证行为可预测?
我在一个项目里就见过,某位同事把PC端那套极其“正统”的观察者模式原封不动搬进了一个只有16KB RAM的低功耗采集节点。每个订阅者动态new一个节点来挂载,发布事件时用vector遍历回调。结果就是系统跑了一个多月后,在堆碎片和内存不足的双重打击下,随机死机。最后排查下来,根本不是业务逻辑问题,而是内存分配失败后的空指针操作。这件事给我的教训很深:在嵌入式里,设计模式不是拿来“秀”的,是在资源约束下平衡可维护性和可控性的工具。
提示:评估一个模式能不能用的第一步,不是看它的结构多优雅,而是先估算它在最坏情况下会吃掉多少RAM和Flash,以及是否引入了不可控的堆行为。
1.2 可预测性比“优雅”更重要
嵌入式系统很多时候要面对硬实时或软实时的要求。比如一个电机控制回路,每隔100us就要完成一次电流采样和PID计算,你说我用一个抽象工厂去创建PID控制器?光是那几层虚表(如果用了C++的话)就可能让采样窗口溢出。
所以在嵌入式领域,“这个模式最坏情况的执行时间是否可预知”是一个硬指标。状态机的分支判断、查表取函数的函数指针调用、静态注册表的线性遍历,这些结构的时间复杂度都相对固定且容易测算。反而是一些动态特性很强的方案,比如动态创建对象、运行时依赖注入,虽然扩展性好,但时序上很难做静态分析。
我个人的工程习惯是:先用裸代码把功能跑通、测出真实执行时间,再考虑怎么用模式重构。从来没有反过来“先设计一套模式框架,再把业务套进去”的做法——后者在资源受限场景里很容易把自己套死。
1.3 哪些模式在嵌入式里最值得用
根据我这些年看过的代码和自己写的代码,我大致把设计模式按“嵌入式适用度”排了个队,用一张表总结如下:
| 模式 | 适用场景举例 | 主要代价 | 嵌入式推荐度 |
|---|---|---|---|
| 状态模式 | 通信协议解析、按键消抖、任务状态流转 | 状态与事件表结构较多,但内存可控 | 极高 |
| 观察者模式 | 传感器超限事件、按键事件分发、多模块联动 | 回调注册表占用内存,需避免动态分配 | 高 |
| 命令模式 | 串口指令解析、菜单操作、运动控制指令排队 | 命令表结构清晰,资源开销中等 | 高 |
| 单例模式 | 外设驱动如UART、ADC、定时器的全局管理 | C语言中无天然语法支持,需约束 | 中高 |
| 工厂模式 | 根据设备类型创建不同传感器驱动 | 可能会引入函数指针表/结构体操作 | 中 |
| 策略模式 | 选择不同滤波算法、不同通信协议 | 函数指针调用开销小,但接口设计需仔细 | 中 |
| 观察者(动态订阅) | PC端迁移来的一对多通知场景 | 堆动态分配风险高 | 低 |
| 装饰器模式 | 数据流叠加处理如在原始数据上包封包 | 层数多了后RAM和调用开销不可控 | 低 |
这里再强调一句:模式是一种思维,不是一种代码模板。在嵌入式里,用C语言实现模式太常见了,函数指针、结构体、回调机制,已经能覆盖绝大部分需求,不是非要用C++。
2. 嵌入式最实用的四个模式,从原理到关键细节拆解
2.1 状态模式:状态机是嵌入式软件的地基
状态模式是我在嵌入式项目里用的最多的模式,没有之一。为什么?因为嵌入式设备的运行逻辑本质上一堆状态在转移:待机、启动、运行、告警、休眠,系统每个时刻都处于某一个状态,并根据事件跳转。用状态机来理解和表达这种逻辑,比用一堆if-else叠加flag要清晰得多。
在C语言里,我习惯用一个结构体来表示状态机的核心:
typedef struct { uint8_t current_state; const StateTable_t *table; // 状态动作表 uint32_t event_flag; // 事件标志 void *context; // 上下文数据,用来传递用户数据 } StateMachine_t; typedef struct { uint8_t state; uint16_t event; void (*action)(StateMachine_t *sm, void *param); uint8_t next_state; } StateTable_t;这种结构其实就是一个状态转移表,每一行表示“当前状态 + 发生的事件 → 执行动作 → 跳到下一个状态”。用查表代替一长串switch-case,好处有两个:
- 第一,新增一个状态或事件时,只需要往表格里增加一行,不需要动主调度逻辑,状态机的核心代码几乎不变。
- 第二,表格本身可以单独放在Flash里(用const修饰),不占RAM,对于Flash大RAM小的MCU来说非常友善。
我用状态机做过最典型的东西是按键消抖。逻辑不算复杂:按下按键之后,不能立刻确认有效,而是要等消抖时间(比如20ms)后确认电平稳定,最后还在按键释放之后执行一次“短按/长按”的事件区分。用if-else写这玩意儿也能写,但状态一多,尤其是长按、短按、连击这些场景混在一起时,代码很快就变成一团乱麻。而用状态机的按键识别模块,有以下状态:
- 按键空闲
- 按下检测(等待消抖稳定)
- 按键按下(滤波后的稳定阶段)
- 长按触发等待
- 释放
每个状态收到的定时扫描事件、按下事件、释放事件都有明确的转移表。这样不仅代码清晰,而且测试也有抓手——输入一串模拟的按键事件序列,对比状态转移是否符合预期,就能完成自动化测试。
注意:状态机最忌讳的是一个状态里的“动作”写得太长。动作最好只做几件事:读取输入、简单判断、置标志位、切换状态。复杂计算要放到任务循环中处理,不能阻塞在状态机里,否则实时性会被拖垮。
2.2 观察者模式:让事件源不再关心谁在听
观察者模式的核心思想很简单:当一个事件发生时,所有关注这个事件的对象/模块都会收到通知,而事件源不需要知道通知的具体接收方是谁。你在公众号写文章,读者订阅你的号,后台发布后系统自动推送给订阅的人——这就是观察者模式的日常理解。
在嵌入式里,它的典型应用场景是:传感器检测到数值越限,需要同时通知报警模块、显示模块和通信模块上报。如果直接让传感器模块去调用三个模块的函数,那么传感器模块就和业务逻辑强耦合了,下次再加一个云上报模块,又得回头改传感器代码。观察者模式就是为了解决这种“一对多通知”问题。
我推荐的嵌入式C实现方式,不是动态订阅,而是静态注册表。具体来说,维护一个全局的回调函数指针数组:
#define MAX_OBSERVERS 8 typedef void (*ObserverCallback_t)(uint32_t event_id, void *params); typedef struct { int8_t count; ObserverCallback_t callbacks[MAX_OBSERVERS]; } ObserverRegistry_t; void observer_register(ObserverCallback_t cb); void observer_notify(uint32_t event_id, void *params);每次有事件发生,调用observer_notify遍历回调数组,逐个通知。所有观察者在系统初始化阶段通过observer_register把自己挂上去。这样设计之后,事件源(比如ADC采集模块)就完全不认识报警模块、显示模块和通信模块了,它只需要发布事件即可。
这个方案在实际运行中非常稳定,因为:
- 没有动态内存分配,数组大小在编译期就定下来了;
- 遍历时间上限固定,就是MAX_OBSERVERS次函数调用;
- 调试时可以打印观察者注册表,一目了然。
不过有一个痛点要注意:回调函数运行在什么上下文里?如果是中断里触发的通知,那回调函数就必须有“只置标志、不做重活”的觉悟。中断里直接跑一个耗时的显示刷新逻辑,那是给自己埋雷。我的习惯是:中断里只把事件记录到队列,真正的通知在任务级(或主循环)里处理。
2.3 命令模式:把操作变成数据
命令模式的本质是“把一段操作封装成一个对象”,这样你就可以把操作排队、撤销、记录日志。就像你在餐厅点单,你不直接跟厨师说“放盐放糖放辣”,而是把写好的菜单交给服务员,厨房按单子出菜。
在嵌入式设备里,一个特别典型的场景就是上位机通过串口下发指令。假设设备支持几十条指令,比如查询设备状态、修改传感器报警阈值、启动校准、升级固件等。一个最朴素的写法是用一个巨大的switch-case根据指令码分发,但指令越来越多时,这个函数就会变得又臭又长,而且每次加指令都要小心翼翼地改动它。
命令模式的C语言做法,是用一张命令表:
typedef struct { const char *cmd_name; uint8_t cmd_code; int (*handler)(char *args, uint8_t len, Response_t *resp); } CommandEntry_t; static const CommandEntry_t cmd_table[] = { { "STATUS", 0x01, handle_query_status }, { "SETALM", 0x02, handle_set_alarm }, { "CALIB", 0x03, handle_calibration }, // ... };上位机发来一帧数据后,解析层把操作码提取出来,遍历这张表找到对应项,调用同一签名的处理函数。新加一条指令,就是往表里增加一行,再新增一个处理函数,和原有代码基本是隔离的。
这种模式的另一个好处是便于做“OCP”(开闭原则,对扩展开放、对修改关闭)。扩展不需要改核心流程。同时,处理函数的签名统一,非常方便做模拟测试——直接构造参数、调用函数,检查返回的Response结构体即可。
如果系统里用到了服务调度器(比如多个仪器同时排队执行测量任务),每条命令还可以进一步封装成任务节点挂在队列里,这就把“解析并执行”变成了“解析→入队→按序执行”,这也是命令模式在嵌入式里的进阶用法。
2.4 单例模式与工厂模式:硬件访问接口的规整方式
单例模式的意思很直接:确保某个类只有一个实例,并提供一个全局访问点。对于嵌入式开发者来说,每个外设基本上都是物理上唯一的,UART0只有一个,ADC外设只有一套寄存器。这时如果代码里多个模块都直接操作这一套寄存器,很容易出现配置混乱、相互覆盖的问题。比较好的做法是把设备驱动封装成“单例”式的接口,所有访问都通过同一个驱动入口。
在C语言里没有private、static类成员这些机制,但可以通过访问控制头文件来模拟——把设备内部寄存器定义放在C文件里(对外不可见),只暴露有限的接口函数。外部模块调接口,永远不能直接碰到寄存器。这样至少保证了访问入口的唯一性,也避免了一个模块偷偷改动另一个模块正在用的配置。
工厂模式在嵌入式里用得相对少一些,但在“一套代码适配多种硬件型号”的项目里很有用。比如一个主板设计要兼容两种不同品牌的温湿度传感器,它们的I2C地址、初始化序列、数据格式都不同。直接在主逻辑里写if-else判断当前是哪个型号,代码会散落得到处都是。用工厂模式的话,提供一个“传感器创建函数”,根据编译期的宏或运行时的ID返回对应的驱动接口结构体(一个函数指针表),上层统一按同一套接口操作。
typedef struct { int (*init)(void); int (*read_temperature)(float *temp); int (*read_humidity)(float *humi); } TempHumiDriver_t; const TempHumiDriver_t *temp_humi_driver_create(uint8_t sensor_type);上层代码只和结构体指针打交道,不用关心具体是哪颗传感器。换传感器时,只需要新增驱动实现,然后修改工厂函数里的映射关系。这个模式在Bootloader、多平台适配、板级支持包里特别有价值。
3. 一个数据采集模块的模式协同实战
3.1 模拟项目需求与设计约束
为了把上面的理论落到地上,我拿一个典型的模拟项目来完整过一遍设计过程。假设我们要做一个环境数据采集模块(就叫“模拟项目X”吧),需求如下:
- 采集两路模拟量(例如温湿度和一个外部变送器的4-20mA信号);
- 对采集值做滑动平均滤波;
- 当采集值超过用户设定的报警阈值时,本地LED闪烁报警并向上位机发送报警消息;
- 用户通过串口上位机可查询实时数据、修改报警阈值、启动或停止设备;
- 设备支持一个物理按键,短按切换显示模式(温度优先/湿度优先/交替显示),长按恢复出厂设置。
硬件估算资源:MCU主频72MHz,RAM 32KB,Flash 128KB,外部无大容量存储,运行RTOS。设计上有一个硬要求:系统初始化后原则上不进行动态内存分配,所有对象和队列在编译期静态分配好。
这个需求如果平铺直叙地用裸码写,大概可以写,但后续要新增传感器通道、新增指令时一定会疼。所以我在设计阶段就决定,要把几个模式组合起来。
3.2 模式选型与模块划分
我按上面那种“先想清楚结构再动笔”的思路,把系统划分成了下面几块:
- 采集任务(ADC轮询 + 滑动滤波):这里用一个简单的封装结构表示采集通道,暂时不需要工厂模式,因为通道数是固定的。
- 报警联动:采用观察者模式。采样任务发布“温度超限”“湿度超限”“恢复正常”三类事件,LED控制和通信上报订阅这些事件。
- 按键动作:用状态机实现短按切换、长按恢复。同时,切屏触发事件,由显示模块响应。
- 串口指令:采用命令模式。解析上位机帧,通过命令表分发到具体处理函数。
- 设备系统状态(运行/待机/配置态):整体上有一个大状态机,挂在RTOS下的一个管理任务里。
这样划分之后,每个模块的职责非常单一,模块之间的依赖变成了明确定义的事件和接口,而不是代码层面的纠缠。
3.3 核心代码结构的落地示例
下面我把几个核心环节的代码结构简化写一下,大家感受一下这种组合方式。
先看报警观察者的注册。系统初始化时:
observer_register(alarm_led_on_event); // LED模块注册 observer_register(upload_alarm_on_event); // 通信模块注册采集任务每次完成一轮ADC采样和滤波,就检查是否越限:
static void check_alarm_condition(void) { if (filtered_temp > alarm_config.temp_limit) { observer_notify(EVENT_TEMP_OVER_LIMIT, &filtered_temp); } }报警的LED模块和通信模块各自实现自己的回调,互不相扰:
static void alarm_led_on_event(uint32_t event_id, void *params) { if (event_id == EVENT_TEMP_OVER_LIMIT) { led_set_blink_pattern(LED_PATTERN_FAST_BLINK); } } static void upload_alarm_on_event(uint32_t event_id, void *params) { if (event_id == EVENT_TEMP_OVER_LIMIT) { build_alarm_frame(FRAME_TYPE_TEMP_ALARM, params); uart_send_frame(); } }再看按键状态机。键盘扫描任务每次获取到按键原始事件(按下、释放、持续按住)后,喂给状态机的处理函数:
static void key_state_machine_on_event(KeyEvent_t ev) { uint16_t idx; // 查状态转移表 for (idx = 0; idx < table_size; idx++) { if (key_sm.current_state == key_table[idx].state && ev == key_table[idx].event) { key_table[idx].action(&key_sm, ev); key_sm.current_state = key_table[idx].next_state; break; } } }短按事件产生后,状态机的动作函数里会调用observer_notify(EVENT_KEY_SHORT_PRESS, NULL),显示模块收到通知去切换屏幕;长按事件则通知恢复出厂配置模块。按键模块本身完全不关心屏幕上显示的是什么,这就是状态机+观察者的组合妙处。
串口指令部分,解析流程收到完整帧后:
static int dispatch_cmd(CommandFrame_t *frame) { for (uint16_t i = 0; i < ARRAY_SIZE(cmd_table); i++) { if (cmd_table[i].cmd_code == frame->cmd_code) { return cmd_table[i].handler(frame->data, frame->len, &frame->resp); } } return CMD_CODE_UNKNOWN; }所有上位机指令都走这同一个分发函数,新增指令时不需要改这个函数本身,这就能让改动范围控制到最小。
3.4 这套结构在真实维护中的收益
我在实际工程中深刻体会到,模式组合带来的收益不是体现在第一版开发的速度上,而是体现在两类场景里。
第一类是加功能。比如,项目已经稳定运行了一段时间,突然提出要增加一个云平台上报模块,也要关注温湿度超限报警。如果没有观察者模式,我就得在采集任务里再加一行调用云上报模块的函数,云上报模块直接和采集模块耦合在一起。而有了观察者事件机制,新模块只需要初始化时注册一下自己的回调,采集任务、原有的报警业务逻辑一行都不用改。
第二类是修Bug。如果没有状态机和命令表,查一个按键逻辑的Bug,可能需要理顺一大片if-else嵌套。而在状态机里,打印当前state和事件序号,再看表里该跳哪一行,问题往往几分钟就能定位。命令表同理,看到指令码和参数就能直接定位到handler函数,不需要在主分发函数里来回翻。
这也是我一直跟团队强调的:架构的收益,是在“有人要改代码”的时候才体现出来的。
4. 常见问题排查与避坑经验
4.1 问题一:动态内存分配搞垮了你的模式
这是嵌入式里最容易踩的坑,而且往往出问题时机非常“巧妙”——运行几天甚至几周之后才随机崩溃。原因通常是堆碎片化或分配失败后没有正确处理。
我自己在早期做一套多传感器系统时,用过动态注册观察者节点的方案,当时觉得扩展性好。结果随着传感器数量增加,业务模块频繁注册和注销,堆里留下大量不连续的小空闲块。最终某次注册分配失败,返回空指针,后续直接对空指针解引用,系统跑飞。
现在的解决方法是:
- 核心数据结构全部静态分配。上面说的观察者数组、命令表、状态表,全部在编译期定义好大小。
- 禁止在事件处理流程里做任何malloc/free操作,内存申请只允许发生在初始化阶段。
- 如果实在需要动态分配(比如协议栈里),就使用“固定大小内存块池”而不是堆。分配固定块、不产生碎片,速度也可控。
注意:RTOS自带的堆管理也是不可预测的。可以给特定任务和模块划独立的内存池,让每个模块用自己池里的内存,出问题也容易定位是谁吃光了池子。
4.2 问题二:回调泛滥之后,代码跳来跳去难调试
观察者模式、命令模式用多了,一个明显副作用就是调用堆栈不再是一条直线。A调用B,B触发通知,最后执行C和D,你从日志里看到C和D的执行顺序,但很难一眼看出是谁调用notification触发的。
这个问题几乎每个用回调的嵌入式工程师都会遇到。我的排查思路是:
- 在ObserverNotity和CommandHandler入口加调试日志打印event_id和handler名称。不要小看这两行日志,问题定位起来效率翻倍。
- 用编译期宏控制调试输出级别,上线版本关闭详细日志即可,不会造成性能负担。
- 如果系统和调试器支持,直接在中断或异常钩子里打印当前函数返回地址(__builtin_return_address),然后对照ELF文件里的符号表转成函数名。这个方法比反复猜测快得多。
4.3 问题三:模式用对了,但实时性还是挂了
结构清晰不等于性能够用。我在一个实时性要求较高的应用里试过在中断服务程序里直接调用观察者通知,结果某个回调里执行了一段Flash擦写操作(虽然这个操作不该出现在中断服务里),导致中断响应时间严重超标,系统抖动明显。
我后来强制约定,中断里只做三件事:读取硬件状态、清除中断标志、把事件写入队列。所有通知和模式逻辑都在任务上下文里执行。任务里的最大执行时间可以通过静态分析大概估算出来,这样实时性才有保障。
如果必须在中断里触发非常轻量的行为(比如翻转一个IO口),那也可以直接调用一个短小且不堵塞的通知函数,但要严格遵守回调内部“不等待、不循环、不做耗时操作”的原则。
4.4 问题四:为了模式而模式,导致过度设计
我见过一些项目,模块还不到一万行代码,就已经抽象出四层结构,接口函数命名极其优雅,但代码根本没法维护——因为每一层薄得没有实质内容,反而把简单的数据流转拆成了七八个函数。
设计模式不是越洋越好。一个简单规则是:如果某个模式让代码结构变得比原来更复杂,而且不能明确带来可扩展性或可测性的提升,那就不要用。比如只有两个模块需要响应同一个事件,直接调用一下就好,没必要注册成观察者。对于嵌入式这类资源受限、实时性敏感的系统,“最简单且能工作的方案”通常就是最优方案。
另外,我个人的偏好是“先跑通再重构”。第一版先把功能实现出来,哪怕代码很直白。功能稳定后,选择一个明确痛点(比如某个模块频繁改动),再引入对应模式去优化。这样每次引入一个模式,解决一个真实痛点,架构才是在实战中打磨出来的,而不是拍脑袋套出来的。
5. 从工程实践的角度说几句收尾的话
做嵌入式这些年,我越来越觉得,设计模式不是一种“高端”的装饰,而是一种提前规避混乱的工程手段。用过状态机之后,我再也不想用flag和if-else堆出来的按键逻辑;用过命令表之后,我再也不想在一个巨大的switch-case里翻找指令分支。但这不代表模式是银弹——资源约束、实时性、调试难度,每一个约束都可能让某种模式失效。
如果你刚起步,我的建议很简单:先把状态机和观察者模式用熟,它们解决的是嵌入式代码里出现频率最高的两大问题——复杂状态流转和一对多通知。然后试着把上位机指令解析改成命令表驱动,你很快会感受到“加指令不动主逻辑”有多香。
模式用得好,代码会变成一种资产:越改越清晰,后续接手的人不会想骂人。模式用得不好,代码会变成负债:表面结构分明,实际处处藕断丝连。我在项目里踩过的那些跟内存、实时性和过度抽象有关的坑,希望能帮你少走一段路。这些经验说到底,都是时间和Debug调出来的。