3. 模块化设计核心概念

上一讲我们聊了模块化设计的基础原则。今天继续往下挖,把剩下的三个核心原则讲透。这三个原则——单一职责、开闭原则、里氏替换——在驱动开发里特别容易踩坑。

我见过太多驱动代码,一个函数干五件事,一个结构体管三个外设。改一个地方,崩三个模块。说白了,就是原则没守住。

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 实例。框架代码一行都不用改。

注意: OCP 不是让你把所有东西都抽象化。过度抽象在嵌入式里是灾难。我见过有人把 GPIO 操作都抽象成三层接口,结果一个简单的拉高拉低要调五个函数。性能开销大,调试也麻烦。把握好度,只在「确实会变化」的地方留扩展点。

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 被破坏。

我曾经踩过的坑: 在一个 RTOS 项目里,我写了一个统一的存储抽象层,支持 SPI Flash、SD 卡、EEPROM。接口看起来一样,但 SD 卡的写操作有延迟,EEPROM 有写入次数限制。上层代码没考虑这些差异,结果在 EEPROM 上频繁写入,几天就把芯片写废了。

解决 LSP 问题,通常有两种思路:

  1. 接口细化: 把通用接口拆成更细的接口。比如把 Flash_Ops 拆成 Byte_Accessible_Ops 和 Page_Accessible_Ops。
  2. 契约文档化: 在接口注释里明确写出前置条件、后置条件、副作用。比如「write 操作前必须先 erase」。

我个人更倾向于第一种。接口细化虽然增加了代码量,但把差异暴露在类型系统里,编译器就能帮你检查。比运行时才崩溃强得多。

3.4 三个原则的关系与权衡

这三个原则不是孤立的。它们经常一起出现,也经常互相冲突。

比如,SRP 要求你拆细,OCP 要求你抽象,LSP 要求你行为一致。拆得太细,接口太多,LSP 反而更难保证。抽象得太深,性能下降,SRP 又显得过度。

我画了一张图,帮你理清它们的关系:

驱动模块化三原则关系图 单一职责 (SRP) 一个模块一个修改理由 开闭原则 (OCP) 对扩展开放,对修改关闭 里氏替换 (LSP) 派生实现可替换基类 实践中的权衡 • SRP 拆得越细 → 接口越多 → LSP 越难保证 • OCP 抽象越深 → 调用链越长 → 性能开销越大 • LSP 要求行为一致 → 可能限制硬件特性发挥 • 我的经验:先满足 SRP,再考虑 OCP,最后用 LSP 验证 • 不要为了「完美设计」牺牲可读性和性能

我的经验是:先保证 SRP,把职责分清楚。然后在「确实会变化」的地方用 OCP 留扩展点。最后用 LSP 检查一下,看看你的接口是不是真的通用。

别为了「完美设计」把代码搞得太复杂。嵌入式开发里,可读性和性能同样重要。原则是工具,不是枷锁。

3.5 实战检查清单

最后,给你一份我在代码审查时用的检查清单。每次写完驱动,对着过一遍:

原则 检查项 常见违规信号
SRP 每个函数/结构体是否只有一个修改理由? 一个函数里既有硬件操作又有业务逻辑
OCP 加新功能是否需要改已有代码? switch-case 或 if-else 链不断增长
LSP 不同实现能否无缝替换? 上层代码需要知道具体是哪个实现
我的习惯: 每次提交代码前,把这三个原则写在便签纸上贴在显示器旁边。对着看一遍,能发现很多问题。尤其是 LSP,最容易在不知不觉中被破坏。