单片机也能玩依赖注入?

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

依赖注入(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语言没法做依赖注入",把这篇文章甩给他。

相关推荐
fie888919 小时前
基于51单片机的航模遥控器6通道接收机程序
单片机·嵌入式硬件·51单片机
bu_shuo19 小时前
嵌入式硬件工程师VS单板硬件工程师
嵌入式硬件·电子工程师·单板硬件
llilian_1619 小时前
选择北斗导航卫星信号模拟器注意事项总结 北斗导航卫星模拟器 北斗导航信号模拟器
功能测试·单片机·嵌入式硬件·测试工具·51单片机·硬件工程
Yyq1302086968220 小时前
MH2457,‌国产 32 位屏驱 MCU‌芯片,支持‌1080P 高清显示‌与‌以太网通信‌,广泛应用于两轮车仪表盘及工控屏等领域
单片机·嵌入式硬件
爱吃程序猿的喵21 小时前
南邮计科电工电子实验B《RLC串联谐振电路》实验报告
单片机·嵌入式硬件
独小乐21 小时前
009.中断实践之实现按键测试|千篇笔记实现嵌入式全栈/裸机篇
linux·c语言·驱动开发·笔记·嵌入式硬件·arm
XINVRY-FPGA21 小时前
XC7VX690T-2FFG1157I Xilinx AMD Virtex-7 FPGA
arm开发·人工智能·嵌入式硬件·深度学习·fpga开发·硬件工程·fpga
bubiyoushang8881 天前
利用STM32实现Modbus通信(RTU从机方案)
stm32·单片机·嵌入式硬件
cmpxr_1 天前
【单片机】常用设计模式
单片机·嵌入式硬件·设计模式
杰杰桀桀桀1 天前
4*4无时延矩阵键盘(非阻塞)--附代码链接
stm32·单片机·嵌入式硬件·矩阵·计算机外设·无时延矩阵键盘