4. I2C总线协议与驱动开发:时序、仲裁与实战

I2C,说白了就是一根时钟线加一根数据线,却能挂一堆设备。我刚入行时觉得这协议太简单了,结果第一次调EEPROM就卡了两天——嗯,时序没搞对。这一章咱们就把I2C从里到外扒清楚,再手把手把驱动写出来。

4.1 I2C时序与寻址机制

I2C只有两条线:SCL(时钟)和SDA(数据)。所有通信都由主机控制时钟,从机只能被动响应。我个人习惯把I2C通信拆成四个基本动作:起始、停止、发送字节、应答。

核心时序规则:
  • 起始条件:SCL高电平时,SDA从高变低
  • 停止条件:SCL高电平时,SDA从低变高
  • 数据有效:SCL高电平时,SDA必须稳定;SCL低电平时,SDA才能变化
  • 应答:每发送8位数据后,接收方在第9个SCL周期拉低SDA表示ACK

寻址机制呢?标准模式是7位地址,加上读写位组成第一个字节。比如0x50左移一位变成0xA0(写)或0xA1(读)。我记得第一次用逻辑分析仪抓I2C波形,看到地址字节后面跟了个0(写)或1(读),才真正理解“7位地址+1位读写”是怎么回事。

模式 地址位 最大速率 总线电容
标准模式 7位 100 kHz ≤400 pF
快速模式 7位/10位 400 kHz ≤400 pF
快速模式+ 7位/10位 1 MHz ≤550 pF
避坑指南:我曾经在一条总线上挂了8个设备,结果通信时有时无。查了半天发现是总线电容超标,上拉电阻太小导致信号边沿变缓。后来换成2.2kΩ上拉电阻,问题解决。

4.2 多主机仲裁与时钟拉伸

多主机仲裁,说白了就是多个主机同时抢总线时,谁先拉低SDA谁赢。I2C的仲裁机制很巧妙——它不会破坏数据,只是让输的那一方自动退出。

为什么会这样?因为I2C是开漏输出,多个设备同时拉低SDA时,低电平会覆盖高电平。每个主机在发送数据时都会回读SDA电平,如果发现自己发送的是1但读到的是0,就知道仲裁失败了,立刻释放总线。

仲裁规则:
  • 仲裁在SDA上进行,SCL由主机共同驱动
  • 发送低电平的设备会赢得仲裁
  • 仲裁失败的设备必须释放SDA,等待下次起始条件
  • 仲裁过程中不会丢失数据

时钟拉伸呢?这是从机用来“喘口气”的机制。从机在需要处理数据时,可以拉低SCL,强制主机等待。等从机准备好了,再释放SCL。我在驱动一个慢速温湿度传感器时就遇到过——它每次转换完成后会拉伸时钟约500μs,如果主机不支持时钟拉伸,通信就会失败。

注意:不是所有I2C从机都支持时钟拉伸,也不是所有主机都能正确处理。STM32的硬件I2C外设默认支持时钟拉伸,但软件模拟I2C需要手动处理SCL电平检测。

4.3 硬件I2C与软件模拟I2C

这两种方式各有千秋。我个人的经验是:能上硬件I2C就别用软件模拟,除非引脚不够或者需要特殊时序。

对比项 硬件I2C 软件模拟I2C
CPU占用 低(DMA+中断) 高(轮询/忙等)
引脚选择 固定引脚 任意GPIO
时钟拉伸 自动处理 需手动实现
多主机仲裁 硬件支持 极难实现
代码量 配置寄存器即可 需逐位模拟时序

下面给一个软件模拟I2C的发送字节函数,你想想看,其实就是按位操作GPIO:

void I2C_Soft_SendByte(uint8_t data) {
    for (int i = 0; i < 8; i++) {
        // SCL低电平,设置SDA
        I2C_SCL_LOW();
        if (data & 0x80) {
            I2C_SDA_HIGH();
        } else {
            I2C_SDA_LOW();
        }
        // SCL高电平,从机采样
        delay_us(5);  // 保持建立时间
        I2C_SCL_HIGH();
        delay_us(5);  // 保持高电平时间
        data <<= 1;
    }
    // 释放SDA,等待应答
    I2C_SCL_LOW();
    I2C_SDA_HIGH();
    delay_us(5);
    I2C_SCL_HIGH();
    // 读取ACK
    if (I2C_SDA_READ()) {
        // NACK处理
    }
    I2C_SCL_LOW();
}
经验之谈:软件模拟I2C的延时参数要根据主频调整。我习惯用示波器看波形,确保SCL高电平宽度不低于4μs(100kHz模式),这样兼容性最好。

