3、依赖倒置原则(DIP):用接口解耦硬件驱动
依赖倒置原则,英文叫 Dependency Inversion Principle,简称 DIP。这名字听着挺唬人,其实说白了就一句话:高层模块不应该依赖低层模块,两者都应该依赖抽象。
我刚开始做嵌入式那会儿,觉得这玩意儿就是书本上的理论,离实际代码远着呢。直到有一次,我负责一个传感器数据采集模块,底层驱动换了三版,每次换我都得改上层代码,改得我头皮发麻。嗯,从那以后,我才真正理解 DIP 的价值。
3.1 什么是依赖倒置?先看一个反面教材
咱们先看一个最常见的写法。假设你有一个温度传感器,驱动是 DS18B20,上层业务要读取温度值。
// 低层模块:DS18B20 驱动
typedef struct {
uint8_t device_id;
float last_value;
} DS18B20_Handle;
float DS18B20_ReadTemperature(DS18B20_Handle* handle) {
// 硬件操作代码...
return 25.5f;
}
// 高层模块:业务逻辑
void ProcessTemperature(DS18B20_Handle* sensor) {
float temp = DS18B20_ReadTemperature(sensor);
if (temp > 30.0f) {
// 报警处理...
}
}
这段代码有什么问题?问题大了。ProcessTemperature 直接依赖了 DS18B20_Handle 这个具体类型。哪天你想换成 SHT30 传感器,或者用模拟温度计,你就得把 ProcessTemperature 整个重写。
3.2 核心思想:面向接口编程
DIP 的核心,就是引入一个抽象层——接口。高层模块只跟接口打交道,低层模块去实现这个接口。这样,高层模块就不关心底层用的是 DS18B20 还是 SHT30,它只关心“我能读到温度”这个能力。
我个人的习惯是,在嵌入式 C 里用结构体函数指针表来实现接口。C 语言没有 class 和 interface,但我们可以用结构体模拟出多态的效果。
// 抽象接口:温度传感器
typedef struct {
float (*read)(void* handle);
void (*init)(void* handle);
} TemperatureSensor_Interface;
// 高层模块:只依赖接口
void ProcessTemperature(TemperatureSensor_Interface* sensor, void* handle) {
float temp = sensor->read(handle);
if (temp > 30.0f) {
// 报警处理...
}
}
你看,ProcessTemperature 不再依赖具体的传感器类型了。它只依赖 TemperatureSensor_Interface 这个抽象。低层驱动只要实现这个接口,就能被高层模块调用。
3.3 实战:用接口解耦硬件驱动
咱们来做一个完整的实战案例。假设系统里有两个温度传感器:DS18B20(数字)和 NTC(模拟)。我们要让上层业务代码完全不感知传感器类型。
第一步:定义接口
// sensor_interface.h
typedef struct {
float (*read_temperature)(void* context);
void (*init)(void* context);
void (*deinit)(void* context);
} TemperatureSensor_Interface;
第二步:实现 DS18B20 驱动
// ds18b20_driver.c
#include "sensor_interface.h"
typedef struct {
uint8_t pin;
float last_value;
} DS18B20_Context;
static float ds18b20_read(void* context) {
DS18B20_Context* ctx = (DS18B20_Context*)context;
// 实际读取 DS18B20 的代码...
return 25.5f;
}
static void ds18b20_init(void* context) {
DS18B20_Context* ctx = (DS18B20_Context*)context;
// 初始化 GPIO、时序等...
}
const TemperatureSensor_Interface DS18B20_Driver = {
.read_temperature = ds18b20_read,
.init = ds18b20_init,
.deinit = NULL
};
第三步:实现 NTC 驱动
// ntc_driver.c
#include "sensor_interface.h"
typedef struct {
uint8_t adc_channel;
float beta_value;
} NTC_Context;
static float ntc_read(void* context) {
NTC_Context* ctx = (NTC_Context*)context;
// 读取 ADC,查表计算温度...
return 27.3f;
}
static void ntc_init(void* context) {
NTC_Context* ctx = (NTC_Context*)context;
// 初始化 ADC...
}
const TemperatureSensor_Interface NTC_Driver = {
.read_temperature = ntc_read,
.init = ntc_init,
.deinit = NULL
};
第四步:高层业务代码
// temperature_monitor.c
#include "sensor_interface.h"
typedef struct {
TemperatureSensor_Interface* driver;
void* context;
} TemperatureMonitor;
void Monitor_Init(TemperatureMonitor* monitor,
TemperatureSensor_Interface* driver,
void* context) {
monitor->driver = driver;
monitor->context = context;
if (driver->init) {
driver->init(context);
}
}
float Monitor_GetTemperature(TemperatureMonitor* monitor) {
return monitor->driver->read_temperature(monitor->context);
}
第五步:系统集成
// main.c
#include "temperature_monitor.h"
#include "ds18b20_driver.h"
#include "ntc_driver.h"
// 创建两个传感器实例
static DS18B20_Context ds18b20_ctx = { .pin = 1 };
static NTC_Context ntc_ctx = { .adc_channel = 0, .beta_value = 3950 };
// 创建两个监控器
TemperatureMonitor monitor1;
TemperatureMonitor monitor2;
void main(void) {
Monitor_Init(&monitor1, &DS18B20_Driver, &ds18b20_ctx);
Monitor_Init(&monitor2, &NTC_Driver, &ntc_ctx);
while (1) {
float t1 = Monitor_GetTemperature(&monitor1);
float t2 = Monitor_GetTemperature(&monitor2);
// 业务处理...
}
}
你看,main.c 里只需要在初始化时指定用哪个驱动,后续的业务代码完全不用改。这就是 DIP 带来的好处。
3.4 DIP 与可测试性
DIP 对测试的贡献,我觉得是最大的。你想想看,如果没有接口抽象,你要测试 ProcessTemperature 这个函数,就必须接上真实的传感器硬件。这在开发板上还行,但在 CI 环境里根本跑不了。
有了接口,我们可以轻松写一个 Mock 驱动:
// mock_sensor.c
#include "sensor_interface.h"
static float mock_temperature = 25.0f;
static float mock_read(void* context) {
(void)context;
return mock_temperature;
}
void Mock_SetTemperature(float temp) {
mock_temperature = temp;
}
const TemperatureSensor_Interface Mock_Driver = {
.read_temperature = mock_read,
.init = NULL,
.deinit = NULL
};
测试代码里,用 Mock_Driver 替换真实驱动,就可以在 PC 上跑单元测试了。想测高温报警?Mock_SetTemperature(35.0f) 就行。想测低温?Mock_SetTemperature(-10.0f)。完全不需要硬件。
3.5 DIP 的常见误区
我见过不少团队,说是在用 DIP,实际上只是把依赖关系从“直接调用”变成了“通过指针调用”,但高层模块依然知道低层模块的具体类型。比如:
// 错误的做法:高层模块依然依赖具体类型
void ProcessTemperature(DS18B20_Handle* sensor) {
// 虽然用了函数指针,但参数类型还是具体的
sensor->interface->read(sensor);
}
这不算真正的 DIP。真正的 DIP 要求高层模块完全不知道低层模块的具体类型。参数应该是 void* 或者接口指针,而不是具体的结构体指针。
| 对比项 | 传统写法 | DIP 写法 |
|---|---|---|
| 高层依赖 | 具体驱动类型 | 抽象接口 |
| 更换驱动 | 修改高层代码 | 只需修改初始化 |
| 单元测试 | 需要真实硬件 | 可用 Mock 驱动 |
| 代码复用 | 低 | 高 |
3.6 DIP 在分层架构中的位置
下面这张图展示了 DIP 如何改变分层架构的依赖方向。传统架构是高层依赖低层,DIP 让两者都依赖中间的抽象层。
3.7 小结
依赖倒置原则,说白了就是让高层模块和低层模块都去依赖一个稳定的抽象接口。这样做有三个好处:
- 解耦: 更换硬件驱动时,上层代码不用改
- 可测试: 可以用 Mock 驱动在 PC 上跑单元测试
- 可扩展: 新增一种传感器,只需要写一个新的驱动实现
在 C 语言里,用结构体函数指针表来实现接口,是嵌入式领域最实用的 DIP 落地方式。虽然比直接调用多写几行代码,但后期维护省下的时间,绝对值得。