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 标准读写流程
写操作流程:
- 主机发送START条件
- 主机发送7位从机地址 + 写位(0)
- 从机回复ACK
- 主机发送寄存器地址(高字节)
- 从机回复ACK
- 主机发送寄存器地址(低字节)——如果是16位地址
- 从机回复ACK
- 主机发送数据字节
- 从机回复ACK
- 重复步骤8-9直到数据发送完毕
- 主机发送STOP条件
读操作流程:
- 主机发送START条件
- 主机发送从机地址 + 写位(0)——伪写
- 从机回复ACK
- 主机发送寄存器地址
- 从机回复ACK
- 主机发送RESTART条件
- 主机发送从机地址 + 读位(1)
- 从机回复ACK
- 从机发送数据字节
- 主机回复ACK(继续读)或NACK(最后一字节)
- 重复步骤9-10直到数据接收完毕
- 主机发送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控制总线的核心知识点和调试路径,方便你对照检查自己的理解。
4.7 避坑指南
最后,分享几个我亲身踩过的坑,希望能帮你少走弯路。
- 上拉电阻不能省:I2C的SCL和SDA必须加上拉电阻,典型值4.7kΩ。我曾经在一块开发板上忘了加上拉,折腾了两天才发现。
- SPI的CS要严格管理:多个SPI设备共用总线时,CS必须互斥。一个设备通信时,其他设备的CS必须保持高电平。否则数据会串扰。
- 读操作后的NACK:I2C读操作最后一字节,主机必须发NACK,否则从机不会释放总线。这个细节很多人会忽略。
- 时钟频率不是越高越好:高速I2C/SPI对PCB走线要求很高。如果板子布线不好,降频往往能解决很多诡异问题。
- 写后读验证:配置完关键寄存器后,建议读回来验证一下。我遇到过Sensor内部寄存器没写进去的情况,不验证根本发现不了。
总结一句话:I2C/SPI驱动调试,七分靠代码,三分靠波形。学会用示波器,你的调试效率能提升一个数量级。