4. I2C 总线通信:从原理到实战
I2C 总线,说实话,是嵌入式开发里最常用的串行通信协议之一。它只用两根线——SCL(时钟)和 SDA(数据),就能挂载多个设备。我刚开始接触时也觉得神奇,两根线怎么搞定所有通信?后来才明白,靠的就是协议本身的严谨设计。
这一章,我会带你从 I2C 协议的核心原理讲起,再到 ESP-IDF 的驱动配置,最后用 EEPROM 读写实战收尾。多设备总线仲裁这块,我也会分享一些实际项目中的踩坑经验。
4.1 I2C 协议原理:两根线的艺术
I2C 是同步、半双工的通信协议。主机产生时钟,从机响应。每次通信都以「起始条件」开始,以「停止条件」结束。
核心信号定义:
- 起始条件 (S):SCL 高电平时,SDA 从高切换到低
- 停止条件 (P):SCL 高电平时,SDA 从低切换到高
- 数据有效:SCL 高电平时,SDA 必须稳定;SCL 低电平时,SDA 可以变化
- 应答 (ACK):每字节后接收方拉低 SDA 表示确认
为什么需要应答机制?说白了,就是为了让发送方知道数据是否被正确接收。我曾经在一个项目中,因为从机地址写错,主机一直收不到 ACK,整个系统卡在 I2C 中断里出不来。嗯,从那以后我写 I2C 驱动一定会加超时处理。
I2C 的地址有 7 位和 10 位两种。7 位地址最多挂 128 个设备,但实际要留一些保留地址,所以一般能挂 100 多个。10 位地址兼容 7 位,但用得不多。
| 特性 | 标准模式 | 快速模式 | 高速模式 |
|---|---|---|---|
| 最大速率 | 100 kbps | 400 kbps | 3.4 Mbps |
| 最大总线电容 | 400 pF | 400 pF | 100 pF |
| 上拉电阻 | 4.7 kΩ 左右 | 2.2 kΩ 左右 | 1 kΩ 左右 |
我个人习惯在 ESP32 上用 400 kbps 的快速模式,兼顾速度和稳定性。如果走线太长,我会降到 100 kbps。
4.2 ESP-IDF I2C 驱动配置
ESP-IDF 的 I2C 驱动封装得不错,但配置项不少。我建议你从 i2c_config_t 结构体入手。
#include "driver/i2c.h"
#define I2C_MASTER_SCL_IO 22 // GPIO 编号
#define I2C_MASTER_SDA_IO 21 // GPIO 编号
#define I2C_MASTER_NUM 0 // I2C 端口号
#define I2C_MASTER_FREQ_HZ 400000 // 400 kHz
void i2c_master_init(void) {
i2c_config_t conf = {
.mode = I2C_MODE_MASTER,
.sda_io_num = I2C_MASTER_SDA_IO,
.scl_io_num = I2C_MASTER_SCL_IO,
.sda_pullup_en = GPIO_PULLUP_ENABLE,
.scl_pullup_en = GPIO_PULLUP_ENABLE,
.master.clk_speed = I2C_MASTER_FREQ_HZ,
};
i2c_param_config(I2C_MASTER_NUM, &conf);
i2c_driver_install(I2C_MASTER_NUM, conf.mode, 0, 0, 0);
}
小提示: 如果你的板子外部已经有上拉电阻,可以把 sda_pullup_en 和 scl_pullup_en 设为 GPIO_PULLUP_DISABLE,避免内部上拉和外部上拉并联导致电阻过小。
配置完成后,就可以用 i2c_master_write_to_device() 和 i2c_master_read_from_device() 这两个封装好的 API 了。它们内部处理了起始、停止、应答等细节。
但如果你需要更灵活的控制,比如连续读写、带子地址的访问,那就得用 i2c_cmd_handle_t 来构建命令链路。
esp_err_t i2c_write_byte(uint8_t dev_addr, uint8_t reg_addr, uint8_t data) {
i2c_cmd_handle_t cmd = i2c_cmd_link_create();
i2c_master_start(cmd);
i2c_master_write_byte(cmd, (dev_addr << 1) | I2C_MASTER_WRITE, true);
i2c_master_write_byte(cmd, reg_addr, true);
i2c_master_write_byte(cmd, data, true);
i2c_master_stop(cmd);
esp_err_t ret = i2c_master_cmd_begin(I2C_MASTER_NUM, cmd, pdMS_TO_TICKS(1000));
i2c_cmd_link_delete(cmd);
return ret;
}
注意: 命令链路创建后一定要记得删除,否则会造成内存泄漏。我见过有人把 i2c_cmd_link_delete 放在条件判断后面,结果某些路径下没执行到,内存越用越多。
4.3 读写 EEPROM 实战
EEPROM 是 I2C 设备里最典型的代表。以 AT24C02 为例,容量 256 字节,地址 0x50(7 位)。
写操作分两种:字节写和页写。AT24C02 的页大小是 8 字节。页写时要注意地址不能跨页,否则数据会回绕覆盖。我曾经踩过这个坑,写入 16 字节数据,结果只成功了前 8 个,后面的把前面的覆盖了。
esp_err_t eeprom_write_page(uint8_t dev_addr, uint8_t mem_addr, uint8_t *data, size_t len) {
// 检查是否跨页
if ((mem_addr + len - 1) / 8 != mem_addr / 8) {
return ESP_ERR_INVALID_SIZE;
}
i2c_cmd_handle_t cmd = i2c_cmd_link_create();
i2c_master_start(cmd);
i2c_master_write_byte(cmd, (dev_addr << 1) | I2C_MASTER_WRITE, true);
i2c_master_write_byte(cmd, mem_addr, true);
i2c_master_write(cmd, data, len, true);
i2c_master_stop(cmd);
esp_err_t ret = i2c_master_cmd_begin(I2C_MASTER_NUM, cmd, pdMS_TO_TICKS(1000));
i2c_cmd_link_delete(cmd);
// 等待 EEPROM 内部写入完成(最多 5ms)
vTaskDelay(pdMS_TO_TICKS(5));
return ret;
}
读操作就简单多了。先写要读的内存地址,再重新发起起始条件,切换为读模式。
esp_err_t eeprom_read_bytes(uint8_t dev_addr, uint8_t mem_addr, uint8_t *buf, size_t len) {
i2c_cmd_handle_t cmd = i2c_cmd_link_create();
// 先写地址
i2c_master_start(cmd);
i2c_master_write_byte(cmd, (dev_addr << 1) | I2C_MASTER_WRITE, true);
i2c_master_write_byte(cmd, mem_addr, true);
// 重新起始,切换读
i2c_master_start(cmd);
i2c_master_write_byte(cmd, (dev_addr << 1) | I2C_MASTER_READ, true);
if (len > 1) {
i2c_master_read(cmd, buf, len - 1, I2C_MASTER_ACK);
}
i2c_master_read_byte(cmd, &buf[len - 1], I2C_MASTER_NACK);
i2c_master_stop(cmd);
esp_err_t ret = i2c_master_cmd_begin(I2C_MASTER_NUM, cmd, pdMS_TO_TICKS(1000));
i2c_cmd_link_delete(cmd);
return ret;
}
关键点: 最后一个字节要发 NACK,告诉从机不要再发了。否则从机会继续输出数据,导致总线冲突。
4.4 多设备总线仲裁
I2C 支持多主机,但同一时刻只能有一个主机控制总线。如果两个主机同时发起通信,就会发生仲裁。
仲裁的规则很简单:谁先拉低 SDA,谁就赢得总线。输的一方要释放总线,等待下次机会。你想想看,这其实是一种「线与」逻辑——只要有一个设备拉低,总线就是低电平。
在实际项目中,多主机场景不多见。更常见的是多个从机挂在同一总线上。这时候要注意地址冲突。我建议你在设计阶段就规划好地址分配,避免后期改板。
| 场景 | 建议 |
|---|---|
| 同型号设备多个 | 使用地址引脚(A0/A1/A2)区分 |
| 不同型号设备 | 确认地址不冲突,查阅数据手册 |
| 地址冲突无法避免 | 使用 I2C 多路复用器(如 PCA9548) |
避坑指南: 我曾经在一个项目里用了两个 AT24C02,忘记改地址引脚,结果两个设备地址都是 0x50。读数据时两个设备同时应答,数据完全乱掉。排查了整整一天才找到原因。
另外,总线上的上拉电阻也要注意。如果挂的设备太多,总线电容增大,信号边沿会变缓。我一般用示波器看 SCL 的上升沿,如果太缓就换小一点的上拉电阻,或者降低通信速率。
好了,I2C 的内容就到这里。从协议到驱动,再到 EEPROM 实战和仲裁机制,每一步都有值得注意的细节。希望你能把这些经验带到自己的项目里,少走一些弯路。