☰
FPGA实战:I2C总线时序、仲裁与EEPROM读写稳定性指南
2026/10/14 17:29:19 网站建设 项目流程

1. 从第一脚踩进泥里,到把整条I2C总线握在手里

如果你是从上一篇一路实践过来的,大概率已经在FPGA上把一个最简单的I2C主机写通了:能拉出起始条件、能往从机寄存器里写数据、运气好的时候还能抓到一次完整的ACK回读。但等真把I2C用到项目里,你会发现当初那些"能跑"的代码,几乎全都不够用。

为什么?因为真正工程里的I2C,从来不是把波形拉对就行。

我在某图像处理项目的板卡上调试时,凌晨两点钟盯着逻辑分析仪抓波形——主机明明脸上写着"我发完了",从机却死活不回ACK。后来查了整整两天,问题出在scl高电平的保持时间比从机datasheet要求的minimum高电平时间窄了差不多一百纳秒。一百纳秒,信号"看起来"完全正常,但设备就是不认。

这就是我写这篇第二篇的初衷。上一篇我们解决的是"怎么发出一个像样的I2C波形",这一篇要解决的是"怎么让你的I2C在真实器件上稳定工作"。我会把多主机场景里的仲裁、总线时序的精细测量与调整、EEPROM和传感器的实际读写,以及逻辑分析仪抓波形时最容易被忽略的几个坑,一次性摊开讲清楚。

这篇文章适合谁?已经能写基础I2C主机、但被实际联调折磨过的同学,会把RTL代码写出来但不确定时序余量够不够的工程师,以及在"波形能看"和"功能稳过"之间反复横跳的FPGA入门者。读之前请确保你已经知道起始/停止条件长什么样,以及一个最简单的状态机怎么写。

2. 从"能通"到"稳定"的核心差距:把时间里每个缝隙算清楚

2.1 重新认识I2C时序:这不是电平游戏,是时间游戏

很多初学者看I2C波形,脑子里只有一个概念:高电平是1,低电平是0,SCL上升沿采数据。这个认知在写仿真的时候是完全够用的,因为仿真模型根本不在乎你高低电平各维持了多少纳秒。但真到了物理器件上,每一个时间窗口都是一道红线。

我见过最冤的一次问题出在——标准模式(Standard Mode)的SCL是100kHz,快速模式(Fast Mode)是400kHz,而有些体积小的EEPROM芯片在2.5V电压下只能支持到400kHz,你却用1MHz的快速+模式去跑。波形一样,速率超了,器件时好时坏。

所以我们先把I2C协议里几个最容易因为"看着差不多"而翻车的参数逐一敲死。这是《I2C总线规范》里最核心的时序节点:

参数标准模式快速模式注意点
SCL时钟频率100kHz400kHz不要只看标称值,要同时满足所有器件要求
tSU;STA(起始条件建立时间)4.7µs0.6µs在SCL高电平期间SDA下降
tHD;STA(起始条件保持时间)4.0µs0.6µs从SCL下降沿到SDA首次变化
tSU;DAT(数据建立时间)250ns100nsSDA必须在SCL上升沿前有效
tHD;DAT(数据保持时间)0~3.45µs0~0.9µs有的器件对0ns保持有要求,需看器件手册
tSU;STO(停止条件建立时间)4.0µs0.6µs在SCL高电平期间SDA上升
tBUF(总线空闲时间)≥4.7µs≥1.3µs停止到下一次起始之间的间隔
tR/tF(上升/下降时间)≤1000ns≤300ns影响上拉电阻的选择,见下文

请注意,上面列的是规范的下限。实际工程中千万不要卡着下限做设计。我记得有个项目为了跑高一点速率,把时序参数按精确到下一位去配,结果不同批次板卡换上之后环境温度一升,有些组合直接误码。正确做法是:参考器件手册,给出60%~70%的余量,让极端情况下也能稳定工作。

2.2 上拉电阻不是随便选的:计算过程全写出来

I2C的SDA和SCL是开漏结构,线上需要上拉电阻把电平拉高。我见过不少新手直接把开发板上默认的上拉电阻照抄到自己的板子上,也不管总线电容、驱动能力、上升沿是否满足时序。等到总线挂的设备一多,或走线变长,就出现"大部分时候正常、偶尔通信失败"的玄学故障。

