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只改硬件抽象层,换传感器只改解析层,换显示方式只改应用层。互不干扰。
开闭原则:对扩展开放,对修改关闭
开闭原则(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结构体里就行。已有的代码一行都不用改。
单一职责 + 开闭原则:实战中的组合拳
这两个原则经常一起用。单一职责帮你把模块拆小,开闭原则帮你让模块可扩展。我画了一张图,展示它们如何协同工作:
这张图展示了三层架构:
- 应用层:只依赖接口,不依赖具体设备
- 接口层:定义契约,不实现细节
- 驱动实现层:每个设备一个独立模块,互不干扰
新增一个I2C设备,只需要在驱动实现层加一个文件,应用层和接口层完全不用动。这就是开闭原则的落地。
避坑指南:常见误区
我总结了几条实战中容易踩的坑,分享给你:
- 过度拆分:一个只有两个函数的模块也硬拆成三个文件,结果头文件比源文件还多。单一职责不是“一个函数一个文件”,而是“一个变化原因一个模块”。
- 接口设计太抽象:为了追求开闭原则,把接口设计得过于通用,结果每个具体实现都要做大量类型转换。接口要“够用就好”,不要过度设计。
- 忽略性能开销:在中断服务函数里用函数指针调用设备操作,间接调用会增加几十个时钟周期。高频场景下,建议用宏或内联函数替代。
小结:两个原则的对比
| 原则 | 核心问题 | 解决手段 | 典型反例 |
|---|---|---|---|
| 单一职责 | 一个模块该做几件事? | 按变化原因拆分 | 驱动文件里混着硬件操作、数据解析和显示 |
| 开闭原则 | 加新功能要不要改旧代码? | 函数指针+接口抽象 | 加新设备类型时在旧代码里加if-else |
这两个原则,一个管“拆”,一个管“接”。拆得好,模块之间松耦合;接得好,扩展新功能不伤旧代码。你想想看,如果每个驱动模块都按这个思路设计,后续的单元测试和静态分析会轻松很多——因为每个模块的边界清晰,测试用例也好写。