1. I2C 在嵌入式驱动开发中的真实定位
I2C 总线在嵌入式圈子里有个很尴尬的地位:几乎每个 MCU 都带,几乎每个项目都会用到,但真正把它写稳、写透的人并不多。大部分人的 I2C 经验停留在“调通一个 OLED”或者“读一个 EEPROM”的层面,一旦遇到多设备挂载、时钟拉伸、总线死锁、休眠唤醒后设备失联这些场景,就开始抓瞎。
这一期我想把 I2C 驱动开发这件事从头到尾捋一遍。不是那种“I2C 有两根线,一根 SDA 一根 SCL”的科普,而是从实际项目出发,讲清楚在嵌入式 Linux 和裸机环境下,I2C 驱动到底该怎么设计、怎么调试、怎么避坑。涉及的内容会覆盖 I2C 通信协议的本质、时序细节、Linux 下的 i2c-dev 和 i2c-client 两种驱动模型、常见外设(EEPROM、OLED、传感器、编码器、数字电位器)的读写套路,以及那些文档里不会写但实际项目中一定会遇到的坑。
适合的读者是已经能点亮 LED、跑通过串口、对 GPIO 和中断有基本概念,准备往驱动层深入的嵌入式开发者。如果你还在纠结寄存器怎么配,建议先把 GPIO 和时钟树搞清楚再来看这篇。I2C 驱动开发的核心难点不在协议本身,而在于对时序的精确控制和对异常状态的恢复能力,这两点贯穿全文。
2. I2C 协议层:那些被忽略的时序细节
2.1 起始、停止与重复起始的实际含义
I2C 的物理层很简单,SDA 和 SCL 两根线,加上拉电阻。但协议层的细节决定了你的驱动能不能在复杂环境下稳定运行。起始条件(START)的定义是:SCL 为高电平时,SDA 从高变低。停止条件(STOP)是:SCL 为高电平时,SDA 从低变高。这两个条件必须由主机产生,从机永远不能主动发起。
重复起始(Repeated START)是很多人忽略的一个点。它的时序和普通起始一样,但它出现在一次传输的中间,而不是总线空闲时。为什么需要它?考虑这样一个场景:你要读一个 EEPROM 的某个地址,标准流程是先写设备地址加写标志,再写内存地址,然后发起重复起始,再写设备地址加读标志,最后读数据。如果没有重复起始,你必须在写完之后发一个停止条件,再重新发起始条件。这中间总线会短暂空闲,如果系统里有多个主机,就可能被其他主机抢走总线。重复起始保证了整个读操作是一个原子操作,不会被中断。
在实际驱动代码里,很多硬件 I2C 控制器会自动处理重复起始,你只需要在传输结构里把两个消息连续提交即可。但软件模拟 I2C 的时候,必须手动实现这个时序,否则读 EEPROM 会失败。我见过不少人用软件 I2C 读 EEPROM 读不出来,最后发现就是漏了重复起始。
2.2 时钟拉伸:从机的“暂停键”
时钟拉伸(Clock Stretching)是 I2C 协议里一个非常关键但经常被忽视的机制。它允许从机在还没准备好数据的时候,把 SCL 线拉低,强制主机等待。这相当于从机按下了暂停键,主机必须等到从机释放 SCL 之后才能继续产生时钟。
为什么需要这个机制?因为 I2C 是同步通信,主机产生时钟,从机被动响应。但有些从机处理速度慢,比如某些传感器完成一次 ADC 转换需要时间,或者 EEPROM 在写周期内不响应。如果没有时钟拉伸,主机按自己的节奏发时钟,从机跟不上就会丢数据。
问题在于,不是所有主机都支持时钟拉伸。很多 MCU 的硬件 I2C 外设对时钟拉伸的处理有 bug,或者根本不支持。比如某些早期的 STM32 型号,硬件 I2C 在从机拉伸时钟时会出错。这时候要么换软件模拟,要么在驱动层加延时来规避。我在实际项目里遇到过 BH1750 光照传感器在低功耗模式下需要时钟拉伸,但 STM32F1 的硬件 I2C 处理不好,最后改成软件 I2C 才稳定。
注意:如果你的 I2C 总线上挂了多个从机,时钟拉伸的兼容性要逐个确认。一个从机拉伸时钟,总线上所有设备都会受影响。
2.3 数据帧格式与 ACK/NACK 的边界情况
I2C 的数据帧格式是:每个字节 8 位,高位先发,后面跟一个 ACK/NACK 位。发送方释放 SDA,接收方拉低 SDA 表示 ACK,保持高电平表示 NACK。这里有几个边界情况需要特别注意。
第一个是地址帧和数据帧的 ACK 来源不同。地址帧的 ACK 由被寻址的从机产生,数据帧的 ACK 由接收方产生。写操作时,主机发数据,从机回 ACK;读操作时,从机发数据,主机回 ACK。最后一个字节读完后,主机必须回 NACK,然后发停止条件。如果主机在最后一个字节回了 ACK,从机会继续发下一个字节,导致总线挂死。
第二个是 NACK 的处理。主机收到 NACK 通常意味着从机没准备好或者地址不对。在驱动层,收到 NACK 后应该立即发停止条件释放总线,而不是继续发时钟。我见过有人在收到 NACK 后还在循环发数据,结果整个总线被拉死,所有设备都失联。
第三个是总线仲裁。多主机环境下,两个主机同时发起传输,谁先拉低 SDA 谁赢。输的那一方要立即退出,转为从机模式。这个机制在单主机系统里用不到,但理解它有助于你明白为什么 I2C 的 SDA 和 SCL 必须用开漏输出。
3. 裸机 I2C 驱动:从寄存器到状态机
3.1 硬件 I2C 与软件模拟的选型逻辑
裸机环境下做 I2C 驱动,第一个决策就是硬件 I2C 还是软件模拟。硬件 I2C 的优点是速度快、CPU 占用低、时序精确。缺点是不同 MCU 的硬件 I2C 外设差异大,有些型号的硬件 I2C 有已知 bug,调试起来很痛苦。软件模拟的优点是移植性好、时序可控、不受硬件外设限制。缺点是速度慢、CPU 占用高、时序精度依赖延时函数。
我的选型原则是这样的:如果 MCU 的硬件 I2C 外设成熟稳定(比如 STM32F4 系列、ESP32 系列),优先用硬件 I2C。如果硬件 I2C 有已知问题(比如 STM32F1 的某些型号),或者需要非常特殊的时序(比如某些非标准 I2C 设备),用软件模拟。另外,如果 I2C 总线上有需要时钟拉伸的设备,而硬件 I2C 不支持,也只能用软件模拟。
软件模拟 I2C 的代码结构通常是一个状态机,每个状态对应一个时序单元。比如起始条件、发送字节、接收字节、发送 ACK、接收 ACK、停止条件。每个状态里控制 SDA 和 SCL 的电平,然后延时半个周期。延时的精度决定了 I2C 的实际速率。标准模式 100kHz,快速模式 400kHz,高速模式 3.4MHz。软件模拟一般只能做到 100kHz 左右,再快延时就不准了。
3.2 状态机设计:避免阻塞式延时
很多人的软件 I2C 代码是阻塞式的,发一个字节就死等 ACK,等不到就卡在那里。这在简单项目里能用,但在复杂系统里是灾难。如果从机没接好或者地址错了,整个系统就卡死了。
正确的做法是把 I2C 传输设计成状态机,每个状态执行一小步,然后返回。主循环或者定时器中断里轮询状态机,超时了就报错退出。这样即使从机没响应,系统也不会卡死。状态机的状态可以这样划分:IDLE、START、SEND_ADDR、CHECK_ACK、SEND_DATA、RECV_DATA、SEND_ACK、STOP、ERROR。每个状态里只做一个动作,然后根据结果跳转到下一个状态。
超时机制是必须的。每个状态都有一个超时计数器,超过阈值就跳到 ERROR 状态,发停止条件释放总线。超时阈值根据 I2C 速率来定,100kHz 下一个字节大概 90 微秒,超时设 1 毫秒足够了。
3.3 中断驱动与 DMA 的取舍
硬件 I2C 可以用中断或者 DMA 来驱动。中断方式下,每个字节传输完成产生一个中断,CPU 在中断里处理下一个字节。DMA 方式下,CPU 只需要配置好传输参数,DMA 控制器自动搬运数据,传输完成后产生一个中断。
中断方式的优点是实现简单,适合小数据量传输。缺点是每个字节都要进中断,CPU 占用高。DMA 方式的优点是 CPU 占用低,适合大数据量传输(比如读写 EEPROM 的连续页)。缺点是实现复杂,需要处理 DMA 和 I2C 的同步问题。
我的建议是:如果传输数据量小于 16 字节,用中断方式就够了。如果大于 16 字节,考虑用 DMA。但要注意,I2C 的 DMA 传输和 SPI 不同,I2C 有地址帧、ACK 位这些额外的时序,DMA 控制器不一定能自动处理。很多 MCU 的 I2C DMA 只支持数据阶段的搬运,地址和 ACK 还是要 CPU 参与。所以实际用起来,I2C DMA 的收益没有 SPI DMA 那么明显。
4. 嵌入式 Linux 下的 I2C 驱动模型
4.1 i2c-dev:用户态直接操作总线
嵌入式 Linux 下操作 I2C 有两种方式:i2c-dev 和 i2c-client。i2c-dev 是把 I2C 适配器暴露成字符设备,用户态程序通过 ioctl 直接发起 I2C 传输。这种方式适合快速验证和简单应用,不需要写内核驱动。
使用 i2c-dev 的流程是这样的:打开 /dev/i2c-N 设备文件,然后用 I2C_SLAVE ioctl 设置从机地址,接着用 read/write 或者 I2C_RDWR ioctl 进行数据传输。I2C_RDWR 是最灵活的,可以一次提交多个消息,支持重复起始。
#include <linux/i2c-dev.h> #include <linux/i2c.h> #include <fcntl.h> #include <sys/ioctl.h> #include <unistd.h> int fd = open("/dev/i2c-1", O_RDWR); ioctl(fd, I2C_SLAVE, 0x50); struct i2c_msg msgs[2]; unsigned char addr_buf[1] = {0x00}; unsigned char data_buf[16]; msgs[0].addr = 0x50; msgs[0].flags = 0; msgs[0].len = 1; msgs[0].buf = addr_buf; msgs[1].addr = 0x50; msgs[1].flags = I2C_M_RD; msgs[1].len = 16; msgs[1].buf = data_buf; struct i2c_rdwr_ioctl_data rdwr; rdwr.msgs = msgs; rdwr.nmsgs = 2; ioctl(fd, I2C_RDWR, &rdwr);这段代码读 EEPROM 的 0x00 地址开始的 16 个字节。两个消息连续提交,第一个写内存地址,第二个读数据,中间自动插入重复起始。这是 i2c-dev 最常用的模式。
i2c-dev 的优点是简单直接,不需要编译内核模块。缺点是每次传输都要进内核,开销大;而且用户态程序要自己处理错误和重试。适合调试阶段和简单应用,不适合高性能场景。
4.2 i2c-client:内核态驱动框架
i2c-client 是内核态的 I2C 驱动模型。你需要实现一个 i2c_driver 结构体,注册到 I2C 核心层,然后实现 probe、remove、以及具体的读写函数。这种方式适合需要在内核态频繁访问 I2C 设备的场景,比如触摸屏、传感器、电源管理芯片。
i2c-client 驱动的核心是 i2c_transfer 函数,它和 i2c-dev 的 I2C_RDWR 类似,也是提交一组 i2c_msg。区别在于 i2c_transfer 是在内核态调用的,不需要经过字符设备层。
static int mydev_read(struct i2c_client *client, u8 reg, u8 *buf, int len) { struct i2c_msg msgs[2]; int ret; msgs[0].addr = client->addr; msgs[0].flags = 0; msgs[0].len = 1; msgs[0].buf = ® msgs[1].addr = client->addr; msgs[1].flags = I2C_M_RD; msgs[1].len = len; msgs[1].buf = buf; ret = i2c_transfer(client->adapter, msgs, 2); if (ret < 0) return ret; return 0; }i2c-client 驱动需要在设备树或者板级文件里描述设备信息,包括 I2C 总线号、从机地址、中断引脚等。内核启动时会根据这些信息匹配驱动,调用 probe 函数。probe 函数里初始化设备,注册字符设备或者 input 设备,供用户态使用。
i2c-client 的优点是性能好、可以处理中断、可以和其他内核子系统集成。缺点是需要编译内核模块,调试起来比用户态麻烦。我的经验是,如果只是读个传感器数据,用 i2c-dev 就够了;如果要处理中断、要做电源管理、要和其他驱动交互,就必须用 i2c-client。
4.3 设备树中的 I2C 节点配置
设备树是嵌入式 Linux 描述硬件的方式。I2C 设备在设备树里的节点通常挂在 I2C 控制器节点下面。比如:
&i2c1 { status = "okay"; clock-frequency = <100000>; eeprom@50 { compatible = "atmel,24c02"; reg = <0x50>; }; oled@3c { compatible = "solomon,ssd1306"; reg = <0x3c>; }; };clock-frequency 指定 I2C 总线速率,标准模式 100kHz,快速模式 400kHz。reg 属性指定从机地址。compatible 属性用于匹配驱动。
这里有个坑:I2C 从机地址在设备树里是 7 位地址,不包括读写位。但有些数据手册给的地址是 8 位的,包含了读写位。比如 SSD1306 的数据手册写从机地址是 0x78,这是 8 位地址,实际 7 位地址是 0x3C。设备树里要填 0x3C,不是 0x78。我见过不少人在这里填错,导致驱动匹配不上。
另一个坑是地址冲突。I2C 总线上每个从机地址必须唯一。如果两个设备地址相同,总线会出问题。有些设备可以通过引脚配置地址,比如 EEPROM 的 A0/A1/A2 引脚。设计硬件的时候就要规划好地址分配,避免冲突。
5. 典型外设的 I2C 读写套路
5.1 EEPROM:页写与写周期等待
EEPROM 是最经典的 I2C 设备,也是学习 I2C 驱动的最好样本。以 AT24C02 为例,容量 2Kbit,即 256 字节,页大小 8 字节。读操作很简单,写内存地址然后读数据。写操作稍微复杂,分字节写和页写。
字节写是写一个内存地址加一个字节数据。页写是写一个内存地址加多个字节数据,但不能跨页。AT24C02 的页大小是 8 字节,如果你从地址 0x06 开始写 4 个字节,会写到 0x06、0x07、0x00、0x01,因为地址在页内回绕了。这是 EEPROM 的一个特性,也是容易踩的坑。
写操作完成后,EEPROM 进入内部写周期,通常 5 毫秒。在这期间,EEPROM 不响应任何 I2C 命令。如果你紧接着发起下一次写操作,会收到 NACK。正确的做法是写完之后等待 5 毫秒,或者用“应答轮询”的方式:反复发起起始条件加设备地址,直到收到 ACK 为止。
void eeprom_wait_ready(int fd, uint8_t addr) { uint8_t dummy; while (1) { if (i2c_read(fd, addr, &dummy, 1) == 0) break; usleep(100); } }应答轮询比固定延时更高效,因为写周期可能提前完成。但要注意,有些 EEPROM 在写周期内会拉低 SCL 做时钟拉伸,而不是回 NACK。这时候主机要支持时钟拉伸才能正确等待。
5.2 OLED 屏:命令与数据的分界
SSD1306 驱动的 OLED 屏是 I2C 设备里比较特殊的一类,因为它需要区分命令和数据。SSD1306 的 I2C 传输格式是:第一个字节是控制字节,0x00 表示后面跟的是命令,0x40 表示后面跟的是数据。然后才是实际的内容。
void oled_write_cmd(int fd, uint8_t cmd) { uint8_t buf[2] = {0x00, cmd}; write(fd, buf, 2); } void oled_write_data(int fd, uint8_t data) { uint8_t buf[2] = {0x40, data}; write(fd, buf, 2); }初始化 SSD1306 需要发送一系列命令,包括设置对比度、显示模式、扫描方向、时钟分频等。这些命令的顺序不能乱,否则屏幕不亮或者显示异常。我建议直接参考厂商提供的初始化序列,不要自己瞎试。
0.9 寸 OLED 和 1.3 寸 OLED 的驱动芯片可能不同。0.9 寸通常是 SSD1306,1.3 寸通常是 SH1106。SH1106 的显存是 132x64,但屏幕只有 128x64,所以每页有 2 个字节的偏移。如果你用 SSD1306 的驱动去驱动 SH1106,显示会偏移两列。这个坑我在项目里踩过,调了半天才发现是驱动芯片不兼容。
5.3 传感器:BH1750 与 AS5600 的读取差异
BH1750 是光照传感器,I2C 地址 0x23(ADDR 引脚接地)或 0x5C(ADDR 接 VCC)。它的读取流程是:发送测量命令,等待测量完成,然后读 2 个字节的数据。测量时间取决于测量模式,连续高分辨率模式需要 120 毫秒左右。
AS5600 是磁编码器,I2C 地址 0x36。它的读取流程是:直接读角度寄存器,得到 12 位的角度值。AS5600 支持硬件 I2C 和软件 I2C,但硬件 I2C 读取时要注意时序,因为 AS5600 的寄存器地址是 16 位的,需要发送两个字节的地址。
uint16_t as5600_read_angle(int fd) { uint8_t reg[2] = {0x0E, 0x00}; uint8_t data[2]; struct i2c_msg msgs[2]; msgs[0].addr = 0x36; msgs[0].flags = 0; msgs[0].len = 2; msgs[0].buf = reg; msgs[1].addr = 0x36; msgs[1].flags = I2C_M_RD; msgs[1].len = 2; msgs[1].buf = data; i2c_transfer(fd, msgs, 2); return (data[0] << 8) | data[1]; }AS5600 的角度寄存器是 0x0E,12 位数据,高 4 位在 0x0E,低 8 位在 0x0F。读出来之后要屏蔽掉高 4 位的无效数据。
5.4 数字电位器与 DAC:通过 I2C 调节电压
用 I2C 数字电位器或者 DAC 来调节 DC-DC 的反馈引脚电压,是一个很实用的技巧。DC-DC 的输出电压由反馈引脚的分压电阻决定。如果你在分压电阻上并联一个数字电位器,就可以通过 I2C 动态调节输出电压。
比如 MCP4725 是 I2C DAC,12 位分辨率,地址 0x60。它的输出接一个电阻到 DC-DC 的反馈引脚,就可以控制输出电压。写 DAC 的流程是:发送快速写命令,然后发送 12 位数据。
void mcp4725_set(int fd, uint16_t value) { uint8_t buf[3]; buf[0] = 0x40; buf[1] = (value >> 4) & 0xFF; buf[2] = (value << 4) & 0xF0; write(fd, buf, 3); }这个方案的关键是计算电阻值。假设 DC-DC 的反馈电压是 0.8V,上分压电阻是 10k,下分压电阻是 2k,输出电压是 0.8 * (10+2) / 2 = 4.8V。如果你在 2k 电阻上并联一个数字电位器,调节电位器的阻值,就可以改变下分压电阻的有效值,从而改变输出电压。具体计算要用并联电阻公式,这里不展开。
6. 那些让你加班到凌晨的 I2C 坑
6.1 总线死锁:从机拉低 SDA 不放
I2C 总线死锁是最常见也最头疼的问题。现象是 SDA 一直被拉低,主机发不了起始条件,所有设备都失联。原因通常是从机在传输过程中被复位或者断电,导致它还在等待时钟,但主机已经放弃了传输。
解决方法是手动模拟时钟脉冲,让从机把剩下的数据发完,释放 SDA。具体操作是:把 SCL 配置为 GPIO 输出,发送 9 个时钟脉冲,然后发一个停止条件。如果从机是正常的,它会在第 9 个时钟后释放 SDA。
void i2c_bus_recover(int scl_pin, int sda_pin) { gpio_set_output(scl_pin); gpio_set_input(sda_pin); for (int i = 0; i < 9; i++) { gpio_set_low(scl_pin); udelay(5); gpio_set_high(scl_pin); udelay(5); } gpio_set_output(sda_pin); gpio_set_low(sda_pin); udelay(5); gpio_set_high(scl_pin); udelay(5); gpio_set_high(sda_pin); }这个恢复流程在 Linux 下有现成的实现,叫 i2c-gpio-recover。在设备树里配置 gpios 属性,内核会在总线死锁时自动调用恢复流程。
6.2 休眠唤醒后 I2C 设备失联
ESP32 休眠唤醒后 I2C 设备失联是一个经典问题。原因是休眠时 I2C 控制器断电,唤醒后没有重新初始化。解决方法是在唤醒后重新配置 I2C 控制器,包括时钟、引脚、速率。
另一个可能的原因是休眠时 SDA 或 SCL 被拉低,唤醒后从机处于异常状态。这时候需要执行总线恢复流程。ESP32 的 I2C 驱动有一个 i2c_reset_tx_fifo 和 i2c_reset_rx_fifo 函数,可以在唤醒后调用。
还有一种情况是休眠时从机也断电了,唤醒后从机需要重新初始化。比如 OLED 屏,唤醒后需要重新发送初始化命令。这个要在应用层处理,驱动层管不了。
6.3 上拉电阻选型:不是随便放一个 4.7k 就行
I2C 的上拉电阻选型经常被忽视。很多人直接抄别人的原理图,放两个 4.7k 电阻就完事了。但实际上,上拉电阻的阻值要根据总线速率、总线电容、电源电压来计算。
上拉电阻的最大值由上升时间决定。I2C 标准规定,标准模式下上升时间不超过 1000 纳秒,快速模式下不超过 300 纳秒。上升时间 t = R * C,其中 R 是上拉电阻,C 是总线电容。总线电容包括 PCB 走线电容、引脚电容、设备电容,通常 10 到 50 皮法。
假设总线电容 50 皮法,快速模式上升时间 300 纳秒,那么 R = 300ns / 50pF = 6k。所以上拉电阻不能大于 6k。最小值由灌电流决定。I2C 标准规定,标准模式下灌电流不超过 3 毫安,快速模式下不超过 6 毫安。电源电压 3.3V,灌电流 3 毫安,那么 R = 3.3V / 3mA = 1.1k。所以上拉电阻在 1.1k 到 6k 之间。
4.7k 是一个折中值,适合 100kHz 的总线速率和较小的总线电容。如果你的总线速率是 400kHz,或者总线电容较大,4.7k 可能就太大了,导致上升沿变缓,通信失败。这时候要换小一点的电阻,比如 2.2k。
6.4 多设备挂载时的地址冲突与总线负载
多设备挂载时,地址冲突是最直接的问题。但即使地址不冲突,总线负载也会影响通信质量。每个设备都会给总线增加电容,设备越多,总线电容越大,上升时间越长。如果超过标准限制,通信就会出错。
解决方法是减少总线电容,比如缩短走线、减少设备数量、使用 I2C 多路复用器(如 TCA9548A)。TCA9548A 是一个 8 通道 I2C 开关,可以把总线分成 8 个子总线,每个子总线上挂不同的设备。这样即使多个设备地址相同,也可以分别挂在不同通道上。
另一个问题是总线速率。设备越多,支持的最高速率可能越低。比如有些老旧的 EEPROM 只支持 100kHz,如果你总线上还挂了支持 400kHz 的传感器,整个总线只能跑 100kHz。这时候要么分开总线,要么接受低速。
7. 调试 I2C 的实用工具与方法
7.1 i2c-tools:命令行快速验证
i2c-tools 是 Linux 下最常用的 I2C 调试工具。i2cdetect 可以扫描总线上的设备,i2cget 和 i2cset 可以读写寄存器。
i2cdetect -y 1 i2cget -y 1 0x50 0x00 i2cset -y 1 0x50 0x00 0xABi2cdetect 的原理是向每个地址发送起始条件加设备地址,看是否收到 ACK。如果收到 ACK,说明该地址有设备。但要注意,有些设备在写周期内不响应,i2cdetect 可能扫不到。还有些设备地址是保留的,i2cdetect 会跳过。
i2cget 和 i2cset 适合快速验证寄存器读写。但它们的传输格式是固定的,不支持重复起始。对于需要重复起始的设备(比如 EEPROM),i2cget 可能读不出来。这时候要用 i2ctransfer,它支持自定义消息组合。
i2ctransfer -y 1 w1@0x50 0x00 r16这条命令向 0x50 写一个字节 0x00,然后读 16 个字节。中间自动插入重复起始。
7.2 逻辑分析仪:抓时序的终极手段
逻辑分析仪是调试 I2C 的终极武器。它可以把 SDA 和 SCL 的波形抓下来,解码成具体的字节和 ACK。Saleae 的逻辑分析仪配合它的软件,可以自动解码 I2C 协议,非常方便。
抓波形的时候要注意采样率。I2C 标准模式 100kHz,快速模式 400kHz,采样率至少要 4 倍以上,建议 10 倍。比如 400kHz 的总线,采样率设 4MHz 以上。采样深度要足够,至少能抓到一个完整的传输周期。
分析波形的时候重点看几个地方:起始条件是否干净、地址帧的 ACK 是否正常、数据帧的 ACK 是否正常、停止条件是否干净、有没有时钟拉伸、上升沿是否太缓。如果上升沿太缓,说明上拉电阻太大或者总线电容太大。
7.3 用 GPIO 模拟 I2C 做交叉验证
如果你怀疑硬件 I2C 有问题,可以用 GPIO 模拟 I2C 做交叉验证。把 SDA 和 SCL 配置成 GPIO,用软件模拟时序,看能不能通信。如果软件模拟能通,硬件 I2C 不通,说明硬件 I2C 的配置有问题。如果软件模拟也不通,说明硬件电路有问题。
这个方法虽然笨,但非常有效。我在项目里遇到过 STM32 硬件 I2C 在特定条件下死锁的问题,最后就是用 GPIO 模拟验证的。软件模拟跑了几天都没问题,硬件 I2C 几个小时就死一次,最后确认是硬件 I2C 外设的 bug。
8. 从驱动到应用:I2C 代码的工程化组织
8.1 分层设计:总线层、设备层、应用层
I2C 代码的工程化组织很重要。我通常分三层:总线层、设备层、应用层。总线层封装 I2C 的基本读写,提供 i2c_read 和 i2c_write 接口。设备层封装具体设备的寄存器读写,比如 eeprom_read、oled_write_cmd、bh1750_read。应用层调用设备层的接口,实现业务逻辑。
这样分层的好处是,换 MCU 或者换 I2C 控制器时,只需要改总线层,设备层和应用层不用动。换设备时,只需要改设备层,总线层和应用层不用动。
总线层的接口设计要统一。比如:
int i2c_write(int bus, uint8_t addr, uint8_t *buf, int len); int i2c_read(int bus, uint8_t addr, uint8_t *buf, int len); int i2c_write_read(int bus, uint8_t addr, uint8_t *wbuf, int wlen, uint8_t *rbuf, int rlen);i2c_write_read 是最常用的,它封装了写地址加重复起始加读数据的流程。设备层的函数都基于这三个接口实现。
8.2 错误处理与重试策略
I2C 通信出错是常态,尤其是在电磁干扰大的环境下。错误处理策略决定了系统的稳定性。我的做法是:每次 I2C 传输失败后重试 3 次,每次重试之间延时 1 毫秒。如果 3 次都失败,执行总线恢复流程,然后再重试 3 次。如果还是失败,返回错误给上层。
重试的时候要注意,不是所有错误都值得重试。NACK 错误可能是从机没准备好,重试有用。总线死锁错误重试没用,必须先恢复总线。超时错误可能是从机没响应,重试也可能没用。所以错误处理要分类,不同错误不同策略。
int i2c_transfer_with_retry(int bus, uint8_t addr, uint8_t *wbuf, int wlen, uint8_t *rbuf, int rlen) { int ret; for (int i = 0; i < 3; i++) { ret = i2c_write_read(bus, addr, wbuf, wlen, rbuf, rlen); if (ret == 0) return 0; if (ret == -EAGAIN) usleep(1000); else if (ret == -EBUSY) { i2c_bus_recover(bus); usleep(1000); } } return ret; }8.3 性能优化:批量传输与缓存
I2C 的速率有限,标准模式 100kHz,快速模式 400kHz。每次传输都有起始条件、地址帧、ACK 位的开销,实际有效数据速率更低。所以性能优化的关键是减少传输次数,尽量批量传输。
比如读 EEPROM,如果每次读一个字节,读 256 字节需要 256 次传输。如果一次读 16 字节,只需要 16 次传输。如果一次读 256 字节,只需要 1 次传输。当然,EEPROM 的页大小有限制,不能一次读太多。但传感器数据通常可以批量读。
另一个优化是缓存。如果某个寄存器的值不经常变,可以读一次缓存起来,下次直接用缓存,不用再读。比如 OLED 的初始化命令,只需要发一次,不用每次都发。但要注意缓存的一致性,如果设备状态可能被外部改变,缓存就要失效。
9. 写在最后:一些个人体会
I2C 驱动开发这件事,说难不难,说简单也不简单。协议本身很简单,两根线,几个状态。但实际项目里遇到的问题,往往不是协议本身的问题,而是硬件、时序、异常处理这些细节的问题。
我个人的经验是,调试 I2C 问题的时候,先确认硬件没问题,再确认时序没问题,最后才怀疑代码。硬件问题包括上拉电阻、电源、走线、地址配置。时序问题包括速率、时钟拉伸、重复起始。代码问题包括状态机、超时、错误处理。按这个顺序排查,能省很多时间。
还有一个体会是,不要迷信硬件 I2C。硬件 I2C 虽然快,但不同 MCU 的硬件 I2C 差异很大,有些型号的硬件 I2C 有已知 bug,调试起来很痛苦。软件模拟 I2C 虽然慢,但可控性强,移植性好,在很多场景下反而是更稳妥的选择。
最后,I2C 总线上挂的设备越多,出问题的概率越大。如果项目里 I2C 设备很多,建议用 I2C 多路复用器,把总线分开,每个子总线上挂少量设备。这样即使某个设备出问题,也不会影响其他设备。这个经验是我在一个项目里踩了无数次坑之后总结出来的,希望对你有用。