上拉电阻的取值有两个边界,一个由上升时间决定,一个由灌电流决定:

  • 下限由输出低电平时的灌电流决定。I2C器件在输出低电平时要能承受灌入的电流,通常最大灌电流是3mA(有的器件是20mA)。所以:
    • 若VCC=3.3V,最小电阻 = 3.3V / 3mA = 1.1kΩ。取个系列值,1kΩ或1.2kΩ一般没问题。
    • 若VCC=5V,最小电阻 = 5V / 3mA ≈ 1.67kΩ,取2.2kΩ以上更保险。
  • 上限由上升时间决定。上升时间近似为:tr ≈ 0.8473 × R_pullup × C_bus。这是RC充电到70%的经典公式。标准模式要求tr ≤ 1000ns,快速模式要求tr ≤ 300ns。
    • 假设总线电容C_bus = 50pF,快速模式下想要tr ≈ 200ns,则 R_pullup = 200ns / (0.8473 × 50pF) ≈ 4.72kΩ。取4.7kΩ正合适。
    • 但如果板卡走线长,C_bus到了200pF,还继续用4.7kΩ,那么tr ≈ 0.8473 × 4700 × 200pF ≈ 796ns,快速模式下妥妥超标。

实际操作中,如果无法准确估算C_bus,一个安全的做法是先在原理图阶段预留不同阻值的焊盘(比如0603封装可以手焊更换),调试时实测SDA/SCL上升时间再定。

我个人的经验值,3.3V系统、走线较短、挂两个以内器件,选4.7kΩ;挂三个以上或线长超过10cm,选2.2kΩ;如果走线特别长,或者需要抗干扰能力更强的应用,可以考虑用更低阻值并在SCL/SDA上串小电阻来抑制振铃。

2.3 主设备自己也要遵守总线协议吗?——是的

有些工程师写FPGA的I2C主机时有个误区:协议里的时序参数是给器件厂商做器件用的,主机只要把时钟拉出来就行了。情况完全不是这样。如果你的主机在起始条件之前没有留够总线空闲时间(tBUF),从机内部的状态机可能还没有从上一个停止条件中恢复过来,直接导致第一个字节就收不到ACK。

我调试时习惯把主机时序参数做成可配置的parameter,每次换器件时先查datasheet里的极限参数,留出足够余量后写到RTL顶层。这篇文章后面给出的代码,也是这个思路。

3. 多主机与仲裁:总线上不能有两个"老大"

3.1 仲裁的本质是"开漏"天然的按位与

如果总线上只有一个主机,你不需要考虑仲裁。但你在做某些冗余系统、或者需要在CPU和FPGA之间共享一条I2C总线时,多主机场景就成了必须处理的问题。

多主机仲裁的原则,用一句话概括:如果两个主机同时开始传输,它们都会在SCL上升沿采样SDA,如果某个主机准备发出的SDA电平与总线上实际采到的电平不一致,这个主机就退出竞争,剩下的主机继续传输。

为什么能做到这一点?因为开漏结构天然实现了一个"线与"功能——任何一个主机拉低SDA,总线就是低电平。只要有一个主机输出低电平,另一个输出高电平,输出高电平的那个主机在采样时看到总线是低的,就会知道自己竞争失败了。这个机制在物理层是自动完成的,不需要额外的握手协议,但作为FPGA设计者,你必须让你的状态机在发现总线电平与自己预期不符时,立即撤销输出并把SDA释放为高阻,然后等待总线空闲。

3.2 FPGA怎么参与仲裁:几点实测建议

还是用Xilinx的FPGA举例,建议IOBUF里的I/O引脚实现双向端口,这样SDA既能作为输入也能作为输出。仲裁逻辑放在状态机中,最核心的一点是:在你发送SDA数据的那个时钟周期,必须同时采样SDA引脚上的实际电平,并将采样值与你要发送的数据逐位比对。一旦发现不匹配,必须立刻终止当前字节的发送,释放总线。

下面这段是仲裁关键逻辑的参考框架,只画了核心思路,不是完整代码:

