第2章:I2C协议详解与实战

各位同学,今天我们来啃一块硬骨头——I2C协议。说实话,I2C这玩意儿在嵌入式开发里太常见了,智能手表里的加速度计、陀螺仪、心率传感器,十有八九都是挂I2C总线上的。我当年刚入行时,被I2C的时序折腾得够呛,逻辑分析仪一接上去,波形乱七八糟的。后来才明白,搞懂物理层和协议层,是写好驱动的基础。

2.1 I2C物理层与协议层

I2C总线就两根线:SCL(时钟线)和SDA(数据线)。所有设备都挂在这两根线上,主设备负责产生时钟,从设备响应数据。说白了,这就是一个主从通信的架构。

物理层要点:

  • SCL和SDA都是开漏输出,需要上拉电阻(典型值4.7kΩ)
  • 总线空闲时,两根线都是高电平
  • 每个从设备有唯一7位或10位地址
  • 标准模式100kHz,快速模式400kHz,高速模式3.4MHz

我在项目中遇到过一个问题:手表上同时挂了三个I2C传感器,结果通信老是出错。后来用示波器一量,发现上拉电阻选得太大了,信号上升沿太慢。换成2.2kΩ的电阻,问题就解决了。嗯,这里要注意,上拉电阻的选择跟总线电容有关,不是随便选的。

2.2 I2C时序分析

I2C的时序其实不复杂,但细节很多。我们一个一个来看。

起始条件(START): SCL高电平时,SDA从高变低。这个信号告诉所有从设备:准备接收数据。

停止条件(STOP): SCL高电平时,SDA从低变高。这个信号表示传输结束。

数据传输: 每个数据字节8位,MSB先传。SCL低电平时改变SDA,SCL高电平时采样SDA。你想想看,为什么这样设计?就是为了保证数据在时钟高电平时是稳定的。

应答位(ACK): 每发送完一个字节,接收方要在第9个时钟周期拉低SDA,表示收到了。如果没拉低,就是NACK,发送方就知道出问题了。

关键时序参数:

参数标准模式快速模式
SCL频率100kHz400kHz
上升时间≤1000ns≤300ns
下降时间≤300ns≤300ns
数据保持时间≥0ns≥0ns

我曾经调试一个心率传感器,读出来的数据总是跳变。用逻辑分析仪抓波形,发现SCL的上升沿太缓了,超过了300ns。查了半天,原来是PCB走线太长,寄生电容太大。后来把走线缩短,问题就解决了。所以啊,时序参数不是纸上谈兵,实际项目中一定要验证。

2.3 在RTOS中编写I2C主机驱动

在RTOS里写I2C驱动,跟裸机最大的区别就是:要考虑任务调度和互斥访问。我习惯把I2C操作封装成一个驱动层,上层应用只管调用API,不用关心底层细节。

先看一个简单的I2C读写函数:

// I2C主机写操作
int i2c_master_write(uint8_t dev_addr, uint8_t reg_addr, uint8_t *data, uint16_t len)
{
    // 1. 发送起始条件
    i2c_start();
    
    // 2. 发送设备地址 + 写位
    i2c_send_byte(dev_addr << 1 | 0);
    if (i2c_wait_ack() != 0) {
        i2c_stop();
        return -1;
    }
    
    // 3. 发送寄存器地址
    i2c_send_byte(reg_addr);
    if (i2c_wait_ack() != 0) {
        i2c_stop();
        return -2;
    }
    
    // 4. 发送数据
    for (int i = 0; i < len; i++) {
        i2c_send_byte(data[i]);
        if (i2c_wait_ack() != 0) {
            i2c_stop();
            return -3;
        }
    }
    
    // 5. 发送停止条件
    i2c_stop();
    return 0;
}

在RTOS里,我建议加上互斥锁保护。因为多个任务可能同时访问I2C总线,不加锁的话,数据就乱套了。

我的经验: 在FreeRTOS里,用二进制信号量保护I2C总线。每个I2C操作前获取信号量,操作完后释放。这样既保证了互斥,又不会造成死锁。

// RTOS环境下的I2C写操作
int i2c_master_write_rtos(uint8_t dev_addr, uint8_t reg_addr, uint8_t *data, uint16_t len)
{
    int ret;
    
    // 获取I2C总线信号量
    if (xSemaphoreTake(i2c_semaphore, pdMS_TO_TICKS(100)) != pdTRUE) {
        return -TIMEOUT;
    }
    
    // 执行I2C操作
    ret = i2c_master_write(dev_addr, reg_addr, data, len);
    
    // 释放信号量
    xSemaphoreGive(i2c_semaphore);
    
    return ret;
}

你可能会问:为什么不用互斥量?其实都可以,但我个人习惯用二进制信号量,因为它的语义更清晰——谁拿到谁用,用完就还回来。

2.4 使用逻辑分析仪调试I2C总线

调试I2C总线,逻辑分析仪是神器。我刚开始做驱动时,全靠printf打印调试信息,效率低得可怜。后来学会了用逻辑分析仪,问题定位快多了。

调试步骤:

  1. 把逻辑分析仪的通道0接SCL,通道1接SDA,共地
  2. 设置采样率,至少是I2C频率的4倍(我一般设2MHz)
  3. 触发条件设为SCL下降沿,这样能抓到完整的通信过程
  4. 抓取波形后,用协议解析功能自动解码

注意: 逻辑分析仪的输入电压范围要跟I2C总线匹配。3.3V的系统就用3.3V的逻辑分析仪,别用5V的,会烧芯片。我曾经就干过这种事,还好只是烧了一个传感器,不然后果严重。

看波形时,我主要关注几个点:

  • 起始条件是否正确?SCL高电平时SDA有没有正确跳变?
  • 地址字节对不对?7位地址有没有左移一位?
  • 应答位有没有拉低?如果从设备没应答,可能是地址错了或者设备没上电
  • 数据字节的时序对不对?SCL低电平时SDA有没有稳定?

我记得有一次调试一个气压传感器,读出来的数据全是0xFF。用逻辑分析仪一看,发现从设备在应答位之后没有释放SDA总线,导致主设备无法继续发送时钟。后来查手册,发现这个传感器需要额外的延时才能准备好数据。加上一个10ms的延时后,问题就解决了。

嗯,说到延时,这里有个小技巧:在RTOS里不要用忙等延时,用vTaskDelay()让出CPU。不然的话,你的系统响应会变得很慢。

好了,I2C协议的内容就讲到这里。下一章我们讲SPI协议,那个速度更快,但线也更多。到时候我们再细聊。