4.4 实战:驱动EEPROM(AT24C02)

AT24C02是2Kbit的EEPROM,内部组织成256字节,每页8字节。写操作需要先发设备地址,再发内部地址,最后发数据。读操作分两种:当前地址读和随机地址读。

我记得第一次写EEPROM驱动时,没注意页写限制——连续写超过8字节就会回卷覆盖。后来看了数据手册才发现,每次页写最多8字节,跨页必须重新发送起始条件。

// EEPROM页写函数
HAL_StatusTypeDef EEPROM_WritePage(uint16_t addr, uint8_t *data, uint8_t len) {
    uint8_t buf[10];  // 设备地址 + 内部地址 + 最多8字节数据
    buf[0] = (DEVICE_ADDR << 1) | 0;  // 写操作
    buf[1] = addr & 0xFF;              // 内部地址
    for (int i = 0; i < len && i < 8; i++) {
        buf[2 + i] = data[i];
    }
    // 发送起始条件 + 地址 + 数据 + 停止条件
    return HAL_I2C_Master_Transmit(&hi2c1, DEVICE_ADDR << 1, buf, 2 + len, 100);
}
注意:EEPROM写完后需要等待内部编程完成(约5ms)。我习惯在每次写操作后加一个轮询ACK的循环,直到设备应答为止。

4.5 实战:驱动温湿度传感器(SHT30)

SHT30是数字温湿度传感器,I2C接口,默认地址0x44。它支持多种测量模式:单次测量、周期测量、带CRC校验等。我个人习惯用单次测量模式,功耗低,控制简单。

驱动流程:发送测量命令 → 等待测量完成(约15ms) → 读取6字节数据(温度高、温度低、CRC、湿度高、湿度低、CRC)。

// SHT30单次测量命令
#define SHT30_CMD_MEASURE_HIGH 0x2C06

HAL_StatusTypeDef SHT30_ReadTempHum(float *temp, float *hum) {
    uint8_t cmd[2] = {0x2C, 0x06};
    uint8_t buf[6];
    
    // 发送测量命令
    HAL_I2C_Master_Transmit(&hi2c1, 0x44 << 1, cmd, 2, 100);
    HAL_Delay(20);  // 等待测量完成
    
    // 读取数据
    HAL_I2C_Master_Receive(&hi2c1, 0x44 << 1, buf, 6, 100);
    
    // 计算温度(公式来自数据手册)
    uint16_t raw_temp = (buf[0] << 8) | buf[1];
    *temp = -45.0f + 175.0f * raw_temp / 65535.0f;
    
    // 计算湿度
    uint16_t raw_hum = (buf[3] << 8) | buf[4];
    *hum = 100.0f * raw_hum / 65535.0f;
    
    return HAL_OK;
}
避坑指南:我曾经发现SHT30读出的温度总是偏高,排查了半天发现是传感器靠近了MCU的发热区域。后来在PCB布局时把传感器放在板边,远离热源,数据就正常了。

4.6 I2C知识体系总览

下面这张图把I2C的核心知识点串起来了,从物理层到应用层,你一看就明白:

I2C总线协议知识体系 物理层 SCL + SDA 开漏输出 上拉电阻 4.7kΩ 时序层 起始/停止条件 数据有效 + 应答 协议层 7位/10位寻址 读写位 + 寄存器地址 高级特性 多主机仲裁 时钟拉伸 实现方式 硬件I2C外设 软件GPIO模拟 实战设备 AT24C02 EEPROM SHT30 温湿度 核心原则:I2C是同步、半双工、多主机总线 所有通信由主机控制时钟,从机只能通过时钟拉伸请求等待

嗯,I2C的内容就这些。从时序到寻址,从仲裁到实战,你只要把每个环节吃透,以后遇到任何I2C设备都能快速上手。记住一点:示波器或逻辑分析仪是你调I2C最好的朋友——波形不会骗人。