嵌入式开发中,最常用的设计模式其实就这几个
嵌入式开发中,最常用的设计模式其实就这几个
提到“23 种设计模式”,很多人的第一反应可能是《设计模式》那本经典书里的各种 UML 图、类继承、对象关系。
但如果你主要做的是 STM32、FreeRTOS、驱动开发、物联网设备、控制系统,实际工作中会发现一件很有意思的事情:
23 种设计模式并不会平均出现。
有些模式在嵌入式项目里非常常见,甚至每天都在用;有些模式可能工作几年都碰不到一次。
更重要的是,嵌入式里的设计模式往往不会长成教科书里的样子。
比如函数指针、回调函数、状态机、统一驱动接口、消息队列,这些看起来只是普通的 C 语言写法,背后其实已经包含了不少设计模式的思想。
这篇文章不打算把 23 种模式全部讲一遍,而是聊一聊:
嵌入式开发中,真正比较常用的设计模式有哪些,以及它们通常用在什么地方。
一、先说结论
如果按照嵌入式项目里的实用程度来看,我个人会优先关注下面几个:
| 设计模式 | 常见程度 | 常见场景 |
|---|---|---|
| State 状态模式 | ★★★★★ | 状态机、通信流程、设备工作模式 |
| Strategy 策略模式 | ★★★★★ | 算法切换、控制策略、协议切换 |
| Observer 观察者模式 | ★★★★★ | 事件通知、回调、消息分发 |
| Factory 工厂模式 | ★★★★★ | 驱动创建、多硬件型号适配 |
| Adapter 适配器模式 | ★★★★☆ | HAL/BSP、跨平台接口统一 |
| Facade 外观模式 | ★★★★☆ | 复杂模块对外提供简单接口 |
| Command 命令模式 | ★★★★☆ | 消息队列、CLI、设备控制 |
| Singleton 单例模式 | ★★★★☆ | 系统管理器、日志、配置管理 |
如果时间有限,不需要一上来就背完 23 种。
先把这几个搞懂,已经可以解决嵌入式项目里很大一部分架构问题。
1. State:状态模式
状态模式应该算是嵌入式开发里最容易遇到的一种模式。
原因很简单:
嵌入式设备本身就是一个不断切换状态的系统。
比如一个蓝牙设备,可能经历:
关机
↓
初始化
↓
广播
↓
连接
↓
工作
↓
休眠一个充电设备可能是:
待机
↓
检测
↓
充电
↓
充满
↓
故障一个电机控制系统可能又是:
停止
↓
启动
↓
运行
↓
制动
↓
故障刚开始写的时候,我们通常会直接使用 switch-case:
switch (state)
{
case STATE_IDLE:
break;
case STATE_RUNNING:
break;
case STATE_ERROR:
break;
default:
break;
}这种写法本身没有问题。
实际上,简单状态机用 switch-case 往往还是最合适的。
问题一般出现在项目越来越复杂以后。
比如每个状态里面又开始判断:
if (...)
{
if (...)
{
switch (...)
{
...
}
}
}状态越来越多,状态之间的跳转也越来越乱,最后就会变成一个几百行甚至几千行的大函数。
这时候就可以考虑把每个状态拆开。
例如:
typedef struct
{
void (*enter)(void);
void (*run)(void);
void (*exit)(void);
} State;不同状态分别实现:
IdleState
RunningState
SleepState
ErrorState状态切换的时候,不再让所有逻辑挤在一个 switch 里面。
这样做最大的好处不是“代码看起来高级”,而是:
某一个状态的代码,只负责处理这个状态自己的事情。
对于通信协议、充电流程、电机控制、蓝牙连接、网络连接这些场景来说,非常实用。
所以如果让我推荐嵌入式开发最值得先学的设计模式,我会把 State 放在第一位。
2. Strategy:策略模式
策略模式听起来比较抽象,其实我们平时经常在用。
假设现在有一个传感器数据滤波接口:
float Filter_Update(float input);但不同产品可能使用不同算法:
滑动平均
低通滤波
中值滤波
卡尔曼滤波如果直接写:
if (filter_type == FILTER_AVERAGE)
{
...
}
else if (filter_type == FILTER_KALMAN)
{
...
}算法一多,代码很快就会乱。
这时候可以把不同算法统一成同一个接口。
例如:
typedef struct
{
float (*update)(float input);
} FilterStrategy;然后不同滤波算法分别实现自己的 update()。
上层只需要知道:
filter->update(data);至于当前到底是滑动平均还是卡尔曼滤波,上层不关心。
这就是策略模式最核心的思想:
把“做什么”固定下来,把“具体怎么做”换掉。
嵌入式里面常见的应用非常多,比如:
PID 参数策略
滤波算法
充电策略
电机控制算法
CRC 算法
编码算法
功耗策略
通信重试策略尤其是在 C 语言里面,函数指针就是实现策略模式最常见的方式之一。
很多时候你已经在用了,只是没有把它叫做 Strategy。
3. Observer:观察者模式
如果你的项目里面大量使用:
回调函数
事件通知
消息订阅
事件总线那么你其实已经接触到观察者模式了。
举个简单的例子。
温度传感器检测到温度变化以后,可能有几个模块都需要知道:
温度传感器
↓
温度变化事件
↓
┌───┼────┐
↓ ↓ ↓
LCD 日志 报警最直接的写法可能是:
LCD_UpdateTemperature(temp);
Log_SaveTemperature(temp);
Alarm_CheckTemperature(temp);刚开始没什么问题。
但是以后又增加:
蓝牙上报
云端上传
数据存储
风扇控制传感器模块就会越来越臃肿。
更麻烦的是,传感器模块开始依赖一堆完全不应该关心的东西。
一个更好的方式是:
typedef void (*EventCallback)(int event, void *data);各个模块自己订阅事件。
温度变化以后,传感器模块只负责:
Event_Publish(EVENT_TEMP_CHANGED, &temperature);到底哪些模块处理这个事件,由事件系统负责。
这样:
Sensor
↓
Event Manager
↓
┌───┬────┬─────┐
UI Log Alarm BLE传感器模块和其他模块之间就解耦了。
这种思想在嵌入式里非常常见,特别是:
FreeRTOS
GUI
物联网设备
传感器系统
事件驱动系统所以平时看到“回调函数”,不要只把它理解成一种 C 语言技巧。
很多时候它背后的设计思想就是 Observer。
4. Factory:工厂模式
工厂模式在驱动层特别有用。
比如一个产品可能因为成本、缺货或者不同版本,使用不同的温度传感器:
SHT30
TMP117
DS18B20但业务层其实并不想知道下面到底是哪一颗芯片。
业务层只希望:
sensor->init();
sensor->read(&temperature);于是可以先定义一个统一接口:
typedef struct
{
int (*init)(void);
int (*read)(float *value);
} TempSensor;然后提供:
TempSensor *Sensor_Create(SensorType type);初始化时:
sensor = Sensor_Create(config.sensor_type);后面的业务代码就统一了:
sensor->init();
sensor->read(&temperature);这类设计特别适合:
多型号产品
多供应商器件
硬件版本切换
驱动框架
BSP
HAL它解决的核心问题其实很简单:
谁负责创建对象或者驱动实例?
不要让业务层到处写:
if (board == ...)
{
...
}
else if (board == ...)
{
...
}把创建过程集中起来,项目会干净很多。
5. Adapter:适配器模式
做过跨平台或者 BSP 的同学,对这个模式应该非常熟悉。
比如发送 UART 数据。
STM32 可能是:
HAL_UART_Transmit(...);ESP32 可能是:
uart_write_bytes(...);Linux 下可能直接是:
write(...);如果业务代码直接调用这些接口,那么项目和具体平台就绑定死了。
一种常见做法是自己定义接口:
int UART_Send(uint8_t *data, uint16_t len);然后不同平台分别实现。
Application
↓
UART Interface
↓
┌──────┬──────┬──────┐
STM32 ESP32 Linux从设计模式的角度看,这其实就是 Adapter。
适配器模式的作用就是:
外面的接口保持不变,里面帮你适配不同实现。
这个模式在嵌入式项目里的价值非常大。
特别是你需要:
更换 MCU
更换 SDK
切换 RTOS
跨平台运行
适配第三方驱动的时候。
一个 BSP 设计得好不好,很大程度上就是看上层代码到底需要知道多少底层细节。
6. Facade:外观模式
Facade 这个名字有点抽象,但实际很好理解。
比如现在有一个 Wi-Fi 模块。
它内部可能包含:
UART 驱动
AT 指令
字符串解析
超时检测
重试机制
状态机
Socket 管理如果这些接口全部暴露给业务层,业务代码很快就会变成:
AT_Send(...);
AT_WaitResponse(...);
UART_Clear(...);
Timer_Start(...);
Parser_Reset(...);这样的问题很明显:
上层知道得太多了。
更合理的方式是给业务层几个简单接口:
Wifi_Init();
Wifi_Connect(ssid, password);
Wifi_Send(data, len);至于内部怎么发 AT 指令、怎么超时、怎么重连,上层根本不用知道。
结构就变成:
Application
↓
WiFi API
↓
┌─────────────┐
UART
AT Parser
Timer
State Machine
Socket
└─────────────┘这里的 WiFi API 就可以理解成 Facade。
一句话理解:
Facade 就是给复杂系统套一层简单好用的外壳。
嵌入式模块设计里面,这个思想非常重要。
好的模块应该尽量做到:
外面看起来简单,复杂度藏在里面。
7. Command:命令模式
如果项目里面使用 FreeRTOS 消息队列,Command 模式也非常常见。
例如:
UART Task
BLE Task
CAN Task
↓
消息队列
↓
Control Task消息可能是:
typedef enum
{
CMD_START,
CMD_STOP,
CMD_SET_SPEED,
CMD_RESET
} CommandType;这种其实已经有一点 Command 的味道了。
进一步还可以变成:
typedef struct
{
void (*execute)(void *arg);
void *arg;
} Command;消息队列收到 Command 后直接:
cmd.execute(cmd.arg);这样做之后,发送者只负责发送命令,不需要知道命令最终是怎么执行的。
这个模式在下面这些地方比较常见:
FreeRTOS Queue
CLI 命令
AT 命令
CAN 指令
设备控制
任务调度所以做 RTOS 项目的话,Command 很值得理解。
8. Singleton:单例模式
单例模式也是一个经常出现的模式。
比如系统里面可能只有一个:
日志管理器
配置管理器
系统管理器
设备管理器
看门狗管理器C 语言里面经常这样写:
static DeviceManager manager;然后通过函数获取:
DeviceManager *DeviceManager_GetInstance(void)
{
return &manager;
}本质上,这已经是一种 Singleton 的实现方式。
不过这个模式需要特别注意。
因为单例用多了,很容易变成:
Singleton
↓
全局变量
↓
到处访问
↓
模块耦合最后每个模块都能随便访问所有东西。
这种项目维护起来会非常痛苦。
所以单例不是不能用,而是要问自己一句:
这个对象真的必须全局唯一吗?
如果只是为了“调用方便”,那最好再想想。
设计模式不是为了写得“高级”
刚开始学习设计模式的时候,很容易犯一个错误:
看到什么都想套模式。
比如原来十几行能解决的问题,最后写成:
接口
抽象层
工厂
策略
适配器
管理器绕了几层以后,自己过一个月都不一定看得懂。
这并不是一个好设计。
设计模式本质上只是前人总结出来的:
一些经常出现的软件设计问题,以及比较成熟的解决思路。
项目简单的时候:
switch-case完全可以。
项目只有一种硬件的时候,也不一定非要搞工厂。
一个函数就能解决的事情,也没必要强行定义一堆结构体和函数指针。
真正适合引入设计模式的时候,通常是你开始遇到这些问题:
代码改一个地方,很多模块都跟着改
状态越来越多,switch 已经控制不住
换一颗芯片,整个业务层都要修改
增加一种算法,到处增加 if-else
一个模块依赖了十几个其他模块
模块越来越难单独测试这个时候,设计模式的价值就体现出来了。
嵌入式里的设计模式,很多时候没有“标准长相”
这点很重要。
因为 GoF 设计模式最早更多是面向对象背景,而大量嵌入式项目使用的还是 C 语言。
所以你不一定会看到:
class
inherit
virtual
interface但设计思想是一样的。
例如:
| 嵌入式代码里的写法 | 背后的设计思想 |
|---|---|
| 函数指针 | Strategy |
| 回调函数 | Observer |
| ops 结构体 | Strategy / Bridge |
| 状态机 | State |
| BSP 统一接口 | Adapter |
xxx_create() | Factory |
| 模块统一 API | Facade |
| 消息队列 + Handler | Command |
| static 全局实例 | Singleton |
所以学习设计模式的时候,不要死记 UML。
真正应该去理解的是:
它到底解决什么问题。
如果只学几个,建议按这个顺序
如果主要做:
- STM32
- C 语言
- FreeRTOS
- MCU 驱动
- 物联网设备
我会建议按照下面的顺序学习:
State
↓
Strategy
↓
Observer
↓
Factory
↓
Adapter
↓
Facade
↓
Command
↓
Singleton其中前几个尤其重要。
State
解决:
不同状态下,系统应该做什么?
Strategy
解决:
同一个功能,有多种不同实现怎么办?
Observer
解决:
一个事件发生后,怎么通知多个模块?
Factory
解决:
不同设备、不同驱动实例应该怎么创建?
Adapter
解决:
不同平台接口不一样,怎么统一?
Facade
解决:
底层很复杂,怎么给上层提供简单接口?
Command
解决:
怎么把“谁发命令”和“谁执行命令”分开?
把这几个问题想明白以后,设计模式就不会那么抽象了。
最后
对于嵌入式开发来说,真正重要的并不是:
我会不会 23 种设计模式?
而是看到一段越来越复杂的代码时,你能不能意识到:
这里其实是在处理状态
这里其实是在切换算法
这里应该做事件解耦
这里应该把硬件接口抽象一下
这里不应该让业务层知道这么多底层细节当你开始产生这些想法的时候,设计模式其实已经开始发挥作用了。
设计模式学到最后,并不是让代码看起来更复杂。
恰恰相反。
它真正的价值,是让一个越来越复杂的项目,仍然能够保持清晰。
标签:无