分类 默认分类 下的文章

用嵌入式 C 重新理解工厂模式:它解决的从来不只是“创建对象”

很多人第一次接触工厂模式,是从 Java 里的 new、接口、继承开始的。
但到了嵌入式 C,既没有 new,也没有类,很多人就会产生疑问:

工厂模式在嵌入式里到底有什么用?

其实只要把“对象”换成“模块”,这个模式一下就变得很接地气了。


一、先说结论:工厂模式到底在解决什么?

工厂模式表面上是在“创建对象”,但它真正想解决的问题是:

把“一个模块是怎么被组装出来的”这件事,从“这个模块怎么被使用”里面拆出去。

在嵌入式项目里,一个“对象”通常不是 Java 里的某个 class,而是下面这些东西的组合:

对象 / 模块
=
状态数据 struct
+ 操作接口 function pointer
+ 硬件资源
+ 依赖模块
+ 初始化过程
+ 生命周期

比如一个温度传感器模块,看起来只是“读温度”,但真正把它跑起来,背后可能要处理:

SHT30
├── 使用 I2C1
├── 地址 0x44
├── 上电后需要等待
├── 需要软复位
├── 需要 CRC 校验
└── 还要处理通信错误

业务代码真正关心的,其实只是:

float temperature = 0.0f;
sensor_read_temperature(sensor, &temperature);

它不应该天天操心:

  • 到底是哪颗传感器;
  • 挂在哪条 I2C 总线上;
  • 地址是多少;
  • 初始化要发什么命令;
  • 复位顺序是什么;
  • 换板子后底层怎么改。

这就是工厂模式最有价值的地方。


二、先别急着上模式,最简单的代码往往最好

假设一个产品支持两种温度传感器:

typedef enum
{
    SENSOR_TYPE_SHT30,
    SENSOR_TYPE_TMP117
} SensorType;

项目刚开始时,最直接的写法可能是这样:

int app_init(SensorType type)
{
    if (type == SENSOR_TYPE_SHT30)
    {
        i2c_init(I2C1);
        sht30_init(I2C1, 0x44);
    }
    else if (type == SENSOR_TYPE_TMP117)
    {
        i2c_init(I2C2);
        tmp117_init(I2C2, 0x48);
    }

    return 0;
}

这段代码有没有问题?

没有。

甚至在项目规模还小的时候,我反而建议就这么写。

设计模式不是为了把简单代码写复杂。

如果创建逻辑就三五行,而且以后大概率也不会变,硬套一层工厂,只会让代码更绕。

真正应该考虑工厂模式,是当下面这些情况开始出现时:

  • 支持的硬件型号越来越多;
  • if-else / switch 越来越长;
  • 每种模块的初始化过程越来越复杂;
  • 业务代码里开始到处出现硬件细节;
  • 同一段创建逻辑被多处复制;
  • 做单元测试时,很难把真实硬件替换掉。

这时候,问题就不再是“代码不好看”,而是:

变化开始污染稳定代码。


三、嵌入式 C 里的“对象”到底是什么?

Java/C++ 里讲设计模式,经常会出现:

  • 接口;
  • 抽象类;
  • 对象;
  • 多态。

C 语言里没有这些语法糖,但同样可以表达。

例如,我们定义一个传感器接口:

typedef struct Sensor Sensor;

typedef struct
{
    int (*init)(Sensor *self);
    int (*read_temperature)(Sensor *self, float *value);
    int (*deinit)(Sensor *self);
} SensorOps;

struct Sensor
{
    const SensorOps *ops;
    void *driver_data;
};

业务代码只通过接口使用它:

int sensor_read_temperature(Sensor *sensor, float *value)
{
    if (sensor == NULL ||
        sensor->ops == NULL ||
        sensor->ops->read_temperature == NULL)
    {
        return -1;
    }

    return sensor->ops->read_temperature(sensor, value);
}

这套写法里,Sensor 可以理解为“接口对象”,而 SensorOps 就是一张操作函数表。

所以以后看到面向对象里的概念,可以先这样翻译:

面向对象概念嵌入式 C 中常见写法
对象struct
接口一组函数指针
多态不同 ops
构造函数xxx_init() / xxx_create()
依赖struct *
工厂xxx_factory_create()

只要这层映射能建立起来,很多设计模式放到 C 里就不神秘了。


四、简单工厂:先把“选哪个实现”集中起来

假设现在支持:

SHT30
TMP117

我们可以先把创建逻辑从业务层抽出来。

1. 定义统一入口

typedef enum
{
    SENSOR_TYPE_SHT30,
    SENSOR_TYPE_TMP117
} SensorType;

Sensor *sensor_factory_create(SensorType type);

2. 工厂里负责选择具体实现

Sensor *sensor_factory_create(SensorType type)
{
    switch (type)
    {
        case SENSOR_TYPE_SHT30:
            return sht30_create();

        case SENSOR_TYPE_TMP117:
            return tmp117_create();

        default:
            return NULL;
    }
}

3. 业务代码只拿接口

static Sensor *g_sensor;

void app_init(void)
{
    g_sensor = sensor_factory_create(g_config.sensor_type);

    if (g_sensor == NULL)
    {
        system_fault();
        return;
    }

    g_sensor->ops->init(g_sensor);
}

此时,业务层已经不再关心:

SHT30 怎么初始化
TMP117 怎么初始化
以后新增 BME280 怎么办

它只关心:

我需要一个 Sensor

这就是简单工厂最直接的价值。


五、工厂模式并没有消灭复杂度,只是把复杂度搬到了正确的位置

很多人学完工厂模式后,会得到一个错误印象:

工厂模式就是为了消灭 if-else。

其实不是。

你看刚才的代码:

switch (type)
{
    case SENSOR_TYPE_SHT30:
        ...
    case SENSOR_TYPE_TMP117:
        ...
}

判断还在。

工厂模式真正做的是把原来混在业务层里的这些东西:

业务逻辑
传感器类型判断
硬件资源选择
初始化流程
错误处理

拆开成:

app.c
└── 业务逻辑

sensor_factory.c
└── 选择哪个传感器

sht30.c
└── SHT30 自己的初始化

tmp117.c
└── TMP117 自己的初始化

复杂度没有消失。

但它被放到了更合适的位置。

这在实际项目里非常重要,因为软件架构很多时候并不是“消灭复杂度”,而是:

把不同类型的复杂度隔离开。


六、嵌入式里简单工厂往往比“标准 OO 写法”更实用

很多 MCU 项目里,对象生命周期非常固定:

上电
→ 初始化
→ 一直运行
→ 断电

这和服务器程序不一样。

