4. 依赖注入基础概念:什么是依赖?什么是注入?控制反转(IoC)思想
好,我们正式开始聊依赖注入。很多嵌入式工程师第一次听到这个词,第一反应是——「这又是 Java 那套东西吧?跟我们 C 语言、单片机有什么关系?」
说实话,我当年也是这么想的。直到我在一个中型物联网项目里,被模块间的耦合折磨得痛不欲生,才真正理解了它的价值。
4.1 什么是依赖?—— 你代码里「离不开」的东西
先看一个最简单的例子。你写了一个 LED 闪烁的模块:
// led_blinker.c
#include "gpio.h"
void led_blink(void) {
gpio_set_level(GPIO_LED_PIN, 1);
delay_ms(500);
gpio_set_level(GPIO_LED_PIN, 0);
delay_ms(500);
}
这段代码里,led_blink 函数直接调用了 gpio_set_level 和 delay_ms。这就是依赖——你的模块要正常工作,必须依赖另一个模块的存在。
依赖的本质:模块 A 要完成功能,必须调用模块 B 的接口。没有 B,A 就编译不过、跑不起来。
依赖本身不是坏事。问题是——依赖的方向和强度。我见过太多代码,一个 .c 文件里 #include 了十几个头文件,改一个底层驱动,上层三个模块都要跟着改。这就是典型的紧耦合。
4.2 依赖带来的三个典型问题
我在项目中遇到过几次因为依赖关系没理清导致的「血案」。总结下来,依赖管理不好,主要会带来三个问题:
| 问题 | 表现 | 后果 |
|---|---|---|
| 编译耦合 | 改一个底层头文件,整个工程重新编译 | 大型项目编译一次 10 分钟以上 |
| 测试困难 | 要测 LED 模块,必须先烧录硬件固件 | 无法做单元测试,全靠板级调试 |
| 移植成本高 | 换一个 MCU 型号,所有模块都要改 | 代码复用率极低,每次都是重写 |
你想想看,如果你的 LED 模块直接调用了 STM32 的 HAL 库函数,换到 GD32 或者 AT32 的时候,是不是得把整个模块重写一遍?
4.3 什么是注入?—— 把依赖「从外面塞进来」
好,那怎么解决这个问题?核心思路就四个字:不要主动找,等着别人给。
传统写法是模块内部自己创建或直接调用依赖:
// 传统写法:模块主动依赖
void led_blink(void) {
// 直接调用底层函数
gpio_set_level(GPIO_LED_PIN, 1);
}
注入的写法是:模块只定义接口,依赖由外部传入:
// 注入写法:依赖从参数传入
typedef struct {
void (*set_pin)(int pin, int level);
void (*delay_ms)(uint32_t ms);
} LedBlinker_Ops;
void led_blink(LedBlinker_Ops *ops) {
ops->set_pin(GPIO_LED_PIN, 1);
ops->delay_ms(500);
ops->set_pin(GPIO_LED_PIN, 0);
ops->delay_ms(500);
}
注入的本质:依赖不再由模块内部创建或查找,而是由外部(调用者或容器)在运行时「注入」进来。
说白了,就是把「我要用谁」的决定权,从模块内部移到了模块外部。
4.4 控制反转(IoC)—— 谁控制谁?
依赖注入的背后,有一个更核心的思想——控制反转(Inversion of Control, IoC)。
传统模式下,你的模块是「主动控制者」:
- 它自己决定调用哪个 GPIO 驱动
- 它自己决定使用哪个延时函数
- 它自己决定依赖的具体实现
IoC 模式下,控制权反转了:
- 模块只声明「我需要什么能力」
- 由外部框架或调用者决定「给它什么实现」
- 模块不再控制依赖的选择,而是被控制
注意:IoC 不是一种具体的技术,而是一种设计原则。依赖注入(DI)是实现 IoC 最常见的手段,但不是唯一手段。服务定位器(Service Locator)也是一种 IoC 的实现方式。
4.5 嵌入式场景下的 IoC 价值
你可能会问:「在资源受限的 MCU 上搞 IoC,值得吗?」
我的回答是:越复杂的嵌入式系统,越需要 IoC。
我举个例子。之前做一个多传感器采集终端,需要支持 5 种不同的温湿度传感器。如果没有 IoC,代码大概是这样的:
// 没有 IoC:硬编码
void read_temperature(void) {
#ifdef SENSOR_SHT30
sht30_read();
#elif defined(SENSOR_DHT22)
dht22_read();
#elif defined(SENSOR_BME280)
bme280_read();
#endif
}
这种写法,每加一种传感器就要改一次主逻辑代码。而且条件编译多了,代码可读性极差。
用了 IoC 之后:
// 有 IoC:接口注入
typedef struct {
int (*init)(void);
int (*read_temp)(float *temp);
int (*read_humid)(float *humid);
} Sensor_Ops;
// 主逻辑只依赖接口,不依赖具体传感器
void process_sensor_data(Sensor_Ops *sensor) {
float temp, humid;
sensor->read_temp(&temp);
sensor->read_humid(&humid);
// 处理数据...
}
换传感器?只需要在初始化时注入不同的 Sensor_Ops 实例就行。主逻辑一行代码都不用改。
4.6 依赖注入的三种常见方式
在嵌入式 C 语言中,依赖注入主要有三种实现方式:
| 注入方式 | 实现方法 | 适用场景 |
|---|---|---|
| 接口注入 | 通过函数指针结构体传入 | 最常用,适合模块间解耦 |
| 参数注入 | 通过函数参数直接传入 | 简单场景,临时替换依赖 |
| 全局注册表 | 通过全局表查找依赖 | 适合驱动层统一管理 |
我个人习惯优先使用接口注入。它最清晰,也最容易做单元测试。你只需要在测试时传入一个 mock 的接口结构体,就能在不依赖硬件的情况下测试业务逻辑。
4.7 本章知识体系
下面这张图,把依赖、注入、IoC 三者的关系梳理清楚了:
4.8 避坑指南
最后,分享几个我踩过的坑:
我曾经在一个项目里过度设计,把所有函数都改成了接口注入。结果一个简单的 GPIO 翻转,要传 3 层结构体指针。代码是「解耦」了,但可读性一塌糊涂。
我的教训是:只在变化点使用依赖注入。稳定的底层驱动、不会换的硬件抽象,没必要强行解耦。
另外,注意注入的粒度。我建议:
- 模块级接口:用结构体函数指针表(接口注入)
- 函数级临时替换:用参数注入
- 全局服务(如日志、定时器):用全局注册表