// 伪代码/核心逻辑片段:逐位比较,发现不一致则退出 always @(posedge clk or posedge rst) begin if (rst) begin i2c_arb_lost <= 1'b0; end else if (state == DATA_BIT && scl_high_sampled) begin // sda_sampled是SDA线上的实际电平 if (sda_sampled != tx_bit) begin i2c_arb_lost <= 1'b1; // 竞争失败,标记仲裁丢失 end else begin i2c_arb_lost <= i2c_arb_lost; end end end

仲裁丢失后,你的主机必须放弃当前传输且不能干扰另一个主机的通信。标准规范里,仲裁丢失的主机应当在同一字节之后的停止条件出现时,重新尝试发送。我在实际项目里看到很多实现直接让主机延迟随机时间再重试,这在多主机总线冲突频繁的场合也够用,但会牺牲一些总线利用率。更稳妥的办法是每次仲裁失败后,等待总线进入空闲状态再发起下一次传输,具体实现可以通过检测SDA和SCL同时为高且维持超过tBUF时间来判断。

3.3 时钟同步与拉伸:仲裁之外的另一层"协作"

多主机状态下,SCL也是多个主机共同驱动的。依然是开漏一样,各主机产生各自时钟信号,谁先拉低SCL,SCL就变低,后面想保持高电平的主机就会在时钟高电平期间被压低。这就实现了时钟同步——总线上的最小低电平时间和最小高电平时间,由各主机逐个拉低的时序共同决定。

还有一类器件,比如某些传感器芯片,在内部处理数据时会把SCL拉低,这个机制叫"时钟拉伸":主设备必须检测到SCL真正变高后才继续后续时序,而不是机械地等自己产生的时钟节拍。如果你的主机状态机不管SCL实际电平,直接根据自己内部计数跳转,在遇到支持时钟拉伸的从设备时就会出现数据错位。

我在写针对某传感器的驱动程序时,第一次因为没处理时钟拉伸,读出来的温度偶发跳变,且跳变值恰好是某个字节没有对齐的特征值。后来把所有等待SCL变高的地方全部改成采样等待逻辑,就再也没有出现过类似问题。这里把处理时钟拉伸的核心时序写下来:确认起始/停止状态后、发送下一个bit前、主设备在驱动SCL拉高后,等待SCL引脚实际采样为高,才继续后续数据采样,否则一直等待。

4. 实战:用FPGA读写一款EEPROM的全流程移植记录

4.1 选型与全系统架构

我以某型号EEPROM为例,它的地址是7位0b1010000(根据A0/A1/A2引脚决定I2C从机地址),容量是256Kbit,也就是32KB,支持页写(page write,最多32字节)和随机读(random read)。这个器件在FPGA项目里很典型,一是特性稳定、文档清楚,二是可以从它出发摸清I2C几乎所有主从交互花样。

在这个例子里,FPGA作为主机,通过I2C总线下挂一个此EEPROM,用板载按键触发一次"读改写写"操作:从地址0x0000读取8个字节,在FPGA内部做一次异或运算,再将结果写回同一地址。整个过程在串口打印运行状态,方便观察。

你如果拿这套思路去套其他器件,比如温度传感器、陀螺仪、音频解码芯片,核心状态机的骨架完全可以复用,改动最多的是器件地址和命令格式这两部分。

4.2 从机地址与命令解析

EEPROM器件的第一个字节是0b1010000拼接上R/W位:写方向是0xA0,读方向是0xA1。页写命令按图索骥:发送起始、发送写地址字节0xA0、发送16位字节地址(高字节在前)、接着最迟发送一页数据(32字节以内)、停止。

所有器件都遵循"起始 + 从机地址 + 命令"这样的交互,而像温度传感器(比如某常见型号)多数时候不需要片内字节地址,反而要先发一个指针寄存器地址,再读数据。建议你在动手写代码之前,先画一张时序草图,把哪些字节是从机地址、哪些是指令/寄存器地址、哪些是数据理清楚。

4.3 状态机从"单字节写"扩展为"页写与随机读"