我们通常不需要频繁地动态创建、销毁对象,也不一定需要 malloc()。

因此完全可以这样写:

static Sht30 g_sht30;
static Tmp117 g_tmp117;

然后工厂只是返回静态实例:

Sensor *sensor_factory_get(SensorType type)
{
    switch (type)
    {
        case SENSOR_TYPE_SHT30:
            return sht30_get_instance();

        case SENSOR_TYPE_TMP117:
            return tmp117_get_instance();

        default:
            return NULL;
    }
}

这种方式对嵌入式很友好:

  • 内存占用确定;
  • 不会产生堆碎片;
  • 生命周期清楚;
  • 调试简单;
  • 实时性更容易控制。

所以不要照搬 Java 的“每次 new 一个对象”。

在嵌入式里,模式要服务于资源约束,而不是反过来。


七、什么时候简单工厂开始变得难维护?

当每种设备的“创建过程”越来越复杂时,简单工厂很快会变成这样:

Sensor *sensor_factory_create(SensorType type)
{
    switch (type)
    {
        case SENSOR_TYPE_SHT30:
        {
            i2c_init(...);
            gpio_init(...);
            delay_ms(...);

            sht30_reset(...);
            sht30_wait_ready(...);
            sht30_read_serial(...);
            sht30_config_measurement(...);

            return ...;
        }

        case SENSOR_TYPE_BME280:
        {
            spi_init(...);
            gpio_init(...);

            bme280_reset(...);
            bme280_read_calibration(...);
            bme280_config(...);

            return ...;
        }

        case SENSOR_TYPE_DS18B20:
        {
            ...
        }
    }
}

这时候,问题已经不是 switch 长不长了。

真正的问题是:

每一种对象的创建过程本身就已经很复杂。

这时可以继续拆。


八、工厂方法:把每一种实现的创建过程单独管理

在 C 里,不需要死磕“Factory 接口”这种面向对象形式。

更实用的理解是:

每种具体实现,都有自己的 creator。

比如:

Sensor *sht30_create(void);
Sensor *tmp117_create(void);
Sensor *bme280_create(void);

SHT30 自己负责自己的初始化:

Sensor *sht30_create(void)
{
    static Sht30 instance;

    memset(&instance, 0, sizeof(instance));

    instance.base.ops = &g_sht30_ops;
    instance.bus = i2c_factory_get(I2C_BUS_1);
    instance.address = 0x44;

    if (sht30_hw_reset(&instance) != 0)
    {
        return NULL;
    }

    if (sht30_read_serial_number(&instance) != 0)
    {
        return NULL;
    }

    if (sht30_config_measurement(&instance) != 0)
    {
        return NULL;
    }

    return &instance.base;
}

TMP117 则完全独立:

Sensor *tmp117_create(void)
{
    static Tmp117 instance;

    memset(&instance, 0, sizeof(instance));

    instance.base.ops = &g_tmp117_ops;
    instance.bus = i2c_factory_get(I2C_BUS_2);
    instance.address = 0x48;

    if (tmp117_reset(&instance) != 0)
    {
        return NULL;
    }

    if (tmp117_set_conversion_mode(&instance) != 0)
    {
        return NULL;
    }

    return &instance.base;
}

总工厂只负责“找谁来创建”:

Sensor *sensor_factory_create(SensorType type)
{
    switch (type)
    {
        case SENSOR_TYPE_SHT30:
            return sht30_create();

        case SENSOR_TYPE_TMP117:
            return tmp117_create();

        default:
            return NULL;
    }
}

这时候职责就很清晰:

总工厂
└── 决定“选谁”

具体 creator
└── 决定“怎么创建”

九、再进一步:用函数指针表代替越来越长的 switch

当驱动数量继续增加,可以做一张注册表。

typedef Sensor *(*SensorCreateFunc)(void);

typedef struct
{
    SensorType type;
    SensorCreateFunc create;
} SensorFactoryEntry;

注册具体实现:

static const SensorFactoryEntry g_sensor_factory_table[] =
{
    { SENSOR_TYPE_SHT30,  sht30_create  },
    { SENSOR_TYPE_TMP117, tmp117_create },
    { SENSOR_TYPE_BME280, bme280_create }
};

统一查表:

Sensor *sensor_factory_create(SensorType type)
{
    size_t count =
        sizeof(g_sensor_factory_table) /
        sizeof(g_sensor_factory_table[0]);

    for (size_t i = 0; i < count; i++)
    {
        if (g_sensor_factory_table[i].type == type)
        {
            return g_sensor_factory_table[i].create();
        }
    }

    return NULL;
}

从结构上看,这其实就是:

type
↓
查表
↓
creator function
↓
具体实现

在嵌入式项目里,类似思路到处都能见到:

  • driver table;
  • device table;
  • protocol table;
  • command table;
  • init table。

很多时候,你已经在用“工厂思想”,只是没给它起这个名字。


十、工厂模式最适合的场景之一:驱动层

驱动层经常有一个特点:

上层接口稳定,底层实现变化频繁。

例如统一定义一个存储接口:

typedef struct Storage Storage;

typedef struct
{
    int (*read)(
        Storage *self,
        uint32_t addr,
        void *buf,
        uint32_t len);

    int (*write)(
        Storage *self,
        uint32_t addr,
        const void *buf,
        uint32_t len);

    int (*erase)(
        Storage *self,
        uint32_t addr,
        uint32_t len);
} StorageOps;

struct Storage
{
    const StorageOps *ops;
};

底层可能有:

SPI Flash
QSPI Flash
EEPROM
FRAM
文件模拟器

业务层只关心:

storage->ops->read(...);
storage->ops->write(...);

到底用哪一种,可以交给工厂。

这类场景通常比“为了创建一个普通 struct”更值得上工厂模式。


十一、抽象工厂:一次切换一整套平台实现

抽象工厂听起来很抽象,但放到嵌入式里其实很好理解。

假设同一套业务需要支持:

STM32F4
STM32H7
NXP RT1062
PC 仿真环境

每个平台都有自己的一整套底层实现:

UART
SPI
GPIO
Flash
Timer

这时我们真正想切换的,不是一个单独的 UART,而是一整套平台能力。

可以定义:

typedef struct
{
    Uart  *(*create_uart)(int id);
    Spi   *(*create_spi)(int id);
    Gpio  *(*create_gpio)(int id);
    Flash *(*create_flash)(int id);
} BoardFactory;

STM32 平台:

static const BoardFactory g_stm32_factory =
{
    .create_uart  = stm32_uart_create,
    .create_spi   = stm32_spi_create,
    .create_gpio  = stm32_gpio_create,
    .create_flash = stm32_flash_create
};

