简介:这份资源提供I2C从机端的Verilog完整实现,面向FPGA开发者、数字IC设计初学者及需要为微控制器挂载外设的工程师,用于解决从机地址识别、时钟同步、数据收发与应答等协议细节的落地问题。压缩包共50个文件,约1.2MB,以17个Verilog源码文件为核心,辅以仿真测试平台、综合脚本、约束文件及说明文档,并包含少量C语言、批处理与PDF资料,覆盖RTL设计、验证、综合到板级下载的完整流程。资源中已包含状态机、边沿检测、移位寄存器与地址解码等模块,读者可据此理解I2C从机在SCL/SDA上的时序控制逻辑,并直接移植或修改以适配自身寄存器映射与总线速率需求。目前已有3364人学习下载,适合作为FPGA通信接口设计的参考工程。
1. 从机端 I2C 为什么总在第七个时钟翻车
做 FPGA 或 ASIC 的同行,几乎都写过 i2c slave 端 verilog 代码,但真正一次跑通、还能稳定挂到主控总线上的,比例并不高。最常见的场景是:主控发来地址帧,仿真波形看着像那么回事,可第七个 SCL 上升沿一过,从机要么没拉低 SDA 应答,要么把总线拽死,主控直接报 NACK 超时。问题往往不在“会不会写状态机”,而在对 I2C 电气时序和从机侧采样窗口的理解偏差。
I2C 从机端 Verilog 代码要解决的核心诉求其实就三件事:正确识别起始/停止条件、在正确的时钟沿采样并驱动 SDA、在需要时把 SCL 拉低做时钟拉伸。它适合两类人:一类是要把传感器、EEPROM 或自定义寄存器组挂到主控总线上的 FPGA 工程师;另一类是做 SoC 外设、需要把 I2C slave 集成进 APB 或 AXI 寄存器接口的 IC 设计者。下面这套结构,是我在多个模拟项目和某图像处理 Demo 的配置通道里反复用过的,能直接复现。
2. 从机端状态机与时序窗口怎么定
2.1 先分清采样沿和驱动沿
I2C 是开漏总线,主机和从机都只能拉低,不能主动拉高。这意味着从机端 Verilog 里所有 SDA 输出都必须写成三态或开漏形式,常见做法是用一个sda_oe使能加一个sda_out数据,顶层再用assign sda = sda_oe ? 1'b0 : 1'bz;。很多新手直接写assign sda = sda_out;,仿真里因为主机模型会拉高,看着没事,一上板就总线锁死,这是血泪经验里排第一的坑。
采样沿的选择取决于你站在从机视角。标准模式和快速模式下,数据在 SCL 低电平期间变化,在 SCL 高电平期间有效。从机要在 SCL 上升沿采样 SDA,在 SCL 下降沿之后驱动 SDA。所以状态机通常用 SCL 的上升沿和下降沿分别触发,或者用一个远高于 SCL 的系统时钟过采样。我一般会用一个 4 倍于 SCL 最高频率的clk做同步,再用边沿检测抓 SCL 的跳变,这样比直接拿 SCL 当时钟更稳,综合后也不容易出亚稳态。
2.2 状态划分与位计数
一个够用的 slave 状态机至少要有:IDLE、ADDR、ACK1、DATA_RX、ACK2、DATA_TX、ACK3、STOP。位计数用 3 位或 4 位寄存器,0 到 7 计 8 个数据位,第 9 个时钟周期专门处理 ACK。地址匹配时要注意,7 位地址加读写位共 8 位,读是 1、写是 0,别把读写位当成地址的一部分去比较。
// 7-bit address slave, 4x oversampling clock localparam IDLE = 3'd0, ADDR = 3'd1, ACK1 = 3'd2, RX = 3'd3, ACK2 = 3'd4, TX = 3'd5, ACK3 = 3'd6, STOP = 3'd7; reg [2:0] state, next_state; reg [3:0] bit_cnt; // 0..8, 8 means ACK phase reg [7:0] shift_reg; // address or data shift register reg sda_oe; // 1 = drive low, 0 = release reg sda_out; reg scl_stretch; // pull SCL low when set // edge detect on SCL reg scl_d1, scl_d2; wire scl_rise = (scl_d1 & ~scl_d2); wire scl_fall = (~scl_d1 & scl_d2); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin scl_d1 <= 1'b1; scl_d2 <= 1'b1; end else begin scl_d1 <= scl; scl_d2 <= scl_d1; end end这段代码的关键点是scl_rise和scl_fall两个单周期脉冲,它们把异步的 SCL 边沿同步到系统时钟域。bit_cnt到 8 时进入 ACK 阶段,此时从机根据是读还是写决定拉低 SDA 还是释放。参数上,clk频率建议至少是 SCL 的 4 倍,快速模式 400kHz 下用 2MHz 以上;如果做快速模式加 1MHz,系统时钟最好 8MHz 起步,否则过采样点太粗,容易采到 SDA 跳变沿。
2.3 起始与停止条件的判定
起始条件是 SCL 高时 SDA 由高变低,停止条件是 SCL 高时 SDA 由低变高。从机端不能只靠 SCL 边沿,必须同时看 SDA。常见做法是在 IDLE 状态持续监测sda_d1 & ~sda_d2且scl为高,一旦命中就跳到 ADDR 并清位计数。停止条件则在任意状态检测~sda_d1 & sda_d2且scl为高,命中后回 IDLE 并释放 SDA。
// start/stop detect, sda_d1/d2 are synchronized SDA wire sda_rise = (sda_d1 & ~sda_d2); wire sda_fall = (~sda_d1 & sda_d2); wire start_cond = scl_d1 & sda_fall; wire stop_cond = scl_d1 & sda_rise; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; bit_cnt <= 4'd0; end else begin case (state) IDLE: if (start_cond) begin state <= ADDR; bit_cnt <= 4'd0; end // ... other states default: state <= IDLE; endcase if (stop_cond) state <= IDLE; end end这里有个容易忽略的点:sda_d1和sda_d2必须和scl_d1、scl_d2用同一级同步,否则起始条件判定会出现一个时钟周期的错位,仿真里可能刚好蒙对,上板就随机丢帧。我一般会把 SDA 和 SCL 各打两拍,再统一做边沿检测。
3. 把代码跑起来:仿真、综合与上板验证
3.1 写一个够用的 testbench 主机模型
验证 slave 端最省事的办法是自己写一个行为级主机模型,按 I2C 时序产生起始、地址、数据和停止。不要用现成的 VIP,太重,改起来还麻烦。下面这个任务式主机模型可以直接嵌进 testbench。
// simple I2C master model, 100kHz with 10ns time unit task i2c_start; begin sda = 1'b1; scl = 1'b1; #5000; sda = 1'b0; #5000; scl = 1'b0; #5000; end endtask task i2c_write_byte(input [7:0] data); integer i; begin for (i = 7; i >= 0; i = i - 1) begin sda = data[i]; #5000; scl = 1'b1; #5000; scl = 1'b0; #5000; end // ACK phase: release SDA, sample sda = 1'bz; #5000; scl = 1'b1; #5000; ack = ~sda; // slave should pull low scl = 1'b0; #5000; end endtask task i2c_stop; begin sda = 1'b0; #5000; scl = 1'b1; #5000; sda = 1'b1; #5000; end endtask这个模型里#5000对应 10ns 时间单位的 50us,也就是 100kHz 的半个周期。ack = ~sda这一句是采样从机是否拉低,如果从机没拉低,ack就是 0,仿真日志里能直接看到 NACK。参数上,sda和scl都要声明成reg并在 testbench 顶层用pullup或显式上拉,否则1'bz会变成 x。
3.2 综合约束与三态处理
综合时最容易翻车的是三态推断。FPGA 内部一般没有真三态,1'bz会被综合成使能加多路器。所以顶层必须写成assign sda = sda_oe ? 1'b0 : 1'bz;,综合工具会自动推断出 IOBUF。如果你在代码里写assign sda = sda_out;,综合器会直接报多驱动或生成一个永远拉高的输出,上板后主机拉低时从机也在拉高,总线电流直接飙上去。
# XDC constraint example for I2C pins set_property PACKAGE_PIN F14 [get_ports scl] set_property PACKAGE_PIN F15 [get_ports sda] set_property IOSTANDARD LVCMOS33 [get_ports {scl sda}] set_property PULLUP true [get_ports {scl sda}]约束里PULLUP true是必须的,外部虽然通常有 4.7k 上拉,但内部弱上拉能在外部电阻没焊或阻值偏大时兜底。IOSTANDARD按你板子的电平选,1.8V 系统就写 LVCMOS18,别照抄 3.3V。
3.3 上板抓波形的三个观察点
上板后如果通信不稳,优先看三个地方。第一,SCL 上升沿处 SDA 是否已经稳定,如果 SDA 还在跳,说明从机驱动太晚,需要把sda_oe的赋值提前半个过采样周期。第二,ACK 阶段 SDA 被拉低的时间是否覆盖整个第 9 个 SCL 高电平,短了主机会判 NACK。第三,停止条件后 SDA 是否在一个 SCL 周期内释放,如果一直拉着,下一次起始会被误判。
我一般会在 ILA 里同时抓state、bit_cnt、sda_oe和scl_stretch,触发条件设成state == ACK2,这样一眼就能看出 ACK 有没有发出去。如果 ILA 资源紧张,至少抓sda_oe和scl,看拉低窗口对不对。
4. 避坑与常见问题排查
4.1 第七个时钟后主机报 NACK
现象是地址帧发完,主机在 ACK 周期采样到高电平,直接终止传输。原因通常有两个:一是地址比较时把读写位也算进去了,导致匹配失败;二是 ACK 阶段sda_oe没有及时置起,SDA 还是释放状态。解决方法是把地址比较写成shift_reg[7:1] == SLAVE_ADDR,读写位单独用shift_reg[0]判断,同时在bit_cnt == 8且state == ACK1时无条件拉低 SDA 一个 SCL 周期。
4.2 时钟拉伸导致主机超时
现象是主机发完一个字节后 SCL 被从机拉低很久,主机报总线超时。原因是scl_stretch在数据处理没完成时一直置位,而主机没有超时容忍。解决方法是给拉伸加一个最大周期计数,比如 16 个系统时钟后强制释放,或者在寄存器读写路径上做流水,别在 ACK 阶段做长组合逻辑。我一般会把拉伸限制在 1 个 SCL 周期内,超过就说明设计有问题,该优化数据通路而不是硬拉。
4.3 多字节连读时数据错位
现象是读第一个字节对,第二个字节开始整体偏移一位。原因是 TX 状态下shift_reg的加载时机不对,在 ACK3 阶段就加载了下一个字节,导致 SDA 在 ACK 周期被提前驱动。正确做法是在 ACK3 的 SCL 下降沿之后才加载shift_reg,并且加载时sda_oe保持释放,等下一个 SCL 低电平再驱动第一位。
4.4 仿真通过但上板随机丢帧
现象是仿真波形完美,上板跑几分钟就丢一帧。原因多半是 SDA 和 SCL 的同步级数不一致,或者没有做输入滤波。解决方法是给 SDA 和 SCL 各加两级同步寄存器,再在同步后加一个简单的数字滤波,比如连续 3 个采样点相同才认。参数上,滤波深度别超过过采样率的四分之一,否则会吃掉建立时间。
4.5 总线锁死无法恢复
现象是某次异常后 SDA 一直被拉低,主机发什么都不响应。原因是从机状态机卡在某个中间态,sda_oe一直是 1。解决方法是在状态机里加一个超时计数器,任何非 IDLE 状态持续超过 9 个 SCL 周期就强制回 IDLE 并释放 SDA。这个后悔药我每个项目都会加,成本几乎为零,但能省掉现场断电重启的麻烦。
5. 进阶:把 slave 挂到寄存器接口并做批量验证
5.1 从字节流到寄存器映射
实际项目里,slave 不会只收发裸字节,通常要映射成寄存器。常见做法是在RX状态收到第一个字节作为寄存器地址,第二个字节开始作为数据写入;读方向则在TX状态先发地址对应的数据,地址自增。下面是一个简化的寄存器写路径。
// register file write, addr auto-increment reg [7:0] reg_file [0:15]; reg [3:0] reg_addr; reg first_byte; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin first_byte <= 1'b1; reg_addr <= 4'd0; end else if (state == RX && bit_cnt == 4'd8 && scl_rise) begin if (first_byte) begin reg_addr <= shift_reg[3:0]; first_byte <= 1'b0; end else begin reg_file[reg_addr] <= shift_reg; reg_addr <= reg_addr + 1'b1; end end else if (stop_cond) begin first_byte <= 1'b1; end end这里first_byte标志用来区分地址字节和数据字节,stop_cond时复位,保证下一次传输重新从地址开始。reg_addr自增让你可以连续写多个寄存器,主机不用每次重发地址。参数上,reg_file深度按实际寄存器数量定,综合时会映射成分布式 RAM 或块 RAM,16 个以内用 LUTRAM 就够。
5.2 用脚本做批量回归
单靠手写 testbench 跑几个用例不够,我一般会用一个 Python 脚本生成随机读写序列,再和 Verilog testbench 通过文件交互。脚本负责生成激励和期望值,testbench 负责驱动和比对,跑 1000 次随机传输,覆盖地址边界、连续读写、异常停止等场景。
# gen_stimulus.py: generate random I2C transactions import random with open("stim.txt", "w") as f: for _ in range(1000): addr = random.randint(0, 15) data = [random.randint(0, 255) for _ in range(random.randint(1, 4))] f.write(f"W {addr:02x} " + " ".join(f"{d:02x}" for d in data) + "\n") f.write(f"R {addr:02x} {len(data)}\n")这个脚本生成写和读两种事务,写事务带 1 到 4 个数据字节,读事务指定读回长度。testbench 里用$fscanf逐行读入,驱动主机模型,再把读回数据和期望值比对。跑完 1000 次如果零错误,基本可以认为 slave 的协议层是稳的。参数上,随机种子固定下来方便复现,别每次跑都换种子,否则出了问题没法回溯。
5.3 一个我常犯的错
早期我总想把所有逻辑塞进一个 always 块,觉得这样“紧凑”。结果状态机、位计数、移位寄存器互相耦合,改一个地方就崩三个用例。后来强制自己拆成三个块:一个纯组合的 next_state 逻辑,一个时序的状态和计数更新,一个独立的移位和寄存器路径。拆完之后,仿真调试时间至少省了一半。如果你现在手里的 slave 代码超过 200 行还只有一个 always,建议先拆再改,别硬调。希望帮到你。
本文还有配套的精品资源,点击获取