上一篇已经实现了最基础的单字节写。现在要扩展:

  • 页写状态机:写完第一个数据字节后,如果没有停止条件但SCL继续产生时钟,EEPROM内部自动把地址加一,连续写入后续字节。在FPGA侧,你只需要在发送完一个字节并确认ACK后,判断是否还在同一页内(通常在页边界内连续写),如果是就让状态机直接进入发送下一个数据字节的状态,而不是回到闲置状态重新开始起始条件。
  • 随机读状态机:读比写多了"伪写"阶段。你需要先用一个起始条件把从机地址和你想读的字节地址发过去,然后发一个停止条件(有些器件允许Repeated Start,但大多数直接用停止条件更稳),再发起始 + 从机读取地址(R/W=1),然后读回数据。

读取阶段一个常见问题是:主机读回最后一个字节后,必须回发一个NACK来告诉从机"别再发了",然后才能发停止条件。如果主机在最后一字节回了ACK,从机会继续驱动时钟往总线上放数据,整个时序就乱了。

我建议你把状态机的每个状态命名和功能写清楚:比如S_IDLE、S_START、S_SEND_ADDR、S_CHECK_ACK、S_SEND_DATA、S_CHECK_ACK_DATA、S_READ_DATA、S_SEND_ACK、S_SEND_NACK、S_STOP、S_WAIT_BUS_FREE。让每个状态只做一件明确的事,退出条件全部明确,这样后续调试比写一个"万能循环状态机"方便得多。

4.4 完整RTL参考:以页写为例

下面是一段精简后的页写状态机核心逻辑,不是可以直接上板的全工程,但把最关键的状态跳转逻辑写出来了。时钟频率假设是50MHz(周期20ns),目标I2C时钟200kHz(即快速模式的半速,留有较大余量)。