PC 测试平台:

static const BoardFactory g_host_factory =
{
    .create_uart  = mock_uart_create,
    .create_spi   = mock_spi_create,
    .create_gpio  = mock_gpio_create,
    .create_flash = file_flash_create
};

业务代码:

void system_init(const BoardFactory *factory)
{
    Uart *uart = factory->create_uart(0);
    Flash *flash = factory->create_flash(0);

    protocol_init(uart);
    config_manager_init(flash);
}

这样就能做到:

同一套业务代码
├── 跑在 STM32 上
└── 跑在 PC 单元测试里

这个场景可以把抽象工厂理解成:

“平台级资源供应商”。


十二、理解工厂模式后,再看 DI 就很顺了

依赖注入(Dependency Injection,DI)经常和 Spring、IOC、反射绑在一起讲,容易让嵌入式开发者觉得很远。

其实把外壳拿掉以后,DI 的核心很简单:

模块不要自己去找依赖,而是由外部把依赖交给它。

举个例子。

不使用 DI

void motor_controller_init(void)
{
    pwm_init(TIM1);
    encoder_init(TIM2);
    current_sensor_init(ADC1);
}

这段代码的问题是:

MotorController
已经知道
PWM = TIM1
Encoder = TIM2
CurrentSensor = ADC1

换板子麻烦,做 PC 单元测试更麻烦。


十三、用 C 语言做最朴素的 DI

先把依赖显式写出来:

typedef struct
{
    Pwm *pwm;
    Encoder *encoder;
    CurrentSensor *current_sensor;
} MotorControllerConfig;

MotorController:

typedef struct
{
    MotorControllerConfig config;
    float target_speed;
} MotorController;

初始化:

int motor_controller_init(
    MotorController *self,
    const MotorControllerConfig *config)
{
    if (self == NULL || config == NULL)
    {
        return -1;
    }

    self->config = *config;
    self->target_speed = 0.0f;

    return 0;
}

注意,此时 MotorController 已经不知道:

TIM1
TIM2
ADC1
STM32 HAL

它只知道:

我需要一个 PWM
我需要一个 Encoder
我需要一个 CurrentSensor

至于这些东西是谁创建的,由系统启动层负责。


十四、嵌入式里最实用的 DI:Composition Root

我们可以在 app_bootstrap.c 或 system_init.c 里集中组装整个系统。

static Pwm g_pwm;
static Encoder g_encoder;
static CurrentSensor g_current_sensor;
static MotorController g_motor;

void app_bootstrap(void)
{
    stm32_pwm_create(&g_pwm, TIM1, 1);

    stm32_encoder_create(&g_encoder, TIM2);

    stm32_current_sensor_create(
        &g_current_sensor,
        ADC1,
        3);

    MotorControllerConfig config =
    {
        .pwm = &g_pwm,
        .encoder = &g_encoder,
        .current_sensor = &g_current_sensor
    };

    motor_controller_init(
        &g_motor,
        &config);
}

有人可能会说:

这不就是手工初始化吗?

没错。

它就是手工 DI。

而且对于绝大多数 MCU 项目来说,这种方式通常已经足够好。

因为它把整个系统的依赖关系集中在一个地方:

app_bootstrap.c
├── 创建 PWM
├── 创建 Encoder
├── 创建 CurrentSensor
└── 注入 MotorController

这比在各个模块内部到处调用 xxx_get_instance() 更清楚。


十五、为什么依赖注入比 Service Locator 更容易维护?

很多嵌入式项目喜欢这样写:

void motor_controller_init(void)
{
    Pwm *pwm = pwm_get_instance();
    Encoder *encoder = encoder_get_instance();
}

这种方式表面上也挺方便。

但问题是依赖被藏起来了。

从函数签名:

motor_controller_init();

你根本不知道它依赖:

PWM
Encoder
ADC
Logger
Flash

而 DI 会把依赖显式暴露出来:

motor_controller_init(&motor, &config);

看 config 就能知道这个模块需要什么。

可以简单记成:

Service Locator
= 模块自己去找依赖

Dependency Injection
= 外部把依赖送进模块

大型项目里,显式依赖通常更容易维护和测试。


十六、Factory 和 DI 到底是什么关系?

可以这样理解:

Factory
解决的是:
“一个模块怎么创建”

而 DI / Composition Root 解决的是:

“整个系统怎么组装”

例如:

              app_bootstrap
                    |
        +-----------+-----------+
        |           |           |
     PWM Factory  Encoder    ADC Factory
        |           |           |
        +-----------+-----------+
                    |
                    v
            MotorController
                    |
                    v
                   App

工厂负责产生模块。

DI 负责把模块连起来。

这两个思想是可以一起使用的。


十七、C 没有反射,照样能做“配置驱动”

Java 里的 DI 容器经常依赖反射,根据配置动态创建类。

C 语言没有这种机制,但并不代表做不了类似的事情。

可以用:

ID
+
注册表
+
函数指针

代替一部分反射能力。

例如:

typedef enum
{
    DRIVER_SHT30,
    DRIVER_BME280,
    DRIVER_MPU6050
} DriverId;

typedef void *(*DriverCreateFunc)(void);

typedef struct
{
    DriverId id;
    DriverCreateFunc create;
} DriverFactoryEntry;

注册:

static const DriverFactoryEntry g_driver_table[] =
{
    { DRIVER_SHT30,   sht30_create   },
    { DRIVER_BME280,  bme280_create  },
    { DRIVER_MPU6050, mpu6050_create }
};

于是就有:

配置里的 driver id
↓
查注册表
↓
找到 create 函数
↓
创建对应驱动

它不是语言级反射,但从架构目的上看,已经能实现类似的“配置驱动创建”。


十八、一个完整的小例子:环境监测设备

假设我们做一个环境监测设备。

结构如下:

Application
    |
    v
EnvironmentService
    |
    v
Sensor
    |
    +------ SHT30
    |
    +------ BME280
    |
    v
I2C
    |
    v
STM32 HAL

1. I2C 接口

typedef struct I2c I2c;

typedef struct
{
    int (*write)(
        I2c *self,
        uint8_t addr,
        const uint8_t *data,
        size_t len);

    int (*read)(
        I2c *self,
        uint8_t addr,
        uint8_t *data,
        size_t len);
} I2cOps;

struct I2c
{
    const I2cOps *ops;
    void *context;
};

2. Sensor 接口

typedef struct Sensor Sensor;

typedef struct
{
    int (*read_temperature)(
        Sensor *self,
        float *value);

    int (*read_humidity)(
        Sensor *self,
        float *value);
} SensorOps;

