模块化设计原则:驱动开发的"四根柱子"
做驱动开发久了,你会发现一个规律:代码腐烂的速度,往往和项目复杂度成正比。我见过太多驱动代码,刚开始写的时候挺清爽,三个月后就成了没人敢动的"屎山"。问题出在哪?说白了,就是没遵守模块化设计的基本原则。
今天聊的这四个原则——高内聚低耦合、接口隔离、单一职责、依赖倒置——不是我发明的,是无数前辈踩坑踩出来的。我个人习惯把它们称为驱动开发的"四根柱子"。你把这四根柱子立稳了,代码的复用性和可维护性自然就上来了。
1. 高内聚低耦合:驱动模块的"边界感"
什么叫高内聚?一个模块内部的东西,应该高度相关。比如 SPI 控制器驱动,你就别把 GPIO 的初始化逻辑塞进去。低耦合呢?模块之间的依赖要尽可能少,最好只通过接口通信。
我在项目中遇到过这样一个案例:某款传感器的驱动,把 I2C 读写、数据解析、日志打印、甚至电源管理全写在一个文件里。后来要换一个 I2C 控制器,好家伙,整个驱动都得重写。这就是典型的"内聚低、耦合高"。
怎么做到低耦合?我总结了一个简单方法:模块之间只通过函数指针或抽象接口交互。别直接调用对方的结构体成员,更别共享全局变量。你想想看,两个模块共用一个全局变量,调试的时候你改我改大家改,出问题了谁负责?
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;
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();
}
四个原则的协同效应
这四个原则不是孤立的。你想想看:
- 单一职责做好了,自然就高内聚了
- 接口隔离做对了,耦合度就降下来了
- 依赖倒置用上了,接口隔离就有了实现基础
我个人的习惯是:先按单一职责把模块拆开,再用接口隔离原则设计每个模块的对外接口,最后用依赖倒置把模块之间的依赖关系理顺。三步走下来,代码结构基本就稳了。