FPGA实战:I2C从机协议实现与Verilog状态机设计要点
简介这是一套面向FPGA开发者的I2C从机Verilog实现代码定位在一个可仿真、可综合的完整工程。代码对I2C协议中的从机地址识别、时钟线边沿同步、数据字节收发、应答信号生成以及寄存器读写操作都做了模块化处理同时包含状态机控制、边沿检测、移位寄存器等典型数字逻辑方便使用者从底层时序到上层控制完整理解。压缩包共收录50个文件大小约1.2MB以17个Verilog源文件为核心辅以批处理与Makefile等自动构建脚本、Quartus工程的约束文件、C语言辅助参考程序及PDF说明文档可在常见FPGA环境中快速完成仿真与综合。文件按源码、测试、文档等分目录存放结构清晰适合作为学习模板或直接移植到项目中使用。目前已有3359人学习下载尤其适合具备一定Verilog基础、需要设计或调试I2C从设备模块的工程师和在校学生。1. 从机的视角看I2C协议和主机完全是两码事写I2C从机之前先把主机那套思维扔掉。I2C主机代码满大街都是启动条件自己发、时钟自己产生、数据自己控制节奏写起来很“爽”。但从机不一样从机的本质是被动响应——所有时序都是外部主机强加给你的你不知道主机什么时候会发起传输也不知道它这次要读还是写更不知道它会不会中途重启总线。我刚接手这个任务时第一反应是“I2C从机不就是检测起始条件、收地址、回ACK嘛”结果写出来的第一版代码在上板测试时连地址匹配都过不了。后来冷静下来把协议从从机的角度重新捋了一遍才发现问题出在几个非常基础的判断上。从机的核心状态流是这样的总线空闲时SDA和SCL都是高电平。检测到SCL高电平期间SDA出现下降沿这是START条件代表一次传输开始。接下来8个SCL周期从机需要在这8个bit里完成地址比对。如果匹配在第9个时钟周期把SDA拉低回一个ACK。之后根据读写位决定方向写方向就是继续接收数据字节读方向则是从机要在SCL低电平期间把数据放到SDA上主机在第9个周期回ACK或NACK。检测到SCL高电平期间SDA出现上升沿这是STOP条件回到空闲状态。这里有个新手最容易踩的坑地址比对必须在第9个时钟之前完成。我第一版代码用的是串行移位寄存器每收到一个bit就移位一次等8个bit收完后用组合逻辑和配置的从机地址比较然后延迟一拍输出ACK。表面上看没问题但实际综合后从“收到第8个bit”到“ACK输出有效”之间经过的路径太长在400kHz模式下勉强能过上到1MHz就必挂。后来改成边收边比——前7bit先和地址比对最后一个bit到来时直接判断是否全匹配这样第9个周期一开始ACK就已经稳定了。再一个就是起始和停止条件的检测。很多参考代码是用SCL采样SDA的电平变化来做但实际总线上的信号是有毛刺的尤其当主机是MCU用GPIO模拟I2C时边沿质量很差。稳妥的做法是先用两级触发器同步SDA和SCL消除亚稳态然后检测同步后的信号边沿再判断“SCL高电平时SDA是否发生跳变”。只有满足这个条件才算真正的START或STOP否则忽略。我见过一个项目里从机总能收到地址但数据全是乱的查到最后就是START检测没做“SCL高电平”这个限定——SCL低电平时SDA的变化被误判成了START整个状态机被重置了一万次。这个问题在低速主机上不明显因为SCL低电平时间足够长但主机一旦快起来误判概率直线上升。2. 引脚处理与输入同步决定稳定性的第一道关口从机代码能不能稳定跑往往不是状态机逻辑的问题而是引脚输入处理的问题。FPGA内部逻辑跑在几百MHz而I2C总线上是几十到几百kHz的信号跨时钟域是必然的。如果不做同步采样到亚稳态会让状态机随机跳转故障极其难查。我的做法是每个输入引脚都接两级同步触发器代码非常简单reg scl_sync1, scl_sync2; reg sda_sync1, sda_sync2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin scl_sync1 1b1; scl_sync2 1b1; sda_sync1 1b1; sda_sync2 1b1; end else begin scl_sync1 scl_io; scl_sync2 scl_sync1; sda_sync1 sda_io; sda_sync2 sda_sync1; end end同步之后后面所有逻辑都用scl_sync2和sda_sync2不再直接碰引脚。这里要注意的是上拉电阻必须接。FPGA的I/O引脚如果不配置成开漏加上拉SDA根本拉不回来高电平整个总线会一直卡在低电平。我最初在纯仿真里看不到这个问题因为testbench里信号是理想电平但上板后SDA总是低排查了半天才发现是IO配置少了上拉。还有一个容易被忽略的点从机的SDA输出必须是开漏。在FPGA里三态门的写法是assign sda_io sda_out_en ? sda_out : 1bz;sda_out_en拉高时驱动总线拉低时释放总线让上拉电阻把电平拉高。很多初学者会直接写成assign sda_io sda_out;这在仿真里没问题但上板后会和其他设备冲突——主机也在驱动SDA两个push-pull输出对着干轻则通信错误重则烧引脚。输入同步带来的延迟也要心里有数。同步两级触发器大约带来2个系统时钟周期的延迟边沿检测要基于同步后的信号做不能拿原始引脚信号和同步信号混着用。比如START检测必须用sda_sync2的边沿和scl_sync2的电子组合判断否则会出现检测到的START比实际总线事件晚半拍导致地址的bit0被吞掉。3. 核心状态机设计从字节流到寄存器访问状态机主结构并不复杂四个状态足够IDLE、START、ADDRESS、DATA。难的是每个状态里的细节控制。先说ADDRESS状态。从机需要知道自己的地址通常支持7位地址模式就够了。设计时要把地址做成可配置的不要写死在代码里。我一般用一个寄存器存储从机地址复位后赋默认值外部可以通过其他接口修改。这样做的好处是同一套代码可以用在不同I2C地址的设备上调试时也可以临时换个地址避开冲突。地址接收用移位寄存器每来一个SCL上升沿采样一位SDA。第七位是MSB还是LSB取决于你的设计约定I2C协议里是先传MSB这个别搞反了。第8位是读写位1代表读0代表写。收到完整8位后如果地址匹配进入DATA状态并且在第九个SCL周期拉低SDA回ACK如果不匹配直接回IDLE对所有后续数据都不响应直到下一个START。在DATA状态读写两条路径是不同的需要单独设计。写路径相对简单每个SCL上升沿采样一个bit8个bit凑成一个字节后写入目标寄存器然后在第9个周期回ACK。这里有个设计决策是收完一整字节再一起写入寄存器还是边收边写我的经验是收完再写因为I2C的一个字节传输随时可能被STOP或重复START打断边收边写会导致半截数据已经进了寄存器破坏了原有内容。收完校验无误后统一提交配合内部的we信号能确保数据完整性。读路径复杂一点。从机的SDA输出必须只在SCL低电平时变化在SCL高电平时保持稳定。所以发送数据的流程是检测到SCL下降沿后把下一个bit放到SDA上主机在SCL高电平时采样。第一次发送时主机在ACK之后会释放SDA从机要在一个SCL低电平期间把数据bit0准备好。说到读多字节很多场景需要地址自增。比如主机连续读16个寄存器从机每发完一个字节并收到ACK后内部地址指针自动加1下一个字节就从新地址读取。这个逻辑用一个小计数器就能实现但要注意如果主机在读完一个字节后回NACK说明它不想再读了从机要停止发送并回到IDLE等待下一个START不能继续把下一个字节的数据往总线上放。这里放一段核心状态机的骨架代码方便新人理解整体结构localparam IDLE 3d0; localparam START 3d1; localparam ADDRESS 3d2; localparam DATA 3d3; reg [2:0] state, next_state; reg [3:0] bit_cnt; reg [6:0] addr_buf; reg rw_flag; always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end always (*) begin next_state state; case (state) IDLE: if (start_det) next_state START; START: if (scl_fall) next_state ADDRESS; ADDRESS: if (bit_cnt 4d8 scl_fall) begin if (addr_match) next_state DATA; else next_state IDLE; end DATA: if (stop_det) next_state IDLE; else if (scl_fall bit_cnt 4d8) begin // 读模式下若收到主机的NACK则结束 if (rw_flag !ack_from_host) next_state IDLE; end default: next_state IDLE; endcase end代码里用的是“检测到SCL下降沿”来切换状态而不是用时钟沿直接采样SCL电平这样更能精准对齐到每一位的边界。SCL下降沿和上升沿的检测通过对同步后的scl做边沿检测实现wire scl_rise scl_sync2 ~scl_sync2_d1; wire scl_fall ~scl_sync2 scl_sync2_d1;4. 时钟频率选择与建立保持时间别忽视的物理约束从机内部逻辑跑在系统时钟上而I2C总线有自己的时钟。系统时钟频率必须远高于I2C时钟才能保证每个位周期内有足够的采样点。以100kHz标准模式为例每个bit是10us如果你的系统时钟是50MHz20ns周期一个bit时间内有500个时钟周期同步、边沿检测、状态跳转、数据采样全都能从容完成。但如果系统时钟只有1MHz一个bit内只有10个周期再扣掉同步延迟状态机的响应裕量就很紧张了。所以建议I2C从机模块的输入时钟至少要比I2C时钟高20倍。支持400kHz快速模式时系统时钟建议8MHz以上支持1MHz快速模式时系统时钟要20MHz以上。除了频率还有一个是建立时间和保持时间的要求。I2C规范对数据有明确的时序约束SDA在SCL上升沿之前必须稳定至少一段时间建立时间在SCL上升沿之后还要保持一段时间保持时间。因为FPGA里你是用系统时钟采样SCL和SDA所以这个约束转换成“采样到的数据要在正确的时刻被锁存”。简单来说在检测到SCL上升沿后不要立刻就去采SDA最好等几个系统时钟周期等SDA的电平稳定后再采样——这其实就是给SDA信号留出了天然的建立时间。我实际代码里的做法是检测到SCL上升沿后用系统时钟打两拍再锁存SDA。这样即便总线上有轻微振铃采样到的也是稳定后的电平。代价是采到的数据比理想时刻晚了几十个ns但I2C从机的接收端本来就允许有一定的响应延迟只要在下一个bit开始前处理好就行这个裕量完全够用。时钟拉伸Clock Stretching也是从机必备的能力。某些场景下从机收到一个字节后需要时间去处理比如内部写Flash、更新寄存器如果不能在规定时间内准备好下一个操作可以把SCL拉低让主机暂停时钟。主机检测到SCL被拉低后会等待直到从机释放SCL才继续。这个功能在FPGA从机里实现也不难你只要在需要延时的时段把SCL引脚驱动为低电平即可。但前提是SCL引脚必须配置成开漏输出否则你拉低它、主机也在拉高它就是短路。不过别一开始就上时钟拉伸。对于大多数寄存器读写场景FPGA从机响应速度远快于I2C总线速度根本不需要拉伸。加入这个功能反而提升了状态机的复杂度容易引入bug。先用最简单的方式跑通确认有需求再加也不迟。5. 寄存器文件和读写路径让从机真正“有用”的关键一个只响应地址、不回数据的从机是没有任何价值的。实际项目里从机后面一定挂着一组寄存器主机通过I2C读写这些寄存器实现对外设的控制或状态采集。寄存器文件的设计看似简单真正做起来有几个细节非常关键。第一地址映射要清晰。从机收到主机发来的地址字节和数据字节后第一个数据字节通常是寄存器地址之后的数据才是要写入的值。所以写流程是START 从机地址(W) 寄存器地址 数据1 数据2 … STOP。有的从机还支持不带寄存器地址直接连续写这个看你的协议定义。我的习惯是在寄存器文件里做一个二维的寄存器数组用寄存器地址做索引reg [7:0] reg_file [0:255];这样最大支持256个寄存器每个8bit对大多数I2C设备来说足够用了。结合上一节提到的地址自增逻辑主机可以连续读写任意长度的寄存器区间。第二不同寄存器要有不同的读写属性。有些寄存器是只读的比如设备ID、版本号有些是只写的比如命令寄存器有些是读写兼可的。在从机模块里需要把读和写的访问分别生成独立的信号交由上层逻辑根据寄存器地址决定是否响应。做法是wire reg_wr_en (state DATA) (bit_cnt 4d8) !rw_flag byte_valid; wire reg_rd_en (state DATA) (bit_cnt 4d8) rw_flag; always (posedge clk or negedge rst_n) begin if (!rst_n) reg_file[0] 8h00; else if (reg_wr_en !is_readonly(reg_addr)) reg_file[reg_addr] rx_byte; end只读寄存器就是压根不产生写允许信号这样即使主机写了也不会生效。这一步做扎实了后面集成应用功能时非常省心。第三读数据路径的时序容易出问题。读模式下第9个时钟周期主机回ACK后SDA会被主机释放此时从机需要在下一个SCL低电平期间把数据的最高位放到SDA上。如果从机判断“是否该发送下一个字节”的时机晚了一个周期就会漏掉一个bit导致主机读到的数据错位。解决方法是在主机返回ACK的那个SCL上升沿就准备好下一次发送的第一个数据位在这个SCL的下降沿后立刻把数据放到SDA上。这样等到主机下一个SCL上升沿采样时数据早就稳定了。6. 仿真与testbench搭建没有靠谱的tb就等于盲人摸象写从机代码容易难在验证。I2C从机的测试不能只靠上板必须有完善的仿真环境。我的做法是自己写一个简单的主机模型BVMBus Functional Model用来模拟主机发起各种传输。testbench的主机模型核心是几个task分别执行START、发送字节、读取字节、STOP等操作task i2c_start; begin sda_m 1b1; scl_m 1b1; #1000; sda_m 1b0; #1000; scl_m 1b0; end endtask task i2c_write_byte; input [7:0] data; integer i; begin for (i 7; i 0; i i - 1) begin sda_m data[i]; #500; scl_m 1b1; #500; scl_m 1b0; end // 释放SDA准备接收从机的ACK sda_m 1bz; #250; scl_m 1b1; #250; scl_m 1b0; end endtask注意上面发送字节的时序要和I2C协议对齐SDA在SCL低电平时变化SCL上升沿采样。仿真里scl_m和sda_m都声明为reg驱动后要留出足够的时间让FPGA内部的同步电路和状态机响应。我用的是50MHz系统时钟仿真里每个bit周期设为2us等效500kHz跑一轮完整的读写测试也就几毫秒仿真时间很快。测试用例要覆盖这些场景地址匹配写一个字节地址匹配写多个字节验证地址自增地址匹配读一个字节地址匹配读多个字节最后一个字节主机回NACK地址不匹配时从机不应答传输中途插入重复START传输中途STOP后立即再次START我踩过的坑之一testbench里忘了在每字节传输结束后释放SDA总线导致仿真里看起来一切都正常但换到真实环境里从机的ACK信号根本没生效。原因是我把sda_m一直驱动为0相当于强制把SDA拉低掩盖了从机回ACK的波形。后来我在每个字节的第9个时钟周期前都加上#250; sda_m 1bz;这句释放语句真实的ACK波形才露出来。还有一件事用Vivado的人都知道仿真时必须在testbench里加上拉电阻的模型pullup(sda_m); pullup(scl_m);不加这句三态门释放后SDA会变成X态未知态后续逻辑判断全乱。这个细节在教科书里几乎没人讲但实际仿真中一天能遇到八回。7. 上板调试的实战经验波形不撒谎逻辑会骗人仿真过了不代表上板一定能跑。I2C从机上板调试时最常用的工具是逻辑分析仪其次是示波器。没有逻辑分析仪的话用带I2C解码功能的示波器也行。第一个容易遇到的问题是SDA拉不下来。代码里三态门方向写对了、上拉也加了但SDA还是永远高电平。这种问题九成是IO引脚配置的问题检查一下综合约束文件里引脚是否设成了开漏输出。以Xilinx器件为例引脚约束里要加上PULLUP属性否则内部上拉没使能外部板子也没焊接上拉电阻总线就悬空了。第二个经典故障是能收到地址但数据错位。如果主机是STM32这类MCU大概率是主机侧I2C外设配置成了7位地址模式但从机侧你实现的是10位地址或者反过来。两者不匹配时地址比对永远失败从机自然不响应。先确认两侧地址模式一致再确认地址值本身没写错。第三个故障是SCL一直为低。这个现象往往是主机启动传输后从机状态机进入了某个死循环拉低了SCL做时钟拉伸但一直没释放。查法也简单把逻辑分析仪接上看从机是在哪一个字节后把SCL拉低的——如果总是在收到某个特定数据值后卡住那多半是状态机里对该数据的响应分支存在漏洞比如某个case分支没写default导致状态机跳到了一个未知状态。Verilog里case语句的default一定不能省否则综合出的状态机行为会匪夷所思。遇到这种问题我的调试方法是在关键节点打调试信号。从机模块预留几个调试输出引脚把当前状态机的状态值直接用二进制输出到LED或者测试点。上板后看LED显示的状态编号就能反推它卡在哪。别觉得这样土这招比任何仿真都有效——因为仿真环境往往和真实电气环境有差异只有真实总线上的行为才是最可信的。8. 最后一个提高稳定性的建议做I2C从机尤其要支持多主机总线或长线缆传输时建议在状态机入口加一个毛刺过滤器——检测到START后延迟一小段时间再确认过滤掉总线上因为干扰产生的伪起始条件。这个延迟不能太长一般几个微秒就够否则会错过真实起始条件。实现也不复杂用一个计数器在检测到START后数固定周期如果期间总线变成IDLE状态就放弃直到计数完成才真正进入ADDRESS。整套代码跑下来从RTL到上板验证花时间最多的一定是调试阶段。仿真是必要手段但别过度依赖最终以逻辑分析仪和示波器为准。我现在的习惯是每写一个模块先写testbench做基础验证然后立刻编译上板跑一个最小程序确认引脚和总线物理链路没问题再往里面加复杂功能。这样定位问题时变量更少效率高很多。福步一步一个脚印把每个功能模块都吃透再集成这套流程做完I2C从机这个方向基本就出师了。本文还有配套的精品资源点击获取