struct Sensor
{
    const SensorOps *ops;
};

3. SHT30 实现

typedef struct
{
    Sensor base;
    I2c *i2c;
    uint8_t address;
} Sht30;

创建函数:

Sensor *sht30_create(
    I2c *i2c,
    uint8_t address)
{
    static Sht30 sensor;

    memset(&sensor, 0, sizeof(sensor));

    sensor.base.ops = &g_sht30_ops;
    sensor.i2c = i2c;
    sensor.address = address;

    if (sht30_reset(&sensor) != 0)
    {
        return NULL;
    }

    return &sensor.base;
}

注意这里:

I2c *i2c

本身就是依赖注入。

SHT30 只知道“我需要一个 I2C”,并不知道底层到底是:

STM32 I2C1
STM32 I2C2
Mock I2C

4. Sensor Factory

typedef enum
{
    SENSOR_SHT30,
    SENSOR_BME280
} SensorType;
Sensor *sensor_factory_create(
    SensorType type,
    I2c *i2c)
{
    switch (type)
    {
        case SENSOR_SHT30:
            return sht30_create(i2c, 0x44);

        case SENSOR_BME280:
            return bme280_create(i2c, 0x76);

        default:
            return NULL;
    }
}

5. 业务层

typedef struct
{
    Sensor *sensor;
} EnvironmentService;
void environment_service_init(
    EnvironmentService *self,
    Sensor *sensor)
{
    self->sensor = sensor;
}

业务代码只操作统一接口:

int environment_service_run(
    EnvironmentService *self)
{
    float temperature = 0.0f;

    return self->sensor->ops->read_temperature(
        self->sensor,
        &temperature);
}

6. 系统组装

static I2c g_i2c1;
static EnvironmentService g_env_service;

void app_bootstrap(void)
{
    I2c *i2c =
        stm32_i2c_create(
            &g_i2c1,
            I2C1);

    Sensor *sensor =
        sensor_factory_create(
            g_config.sensor_type,
            i2c);

    environment_service_init(
        &g_env_service,
        sensor);
}

现在整个系统的边界非常清楚:

app_bootstrap
├── 创建 I2C
├── 创建 Sensor
└── 注入 EnvironmentService

而业务层已经不再关心:

STM32 HAL
SHT30
0x44
I2C1

十九、为什么这种写法特别适合单元测试?

生产环境:

Sensor *sensor =
    sensor_factory_create(
        SENSOR_SHT30,
        stm32_i2c);

单元测试:

Sensor *sensor =
    fake_sensor_create();

假传感器:

static int fake_read_temperature(
    Sensor *self,
    float *value)
{
    *value = 25.0f;
    return 0;
}

测试业务层:

void test_environment_service(void)
{
    Sensor *sensor = fake_sensor_create();

    EnvironmentService service;

    environment_service_init(
        &service,
        sensor);

    /* 后续断言 */
}

真实硬件和测试替身都实现同一个接口。

业务代码不用改。

这就是“依赖抽象,而不是依赖具体实现”带来的直接收益。


二十、到底什么时候该用哪一种?

可以用下面这个判断思路:

创建过程很简单吗?
|
+-- 是 --> 直接 xxx_init()
|
+-- 否
    |
    +-- 需要根据类型选择不同实现吗?
        |
        +-- 是 --> 简单工厂
        |
        +-- 否 --> 继续看创建过程是否复杂

如果每种实现的创建过程都很复杂:

Simple Factory
        |
        v
拆成多个 creator
        |
        v
Factory Method 思路

如果要整体切换一组相关底层能力:

UART + SPI + GPIO + Flash
一起切换
        |
        v
Abstract Factory

如果整个系统模块很多,而且依赖关系复杂:

Composition Root / DI

二十一、把这几个模式连起来,其实是一条很自然的演进路线

可以把它们看成几个复杂度等级。

Level 0:直接初始化

sht30_init();

适合:

简单、固定、不会变

Level 1:简单工厂

sensor_factory_create(type);

解决:

“选哪个实现”

Level 2:Factory Method 思路

sht30_create();
bme280_create();
tmp117_create();

解决:

“每一种实现怎么复杂地创建”

Level 3:抽象工厂

board_factory->create_uart();
board_factory->create_spi();
board_factory->create_flash();

解决:

“整套平台实现一起切换”

Level 4:DI / Composition Root

App
├── Logger
│   └── UART
├── Storage
│   └── Flash
└── Motor
    ├── PWM
    ├── Encoder
    └── ADC

解决:

“整个系统怎么组装”

二十二、真正应该记住的不是模式名字,而是“变化边界”

做嵌入式项目时,可以经常问自己一个问题:

哪些东西是稳定的,哪些东西是经常变化的?

例如:

稳定:
温控算法

变化:
SHT30 -> TMP117
I2C1 -> I2C2
STM32 -> NXP
真实设备 -> Mock

那架构就应该让稳定部分尽量只依赖稳定接口。

          TemperatureControl
                 |
                 v
              Sensor
                 ^
                 |
       +---------+---------+
       |                   |
     SHT30               TMP117
       ^                   ^
       +------ Factory -----+

Factory 最重要的意义,其实不是“创建对象”。

而是:

在稳定代码和易变创建逻辑之间建立一道边界。


二十三、嵌入式项目尤其要防止“设计模式过度”

学完设计模式以后,很容易走向另一个极端:

GPIOFactory
ClockFactory
DelayFactory
RegisterFactory
PinFactory
...

结果原本很简单的代码:

Led led;

led_init(
    &led,
    GPIOA,
    GPIO_PIN_5);

被改成:

Led *led =
    led_factory_create(
        LED_FACTORY_INSTANCE,
        LED_TYPE_NORMAL,
        GPIO_PROVIDER_STM32,
        ...);

这通常不是更好的设计。

工厂模式不是为了让代码看起来“高级”。

它真正适合的场景是:

创建已经复杂到了值得单独管理。

如果一个模块初始化只有两三行,而且实现永远固定,就直接写。

嵌入式开发最怕的不是代码“不够抽象”,而是抽象层太多以后:

  • 调试路径变长;
  • 资源关系看不清;
  • 初始化顺序隐藏;
  • 新人不敢改;
  • 出问题时找不到实际执行路径。

设计模式应该降低复杂度,而不是制造复杂度。


二十四、最后只记 5 句话

如果整篇文章只留下几句话,可以记下面这些。

1. struct + 函数指针,就是 C 世界里最常见的“对象 + 接口”

很多面向对象设计模式,都可以用这套方式落地。

