模块化设计原则:驱动开发的"四根柱子"

做驱动开发久了,你会发现一个规律:代码腐烂的速度,往往和项目复杂度成正比。我见过太多驱动代码,刚开始写的时候挺清爽,三个月后就成了没人敢动的"屎山"。问题出在哪?说白了,就是没遵守模块化设计的基本原则。

今天聊的这四个原则——高内聚低耦合、接口隔离、单一职责、依赖倒置——不是我发明的,是无数前辈踩坑踩出来的。我个人习惯把它们称为驱动开发的"四根柱子"。你把这四根柱子立稳了,代码的复用性和可维护性自然就上来了。

核心观点:模块化不是把代码拆成文件就完事了,而是要让每个模块"各司其职、互不干扰、易于替换"。
驱动模块化设计四原则 可复用驱动模块 高内聚低耦合 接口隔离原则 单一职责原则 依赖倒置原则 四个原则相互支撑,缺一个都会导致驱动代码难以维护和复用

1. 高内聚低耦合:驱动模块的"边界感"

什么叫高内聚?一个模块内部的东西,应该高度相关。比如 SPI 控制器驱动,你就别把 GPIO 的初始化逻辑塞进去。低耦合呢?模块之间的依赖要尽可能少,最好只通过接口通信。

我在项目中遇到过这样一个案例:某款传感器的驱动,把 I2C 读写、数据解析、日志打印、甚至电源管理全写在一个文件里。后来要换一个 I2C 控制器,好家伙,整个驱动都得重写。这就是典型的"内聚低、耦合高"。

实战建议:写驱动前先画模块依赖图。如果两个模块之间的箭头超过 3 条,说明耦合已经偏高了,得重新设计。

怎么做到低耦合?我总结了一个简单方法:模块之间只通过函数指针或抽象接口交互。别直接调用对方的结构体成员,更别共享全局变量。你想想看,两个模块共用一个全局变量,调试的时候你改我改大家改,出问题了谁负责?

2. 接口隔离原则:别让调用方"被迫依赖"

这个原则说白了就是:不要强迫调用方使用它不需要的接口。举个例子,你写了一个"万能外设驱动",里面包含了读、写、配置、中断处理、DMA 传输等 20 个函数。但你的用户可能只需要读和写,那剩下的 18 个函数对他来说就是负担。

我记得有一次做项目,团队里有人设计了一个"超级 SPI 驱动",一个结构体里塞了 30 多个函数指针。结果每个使用方都得包含这个头文件,编译时间暴涨,代码可读性也差。后来我把它拆成了三个小接口:spi_master_t(基本读写)、spi_dma_t(DMA 传输)、spi_config_t(参数配置)。每个调用方只包含自己需要的那个接口。

// 不好的设计:一个接口包含所有功能
typedef struct {
    int (*read)(uint8_t *buf, uint32_t len);
    int (*write)(uint8_t *buf, uint32_t len);
    int (*config)(spi_cfg_t *cfg);
    int (*dma_send)(uint8_t *buf, uint32_t len);
    int (*dma_recv)(uint8_t *buf, uint32_t len);
    // ... 还有 20 多个函数指针
} spi_driver_t;

// 好的设计:拆分成多个小接口
typedef struct {
    int (*read)(uint8_t *buf, uint32_t len);
    int (*write)(uint8_t *buf, uint32_t len);
} spi_io_t;

typedef struct {
    int (*config)(spi_cfg_t *cfg);
} spi_config_t;

typedef struct {
    int (*dma_send)(uint8_t *buf, uint32_t len);
    int (*dma_recv)(uint8_t *buf, uint32_t len);
} spi_dma_t;
避坑提醒:我曾经见过有人为了"接口统一",把所有外设驱动都塞进同一个结构体。结果 UART 驱动里出现了 SPI 的片选函数,I2C 驱动里出现了 GPIO 的中断注册。这种"万能接口"最后谁都不敢用。

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

这个原则最直观,但也最容易违反。你想想看,一个驱动文件里,既处理硬件寄存器,又做数据校验,还负责日志输出——这明显是三个职责。一旦其中一个职责变化,整个文件都得改。

我个人的经验是:一个驱动模块的职责,应该能用一句话说清楚。比如"这个模块负责读取温度传感器的原始数据",那就别让它干别的。如果说不清楚,说明职责不单一。

模块 单一职责 不应包含
I2C 控制器驱动 管理 I2C 总线的时序和传输 传感器数据解析、日志输出
温度传感器驱动 读取温度值并返回原始数据 温度单位转换、报警逻辑
GPIO 驱动 控制引脚的电平和方向 按键消抖、LED 闪烁逻辑

你可能会问:那数据解析和单位转换放哪?放上层应用层,或者单独建一个"数据处理模块"。驱动层只负责和硬件打交道,别掺和业务逻辑。

4. 依赖倒置原则:面向接口编程,而非面向实现

这个原则听起来有点绕,其实核心就一句话:高层模块不应该依赖低层模块,两者都应该依赖抽象接口。

拿驱动开发来说,你的应用层代码不应该直接调用某个具体芯片的驱动函数。而是应该定义一个抽象接口,比如 sensor_read(),然后让具体的驱动去实现这个接口。这样换芯片的时候,你只需要换一个实现,应用层代码一行都不用改。

我曾经在一个项目里吃过这个亏。当时做的是多传感器数据采集,每个传感器都直接调用了各自的驱动 API。后来要换一个型号的温湿度传感器,结果应用层代码改了 30 多处。从那以后,我强制团队所有传感器驱动都必须实现统一的 sensor_if_t 接口。

// 定义抽象接口
typedef struct {
    int (*init)(void);
    int (*read)(uint8_t *data, uint32_t len);
    int (*deinit)(void);
} sensor_if_t;

// 具体实现:SHT30 温湿度传感器
static int sht30_init(void) { /* ... */ }
static int sht30_read(uint8_t *data, uint32_t len) { /* ... */ }
static int sht30_deinit(void) { /* ... */ }

const sensor_if_t sht30_driver = {
    .init = sht30_init,
    .read = sht30_read,
    .deinit = sht30_deinit,
};

// 应用层只依赖抽象接口,不关心具体是哪个传感器
void app_temperature_monitor(sensor_if_t *sensor) {
    sensor->init();
    uint8_t buf[4];
    sensor->read(buf, 4);
    // 处理数据...
    sensor->deinit();
}
关键点:依赖倒置让驱动代码具备了"插拔能力"。换驱动就像换 USB 设备一样,插上就能用,拔掉也不影响系统。

四个原则的协同效应

这四个原则不是孤立的。你想想看:

  • 单一职责做好了,自然就高内聚了
  • 接口隔离做对了,耦合度就降下来了
  • 依赖倒置用上了,接口隔离就有了实现基础

我个人的习惯是:先按单一职责把模块拆开,再用接口隔离原则设计每个模块的对外接口,最后用依赖倒置把模块之间的依赖关系理顺。三步走下来,代码结构基本就稳了。

小技巧:写驱动前先花 10 分钟画个模块关系图。如果发现某个模块的职责描述超过两句话,或者模块之间的依赖箭头乱成一团,那就说明设计有问题,趁早重构。