4. I2C总线驱动与设备探测:协议时序、主机驱动与从设备扫描

I2C总线,说实话,是嵌入式系统里最常用的低速总线之一。两根线——SCL和SDA,就能挂一堆设备。我在项目里见过有人用I2C挂了8个传感器加一个EEPROM,跑得稳稳的。

但I2C也有它的脾气。时序不对,设备不认。地址冲突,总线卡死。今天我们就从协议时序开始,一步步把I2C主机驱动写出来,再做个设备扫描器,把挂在上面的从设备都揪出来。

4.1 I2C协议时序:从START到STOP

I2C的通信,说白了就是主设备控制时钟,在SDA上按规则发送数据。每个字节8位,后面跟一个ACK位。就这么简单。

核心时序就几个:

  • START条件:SCL高电平时,SDA从高变低
  • 数据采样:SCL低电平时改变SDA,高电平时采样
  • ACK/NACK:第9个时钟,从设备拉低SDA表示应答
  • STOP条件:SCL高电平时,SDA从低变高

关键点:START和STOP是唯一允许SDA在SCL高电平时变化的时刻。其他时候SDA只能在SCL低电平时变化。这个规则一旦违反,总线上的设备就会乱掉。

我刚开始写I2C驱动时,就犯过这个错。在数据传输中间不小心让SDA在SCL高电平跳了一下,结果整个总线卡死,所有设备都不响应了。查了两天才发现是GPIO模拟时序时中断处理不当导致的。

标准I2C速率分几种:

模式 最大速率 典型应用
标准模式 100 kHz 大多数传感器、EEPROM
快速模式 400 kHz 高分辨率ADC、音频编解码
快速+模式 1 MHz 大容量存储器、显示驱动
高速模式 3.4 MHz 视频、高速数据采集

我个人习惯,在MCU上做I2C驱动时,先按100kHz标准模式调通,再往上提速率。为什么?因为100kHz的时序容限大,GPIO模拟或硬件I2C都容易过。速率上去后,上拉电阻、线长、寄生电容都会影响信号质量。

4.2 主机模式驱动编写:从GPIO模拟到硬件I2C

写I2C主机驱动,有两种方式:GPIO模拟和硬件I2C外设。我们先看GPIO模拟,因为这种方式让你真正理解时序。

4.2.1 GPIO模拟I2C

核心就是操作两个GPIO引脚,按协议产生时序。下面是一个精简的模拟I2C驱动框架:

// I2C GPIO模拟驱动核心函数
static void i2c_delay(void) {
    // 根据目标速率调整延时
    // 100kHz -> 5us, 400kHz -> 1.25us
    delay_us(5);
}

static void i2c_start(void) {
    SDA_OUT();          // SDA设为输出
    SDA_HIGH();         // SDA高
    SCL_HIGH();         // SCL高
    i2c_delay();
    SDA_LOW();          // SDA低 -> START
    i2c_delay();
    SCL_LOW();          // SCL低,准备传输数据
}

static void i2c_stop(void) {
    SDA_OUT();
    SDA_LOW();          // SDA低
    SCL_HIGH();         // SCL高
    i2c_delay();
    SDA_HIGH();         // SDA高 -> STOP
    i2c_delay();
}

