3. I2C驱动实现:I2C时序分析、主机读写函数封装、多字节传输与错误处理

好,咱们进入第三章。I2C总线,说白了就是嵌入式世界里最常用的「小水管」——两根线,一根时钟(SCL),一根数据(SDA),就能把传感器、屏幕、存储芯片全串起来。我最早接触I2C是在做游戏手柄的六轴陀螺仪驱动时,当时被时序折腾得够呛。今天咱们就把I2C的底裤扒干净,从时序到代码,一步到位。

3.1 I2C时序分析:别被波形图吓到

很多新手看到I2C的时序图就头大。其实你把它拆成几个动作,就简单了。

核心动作就四个:

  • 起始条件(Start):SCL高电平时,SDA从高变低。这是总线的「发令枪」。
  • 停止条件(Stop):SCL高电平时,SDA从低变高。这是「比赛结束」。
  • 数据位传输:SCL低电平时改变SDA,SCL高电平时采样SDA。记住这个节奏就行。
  • 应答位(ACK/NACK):每发完8个数据位,第9个时钟周期由接收方拉低SDA表示应答。如果没拉低,就是NACK,表示「我没收到,重发!」

我个人习惯:写I2C驱动前,先用逻辑分析仪抓一次波形。看一眼真实的起始、停止、ACK,比看十遍数据手册都管用。我在项目中遇到过,某国产传感器对时序要求特别苛刻,SCL上升沿必须小于300ns,否则不认。这种坑,光看手册根本发现不了。

3.2 主机读写函数封装:从底层到API

咱们直接上代码。我习惯把I2C操作封装成三个基础函数:起始、停止、字节收发。然后在此基础上构建读写API。

// 基础函数:起始条件
void I2C_Start(void) {
    SDA_HIGH();   // SDA释放
    SCL_HIGH();   // SCL拉高
    delay_us(5);  // 保持建立时间
    SDA_LOW();    // SDA拉低,产生起始
    delay_us(5);  // 保持保持时间
    SCL_LOW();    // 拉低SCL,准备传输数据
}

// 基础函数:停止条件
void I2C_Stop(void) {
    SDA_LOW();    // 确保SDA低
    SCL_HIGH();   // SCL拉高
    delay_us(5);
    SDA_HIGH();   // SDA拉高,产生停止
    delay_us(5);
}

// 基础函数:发送一个字节,返回ACK状态
uint8_t I2C_SendByte(uint8_t data) {
    uint8_t i;
    for (i = 0; i < 8; i++) {
        if (data & 0x80) SDA_HIGH();
        else SDA_LOW();
        data <<= 1;
        SCL_HIGH();
        delay_us(5);
        SCL_LOW();
        delay_us(5);
    }
    // 第9个时钟:读取ACK
    SDA_HIGH();  // 释放SDA,让从机控制
    SCL_HIGH();
    delay_us(5);
    uint8_t ack = (SDA_READ() == 0) ? 1 : 0; // 0表示ACK
    SCL_LOW();
    return ack;
}

// 基础函数:读取一个字节,并决定是否发送ACK
uint8_t I2C_ReadByte(uint8_t send_ack) {
    uint8_t i, data = 0;
    SDA_HIGH();  // 释放SDA,让从机驱动
    for (i = 0; i < 8; i++) {
        data <<= 1;
        SCL_HIGH();
        delay_us(5);
        if (SDA_READ()) data |= 0x01;
        SCL_LOW();
        delay_us(5);
    }
    // 发送ACK或NACK
    if (send_ack) SDA_LOW();   // 应答:继续读
    else SDA_HIGH();           // 非应答:停止读
    SCL_HIGH();
    delay_us(5);
    SCL_LOW();
    SDA_HIGH();  // 释放总线
    return data;
}

避坑指南:我曾经在STM32上用GPIO模拟I2C,忘记在读取字节前把SDA设为输入模式,结果读回来的全是0xFF。折腾了一下午才发现。记住:SDA方向切换一定要做对,发送时推挽输出,读取前切换为开漏输入。

3.3 多字节传输与错误处理

单字节读写只是热身。实际项目中,传感器数据往往是一串——比如MPU6050的加速度计,一次要读6个字节(X、Y、Z各2字节)。多字节传输的关键在于:连续读时,最后一个字节要发NACK,告诉从机「够了,别发了」。

下面是我常用的多字节读写封装,带错误处理:

// 多字节写:向从机地址dev_addr的寄存器reg_addr写入len字节数据
int I2C_WriteMulti(uint8_t dev_addr, uint8_t reg_addr, uint8_t *data, uint16_t len) {
    I2C_Start();
    // 发送从机地址+写位
    if (!I2C_SendByte(dev_addr << 1 | 0)) {
        I2C_Stop();
        return -1;  // 从机无应答
    }
    // 发送寄存器地址
    if (!I2C_SendByte(reg_addr)) {
        I2C_Stop();
        return -2;
    }
    // 逐字节发送数据
    for (uint16_t i = 0; i < len; i++) {
        if (!I2C_SendByte(data[i])) {
            I2C_Stop();
            return -3;  // 某字节发送失败
        }
    }
    I2C_Stop();
    return 0;  // 成功
}

// 多字节读:从从机地址dev_addr的寄存器reg_addr读取len字节
int I2C_ReadMulti(uint8_t dev_addr, uint8_t reg_addr, uint8_t *buffer, uint16_t len) {
    // 第一步:伪写,设置寄存器指针
    I2C_Start();
    if (!I2C_SendByte(dev_addr << 1 | 0)) {
        I2C_Stop();
        return -1;
    }
    if (!I2C_SendByte(reg_addr)) {
        I2C_Stop();
        return -2;
    }
    // 第二步:重启,切换为读模式
    I2C_Start();
    if (!I2C_SendByte(dev_addr << 1 | 1)) {
        I2C_Stop();
        return -3;
    }
    // 第三步:连续读取,前len-1个字节发ACK,最后一个发NACK
    for (uint16_t i = 0; i < len; i++) {
        uint8_t send_ack = (i < len - 1) ? 1 : 0;
        buffer[i] = I2C_ReadByte(send_ack);
    }
    I2C_Stop();
    return 0;
}

注意:错误处理不是摆设。我见过太多人写I2C驱动时,假设从机永远在线、永远应答。结果产品量产时,某批次传感器焊接不良,总线卡死,整机死机。我的建议是:每次I2C操作都检查返回值,如果连续3次失败,就复位从机(拉低SCL 9个时钟)并重新初始化。

3.4 实战经验:I2C速率与上拉电阻

最后聊两个容易被忽略的点:

参数 标准模式(100kHz) 快速模式(400kHz) 我的建议
上拉电阻 4.7kΩ ~ 10kΩ 1.5kΩ ~ 4.7kΩ 先按4.7kΩ试,波形不好再换小
总线电容 < 400pF < 200pF 线长超过20cm就要小心
SCL高电平时间 > 4.0μs > 0.6μs 用示波器实测最准

嗯,这里要注意:上拉电阻不是随便选的。电阻太大,上升沿变缓,高速通信会出错;电阻太小,功耗增加,从机可能拉不动。我在做游戏手柄时,为了省电选了10kΩ上拉,结果400kHz通信时波形像正弦波,数据错得一塌糊涂。后来换成2.2kΩ才搞定。

好了,I2C驱动这块就讲这么多。下一章咱们开始玩真的——用I2C驱动MPU6050陀螺仪,读取六轴数据。到时候你会看到,今天封装的这些函数,到底怎么用在实战中。