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 整个重写。

我曾经踩过的坑: 有个项目,产品经理说“传感器型号先定 DS18B20,后面可能换”。我当时图省事,直接硬编码了。结果三个月后真要换传感器,我花了整整两天改上层代码,还引入了一个隐蔽的 bug——温度值单位搞混了。从那以后,我再也不敢让高层模块直接依赖具体驱动了。

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)。完全不需要硬件。

关键指标: 引入 DIP 后,我的项目里硬件相关代码的单元测试覆盖率从 15% 提升到了 85%。不是因为代码写得更好了,而是因为可测试性设计让测试变得可行。

3.5 DIP 的常见误区

我见过不少团队,说是在用 DIP,实际上只是把依赖关系从“直接调用”变成了“通过指针调用”,但高层模块依然知道低层模块的具体类型。比如:

// 错误的做法:高层模块依然依赖具体类型
void ProcessTemperature(DS18B20_Handle* sensor) {
    // 虽然用了函数指针,但参数类型还是具体的
    sensor->interface->read(sensor);
}

这不算真正的 DIP。真正的 DIP 要求高层模块完全不知道低层模块的具体类型。参数应该是 void* 或者接口指针,而不是具体的结构体指针。

对比项 传统写法 DIP 写法
高层依赖 具体驱动类型 抽象接口
更换驱动 修改高层代码 只需修改初始化
单元测试 需要真实硬件 可用 Mock 驱动
代码复用 低 高

3.6 DIP 在分层架构中的位置

下面这张图展示了 DIP 如何改变分层架构的依赖方向。传统架构是高层依赖低层,DIP 让两者都依赖中间的抽象层。

依赖倒置原则:架构依赖关系对比 传统架构(高层依赖低层) 高层业务模块 依赖 具体驱动(DS18B20) DIP 架构(都依赖抽象) 高层业务模块 依赖 抽象接口 实现 DS18B20 驱动 NTC 驱动 Mock 驱动(测试) 传统架构:高层→低层(箭头向下) | DIP 架构:高层→接口←低层(双向依赖抽象) DIP 让高层模块不再关心底层是 DS18B20、NTC 还是 Mock,只关心“能读到温度”这个能力
我的经验: 刚开始用 DIP 时,别想着一步到位把所有驱动都抽象化。我建议先从最常换的硬件开始,比如传感器、通信接口。等团队习惯了这种写法,再逐步推广到其他模块。一口吃不成胖子,架构演进也是。

3.7 小结

依赖倒置原则,说白了就是让高层模块和低层模块都去依赖一个稳定的抽象接口。这样做有三个好处:

  • 解耦: 更换硬件驱动时,上层代码不用改
  • 可测试: 可以用 Mock 驱动在 PC 上跑单元测试
  • 可扩展: 新增一种传感器,只需要写一个新的驱动实现

在 C 语言里,用结构体函数指针表来实现接口,是嵌入式领域最实用的 DIP 落地方式。虽然比直接调用多写几行代码,但后期维护省下的时间,绝对值得。