4. I2C读写操作:五种核心流程详解

I2C协议里,读写操作其实就五种基本套路。搞懂了它们,你就能跟绝大多数I2C传感器顺畅沟通了。我个人习惯把这五种操作分成两组:写操作和读操作。写操作简单些,读操作花样多点。

核心要点: I2C通信的基石就是这五种操作——单字节写、多字节写、当前地址读、随机地址读、顺序读。掌握了它们,I2C驱动开发就成功了一半。

4.1 写操作:单字节写 vs 多字节写

写操作说白了就是主机往从机里塞数据。根据数据量不同,分两种。

4.1.1 单字节写

这是最基础的操作。流程如下:

  1. 主机发送起始条件(S)
  2. 主机发送从机地址 + 写位(0)
  3. 从机回复ACK
  4. 主机发送寄存器地址(1字节)
  5. 从机回复ACK
  6. 主机发送数据(1字节)
  7. 从机回复ACK
  8. 主机发送停止条件(P)

嗯,这里要注意:寄存器地址的长度取决于芯片设计。有些传感器用8位地址,有些用16位。我遇到过一款气压传感器,它的寄存器地址是16位的,结果我按8位去写,数据全写歪了。调试了半天才发现是地址长度不对。

// 单字节写示例(伪代码)
uint8_t i2c_write_byte(uint8_t dev_addr, uint8_t reg_addr, uint8_t data) {
    i2c_start();                    // S
    i2c_send_byte(dev_addr << 1);  // 从机地址 + 写位
    if (!i2c_wait_ack()) return ERROR;
    i2c_send_byte(reg_addr);        // 寄存器地址
    if (!i2c_wait_ack()) return ERROR;
    i2c_send_byte(data);            // 数据
    if (!i2c_wait_ack()) return ERROR;
    i2c_stop();                     // P
    return OK;
}

4.1.2 多字节写

多字节写其实就是单字节写的扩展版。发送完寄存器地址后,连续发多个数据字节,每个字节后从机都会回复ACK。直到主机发停止条件。

我曾经调试一个加速度计,需要一次性配置三个寄存器。用单字节写要发三次起始和停止,效率很低。改用多字节写后,一次搞定,代码也清爽多了。

// 多字节写示例
uint8_t i2c_write_bytes(uint8_t dev_addr, uint8_t reg_addr, 
                        uint8_t *data, uint8_t len) {
    i2c_start();
    i2c_send_byte(dev_addr << 1);
    if (!i2c_wait_ack()) return ERROR;
    i2c_send_byte(reg_addr);
    if (!i2c_wait_ack()) return ERROR;
    
    for (uint8_t i = 0; i < len; i++) {
        i2c_send_byte(data[i]);
        if (!i2c_wait_ack()) return ERROR;
    }
    i2c_stop();
    return OK;
}
小技巧: 多字节写时,有些芯片内部地址会自动递增。比如你写寄存器0x10,写了3个字节,数据会依次写入0x10、0x11、0x12。但不是所有芯片都这样,一定要看数据手册。

4.2 读操作:三种方式各有千秋

读操作比写操作复杂,因为需要先告诉从机读哪个地址,然后再切换成读模式。我刚开始学I2C时,经常在地址切换这块栽跟头。

4.2.1 当前地址读

当前地址读是最简单的读操作。它不指定寄存器地址,直接读从机当前内部地址指针指向的数据。

  1. 主机发送起始条件(S)
  2. 主机发送从机地址 + 读位(1)
  3. 从机回复ACK
  4. 从机发送数据(1字节)
  5. 主机回复NACK(表示只读1字节)
  6. 主机发送停止条件(P)

这个操作很少单独用,因为你不确定当前指针指到哪了。但有一种场景很实用——连续读时,读完一批数据后,指针自动移到下一个位置,这时用当前地址读就能接着读。

4.2.2 随机地址读

随机地址读是最常用的读操作。它先写寄存器地址,再重新发起读操作。

  1. 主机发送起始条件(S)
  2. 主机发送从机地址 + 写位(0)
  3. 从机回复ACK
  4. 主机发送寄存器地址(1字节)
  5. 从机回复ACK
  6. 主机发送重复起始条件(Sr)
  7. 主机发送从机地址 + 读位(1)
  8. 从机回复ACK
  9. 从机发送数据(1字节)
  10. 主机回复NACK
  11. 主机发送停止条件(P)

这里的关键是第6步的重复起始条件(Sr)。它不是停止后再开始,而是在总线上直接发一个起始信号。这样做的好处是:总线不会被释放,其他主机抢不走控制权。

