做嵌入式这几年,I2C是绕不开的总线。一块板子上温度传感器、EEPROM、RTC、屏幕,可能全挂在同一对SDA/SCL上,调试串口还没来得及接,I2C得先转起来。很多人觉得I2C入门很简单:两根线、一个地址、几条读写命令,照着Demo改改就能跑。但当它真的出问题——总线挂死、从机无应答、数据乱跳、时序和逻辑分析仪对不上——又常常无从下手。这篇我从一个老嵌入式工程师的角度,把I2C按物理层、协议层、驱动实现、故障排查、多平台落地的顺序完整拆一遍,把时序图、波形和代码放一起讲。刚入门的同学可以用来建立系统认知,被I2C坑过的工程师也可以拿来查漏补缺。
1. I2C设计初衷与家族演变:从电视机的调台旋钮说起
1.1 飞利浦当年面对的引脚困境
上世纪80年代初,飞利浦在电视机、音响产品内部遇到了一个很实际的问题:MCU、音频处理芯片、收音调谐器、EEPROM之间要通信,但并行总线占用太多引脚和PCB面积,板子越做越小,走线越来越多。工程师们于是想着把控制线压缩成两根:一根时钟SCL,一根数据SDA,所有芯片共享一对线。1982年,飞利浦半导体设计了这套总线,并命名为I2C——Inter-Integrated Circuit,也就是集成电路间总线,直到1992年才发布第一份正式规范。
这个"为慢速控制而设计"的原始定位,解释了I2C几乎所有后续特性:线少但结构完整、速率不需要多高、器件多但地址有限、天生适合处理配置和状态数据。后来它被写进几乎所有MCU外设里,成为板级通信的事实标准之一。
1.2 从标准模式到超快速模式
I2C发展到现在,有几个常见速率等级:
| 模式 | 速率 | 典型用途 |
|---|---|---|
| 标准模式(Sm) | 100 kbit/s | 基础EEPROM、RTC时钟芯片 |
| 快速模式(Fm) | 400 kbit/s | 传感器、OLED屏、ADC等一般外设 |
| 快速模式+(Fm+) | 1 Mbit/s | 频繁读写的大容量EEPROM、日志存储 |
| 高速模式(Hs) | 3.4 Mbit/s | 专用视频、音频数据通道 |
| 超快速模式(UFm) | 5 Mbit/s | 单向传输,实际很少用 |
实际项目中,400 kbit/s 是绝大多数器件的分水岭。很多传感器数据手册写"支持1MHz",但量产器件原厂未必在1MHz下做过完整测试。我自己的习惯是先把I2C时钟配成400k,遇到信号完整性问题再降到100k验证,先排除时序问题,再回来查硬件。
1.3 定位:板内的"低俗"总线
I2C不是为长距离和大流量设计的。规范规定一条总线的电容上限约400pF,折算下来PCB走线长度大概几十厘米到一米多,视线宽、过孔数和挂载设备而定。要传大文件,请选SPI、SDIO或以太网;同一块板上做配置、状态采集、低速率交互,I2C是最省资源的方案。SPI高速但四根线、每个从机还要单独片选;UART简单,但没有标准的多机寻址机制;CAN适合长距离和强干扰。I2C正好卡在中间:板内、低速、多从机、二线制。
2. 开漏输出与上拉电阻:先理解物理层再写代码
2.1 为什么必须是开漏而不是推挽
刚接触电路的同学最容易问:为什么I2C不用推挽输出?推挽输出能力更强,上升沿也更陡,不好吗?
问题是推挽结构不能直接把多个设备的输出并在一起。两个推挽引脚连到同一根线上,一个输出高电平,另一个输出低电平,就会出现一根线同时被拉向VCC和GND的情况,轻则数据错乱,重则烧毁管脚。I2C要支持一主多从甚至多主,必须保证任何设备都能安全地把总线拉低,于是选择了开漏结构:管脚只负责拉低或释放,高电平完全靠外部上拉电阻完成。
这样一来总线天然形成了"线与"逻辑:只要有一个设备把SDA拉低,整条SDA就是低电平,其他设备的状态不影响结果。这个特性在后面讲多主机仲裁时会非常重要。另外要注意,SCL线也不只是主机在驱动。从机在时钟拉伸时会把SCL拉低,所以SCL同样必须是开漏。
2.2 上拉电阻计算:先算下限,再算上限
选择上拉电阻主要看两个边界:
最小电阻由灌电流决定,公式是:
Rp_min = (VCC - VOL_max) / IOL_max其中VOL_max是低电平输出最大值,I2C规范一般取0.4V;IOL_max是管脚能承受的最大灌电流,标准模式约3mA,快速模式+器件可以达到20mA。3.3V系统用3mA算:(3.3-0.4)/0.003≈966Ω,所以取1k以上比较稳妥。
最大电阻由上升时间决定,公式是:
Rp_max = tr / (0.8473 × Cb)其中tr是要求的总线上升时间,标准模式为1000ns,快速模式为300ns,快速模式+为120ns;Cb是总线电容。举例:3.3V系统跑400k,总线电容实测约200pF:
Rp_max = 300ns / (0.8473 × 200pF) ≈ 1770Ω所以选1.5k或2.2k都合理。如果走线比较长,电容到了400pF,Rp_max只剩约885Ω,这时1k挡位才够用。反过来,上拉太小会让低电平时灌电流过大,所以要在最小阻值和最大阻值之间取值。很多开发板默认用4.7k,在短距离、单设备、低速场景完全没问题,但长走线跑400k就可能出现上升沿过缓的隐患。
2.3 总线电容超标怎么处理
如果同一总线上挂了七八个设备,或者用了接插线转接,总线电容很容易超过400pF。应对办法有几种:降低速率到100k,放宽上升时间要求;减小上拉电阻到一挡;使用I2C总线缓冲器隔离两段电容;或者干脆把设备拆分到不同的I2C控制器上。第三点要优先考虑,因为仅仅减小上拉电阻会增大低电平灌电流,还容易诱发信号反射。
3.3V和5V器件混接时,开漏结构有个天然优势:上拉电阻接多少伏,总线高电平就是多少伏,不需要额外的方向控制信号。但前提是从机管脚必须耐压,而且要仔细看数据手册里的VIH和VIL,有些"5V兼容"的说法实际只保证耐压,不保证逻辑电平识别完整。
3. 时序图逐段朗读:起始、停止、ACK与地址字节
3.1 起始条件与停止条件,其实只有一句话
I2C总线上有两种特殊状态,是SDA在SCL高电平期间的跳变:
- 起始条件(START):SCL为高电平时,SDA从高跳到低。
- 停止条件(STOP):SCL为高电平时,SDA从低跳到高。
为什么特意强调"SCL高电平期间"?因为数据有效性的规则恰恰要求SCL高电平时SDA保持稳定,所以这两个跳变就成了例外,也因此被用来标识一次传输的开始和结束。多主机系统里,START可以理解为"我宣布占用总线",STOP就是"我释放总线"。在单主机系统中,STOP之后可以立刻发起新的START,但很多从机在STOP后需要几微秒准备时间,我在代码里习惯在STOP后面加至少5us延时,避免紧跟着的START被打回。
3.2 数据有效性:SCL高电平期间SDA必须雷打不动
I2C传数据的规则非常直白:数据在SCL上升沿被采样,但在实际时序图里,真正要保证的是SCL高电平的整个区间内,SDA电平不能变化,只能等SCL回到低电平后才能切换下一位数据。所以I2C没有固定的数据采样边沿,而是把高电平区间当成采样窗口。
这就引出一个常见错误:有人把I2C时序写成SCL上升沿后立刻翻转SDA下一位,偶像素数据正好在SCL高电平期间跳动,导致从机采到错误电平。正确的做法一定是在SCL为低的时候改SDA,SCL拉高后等到从机采样完毕再拉低。实际调试中,如果你在示波器上看到SDA波形在SCL高电平中间有毛刺,十有八九是代码时序写错了。
3.3 ACK的本质:不是"回答正确",而是"我还活着"
ACK是很多人理解偏的地方。每传完一个字节,主机或从机需要在第9个时钟周期释放SDA,由接收方把SDA拉低,这就是ACK。它的含义不是校验数据内容是否正确,而是告诉发送方:"我接收端还在工作,你可以继续发下一位。"
三类常见的NACK场景:
- 主机寻址一个不存在的从机设备,从机没有响应,主机在第9个时钟会读到高电平。
- 从机接收到的寄存器地址越界,或者处于写保护状态,拒绝接收数据。
- 主机作为接收方,读完最后一字节前主动发NACK,表示"我不要再读了",然后发STOP。
最后一种NACK特别重要。读EEPROM或传感器时,如果主机在最后一个字节还回ACK,从机会认为还要继续发数据,于是继续拉高SDA输出下一字节,总线状态就乱了。
3.4 7位地址、8位字节地址与一次完整读写
I2C设备寻址最常见的是7位地址模式。举个典型例子,AT24C32的器件地址是1010 A2 A1 A0,其中A0~A2是芯片上的地址引脚,所以板子上可以同时接8颗相同型号的EEPROM。数据手册往往写"Slave Address 0x50~0x57",这是7位地址。实际操作时,需要把7位地址左移一位,最低位是R/W:0表示写,1表示读。于是:
- 写地址 = (0x50 << 1) | 0 = 0xA0
- 读地址 = (0x50 << 1) | 1 = 0xA1
很多STM32新手把0x50直接填进I2C地址参数,结果总是不出数据,原因就是没搞懂7位和8位地址的区别。HAL库的DevAddress参数、Linux的i2c-set命令,通常用的都是8位地址;而很多IC数据手册给的是7位地址,换算关系要心里有数。
一次完整的向EEPROM指定地址写一个字节的流程:
- 主机发START。
- 主机发写地址0xA0,从机回ACK。
- 主机发EEPROM内部存储地址的高8位,从机回ACK。
- 主机发存储地址的低8位,从机回ACK。
- 主机发数据字节,从机回ACK。
- 主机发STOP。
如果是从EEPROM随机读,流程会变成"先写后读":先按写入流程发送设备地址和存储地址,然后不发送数据,改为发一次REPEATED START,接着发读地址0xA1,从机开始输出数据,主机读完回NACK,最后STOP。这里为什么要用REPEATED START而不是STOP再START,我放到第5章细说。
4. 从AT24C32到BMP280:设备驱动的两种风格
4.1 AT24C32页写与轮询写完成
AT24C32是4KB的EEPROM,内部按32字节为一页。它的顺序写支持"页写":在同一个页内连续写入时,地址会自动递增,一次最多可以写入32字节。但有一个致命的坑——如果写入过程中跨越页边界,地址会回卷到当前页的开头,后面的数据就会覆盖掉页首的内容。
所以在写跨越页边界的数据时,要么自己拆包,先算当前页还剩多少字节,一段一段写;要么保证每次写入的起始地址都对齐到页边界,然后严格控制长度不超过32字节。实际产品中我更推荐后者配合轮询方式实现:
发送写地址 → 发送页内数据 → STOP 然后不断发送设备地址+写位 只要收到ACK,说明内部擦写完成,可以继续下一批EEPROM写入后需要内部擦写时间,AT24C32约为5ms,期间不响应ACK。轮询ACK比固定延时5ms更可靠,时间紧张的项目能省下不少等待,而且即使在写周期内加了干扰也不会误判。
4.2 BMP280寄存器自动递增与读6字节的调包坑
BMP280这类传感器是典型的寄存器型设备。它内部有一组测量寄存器,0xF7~0xFC按顺序存放压力MSB/LSB/xLSB、温度MSB/LSB/xLSB,并且支持地址自动递增。所以读一次数据只需要:
- START + 写地址0xEC(BMP280地址0x76左移)。
- 发寄存器地址0xF7。
- REPEATED START + 读地址0xED。
- 连续读6字节,逐字节回ACK,最后一字节回NACK。
- STOP。
表面看很简单,但实际容易踩两个坑。一是好多BMP280模块地址是可配置的,SDO引脚接GND是0x76,接VCC是0x77,不同批次模块默认不一样,扫描总线或者看模块丝印最直接。二是测量完需要时间,如果你写完控制寄存器马上读数据,读到的往往还是上次的值或者0xFF,要等转换时间至少等几十毫秒,或者轮询状态寄存器。
4.3 软件模拟I2C与硬件I2C的取舍
硬件I2C外设是一个绕不开的话题。它的优势是时钟由硬件产生,不占用CPU,还能配合DMA批量传输,时序也更稳定;缺点是有些早期MCU的硬件I2C外设历史上被大量吐槽,典型的是老一代STM32标准外设库的I2C事件标志处理,不少人遇到过进死循环、总线状态卡死的问题。这里面有一部分是芯片勘误表中的真实坑,但更多是写法问题:事件标志依赖时序,在中断优先级、系统时钟配置不当的场景下容易错过事件,代码就卡住了。现在的HAL库处理得比较完善,普通应用可以直接用硬件外设。
软件模拟I2C则把GPIO当开漏用,时序全靠delay和翻转,好处是任意引脚都能复用,初始化和排错难度低,代码逻辑完全透明。很多工程师宁愿用软I2C来读写传感器,因为一旦出问题可以直接量波形、改时序,不需要和外设寄存器死磕。代价是CPU要一直在循环里翻转引脚,高频通信下占用可观。
我的经验是:传感器读取、OLED刷新这类中低速场景,软件模拟完全够用;大容量EEPROM频繁写入、需要DMA搬运数据时,还是用硬件I2C更省心。
4.4 一份可以直接抄的极简软件I2C代码
把上面讲的时序归纳成代码,核心就是start、stop、写字节、读字节四件事:
typedef struct { void (*scl_write)(uint8_t level); void (*sda_write)(uint8_t level); uint8_t (*sda_read)(void); void (*delay_us)(uint32_t us); } soft_i2c_t; static void soft_i2c_start(soft_i2c_t *bus) { bus->sda_write(1); bus->scl_write(1); bus->delay_us(5); bus->sda_write(0); bus->delay_us(5); bus->scl_write(0); } static void soft_i2c_stop(soft_i2c_t *bus) { bus->sda_write(0); bus->scl_write(1); bus->delay_us(5); bus->sda_write(1); bus->delay_us(5); } static uint8_t soft_i2c_write_byte(soft_i2c_t *bus, uint8_t data) { uint8_t i, nack; for (i = 0; i < 8; i++) { bus->sda_write((data >> (7 - i)) & 1); bus->scl_write(1); bus->delay_us(5); bus->scl_write(0); } bus->sda_write(1); // 释放SDA给从机应答 bus->scl_write(1); bus->delay_us(5); nack = bus->sda_read(); // 0: ACK, 1: NACK bus->scl_write(0); return nack; } static uint8_t soft_i2c_read_byte(soft_i2c_t *bus, uint8_t ack) { uint8_t i, data = 0; bus->sda_write(1); // 释放SDA,从机拉线输出数据 for (i = 0; i < 8; i++) { data = (data << 1) | (bus->sda_read() ? 1 : 0); bus->scl_write(1); bus->delay_us(5); bus->scl_write(0); } bus->sda_write(ack ? 0 : 1); // 最后一字节回NAK,其余回ACK bus->scl_write(1); bus->delay_us(5); bus->scl_write(0); return data; }这段代码没有和特定MCU强绑定,只需要把scl_write、sda_write、sda_read三个函数换成实际的GPIO操作即可。读字节时最后一个参数ack传1就是回NACK结束,传0继续保持通信。用的时候再在上层封装一个"写指定寄存器"和"读指定寄存器"的函数,就能通吃大部分I2C芯片。
5. 多主机仲裁、时钟拉伸与REPEATED START:高级特性的真实使用场景
5.1 仲裁:为什么两根线上还能共享总线
多主机I2C系统里,两个主机可能同时检测到总线空闲,同时发出START。这时信号会发生冲突吗?不会,因为开漏结构和线与逻辑让仲裁变得"无痛"。两个主机都在SCL控制下一位一位发地址,如果某个主机想发1,但另一台已经把SDA拉成了0,它会在SCL高电平期间发现SDA电平和自己预期不符,于是退出竞争,只剩发起总线的主机继续。整个过程数据字节不会被破坏,输掉的主机就像什么都没发生过一样等待下一轮。
实际产品里多主机场景不多,但理解仲裁能解释不少现象:为什么I2C不支持两个主机同时访问同一个从机、为什么从机不能在START后随意拉低SDA压过主机。这个机制也让I2C特别适合一个系统里多个处理器共享一组传感器数据,比如主控和低功耗协处理器共用一颗RTC。
5.2 时钟拉伸:从机说"等等再等"
时钟拉伸是指从机把SCL拉低,阻止主机继续产生时钟脉冲。从机的SoC需要时间处理内部逻辑或者DMA搬运时,它可以在收到一个字节后不放行SCL,主机检测到SCL仍然为低,就会自动等待,直到从机释放SCL。
这个特性对软件模拟I2C尤其要命。很多基础教程写写字节函数时,只在SCL拉高后固定延时几微秒,完全不会去读SCL的电平。如果遇到一个偶尔拉伸的从机,数据就会错位甚至丢失。严谨的软I2C应做到:每次把SCL拉高后,先检查SCL实际电平,如果仍是低就继续等,直到读到高电平再继续。代码里加一个while (scl_read() == 0) ;判断,就能兼容绝大多数带时钟拉伸的从机。
5.3 REPEATED START的原子性
REPEATED START通俗说就是"不释放总线,重新发一个START"。它的价值在一主多从或双主机场景下非常明显。比如读取寄存器型设备,推荐流程是:
START → 设备写地址 → 寄存器地址 → REPEATED START → 设备读地址 → 数据 → NACK → STOP如果中间不用REPEATED START,而是STOP再START,总线上就会产生一个空闲窗口。这段时间另一个主机——假如系统里还有一个主控在循环读同一个传感器——就可能插队发起访问,导致你前面发出去的寄存器地址被别的请求覆盖。使用REPEATED START能把"指定寄存器"和"读数据"捆绑成一个原子操作,从根本上避免这个问题。即使单主机系统,也建议严格按这个时序写,因为多主机是不确定性最高的场景,宁可把一个习惯养成好,也不要等到联调时才改。
6. 总线挂死、无应答与乱码:一条可复现的排查链路
6.1 第一个经典故障:SDA一直为低
I2C最常见的故障之一就是SDA被拉死。现象很直接:一上电,SDA对地为0V,SCL可能有脉冲也可能没有,所有读写全部超时。排查顺序一定要固定:
- 用万用表测SDA和SCL对地电压。如果SDA恒为0V、SCL为高,基本是某一个设备把SDA拉住了。
- 把总线上设备逐个从线上摘掉,摘到哪个SDA恢复高电平,哪个就是嫌疑设备。
- 如果无法断电摘除,就写一个"SCL脉冲9次"的恢复函数:把SCL连续拉低拉高9次,相当于给从机补完一帧未完成的数据传输,大多数从机状态机会被复位。
- 还不行,检查地址冲突、上拉电阻、供电电源是否稳定。
我遇到过不止一次,某款屏幕模块在上电后未初始化时就拉死SDA。后来在系统启动早期主动给该设备发复位序列,问题立刻消失。
6.2 上拉与星形拓扑导致的波形劣化
第二个坑是"100k正常、400k偶尔乱、100k稳定、但换一块板子又好了"。这种问题十有八九和信号完整性有关。要重点检查的是:上拉电阻是不是太大,走线是不是太长,以及是否存在星形分支。
用示波器看,正常I2C的上升沿应该接近垂直,如果看到明显的斜坡,说明RC充电时间太长。快速模式400k要求上升时间不超过300ns,如果实测超过这个值,数据就有概率被误采。对策是减小上拉电阻、缩短走线,或者把拓扑从星形改成菊花链。
一个很容易被忽略的细节是:很多模块自带I2C上拉电阻。如果你主板上又加了一组上拉,两个上拉并联后阻值会减半,可能低电平拉不干净;反过来,如果两边都没上拉,总线又全是毛刺。最后统计好实际挂载的电阻数量,不要想当然。
6.3 地址不对、寄存器地址不对
"从机无应答"排到硬件之后,最常见的就是地址问题。把7位地址和8位地址搞混,是最典型的一种。另一个问题是器件地址引脚没接对,比如AT24C32的A0/A1/A2悬空或接法不同,地址就会偏掉。
还有个隐蔽问题:寄存器地址是8位还是16位。EEPROM和部分传感器用的是16位内部地址,时序里要分成高字节、低字节发送;而很多传感器内部寄存器只有8位地址,只发一个字节。如果把16位地址的器件当成8位来操作,低字节会被当成数据写进去,读出来全是乱的。
排查思路是先用i2cdetect或逻辑分析仪确认设备地址能不能被应答,能应答再操作寄存器。不要跳过第一步直接读数据,否则你没法区分是设备没起来还是只是寄存器地址写错。
6.4 逻辑分析仪看波形的正确姿势
I2C调试建议常备一台逻辑分析仪,不用很贵,采样率在24MHz以上就够用。用的时候注意几点:
- 采样率至少是总线速率的4倍以上。400k的总线,采样率至少要设2MHz,实际用12MHz或24MHz更稳妥。
- 触发设置成SDA下降沿,这样能第一时间抓到START条件。
- 解码后的波形不要只看数据,要把光标放在START/STOP/ACK/NACK位置逐个确认。
有个很实用的技巧:不要把逻辑分析仪探头接在主控端,而是接到最远的从机端。因为如果总线中间有阻性损耗或反射,主控端波形可能正常,远端从机看到的却是另一幅样子。在远端抓波形,能发现很多主控端看不到的问题。
6.5 Linux下的I2C调试工具
嵌入式Linux调试I2C比裸机更方便,前提是内核开启了CONFIG_I2C_CHARDEV。常用工具:
# 扫描总线上有哪些设备地址 i2cdetect -y 1 # 查看具体设备所有寄存器的值 i2cdump -y 1 0x50 # 读单个字节 i2cget -y 1 0x50 0x00 # 写一个字节 i2cset -y 1 0x50 0x00 0xAA其中-y表示跳过确认,1是I2C总线编号,具体以/dev/i2c-N为准。用工具确认设备地址和寄存器行为,再去调驱动,效率会高很多。如果i2cdetect扫不到设备,而前面6.1到6.3又都排查过了,就要怀疑设备树或驱动里i2c控制器有没有正常使能。
7. STM32、ESP32与Linux:三套主机的落地差异
7.1 STM32硬件I2C与HAL库
STM32的硬件I2C在HAL库下用起来比标准外设库省心不少。读写EEPROM这类场景,核心就是两个函数:
HAL_I2C_Mem_Write(&hi2c1, 0xA0, addr, I2C_MEMADD_SIZE_16BIT, data, len, 1000); HAL_I2C_Mem_Read(&hi2c1, 0xA0, addr, I2C_MEMADD_SIZE_16BIT, data, len, 1000);参数里的第三、四个参数是内部寄存器地址和地址宽度,很多新手会在这两个参数上栽跟头。EEPROM内部地址是16位,必须用I2C_MEMADD_SIZE_16BIT;而BMP280这类传感器内部地址是8位,要用I2C_MEMADD_SIZE_8BIT。一旦这里选错,后续数据全是乱的。
使用DMA模式时,要注意三点:一是HAL_I2C_Mem_Read_DMA是非阻塞的,必须在回调函数或信号量里等待传输完成;二是DMA缓冲区在传输结束前不能被释放;三是超时要设得足够长,否则在高负载系统中容易误判。
关于早期STM32 I2C的"bug传闻",我的看法是有一部分确实是芯片勘误表里的坑,但更多是代码没有正确处理忙标志和事件标志。现在的HAL库和L4/G4系列硬件已经成熟很多,正常使用不需要过度恐惧。
7.2 ESP-IDF新老接口的差异
ESP32的I2C驱动经历了一次大版本演变。旧版driver/i2c.h接口用i2c_param_config加i2c_driver_install,配置两个I2C接口各做各的事,代码比较繁琐;新版i2c_master组件从ESP-IDF v5.2开始推荐使用,结构更清晰,用i2c_master_bus_add_device注册从机设备,然后直接调用i2c_master_transmit、i2c_master_receive,不再需要手动处理ACK和STOP细节。
热搜里有人问i2c_master_write_byte怎么处理,实际在新接口里,想写单一字节直接构造一个长度为1的缓冲区,调用i2c_master_transmit传进去就行。旧接口里倒是有类似i2c_master_write_to_device这样的辅助函数,可以把一整个buf发送到指定地址,更省心。
配置两个I2C接口时,一个大坑是引脚冲突和时钟源配置。ESP32的I2C时钟可以源自APB或RTC,不同频率下精度不一样,要从前后修改时钟会影响I2C时序,建议先把I2C时钟源固定下来再调节其他外设。
7.3 Linux I2C设备驱动与EMIO
Linux下I2C驱动分三层:adapter对应控制器,client对应具体从机,driver实现具体设备的读写逻辑。设备树里描述从机设备和总线频率,驱动probe时拿到struct i2c_client,之后就可以用i2c_transfer发送读写请求。Zynq或复旦微这类FPGA+PS架构的平台上,I2C控制器如果用的不是固定的MIO引脚,而是走EMIO接到PL侧,需要在设备树里正确配置引脚映射和pinctrl,否则控制器初始化成功了,物理引脚上却没有任何波形。
我遇到过的问题是设备树里明明写了clock-frequency = <400000>;,上电后实测速率却只有100k。查了很久才发现是pinctrl配置把EMIO引脚的功能设置成了GPIO,I2C控制器输出根本没有路由到PL侧。这类问题看设备树无法直接看出来,要用示波器量引脚,同时对照芯片参考手册里的信号路由表。
8. 更多设备、跨电平通信:I2C扩展与常见器件接线
8.1 TCA9548A解决设备地址冲突
当总线上挂了两个相同地址的传感器,比如两块地址都是0x68的IMU,I2C就无法区分它们。这时可以用TCA9548A这类I2C多路复用器:它本身占用一个地址,下面分出8条通道,每条通道可以独立挂一组I2C设备。
用法很简单:访问通道0的从机前,先向TCA9548A的控制寄存器写入0x01,选通通道0;访问结束后切通道前,最好写入0x00断开当前通道再切,避免两个通道同时在线导致地址再次冲突。速度要求高的场景要注意,每次切换通道都会产生额外传输,读写大量不同通道的数据时,效率会打折扣。
8.2 PCA9306双向电平转换
3.3V主控和5V外设之间做电平转换,PCA9306是最常用的方案之一。它是双向的,不需要方向控制引脚,核心是把两个电压域的总线连接起来,两端各接上拉到各自电源的上拉电阻。工程上有一个常见误解:以为PCA9306本身带了上拉,实际没有,两边的上拉电阻都得自己加,而且上拉电压要分别接到对应的VREF1和VREF2。
接线时注意把低电压侧的VREF1接3.3V,高电压侧的VREF2接5V,然后把两边的SDA和SDA、SCL和SCL分别接到芯片对应管脚。如果信号速率在400k以上,尽量选传播延迟更低的型号或直接用TXS0102,因为PCA9306在高速下会有额外延迟,极端情况下会影响时序。
8.3 常见外设接线参考
给一个常用的设备地址参考表,方便快速对比:
| 器件 | 7位地址 | 典型用途 |
|---|---|---|
| AT24C32 | 0x50~0x57 | 4KB EEPROM |
| BMP280 | 0x76或0x77 | 温湿度气压传感器 |
| MPU6050 | 0x68 | 六轴惯性传感器 |
| SSD1306 | 0x3C或0x3D | 0.96寸OLED屏 |
| DS3231 | 0x68 | 高精度RTC时钟芯片 |
| AS5600 | 0x36 | 磁编码器 |
| TCA9548A | 0x70~0x77 | I2C多路复用器 |
这些地址大部分通过引脚或模块设计可以切换,所以第一件事永远是扫描总线,而不是对着数据手册想当然。用逻辑分析仪或者i2cdetect确认设备真实地址,再开始写驱动,能省掉大量排查时间。
回到开头说的那个问题——I2C到底难不难?我的体会是:它不难,难的是在动手写代码之前,有没有把物理层的开漏、上拉、时序的"为什么"想清楚。只要总线硬件正常,再配合一套系统的排查顺序,I2C的绝大多数问题都是纸老虎。希望这篇基于个人实践经验的拆解,能让你下次遇到问题时少走几步弯路。