static int i2c_write_byte(uint8_t data) {
    uint8_t i;
    for (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_IN();
    SCL_HIGH();
    i2c_delay();
    int ack = (SDA_READ() == 0) ? 0 : -1;
    SCL_LOW();
    SDA_OUT();
    return ack;
}

经验之谈:GPIO模拟I2C时,延时函数一定要准。我见过有人用空循环做延时,结果不同编译器优化等级下时序完全不一样。建议用硬件定时器或系统滴答时钟做微秒级延时。

4.2.2 硬件I2C外设驱动

大多数MCU都内置I2C外设。用硬件I2C,你只需要配置寄存器,剩下的时序由硬件完成。以STM32为例:

// 硬件I2C初始化
void i2c_hw_init(I2C_TypeDef *i2c, uint32_t speed) {
    // 1. 使能时钟
    RCC->APB1ENR |= RCC_APB1ENR_I2C1EN;
    
    // 2. 配置GPIO为复用开漏
    // SCL - PB6, SDA - PB7 (I2C1)
    GPIOB->AFR[0] |= (4 << 24) | (4 << 28);  // AF4
    GPIOB->MODER |= (2 << 12) | (2 << 14);   // 复用模式
    GPIOB->OTYPER |= (1 << 6) | (1 << 7);    // 开漏输出
    
    // 3. 配置I2C时序
    i2c->TIMINGR = calc_i2c_timing(speed, APB1_CLK);
    
    // 4. 使能I2C
    i2c->CR1 |= I2C_CR1_PE;
}

// 硬件I2C发送
int i2c_hw_write(I2C_TypeDef *i2c, uint8_t addr, 
                 uint8_t *data, uint32_t len) {
    // 等待总线空闲
    while (i2c->ISR & I2C_ISR_BUSY);
    
    // 发送START + 从设备地址
    i2c->CR2 = (addr << 1) | I2C_CR2_START;
    
    // 发送数据
    for (uint32_t i = 0; i < len; i++) {
        while (!(i2c->ISR & I2C_ISR_TXIS));
        i2c->TXDR = data[i];
    }
    
    // 等待传输完成
    while (!(i2c->ISR & I2C_ISR_TC));
    
    // 发送STOP
    i2c->CR2 |= I2C_CR2_STOP;
    
    return 0;
}

注意:硬件I2C的时序配置寄存器(TIMINGR)计算很关键。不同MCU的算法不同,建议直接用厂商提供的工具生成配置值。我自己曾经手动算错过一次,导致I2C时钟频率偏差30%,设备间歇性不响应。

4.3 从设备地址扫描与挂载

写好了驱动,怎么知道总线上挂了哪些设备?答案是地址扫描。I2C从设备地址是7位(也有10位地址模式,但用得少),范围0x00-0x7F。但有些地址是保留的,实际可用的地址范围是0x08-0x77。

扫描原理很简单:向每个可能的地址发送START条件+写操作,看是否有ACK回应。有ACK说明该地址有设备。

// I2C总线设备扫描
void i2c_scan_bus(void) {
    uint8_t addr;
    int device_count = 0;
    
    printf("I2C Bus Scanning...\n");
    printf("   ");
    for (addr = 0; addr < 0x80; addr++) {
        if (addr % 16 == 0) {
            printf("\n0x%02X: ", addr);
        }
        
        // 发送START + 地址 + 写位,检查ACK
        i2c_start();
        int ack = i2c_write_byte((addr << 1) | 0);  // 写操作
        i2c_stop();
        
        if (ack == 0) {
            printf("0x%02X ", addr);
            device_count++;
        } else {
            printf(" --  ");
        }
    }
    
    printf("\nFound %d device(s)\n", device_count);
}

扫描结果通常以表格形式输出,方便查看:

地址范围 常见设备 备注
0x20 - 0x27 PCF8574 I/O扩展 A0/A1/A2引脚决定具体地址
0x3C - 0x3D OLED显示屏(SSD1306) 0x3C最常见
0x48 - 0x4F 温度传感器(LM75, TMP102) 地址引脚决定偏移
0x50 - 0x57 EEPROM(AT24C系列) 0x50最常用
0x68 RTC(DS3231, PCF8563) 部分RTC用0x6F
0x76 - 0x77 气压计(BMP280, BME280) SDO引脚决定

挂载流程:扫描到设备后,需要做三件事——验证设备ID寄存器(如果有)、初始化设备配置、注册到设备管理层。我习惯在扫描阶段就读取设备ID,避免地址冲突导致误判。

4.4 I2C总线知识结构

下面这张图把I2C总线驱动与设备探测的核心知识点串起来了:

I2C总线驱动与设备探测知识结构 I2C总线驱动与设备探测知识结构 I2C协议基础 START / STOP 条件 数据采样与ACK/NACK 时钟同步与仲裁 驱动实现方式 GPIO模拟I2C 完全理解时序,移植性强 硬件I2C外设 效率高,CPU占用低 设备探测与挂载 地址扫描 设备ID验证 初始化与注册 核心原则:先理解时序 -> 再实现驱动 -> 最后扫描挂载,层层递进

4.5 实战中的避坑指南

写I2C驱动这么多年,踩过的坑不少。挑几个典型的说说:

  • 总线卡死:从设备在传输中复位了,SDA被拉低不放。解决办法是加超时机制,超时后发9个时钟脉冲让设备释放总线。
  • 地址冲突:两个设备用了相同地址。我遇到过一块板子上两个传感器都默认0x48,只能改硬件地址引脚或换设备。
  • 速率不匹配:从设备不支持高速模式,主设备却配了400kHz。结果就是随机性通信失败。建议先按100kHz扫一遍,确认设备都响应再提速率。
  • 上拉电阻:I2C总线需要上拉电阻。我见过有人忘了加上拉,结果总线一直低电平,所有设备都不响应。标准值是4.7kΩ,总线电容大时用2.2kΩ。

曾经有个项目,I2C总线挂了5个设备,偶尔出现通信失败。查了三天,最后发现是SCL线上串了100Ω电阻导致信号上升沿变缓,在400kHz下时序违规。换成0Ω电阻后问题解决。所以,高速I2C时走线要短,上拉要强,串联电阻要小。

好了,I2C总线驱动与设备探测的核心内容就这些。从协议时序到驱动实现,再到设备扫描挂载,每一步都有它的门道。写驱动时多想想时序,调试时多用逻辑分析仪看波形,很多问题一眼就能看出来。