3. 传感器硬件接口基础:I2C、SPI、UART协议简述与固件抽象层设计

做传感器固件,说白了就是跟芯片的脚打交道。你想想看,传感器芯片再智能,也得通过几根线把数据传出来。我这些年经手的项目,十有八九都离不开I2C、SPI、UART这三个老伙计。今天咱们就聊聊它们,重点说说固件层怎么把它们抽象好,免得以后换颗芯片就得重写一遍驱动。

3.1 三大接口协议速览

先快速过一遍基础。我不打算讲得太深,毕竟咱们是搞固件的,不是做芯片设计的。但该懂的核心点,一个都不能少。

3.1.1 I2C(Inter-Integrated Circuit)

I2C 只有两根线:SCL(时钟)和 SDA(数据)。它是半双工的,靠地址寻址。一个总线上可以挂几十个设备,每个设备有唯一的7位或10位地址。

核心特点:

  • 两根线,节省IO口
  • 速度一般:标准模式100kHz,快速模式400kHz,高速模式3.4MHz
  • 需要上拉电阻
  • 有应答机制(ACK/NACK)

我踩过的坑: 曾经有个项目,I2C总线老是莫名其妙卡死。查了两天才发现,是某个从设备在掉电状态下把SDA线拉低了。从那以后,我习惯在初始化时先发一个停止条件,把总线复位一下。

3.1.2 SPI(Serial Peripheral Interface)

SPI 是四线制:SCLK(时钟)、MOSI(主出从入)、MISO(主入从出)、CS(片选)。全双工,速度可以跑得很快,几十MHz很常见。

核心特点:

  • 四根线,IO占用多
  • 全双工,收发可以同时进行
  • 没有应答机制,可靠性靠协议层保证
  • 每个从设备需要一根独立的CS线

个人习惯: 我一般把SPI的时钟极性(CPOL)和时钟相位(CPHA)单独做成配置项。因为不同传感器厂商对这两个位的定义经常不一样,写死在代码里后面改起来很痛苦。

3.1.3 UART(Universal Asynchronous Receiver/Transmitter)

UART 只需要TX和RX两根线。异步通信,没有时钟线。双方得约定好波特率、数据位、停止位、校验位。

核心特点:

  • 两根线,距离可以传得远(RS232/RS485)
  • 异步,不需要时钟同步
  • 点对点通信,不能像I2C那样挂多个设备
  • 波特率要匹配,否则数据全乱

注意: 很多传感器用UART输出数据时,会先发一个固定的帧头(比如0xAA 0x55)。我建议你在固件里一定要做帧头校验,别偷懒。我曾经见过一个项目,没做帧头校验,结果上电瞬间的毛刺被当成有效数据,整个系统直接跑飞。

3.2 三种协议的对比

特性 I2C SPI UART
线数 2 4(最少3) 2
通信方式 半双工 全双工 全双工
速度 中低速 高速 中低速
多设备支持 是(地址寻址) 是(片选) 否(点对点)
硬件复杂度
典型传感器 温湿度、气压 加速度计、陀螺仪 GPS、激光雷达

3.3 固件抽象层设计

好了,协议讲完了。但咱们做固件的,不能每次换个传感器就把驱动重写一遍。我建议你做一个硬件抽象层(HAL),把底层操作和上层业务逻辑隔离开。

说白了,就是定义一组统一的接口。不管底层是I2C、SPI还是UART,上层调用的函数名都一样。这样换传感器或者换MCU的时候,只需要改底层实现,上层代码几乎不用动。

3.3.1 抽象层接口定义

我个人习惯这样定义接口:

/* 传感器总线抽象接口 */
typedef struct {
    int32_t (*init)(void *config);
    int32_t (*write)(uint8_t dev_addr, uint8_t *data, uint16_t len);
    int32_t (*read)(uint8_t dev_addr, uint8_t *buf, uint16_t len);
    int32_t (*write_read)(uint8_t dev_addr, 
                          uint8_t *tx_data, uint16_t tx_len,
                          uint8_t *rx_buf, uint16_t rx_len);
    int32_t (*deinit)(void);
} bus_ops_t;

你看,这个结构体里只有五个函数指针。init和deinit负责初始化和反初始化,write和read是基本的读写,write_read是写后立即读(很多传感器寄存器操作都需要这个)。

为什么要有write_read? 我遇到过不少传感器,读数据之前必须先写寄存器地址。如果分开调用write和read,中间可能会被其他任务打断。合在一起做,可以保证操作的原子性。

3.3.2 I2C 实现示例

