单片机也能玩依赖注入?聊聊嵌入式代码解耦的狠招
依赖注入(Dependency Injection),这个词一般在Java/Spring的世界里才会频繁出现。一说到嵌入式,大家想到的是寄存器、中断、DMA,似乎跟"设计模式"八竿子打不着。
但你仔细想想------你有没有遇到过这种情况:想给一个模块换个底层驱动,结果发现它内部到处直接调HAL函数,改起来牵一发动全身?或者想对业务逻辑做单元测试,发现它死死绑着硬件,离了板子根本跑不了?
这些痛点,依赖注入正好能治。而且在C语言里实现它,比你想象的要简单得多。
一、依赖注入说白了就一句话
不要让一个模块自己去找它需要的东西,而是由外部把它需要的东西塞给它。
就这么简单。剩下的所有概念------IoC容器、接口抽象、构造注入、setter注入------都是这句话的不同实现方式。
大白话比喻
你去饭馆吃饭。
没有依赖注入的做法:你走进后厨,自己开冰箱找食材,自己切菜,自己开火炒。你跟那口锅、那个灶台、那个冰箱死死绑定了。换个饭馆?你得重新学它的厨房布局。
有依赖注入的做法:你坐在座位上,跟服务员说"来一份宫保鸡丁"。食材从哪来、谁来切、用什么锅炒------你不管。服务员(注入者)把做好的菜端给你。明天换了供应商、换了厨师,你的体验没有任何变化。
你就是那个业务模块。厨房里的锅碗瓢盆就是底层依赖。服务员就是注入机制。
二、嵌入式里的"依赖"长什么样
在写单片机代码的时候,最常见的依赖有这么几类:
硬件抽象层(HAL):GPIO操作、SPI/I2C通信、定时器、ADC......你的业务逻辑几乎必然要调用这些。
通信接口:串口发送、CAN发帧、TCP发包。你的协议栈要往外发数据,就得依赖一个发送通道。
存储:Flash读写、EEPROM操作。你的配置管理模块要存取参数。
时间源:获取时间戳、延时。你的状态机要做超时判断。
问题是------大多数嵌入式代码长这样:
c
// temperature_monitor.c ------ 温度监测模块
#include "hal_adc.h" // 直接include硬件驱动
#include "hal_gpio.h"
#include "uart_send.h"
void temp_monitor_run(void) {
int16_t raw = hal_adc_read(ADC_CH_TEMP); // 直接调HAL
float temp = raw * 0.01f;
if (temp > 85.0f) {
hal_gpio_set(PIN_ALARM, 1); // 直接操作GPIO
uart_send_string("OVERHEAT!"); // 直接调串口
}
}
这段代码有什么问题?
- 换芯片平台,这个文件必须改。 从STM32迁移到ESP32?
hal_adc_read接口变了,改。 - 没法在PC上跑单元测试。 你电脑上哪来的ADC?哪来的GPIO?
- 报警方式写死了。 现在是GPIO+串口,客户说下个版本要改成蜂鸣器+蓝牙推送,你又得钻进这个文件里大改。
这些问题的根源都是一个------模块自己去"找"了它的依赖,而不是由外部"给"它。
三、C语言实现依赖注入的三板斧
Java有注解、有Spring容器。C语言啥都没有,怎么做依赖注入?答案是:函数指针 + 结构体 + 初始化时赋值。没了,就这三样。
第一板斧:定义接口(函数指针结构体)
c
// temp_monitor_deps.h ------ 定义"我需要什么"
typedef struct {
// 我需要一个读温度的能力
int16_t (*read_temperature)(void);
// 我需要一个触发报警的能力
void (*trigger_alarm)(const char *msg);
// 我需要一个获取时间戳的能力
uint32_t (*get_tick_ms)(void);
} temp_monitor_deps_t;
这个结构体就是"接口"。它不关心谁来实现,只声明"我需要这些能力"。
第二板斧:模块只通过接口工作
c
// temp_monitor.c ------ 看不到任何硬件头文件了
#include "temp_monitor_deps.h"
typedef struct {
temp_monitor_deps_t deps; // 依赖被"注入"在这里
float last_temp;
uint32_t last_check_time;
} temp_monitor_t;
void temp_monitor_init(temp_monitor_t *self, const temp_monitor_deps_t *deps) {
self->deps = *deps; // 注入!
self->last_temp = 0;
self->last_check_time = 0;
}
void temp_monitor_run(temp_monitor_t *self) {
int16_t raw = self->deps.read_temperature(); // 通过注入的接口调用
self->last_temp = raw * 0.01f;
if (self->last_temp > 85.0f) {
self->deps.trigger_alarm("OVERHEAT!"); // 不知道报警怎么实现的,也不关心
}
self->last_check_time = self->deps.get_tick_ms();
}
注意看------整个文件里没有#include "hal_xxx.h"。它跟硬件彻底解耦了。
第三板斧:外部组装,注入依赖
c
// main.c ------ 在这里"装配"
#include "temp_monitor.h"
#include "hal_adc.h"
#include "hal_gpio.h"
#include "uart_send.h"
// 适配函数:把HAL包一层,匹配接口签名
static int16_t hw_read_temp(void) {
return hal_adc_read(ADC_CH_TEMP);
}
static void hw_alarm(const char *msg) {
hal_gpio_set(PIN_ALARM, 1);
uart_send_string(msg);
}
// 组装
static temp_monitor_t monitor;
void app_init(void) {
temp_monitor_deps_t deps = {
.read_temperature = hw_read_temp,
.trigger_alarm = hw_alarm,
.get_tick_ms = HAL_GetTick, // STM32 HAL的函数签名正好匹配
};
temp_monitor_init(&monitor, &deps);
}
所有的"脏活"------跟具体硬件打交道的代码------全集中在main.c(或一个专门的wiring.c)里。 业务模块干干净净。
四、这么做到底赚了什么
4.1 单元测试直接在PC上跑
c
// test_temp_monitor.c ------ 在你的电脑上用gcc编译运行
static int16_t fake_temperature = 0;
static int alarm_triggered = 0;
static uint32_t fake_tick = 0;
static int16_t mock_read_temp(void) { return fake_temperature; }
static void mock_alarm(const char *msg) { alarm_triggered = 1; }
static uint32_t mock_tick(void) { return fake_tick; }
void test_overheat_triggers_alarm(void) {
temp_monitor_t mon;
temp_monitor_deps_t deps = {
.read_temperature = mock_read_temp,
.trigger_alarm = mock_alarm,
.get_tick_ms = mock_tick,
};
temp_monitor_init(&mon, &deps);
// 正常温度,不报警
fake_temperature = 5000; // 50.00度
alarm_triggered = 0;
temp_monitor_run(&mon);
assert(alarm_triggered == 0);
// 超温,报警
fake_temperature = 8600; // 86.00度
temp_monitor_run(&mon);
assert(alarm_triggered == 1);
printf("PASS: overheat triggers alarm\n");
}
不需要开发板,不需要JTAG,不需要等硬件,gcc test_temp_monitor.c -o test && ./test,直接跑。这对开发效率的提升是质变。
4.2 换平台不再伤筋动骨
从STM32迁移到RISC-V?业务模块一行不改。只需要在main.c里重新写适配函数,把新平台的HAL接上就行。
4.3 同一套业务逻辑适配不同产品
高端产品用SPI传感器,低端产品用ADC直接采样。两套deps配置,同一个温度监测模块。
c
// 高端产品
deps.read_temperature = spi_sensor_read;
// 低端产品
deps.read_temperature = adc_direct_read;
// 业务模块完全一样
五、几个实战中踩过的坑
坑一:接口粒度太细
有人把每个HAL函数都独立成一个注入项。结果deps结构体有二三十个函数指针,看着就头皮发麻。
建议:按"能力"而非"函数"来划分。 比如"通信能力"是一个结构体,包含send/recv/flush;"存储能力"是另一个结构体,包含read/write/erase。不要把send和recv拆成两个独立注入项。
坑二:运行时开销焦虑
"函数指针调用有额外开销,比直接调用慢!"
是,慢了一次间接寻址。在Cortex-M4上大约2-3个时钟周期。你的业务逻辑跑一次少说几百上千周期。这点开销真的无所谓。
如果你做的是纳秒级别的信号处理,那确实不适合在热路径上用函数指针。但99%的嵌入式业务逻辑不在这个级别。
坑三:过度设计
只有一种传感器、一个平台、一个人开发、永远不测试------这种项目搞依赖注入就是没事找事。
判断标准很简单:你会不会需要替换这个依赖? 如果答案是"可能会",就注入。如果答案是"不可能,这辈子不换",直接调就完了。
六、进阶:注册式注入与"穷人的IoC"
上面展示的是"构造注入"------初始化时一次性塞进去。在更复杂的场景下,你可能需要动态注册能力。
c
// 一个极简的服务注册表
typedef struct {
void *services[SERVICE_MAX];
} service_registry_t;
void registry_register(service_registry_t *reg, uint8_t id, void *impl) {
reg->services[id] = impl;
}
void *registry_get(service_registry_t *reg, uint8_t id) {
return reg->services[id];
}
各模块启动时把自己的实现注册进去,需要的时候按ID取。这就是一个最小的IoC容器。Spring的核心思想,用C也就十几行。
当然,C语言没有类型安全的反射机制,所以取出来的是void*,用的时候得自己强转。这是C的宿命------自由且危险。
七、回到比喻
还记得开头的饭馆吗?
- deps结构体 = 菜单。列出了你需要的所有"菜品"(能力),但不规定食材从哪来。
- init函数 = 下单。服务员把菜单上的每一项都落实到具体的厨师和食材上。
- main.c / wiring.c = 后厨调度。知道哪个厨师做什么菜、食材从哪个供应商来。
- mock函数 = 你在家自己做的简易版。食材简单、味道凑合,但够你测试"这道菜的口味配方对不对"。
你(业务模块)只需要说"我要宫保鸡丁",不用关心鸡肉是冷鲜的还是冷冻的。哪天换了供应商,你照样吃得舒服。
八、总结:三个记住
记住第一点:依赖注入不是框架特性,是设计思路。一个函数指针结构体 + 初始化时赋值,就完成了。
记住第二点:核心判断标准是"这个依赖会不会变"。会变的就注入,不会变的别瞎折腾。
记住第三点:最大收益不是"架构好看",是"能测试"。一个能在PC上跑单元测试的嵌入式项目,bug率和别人不是一个量级的。
下次有人跟你说"C语言没法做依赖注入",把这篇文章甩给他。