1. 项目概述:为什么I2C透传在FPGA里非得用三态门不可?
我第一次在Xilinx Zynq-7000板子上跑I2C从设备时,直接把SCL/SDA信号连到普通IO口,结果主机一发START信号,总线就卡死——示波器上看到SDA被拉低后死活抬不起来。折腾三天才发现,问题根本不在协议解析逻辑,而在物理层驱动方式错了。I2C不是普通双向总线,它本质是开漏(Open-Drain)结构,必须靠外部上拉电阻提供高电平,而器件只能做“拉低”或“释放”两种动作。FPGA普通IO口默认是推挽输出,一旦输出高电平,就会和上拉电阻形成直流通路,轻则电流超标,重则烧毁IO单元。这正是三态门(Tri-state Buffer)存在的根本意义:它不是简单地“输出0/1”,而是提供第三种状态——高阻态(Z),相当于物理上断开连接,让上拉电阻自然把线拉高。
这个项目标题里的“信号透传”,说白了就是不做协议解析、不改数据内容,纯粹当一根可配置的“电子跳线”。比如你有一块带温湿度传感器的I2C子板,想把它接到主控FPGA上,但主控本身已有I2C控制器,又不想改固件;或者多个FPGA之间需要共享同一组I2C设备,靠软件仲裁太慢,就得靠硬件级透传。这时候三态门就是唯一解法——它让FPGA既能监听总线(输入模式),又能主动干预(输出低电平),还能完全隐身(高阻态),三个状态无缝切换,完美复现开漏器件行为。网上搜“Vivado I2C”出来的90%教程都在讲怎么用AXI IIC IP核做主从机,但真正做系统集成时,往往需要的是这种底层透传能力。本项目不依赖任何IP核,纯Verilog实现,代码不到100行,却把三态控制时序、毛刺抑制、电平兼容性这些坑全踩了一遍,附带实测波形图和Vivado 2022.2完整工程结构说明。
2. 核心设计思路与三态门选型逻辑
2.1 为什么不用普通双向IO?——从电气特性讲清楚
很多人以为FPGA的inout端口天然支持三态,只要写inout wire sda;就能搞定。这是个致命误区。Vivado综合器对inout端口的处理极其保守:它会自动插入IOBUF原语,但该原语的使能(OE)信号必须由用户显式控制,且OE的时序约束极难满足。更关键的是,inout端口在综合后会生成额外的布线资源,容易引发时序违例,尤其在高频I2C(400kHz以上)场景下,SDA/SCL边沿抖动可能超过10ns,导致START/STOP条件识别失败。我实测过Zynq-7020在100kHz下用inout端口透传,示波器显示SDA上升沿有明显阶梯状爬升,这是因为IOBUF内部MOS管导通电阻与PCB走线电容形成RC延迟,而三态门通过外部明确控制OE信号,能把驱动强度和释放时机捏在自己手里。
真正的三态门实现,核心是分离“数据方向”和“数据内容”两个维度。以SDA为例:
- 当FPGA作为监听者时:OE=0(高阻),SDA_IN采样外部电平
- 当FPGA需响应从机地址时:OE=1,DATA_OUT=0(主动拉低)
- 当FPGA不干预时:OE=1,DATA_OUT=1(但此时因开漏特性,实际电平由上拉电阻决定)
这个逻辑不能靠单个inout变量完成,必须拆成三根独立信号:sda_in(输入)、sda_out(输出数据)、sda_oe(输出使能)。Vivado中对应原语是OBUFT(Output Buffer with Tri-state),它比IOBUF更底层、更可控,且支持精确的时序约束。
2.2 Vivado中三态门的三种实现路径对比
| 实现方式 | 原语调用 | 时序可控性 | 资源占用 | 适用场景 | 我的实测结论 |
|---|---|---|---|---|---|
| 手动例化OBUFT | OBUFT #(.IOSTANDARD("LVCMOS33")) uut_sda (.I(sda_out), .T(sda_oe), .O(sda_pin)); | ★★★★★(可加OFFSET约束) | 最小(1个LUT+1个IO) | 高频/严时序 | 首选,本项目采用 |
| 使用inout端口+assign | assign sda_pin = (sda_oe) ? sda_out : 1'bz; | ★★☆☆☆(综合器自动插入IOBUF,OE时序难控) | 中等(额外布线延迟) | 低速调试 | 易出毛刺,放弃 |
| AXI IIC IP核透传模式 | 配置IP为“Passthrough” | ★★★☆☆(IP内部状态机不可见) | 大(约200 LUT) | 快速验证 | 功能完整但黑盒,无法debug物理层 |
特别注意:OBUFT原语必须配合正确的IO标准设置。I2C总线电压常见3.3V/1.8V,若FPGA Bank电压设为3.3V但误配IOSTANDARD为"LVCMOS18",会导致驱动能力不足,SDA上升沿拖沓。我在黑金AX7010板子上吃过亏——SDA上升时间长达1.2μs(标准要求≤1μs),最后发现是Vivado里Bank 34的IOSTANDARD没手动改成"LVCMOS33",而是用了默认值。
2.3 透传逻辑的边界条件设计
单纯透传会引发总线冲突。比如主机发START后,从机还没响应,FPGA却因逻辑错误提前拉低SDA,就会造成总线锁死。因此必须加入“总线仲裁”机制:
- 检测START条件:SCL高时SDA由高变低
- 检测STOP条件:SCL高时SDA由低变高
- 在START到STOP期间,FPGA只允许被动监听,禁止主动输出
- 仅当检测到特定地址(如0x50)且自身配置为“透传使能”时,才允许OE=1
这个状态机用Moore型实现最稳妥,避免组合逻辑毛刺。我设计了4个状态:IDLE(空闲)、START_DET(检测到START)、ADDR_MATCH(地址匹配)、BUSY(透传中)。其中ADDR_MATCH状态持续时间严格限定为9个SCL周期(8位地址+1位R/W),超时自动退回IDLE,防止因时钟偏差导致状态机卡死。
3. 核心代码解析与Vivado工程配置细节
3.1 顶层模块与三态门实例化
// i2c_passthrough.v module i2c_passthrough #( parameter CLK_FREQ_MHZ = 100, // 系统时钟频率 parameter I2C_FREQ_KHZ = 100 // 目标I2C频率 )( input wire clk, // 100MHz系统时钟 input wire rst_n, // 低电平复位 input wire en, // 透传使能信号(来自按键或寄存器) input wire [6:0] target_addr, // 目标从机地址(7位) // FPGA侧I2C接口(接主控CPU) inout wire sda_main, // 主控侧SDA inout wire scl_main, // 主控侧SCL // 设备侧I2C接口(接传感器等) inout wire sda_dev, // 设备侧SDA inout wire scl_dev // 设备侧SCL ); // 内部信号声明 wire sda_main_in, sda_dev_in; wire sda_main_out, sda_dev_out; wire sda_main_oe, sda_dev_oe; wire scl_main_out, scl_dev_out; wire scl_main_oe, scl_dev_oe; // 三态门实例化(关键!) OBUFT #(.IOSTANDARD("LVCMOS33")) uut_sda_main ( .I(sda_main_out), .T(sda_main_oe), .O(sda_main) ); OBUFT #(.IOSTANDARD("LVCMOS33")) uut_sda_dev ( .I(sda_dev_out), .T(sda_dev_oe), .O(sda_dev) ); OBUFT #(.IOSTANDARD("LVCMOS33")) uut_scl_main ( .I(scl_main_out), .T(scl_main_oe), .O(scl_main) ); OBUFT #(.IOSTANDARD("LVCMOS33")) uut_scl_dev ( .I(scl_dev_out), .T(scl_dev_oe), .O(scl_dev) ); // 输入缓冲(防止高阻态采样噪声) IBUF uut_sda_main_ibuf (.I(sda_main), .O(sda_main_in)); IBUF uut_sda_dev_ibuf (.I(sda_dev), .O(sda_dev_in)); IBUF uut_scl_main_ibuf (.I(scl_main), .O(scl_main_in)); IBUF uut_scl_dev_ibuf (.I(scl_dev), .O(scl_dev_in)); // 核心透传逻辑(简化版,完整版见附件) i2c_passthrough_core #( .CLK_FREQ_MHZ(CLK_FREQ_MHZ), .I2C_FREQ_KHZ(I2C_FREQ_KHZ) ) uut_core ( .clk(clk), .rst_n(rst_n), .en(en), .target_addr(target_addr), .sda_main_in(sda_main_in), .sda_dev_in(sda_dev_in), .scl_main_in(scl_main_in), .scl_dev_in(scl_dev_in), .sda_main_out(sda_main_out), .sda_dev_out(sda_dev_out), .scl_main_out(scl_main_out), .scl_dev_out(scl_dev_out), .sda_main_oe(sda_main_oe), .sda_dev_oe(sda_dev_oe), .scl_main_oe(scl_main_oe), .scl_dev_oe(scl_dev_oe) ); endmodule提示:OBUFT原语必须在.xdc约束文件中显式声明IO标准,否则Vivado默认用Bank电压对应的电平标准,极易出错。正确写法:
set_property IOSTANDARD LVCMOS33 [get_ports {sda_main}] set_property IOSTANDARD LVCMOS33 [get_ports {sda_dev}] set_property IOSTANDARD LVCMOS33 [get_ports {scl_main}] set_property IOSTANDARD LVCMOS33 [get_ports {scl_dev}]
3.2 透传核心状态机详解
// i2c_passthrough_core.v(关键片段) always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; sda_main_out <= 1'b1; sda_dev_out <= 1'b1; scl_main_out <= 1'b1; scl_dev_out <= 1'b1; sda_main_oe <= 1'b0; sda_dev_oe <= 1'b0; scl_main_oe <= 1'b0; scl_dev_oe <= 1'b0; addr_cnt <= 0; end else begin case (state) IDLE: begin // 检测START:SCL高时SDA下降沿 if (scl_main_in && !sda_main_in && sda_main_in_dly) begin state <= START_DET; sda_main_oe <= 1'b0; // 立即进入高阻监听 sda_dev_oe <= 1'b0; end else begin // 透传模式:主控侧SDA/SCL直接镜像到设备侧 sda_dev_out <= sda_main_in; sda_dev_oe <= 1'b0; // 设备侧只监听 scl_dev_out <= scl_main_in; scl_dev_oe <= 1'b0; end end START_DET: begin // 采样8位地址+1位R/W,共9个SCL周期 if (scl_main_in && !scl_main_in_dly) begin // SCL上升沿采样 if (addr_cnt < 9) begin addr_reg[addr_cnt] <= sda_main_in; addr_cnt <= addr_cnt + 1; end else begin // 地址比对(7位地址左移1位,第0位为R/W) if ({addr_reg[8:1], 1'b0} == target_addr) begin state <= ADDR_MATCH; sda_dev_oe <= 1'b1; // 允许设备侧响应 end else begin state <= IDLE; end end end end ADDR_MATCH: begin // 透传数据:设备侧SDA/SCL完全镜像主控侧 sda_dev_out <= sda_main_in; sda_dev_oe <= 1'b0; // 注意:此处仍为高阻,靠上拉电阻抬高 scl_dev_out <= scl_main_in; scl_dev_oe <= 1'b0; // 检测STOP退出 if (scl_main_in && sda_main_in && !sda_main_in_dly) begin state <= IDLE; end end endcase end end这里有个反直觉的设计点:ADDR_MATCH状态下sda_dev_oe仍为0(高阻),而不是1。因为I2C透传的本质是“让设备自己说话”,FPGA只负责把主控的SDA/SCL波形无损传递过去,设备收到地址后会自行拉低SDA应答。如果FPGA在此时强行OE=1并输出0,就会和设备拉低冲突,造成大电流。真正的“透传”是物理层的透明桥接,不是逻辑层的主动干预。
3.3 Vivado工程关键配置步骤
创建工程时选择“RTL Project”而非“IP Catalog”
很多人直接用IP Catalog添加IIC核,但透传需要底层控制。新建工程时务必勾选“Do not specify sources at this time”,后续再添加Verilog文件。添加约束文件(.xdc)的强制操作
- 右键Sources窗口 → “Add Sources” → “Add Constraints”
- 手动编写IO位置约束(以黑金AX7010为例):
set_property PACKAGE_PIN U18 [get_ports sda_main] set_property PACKAGE_PIN V18 [get_ports scl_main] set_property PACKAGE_PIN T17 [get_ports sda_dev] set_property PACKAGE_PIN U17 [get_ports scl_dev] - 必须添加时钟约束(否则综合时序报告全是红色):
create_clock -name sys_clk -period 10.000 -waveform {0 5} [get_ports clk]
Implementation阶段的关键设置
- 在“Settings” → “Synthesis”中,将“Flatten Hierarchy”设为“none”,保留模块层级便于debug
- 在“Settings” → “Implementation” → “Strategy”中,选择“Performance_ExplorePostRoutePhysOpt”,此策略对三态门布线优化效果最好
- 最关键的一步:右键“Generate Bitstream” → “Edit Device Properties” → 勾选“Allow I/O to be placed in any I/O bank”,否则OBUFT可能因Bank电压限制无法布局
Debug Hub配置(必做!否则ILA抓不到信号)
- 在“Flow Navigator” → “Program and Debug” → “Open Hardware Manager”
- 点击“Open Target”前,先执行“Run Hardware Server”确保JTAG链路正常
- 添加ILA核时,必须将sda_main、sda_dev、scl_main、scl_dev全部设为“Input”类型,因为它们是inout端口,ILA只能采样输入缓冲后的信号(即sda_main_in等)
4. 实操过程与波形验证全流程
4.1 硬件连接与上拉电阻计算
I2C总线质量90%取决于上拉电阻。公式:R_pullup_min = Vcc / I_sink_max,R_pullup_max = tr / (0.8473 * C_bus)
- 黑金AX7010的IO驱动能力:I_sink_max = 12mA(LVCMOS33)
- 实测PCB走线电容C_bus ≈ 100pF(含传感器引脚电容)
- 计算得:R_min = 3.3V / 0.012A ≈ 275Ω,R_max = 1000ns / (0.8473 * 100pF) ≈ 11.8kΩ
我最终选用4.7kΩ贴片电阻(0805封装),实测效果最佳:
- 上升时间tr = 0.8473 * 4.7kΩ * 100pF ≈ 400ns(满足100kHz要求)
- 下拉电流I = 3.3V / 4.7kΩ ≈ 0.7mA(远低于IO最大灌电流)
注意:不要用面包板跳线做I2C!我第一次测试用杜邦线连接,C_bus飙升至300pF,即使换1kΩ电阻,上升时间也达1.2μs,导致主机反复重发。改用PCB硬连接后问题消失。
4.2 Vivado中波形抓取实录
ILA核配置要点
- 采样深度设为4096(最小值,节省BRAM资源)
- 时钟域必须选
clk(不能选scl_main,否则触发不稳定) - 触发条件设为“sda_main_in == 0 && scl_main_in == 1”,捕获START信号
- 添加信号:
sda_main_in,sda_dev_in,scl_main_in,scl_dev_in,state,addr_cnt
典型波形分析
抓取到的波形显示:- 主控发出START后,
sda_main_in在SCL高电平期间由1→0,state从IDLE→START_DET - 接着9个SCL周期内,
addr_reg逐位锁存SDA数据,第9周期比对成功,state→ADDR_MATCH - 此时
sdad_dev_in波形与sdad_main_in完全重合,误差<2ns(示波器测量) - STOP信号出现时,
sda_main_in在SCL高电平期间由0→1,state→IDLE
- 主控发出START后,
毛刺抑制实战技巧
初始版本在START检测时出现误触发,原因是SDA线上存在电源噪声。解决方案:- 在IBUF后增加两级同步器(打两拍):
reg sda_main_sync1, sda_main_sync2; always @(posedge clk) begin sda_main_sync1 <= sda_main_in; sda_main_sync2 <= sda_main_sync1; end assign sda_main_in_dly = sda_main_sync2; - 将START检测条件改为:
scl_main_in && !sda_main_in_dly && sda_main_in_dly_prev(用延迟信号做边沿检测)
这样可滤除<20ns的毛刺,实测抗干扰能力提升3倍。
- 在IBUF后增加两级同步器(打两拍):
4.3 完整工程目录结构说明
i2c_passthrough_project/ ├── src/ # 源码目录 │ ├── i2c_passthrough.v # 顶层模块 │ ├── i2c_passthrough_core.v # 核心逻辑 │ └── i2c_passthrough_tb.v # 测试平台(含I2C协议模拟器) ├── constraints/ # 约束文件 │ └── ax7010.xdc # 黑金AX7010板卡约束 ├── simulation/ # 仿真文件 │ └── i2c_wave.do # ModelSim波形脚本 ├── docs/ # 文档 │ └── i2c_timing.pdf # I2C时序参数表(含Vivado实测数据) └── vivado/ # Vivado工程文件(自动生成)特别提醒:.xdc文件必须放在constraints/目录下,且在Vivado中右键该文件 → “Set as Constrains File”,否则约束不生效。我曾因忘记这步,导致综合后IO位置乱飞,浪费2小时排查。
5. 常见问题与独家避坑指南
5.1 Vivado报错“Cannot place BUFG”怎么办?
这是新手最高频报错。根本原因是:你把clk信号直接连到了OBUFT的.I端口,而OBUFT必须用全局时钟网络驱动。解决方案:
- 在顶层模块中,用
BUFG原语缓冲时钟:BUFG uut_clk_buf (.I(clk), .O(clk_buf)); // 后续所有逻辑用clk_buf,而非原始clk - 或者更简单:在Vivado中右键
clk端口 → “Create Clock”,自动生成BUFG约束
实测教训:某次我把
clk_buf误命名为clk_out,Vivado自动将其识别为输出时钟,导致布局布线失败。命名必须含clk关键字。
5.2 I2C通信失败的5种物理层原因排查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| SDA始终为高电平 | 上拉电阻开路或阻值过大 | 万用表测SDA对地电阻 | 更换4.7kΩ电阻,确认焊接无虚焊 |
| SDA无法拉低 | FPGA IO配置错误或OE未使能 | 示波器测OBUFT输出引脚 | 检查OBUFT.T信号是否为0,确认set_property IOSTANDARD已设置 |
| SCL波形畸变 | SCL线上并联电容过大 | 断开设备侧,单独测主控侧SCL | 移除多余走线,缩短SCL长度<10cm |
| START识别失败 | 时钟域不同步导致采样错误 | ILA抓取scl_main_in和sda_main_in | 加两级同步器,用scl_main_in_dly做边沿检测 |
| 总线锁死 | 多个设备同时拉低SDA | 用逻辑分析仪看SDA电平 | 检查是否有其他I2C设备未断电,或FPGA OE信号时序错误 |
5.3 三态门时序违例的终极解决法
当Vivado Implementation报告出现“Timing Summary”中WNS (Worst Negative Slack)为负值时,说明三态门OE信号到达时间晚于数据信号。我的压箱底技巧:
- 在
OBUFT.T(OE信号)路径上插入一级寄存器:reg sda_main_oe_reg; always @(posedge clk) sda_main_oe_reg <= sda_main_oe; OBUFT uut_sda_main (.I(sda_main_out), .T(sda_main_oe_reg), .O(sda_main)); - 在.xdc中添加输出延迟约束:
set_output_delay -clock clk -max 2.0 [get_ports sda_main] set_output_delay -clock clk -min 0.5 [get_ports sda_main]
这样可强制布线工具优先优化OE路径,实测将WNS从-1.2ns提升至+0.8ns。
5.4 从Xilinx迁移到Intel FPGA的注意事项
虽然本项目基于Xilinx Vivado,但原理通用。迁移到Intel Quartus时需注意:
- Intel对应原语是
ALT_IOBUF_TRI,参数名不同:.oe()对应Vivado的.T,.data()对应.I - Intel不支持
IOSTANDARD直接设为"LVCMOS33",需在Pin Planner中右键引脚 → “I/O Standard” → 选择“3.3-V LVTTL” - Intel的时序约束语法不同:
create_clock -name sys_clk -period 10 [get_ports clk](无-waveform参数)
我用Intel Cyclone IV EP4CE10做过移植,关键改动就3处:原语替换、IO标准修改、约束语法调整,2小时完成。
最后分享个小技巧:每次修改OBUFT相关代码后,务必在Vivado中执行“Report DRC”(Design Rule Check),重点查看“[DRC BUFC-1]”类报错,这类报错直接关联三态门驱动能力,比时序报告更能提前发现硬件隐患。