static int32_t i2c_write(uint8_t dev_addr, uint8_t *data, uint16_t len)
{
    /* 这里调用MCU的I2C驱动库 */
    return hal_i2c_master_transmit(I2C1, dev_addr, data, len, 1000);
}

static int32_t i2c_read(uint8_t dev_addr, uint8_t *buf, uint16_t len)
{
    return hal_i2c_master_receive(I2C1, dev_addr, buf, len, 1000);
}

static int32_t i2c_write_read(uint8_t dev_addr,
                              uint8_t *tx_data, uint16_t tx_len,
                              uint8_t *rx_buf, uint16_t rx_len)
{
    /* 先写后读,带重启条件 */
    hal_i2c_master_transmit(I2C1, dev_addr, tx_data, tx_len, 1000);
    return hal_i2c_master_receive(I2C1, dev_addr, rx_buf, rx_len, 1000);
}

const bus_ops_t i2c_bus_ops = {
    .init = i2c_init,
    .write = i2c_write,
    .read = i2c_read,
    .write_read = i2c_write_read,
    .deinit = i2c_deinit,
};

3.3.3 SPI 实现示例

static int32_t spi_write_read(uint8_t dev_addr,
                              uint8_t *tx_data, uint16_t tx_len,
                              uint8_t *rx_buf, uint16_t rx_len)
{
    /* SPI是全双工,CS由dev_addr指定 */
    hal_gpio_write(dev_addr, 0);  /* 拉低片选 */
    hal_spi_transfer(SPI2, tx_data, rx_buf, tx_len > rx_len ? tx_len : rx_len);
    hal_gpio_write(dev_addr, 1);  /* 拉高片选 */
    return 0;
}

const bus_ops_t spi_bus_ops = {
    .init = spi_init,
    .write = spi_write_read,  /* SPI的write和read其实是一样的 */
    .read = spi_write_read,
    .write_read = spi_write_read,
    .deinit = spi_deinit,
};

注意: SPI的write和read其实都是全双工操作。你写的时候也在读,读的时候也在写。所以很多SPI驱动里,write和read指向同一个函数。调用write时,读到的数据直接丢弃就行。

3.3.4 UART 实现示例

static int32_t uart_write(uint8_t dev_addr, uint8_t *data, uint16_t len)
{
    /* UART没有地址概念,dev_addr可以忽略或用作端口号 */
    return hal_uart_transmit(UART1, data, len, 1000);
}

static int32_t uart_read(uint8_t dev_addr, uint8_t *buf, uint16_t len)
{
    return hal_uart_receive(UART1, buf, len, 1000);
}

const bus_ops_t uart_bus_ops = {
    .init = uart_init,
    .write = uart_write,
    .read = uart_read,
    .write_read = NULL,  /* UART一般不需要写后立即读 */
    .deinit = uart_deinit,
};

3.4 上层如何使用

有了这个抽象层,上层传感器驱动就变得很干净了:

/* 传感器驱动,不关心底层是什么总线 */
typedef struct {
    bus_ops_t *bus;
    uint8_t addr;  /* I2C地址 或 SPI片选引脚 或 UART端口号 */
} sensor_t;

int32_t sensor_read_reg(sensor_t *sensor, uint8_t reg, uint8_t *value)
{
    return sensor->bus->write_read(sensor->addr, &reg, 1, value, 1);
}

int32_t sensor_write_reg(sensor_t *sensor, uint8_t reg, uint8_t value)
{
    uint8_t buf[2] = {reg, value};
    return sensor->bus->write(sensor->addr, buf, 2);
}

这样做的好处: 你想想看,如果项目初期用I2C接口的传感器做验证,后期换成SPI接口的。你只需要在初始化时把bus指针指向spi_bus_ops,上层代码一行都不用改。我在一个车载项目里就是这么干的,省了至少两周的调试时间。

3.5 避坑指南

最后分享几个我踩过的坑:

  • 超时处理不能省: 我曾经在I2C通信里没加超时,结果传感器挂了,MCU直接卡死在等待应答的循环里。现在我的每个总线操作都有超时参数。
  • 错误码要统一: 不同MCU的HAL库返回的错误码不一样。我习惯在抽象层里做一次映射,统一成自己定义的错误码。这样上层代码不用关心底层用的是ST还是NXP的芯片。
  • 中断和DMA要考虑: 如果传感器数据量很大(比如图像传感器),轮询方式会占死CPU。我建议在抽象层里预留中断和DMA的接口,虽然实现复杂点,但性能提升很明显。

嗯,今天就聊到这儿。下一章咱们会基于这个抽象层,开始设计具体的传感器状态机。到时候你就知道,把底层接口抽象好,状态机写起来有多顺手了。