1. 依赖注入基础:从硬编码到松耦合

各位同学好,我是老周。做嵌入式十几年,踩过的坑比写过的代码还多。今天咱们聊一个听起来很“上层”的概念——依赖注入。别急着关页面,这玩意儿在嵌入式里不但能用,而且能救命。

先问个问题:你写单片机程序时,是不是经常这样——

// 传统写法:直接调用硬件
void led_on(void) {
    HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET);
}

void temperature_report(void) {
    float temp = read_temperature_sensor();  // 直接调用传感器驱动
    if (temp > 50.0f) {
        led_on();
    }
}

这段代码有什么问题?嗯,temperature_report 和 read_temperature_sensor 绑死了。换一个传感器型号,你得改这个函数。更麻烦的是——你想在 PC 上模拟测试?没门。

什么是依赖注入?说白了就是“把东西从外面塞进来”

依赖注入(Dependency Injection,简称 DI)的核心思想很简单:一个模块不应该自己去创建它依赖的对象,而是由外部把依赖“注入”进来。

你想想看,现实世界里你修车,不会自己造螺丝刀吧?你把螺丝刀“注入”到修车这个动作里。程序也一样。

用 C 语言实现一个最简单的 DI:

// 定义接口
typedef struct {
    void (*on)(void);
    void (*off)(void);
} led_if_t;

// 具体实现 A:板载 LED
static void gpio_led_on(void) { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, 1); }
static void gpio_led_off(void) { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, 0); }
const led_if_t gpio_led = { gpio_led_on, gpio_led_off };

// 具体实现 B:I2C 扩展 LED
static void i2c_led_on(void) { i2c_write(0x20, 0x01); }
static void i2c_led_off(void) { i2c_write(0x20, 0x00); }
const led_if_t i2c_led = { i2c_led_on, i2c_led_off };

// 业务逻辑:不关心具体 LED 类型
void temperature_report(const led_if_t *led) {
    float temp = read_temperature_sensor();
    if (temp > 50.0f) {
        led->on();  // 通过接口调用,不直接操作硬件
    }
}

// 注入点:在 main 里决定用哪个实现
int main(void) {
    const led_if_t *my_led = &gpio_led;  // 或者 &i2c_led
    temperature_report(my_led);
}

看到了吗?temperature_report 不再知道底层是 GPIO 还是 I2C。它只认接口。这就是 DI 的雏形。

核心要点:依赖注入的本质是“控制反转”——不是你主动去 new 一个依赖,而是别人把依赖递给你。

为什么嵌入式需要 DI?三个字:要命的事

我见过太多嵌入式项目,代码写死了硬件依赖。结果呢?

  • 换 MCU 系列? 重写驱动层,业务逻辑也得跟着改。
  • 加一个模拟器做 CI 测试? 不可能,因为代码直接调寄存器。
  • 两个工程师并行开发? A 等 B 写完驱动才能开始写逻辑。

DI 能解决这些问题。具体来说:

场景 没有 DI 的后果 有 DI 的好处
硬件换型 业务代码大面积修改 只换注入的驱动对象
单元测试 无法在 PC 上运行 注入 Mock 对象即可测试
团队协作 强依赖阻塞开发 接口先行,并行开发
代码复用 每个项目重写一遍 业务逻辑跨平台复用
调试定位 问题淹没在耦合代码中 可以单独隔离测试每个模块

我记得有一次做车载项目,客户中途换了温度传感器型号。传统写法的话,至少改 5 个文件。但因为用了 DI,我只改了 main.c 里的一行注入代码。项目经理当时看我的眼神……嗯,有点东西。

传统硬编码 vs DI 模式:一张表说清楚

咱们直接对比两种写法,你感受一下差距。

对比维度 传统硬编码 DI 模式
依赖创建 模块内部直接 new/create 外部传入,模块只管用
硬件耦合 函数内直接调 HAL/寄存器 通过接口指针调用
可测试性 极差,必须上硬件 好,可注入模拟对象
代码修改影响 牵一发动全身 局部修改,不影响调用方
编译依赖 强依赖具体头文件 只依赖接口头文件
运行时开销 无额外开销 一次间接调用(函数指针)
代码可读性 直观但僵硬 需要理解接口抽象

避坑指南:我曾经在一个资源极度受限的 Cortex-M0 项目上强行用 DI,每个函数都通过指针调用,结果中断延迟增加了 30%。后来学乖了——DI 用在慢速路径(传感器读取、状态机切换)上,快速路径(中断服务、DMA 回调)保持直接调用。别为了设计模式把实时性搞丢了。

DI 在嵌入式中的三种常见形态

嵌入式资源有限,别整那些花里胡哨的框架。我常用的就三种:

  1. 编译时 DI(最轻量):通过条件编译或链接脚本选择实现。适合产品线固定、不需要运行时切换的场景。
  2. 运行时 DI(最灵活):通过函数指针或结构体接口在运行时注入。适合需要动态切换硬件或模拟测试的场景。
  3. 混合模式(最实用):编译时决定注入点,运行时决定具体实现。我大部分项目用这个。

举个例子,混合模式长这样:

// 编译时选择接口
#if defined(USE_SENSOR_A)
    #include "sensor_a.h"
    #define SENSOR_INIT sensor_a_init
#elif defined(USE_SENSOR_B)
    #include "sensor_b.h"
    #define SENSOR_INIT sensor_b_init
#endif

// 运行时注入
typedef struct {
    float (*read_temp)(void);
    uint8_t (*read_humidity)(void);
} sensor_if_t;

// 在 main 里根据配置注入
int main(void) {
    sensor_if_t sensor;
    SENSOR_INIT(&sensor);  // 编译时决定初始化哪个传感器
    // 之后所有代码只通过 sensor 接口操作
    float temp = sensor.read_temp();
}

这种写法,编译时决定“用哪个传感器”,运行时通过接口操作。既保留了 DI 的灵活性,又没增加太多开销。

一张图看懂 DI 的核心逻辑

下面这张 SVG 图展示了传统硬编码和 DI 模式的本质区别。我建议你多看几眼,理解了这个结构,后面所有章节都好办。

传统硬编码 vs DI 模式对比 传统硬编码 业务模块 直接调用 HAL/寄存器 具体硬件 A 换硬件 → 改业务代码 测试 → 必须上真实硬件 高耦合 · 低复用 DI 模式 业务模块 接口抽象层(函数指针) 硬件 A 实现 硬件 B 实现 换硬件 → 换注入对象 测试 → 注入 Mock 即可 低耦合 · 高复用

我的建议:刚开始接触 DI 时,别想着一步到位。先从“把硬件调用改成函数指针”开始。哪怕只改一个模块,你也能立刻感受到测试和调试的便利。我带的团队里,新人通常花两周就能上手——前提是别一开始就搞什么 DI 容器、自动注入,嵌入式没那个必要。

小结:DI 不是银弹,但它是好锤子

依赖注入不是什么高深莫测的设计模式。说白了,就是别让你的代码“硬点”太多。把变化的部分抽象成接口,从外部注入进来。

嵌入式开发中,DI 最大的价值不是“优雅”,而是可测试、可调试、可维护。你想想看,当你的代码能在 PC 上跑单元测试,当换硬件只需要改一行注入代码,当新同事看你的代码能快速理解模块边界——这些才是 DI 真正值钱的地方。

当然,DI 也有代价:多一层间接调用、代码量增加、需要设计接口。但以我这些年的经验来看,对于超过 5000 行的嵌入式项目,DI 带来的收益远大于成本。