3、传感器数据采集基础:I2C协议详解(时序、地址、读写)、SPI协议详解(模式、片选、DMA)、寄存器操作封装
各位同学,欢迎来到传感器驱动开发的第一道坎——总线协议。说实话,搞了这么多年嵌入式,我见过太多人在I2C和SPI上翻车了。不是时序没配好,就是地址搞错了,最后拿着示波器抓波形抓到怀疑人生。今天咱们就把这两个协议彻底讲透,顺便聊聊我这些年踩过的坑。
3.1 I2C协议:两根线搞定一切
I2C,说白了就是一根时钟线(SCL)加一根数据线(SDA)。两根线能挂一堆设备,靠的就是地址。我个人习惯把I2C比作一个班级——SCL是老师喊口令,SDA是学生传纸条。
3.1.1 时序基础
先看最核心的时序图。我当年第一次看I2C时序图,差点被那些上升沿、下降沿搞晕。其实记住几个关键点就行:
- 起始条件:SCL高电平时,SDA从高变低。这就是“我要开始说话了”的信号。
- 停止条件:SCL高电平时,SDA从低变高。这就是“我说完了”。
- 数据采样:数据在SCL低电平时变化,高电平时被采样。嗯,这里要注意,千万别在SCL高电平时去动SDA,除非你要发起始或停止。
避坑指南:我曾经在一个项目里,因为起始条件没满足保持时间,导致从机死活不响应。后来用逻辑分析仪一看,SDA下降沿和SCL上升沿几乎同时发生,差了不到100ns。从那以后,我写I2C驱动都会在起始条件后面加个微小的延时。
3.1.2 地址与读写
I2C地址通常是7位或10位。咱们最常用的是7位地址。举个例子,一个MPU6050的地址是0x68,但你要发送的时候得左移一位,再加上读写位。
为什么会这样?因为I2C协议规定,第一个字节的高7位是地址,最低位是读写标志。0表示写,1表示读。所以:
- 写操作:发送 0x68 << 1 | 0 = 0xD0
- 读操作:发送 0x68 << 1 | 1 = 0xD1
我建议你在写驱动时,直接定义两个宏:
#define MPU6050_ADDR 0x68
#define MPU6050_WRITE ((MPU6050_ADDR << 1) | 0)
#define MPU6050_READ ((MPU6050_ADDR << 1) | 1)
这样代码可读性高,也不容易搞混。
3.1.3 读写流程
写操作流程很简单:
- 发送起始条件
- 发送设备地址 + 写位(0),等待ACK
- 发送寄存器地址,等待ACK
- 发送数据字节,每字节后等待ACK
- 发送停止条件
读操作稍微绕一点,需要先写寄存器地址,再重新发送起始条件(这叫重复起始),然后读数据:
- 发送起始条件
- 发送设备地址 + 写位,等待ACK
- 发送寄存器地址,等待ACK
- 发送重复起始条件
- 发送设备地址 + 读位,等待ACK
- 读取数据字节,每字节后发送ACK(最后一字节发NACK)
- 发送停止条件
个人经验:读操作时,最后一字节一定要发NACK,否则从机会继续发送数据,导致总线卡死。我刚开始写驱动时忘了这个,结果每次读最后一个数据都多读了一个字节,调试了整整一下午。
3.2 SPI协议:速度与激情
SPI比I2C快得多,但线也多。四条线:SCK(时钟)、MOSI(主出从入)、MISO(主入从出)、CS(片选)。说白了就是全双工通信,主设备发一个bit的同时,从设备也发一个bit回来。
3.2.1 四种模式
SPI有四种模式,由时钟极性(CPOL)和时钟相位(CPHA)决定。我当年背这四种模式背得头大,后来发现其实就两个参数:
| 模式 | CPOL | CPHA | 说明 |
|---|---|---|---|
| 模式0 | 0 | 0 | 空闲时SCK为低,第一个边沿采样 |
| 模式1 | 0 | 1 | 空闲时SCK为低,第二个边沿采样 |
| 模式2 | 1 | 0 | 空闲时SCK为高,第一个边沿采样 |
| 模式3 | 1 | 1 | 空闲时SCK为高,第二个边沿采样 |
你想想看,其实只要记住:CPOL决定空闲电平,CPHA决定采样边沿。大多数传感器默认用模式0或模式3。我建议你拿到传感器数据手册后,第一件事就是查SPI模式,配错了通信直接废掉。
我曾经踩过的坑:有一次用STM32的SPI驱动一个气压计,怎么读都是0xFF。折腾了两天,最后发现传感器要求模式3,而我默认配成了模式0。从那以后,我写SPI驱动都会先确认模式,再写代码。
3.2.2 片选管理
片选(CS)是SPI的灵魂。每个从设备独占一根CS线,低电平有效。通信流程:
- 拉低目标设备的CS引脚
- 等待一小段时间(有些传感器需要CS建立时间)
- 发送/接收数据
- 拉高CS引脚
这里有个细节:CS拉高后,最好等一小段时间再拉低,这叫CS去抖。我见过一些传感器,CS切换太快会进入奇怪的状态。
3.2.3 DMA传输
SPI配合DMA,那才是真正的性能怪兽。DMA说白了就是硬件搬运工,CPU告诉它“把这段内存的数据搬到SPI发送寄存器”,然后CPU就可以去干别的事了。
我一般在PX4驱动里这样用DMA:
// 伪代码示例
void spi_transfer_dma(uint8_t *tx_buf, uint8_t *rx_buf, uint16_t len) {
// 配置DMA通道
// 源地址:tx_buf,目标地址:SPI_DR
// 源地址:SPI_DR,目标地址:rx_buf
// 启动DMA传输
DMA_Enable();
// 等待传输完成(可以用中断或轮询)
while(!DMA_Complete());
}
使用DMA的好处是,CPU可以在SPI传输期间处理其他任务,比如姿态解算。在PX4的传感器驱动中,我习惯用DMA + 中断的方式,这样CPU利用率最高。
3.3 寄存器操作封装
写传感器驱动,说白了就是读写寄存器。但直接操作寄存器太容易出错了。我建议你封装一下:
// 寄存器操作封装
typedef struct {
uint8_t dev_addr; // I2C地址或SPI片选
uint8_t reg_addr; // 寄存器地址
uint8_t value; // 寄存器值
} reg_op_t;
// I2C读写封装
int i2c_read_reg(uint8_t dev_addr, uint8_t reg_addr, uint8_t *data, uint16_t len);
int i2c_write_reg(uint8_t dev_addr, uint8_t reg_addr, uint8_t *data, uint16_t len);
// SPI读写封装
int spi_read_reg(uint8_t cs_pin, uint8_t reg_addr, uint8_t *data, uint16_t len);
int spi_write_reg(uint8_t cs_pin, uint8_t reg_addr, uint8_t *data, uint16_t len);
这样封装后,上层代码只需要调用这些接口,不用关心底层是I2C还是SPI。我在PX4的传感器驱动里就是这么干的,换传感器只需要改底层接口,上层逻辑基本不动。
核心思路:把寄存器操作抽象成“读寄存器”和“写寄存器”两个函数,底层用I2C还是SPI,由配置决定。这样代码复用率高,也方便调试。
3.4 本章知识体系
下面这张图是我自己画的,把本章的核心逻辑串起来了。你看一眼就能明白I2C和SPI的异同:
嗯,这张图把I2C和SPI的核心差异都标出来了。你写驱动时,先想清楚用哪个协议,再根据这张图去配参数,基本不会出错。
我的建议:刚开始学的时候,先用逻辑分析仪抓一下波形,看看实际通信和理论是否一致。我当年就是这么干的,抓几次波形,协议就彻底理解了。
公众号:蓝海资料掘金营,微信deep3321