4. I2C/SPI控制总线:寄存器读写机制、批量配置技巧、时序调试

Sensor驱动开发,说白了就是跟寄存器打交道。而跟寄存器通信,绕不开两条总线——I2C和SPI。我见过不少新手,拿着datasheet对着时序图发呆,或者调了半天发现Sensor没反应,最后查出来是总线配置错了。这一章,咱们就把这两条总线的读写机制、批量配置技巧和时序调试方法彻底讲透。

4.1 I2C与SPI:选谁?为什么?

先搞清楚一个基本问题:什么时候用I2C,什么时候用SPI?

对比项 I2C SPI
引脚数 2线(SCL, SDA) 4线(SCLK, MOSI, MISO, CS)
速度 标准100kHz~快速400kHz,高速可达3.4MHz 通常10MHz~50MHz,甚至更高
通信方式 半双工,主从架构,多设备共用总线 全双工,主从架构,每个设备独立CS
适用场景 配置寄存器、低速传感器、多设备场景 高速数据流、图像传输、高帧率配置
抗干扰能力 较弱,长线容易出问题 较强,差分SPI更稳定

我个人习惯:配置寄存器用I2C,传图像数据用SPI。为什么?因为Sensor的寄存器配置通常不频繁,I2C的2线制能省引脚,而且多设备共用总线很方便。但如果你要高速读取图像数据,或者需要批量写大量寄存器,SPI的吞吐量优势就体现出来了。

核心原则:I2C适合"慢配置、多设备",SPI适合"快传输、少设备"。选错了总线,后面调试会非常痛苦。

4.2 I2C读写机制:从Start到Stop

I2C的通信过程,说白了就是一套握手协议。每次通信以START条件开始,以STOP条件结束。中间是地址+读写位、数据字节和ACK/NACK。

4.2.1 标准读写流程

写操作流程:

  1. 主机发送START条件
  2. 主机发送7位从机地址 + 写位(0)
  3. 从机回复ACK
  4. 主机发送寄存器地址(高字节)
  5. 从机回复ACK
  6. 主机发送寄存器地址(低字节)——如果是16位地址
  7. 从机回复ACK
  8. 主机发送数据字节
  9. 从机回复ACK
  10. 重复步骤8-9直到数据发送完毕
  11. 主机发送STOP条件

读操作流程:

  1. 主机发送START条件
  2. 主机发送从机地址 + 写位(0)——伪写
  3. 从机回复ACK
  4. 主机发送寄存器地址
  5. 从机回复ACK
  6. 主机发送RESTART条件
  7. 主机发送从机地址 + 读位(1)
  8. 从机回复ACK
  9. 从机发送数据字节
  10. 主机回复ACK(继续读)或NACK(最后一字节)
  11. 重复步骤9-10直到数据接收完毕
  12. 主机发送STOP条件

小技巧:读操作前的"伪写"步骤很多人会漏掉。我曾经在一个项目中,读Sensor的ID寄存器死活读不到正确值,折腾了半天才发现是伪写阶段没发寄存器地址。记住:读之前必须先告诉Sensor你要读哪个地址。

4.2.2 代码示例:I2C读写函数

// I2C写多个字节
int i2c_write_regs(uint8_t slave_addr, uint16_t reg_addr, 
                   uint8_t *data, uint16_t len) {
    uint8_t buf[256];
    uint16_t idx = 0;
    
    // 构造发送缓冲区:寄存器地址 + 数据
    buf[idx++] = (reg_addr >> 8) & 0xFF;  // 高字节
    buf[idx++] = reg_addr & 0xFF;         // 低字节
    memcpy(&buf[idx], data, len);
    idx += len;
    
    // 发送START + 从机地址 + 写位
    i2c_start();
    if (i2c_send_byte(slave_addr << 1 | 0) != ACK) {
        i2c_stop();
        return -1;
    }
    
    // 发送数据
    for (uint16_t i = 0; i < idx; i++) {
        if (i2c_send_byte(buf[i]) != ACK) {
            i2c_stop();
            return -2;
        }
    }
    
    i2c_stop();
    return 0;
}

