Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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报文类型。“你是修改现有的报文分发函数,还是在不动现有代码的前提下扩展?开闭原则告诉你答案:扩建不用拆墙。