2. Factory 的核心不是 new

Factory 真正做的是:

把“怎么得到一个能工作的模块”从“怎么使用模块”中拆出去。

3. 简单工厂解决“选哪个”,Factory Method 解决“怎么复杂地创建”

这是嵌入式项目里最实用的理解方式。

4. 抽象工厂很适合 BSP / HAL / 多平台适配

因为它天然适合“一整套底层实现一起替换”。

5. DI 不是 Spring,也不是反射

DI 的本质是:

模块声明自己需要什么,而不是自己跑出去找什么。

系统组装层负责创建和连接它们。


结语

工厂模式真正值得学的,不是那几个类名,也不是 Factory 这个后缀。

真正有价值的是它背后的思路:

创建和使用分离
变化和稳定分离
组装和业务分离

在嵌入式 C 里,这种思想通常会落到下面这些具体形式里:

struct
函数指针
静态实例
creator 函数
注册表
app_bootstrap.c
system_init.c

最终理想的结构应该是:

                app_bootstrap.c
                       |
              创建 + 配置 + 连接
                       |
        +--------------+--------------+
        |              |              |
      Service        Driver         Storage
        |              |              |
        v              v              v
     Interface      Interface      Interface
        ^              ^              ^
        |              |              |
     可替换实现      可替换硬件      可替换后端

可以把 app_bootstrap.c 理解成一个特殊的地方:

它知道整个系统是怎么组装起来的。

而其他模块,只需要知道自己的接口、自己的职责,以及自己依赖什么。

当代码能做到这一点时,工厂模式就不再只是一个“设计模式题目”,而是真正开始参与嵌入式软件架构设计了。


嵌入式里的单例:别只盯着“对象只有一个”,先搞清楚“资源到底归谁管”

很多人第一次接触单例模式,记住的往往是这样一套写法:

class Singleton {
public:
    static Singleton& instance()
    {
        static Singleton obj;
        return obj;
    }

private:
    Singleton() = default;
};

然后脑子里很自然地形成一个等式:

单例模式 = 一个类只能创建一个对象

这个理解不能说错,但如果放到嵌入式开发里,就有点停留在表面了。

因为嵌入式系统面对的不是纯粹的软件对象,而是 UART、SPI、I2C、DMA、Flash、ADC、PWM 这些真实存在的硬件资源。

所以我更愿意这样理解单例:

嵌入式里的单例,不要从“对象只能有一个”出发,而要从“资源只能有一个 Owner”出发。

这句话听起来有点抽象,下面用一个 UART 的例子把它拆开。


先来看一个很常见的场景

假设 MCU 上只有一个 USART1。

现在系统里有两个模块需要使用它:

Logger ----\
            ---> USART1
Modem  ----/

Logger 用它打印日志,Modem 用它发送通信数据。

如果按照传统单例模式的思路,我们可能会把 UART 驱动写成这样:

class Uart1 {
public:
    static Uart1& instance()
    {
        static Uart1 uart;
        return uart;
    }

    void send(const uint8_t* data, size_t len);

private:
    Uart1() = default;
};

然后 Logger 这么用:

void Logger::write(const char* msg)
{
    Uart1::instance().send(
        reinterpret_cast<const uint8_t*>(msg),
        strlen(msg));
}

Modem 也这么用:

void Modem::sendPacket(const uint8_t* data, size_t len)
{
    Uart1::instance().send(data, len);
}

看起来没什么问题。

整个系统里确实只有一个 Uart1 对象。

Uart1 对象数量 = 1

如果只看设计模式的定义,这个 Singleton 已经实现成功了。

但问题真的解决了吗?

不一定。


一个对象,不代表一个 Owner

假设 send() 底层使用 DMA。

Logger 先发送一条日志:

Logger
  ↓
Uart1::send()
  ↓
配置 DMA

buffer = "motor error..."
length = 14

  ↓
DMA 开始发送

DMA 还没有发送结束,Modem 又过来了:

Modem
  ↓
Uart1::send()
  ↓
重新配置 DMA

buffer = packet
length = 64

最后串口输出就有可能乱掉。

原本希望发送:

motor error...
AA 55 01 02 03 04 ...

实际可能变成:

motor er AA 55 01 02 03 ...

或者出现:

DMA 状态异常
UART Busy
日志缺失
协议数据损坏
发送缓存被覆盖

这个时候有一个很关键的问题:

明明整个程序里只有一个 Uart1 对象,为什么还是会出问题?

因为:

对象只有一个
≠
Owner 只有一个

虽然软件里只有一份 Uart1,但是实际上:

Logger:我可以直接操作 USART1

Modem:我也可以直接操作 USART1

两个模块都认为自己有权控制这个硬件。

系统实际变成了这样:

             USART1
            /      \
       Logger      Modem
         ↑           ↑
       Owner?      Owner?

真正的问题,不是创建了几个 C++ 对象。

而是:

同一个物理资源出现了多个控制者。

嵌入式里真正应该先问的问题

如果 MCU 上真的只有一个 USART1,那么软件设计首先要回答的,不应该是:

Uart 类要不要写成 Singleton?

而应该是:

USART1 到底归谁管?

也就是:

谁才是这个资源唯一的 Owner?

这才是比“对象有几个”更重要的问题。


换一种设计:给 UART 一个真正的 Owner

假设我们规定:

UartTxTask

是 USART1 唯一的管理者。

系统结构改成:

Logger ──────┐
             │
Modem  ──────┼──> TX Queue ──> UartTxTask ──> USART1
             │
Shell  ──────┘

这时候 Logger 不再直接调用:

HAL_UART_Transmit_DMA(...);

Modem 也不能直接配置 USART。

它们只能提交“发送请求”。

例如定义一个消息:

struct TxMessage {
    const uint8_t* data;
    size_t len;
};

Logger 做的事情变成:

void Logger::write(const char* msg)
{
    TxMessage message {
        reinterpret_cast<const uint8_t*>(msg),
        strlen(msg)
    };

    txQueue.push(message);
}

Modem 同样只是提交数据:

void Modem::sendPacket(const uint8_t* data, size_t len)
{
    TxMessage message {
        data,
        len
    };

    txQueue.push(message);
}

真正操作硬件的是 UART Task:

void uartTxTask()
{
    while (true)
    {
        TxMessage message = txQueue.pop();

        uart.send(message.data, message.len);

        waitTxComplete();
    }
}

于是整个数据路径就非常清楚:

Logger
   │
   │
Modem ───────> Queue ───────> UartTxTask ───────> USART1
   │
   │
Shell

只有 UartTxTask 知道怎么操作:

