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 读写流程

写操作流程很简单:

  1. 发送起始条件
  2. 发送设备地址 + 写位(0),等待ACK
  3. 发送寄存器地址,等待ACK
  4. 发送数据字节,每字节后等待ACK
  5. 发送停止条件

读操作稍微绕一点,需要先写寄存器地址,再重新发送起始条件(这叫重复起始),然后读数据:

  1. 发送起始条件
  2. 发送设备地址 + 写位,等待ACK
  3. 发送寄存器地址,等待ACK
  4. 发送重复起始条件
  5. 发送设备地址 + 读位,等待ACK
  6. 读取数据字节,每字节后发送ACK(最后一字节发NACK)
  7. 发送停止条件

个人经验:读操作时,最后一字节一定要发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线,低电平有效。通信流程:

  1. 拉低目标设备的CS引脚
  2. 等待一小段时间(有些传感器需要CS建立时间)
  3. 发送/接收数据
  4. 拉高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 vs SPI I2C协议 两根线:SCL + SDA 多设备共享总线 7位/10位地址 时序要点: 起始条件:SCL高,SDA下降沿 停止条件:SCL高,SDA上升沿 数据采样:SCL高电平时 读写流程: 写:地址+写位 → 寄存器地址 → 数据 读:地址+写位 → 寄存器地址 → 重复起始 → 地址+读位 → 数据 速度:100kHz-3.4MHz SPI协议 四条线:SCK, MOSI, MISO, CS 全双工通信 每个设备独占CS 四种模式: 模式0:CPOL=0, CPHA=0 模式1:CPOL=0, CPHA=1 模式2:CPOL=1, CPHA=0 模式3:CPOL=1, CPHA=1 DMA传输: CPU配置DMA后即可处理其他任务 适合高速、大数据量传输 速度:10MHz-80MHz 选择 核心:寄存器操作封装 → 统一读写接口 → 上层驱动无需关心底层协议

嗯,这张图把I2C和SPI的核心差异都标出来了。你写驱动时,先想清楚用哪个协议,再根据这张图去配参数,基本不会出错。

我的建议:刚开始学的时候,先用逻辑分析仪抓一下波形,看看实际通信和理论是否一致。我当年就是这么干的,抓几次波形,协议就彻底理解了。


公众号:蓝海资料掘金营,微信deep3321