2.4 依赖注入——管道不自己造水
水龙头里的市政工程
你拧开洗手间的水龙头。水出来了。
水从哪里来?不是你家里那堵墙凭空产生的。水从城市的自来水主管网,经过小区的供水干管,穿过你所在楼栋的立管,进入你家的入户管,最终从水龙头流出来。你家里的水管不负责打井。它只是接收上游传来的水,并在自身范围内完成输送。
现在想象另一个世界:物业告诉你,新装的这栋楼没有接市政水管。你家的每个水龙头后面都附着一台小型水泵和一口50米深的微型井。每次你拧开水龙头,小水泵启动,从你脚下的地层里抽水。32层,每层4户,128口水井互相抢夺同一片地下含水层。三个月后,大楼地基开始不均匀沉降。
你不会觉得“家里打一口井多方便,不用交水费“,因为你知道这栋楼会塌。
但你在写嵌入式C代码的时候,这种“自己打井“的模式每天都在发生。
核心洞察:依赖应该来自上游供给,而不是自己创造。 你家水管不负责打井,水来自市政主管;若每个水龙头后都附一台水泵和一口井,128口井争抢同一片含水层,大楼地基必然沉降。代码中模块自己创建依赖,就是这个“打井模式“。
直接依赖:你在自己打井
一个典型的传感器驱动:
/* temperature_sensor.c */
#include "spi_driver.h"
float TempSensor_Read(void) {
uint8_t tx_data[2] = {0x50, 0x00}; /* 读寄存器0x00 */
uint8_t rx_data[2];
/* 直接调用SPI驱动——这就是'自己打井' */
SPI_Transmit(SPI2, tx_data, 2);
SPI_Receive(SPI2, rx_data, 2);
uint16_t raw = (rx_data[0] << 8) | rx_data[1];
return raw * 0.0625f; /* LSB = 0.0625°C */
}
这段代码工作正常。TempSensor_Read 调用 SPI_Transmit 和 SPI_Receive,每次返回正确的温度值。
问题不是“现在能不能工作“。问题是“什么时候不能工作“。
场景一:你换了一颗MCU。SPI_Transmit 和 SPI_Receive 的寄存器操作全部变了。你需要修改 spi_driver.c,这是预期的。但 temperature_sensor.c 呢?它直接调用了 SPI_Transmit,而 SPI_Transmit 在新芯片上的头文件位置、函数签名、甚至函数名称都可能变了。你需要修改 temperature_sensor.c。但它的逻辑(读寄存器0x50、转换LSB到温度值)和芯片型号毫无关系。
场景二:你需要测试 TempSensor_Read 的逻辑。但为了运行这个测试,你必须有一个真实的SPI外设在工作,或者你需要连接到真实的温度传感器芯片。在PC上的单元测试环境里,你没有这些东西。
场景三:产品升级到第二代,温度传感器从SPI接口换成了I2C接口。TempSensor_Read 里的LSB转换逻辑(raw * 0.0625f)完全没变,但因为函数体内写死了 SPI_Transmit 和 SPI_Receive,你不得不重写整个函数。
这三个场景都在告诉你同一件事:temperature_sensor.c 和 spi_driver.c 之间有一条不应该存在的刚性连接。
在建筑的隐喻里,这就是你家水龙头后面那口50米深的微型井。
核心洞察:直接依赖是一条“现在能用、将来会断“的刚性连接。
TempSensor_Read写死调用SPI_Transmit:换MCU要改、单元测试离不开真实硬件、SPI换I2C要重写整个函数——三个场景都在说同一件事:传感器驱动与SPI驱动之间有一条不该存在的刚性连接。
依赖注入:让水从市政主管来
依赖注入的核心思想就是三个字:别人给。
你的模块不创建自己的依赖,不主动寻找依赖,不假设依赖的具体实现。它声明自己“需要什么“,然后在初始化时接收这些依赖,由外部的组装代码负责把具体的依赖传进来。
在C语言里(注意,不是Spring,不是Python装饰器,不是C++的std::function,就是朴素的ANSI C),实现依赖注入的方式是函数指针表和配置结构:
/* sensor_if.h — 定义一个"通信接口"合约 */
typedef void (*comm_write_fn_t)(const uint8_t *data, uint16_t len);
typedef void (*comm_read_fn_t)(uint8_t *data, uint16_t len);
typedef struct {
comm_write_fn_t write;
comm_read_fn_t read;
} CommInterface_t;
/* temperature_sensor.h — 依赖声明在初始化参数里 */
typedef struct {
CommInterface_t comm; /* 我需要一个通信接口——至于是SPI还是I2C,我不关心 */
uint8_t address; /* 传感器在总线上的地址 */
} TempSensorConfig_t;
typedef struct TempSensor_s TempSensor_t; /* 不透明指针 */
TempSensor_t *TempSensor_Init(const TempSensorConfig_t *config);
float TempSensor_Read(TempSensor_t *sensor);
/* temperature_sensor.c — 实现不依赖任何具体的通信方式 */
struct TempSensor_s {
CommInterface_t comm;
uint8_t address;
};
TempSensor_t *TempSensor_Init(const TempSensorConfig_t *config) {
static TempSensor_t sensor; /* 静态分配——嵌入式铁律 */
sensor.comm = config->comm; /* 接收依赖,不创建依赖 */
sensor.address = config->address;
return &sensor;
}
float TempSensor_Read(TempSensor_t *sensor) {
uint8_t tx[2] = {0x50, 0x00};
uint8_t rx[2];
sensor->comm.write(tx, 2); /* 通过注入的接口通信 */
sensor->comm.read(rx, 2);
uint16_t raw = (rx[0] << 8) | rx[1];
return raw * 0.0625f;
}
/* main.c — 组装代码:在这里把SPI"接"到温度传感器上 */
#include "spi_driver.h"
#include "temperature_sensor.h"
void SPI_WriteWrapper(const uint8_t *data, uint16_t len) {
SPI_Transmit(SPI2, (uint8_t *)data, len);
}
void SPI_ReadWrapper(uint8_t *data, uint16_t len) {
SPI_Receive(SPI2, data, len);
}
int main(void) {
SPI_Init(SPI2, 1000000); /* 1MHz */
TempSensorConfig_t config = {
.comm = { SPI_WriteWrapper, SPI_ReadWrapper }, /* 注入SPI */
.address = 0x48
};
TempSensor_t *sensor = TempSensor_Init(&config);
while (1) {
float temp = TempSensor_Read(sensor);
/* ... */
}
}
现在回答之前三个场景:
场景一:换MCU。 你改 spi_driver.c,重新实现 SPI_Transmit 和 SPI_Receive。temperature_sensor.c 无需任何修改。
场景二:单元测试。 你写一个mock函数:
/* test_temperature_sensor.c */
static uint8_t mock_rx[2] = {0x0F, 0xA0}; /* 25°C: 4000 * 0.0625 */
static void MockWrite(const uint8_t *data, uint16_t len) { /* no-op */ }
static void MockRead(uint8_t *data, uint16_t len) {
data[0] = mock_rx[0];
data[1] = mock_rx[1];
}
void Test_TempSensor_Read_Returns_Correct_Temperature(void) {
TempSensorConfig_t config = {
.comm = { MockWrite, MockRead },
.address = 0x48
};
TempSensor_t *sensor = TempSensor_Init(&config);
float temp = TempSensor_Read(sensor);
assert(25.0f == temp);
}
不需要真实的SPI硬件,不需要物理温度传感器。你在PC上用GCC就能跑这个测试。
场景三:传感器接口从SPI换成I2C。 你写一个 I2C_WriteWrapper 和 I2C_ReadWrapper,在 main.c 里把它们注入给 TempSensor_Init。temperature_sensor.c 无需任何修改。
核心洞察:依赖注入的核心是三个字:别人给。 模块不创建、不寻找依赖,只在初始化时通过函数指针表和配置结构接收它;组装层把SPI或I2C接进来。由此换MCU不改传感器、测试用mock即可、协议变更只换wrapper——
temperature_sensor.c一行不用动。
在Cortex-R5上:你不必为“优雅“付出代价
你可能已经注意到了:上述依赖注入的实现用到了函数指针。每次通信调用都经过一次函数指针间接跳转。在Cortex-R5上,这会增加几条指令的延迟,大概几个CPU周期。用一个函数指针表来存放两个函数指针,又额外占用8个字节的RAM。
这是成本。但这个成本换来了什么?
换来了可测试性。没有依赖注入,你没法在不连接真实硬件的情况下测试 TempSensor_Read 的温度转换逻辑。对于一个汽车ECU项目来说,在目标硬件上调试一次需要:连上调试器、烧录Flash、设置断点、触发中断、观察变量,这个过程可能耗时五分钟。在PC上跑一次单元测试耗时0.01秒。你每天要验证这个函数十次。一年下来,依赖注入帮你省下的时间是以“工作日“为单位计算的。
换来了可移植性。没有依赖注入,传感器驱动和SPI驱动是刚性耦合的。换接口协议意味着重写传感器驱动。有依赖注入,传感器驱动不知道也不关心底层是SPI还是I2C。它只知道“有人会在 init 时给我一个能读能写的通信接口“。
换来了并行开发。团队成员A写 temperature_sensor.c,只需要知道 CommInterface_t 的合约。团队成员B写 spi_driver.c,只需要实现 SPI_WriteWrapper 和 SPI_ReadWrapper。两个人可以并行工作,不需要等对方先完成。在汽车行业的开发周期里,Tier-1的软件团队少则3人,多则30人,这种并行能力直接决定项目是否延期。
而你付出的代价是:8个字节RAM和几个CPU周期。 如果这都不值得,那说明你的SRAM已经紧张到不到256字节空闲。在这种极限场景下,你需要做的是先解决硬件资源问题,而不是在软件架构上妥协。
核心洞察:依赖注入的成本,换来可测试性、可移植性与并行开发。 几个CPU周期和8字节RAM的代价,换回0.01秒的PC单测对五分钟的硬件调试、换接口协议不用重写驱动、A写传感器B写驱动可并行;若这点代价都承受不起,说明SRAM已紧张到不足256字节,该先解决硬件问题。
依赖注入不是框架,是思维习惯
很多嵌入式工程师一听到“依赖注入“就会联想到Java的Spring框架:XML配置文件、注解、自动装配、容器管理。然后他们说:“我们这是裸机C,没有反射,没有运行时类型信息,搞不了依赖注入。”
这是误解。
依赖注入不是框架提供的功能,它是一种设计思维。它的本质是:一个模块需要的东西,通过参数传入,而不是自己在内部创建或寻找。 你在用一个文件里写了 static CommInterface_t comm; 然后 comm.write = SPI_WriteWrapper;,这已经是依赖注入。不需要框架,不需要容器,不需要XML。就是结构体赋值和函数指针,C语言1972年就支持的特性。
在AUTOSAR里,这种思想体现在每个模块的初始化过程中。Can_Init(&Can_Config)、EcuM_Init(&EcuM_Config)、ComM_Init(&ComM_Config),每个模块在初始化时接收一个配置结构,配置结构里包含了该模块运行所需的所有外部信息。模块不主动读取EOL(End of Line)参数、不主动查询硬件版本、不主动判断当前是量产模式还是工程模式。所有这些信息都在配置结构里,由集成方在组装阶段决定。
这就是建筑里的“市政供水“模式:每家的水龙头只管出水,水的来源由总管道和阀门决定。集成方(物业/水务公司)负责把正确的管道接到正确的水龙头上。水龙头的制造商不需要知道(也不应该知道)水是从水库来的还是从井里来的。
核心洞察:依赖注入不是框架,是“别人给,不是自己造“的思维习惯。 它不需要Spring、容器或XML,朴素C里一个配置结构体加函数指针就是依赖注入;AUTOSAR每个模块的
Can_Init(&Can_Config)式初始化,正是这种思想的制度化。
本篇小结
- 依赖注入让模块接收依赖而不是创建依赖:通过C语言的函数指针表和配置结构实现。
- 解决的三个问题:换硬件时避免波及、单元测试时不需要真实硬件、接口协议变更时保护上层逻辑。
- 成本与收益:在Cortex-R5上,成本是函数指针的间接跳转(几个CPU周期)和指针存储(几个字节RAM),收益是可测试性、可移植性和并行开发能力。
- 依赖注入是设计思维,而不是框架特性:“别人给,不是自己造”。
- AUTOSAR的模块初始化全部遵循这个模式。
【下集预告】:你的系统一切就绪:砖块坚实、接口清晰、职责单一、依赖注入。但现在产品经理说:“V2.1要增加两种新的CAN报文类型。“你是修改现有的报文分发函数,还是在不动现有代码的前提下扩展?开闭原则告诉你答案:扩建不用拆墙。