4、接口设计原则(下):单一职责、开闭原则、里氏替换在驱动接口中的应用

上一讲我们聊了接口隔离和依赖倒置,说白了就是「别让调用方知道太多」和「面向抽象编程」。今天继续往下挖,把剩下的三个原则掰开揉碎了讲。这三个原则在驱动层里特别容易踩坑,我当年刚做嵌入式那会儿,没少在这上面栽跟头。

4.1 单一职责原则:一个接口只干一件事

单一职责原则(SRP)听起来很简单——一个模块只负责一个功能。但在驱动接口设计里,很多人会把它理解成「一个函数只做一件事」。其实没那么简单。

我见过最典型的反例:一个 SPI 驱动接口,既负责配置时钟极性,又负责发送数据,还顺带做了片选控制。你想想看,如果哪天要换一个 SPI 从设备,片选逻辑变了,你改还是不改?

核心观点: 驱动接口的单一职责,指的是「一个接口只封装一种硬件操作语义」,而不是「一个函数只写一行代码」。

来看个实际例子。假设我们要设计一个 GPIO 输出接口:

/* 反例:一个接口干了三件事 */
int gpio_set_output_pin(int pin, int level, int drive_strength);

/* 正例:拆成三个独立接口 */
int gpio_set_level(int pin, int level);
int gpio_set_drive_strength(int pin, int strength);
int gpio_config_output(int pin);

为什么要拆?因为这三个操作的变更频率和原因完全不同。电平设置是业务逻辑驱动的,驱动强度是硬件适配决定的,输出配置是初始化阶段做的。把它们绑在一起,任何一个需求变化都会导致接口签名改动。

接口 职责 变更原因
gpio_set_level 设置引脚电平 业务逻辑变化
gpio_set_drive_strength 配置驱动能力 硬件适配变化
gpio_config_output 切换为输出模式 初始化流程变化
我的习惯: 写接口之前,先问自己三个问题——这个接口会被谁调用?调用方关心什么?什么情况下这个接口需要改?如果答案超过一个,大概率职责没分清楚。

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

开闭原则(OCP)在驱动层里特别有意思。应用层代码写好了,突然要加一个新硬件,你希望改什么?当然是只加新驱动文件,不动已有代码。

但现实往往是:加一个新传感器,就要改一遍 I2C 驱动接口。为什么?因为接口设计时没考虑扩展性。

我曾经接手过一个项目,UART 驱动接口直接暴露了寄存器地址给应用层。后来要换一个带 FIFO 的新 UART 控制器,好家伙,所有调用方代码全得改。这就是典型的「对修改开放」。

正确的做法是抽象出一层「传输接口」:

/* 定义抽象接口 */
typedef struct {
    int (*send)(void *ctx, const uint8_t *data, size_t len);
    int (*recv)(void *ctx, uint8_t *buf, size_t len);
    int (*control)(void *ctx, int cmd, void *arg);
} uart_ops_t;

/* 具体实现:老款 UART */
static int old_uart_send(void *ctx, const uint8_t *data, size_t len) {
    /* 直接操作寄存器 */
}

/* 具体实现:新款带 FIFO 的 UART */
static int new_uart_send(void *ctx, const uint8_t *data, size_t len) {
    /* 使用 FIFO 发送 */
}

这样设计之后,加一个新 UART 硬件,只需要写一个新的 uart_ops_t 实例。所有调用方代码,一行都不用改。

注意: 开闭原则不是让你把所有东西都抽象化。过度抽象会让接口变得臃肿,反而难以维护。我一般只在「确定会有多种实现」的地方使用这种模式,比如总线接口、存储设备接口。

4.3 里氏替换原则:子类要能替换父类

里氏替换原则(LSP)在面向对象里讲得多,但在 C 语言的驱动接口里同样重要。说白了就是:你用父类指针调用的代码,换成子类实例后,行为不能变。

在嵌入式驱动里,这个原则最常见的应用场景是「设备抽象层」。比如我们有一个通用的 sensor_t 接口:

typedef struct {
    int (*init)(void);
    int (*read)(float *value);
    int (*deinit)(void);
} sensor_t;

温度传感器、湿度传感器、气压传感器都实现了这个接口。按照里氏替换原则,应用层用 sensor_t 指针调用的代码,换成任何一种传感器实例,都应该正常工作。

但我在项目中遇到过一个问题:某个温度传感器的 read 函数,在未初始化时返回 -1,而湿度传感器的 read 函数在同样情况下返回 0。这就违反了里氏替换原则——调用方没法统一处理错误。

避坑指南: 定义接口时,一定要把「契约」写清楚。比如返回值语义、参数范围、状态机转换规则。我曾经因为没约定好「读操作是否阻塞」,导致两个驱动替换后系统卡死。

来看一个更具体的例子。假设我们有一个 storage_t 接口:

typedef struct {
    int (*write)(uint32_t addr, const void *data, size_t len);
    int (*read)(uint32_t addr, void *buf, size_t len);
    int (*erase)(uint32_t addr, size_t len);
} storage_t;

Flash 驱动和 EEPROM 驱动都实现了这个接口。但 Flash 有擦除限制(必须先擦后写),EEPROM 没有。如果 Flash 驱动的 write 函数不检查地址是否已擦除,那调用方就得自己处理这个差异——里氏替换就失败了。

正确的做法是:在接口契约里明确「写操作前是否需要擦除」,或者把擦除操作独立出来,让调用方统一调用。

原则 驱动层应用场景 常见违反案例
单一职责 一个接口只封装一种硬件操作 GPIO 接口同时做配置和电平控制
开闭原则 加新硬件不改已有接口 UART 接口暴露寄存器细节
里氏替换 不同硬件实现可互换 不同传感器返回值语义不一致

4.4 三个原则的协同关系

这三个原则不是孤立的。单一职责让接口变小,开闭原则让接口稳定,里氏替换让接口可互换。它们一起构成了驱动接口设计的「铁三角」。

下面这张图展示了它们之间的关系:

驱动接口设计三大原则协同关系 单一职责 一个接口只干一件事 开闭原则 对扩展开放,对修改封闭 里氏替换 子类可替换父类 协同效果:接口稳定 + 可扩展 + 可互换 设计建议 1. 先按单一职责拆分接口,确保每个接口语义清晰 2. 用开闭原则设计接口结构,预留扩展点 3. 用里氏替换检验接口设计,确保不同实现可互换

实际项目中,我一般按这个顺序来设计接口:先按单一职责把功能拆开,然后用开闭原则搭好框架,最后用里氏替换原则检查一遍。三步走下来,接口质量基本就有保障了。

一个小技巧: 写完接口后,试着用「如果明天要换一个硬件」的心态去审视。如果发现需要改调用方代码,那就是开闭原则没做好。如果发现新硬件的行为和旧硬件不一致,那就是里氏替换没做好。

嗯,这三个原则说起来简单,做起来确实需要经验积累。我刚开始做驱动设计时,也经常写出「万能接口」——一个函数传十几个参数,什么都能干。后来被坑了几次,才慢慢学会克制。记住:好的接口设计,不是功能多,而是职责清、扩展易、替换稳。