嵌入式开发中,最常用的设计模式其实就这几个

提到“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
模块统一 APIFacade
消息队列 + HandlerCommand
static 全局实例Singleton

所以学习设计模式的时候,不要死记 UML。

真正应该去理解的是:

它到底解决什么问题。

如果只学几个,建议按这个顺序

如果主要做:

  • STM32
  • C 语言
  • FreeRTOS
  • MCU 驱动
  • 物联网设备

我会建议按照下面的顺序学习:

State
  ↓
Strategy
  ↓
Observer
  ↓
Factory
  ↓
Adapter
  ↓
Facade
  ↓
Command
  ↓
Singleton

其中前几个尤其重要。

State

解决:

不同状态下,系统应该做什么?

Strategy

解决:

同一个功能,有多种不同实现怎么办?

Observer

解决:

一个事件发生后,怎么通知多个模块?

Factory

解决:

不同设备、不同驱动实例应该怎么创建?

Adapter

解决:

不同平台接口不一样,怎么统一?

Facade

解决:

底层很复杂,怎么给上层提供简单接口?

Command

解决:

怎么把“谁发命令”和“谁执行命令”分开?

把这几个问题想明白以后,设计模式就不会那么抽象了。


最后

对于嵌入式开发来说,真正重要的并不是:

我会不会 23 种设计模式?

而是看到一段越来越复杂的代码时,你能不能意识到:

这里其实是在处理状态

这里其实是在切换算法

这里应该做事件解耦

这里应该把硬件接口抽象一下

这里不应该让业务层知道这么多底层细节

当你开始产生这些想法的时候,设计模式其实已经开始发挥作用了。

设计模式学到最后,并不是让代码看起来更复杂。

恰恰相反。

它真正的价值,是让一个越来越复杂的项目,仍然能够保持清晰。

标签:无

你的评论