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通信中常见的错误。它可能发生在两种场景:

  1. 地址NACK:总线上没有这个地址的从机,或者从机没准备好。
  2. 数据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, &reg_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