ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

I2C总线从开漏物理层到RTL实现:一周攻坚与避坑指南

I2C总线从开漏物理层到RTL实现:一周攻坚与避坑指南 1. 为什么I2C值得花一周时间彻底吃透很多人第一次接触I2C觉得它简单——两根线一根时钟一根数据挂几个从设备写个时序就能跑。但真正做过项目的人都知道I2C是那种“入门五分钟精通五年”的协议。你随便找个做过嵌入式或者FPGA的工程师问问十有八九都踩过I2C的坑上拉电阻选大了波形爬不上去选小了功耗又扛不住多主设备抢总线的时候仲裁丢了数据从设备时钟拉伸把主设备拖死PCB走线稍微长一点就开始出现莫名其妙的NACK。我自己第一次用Verilog写I2C控制器的时候觉得不就是个状态机嘛结果调了整整三天波形抓了几十张最后发现是开漏输出没配对SDA线根本没有被真正释放。从那以后我就下定决心把I2C从物理层到协议层到RTL实现全部捋一遍。这篇文章就是那次“一周攻坚”的完整总结我会把开漏物理层的电气原理、多主仲裁的逐位竞争机制、RTL状态机的设计思路、以及实际调试中遇到的各种坑全部摊开来讲。不管你是刚学Verilog的学生还是正在做项目的嵌入式工程师或者是在调试I2C总线的硬件工程师这篇内容都能帮你把I2C的底层逻辑彻底搞清楚。我不会只给你讲“怎么做”还会告诉你“为什么必须这么做”——因为I2C的很多设计决策背后都有非常实际的电气和逻辑考量。2. 开漏物理层两根线背后的电气博弈2.1 开漏输出的本质为什么不能用推挽I2C最容易被忽视的就是物理层。很多人写代码的时候只关心时序觉得电平翻转就是0和1的事。但I2C的总线结构决定了它必须用开漏输出Open-Drain这不是随便选的而是被总线共享的本质需求逼出来的。先说什么叫开漏。一个普通的CMOS推挽输出内部有上管和下管两个MOSFET输出高的时候上管导通接到VDD输出低的时候下管导通接到GND。这种结构输出阻抗很低驱动能力强但有一个致命问题你绝对不能把两个推挽输出直接连在一起。如果一个设备输出高、另一个输出低那就是VDD直接短路到GND瞬间大电流烧毁器件。I2C总线上挂多个设备每个设备的SDA和SCL都连在同一条线上。如果用推挽输出只要有两个设备同时想输出不同电平总线就短路了。所以I2C规定所有设备的输出级只能是开漏结构——只能主动拉低不能主动拉高。输出高的时候MOS管截止引脚呈高阻态相当于“放手”输出低的时候MOS管导通把线拉到GND。那高电平从哪来靠上拉电阻。总线上的上拉电阻把线拉到VDD当所有设备都“放手”的时候线就是高电平。任何一个设备拉低线就是低电平。这就实现了“线与”逻辑只要有一个设备拉低总线就是低。注意开漏输出配合上拉电阻本质上实现的是一种“分布式与逻辑”。总线的状态是所有设备输出的逻辑与——全1才是1有0就是0。这个特性是多主仲裁的物理基础。2.2 上拉电阻的计算不是随便选个4.7K就行上拉电阻的选择是I2C硬件设计中最容易出问题的地方。很多人抄别人的原理图看到4.7K就跟着用结果换个场景就不通信了。上拉电阻的取值需要同时满足两个条件上升时间够快和低电平电流不超标。先说上升时间。I2C总线的上升沿不是理想的阶跃而是通过上拉电阻给总线电容充电的RC曲线。总线电容来自PCB走线、引脚寄生电容、连接器等典型值在10pF到400pF之间。I2C标准规定标准模式100kHz上升时间不超过1000ns快速模式400kHz不超过300ns快速模式1MHz不超过120ns。上升时间的计算公式是tr ≈ 0.847 × R × C其中R是上拉电阻C是总线电容。假设总线电容是200pF快速模式要求tr ≤ 300ns那么R ≤ 300ns / (0.847 × 200pF) ≈ 1.77kΩ也就是说400kHz下200pF总线电容时上拉电阻不能超过1.77K。如果你用了4.7K上升时间会变成tr 0.847 × 4700 × 200p 796ns这已经超过了快速模式的300ns限制波形会变得很“圆”在高速下可能被误判。再说低电平电流。当设备拉低总线时上拉电阻上的电流会灌入设备的开漏MOS管。I2C标准规定标准模式和快速模式下VOL低电平输出电压最大0.4V灌电流最大3mA。计算最小电阻R ≥ (VDD - VOL) / IOL (3.3V - 0.4V) / 3mA ≈ 967Ω所以上拉电阻的合理范围是1K到1.7K对于3.3V、200pF、400kHz的场景。但实际中还要考虑功耗如果总线上有多个设备同时拉低电流会叠加吗不会因为开漏是线与电流只走一条路径。但每个设备拉低时电流都会流过自己的MOS管所以每个设备的灌电流能力都要满足。参数标准模式100kHz快速模式400kHz快速模式1MHz最大上升时间1000ns300ns120ns最大总线电容400pF400pF550pF典型上拉电阻3.3V4.7K-10K1K-2.2K680Ω-1K最大灌电流3mA3mA3mA实操心得如果你不确定总线电容可以用示波器测上升时间然后反推电容。方法是在总线上加一个已知电阻测上升时间用tr0.847RC算出C再根据目标上升时间选R。我试过几次比盲猜靠谱得多。2.3 上拉电阻小了不通信可能是这个原因热搜词里有个“i2c上拉电阻小了不通信”这个现象很典型。上拉电阻太小比如用了100Ω会出现什么问题第一功耗暴增。3.3V除以100Ω等于33mA这已经超过了大多数I2C设备的灌电流能力。设备拉低时MOS管可能进入线性区VOL升高导致低电平被误判为高电平。第二多个设备同时拉低时电流分配不均。虽然理论上电流只走一条路径但实际PCB上如果有多个设备同时拉低每个设备的MOS管都会导通电流会从VDD经过上拉电阻然后分流到各个设备的MOS管。如果某个设备的导通电阻较大它分到的电流小VOL可能正常但如果某个设备导通电阻小它分到的电流大可能过热。第三上升时间确实快了但下降时间可能变慢。因为开漏输出的下降沿是MOS管放电如果总线电容大放电时间常数是Rds_on × CRds_on通常很小几十欧姆所以下降沿很快。但上拉电阻太小会导致低电平时的静态电流很大对电池供电的设备很不友好。所以上拉电阻不是越小越好要综合上升时间、灌电流、功耗三个因素。我一般会先用1.5K到2.2K试如果波形不好再调整。3. 多主仲裁逐位竞争的艺术3.1 仲裁的物理基础线与逻辑的天然优势多主仲裁是I2C最精妙的设计之一。它不需要额外的仲裁线不需要复杂的协议只靠开漏的线与特性就能实现。原理很简单所有主设备在发送数据的同时也在监听总线上的实际电平。如果自己发的是1但总线上是0说明有别的设备在拉低自己就输了立刻退出。这个过程是逐位进行的。假设两个主设备A和B同时开始传输它们都发送起始条件然后发送地址。在每一个时钟周期它们都把自己的SDA输出和总线上的SDA比较。只要有一位不一致输的那个就退出。举个例子A发送地址0x50二进制1010000B发送地址0x52二进制1010010。前六位都一样第七位A发0B发1。A拉低SDAB放手。总线上是0B读到0发现自己发的是1但总线是0B就输了退出仲裁。A继续传输完全不知道曾经有竞争。这种仲裁机制的好处是不需要重传不需要等待输的设备自动变成从设备或者等待下一次总线空闲。而且仲裁是在数据位级别进行的不会浪费总线时间。注意仲裁只能发生在主设备发送地址或数据阶段。如果两个主设备发送的数据完全一样它们会一直竞争到最后一个位然后其中一个发送停止条件另一个继续。但这种情况很少见因为地址通常不同。3.2 时钟同步慢设备如何拖住快设备多主仲裁还有一个关键机制时钟同步。I2C的SCL线也是开漏的所有主设备都可以拉低SCL。当多个主设备同时传输时它们的时钟周期可能不同。I2C规定SCL的低电平周期由所有主设备中最长的那个决定高电平周期由最短的那个决定。具体来说每个主设备在拉低SCL后会开始计时自己的低电平周期。如果某个主设备的低电平周期还没结束它会继续拉低SCL。其他主设备看到SCL还是低就会等待。所以最终SCL的低电平时间是所有主设备低电平周期的最大值。高电平周期则相反每个主设备释放SCL后上拉电阻把线拉高。但如果有主设备还没释放线就是低。所以高电平时间是所有主设备高电平周期的最小值。这种机制保证了慢速设备不会被快速设备“带偏”整个总线的时钟频率由最慢的设备决定。这也是为什么I2C总线上可以混挂不同速度的设备——只要它们都支持标准模式就能一起工作。3.3 仲裁丢失后的处理从主到从的切换当一个主设备在仲裁中失败时它必须立刻做三件事停止拉低SDA和SCL把自己的输出级切换到高阻态完全释放总线。切换到从设备模式开始监听总线上的地址。如果赢的那个主设备发送的地址正好是自己的地址它就要响应。等待下一次总线空闲如果它还想发起传输必须等到当前传输结束停止条件出现后再重新发起起始条件。这个过程在RTL实现中需要特别小心。很多初学者写的I2C控制器在仲裁丢失后没有正确释放总线导致总线被卡死。正确的做法是在仲裁丢失的那一刻立刻把SDA和SCL的输出使能拉低同时把状态机切换到从设备接收状态。实操心得仲裁丢失是正常现象不是错误。你的控制器必须能正确处理仲裁丢失并且在丢失后能够重新发起传输。我建议在RTL中加一个仲裁丢失计数器如果连续丢失超过一定次数就主动降低优先级或者等待更长时间再试。4. RTL实现从状态机到Verilog代码4.1 状态机设计主从一体的架构用Verilog写I2C控制器最核心的是状态机设计。我推荐用主从一体的架构因为I2C设备经常需要在主从之间切换。状态机可以分成几个主要状态IDLE总线空闲等待启动条件。START发送起始条件SDA在SCL高时从高变低。ADDR发送7位地址加1位读写位。ACK1接收从设备的应答。DATA_TX发送数据字节。ACK2接收从设备的应答。DATA_RX接收数据字节。ACK3发送应答或非应答。STOP发送停止条件SDA在SCL高时从低变高。每个状态都需要一个位计数器来跟踪当前发送或接收到了第几位。I2C的时序要求SDA在SCL低电平时变化在SCL高电平时保持稳定。所以状态机的输出更新应该发生在SCL低电平期间。// 简化的I2C主设备状态机片段 localparam IDLE 3d0; localparam START 3d1; localparam ADDR 3d2; localparam ACK1 3d3; localparam DATA_TX 3d4; localparam ACK2 3d5; localparam DATA_RX 3d6; localparam ACK3 3d7; localparam STOP 3d8; reg [2:0] state, next_state; reg [2:0] bit_cnt; reg sda_out, scl_out; reg sda_oe, scl_oe; // 输出使能1为拉低0为高阻 // SDA和SCL的三态输出 assign sda sda_oe ? sda_out : 1bz; assign scl scl_oe ? scl_out : 1bz;注意这里的sda_oe和scl_oe是输出使能信号。当oe1时输出out的值当oe0时输出高阻。这就是开漏输出的RTL实现方式。4.2 时钟分频与时序参数I2C的时钟频率由SCL决定。标准模式100kHz快速模式400kHz。在FPGA中系统时钟通常是50MHz或100MHz需要分频得到SCL。分频系数的计算假设系统时钟50MHz目标SCL 400kHz那么分频系数 50MHz / 400kHz 125。但I2C的SCL不是50%占空比而是低电平时间可能比高电平时间长。标准规定标准模式下低电平至少4.7us高电平至少4.0us快速模式下低电平至少1.3us高电平至少0.6us。所以分频器需要分别控制高电平和低电平的计数。我一般用两个计数器一个计数高电平周期一个计数低电平周期。这样可以根据需要调整占空比。// 时钟分频器 reg [15:0] clk_cnt; reg scl_r; localparam HIGH_CNT 16d60; // 高电平计数 localparam LOW_CNT 16d65; // 低电平计数 always (posedge clk or negedge rst_n) begin if (!rst_n) begin clk_cnt 0; scl_r 1; end else begin if (clk_cnt (scl_r ? HIGH_CNT : LOW_CNT)) begin clk_cnt clk_cnt 1; end else begin clk_cnt 0; scl_r ~scl_r; end end end注意SCL的高电平时间不能太短否则从设备可能来不及采样。快速模式下高电平至少0.6us对应50MHz时钟是30个周期。我一般会留一些余量用35到40个周期。4.3 仲裁丢失检测的RTL实现仲裁丢失检测是主设备必须实现的功能。原理很简单在每个SCL高电平期间比较自己输出的SDA和实际读回的SDA。如果自己输出1但读回0说明仲裁丢失。// 仲裁丢失检测 reg arb_lost; always (posedge clk or negedge rst_n) begin if (!rst_n) begin arb_lost 0; end else if (state ADDR || state DATA_TX) begin if (scl_r sda_oe sda_out !sda_in) begin arb_lost 1; end end else begin arb_lost 0; end end当arb_lost置位时状态机应该立刻切换到从设备接收模式释放SDA和SCL并等待当前传输结束。4.4 从设备时钟拉伸的处理从设备时钟拉伸是指从设备在需要更多时间处理数据时主动拉低SCL。主设备必须检测到SCL被拉低并等待SCL释放后再继续。在RTL中主设备在每个SCL高电平期间应该检测SCL是否真的被上拉电阻拉高了。如果SCL还是低说明从设备在拉伸时钟主设备应该保持等待。// 时钟拉伸检测 reg scl_stretch; always (posedge clk or negedge rst_n) begin if (!rst_n) begin scl_stretch 0; end else if (scl_oe scl_out !scl_in) begin scl_stretch 1; end else if (!scl_oe scl_in) begin scl_stretch 0; end end当scl_stretch为1时主设备的时钟计数器应该暂停直到SCL真正变高。5. 常见问题与排查技巧实录5.1 波形分析逻辑分析仪怎么抓I2C逻辑分析仪是调试I2C的必备工具。抓I2C波形时要注意几个关键点触发条件设置起始条件触发SDA在SCL高时从高变低。采样率至少是SCL频率的10倍。400kHz的SCL采样率至少4MHz建议10MHz以上。解码设置大多数逻辑分析仪都有I2C解码功能设置好地址位宽7位或10位和读写位。抓到的波形要重点看几个地方起始条件是否干净地址和数据的建立保持时间是否满足ACK位是否正确拉低停止条件是否正常。5.2 常见问题速查表现象可能原因排查方法解决方案总线一直低某个设备拉死SDA或SCL逐个断开设备测电平找到故障设备检查其开漏输出起始条件后无ACK从设备地址不对用逻辑分析仪解码地址核对从设备数据手册的地址波形上升沿太慢上拉电阻太大或总线电容太大测上升时间算RC减小上拉电阻或减少总线负载多主仲裁频繁丢失多个主设备同时发起传输观察仲裁丢失时的波形加软件退避策略错开发起时间从设备时钟拉伸导致超时从设备处理慢测SCL低电平时间增加主设备超时时间或降低SCL频率数据位错误建立保持时间不足测SDA和SCL的相位关系调整RTL中SDA的变化时刻5.3 独家避坑技巧技巧一上拉电阻并联加速。如果上升沿太慢可以在上拉电阻上并联一个二极管和一个小电阻。二极管方向是从总线到VDD这样上升时二极管导通小电阻加速充电下降时二极管截止只有原上拉电阻工作功耗不增加。这个方法我实测有效但要注意二极管的结电容。技巧二总线电容估算。没有示波器的时候可以用万用表测总线对地电容。方法是在总线上加一个已知频率的方波测电流用I C × dV/dt反推C。虽然不太准但能估个大概。技巧三仲裁丢失后不要立刻重试。仲裁丢失后立刻重试很可能再次丢失。我一般会加一个随机退避时间比如等待总线空闲后再等几个微秒错开竞争窗口。技巧四用GPIO模拟I2C调试。在正式写RTL之前可以先用FPGA的GPIO模拟I2C时序手动控制SDA和SCL观察从设备的反应。这样能快速验证硬件连接和从设备地址比直接写RTL调试快得多。技巧五注意电源域。如果I2C总线上有不同电源域的设备比如3.3V和1.8V混挂需要用电平转换器。电平转换器的选择要注意方向性和速率有些转换器不支持开漏输出会导致总线无法拉低。6. 从协议到实践一个完整的I2C读写EEPROM案例6.1 EEPROM的I2C时序特点以常见的24C02 EEPROM为例它的I2C时序有几个特点设备地址是1010xxx其中xxx由A2A1A0引脚决定写操作需要发送设备地址、字地址、数据读操作需要先写设备地址和字地址然后重新发起起始条件发送读地址再读数据。写操作的时序起始条件 → 设备地址写位 → ACK → 字地址 → ACK → 数据 → ACK → 停止条件。注意EEPROM在收到停止条件后才会开始内部写周期期间不响应任何请求主设备需要等待5ms左右。读操作的时序起始条件 → 设备地址写位 → ACK → 字地址 → ACK → 起始条件 → 设备地址读位 → ACK → 数据 → NACK → 停止条件。6.2 Verilog实现的关键片段// EEPROM写操作状态机片段 localparam WR_IDLE 4d0; localparam WR_START 4d1; localparam WR_ADDR 4d2; localparam WR_ACK1 4d3; localparam WR_WORD 4d4; localparam WR_ACK2 4d5; localparam WR_DATA 4d6; localparam WR_ACK3 4d7; localparam WR_STOP 4d8; localparam WR_WAIT 4d9; // 写操作流程 always (posedge clk or negedge rst_n) begin if (!rst_n) begin state WR_IDLE; end else begin case (state) WR_IDLE: if (start) state WR_START; WR_START: state WR_ADDR; WR_ADDR: if (bit_cnt 7) state WR_ACK1; WR_ACK1: if (sda_in 0) state WR_WORD; // 收到ACK WR_WORD: if (bit_cnt 7) state WR_ACK2; WR_ACK2: if (sda_in 0) state WR_DATA; WR_DATA: if (bit_cnt 7) state WR_ACK3; WR_ACK3: if (sda_in 0) state WR_STOP; WR_STOP: state WR_WAIT; WR_WAIT: if (wait_cnt 50000) state WR_IDLE; // 等待5ms endcase end end6.3 实测波形与调试记录我第一次调EEPROM的时候写操作一直不成功逻辑分析仪显示地址和数据的ACK都正常但停止条件后EEPROM就是不响应。后来发现是等待时间不够24C02的内部写周期最大5ms我只等了1ms。改成5ms后一切正常。还有一次读操作时第一个字节读出来总是0xFF。查了半天发现是重新发起起始条件的时候SDA释放得太早导致从设备误判为停止条件。正确的做法是在SCL高电平期间先拉低SDA然后再拉低SCL这样才能形成重复起始条件。实操心得EEPROM的写周期等待时间一定要留足余量。数据手册上写5ms实际可能更长尤其是老化的芯片。我一般等6ms宁可慢一点也不要出错。7. 进阶话题I2C与其他协议的对比与选型7.1 I2C vs SPI vs UART特性I2CSPIUART线数242速度100k-3.4M1M-100M通常1M多设备支持地址寻址支持片选不支持点对点时钟同步同步异步仲裁支持不支持不支持开销地址ACK无起始停止位I2C的优势在于线少、支持多设备、有仲裁机制。劣势是速度相对慢上拉电阻和总线电容限制了传输距离。SPI速度快但线多每个设备需要单独的片选。UART简单但只能点对点。7.2 PMBus与I2C的区别PMBus是建立在I2C物理层之上的协议主要用于电源管理。它的物理层和I2C完全一样但协议层增加了特定的命令集和故障处理机制。PMBus的时钟频率通常固定为100kHz或400kHz支持PEC包错误校验。如果你做电源管理PMBus比裸I2C更方便因为命令集是标准化的。7.3 I2C扩展多路复用和电平转换当总线上设备太多地址冲突或者电容太大时可以用I2C多路复用器如TCA9548A把总线分成多路。每路有独立的电容和地址空间主设备通过写多路复用器的控制寄存器来选择当前通道。电平转换方面如果3.3V和1.8V设备混挂可以用专用的I2C电平转换器如PCA9306它支持开漏输出和双向传输。注意不要用普通的推挽电平转换器会破坏I2C的线与逻辑。8. 我踩过的那些坑和最后的建议调I2C的这几年我踩过的坑真的不少。有一次是上拉电阻用了10K100kHz下勉强能跑换成400kHz就完全不行波形圆得像个馒头。还有一次是多个主设备竞争仲裁丢失后我的控制器没有正确释放总线导致整个总线死锁所有设备都不通信。最惨的一次是PCB上SDA和SCL走线太长没有做阻抗匹配反射导致数据错误查了两天才发现是走线问题。如果让我给正在学I2C的人一个建议那就是不要只写代码一定要抓波形。I2C的问题90%都能从波形上看出来。上升沿太慢、建立保持时间不够、ACK位不对、仲裁丢失这些在波形上都一目了然。逻辑分析仪不贵几百块就能买一个但能帮你省下几十个小时的调试时间。另外RTL实现的时候一定要把仲裁丢失和时钟拉伸这两个功能做进去。很多开源I2C控制器为了简化把这两个功能省了结果在多主或者慢速从设备的场景下直接歇菜。这两个功能不难但能决定你的控制器能不能用在真实项目里。最后再分享一个小技巧如果你不确定从设备的地址可以用逻辑分析仪抓一次成功的通信直接看地址字节。比翻数据手册快得多而且不会出错。我现在的习惯是拿到任何I2C设备先抓波形确认地址和时序然后再写代码。这个习惯帮我省了无数次返工。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进