module i2c_eeprom_page_write ( input clk, // 50MHz input rst_n, input start_write, // 发起页写 input [15:0] mem_addr, input [31:0] page_data, // 简化为一页4字节 inout sda, output reg scl ); // 时钟分频参数:200kHz SCL,半周期2.5µs=125个50MHz时钟周期 localparam SCL_HALF_PERIOD = 125; localparam [3:0] S_IDLE=0, S_START=1, S_SEND_ADDR=2, S_CHECK_ACK=3, S_SEND_ADDRH=4, S_CHECK_ACKH=5, S_SEND_ADDRL=6, S_CHECK_ACKL=7, S_SEND_DATA0=8, S_CHECK_ACK0=9, S_SEND_DATA1=10, S_CHECK_ACK1=11, S_SEND_DATA2=12, S_CHECK_ACK2=13, S_SEND_DATA3=14, S_CHECK_ACK3=15, S_STOP=16, S_WAIT=17; reg [3:0] state, next_state; reg [6:0] bit_cnt; reg [6:0] clk_cnt; reg sda_out; reg scl_out; assign sda = sda_out ? 1'bz : 1'b0; // 开漏,高电平释放 assign scl = scl_out; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_IDLE; end else begin state <= next_state; end end // 时钟节拍产生(半周期翻转) always @(posedge clk or negedge rst_n) begin if (!rst_n) begin scl_out <= 1'b1; clk_cnt <= 0; end else if (clk_cnt == SCL_HALF_PERIOD-1) begin clk_cnt <= 0; scl_out <= ~scl_out; end else begin clk_cnt <= clk_cnt + 1; end end // 状态转移与数据发送 always @(*) begin next_state = state; case (state) S_IDLE: if (start_write) next_state = S_START; S_START: begin // 产生起始条件:SCL高时SDA拉低 if (scl_out == 1'b0) next_state = S_SEND_ADDR; else next_state = S_START; end S_SEND_ADDR: if (bit_cnt==7 && scl_out==1'b1) next_state = S_CHECK_ACK; else if (scl_out==1'b0) bit_cnt = bit_cnt + 1; // ... 后续状态类似,每个数据字节发送8bit,然后ACK endcase end endmodule

代码片段只是帮你看清晰状态间的依赖顺序。实战里每一段SCL翻转逻辑、SDA输出逻辑都要写得很显式。我当时踩得最深的坑是——SDA在SCL低电平期间才允许变化,如果你的状态机在不同时钟相位间贸然切换SDA值,就会在SDA上产生一个毛刺,这个毛刺在示波器上的表现就是某些随机从机偶尔不响应。

提示:I2C协议要求在SCL高电平期间SDA必须保持稳定,只有在SCL低电平时SDA才能变。凡是把SDA翻转放在scl高电平相位的代码,统统算时序违规。

4.5 仿真验证:先过仿真再上板,能救你三个通宵

代码写完先做行为仿真几乎是必须的。你需要做两件事:第一,为EEPROM建一个仿真模型(网上有常见的EEPROM模型可直接挂到Testbench里);第二,用Testbench模拟从机的ACK回馈,检查主机状态机在每个ACK/NAK下的跳转是否符合预期。

我自己写Testbench时习惯加一段总线违规检查:始终监视SDA,一旦发现在SCL高电平期间SDA发生变化(且不是起始/停止条件),就报一个warning。这个方法抓时序毛刺很好用,比上板后用逻辑分析仪盲查快得多。

5. 实际联调中的一串坑:从波形可见到不可见的问题实录

5.1 用逻辑分析仪抓波形:采样率、触发与数据格式

FPGA调试I2C时,逻辑分析仪是不可替代的伙伴。我常用的方案有两种:手边没有逻辑分析仪,用FPGA内部的ILA(Integrated Logic Analyzer,比如Vivado的debug core)抓内部信号;有外部设备时,用带协议解析的逻辑分析仪直接挂在SDA/SCL引脚上。

内部ILA抓波形的优势是不用接外部探头,任何版本的FPGA开发环境都带,适合快速看"主机逻辑是否按预期输出"。但它的致命缺陷是只能看到FPGA引脚侧的逻辑值,引脚外的物理层信号质量——比如上升沿缓、噪声毛刺——是完全看不到的。

外接逻辑分析仪抓波形的优势是能看到真实的引脚电平。但注意:I2C波形上的毛刺可能在抓取的协议解析结果里被识别为一个额外的开始位或者无意义的字节。我踩过的坑正是:逻辑分析仪把长线上的一个反射毛刺解析成了有效的起始条件,导致协议解析窗口错位,看起来像主机发了错误的地址。

刚开始调试时建议采样率尽量高,至少SCL频率的20倍以上。200kHz的I2C总线,用5MS/s以上的采样率抓,才能看清楚边沿附近的毛刺细节。抓完波形后,把触发条件设为"SCL上升沿"或"SDA下降沿",先吃透一波波形,再让逻辑分析仪自动解析,切勿一开始就依赖协议解析功能。

5.2 波形"看起来正常"但功能异常:余量之坑

这是我写这篇文章时最想强调的:很多I2C问题,用肉眼看波形是看不出来的。

之前在某图像处理Demo里,FPGA读取温度传感器,功能上偶尔失败,但逻辑分析仪抓出来的波形始终是"起始、地址、ACK、数据……"一个字节都不少。后来我把波形放大到每个bit去逐个量,才发现在数据字节的第3位上升沿,SCL的高电平时间只有大约180ns,比传感器要求的400ns下限差了一倍多。

为什么波形看起来正常?因为逻辑分析仪的显示精度有限,200ns和400ns的差别在压缩视图里就是同一个像素点。这种"隐形问题"只能通过精确测量边沿间隔才能发现。建议你将逻辑分析仪的时间标尺放到每个bit,逐一量SCL高电平和低电平持续时间、SDA在SCL高电平期间的稳定时间,并与datasheet逐一比对。

5.3 表格式问题排查清单

把过去几年遇到的高频问题整理成一个速查表,你联调时可以先对着查:

现象直接原因排查思路
所有器件都无ACKSCL/SDA线接反,或上拉电阻缺失,或从机地址错示波器确认SCL有翻转,测SDA静态电平;核对器件手册的从机地址位
第一个字节正常,后续数据错位起始后未留tBUF;状态机在SCL低电平期间数据提前变化加大字节间空闲;检查数据翻转时刻是否只在SCL低电平
偶发多读一个字节最后一字节回了ACK而非NACK修改ACK逻辑:最后一个字节前提前回NACK
读数据全是0xFF从机未驱动SDA,SDA悬空被上拉拉高确认你发的寄存器地址正确;确认从机是否处于就绪状态;检查时钟拉伸是否处理
写数据后回读不一致页写跨越页边界,导致地址回绕检查写入地址+数据长度是否超出页大小;按页边界拆分写入
外部逻辑分析仪解析错乱毛刺被解析为起始条件提高采样率,确认信号质量;在总线上加小电容限制上升沿毛刺

上面这张表里的问题,我几乎全部亲身踩过。多数情况下,I2C协议本身没什么复杂的,难就难在把你的设计跟物理世界对齐——时钟余量、器件差异、信号完整性,哪个都能让你调试到深夜。

6. 从"主机"到"总线管理者":进阶方向与个人建议

6.1 动态速率切换与多从设备管理

项目稍大一点,总线上就开始挂多个从机了。有些从机只支持100kHz,有些能跑400kHz,还有像某些新器件,1MHz也能跑。这是总线仲裁之外另一层麻烦——总线上所有从机必须工作在同一速率,所以你要么全局降到最慢的器件速率,要么给不同器件的访问过程配置不同速率。

我采用过一套很土但很有效的方案:整条总线的工作频率取所有在线从机的最低上限,但是总线上所有时序参数都用可配置寄存器组存起来。每次上电扫描一遍总线,根据配置表选择工作频率和时序参数,再把对应值写入FPGA内部寄存器。这套逻辑不复杂,但能让同一份代码适配不同批次的板卡,省去了频繁改parameter重新综合的麻烦。

6.2 错误处理与总线恢复机制

任何物理总线都会出错,I2C尤其如此。总线上只要有一个器件异常拉低SDA,整个总线就会"挂死"——所有通信用不了,因为没有任何器件能把SDA拉回去。这时候需要一种"假通信"来解锁:主机连续产生9个SCL时钟脉冲,让异常的从机把未完成的数据位发完,回到正常状态。

在FPGA主机设计中,建议专门加一个timer:如果在一次完整传输内超过某个时间没有收到ACK或者根本没有SCL反馈,就自动进入总线恢复流程,连续拉9个SCL时钟后尝试重新起始。这个方法在工业场景里很实用,能很大程度上提高系统的自恢复能力。

6.3 我踩过最深的一个坑,也是最想提醒你的一段话

我最想提醒你的不是某个时序参数,而是一个工程习惯:把模块级仿真验证做扎实,再上板。I2C的时序问题很容易让人误以为是板卡问题、时钟问题或者芯片问题,最后兜兜转转又绕回RTL代码。但是你在调试时如果有一个足够真实的仿真环境,把从机的ACK、NACK、时钟拉伸都模拟到位,很多问题在仿真阶段就会暴露,根本到不了上板环节。

我见过不少工程师(包括我自己早期也是这样)一上来就直接烧板卡,挂着逻辑分析仪看波形,抓到一个波形不对就改代码、重新综合、重新烧写,一个下午就这么循环十几个来回。效率极低。正确路线是:先写完整Testbench -> 行为仿真通过 -> 加时序约束 -> 综合后仿真(可选) -> 上板实测。这个流程看起来多了一步仿真,实际能省下好几个通宵。

7. 收尾:把RTL与示波器之间那条路打通

这篇主要聊了从单主机到多主机场景的拓展、上拉电阻的选择逻辑、EEPROM读写实战以及调试时最常见的坑。如果你把文章里提到的点都消化了,你的I2C设计能力应该已经从"能写出发送模块"上升到了"能稳定跑在真实总线上"的水平。

最后再分享一个小技巧:在定义好所有状态和时序参数之后,先在Testbench中用最慢的时钟把所有状态跳转打印出来,逐个人工核对。我当时就在仿真波形里发现了一个ACK状态保持周期不够长的问题——代码里写的跳转条件在某个极端bit组合下会提前一个时钟周期跳出,导致下一字节的起始条件还没准备好,SDA电平就提前变化。这个问题如果不通过仿真里的逐个bit检查,上板后抓破头也未必能定位到。

I2C不难,难的是"稳"。稳下来靠的是对时序参数逐一较真、对仿真环境的完整建模、对波形细节的逐位放大检查。把这些做到位以后,再回去看上一篇那些"基础模块",你会有完全不一样的感受——那不只是几段能跑通的代码,而是每一段都能在任何一条总线上扛住活的工程代码。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询