UART 寄存器
DMA
中断
TX 状态机
发送完成事件

这时才真正形成了:

USART1
   ↓
唯一控制入口
   ↓
UartTxTask
   ↓
Single Owner

有意思的地方来了:这时候甚至不需要 Singleton

UART Task 内部完全可以只是一个普通对象:

Uart uart1(USART1);

然后:

void uartTxTask()
{
    while (true)
    {
        TxMessage message = txQueue.pop();

        uart1.send(message.data, message.len);

        waitTxComplete();
    }
}

注意,这里没有:

Uart::getInstance()

也没有:

private:
    Uart();

更没有什么双重检查锁、懒加载。

它就是一个普通对象。

但是整个系统依然只有一个:

Uart uart1

更重要的是:

只有一个模块有权操作它

所以系统仍然非常清晰。

这就是一个很重要的区别:

只有一个实例,不等于必须使用单例模式。

Singleton 和 Single Owner 到底有什么区别?

可以把两种设计放在一起看。

方案一:对象是单例,但谁都可以操作

Logger ─────┐
            │
Modem ──────┼──> Uart::instance() ──> USART1
            │
Shell ──────┘

对象确实只有一个:

Uart 对象 = 1

但是资源的实际控制者有多个:

Logger
Modem
Shell

每个模块都可以随时调用 UART。

于是你还要继续考虑:

谁先调用?
谁后调用?
DMA 正在忙怎么办?
是否需要 mutex?
ISR 会不会同时访问?
发送缓存归谁维护?
状态机由谁维护?

系统仍然很复杂。


方案二:普通对象,但资源只有一个 Owner

Logger ───┐
          │
Modem ────┼──> Queue ──> UartTask ──> Uart uart1 ──> USART1
          │
Shell ────┘

这里没有使用 Singleton Pattern。

但是:

USART1 Owner = UartTask

所有访问都经过它。

结果反而更加稳定。

所以可以简单理解成:

Singleton
关注的是:
“对象到底有几个?”

Single Owner
关注的是:
“这个资源到底谁说了算?”

而在嵌入式系统里,后一个问题通常更加重要。


为什么这件事在嵌入式里特别重要?

因为嵌入式系统里的很多对象,其实都对应真实资源。

例如:

USART1
SPI1
I2C2
ADC1
DMA1
Flash
Watchdog
RTC
PWM Timer
CAN Controller

这些资源有一个共同特点:

它们不是凭空创建的软件概念,而是真实存在于芯片里的硬件。

比如芯片上只有一个:

USART1

那么它对应的:

寄存器
DMA
中断
发送状态
接收状态
buffer
callback

本质上都应该有明确的归属。

如果多个模块分别维护一份状态:

Module A:

uart_busy = true
tx_buffer = buffer_a

而另外一个模块认为:

Module B:

uart_busy = false
tx_buffer = buffer_b

马上就会出现一个问题:

到底谁的状态才是真的?

硬件只有一份:

USART1

软件却出现两套认知:

A 认为 UART 正在忙
B 认为 UART 空闲

这就是非常典型的状态分裂。


单例真正有价值的地方是什么?

如果只从代码形式看,单例好像只是:

限制对象创建数量

但如果再往下一层看,它真正有价值的地方其实是:

多个访问者
    ↓
统一入口
    ↓
统一状态
    ↓
共享资源

放到嵌入式里,可以理解成:

多个业务模块
    ↓
资源管理者
    ↓
UART / SPI / Flash / DMA

所以真正值得学习的,并不是:

private 构造函数怎么写

也不是:

getInstance() 怎么实现

而是:

资源的所有权应该怎么划分

再举一个 SPI 的例子

很多 MCU 上,一个 SPI 总线会挂多个设备:

              SPI1
               │
       ┌───────┼────────┐
       │       │        │
      Flash   IMU     Display

假设:

Flash 使用:

SPI Mode 0
10 MHz

IMU 使用:

SPI Mode 3
8 MHz

Display 使用:

SPI Mode 0
20 MHz

如果三个模块都可以直接操作 SPI:

FlashDriver ──> SPI1

ImuDriver   ──> SPI1

Display     ──> SPI1

很容易出现这样的情况:

IMU 刚刚把 SPI 配置成 Mode 3
            ↓
Display 把 SPI 改成 Mode 0
            ↓
IMU 继续读取数据

这时候 IMU 读到的数据自然可能完全错误。

所以 SPI 真正需要解决的问题,也不是:

Spi 类到底是不是 Singleton

而是:

SPI1 总线到底归谁管

更合理的结构应该是:

Flash ────┐
          │
IMU ──────┼──> SpiBus ───> SPI1
          │
Display ──┘

每个设备只需要告诉 SpiBus:

我要访问哪个设备
我要什么 SPI Mode
我要什么速度
我要发送什么数据

真正负责切换:

CS
Clock
Mode
DMA
Transfer State

的只有 SpiBus。

这样 SPI 总线才真正只有一个 Owner。


嵌入式里的“只有一个”,其实至少有三层含义

很多时候,我们会笼统地说:

这个东西只能有一个

但实际上,这里面至少包含三件不同的事情。


第一层:对象只有一个

例如:

Uart object = 1

这是传统 Singleton Pattern 最关心的问题。


第二层:状态只有一份

例如 UART 的:

tx_busy
rx_busy
tx_buffer
rx_buffer
current_baudrate
dma_state

这些状态最好只有一个可信来源。

也就是:

Single Source of Truth

否则不同模块各维护一套状态,很容易互相矛盾。


第三层:控制者只有一个

只有一个模块真正决定:

什么时候启动 DMA
什么时候停止 DMA
什么时候修改波特率
什么时候清状态位
什么时候切换 SPI Mode
什么时候修改硬件寄存器

这就是:

Single Owner

在嵌入式开发中:

状态只有一份、控制者只有一个,通常比“对象只有一个”更加重要。

一个很实用的判断方法

以后看到一个硬件模块,先不要急着问:

这个类是不是应该做成 Singleton?

先问下面几个问题。


1. 这个资源到底是什么?

先找到真正需要管理的东西。

例如:

USART1
SPI1
Flash
DMA Channel
Watchdog
ADC
PWM Timer

2. 谁拥有它?

最好能明确回答:

UartTask owns USART1

或者:

SpiBus owns SPI1

而不是:

大家都可以用

如果你回答不出“谁拥有它”,通常意味着资源边界还没有设计清楚。


3. 谁允许直接修改它的状态?

例如:

Logger 不能直接启动 UART DMA
Modem 不能直接改 UART 寄存器
Shell 不能直接修改发送状态

