3. 模块化设计方法

上一讲我们聊了接口隔离和依赖倒置,今天接着把剩下的两个核心原则讲透——单一职责和开闭原则。这两个原则,说白了就是回答一个问题:你的驱动模块,改一次会不会炸一片?

我在项目里见过太多“牵一发动全身”的代码。一个驱动文件三千行,改个GPIO脚位,结果SPI也挂了,定时器也乱了。嗯,这就是典型的职责没分清楚。

单一职责原则:一个模块,只做一件事

单一职责原则(SRP)的定义很简单:一个模块应该只有一个引起它变化的原因。但落地的时候,很多人会问:什么叫“一个原因”?

我个人的判断标准是:如果两个功能会因不同的业务需求而修改,它们就应该分开。

举个例子,一个常见的温湿度传感器驱动:

// 不好的设计:一个模块干了三件事
typedef struct {
    uint8_t  humidity;
    uint8_t  temperature;
    uint16_t raw_data;
} DHT11_HandleTypeDef;

uint8_t DHT11_ReadRaw(uint16_t *raw);          // 硬件通信
uint8_t DHT11_ParseData(uint16_t raw, DHT11_HandleTypeDef *out); // 数据解析
void    DHT11_PrintResult(DHT11_HandleTypeDef *data); // 输出显示

这个模块的问题在哪?

  • 换MCU平台,DHT11_ReadRaw要重写
  • 换传感器型号,DHT11_ParseData要重写
  • 换输出方式(比如从串口改成LCD),DHT11_PrintResult要重写

三个变化原因挤在一个文件里,每次改都提心吊胆。

核心判断方法:问自己一个问题——“这个模块如果明天要删掉一个功能,会不会影响其他功能?”如果会,职责就没分干净。

正确的做法是拆成三层:

// 硬件抽象层:只负责通信
typedef struct {
    uint16_t raw_data;
    uint8_t  crc;
} SensorRawData;

SensorRawData Sensor_ReadRaw(void);

// 数据解析层:只负责转换
typedef struct {
    float temperature;
    float humidity;
} SensorData;

SensorData Sensor_Parse(SensorRawData raw);

// 应用层:只负责输出
void App_DisplaySensorData(SensorData data);

这样拆完,每一层只有一个变化原因。换MCU只改硬件抽象层,换传感器只改解析层,换显示方式只改应用层。互不干扰。

我的经验:在嵌入式项目里,我习惯把“硬件访问”和“数据处理”严格分开。哪怕只是一个读寄存器的函数,也单独放一个文件。这样做单元测试的时候特别爽——mock硬件层,测逻辑层,互不依赖。

开闭原则:对扩展开放,对修改关闭

开闭原则(OCP)听起来有点绕:模块应该对扩展开放,对修改关闭。什么意思呢?就是你想加新功能的时候,不要去改已经写好的代码,而是通过加新代码来实现。

为什么这么重要?

  • 改已有的代码,容易引入回归bug
  • 改过的代码需要重新测试,成本高
  • 多人协作时,改别人写的模块容易冲突

我曾经在一个项目里吃过这个亏。当时写了一个LED驱动,支持单色灯。后来产品要加RGB灯,我直接在原来的驱动里加了一堆if-else。结果呢?单色灯的时序被改坏了,产线上一批板子点不亮。嗯,从那以后我再也不敢在旧代码里硬塞新功能了。

如何用C语言实现开闭原则?

C语言没有虚函数,没有接口继承。但我们可以用函数指针+结构体来模拟多态。这是嵌入式领域最实用的OCP实现方式。

看一个例子:

// 定义一个“设备操作”接口
typedef struct {
    void (*init)(void);
    void (*write)(uint8_t data);
    uint8_t (*read)(void);
} DeviceOps;

// 实现1:UART设备
void UART_Init(void) { /* UART初始化代码 */ }
void UART_Write(uint8_t data) { /* UART发送代码 */ }
uint8_t UART_Read(void) { /* UART接收代码 */ }

const DeviceOps uart_ops = {
    .init  = UART_Init,
    .write = UART_Write,
    .read  = UART_Read
};

// 实现2:SPI设备
void SPI_Init(void) { /* SPI初始化代码 */ }
void SPI_Write(uint8_t data) { /* SPI发送代码 */ }
uint8_t SPI_Read(void) { /* SPI接收代码 */ }

const DeviceOps spi_ops = {
    .init  = SPI_Init,
    .write = SPI_Write,
    .read  = SPI_Read
};

// 上层代码:不关心具体设备
void ProcessData(const DeviceOps *dev) {
    dev->init();
    uint8_t val = dev->read();
    dev->write(val + 1);
}

你看,ProcessData函数完全不依赖具体设备。要加一个新的I2C设备?写一个I2C_Init、I2C_Write、I2C_Read,然后填到DeviceOps结构体里就行。已有的代码一行都不用改。

注意:函数指针表虽然灵活,但会带来额外的RAM开销(每个实例需要存储指针表)和间接调用开销。在资源极度受限的MCU(比如1KB RAM)上,要权衡使用。我一般建议:当设备种类超过3种时,用函数指针表才划算。

单一职责 + 开闭原则:实战中的组合拳

这两个原则经常一起用。单一职责帮你把模块拆小,开闭原则帮你让模块可扩展。我画了一张图,展示它们如何协同工作:

单一职责 + 开闭原则:驱动模块分层架构 应用层(App) 职责:业务逻辑、数据显示、用户交互 设备接口层(DeviceOps) 职责:定义统一操作接口(init/read/write) UART驱动 实现UART_Init/Write/Read SPI驱动 实现SPI_Init/Write/Read I2C驱动 实现I2C_Init/Write/Read 核心思想 新增设备只需在驱动实现层加新模块,接口层和应用层代码完全不动

这张图展示了三层架构:

  • 应用层:只依赖接口,不依赖具体设备
  • 接口层:定义契约,不实现细节
  • 驱动实现层:每个设备一个独立模块,互不干扰

新增一个I2C设备,只需要在驱动实现层加一个文件,应用层和接口层完全不用动。这就是开闭原则的落地。

避坑指南:常见误区

我总结了几条实战中容易踩的坑,分享给你:

我曾经犯过的错:
  1. 过度拆分:一个只有两个函数的模块也硬拆成三个文件,结果头文件比源文件还多。单一职责不是“一个函数一个文件”,而是“一个变化原因一个模块”。
  2. 接口设计太抽象:为了追求开闭原则,把接口设计得过于通用,结果每个具体实现都要做大量类型转换。接口要“够用就好”,不要过度设计。
  3. 忽略性能开销:在中断服务函数里用函数指针调用设备操作,间接调用会增加几十个时钟周期。高频场景下,建议用宏或内联函数替代。

小结:两个原则的对比

原则 核心问题 解决手段 典型反例
单一职责 一个模块该做几件事? 按变化原因拆分 驱动文件里混着硬件操作、数据解析和显示
开闭原则 加新功能要不要改旧代码? 函数指针+接口抽象 加新设备类型时在旧代码里加if-else

这两个原则,一个管“拆”,一个管“接”。拆得好,模块之间松耦合;接得好,扩展新功能不伤旧代码。你想想看,如果每个驱动模块都按这个思路设计,后续的单元测试和静态分析会轻松很多——因为每个模块的边界清晰,测试用例也好写。