// 随机地址读示例
uint8_t i2c_read_byte(uint8_t dev_addr, uint8_t reg_addr) {
    uint8_t data;
    
    // 第一阶段:写寄存器地址
    i2c_start();
    i2c_send_byte(dev_addr << 1);  // 写模式
    i2c_wait_ack();
    i2c_send_byte(reg_addr);
    i2c_wait_ack();
    
    // 第二阶段:重复起始,读数据
    i2c_start();                    // 重复起始
    i2c_send_byte((dev_addr << 1) | 0x01);  // 读模式
    i2c_wait_ack();
    data = i2c_read_byte();
    i2c_send_nack();                // 主机回复NACK
    i2c_stop();
    
    return data;
}
避坑指南: 我曾经在随机地址读时,忘记在第二阶段把从机地址的读写位改成1。结果从机一直处于写模式,读回来的数据全是0xFF。排查了半小时才发现是地址位没改。记住:第一阶段是写地址,第二阶段是读地址,地址本身不变,但读写位要翻转。

4.2.3 顺序读

顺序读就是随机地址读的多字节版本。读第一个字节时主机回复ACK,最后一个字节回复NACK。

// 顺序读示例
uint8_t i2c_read_bytes(uint8_t dev_addr, uint8_t reg_addr, 
                       uint8_t *buffer, uint8_t len) {
    // 第一阶段:写寄存器地址
    i2c_start();
    i2c_send_byte(dev_addr << 1);
    i2c_wait_ack();
    i2c_send_byte(reg_addr);
    i2c_wait_ack();
    
    // 第二阶段:重复起始,连续读
    i2c_start();
    i2c_send_byte((dev_addr << 1) | 0x01);
    i2c_wait_ack();
    
    for (uint8_t i = 0; i < len; i++) {
        buffer[i] = i2c_read_byte();
        if (i == len - 1) {
            i2c_send_nack();  // 最后一个字节回复NACK
        } else {
            i2c_send_ack();   // 其他字节回复ACK
        }
    }
    i2c_stop();
    return OK;
}

顺序读在读取传感器多字节数据时特别有用。比如读取一个16位的温度值,或者读取一组加速度数据(X、Y、Z各2字节,共6字节),用顺序读一次搞定。

4.3 五种操作对比

操作类型 起始次数 地址发送次数 数据方向 典型应用
单字节写 1 1 主机→从机 配置单个寄存器
多字节写 1 1 主机→从机 批量配置寄存器
当前地址读 1 1 从机→主机 连续读取后续数据
随机地址读 2(含重复起始) 2 先写后读 读取指定寄存器
顺序读 2(含重复起始) 2 先写后读 读取多字节数据

4.4 知识体系图

下面这张图把五种操作的关系和流程梳理清楚了。建议你保存下来,调试时对照着看。

I2C五种读写操作流程 I2C 五种读写操作流程 写操作 单字节写 S + 地址(W) + 寄存器地址 + 数据 + P 多字节写 S + 地址(W) + 寄存器地址 + 数据1..N + P 读操作 当前地址读 S + 地址(R) + 数据 + NACK + P 随机地址读 S + 地址(W) + 寄存器地址 + Sr + 地址(R) + 数据 + NACK + P 顺序读 = 随机地址读 + 多字节读取 重复起始 S = 起始条件 Sr = 重复起始条件 P = 停止条件 ACK = 应答 NACK = 非应答 W = 写位(0) R = 读位(1) 调试口诀 写操作:起始 → 地址+写 → 寄存器地址 → 数据 → 停止 读操作:起始 → 地址+写 → 寄存器地址 → 重复起始 → 地址+读 → 数据 → 停止 多字节:最后一个字节发NACK,其余发ACK 地址不变,读写位翻转

4.5 实战避坑指南

搞I2C驱动这么多年,我总结了几条血泪教训:

  • 地址长度要确认: 有些芯片寄存器地址是8位,有些是16位。我遇到过一款温湿度传感器,数据手册写的是"register address",我以为是8位,结果它内部是16位地址映射。读出来的数据全是乱的。
  • 重复起始不能省: 随机地址读时,一定要用重复起始(Sr),不要先发停止再发起始。否则总线会被释放,如果总线上有其他主机,可能抢走控制权。
  • NACK时机要准确: 读操作时,最后一个字节要发NACK,告诉从机"够了,别发了"。如果发了ACK,从机会继续发数据,导致总线卡死。
  • 地址左移一位: 很多MCU的I2C库要求地址左移一位,最低位放读写位。比如从机地址0x76,左移后是0xEC(写)或0xED(读)。这个细节我刚开始也搞错过。
调试小工具: 我习惯在代码里加一个调试打印函数,把每次I2C通信的起始、地址、数据、ACK状态都打印出来。配合逻辑分析仪,基本能定位90%的问题。别嫌麻烦,这个习惯救过我很多次。