4. I2C读写操作:五种核心流程详解
I2C协议里,读写操作其实就五种基本套路。搞懂了它们,你就能跟绝大多数I2C传感器顺畅沟通了。我个人习惯把这五种操作分成两组:写操作和读操作。写操作简单些,读操作花样多点。
4.1 写操作:单字节写 vs 多字节写
写操作说白了就是主机往从机里塞数据。根据数据量不同,分两种。
4.1.1 单字节写
这是最基础的操作。流程如下:
- 主机发送起始条件(S)
- 主机发送从机地址 + 写位(0)
- 从机回复ACK
- 主机发送寄存器地址(1字节)
- 从机回复ACK
- 主机发送数据(1字节)
- 从机回复ACK
- 主机发送停止条件(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;
}
4.2 读操作:三种方式各有千秋
读操作比写操作复杂,因为需要先告诉从机读哪个地址,然后再切换成读模式。我刚开始学I2C时,经常在地址切换这块栽跟头。
4.2.1 当前地址读
当前地址读是最简单的读操作。它不指定寄存器地址,直接读从机当前内部地址指针指向的数据。
- 主机发送起始条件(S)
- 主机发送从机地址 + 读位(1)
- 从机回复ACK
- 从机发送数据(1字节)
- 主机回复NACK(表示只读1字节)
- 主机发送停止条件(P)
这个操作很少单独用,因为你不确定当前指针指到哪了。但有一种场景很实用——连续读时,读完一批数据后,指针自动移到下一个位置,这时用当前地址读就能接着读。
4.2.2 随机地址读
随机地址读是最常用的读操作。它先写寄存器地址,再重新发起读操作。
- 主机发送起始条件(S)
- 主机发送从机地址 + 写位(0)
- 从机回复ACK
- 主机发送寄存器地址(1字节)
- 从机回复ACK
- 主机发送重复起始条件(Sr)
- 主机发送从机地址 + 读位(1)
- 从机回复ACK
- 从机发送数据(1字节)
- 主机回复NACK
- 主机发送停止条件(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;
}
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 知识体系图
下面这张图把五种操作的关系和流程梳理清楚了。建议你保存下来,调试时对照着看。
4.5 实战避坑指南
搞I2C驱动这么多年,我总结了几条血泪教训:
- 地址长度要确认: 有些芯片寄存器地址是8位,有些是16位。我遇到过一款温湿度传感器,数据手册写的是"register address",我以为是8位,结果它内部是16位地址映射。读出来的数据全是乱的。
- 重复起始不能省: 随机地址读时,一定要用重复起始(Sr),不要先发停止再发起始。否则总线会被释放,如果总线上有其他主机,可能抢走控制权。
- NACK时机要准确: 读操作时,最后一个字节要发NACK,告诉从机"够了,别发了"。如果发了ACK,从机会继续发数据,导致总线卡死。
- 地址左移一位: 很多MCU的I2C库要求地址左移一位,最低位放读写位。比如从机地址0x76,左移后是0xEC(写)或0xED(读)。这个细节我刚开始也搞错过。