3. 模块化设计原则(下):SRP、OCP、LSP 在驱动开发中的实践
上一讲我们聊了模块化设计的基础原则。今天继续往下挖,把剩下的三个核心原则讲透。这三个原则——单一职责、开闭原则、里氏替换——在驱动开发里特别容易踩坑。
我见过太多驱动代码,一个函数干五件事,一个结构体管三个外设。改一个地方,崩三个模块。说白了,就是原则没守住。
3.1 单一职责原则(SRP)
SRP 的定义很简单:一个模块只应该有一个被修改的理由。但在驱动里,这个「理由」经常被误解。
举个例子,你写了一个 UART 驱动。它既要管寄存器读写,又要管数据缓冲,还要管协议解析。看起来挺合理对吧?
但你想过没有:
- 换芯片时,寄存器地址变了——你得改驱动
- 协议从 8N1 改成 7E1——你还得改驱动
- 缓冲区从环形改成链表——你照样得改
三个不同的修改原因,挤在一个文件里。这就是 SRP 被破坏的典型症状。
我在一个工业控制项目里遇到过这种事。当时一个 SPI 驱动,把片选逻辑、DMA 传输、数据校验全写在一个函数里。后来客户要求换 Flash 芯片,片选时序变了。我改了一行片选代码,结果 DMA 的 buffer 指针越界了。查了两天才发现,是共用了一个全局状态变量。
正确的做法是分层:
/* 不好的写法:一个结构体管所有 */
typedef struct {
uint32_t base_addr;
uint8_t *tx_buf;
uint8_t *rx_buf;
uint16_t buf_len;
void (*protocol_parse)(uint8_t *data);
void (*hw_config)(void);
} UART_Device;
/* 好的写法:职责分离 */
typedef struct {
uint32_t base_addr;
uint32_t irq_num;
} UART_HW; // 只管硬件寄存器
typedef struct {
uint8_t *buf;
uint16_t head;
uint16_t tail;
uint16_t size;
} UART_Buffer; // 只管数据缓冲
typedef struct {
uint8_t start_byte;
uint8_t end_byte;
uint16_t (*checksum)(uint8_t *data, uint16_t len);
} UART_Protocol; // 只管协议解析
这样拆完之后,每个模块的修改原因只有一个。换芯片改 UART_HW,换协议改 UART_Protocol,互不干扰。
3.2 开闭原则(OCP)
开闭原则说:对扩展开放,对修改关闭。听起来很抽象,但在驱动里其实很具体。
说白了就是:加一个新功能,不要改老代码。加一个新设备,不要改驱动框架。
我早期做传感器驱动时,犯过一个经典错误。当时写了这样一个函数:
void sensor_read(int sensor_type) {
switch (sensor_type) {
case TEMP_SENSOR:
// 读温度
break;
case HUMID_SENSOR:
// 读湿度
break;
case PRESSURE_SENSOR:
// 读气压
break;
// 每加一个新传感器,就要加一个 case
}
}
这个 switch-case 就是 OCP 的反面教材。每次加新传感器,都要打开这个函数,加一个 case。改着改着,函数越来越长,最后没人敢动。
正确的做法是用函数指针表或者回调机制:
typedef struct {
int (*init)(void);
int (*read)(uint8_t *data, uint16_t len);
int (*deinit)(void);
} Sensor_Ops;
/* 注册新传感器,不需要改框架代码 */
const Sensor_Ops temp_sensor = {
.init = temp_init,
.read = temp_read,
.deinit = temp_deinit,
};
const Sensor_Ops humidity_sensor = {
.init = humidity_init,
.read = humidity_read,
.deinit = humidity_deinit,
};
/* 驱动框架只操作 ops 指针,不关心具体传感器 */
int sensor_manager_read(Sensor_Ops *ops, uint8_t *data, uint16_t len) {
return ops->read(data, len);
}
这样,加一个新传感器,只需要写一个新的 Sensor_Ops 实例。框架代码一行都不用改。
3.3 Liskov 替换原则(LSP)
LSP 在驱动开发里经常被忽略。它的核心意思是:子类(或派生实现)应该能替换父类(或基类接口),而不影响程序的正确性。
在 C 语言里,我们通常用结构体加函数指针来模拟继承。LSP 要求:所有实现同一个接口的驱动,行为必须一致。
举个例子,你定义了一个 Flash 驱动接口:
typedef struct {
int (*read)(uint32_t addr, uint8_t *buf, uint32_t len);
int (*write)(uint32_t addr, const uint8_t *buf, uint32_t len);
int (*erase)(uint32_t addr, uint32_t len);
} Flash_Ops;
然后你给 NOR Flash 和 NAND Flash 分别实现了这个接口。看起来没问题,但 LSP 的坑在这里:
- NOR Flash 支持按字节读写,NAND Flash 只能按页读写
- NOR Flash 擦除前不需要先写,NAND Flash 擦除前必须先写 0xFF
- NOR Flash 读操作不会损坏数据,NAND Flash 读多了会有位翻转
如果上层代码假设「所有 Flash 都能按字节写」,那换上 NAND Flash 就崩了。这就是 LSP 被破坏。
解决 LSP 问题,通常有两种思路:
- 接口细化: 把通用接口拆成更细的接口。比如把 Flash_Ops 拆成 Byte_Accessible_Ops 和 Page_Accessible_Ops。
- 契约文档化: 在接口注释里明确写出前置条件、后置条件、副作用。比如「write 操作前必须先 erase」。
我个人更倾向于第一种。接口细化虽然增加了代码量,但把差异暴露在类型系统里,编译器就能帮你检查。比运行时才崩溃强得多。
3.4 三个原则的关系与权衡
这三个原则不是孤立的。它们经常一起出现,也经常互相冲突。
比如,SRP 要求你拆细,OCP 要求你抽象,LSP 要求你行为一致。拆得太细,接口太多,LSP 反而更难保证。抽象得太深,性能下降,SRP 又显得过度。
我画了一张图,帮你理清它们的关系:
我的经验是:先保证 SRP,把职责分清楚。然后在「确实会变化」的地方用 OCP 留扩展点。最后用 LSP 检查一下,看看你的接口是不是真的通用。
别为了「完美设计」把代码搞得太复杂。嵌入式开发里,可读性和性能同样重要。原则是工具,不是枷锁。
3.5 实战检查清单
最后,给你一份我在代码审查时用的检查清单。每次写完驱动,对着过一遍:
| 原则 | 检查项 | 常见违规信号 |
|---|---|---|
| SRP | 每个函数/结构体是否只有一个修改理由? | 一个函数里既有硬件操作又有业务逻辑 |
| OCP | 加新功能是否需要改已有代码? | switch-case 或 if-else 链不断增长 |
| LSP | 不同实现能否无缝替换? | 上层代码需要知道具体是哪个实现 |