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, ®, 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的接口,虽然实现复杂点,但性能提升很明显。
嗯,今天就聊到这儿。下一章咱们会基于这个抽象层,开始设计具体的传感器状态机。到时候你就知道,把底层接口抽象好,状态机写起来有多顺手了。