// I2C读多个字节
int i2c_read_regs(uint8_t slave_addr, uint16_t reg_addr, 
                  uint8_t *data, uint16_t len) {
    // 伪写:发送寄存器地址
    i2c_start();
    if (i2c_send_byte(slave_addr << 1 | 0) != ACK) {
        i2c_stop();
        return -1;
    }
    i2c_send_byte((reg_addr >> 8) & 0xFF);
    i2c_send_byte(reg_addr & 0xFF);
    
    // 重启 + 读操作
    i2c_restart();
    if (i2c_send_byte(slave_addr << 1 | 1) != ACK) {
        i2c_stop();
        return -2;
    }
    
    // 接收数据
    for (uint16_t i = 0; i < len; i++) {
        data[i] = i2c_recv_byte(i == len - 1 ? NACK : ACK);
    }
    
    i2c_stop();
    return 0;
}

4.3 SPI读写机制:全双工的魅力

SPI比I2C简单粗暴。它没有地址概念,靠CS片选区分设备。通信时,主机和从机同时发送和接收数据——这就是全双工。

4.3.1 SPI的四种模式

SPI有四种模式,由CPOL(时钟极性)和CPHA(时钟相位)决定。选错模式,数据读出来全是乱的。

模式 CPOL CPHA 时钟空闲电平 数据采样边沿
模式0 0 0 低电平 上升沿
模式1 0 1 低电平 下降沿
模式2 1 0 高电平 下降沿
模式3 1 1 高电平 上升沿

注意:大部分Sensor默认使用SPI模式0或模式3。我遇到过一块Sensor,datasheet上写的是模式0,但实际测试发现模式3才能正常工作。所以拿到新Sensor,第一件事就是用示波器抓波形确认模式。

4.3.2 SPI读写代码示例

// SPI写寄存器(16位地址)
void spi_write_reg(uint16_t reg_addr, uint8_t data) {
    uint8_t tx_buf[3];
    
    // 构造SPI帧:地址高字节 | 地址低字节 | 数据
    tx_buf[0] = (reg_addr >> 8) & 0xFF;
    tx_buf[1] = reg_addr & 0xFF;
    tx_buf[2] = data;
    
    // 拉低CS
    CS_LOW();
    
    // 发送3个字节(SPI全双工,同时接收,但写操作忽略接收数据)
    for (int i = 0; i < 3; i++) {
        spi_transfer_byte(tx_buf[i]);
    }
    
    // 拉高CS
    CS_HIGH();
}

// SPI读寄存器
uint8_t spi_read_reg(uint16_t reg_addr) {
    uint8_t tx_buf[3];
    uint8_t rx_buf[3];
    
    // 读操作:地址最高位设为1(不同Sensor协议可能不同)
    tx_buf[0] = 0x80 | ((reg_addr >> 8) & 0xFF);
    tx_buf[1] = reg_addr & 0xFF;
    tx_buf[2] = 0x00;  // 发送dummy字节以接收数据
    
    CS_LOW();
    for (int i = 0; i < 3; i++) {
        rx_buf[i] = spi_transfer_byte(tx_buf[i]);
    }
    CS_HIGH();
    
    // 第三个字节是读到的数据
    return rx_buf[2];
}

4.4 批量配置技巧:别一条一条写

你想想看,初始化一个Sensor往往要配置几十甚至上百个寄存器。如果一条一条写,不仅代码冗长,而且效率极低。我见过有人写Sensor驱动,初始化函数里全是i2c_write_reg(addr, data),一个Sensor初始化要几百毫秒——这显然不行。

4.4.1 批量写策略

批量配置的核心思路:把寄存器地址和数据打包成数组,一次总线事务写完。

// 批量配置结构体
typedef struct {
    uint16_t reg_addr;
    uint8_t  data;
} reg_cfg_t;

// Sensor初始化参数表
const reg_cfg_t sensor_init_table[] = {
    {0x0103, 0x01},  // 软件复位
    {0x0100, 0x00},  // 进入待机模式
    {0x0302, 0x32},  // 行时间设置
    {0x0303, 0xDC},  // 行时间设置(低字节)
    {0x0304, 0x03},  // 帧长设置
    {0x0305, 0xE8},  // 帧长设置(低字节)
    // ... 更多配置项
    {0x0100, 0x01},  // 启动流模式
};

