4、I2C驱动层实现:I2C起始/停止条件、发送/接收字节、多字节读写封装、超时与错误处理机制

好,我们进入驱动层的核心部分。I2C总线协议,说白了就是一根时钟线(SCL)加一根数据线(SDA)的舞蹈。你要让这两根线按照规定的节拍跳起来,才能跟传感器对话。这一章,我们就来手撕I2C的底层驱动。

4.1 I2C起始条件与停止条件

I2C通信的开始和结束,都有固定的信号。起始条件(Start Condition)是:SCL为高电平时,SDA从高电平切换到低电平。停止条件(Stop Condition)则是:SCL为高电平时,SDA从低电平切换到高电平。

嗯,这里要注意。很多新手会忽略时序的严格性。我在项目中遇到过,因为GPIO的翻转速度不够快,导致起始条件没被从设备识别,整包数据都丢了。所以,代码里一定要加微秒级的延时。

核心要点:起始和停止条件必须在SCL高电平时完成SDA的跳变。这是I2C协议的硬性规定,没有商量的余地。

// 起始条件
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);         // 保持
}

个人习惯:我一般会把delay_us(5)改成根据主频动态计算的宏。比如#define I2C_DELAY (SystemCoreClock / 100000 / 2)。这样移植到不同主频的MCU时,不用一个个改延时。

4.2 发送与接收一个字节

I2C的数据传输是以字节为单位的。每发送一个字节后,从设备会回复一个ACK(应答)或NACK(非应答)。发送字节时,数据在SCL低电平时准备好,SCL高电平时被采样。说白了,就是SCL上升沿锁存数据。

接收字节的逻辑刚好反过来。主机在SCL低电平时释放SDA,从设备在SCL高电平时把数据放到SDA上。主机在SCL高电平时读取SDA的电平状态。

// 发送一个字节,返回从设备的应答位
uint8_t i2c_send_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();
        delay_us(5);
        SCL_LOW();
        delay_us(5);
    }
    // 释放SDA,等待从设备应答
    SDA_HIGH();
    SCL_HIGH();
    delay_us(5);
    uint8_t ack = SDA_READ(); // 读取应答位
    SCL_LOW();
    delay_us(5);
    return ack;               // 0: ACK, 1: NACK
}

// 接收一个字节,主机发送应答位
uint8_t i2c_recv_byte(uint8_t 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);
    }
    // 主机发送应答
    if (ack) {
        SDA_HIGH();           // NACK
    } else {
        SDA_LOW();            // ACK
    }
    SCL_HIGH();
    delay_us(5);
    SCL_LOW();
    delay_us(5);
    SDA_HIGH();               // 释放SDA
    return data;
}

避坑指南:我曾经在接收最后一个字节时忘了发NACK,结果从设备以为我还要继续读,一直拉着SDA不放,导致总线卡死。记住:读最后一个字节时,一定要发NACK,然后发停止条件。

4.3 多字节读写封装

溶解氧传感器通常需要读写多个寄存器。比如读取校准参数时,可能要连续读4个字节。如果每次都调用单字节函数,效率太低。所以我们要封装多字节读写函数。

多字节读写的套路其实很简单:先发设备地址+写位,再发寄存器地址,然后重启起始条件,发设备地址+读位,最后连续读取N个字节。

// 多字节写:先发寄存器地址,再发数据
uint8_t i2c_write_multi(uint8_t dev_addr, uint8_t reg_addr, 
                         uint8_t *data, uint16_t len) {
    i2c_start();
    if (i2c_send_byte(dev_addr << 1 | 0)) { // 写位
        i2c_stop();
        return 1; // 设备无应答
    }
    if (i2c_send_byte(reg_addr)) {
        i2c_stop();
        return 2; // 寄存器地址无应答
    }
    for (uint16_t i = 0; i < len; i++) {
        if (i2c_send_byte(data[i])) {
            i2c_stop();
            return 3; // 数据发送失败
        }
    }
    i2c_stop();
    return 0; // 成功
}

// 多字节读:先写寄存器地址,再读数据
uint8_t i2c_read_multi(uint8_t dev_addr, uint8_t reg_addr,
                        uint8_t *buffer, uint16_t len) {
    i2c_start();
    if (i2c_send_byte(dev_addr << 1 | 0)) {
        i2c_stop();
        return 1;
    }
    if (i2c_send_byte(reg_addr)) {
        i2c_stop();
        return 2;
    }
    i2c_start(); // 重启起始条件
    if (i2c_send_byte(dev_addr << 1 | 1)) { // 读位
        i2c_stop();
        return 3;
    }
    for (uint16_t i = 0; i < len; i++) {
        uint8_t ack = (i == len - 1) ? 1 : 0; // 最后一字节发NACK
        buffer[i] = i2c_recv_byte(ack);
    }
    i2c_stop();
    return 0;
}

我的经验:多字节读写时,我习惯把错误码定义成枚举。比如I2C_OK、I2C_ERR_NO_ACK、I2C_ERR_TIMEOUT。这样上层调用时,看一眼返回值就知道哪里出了问题,不用猜。

4.4 超时与错误处理机制

嵌入式系统里,最怕的就是死等。如果从设备突然掉线,或者总线被拉死,你的程序就会卡在某个while循环里出不来。所以,超时机制是必须的。

我一般用硬件定时器或者系统滴答定时器来实现超时。每次开始I2C操作前,记录当前时间戳。在等待应答或等待总线空闲的循环里,不断检查时间差是否超过阈值。

// 带超时的起始条件
uint8_t i2c_start_timeout(uint32_t timeout_ms) {
    uint32_t start = get_tick_ms();
    // 等待总线空闲(SDA和SCL都为高)
    while (!(SDA_READ() && SCL_READ())) {
        if (get_tick_ms() - start > timeout_ms) {
            return I2C_ERR_TIMEOUT; // 超时
        }
    }
    i2c_start();
    return I2C_OK;
}

// 带超时的发送字节
uint8_t i2c_send_byte_timeout(uint8_t data, uint32_t timeout_ms) {
    uint32_t start = get_tick_ms();
    // ... 正常的发送逻辑 ...
    // 等待应答时加入超时检查
    while (/* 等待应答条件 */) {
        if (get_tick_ms() - start > timeout_ms) {
            i2c_stop(); // 超时后主动释放总线
            return I2C_ERR_TIMEOUT;
        }
    }
    return I2C_OK;
}

错误处理策略:

  • 设备无应答:重试3次,如果还是无应答,上报错误并复位I2C外设。
  • 总线超时:强制拉高SCL和SDA,发送9个时钟脉冲让从设备复位。
  • 数据校验错误:丢弃当前数据包,重新发起通信。

你想想看,如果传感器在深水里泡着,突然总线卡死了,你的设备是不是就废了?所以,超时和错误处理不是锦上添花,而是保命用的。

避坑指南:我曾经在一个项目里,超时时间设得太短(只有1ms),结果传感器正常工作时偶尔也会触发超时。后来我把超时时间改成10ms,问题就解决了。超时时间要根据实际通信速率来调整,别拍脑袋定。

最后,我建议把所有I2C底层操作都封装成带超时的版本。上层调用时,统一使用这些带超时的函数。这样,整个驱动层的健壮性会提升一个档次。

嗯,这一章的内容就到这里。下一章我们会把这些底层函数组合起来,实现溶解氧传感器的具体读写流程。到时候,你会看到这些基础操作是怎么拼成完整驱动的。