FPGA工程师必看:手写IIC协议Verilog实现全流程与避坑指南 1. IIC协议为什么值得用Verilog手写一遍做FPGA或者ASIC设计的同行迟早都会碰到IIC。它不像SPI那样随便拉几根线就能跑也不像UART那样点对点简单直接。IIC是两根线挂一堆设备的总线结构时序细节多状态机绕稍不注意就会在某个ACK位上卡死。很多初学者第一次写IIC仿真波形看着挺像那么回事一下板子就发现读回来的数据全是0xFF或者0x00抓波形一看要么是START条件没建立对要么是SCL和SDA的相位关系搞反了。我写IIC的Verilog代码前后大概迭代过五六版从最开始只能跑100kHz的“能用就行”版本到后来能稳定跑400kHz、支持多字节连续读写、带超时检测的工程级模块。这个过程踩过的坑比写过的代码行数还多。这篇文章就把我这些年积累下来的IIC协议Verilog实现经验完整拆开讲一遍从协议底层原理到状态机设计从参数计算到仿真验证再到上板调试的排查思路尽量做到你拿着这篇文章就能直接写出一个能跑的IIC控制器。这篇文章适合谁看如果你正在学FPGA、准备用Verilog写IIC主机控制器、或者已经写了一个但总是不稳定想找问题那这篇内容应该能帮到你。我会尽量把每个设计决策背后的原因讲清楚不只是给你一段代码而是让你理解为什么这么写。代码风格上我偏向于可综合、可配置、状态机清晰的结构不会用太多花哨的写法保证你在任何主流综合工具上都能跑通。2. IIC协议的底层逻辑与Verilog实现的对应关系2.1 两根线怎么就能挂这么多设备IIC全称是Inter-Integrated Circuit中文叫集成电路总线。它的物理层极其精简只有两根线一根SCL是时钟线一根SDA是数据线。两根线都是开漏输出结构也就是说每个挂在总线上的设备只能把线拉低不能主动拉高。线要变高靠的是上拉电阻。这个设计的好处是天然支持多设备共享总线任何一个设备拉低都能把总线拉下来不会出现两个设备一个输出高一个输出低导致短路的情况。这个开漏结构对Verilog设计的影响非常直接。在FPGA端SCL和SDA引脚必须配置为开漏输出或者三态输出。具体做法是输出数据用寄存器控制输出使能单独控制。当使能无效时引脚处于高阻态总线由上拉电阻拉高。很多初学者直接用推挽输出驱动SDA结果就是总线仲裁直接失效甚至可能烧掉引脚。这一点在后面代码实现部分我会详细说。IIC的通信速率标准模式是100kHz快速模式是400kHz高速模式能到3.4MHz。大部分传感器和EEPROM用100kHz或400kHz就够了。速率的选择直接影响你Verilog里时钟分频参数的设定后面会给出具体的计算公式。2.2 START和STOP条件的时序本质IIC协议里最核心的两个信号是START和STOP条件。它们不是某个特定的电平而是SCL和SDA之间的相对变化关系。START条件SCL为高电平时SDA从高变低。STOP条件SCL为高电平时SDA从低变高。注意这里的关键点是“SCL为高电平期间”。在SCL为低电平期间SDA可以随意变化因为这时候数据线的变化不被认为是起始或停止条件。这个规则在Verilog里实现的时候最容易犯的错误就是SCL和SDA的更新时序没有错开。如果你在同一个时钟沿同时更新SCL和SDA综合出来的电路可能因为路径延迟不同导致SDA的变化恰好落在SCL的高电平期间产生一个意外的START或STOP条件。正确的做法是SCL的翻转和SDA的翻转使用不同的状态或者不同的时钟相位来控制确保SDA的变化发生在SCL低电平期间。我在实际项目里用的方法是状态机的每个状态只做一件事。比如产生START条件的状态先确保SCL为高、SDA为高然后拉低SDA再进入下一个状态拉低SCL。这样每个时钟周期只改变一根线的状态时序非常清晰。2.3 数据位的建立与保持时间要求IIC协议对数据位的建立时间和保持时间有明确要求。以快速模式400kHz为例数据建立时间tSU:DAT最小是100ns数据保持时间tHD:DAT最小是0ns但实际建议留一定余量。在SCL上升沿之前SDA必须已经稳定在SCL下降沿之后SDA需要保持一段时间才能变化。用Verilog实现的时候如果你的系统时钟是50MHz一个时钟周期是20ns。100ns的建立时间对应5个时钟周期。这意味着你不能在SCL上升沿的同一个时钟周期才去更新SDA至少要提前5个周期把SDA准备好。我在代码里通常会在SCL拉高之前设置一个“数据准备”状态确保SDA提前稳定。这里给一个实用的参数计算思路假设系统时钟频率为Fclk目标SCL频率为Fscl那么SCL的半个周期对应的时钟周期数为N Fclk / (2 * Fscl)。比如50MHz系统时钟、100kHz SCLN 50000000 / 200000 250。也就是说SCL每250个系统时钟周期翻转一次。数据建立时间如果要求250ns那就是250ns * 50MHz 12.5个时钟周期取整为13个周期。这些参数在代码里最好用parameter定义方便不同项目复用。3. IIC主机控制器的Verilog架构设计3.1 状态机怎么划分才清晰IIC主机控制器的核心是一个状态机。状态机划分得合不合理直接决定了代码的可维护性和调试难度。我见过有人把整个IIC传输写成一个巨大的always块几十个状态跳来跳去改一个地方就出一堆bug。也见过有人把IIC拆得太碎状态之间跳转关系复杂到自己也理不清。我推荐的划分方式是按照IIC协议的物理动作来分状态每个状态对应一个明确的、不可再分的总线操作。具体来说一个完整的IIC写传输包含以下基本动作产生START条件发送7位从机地址发送读写位接收ACK发送8位数据接收ACK产生STOP条件读传输稍微复杂一些多了重新START和主机发送NACK的环节。把这些基本动作映射成状态机的状态每个状态只负责一个动作状态之间的跳转条件就是动作完成。这样划分的好处是每个状态的逻辑非常简单调试的时候抓波形一看就知道当前在哪个状态增加新功能比如10位地址、多字节连续读写只需要增加状态和跳转条件不会影响已有逻辑。我通常会把状态机分成三个层次顶层控制状态机负责决定什么时候发起传输、传输多少字节中间层状态机负责字节级的发送和接收底层状态机负责位级的SCL和SDA操作。这种分层设计在Verilog里可以用多个always块实现每个块负责一个层次通过握手信号通信。3.2 时钟分频模块的设计要点IIC的SCL时钟是由系统时钟分频得到的。分频模块看起来简单但有几个细节需要注意。第一个细节是分频计数器的位宽。如果你要支持最低10kHz的SCL频率系统时钟50MHz那么分频系数最大是2500需要12位计数器。如果你只支持100kHz以上8位计数器就够了。但为了代码的通用性我建议直接用16位或者用parameter根据实际需求设定。第二个细节是SCL的占空比。IIC协议对SCL的高电平和低电平时间有最小要求但并没有严格要求占空比必须是50%。不过在实际应用中50%占空比是最稳妥的选择因为这样建立时间和保持时间都有足够的余量。我的做法是分频计数器从0计到N-1其中N是半周期对应的时钟数。计数器前半段SCL为低后半段SCL为高。这样占空比就是精确的50%。第三个细节是SCL的使能控制。在START和STOP条件产生的时候SCL需要保持高电平这时候分频计数器应该停止工作。我的做法是给分频模块加一个enable信号只有当状态机处于数据传输状态时分频模块才输出SCL翻转信号。在START和STOP状态SCL直接由状态机控制。// 时钟分频模块示例 parameter CLK_FREQ 50_000_000; parameter SCL_FREQ 100_000; localparam DIV_CNT CLK_FREQ / (2 * SCL_FREQ); reg [15:0] div_cnt; reg scl_en; reg scl_r; always (posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt 0; scl_r 1b1; end else if (!scl_en) begin div_cnt 0; scl_r 1b1; end else begin if (div_cnt DIV_CNT - 1) begin div_cnt 0; scl_r ~scl_r; end else begin div_cnt div_cnt 1; end end end这段代码里scl_en信号由状态机控制在需要SCL翻转的时候拉高在START和STOP条件期间拉低。scl_r就是最终的SCL输出。3.3 三态门与开漏输出的Verilog写法前面提到IIC总线是开漏结构FPGA端必须用三态输出。Verilog里实现三态输出的标准写法是assign sda sda_out_en ? sda_out : 1bz; assign scl scl_out_en ? scl_out : 1bz;其中sda_out_en和scl_out_en是输出使能信号sda_out和scl_out是输出数据。当使能无效时引脚输出高阻态总线由上拉电阻拉高。这里有一个非常重要的注意事项在读取SDA上的ACK信号或者读数据的时候必须先把输出使能拉低让FPGA释放SDA线否则FPGA输出的值和从机输出的值会冲突。我见过有人在接收ACK的时候忘了释放SDA结果从机拉低SDA的时候FPGA也在驱动SDA轻则读不到正确的ACK重则损坏引脚。另外SCL线在大多数应用里只由主机驱动从机不会拉低SCL时钟拉伸是另一回事后面会提。所以SCL的输出使能可以一直有效不需要三态控制。但为了代码的通用性和安全性我建议SCL也用三态输出在不需要驱动的时候释放总线。4. 完整IIC控制器的Verilog实现与逐段解析4.1 顶层模块的接口定义先来看顶层模块的接口。一个标准的IIC主机控制器需要以下端口module i2c_master ( input wire clk, input wire rst_n, // 用户接口 input wire start, // 启动传输 input wire rw, // 0:写 1:读 input wire [6:0] dev_addr, // 从机地址 input wire [7:0] wr_data, // 写数据 output reg [7:0] rd_data, // 读数据 output reg done, // 传输完成 output reg ack_err, // ACK错误 // IIC物理接口 output wire scl, inout wire sda );这个接口设计有几个考虑。start信号是脉冲触发状态机检测到start为高且当前空闲时启动一次传输。rw决定是读还是写。dev_addr是7位从机地址不包含读写位读写位由rw信号决定。done信号在传输完成时输出一个时钟周期的高脉冲。ack_err在从机没有正确应答时置位方便上层逻辑判断传输是否成功。对于多字节连续读写可以在顶层再加一个字节计数器和FIFO接口。但为了保持代码简洁这里先实现单字节读写多字节的扩展思路在后面讲。4.2 状态机的完整实现状态机是整个IIC控制器的核心。我用的是三段式状态机写法第一段用时序逻辑更新当前状态第二段用组合逻辑判断下一个状态第三段用时序逻辑输出控制信号。这种写法综合出来的电路清晰不容易产生毛刺。状态定义如下localparam IDLE 4d0; localparam START 4d1; localparam SEND_ADDR 4d2; localparam GET_ACK1 4d3; localparam SEND_DATA 4d4; localparam GET_ACK2 4d5; localparam RESTART 4d6; localparam SEND_ADDR_R 4d7; localparam GET_ACK3 4d8; localparam READ_DATA 4d9; localparam SEND_NACK 4d10; localparam STOP 4d11;写传输的流程是IDLE - START - SEND_ADDR - GET_ACK1 - SEND_DATA - GET_ACK2 - STOP - IDLE。读传输的流程是IDLE - START - SEND_ADDR - GET_ACK1 - RESTART - SEND_ADDR_R - GET_ACK3 - READ_DATA - SEND_NACK - STOP - IDLE。每个状态的具体操作IDLE状态等待start信号。检测到start为高时根据rw信号决定进入写流程还是读流程。同时把字节计数器清零ack_err清零。START状态产生START条件。具体操作是确保SCL为高、SDA为高然后拉低SDA。这个状态持续一个SCL周期然后进入SEND_ADDR。SEND_ADDR状态发送7位从机地址和1位读写位。用一个3位计数器从0计到7每个计数周期输出一位地址。发送顺序是MSB先出。在SCL低电平期间更新SDA在SCL高电平期间保持SDA稳定。GET_ACK1状态释放SDA线读取从机拉低的ACK信号。如果SDA为低说明从机应答了如果SDA为高说明从机没有应答置位ack_err。这个状态在SCL高电平期间采样SDA。SEND_DATA状态发送8位写数据流程和发送地址类似只是数据变成8位。GET_ACK2状态接收数据字节的ACK。RESTART状态产生重复START条件。操作和START类似但此时SCL和SDA的状态需要根据当前总线状态来设置。READ_DATA状态释放SDA线在SCL高电平期间采样SDA上的数据。每个SCL周期读取一位8个周期后组成一个字节。SEND_NACK状态主机发送NACK信号告诉从机不要再发了。具体操作是在第9个SCL周期拉高SDA不拉低然后产生STOP条件。STOP状态产生STOP条件。确保SCL为高然后SDA从低变高。4.3 位计数器与字节计数器的配合位计数器用于控制每个字节内8个数据位的发送和接收。我用一个3位计数器bit_cnt从0计到7。在SEND_ADDR和SEND_DATA状态每个SCL周期bit_cnt加1当bit_cnt等于7时表示一个字节发送完毕进入ACK状态。字节计数器用于多字节传输。如果只做单字节读写字节计数器可以省略。但如果要支持连续读写多个字节就需要一个字节计数器byte_cnt来记录当前传输到第几个字节。每完成一个字节的传输byte_cnt加1直到达到预设的传输长度。位计数器和字节计数器的配合逻辑是位计数器在每个SCL周期翻转字节计数器在位计数器归零且ACK完成后翻转。这样就能保证每个字节的8位数据都被正确发送或接收。4.4 SDA方向控制的时机把握SDA的方向控制是IIC实现中最容易出错的地方之一。SDA需要在发送数据时输出在接收ACK和读数据时输入。方向切换的时机非常关键。发送数据时在SCL低电平期间FPGA驱动SDA输出数据位。在SCL高电平期间SDA保持稳定从机采样。接收ACK时在发送完8位数据后FPGA必须立即释放SDA线把方向切换为输入。这个切换必须发生在第9个SCL周期的高电平之前。如果释放晚了从机拉低SDA的时候FPGA还在驱动就会产生总线冲突。我的做法是在SEND_DATA状态的最后一个周期bit_cnt 7且SCL为低就把SDA的输出使能拉低。这样当SCL拉高进入ACK周期时SDA已经处于高阻态从机可以自由拉低。读数据时同样需要在SCL低电平期间释放SDA在SCL高电平期间采样。读数据的每个位都需要在SCL高电平期间读取SDA的值。// SDA方向控制示例 always (posedge clk or negedge rst_n) begin if (!rst_n) begin sda_out_en 1b0; end else begin case (state) SEND_ADDR, SEND_DATA, SEND_ADDR_R: begin // 发送数据时SCL低电平期间驱动SDA if (scl_r 1b0 bit_cnt 3d7) sda_out_en 1b1; else sda_out_en 1b0; end GET_ACK1, GET_ACK2, GET_ACK3, READ_DATA: begin // 接收数据时始终释放SDA sda_out_en 1b0; end SEND_NACK: begin // 发送NACK时驱动SDA为高 sda_out_en 1b1; end default: sda_out_en 1b0; endcase end end这段代码的关键点是在发送状态只有在SCL低电平且bit_cnt在有效范围内时才驱动SDA。在接收状态始终释放SDA。在SEND_NACK状态驱动SDA为高电平表示NACK。5. 仿真验证与上板调试的实操经验5.1 仿真平台怎么搭才能覆盖关键场景写完IIC控制器之后仿真验证是必不可少的一步。我见过很多人仿真只跑一个写传输看到波形上有START、地址、数据、STOP就认为没问题了。结果上板一跑读操作直接挂掉。仿真必须覆盖以下场景第一个场景是单字节写传输。验证START条件、地址发送、ACK接收、数据发送、ACK接收、STOP条件是否都正确。第二个场景是单字节读传输。验证重复START条件、地址发送读写位为1、ACK接收、数据读取、NACK发送、STOP条件是否正确。第三个场景是从机不应答的情况。模拟从机不拉低ACK验证ack_err信号是否正确置位状态机是否能正常回到IDLE。第四个场景是连续多字节读写。验证字节计数器是否正确工作每个字节之间的ACK处理是否正确。仿真平台可以用一个简单的IIC从机模型来模拟从机的行为。从机模型不需要太复杂能响应START、STOP、地址匹配、发送ACK、接收和发送数据就够了。我通常用Verilog写一个行为级的从机模型挂在测试平台上和主机控制器对接。// 简化的IIC从机模型示例 module i2c_slave_model ( inout wire scl, inout wire sda ); reg [7:0] mem [0:255]; reg [7:0] addr; // ... 从机行为逻辑 endmodule仿真的时候重点看几个关键点SCL和SDA的相位关系是否正确ACK周期SDA是否被正确释放数据采样点是否在SCL高电平期间。如果这些都没问题基本功能就稳了。5.2 上板调试时最容易踩的五个坑仿真过了不代表上板就能跑。我总结了一下上板调试时最容易踩的坑有五个。第一个坑是上拉电阻没接或者阻值不对。IIC总线必须有上拉电阻典型值是4.7kΩ到10kΩ。阻值太小功耗大阻值太大上升沿变缓高速通信时可能导致数据错误。我遇到过有人忘了接上拉电阻结果SDA和SCL一直是低电平什么都读不到。第二个坑是引脚约束不对。FPGA的IIC引脚必须配置为开漏输出或者三态输出。如果约束文件里把引脚配置成了推挽输出总线仲裁会失效。在Xilinx的约束文件里需要用set_property配置引脚的开漏属性或者用三态输出的写法让综合工具自动推断。第三个坑是时钟频率设置太高。有些传感器只支持100kHz你设成400kHz它就不响应了。上板之前一定要确认从机支持的速率范围。如果不确定先用100kHz跑通再提速。第四个坑是地址搞错了。IIC从机地址是7位但很多数据手册上写的是8位包含读写位。比如一个EEPROM的地址可能写成0xA0这其实是7位地址0x50左移一位得到的。如果你直接把0xA0当成7位地址发出去从机根本不会应答。正确的做法是把数据手册上的8位地址右移一位得到7位地址。第五个坑是SDA和SCL接反了。这个听起来很低级但实际调试中真的经常发生。尤其是用排线连接的时候一不小心就接反了。上板之前用万用表测一下确认SCL接的是时钟引脚SDA接的是数据引脚。5.3 用示波器抓波形的正确姿势上板调试的时候示波器是最重要的工具。但很多人抓波形的方法不对抓到的波形根本看不出问题。正确的做法是用双通道示波器通道1接SCL通道2接SDA。触发方式设置为SCL下降沿触发或者SDA下降沿触发。时基根据SCL频率来设100kHz的SCL周期是10us时基设成10us/div左右就能看到完整的字节传输。抓波形的时候重点看几个地方START条件的SDA下降沿是否在SCL高电平期间地址字节的8位数据是否在SCL高电平期间稳定ACK周期SDA是否被从机拉低STOP条件的SDA上升沿是否在SCL高电平期间。如果条件允许用逻辑分析仪会更方便。逻辑分析仪可以自动解码IIC协议直接显示地址、数据、ACK/NACK比看示波器波形直观得多。但逻辑分析仪只能看逻辑电平看不到上升沿的斜率所以示波器和逻辑分析仪配合使用效果最好。6. 常见问题速查与进阶优化思路6.1 问题排查速查表现象可能原因排查方法读回数据全0xFFSDA一直高从机没驱动检查从机地址是否正确上拉电阻是否接好读回数据全0x00SDA一直低总线被拉死检查是否有设备一直拉低SDA检查三态控制ACK始终为高从机没应答检查从机地址、从机供电、上拉电阻START条件不产生SCL/SDA初始状态不对检查状态机初始状态确保SCL和SDA先拉高数据位错位SCL和SDA相位关系错误检查SDA更新是否在SCL低电平期间高速时数据错误建立时间不够增加数据准备状态的时钟周期数多字节读写只成功第一个字节字节计数器逻辑错误检查字节计数器的清零和进位逻辑6.2 时钟拉伸的处理方法时钟拉伸是IIC协议的一个特性从机可以通过拉低SCL来暂停传输直到它准备好继续。很多简单的IIC控制器不支持时钟拉伸在遇到支持拉伸的从机时就会出错。处理时钟拉伸的方法是在SCL输出高电平之后检测SCL的实际电平。如果SCL被从机拉低了说明从机在做时钟拉伸主机需要等待SCL真正变高之后才能继续。在Verilog里实现的方法是SCL输出高之后用一个采样电路检测SCL引脚的实际电平如果为低则保持当前状态不变直到SCL变高。// 时钟拉伸检测示例 always (posedge clk or negedge rst_n) begin if (!rst_n) scl_stretch 1b0; else if (scl_out_en scl_out 1b1 scl_in 1b0) scl_stretch 1b1; else if (scl_in 1b1) scl_stretch 1b0; end当scl_stretch为高时状态机暂停推进直到拉伸结束。这个功能在连接某些传感器时是必须的。6.3 多字节连续读写的扩展思路单字节读写跑通之后多字节连续读写是下一步要做的。扩展的思路是增加一个字节计数器和一个传输长度寄存器。每完成一个字节的传输字节计数器加1直到达到传输长度。对于连续写流程是START - 地址W - ACK - 数据1 - ACK - 数据2 - ACK - ... - 数据N - ACK - STOP。对于连续读流程是START - 地址W - ACK - 数据1 - ACK - 数据2 - ACK - ... - 数据N - NACK - STOP。注意最后一个字节主机要发NACK而不是ACK告诉从机停止发送。实现的时候状态机需要在ACK状态判断字节计数器是否达到传输长度。如果达到了写传输进入STOP状态读传输进入SEND_NACK状态。如果没有达到回到SEND_DATA或READ_DATA状态继续传输下一个字节。6.4 超时检测与总线恢复在实际应用中IIC总线可能会因为各种原因卡死比如从机在传输过程中复位了SDA被一直拉低。这时候需要超时检测机制来强制复位状态机释放总线。超时检测的实现方法是用一个计数器记录状态机在某个状态停留的时间。如果超过预设的阈值比如10ms就强制状态机回到IDLE并产生一个总线恢复信号。总线恢复的操作是主机发送9个SCL脉冲每个脉冲期间释放SDA让从机有机会释放SDA。9个脉冲之后再发送一个STOP条件总线就恢复正常了。// 超时检测示例 parameter TIMEOUT_CNT 50_000_000 / 100; // 10ms超时 reg [31:0] timeout_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) timeout_cnt 0; else if (state IDLE) timeout_cnt 0; else if (timeout_cnt TIMEOUT_CNT) timeout_cnt 0; else timeout_cnt timeout_cnt 1; end wire timeout (timeout_cnt TIMEOUT_CNT);当timeout为高时状态机强制回到IDLE并触发总线恢复流程。这个机制在工业现场的应用中非常重要因为总线卡死会导致整个系统失去响应。6.5 参数化设计与代码复用最后说一下参数化设计。IIC控制器的很多参数在不同项目里是不一样的系统时钟频率、SCL频率、超时时间、传输长度等。把这些参数用parameter定义在模块开头可以大大提高代码的复用性。module i2c_master #( parameter CLK_FREQ 50_000_000, parameter SCL_FREQ 100_000, parameter TIMEOUT 10_000_000 // 10ms ) ( // ... );这样在不同的项目里只需要修改parameter的值不需要改动内部逻辑。我通常会把常用的配置做成一个单独的参数文件用include的方式引入这样多个模块可以共享同一套参数。另外代码风格上我建议用同步复位而不是异步复位。虽然异步复位在某些场景下更安全但同步复位在FPGA里综合出来的时序更好而且IIC控制器的复位通常不需要异步。复位信号进来之后用一个同步器打两拍再用可以避免亚稳态问题。关于IIC控制器的Verilog实现基本上就是这些内容。从协议理解到状态机设计从仿真验证到上板调试每个环节都有需要注意的细节。我个人的经验是先把单字节写传输跑通再跑单字节读传输然后跑多字节最后加超时检测和时钟拉伸。一步一步来不要想着一次把所有功能都写完。每跑通一个功能就用示波器或者逻辑分析仪抓一次波形确认时序完全正确再继续。这样虽然看起来慢但实际上是最快的方式因为一旦某个环节有问题你可以很快定位到是哪个功能引入的。