它们只能通过 Owner。


4. 其他模块通过什么方式访问它?

可以是:

普通函数调用
消息队列
事件
Command
Request / Response
IPC

具体使用哪种方式并不是重点。

重点是:

访问路径应该明确

5. 资源状态存在哪里?

例如:

class UartManager {
private:
    bool txBusy;
    uint8_t* txBuffer;
    size_t txLength;
};

最好只有一个地方维护这些状态。

否则非常容易出现:

A 认为 Busy
B 认为 Idle

这样的状态分裂。


一个很容易踩的坑:Singleton 不等于线程安全

很多人可能会觉得:

我都已经把 UART 写成 Singleton 了,
应该不会冲突了吧?

其实完全不是一回事。

Singleton 最多只能帮你保证:

对象数量 = 1

它不能保证:

Task A
Task B
ISR

不会同时访问这个对象。

也就是说:

Singleton
    ≠
Thread Safe

进一步说:

Singleton
    ≠
Resource Safe

你仍然可能需要:

mutex
critical section
queue
event
atomic
interrupt protection

所以在 RTOS 环境中,我通常更喜欢这样的设计:

多个 Task
    ↓
Message Queue
    ↓
唯一 Owner Task
    ↓
Hardware

而不是:

多个 Task
    ↓
Singleton
    ↓
mutex
    ↓
Hardware

两种方式都能实现。

但前一种通常更容易把:

硬件状态
DMA 状态
发送队列
错误处理

集中在一个地方。


“只有一个实例”和“必须使用单例模式”是两回事

例如:

Uart uart1(USART1);

Logger logger(uart1);
Modem modem(uart1);

系统里依然只有一个:

uart1

但是 Uart 这个类型本身并没有被设计成 Singleton。

以后如果硬件增加 UART,可以直接:

Uart uart1(USART1);
Uart uart2(USART2);
Uart uart3(USART3);

然后:

Logger logger(uart1);
Gnss gnss(uart2);
Modem modem(uart3);

整个结构依然成立。

这通常比:

Uart::instance()

灵活得多。

因为今天的板子可能只有:

USART1

下一版硬件可能就变成:

USART1 -> Debug
USART2 -> GNSS
USART3 -> LTE

如果一开始就把:

Uart = Singleton

写死了,后面扩展就会比较麻烦。

所以设计时一定要区分:

当前系统只有一个实例

和:

这个类型从设计上永远只能有一个实例

这完全是两回事。


哪些东西比较适合真正做成 Singleton?

嵌入式里有一些东西确实天然接近 Singleton。

比如:

SystemClockManager

WatchdogManager

ResetManager

BootManager

SystemTime

因为这些资源往往具有这样的特点:

物理上天然唯一

+

生命周期基本等于系统生命周期

+

未来几乎没有多实例需求

+

状态需要全局保持一致

这时候使用 Singleton 并没有什么问题。


哪些东西不要轻易写成 Singleton?

下面这些东西最好多想一下:

UART
SPI
I2C
CAN
Sensor
Protocol
CommunicationChannel
BufferPool
StorageBackend

因为它们非常容易从:

一个

变成:

多个

比如今天:

一个 IMU

下一代产品可能变成:

主 IMU
+
备份 IMU

又比如今天:

CAN1

下一代产品可能变成:

CAN1
CAN2

如果代码里到处都是:

CanBus::instance()

后期扩展就会比较痛苦。


回到最开始那句话

现在再来看:

嵌入式里的单例,不要从“对象只能有一个”出发,而要从“资源只能有一个 Owner”出发。

它真正想表达的并不是:

Singleton 不好

也不是:

嵌入式不要使用单例

而是在提醒我们:

不要把注意力只停留在设计模式的代码形式上。

真正应该思考的是:

这个硬件资源是谁的?

谁维护它的状态?

谁能够修改它?

其他模块通过什么方式访问它?

发生并发时由谁协调?

当这些问题都回答清楚之后:

要不要使用 Singleton

反而只是一个实现细节。

可以把整个思路压缩成一句话:

Singleton 管的是对象数量,Single Owner 管的是资源控制权。

而在嵌入式系统里:

真正危险的通常不是多创建了一个对象,而是同一个硬件资源出现了多个控制者。

所以下次再碰到:

UART
SPI
DMA
Flash
ADC
PWM
Watchdog

先不要急着写:

XXX::instance()

先问一句:

这个资源,到底归谁管?

这个问题想明白了,后面的设计通常也就顺了。


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

提到“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 种设计模式?

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

这里其实是在处理状态

这里其实是在切换算法

这里应该做事件解耦

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

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

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

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

恰恰相反。

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


前言

前阵子入手了一台 RackNerd 服务器,系统装的是 Ubuntu 18.04 LTS。为了记录学习与成长,我决定在上面搭建大名鼎鼎的轻量级博客系统 —— Typecho。

原本以为几行命令就能轻松搞定,没想到一路上踩满了各种暗坑:从系统自带 PHP 版本太低导致代码崩溃(500 错误),到 GitHub 链接在终端被神秘截断,再到 Docker 容器与宿主机数据库的连接权限问题。

为了让后来的小伙伴少走弯路,我把这次历时数小时的“血泪史”整理成了一份绝对能成功的保姆级教程。我们采用 Docker(PHP 8.1)+ 宿主机 MySQL 的混编架构,既无视了老旧系统的限制,又保证了轻量与高效。


🛠️ 第一步:基础环境准备与 MySQL 数据库配置

首先,通过 SSH 工具登录你的服务器。

1. 更新系统包

sudo apt update && sudo apt upgrade -y

2. 安装并配置 MySQL 数据库

由于 Typecho 需要存储数据,我们先在宿主机上安装 MySQL:

sudo apt install mysql-server -y

接着登录 MySQL,创建博客专属的数据库和用户。

🔥 避坑红线 1:由于 Typecho 稍后将运行在 Docker 容器内,对宿主机 MySQL 来说属于“远程连接”。因此创建用户时,必须允许从任意 IP (%) 登录,不能写 localhost!
sudo mysql -u root -p

在 mysql> 提示符下输入以下命令:

-- 创建数据库
CREATE DATABASE typecho_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- 创建允许远程连接的用户(请自行替换为你自己的安全密码)
CREATE USER 'typecho_user'@'%' IDENTIFIED BY 'your_password';

-- 全量授权
GRANT ALL PRIVILEGES ON typecho_db.* TO 'typecho_user'@'%';
FLUSH PRIVILEGES;
EXIT;

3. 解除 MySQL 仅限本地监听的限制

