单片机也能玩依赖注入?

单片机也能玩依赖注入?聊聊嵌入式代码解耦的狠招

依赖注入(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!"); // 直接调串口
    }
}

这段代码有什么问题?

  1. 换芯片平台,这个文件必须改。 从STM32迁移到ESP32?hal_adc_read接口变了,改。
  2. 没法在PC上跑单元测试。 你电脑上哪来的ADC?哪来的GPIO?
  3. 报警方式写死了。 现在是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语言没法做依赖注入",把这篇文章甩给他。

相关推荐
bing_feilong2 小时前
ubuntu22.04: 安装ROS2并测试
嵌入式硬件·机器人
若风的雨2 小时前
【deepseek】Prefetchable的bar是否需要自己处理缓存一致性
嵌入式硬件
学嵌入式的小杨同学4 小时前
STM32 进阶封神之路(十六):PWM 波深度实战 —— 定时器输出 + LED 调光 + 电机调速(库函数 + 寄存器)
stm32·单片机·嵌入式硬件·mcu·硬件架构·硬件工程·智能硬件
世微 如初4 小时前
探秘 AP8660:电流模式升压 DC - DC,高转换与精密基准的完美融合
单片机·芯片·led电源驱动
ShiMetaPi4 小时前
从帧触发到事件驱动:RGB+EVS多模态融合下的无人机识别重构
嵌入式硬件·计算机视觉·嵌入式开发·无人机避障·事件相机
优信电子4 小时前
ESP32开发板单向点对点ESP-NOW无线通信
单片机·嵌入式·arduino
飞睿科技4 小时前
UWB技术推动户外直播摄像跟随应用演进
嵌入式硬件·数码相机·目标跟踪·uwb·相机云台
最概然4 小时前
嵌入式RPC分发器
嵌入式硬件·rpc
QYQ_11275 小时前
嵌入式学习——51单片机(下)
嵌入式硬件·学习·51单片机