// 批量写入函数
void sensor_batch_config(const reg_cfg_t *table, uint16_t count) {
    for (uint16_t i = 0; i < count; i++) {
        // 利用I2C/SPI的地址自增特性,连续写
        i2c_write_regs(SENSOR_ADDR, table[i].reg_addr, 
                       &table[i].data, 1);
        // 有些Sensor需要写后延迟
        if (table[i].reg_addr == 0x0103) {
            delay_ms(10);  // 复位后等待
        }
    }
}

经验之谈:批量配置时,把需要延迟的寄存器单独标记出来。我习惯在结构体里加一个delay_ms字段,0表示不延迟,非0表示写完后等待。这样初始化流程清晰,也方便后期调整时序。

4.4.2 利用地址自增特性

很多Sensor支持地址自增模式。你只需要写一次起始地址,然后连续发数据,Sensor会自动把数据写入连续的寄存器地址。这比逐条写快得多。

// 利用地址自增批量写连续寄存器
// 假设寄存器0x3000~0x300F是连续的增益参数
uint8_t gain_params[16] = {
    0x10, 0x20, 0x30, 0x40,  // R, Gr, Gb, B 增益
    // ... 其他参数
};

// 一次写入16个字节
i2c_write_regs(SENSOR_ADDR, 0x3000, gain_params, 16);
// 或者SPI方式
spi_write_regs(0x3000, gain_params, 16);

4.5 时序调试:示波器是最好的老师

写代码容易,调时序难。我调试总线时序的经验是:先看波形,再改代码,不要猜。

4.5.1 常见时序问题

问题现象 可能原因 排查方法
Sensor无响应 地址错误、上拉电阻缺失、时钟频率过高 抓波形看ACK位,检查地址和上拉
读回数据全为0xFF SPI模式错误、CS时序不对、MISO没连接 确认CPOL/CPHA,检查CS拉低时机
读回数据偶尔错误 时钟抖动、信号反射、电源噪声 降低时钟频率,加终端电阻,检查电源
批量写部分失败 I2C的NACK、SPI的CS被意外拉高 检查每字节后的ACK,确认CS保持低电平

4.5.2 示波器抓波形要点

抓I2C波形时,我习惯把SCL和SDA同时抓到示波器上,触发条件设为SDA的下降沿。这样能清晰看到START条件、地址、数据和STOP条件。

抓SPI波形时,重点看CS、SCLK和MOSI/MISO的时序关系。特别是CS拉低到第一个SCLK上升沿的时间(CS setup time),很多Sensor对这个时间有要求。

我曾经踩过的坑:有一次调试SPI,Sensor偶尔初始化失败。用示波器一看,发现CS拉低后,第一个SCLK上升沿来得太快,只有50ns,而Sensor要求至少100ns。把CS拉低后加了个小延迟,问题就解决了。这种问题,不看波形根本想不到。

4.6 知识体系总览

下面这张图总结了I2C/SPI控制总线的核心知识点和调试路径,方便你对照检查自己的理解。

I2C/SPI控制总线知识体系 总线选择:I2C vs SPI I2C总线 SPI总线 读写机制 START/STOP/ACK 地址自增 连续批量写入 SPI模式 CPOL/CPHA配置 全双工 同时收发 批量配置技巧:参数表 + 地址自增 + 延迟管理 时序调试:示波器抓波形 + 参数调整 常见问题排查 无响应 | 数据错误 | 批量写失败 | 时序违规

4.7 避坑指南

最后,分享几个我亲身踩过的坑,希望能帮你少走弯路。

  • 上拉电阻不能省:I2C的SCL和SDA必须加上拉电阻,典型值4.7kΩ。我曾经在一块开发板上忘了加上拉,折腾了两天才发现。
  • SPI的CS要严格管理:多个SPI设备共用总线时,CS必须互斥。一个设备通信时,其他设备的CS必须保持高电平。否则数据会串扰。
  • 读操作后的NACK:I2C读操作最后一字节,主机必须发NACK,否则从机不会释放总线。这个细节很多人会忽略。
  • 时钟频率不是越高越好:高速I2C/SPI对PCB走线要求很高。如果板子布线不好,降频往往能解决很多诡异问题。
  • 写后读验证:配置完关键寄存器后,建议读回来验证一下。我遇到过Sensor内部寄存器没写进去的情况,不验证根本发现不了。

总结一句话:I2C/SPI驱动调试,七分靠代码,三分靠波形。学会用示波器,你的调试效率能提升一个数量级。