Ubuntu 默认限制 MySQL 只能本地访问,我们需要修改它。

sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf

找到 bind-address = 127.0.0.1,将其修改为:

bind-address = 0.0.0.0

保存退出(Ctrl+O -> Enter -> Ctrl+X),然后重启 MySQL:

sudo systemctl restart mysql

📂 第二步:获取 Typecho 官方源码

🔥 避坑红线 2:很多时候在终端直接用 wget 长链接下载 GitHub 文件,会因为终端工具的换行或粘贴限制,导致链接被无故“截断”,最终只下载了一个坏掉的网页 HTML。

稳妥方案:使用 Xftp 手动上传

  1. 在本地电脑的浏览器中,直接访问官方稳定版直链下载压缩包:
    https://typecho.org
  2. 打开 Xftp 连接服务器,在右侧(服务器端)进入或新建路径:/var/www/typecho。
  3. 将下载好的 release.zip 直接拖拽上传到该目录下。
  4. 回到 SSH 终端执行解压:

    cd /var/www/typecho
    sudo apt install unzip -y
    sudo unzip release.zip
    rm -f release.zip

🐳 第三步:一键构建 Docker 容器环境(解决 PHP 版本冲突)

🔥 避坑红线 3:Ubuntu 18.04 官方自带的 PHP 7.2 版本太老,运行最新版 Typecho 会直接报 PHP Parse error ... Response.php on line 73 的 500 严重错误。而由于系统生命周期结束(EOL),我们无法直接 apt 升级 PHP。
终极解法:使用 Docker 容器隔离技术,直接拉取集成好的 PHP 8.1 环境!

1. 安装 Docker 与 Docker-Compose

sudo apt install docker.io docker-compose -y
sudo systemctl start docker && sudo systemctl enable docker

(注意:如果之前服务器安装了自带的 nginx 或 php,请执行 sudo systemctl stop nginx 确保 80 端口被释放)

2. 编写 Docker 编排文件

cd /var/www
sudo nano docker-compose.yml

粘贴以下内容:

version: '3'

services:
  typecho-php:
    image: php:8.1-fpm-alpine
    container_name: typecho-php
    restart: always
    volumes:
      - /var/www/typecho:/var/www/typecho
    entrypoint: >
      sh -c "docker-php-ext-install pdo_mysql && docker-php-entrypoint php-fpm"

  typecho-nginx:
    image: nginx:alpine
    container_name: typecho-nginx
    restart: always
    ports:
      - "80:80"
    volumes:
      - /var/www/typecho:/var/www/typecho
      - /var/www/nginx.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - typecho-php

3. 编写 Docker 内的 Nginx 配置文件

sudo nano /var/www/nginx.conf

粘贴以下标准的 Typecho 转发与伪静态配置:

server {
    listen 80;
    server_name localhost;

    root /var/www/typecho;
    index index.php index.html index.htm;

    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass typecho-php:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }
}

4. 彻底解决“上传目录无法写入”的权限坑

由于 Docker 容器内外的用户权限不一致,必须在宿主机上把博客目录权限完全放开:

sudo mkdir -p /var/www/typecho/usr/uploads
sudo chmod -R 777 /var/www/typecho

5. 启动容器集群

cd /var/www
sudo docker-compose up -d

输入 sudo docker ps,看到两个容器全部显示 Up,大功告成!


💻 第四步:浏览器可视化安装向导

现在,打开电脑浏览器,输入你的 服务器公网 IP 地址(http://your_server_ip),熟悉的红白相间 Typecho 安装界面就会瞬间弹出来!

填写数据库核心要点:

  • 数据库驱动:务必选择 Pdo 驱动 MySQL。
  • 数据库地址:由于是在 Docker 容器内访问外面的宿主机,这里请填写 Docker 的默认网关桥梁 IP:172.17.0.1(或者直接填写你服务器的公网 IP)。
  • 数据库用户名:typecho_user
  • 数据库密码:your_password (你在第一步中为该用户设置的密码)
  • 数据库名:typecho_db

点击下一步,随后设置好你自己的管理员账号、密码和邮箱,点击确认——恭喜你,你的独立博客正式诞生了!


💡 总结与后记

回顾这次建站,其实真正的难点不在于技术多复杂,而在于在面对老旧的系统环境时,如何灵活地利用 Docker 去破局。
通过这套配置,整个博客不仅完美避开了版本冲突,而且日常运行内存占用极低(仅 200MB 左右),RackNerd 的小机器跑起来轻轻松松。

如果你也有一台闲置的服务器,别让它吃灰了,跟着这篇教程,一起加入独立博客的行列吧!


Hello World!
当你在浏览器里看到这一页时,意味着我已经在自己的 RackNerd 服务器 上,伴随着各种踩坑与调优,成功搭建起了这个属于我自己的 Typecho 动态博客。
在这个处处是信息孤岛、社交媒体信息转瞬即逝的时代,拥有一个完全属于自己的独立博客,就像是在纷繁复杂的互联网世界里,亲手为自己盖了一座林间小屋。在这里,没有算法推荐的干扰,没有字数和格式的限制,只有最纯粹的记录与表达。
关于这个博客,我给它设定了三个核心的初心和方向:

🚀 1. 内容分享:把输入转化为输出

知识如果不经过梳理和分享,就很容易被遗忘。我希望把这里当作我的“知识第二大脑”。无论是平时看到的优质文章、深刻观点,还是生活中的所见所闻,我都会通过文字的方式进行二次加工,分享给来到这里的每一个人。正如一句话所说:“最好的学习方式,就是把学到的东西教会别人。”

📝 2. 记录学习:技术与认知的演进

在搭建这个博客的过程中,我经历了系统防火墙拦截、GitHub 源码下载被截断、PHP 版本不兼容、Docker 容器与宿主机数据库连接被拒等一连串的“硬核挑战”。每一次报错,都是一次学习的机会。
未来,这里会成为我硬核技能的“错题本”和“备忘录”。我会在这里持续记录编程、技术折腾以及各领域新知识的学习历程。

🌱 3. 个人成长:见证自己的迭代

人是在不断修正自我中进步的。文字是时间的锚点,我希望通过持续的复盘和记录,留住自己不同阶段的思考。几年后再回头看现在的文字,也许会觉得青涩,但那正是我一步步走来、不断迭代的最好见证。
写在最后:
种一棵树最好的时间是十年前,其次是现在。
这个博客是我送给未来的自己的一份礼物。不追求流量,不迎合受众,只求日拱一卒,真实记录。
欢迎你的到来,如果你偶然路过这里,也欢迎在下方留下一句评论,让我们在文字的世界里建立连接。