3、I2C驱动移植:手写I2C时序,搞定ToF模组寄存器读写
各位同学,咱们今天聊点硬核的——I2C驱动移植。
说实话,做ToF传感器驱动开发,I2C是绕不过去的坎。你想想看,ToF模组再厉害,它也得通过I2C跟你MCU说话。寄存器读写、配置下发、数据读取,全得靠这条两线总线。我这些年经手的项目,从VL53L1X到TMF8801,底层全是I2C在撑着。
这一节,咱们就手把手把I2C时序写出来。标准模式、快速模式、高速模式,一个一个来。然后移植到STM32 HAL库上,最终实现ToF模组的寄存器读写。重点解决两个老大难问题:时钟延展和NACK处理。
3.1 先搞懂I2C时序本质
I2C说白了就两根线:SCL(时钟)和SDA(数据)。所有通信都靠这两根线的电平变化来完成。
我刚开始学的时候,总觉得时序图看着头疼。后来发现一个窍门——把它当成一个握手协议来理解。
- 起始条件:SCL高电平时,SDA从高拉低。这是“我要开始说话了”的信号。
- 停止条件:SCL高电平时,SDA从低拉高。这是“我说完了”的信号。
- 数据位:SCL低电平时改变SDA,SCL高电平时采样SDA。每个字节8位,后面跟一个ACK位。
- ACK/NACK:第9个时钟周期,从机拉低SDA表示应答(ACK),不拉低表示非应答(NACK)。
核心要点:I2C通信的节奏完全由主机控制。主机产生时钟,从机只能被动响应。但有一个例外——时钟延展。
3.2 三种模式的速度差异
I2C有标准模式(100kHz)、快速模式(400kHz)和高速模式(3.4MHz)。ToF模组通常支持快速模式,部分高端型号支持高速模式。
我做过一个项目,客户要求帧率翻倍。查了半天,发现I2C总线跑在标准模式,成了瓶颈。改成快速模式后,读取时间直接砍半。嗯,有时候性能优化就这么简单。
| 模式 | 最大频率 | 典型应用 | 注意事项 |
|---|---|---|---|
| 标准模式 | 100 kHz | 低速传感器、EEPROM | 兼容性最好,几乎所有从机都支持 |
| 快速模式 | 400 kHz | ToF模组、温湿度传感器 | 需要上拉电阻更小(约1.5kΩ-2.2kΩ) |
| 高速模式 | 3.4 MHz | 高速摄像头、高帧率ToF | 需要特殊硬件支持,布线要求高 |
我的经验:ToF模组驱动开发,先用标准模式调通,再切快速模式。直接上高速模式,出了问题你都不知道是时序问题还是硬件问题。
3.3 手写I2C时序:从零开始造轮子
为什么要手写?因为你要理解底层原理。HAL库封装得太好了,反而让你不知道里面发生了什么。
咱们先写一个最简单的I2C起始条件:
// 标准模式延时函数(100kHz,半周期5us)
void i2c_delay(void) {
// 简单延时,实际项目中用定时器或nop指令
for(volatile int i = 0; i < 10; i++);
}
// 起始条件
void i2c_start(void) {
SDA_HIGH(); // SDA先拉高
SCL_HIGH(); // SCL拉高
i2c_delay(); // 保持一段时间
SDA_LOW(); // SDA拉低(产生起始条件)
i2c_delay(); // 保持
SCL_LOW(); // SCL拉低(准备发送数据)
}
你看,就这么几行代码。但这里面有个坑——时序的精确性。标准模式还好说,快速模式对延时要求就严格多了。我建议用定时器或者硬件PWM来产生精确延时,别用软件循环。
接下来是发送一个字节:
// 发送一个字节,返回ACK状态
uint8_t i2c_write_byte(uint8_t data) {
for(int i = 0; i < 8; i++) {
if(data & 0x80) {
SDA_HIGH();
} else {
SDA_LOW();
}
data <<= 1;
SCL_HIGH();
i2c_delay();
SCL_LOW();
i2c_delay();
}
// 读取ACK
SDA_HIGH(); // 释放SDA,让从机控制
SCL_HIGH();
i2c_delay();
uint8_t ack = SDA_READ(); // 读取SDA电平
SCL_LOW();
i2c_delay();
return ack; // 0表示ACK,1表示NACK
}
注意:读取ACK时,一定要先释放SDA(拉高),然后才能让从机拉低。我见过有人忘了这步,结果永远读不到ACK。
3.4 时钟延展:从机的“喘口气”请求
时钟延展是什么?简单说,就是从机忙不过来的时候,把SCL拉低,告诉主机“你等等,我还没准备好”。
为什么会这样?ToF模组内部有数据处理、测量计算等操作。你刚发完一个配置命令,它需要时间处理。这时候它就会拉低SCL,让你等它。
我遇到过最离谱的情况:某个ToF模组在读取距离数据时,时钟延展了整整2毫秒。如果你没处理这个延展,数据就会读错。
处理时钟延展的代码很简单:
// 等待从机释放SCL(处理时钟延展)
uint8_t i2c_wait_clock_stretch(void) {
uint32_t timeout = 10000; // 超时计数
SCL_HIGH(); // 主机释放SCL
while(SCL_READ() == 0) { // 如果SCL还是低,说明从机在延展
if(--timeout == 0) {
return 1; // 超时,返回错误
}
}
return 0; // 从机释放了SCL
}
关键点:时钟延展发生在SCL高电平期间。主机释放SCL后,如果从机拉低了它,主机就要等待。超时机制必须有,否则一旦从机死锁,你的系统就卡死了。
3.5 NACK处理:别让通信断在这里
NACK(非应答)是I2C通信中常见的错误。它可能发生在两种场景:
- 地址NACK:总线上没有这个地址的从机,或者从机没准备好。
- 数据NACK:从机收到了数据,但无法处理(比如寄存器地址错误)。
我建议的处理策略:
- 遇到NACK,立即产生停止条件,结束本次通信。
- 重试3次,每次间隔10ms。
- 如果还是NACK,上报错误,不要无限重试。
// 带NACK处理的写操作
uint8_t i2c_write_with_retry(uint8_t addr, uint8_t reg, uint8_t data) {
for(int retry = 0; retry < 3; retry++) {
i2c_start();
if(i2c_write_byte(addr << 1 | 0) == 0) { // 写地址
if(i2c_write_byte(reg) == 0) { // 写寄存器地址
if(i2c_write_byte(data) == 0) { // 写数据
i2c_stop();
return 0; // 成功
}
}
}
i2c_stop(); // 产生停止条件
delay_ms(10); // 等待后重试
}
return 1; // 失败
}
我的习惯:调试阶段,我会把NACK信息打印出来。比如“地址NACK:0x52”,这样能快速定位是硬件问题还是软件问题。
3.6 移植到STM32 HAL库
STM32的HAL库封装了I2C操作,但直接用它读写ToF模组,有时候会遇到问题。我建议的做法是:用HAL库的底层函数,自己封装上层接口。
先初始化I2C外设:
// I2C初始化(快速模式400kHz)
void tof_i2c_init(void) {
hi2c1.Instance = I2C1;
hi2c1.Init.ClockSpeed = 400000; // 400kHz
hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2; // 快速模式占空比
hi2c1.Init.OwnAddress1 = 0;
hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE;
hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE;
hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE; // 允许时钟延展
HAL_I2C_Init(&hi2c1);
}
注意NoStretchMode这个参数。一定要设为I2C_NOSTRETCH_DISABLE,否则HAL库会忽略时钟延展,导致通信失败。
然后封装寄存器读写函数:
// 写ToF寄存器
HAL_StatusTypeDef tof_write_reg(uint8_t dev_addr, uint8_t reg_addr, uint8_t data) {
uint8_t buf[2] = {reg_addr, data};
return HAL_I2C_Master_Transmit(&hi2c1, dev_addr << 1, buf, 2, 100);
}
// 读ToF寄存器
HAL_StatusTypeDef tof_read_reg(uint8_t dev_addr, uint8_t reg_addr, uint8_t *data) {
// 先写寄存器地址
HAL_I2C_Master_Transmit(&hi2c1, dev_addr << 1, ®_addr, 1, 100);
// 再读数据
return HAL_I2C_Master_Receive(&hi2c1, dev_addr << 1, data, 1, 100);
}
注意:HAL_I2C_Master_Transmit和HAL_I2C_Master_Receive内部已经处理了起始条件、停止条件和ACK/NACK。但时钟延展的处理,取决于你初始化时设置的NoStretchMode。
3.7 实战:读写ToF模组寄存器
以VL53L1X为例,咱们实际读写一下它的状态寄存器:
// 读取VL53L1X的设备ID(寄存器0x010F)
uint8_t device_id;
if(tof_read_reg(0x52, 0x010F, &device_id) == HAL_OK) {
printf("Device ID: 0x%02X\n", device_id);
if(device_id == 0xEA) {
printf("VL53L1X detected!\n");
}
} else {
printf("Read failed!\n");
}
这里有个细节:VL53L1X的寄存器地址是16位的,而咱们的tof_read_reg函数只支持8位地址。需要修改一下:
// 支持16位寄存器地址的读函数
HAL_StatusTypeDef tof_read_reg16(uint8_t dev_addr, uint16_t reg_addr, uint8_t *data) {
uint8_t addr_buf[2] = {reg_addr >> 8, reg_addr & 0xFF};
HAL_I2C_Master_Transmit(&hi2c1, dev_addr << 1, addr_buf, 2, 100);
return HAL_I2C_Master_Receive(&hi2c1, dev_addr << 1, data, 1, 100);
}
避坑指南:我曾经在读写16位寄存器地址时,忘了把高低字节顺序搞清楚。结果读出来的数据全是错的。后来查手册才发现,VL53L1X是大端模式,高字节在前。
3.8 常见问题与调试技巧
调试I2C,示波器是最好的朋友。但我理解,不是每个人都有示波器。那就用逻辑分析仪,几十块钱的就能用。
几个常见问题:
- SDA一直为低:可能是从机把总线锁死了。试试重新初始化I2C外设,或者给从机断电重启。
- 总是NACK:检查地址对不对,上拉电阻值对不对。我遇到过因为上拉电阻太大,信号上升沿太慢导致NACK的情况。
- 数据错位:检查时钟极性。有些从机要求SCL空闲时为高,有些要求为低。
嗯,I2C驱动移植这块,说白了就是细心活。时序对了,地址对了,剩下的就是调参数。别怕出错,多试几次就熟了。
最后送大家一句话:I2C通信,成也时序,败也时序。把时序吃透了,ToF模组的驱动开发就成功了一半。
公众号:蓝海资料掘金营,微信deep3321