4. I2C总线通信:从时序到实战
I2C总线,说实话,是我在嵌入式开发中用得最多的串行通信协议之一。它只需要两根线——SCL时钟线和SDA数据线,就能挂载多个设备。你想想看,一个MCU通过两根线控制一个OLED屏幕、一个温度传感器、一个加速度计,这在项目里太常见了。
我记得刚入行那会儿,总觉得I2C协议简单,不就是两根线嘛。结果第一次调试OLED屏幕,死活不出画面。后来拿逻辑分析仪一看,时序完全不对。嗯,从那以后,我对I2C的每一个细节都不敢马虎了。
4.1 I2C协议时序基础
I2C的通信本质,就是主机控制时钟线,在时钟的配合下,通过数据线传输地址和数据。我习惯把I2C的时序拆成几个基本动作:
- 起始条件:SCL高电平时,SDA从高变低
- 停止条件:SCL高电平时,SDA从低变高
- 数据位传输:SCL低电平时改变SDA,SCL高电平时采样SDA
- 应答位:每传输8位数据后,接收方在第9个时钟周期拉低SDA表示应答
核心要点:I2C总线上所有设备都是开漏输出,通过上拉电阻实现线与逻辑。谁拉低总线,谁就拥有控制权。这也是总线仲裁的基础。
这里有个细节我特别想强调:数据必须在SCL低电平时变化,高电平时保持稳定。我曾经在一个项目中,因为SCL上升沿不够陡峭,导致从机在错误的时刻采样到了数据。后来在SCL线上加了一个10pF的下拉电容,问题就解决了。
4.2 ESP32作为I2C主机
ESP32的I2C控制器非常灵活。它有两个I2C硬件模块,可以配置为主机或从机。我个人习惯用ESP-IDF的I2C驱动库,它封装了底层的时序细节。
配置主机的基本步骤:
- 配置I2C引脚(SDA、SCL)
- 设置时钟频率(标准模式100kHz,快速模式400kHz)
- 安装I2C驱动
- 创建I2C命令链路并执行
#include "driver/i2c.h"
#define I2C_MASTER_SCL_IO 22
#define I2C_MASTER_SDA_IO 21
#define I2C_MASTER_FREQ_HZ 100000
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_NUM_0, &conf);
i2c_driver_install(I2C_NUM_0, I2C_MODE_MASTER, 0, 0, 0);
}
我的经验:上拉电阻的选择很关键。100kHz时4.7kΩ通常没问题,400kHz时建议用2.2kΩ。如果总线电容较大(比如线很长),上拉电阻要更小。我一般先焊4.7kΩ,用示波器看上升沿,如果太缓就换2.2kΩ。
4.3 ESP32作为I2C从机
ESP32做从机的情况相对少一些,但在多主系统或传感器网络中很有用。配置从机时,需要设置从机地址、接收和发送回调函数。
void i2c_slave_init(void) {
i2c_config_t conf = {
.mode = I2C_MODE_SLAVE,
.sda_io_num = GPIO_NUM_21,
.scl_io_num = GPIO_NUM_22,
.sda_pullup_en = GPIO_PULLUP_ENABLE,
.scl_pullup_en = GPIO_PULLUP_ENABLE,
.slave.addr_10bit_en = 0,
.slave.slave_addr = 0x42,
};
i2c_param_config(I2C_NUM_0, &conf);
i2c_driver_install(I2C_NUM_0, I2C_MODE_SLAVE, 256, 256, 0);
}
从机模式下,ESP32会监听总线上的地址匹配。一旦主机发送的地址与自身地址匹配,就会触发通信。我做过一个项目,用ESP32作为I2C从机,接收另一个主控发来的显示数据,然后驱动本地屏幕。这种架构在分布式系统中很实用。
4.4 驱动OLED显示屏
SSD1306驱动的OLED屏,128x64像素,是I2C通信的经典案例。它的I2C地址通常是0x3C或0x3D。驱动它其实就两件事:发命令和发数据。
命令用于配置显示参数,数据用于填充显存。我习惯把驱动封装成几个函数:
void oled_write_cmd(uint8_t cmd) {
i2c_cmd_handle_t cmd_handle = i2c_cmd_link_create();
i2c_master_start(cmd_handle);
i2c_master_write_byte(cmd_handle, (OLED_ADDR << 1) | I2C_MASTER_WRITE, true);
i2c_master_write_byte(cmd_handle, 0x00, true); // 控制字节:命令
i2c_master_write_byte(cmd_handle, cmd, true);
i2c_master_stop(cmd_handle);
i2c_master_cmd_begin(I2C_NUM_0, cmd_handle, 1000 / portTICK_PERIOD_MS);
i2c_cmd_link_delete(cmd_handle);
}
void oled_write_data(uint8_t data) {
i2c_cmd_handle_t cmd_handle = i2c_cmd_link_create();
i2c_master_start(cmd_handle);
i2c_master_write_byte(cmd_handle, (OLED_ADDR << 1) | I2C_MASTER_WRITE, true);
i2c_master_write_byte(cmd_handle, 0x40, true); // 控制字节:数据
i2c_master_write_byte(cmd_handle, data, true);
i2c_master_stop(cmd_handle);
i2c_master_cmd_begin(I2C_NUM_0, cmd_handle, 1000 / portTICK_PERIOD_MS);
i2c_cmd_link_delete(cmd_handle);
}
注意:OLED的初始化序列必须严格按照数据手册来。我见过有人漏掉了"打开电荷泵"这个命令,结果屏幕一直黑着。初始化完成后,记得发一条"显示开启"命令,否则屏幕不会亮。
显示一张图片或文字,本质就是往显存里写数据。SSD1306的显存是128x64位,共1024字节。我通常先在内存里建一个缓冲区,操作完再一次性刷到屏幕上,这样效率高很多。
4.5 多设备总线仲裁
I2C总线上可以挂多个主机和多个从机。多主机仲裁是I2C协议的精髓。说白了,就是多个主机同时想控制总线时,谁先拉低SDA谁就赢。
仲裁过程是这样的:
- 每个主机在发送数据时,会同时监测SDA电平
- 如果自己发送的是高电平,但SDA被其他主机拉低,说明仲裁失败
- 失败的主机立即释放总线,转为从机模式
- 胜出的主机继续通信
| 场景 | 主机A发送 | 主机B发送 | SDA实际电平 | 仲裁结果 |
|---|---|---|---|---|
| 地址冲突 | 0x50 (01010000) | 0x48 (01001000) | 01000000 | 主机A胜出 |
| 数据冲突 | 0xAA (10101010) | 0x55 (01010101) | 00000000 | 主机B胜出 |
我在一个项目中用过双主机架构:一个ESP32负责传感器采集,另一个负责显示和通信。它们通过I2C共享数据。刚开始担心仲裁会出问题,实际测试下来,只要地址不同、时序规范,仲裁非常可靠。
避坑指南:多主机系统里,每个主机的时钟频率必须一致。我曾经把两个主机的时钟设成不同频率,结果仲裁时时钟同步出了问题,总线直接锁死。后来统一用100kHz,再也没出过问题。
4.6 知识体系总览
下面这张图把I2C通信的核心逻辑串起来了。从协议时序到ESP32的两种角色,再到OLED驱动和多主机仲裁,每一步都有对应的硬件和软件实现。
4.7 常见问题与调试技巧
做I2C开发,调试工具很重要。我推荐几个方法:
- 逻辑分析仪:最直接的调试工具。抓取SCL和SDA波形,看起始条件、地址、数据、应答是否正常
- 示波器:检查信号质量,看上升沿、下降沿、噪声
- I2C扫描程序:遍历所有地址,看哪些设备在线。我每次焊好板子第一件事就是跑扫描
常见坑:
- SDA和SCL接反了——我犯过这个错,查了半天
- 上拉电阻没焊或焊错值——总线一直低电平
- 地址不对——OLED有0x3C和0x3D两种,看模块背面电阻
- 时钟延展——有些从机需要主机等待,没处理的话通信会失败
说实话,I2C调试最怕的是总线锁死。SDA被某个设备一直拉低,其他设备都无法通信。解决办法:给SCL连续发9个时钟脉冲,让卡住的设备完成当前传输,释放SDA。我写了一个"总线恢复"函数,每次初始化前调用一次,从此再没遇到过锁死问题。