IIC主机写模块Verilog实现:状态机设计与时序调试指南 1. IIC主机写模块的整体设计思路1.1 为什么选IIC又为什么先从主机写模块下手做FPGA或者数字IC前端的朋友迟早都会碰到IIC。它两根线一根SCL时钟一根SDA数据省引脚、协议简单、兼容性好几乎所有的传感器、EEPROM、OLED屏、电源管理芯片都在用它。你随便拿一块开发板上面大概率挂着至少一个IIC从设备。所以把IIC的Verilog实现吃透算是数字设计里绕不开的基本功。那为什么先写主机写模块因为读操作本质上也是“先写地址再读数据”写时序是读时序的子集。你把写模块的时序逻辑、状态机、时钟分频、起始停止条件全部搞定了读模块只需要在中间插入一个重复起始条件和一个读数据移位寄存器就行。我个人的习惯是先把写通路跑通用示波器或者逻辑分析仪抓一次波形确认SCL频率、建立保持时间、ACK采样点都对再去扩展读功能这样调试压力小很多。这个模块适合谁看如果你刚学完Verilog语法想找一个有实际时序约束的项目练手IIC主机写模块非常合适。它不像DDR或者PCIe那样复杂但又比流水灯、UART多了真正的双向总线仲裁和时序配合。对于已经工作的朋友如果你手头正好要驱动一个IIC接口的DAC或者EEPROM这篇文章里的代码结构和参数计算方法可以直接拿去改。1.2 模块划分与核心参数计算一个完整的IIC主机写模块我一般拆成四个部分时钟分频器、位传输状态机、字节发送移位寄存器、ACK检测逻辑。这四块各司其职耦合度低方便单独仿真验证。时钟分频器负责把系统时钟降到IIC的SCL频率。IIC标准模式是100kHz快速模式是400kHz高速模式能到3.4MHz。大部分FPGA开发板的系统时钟是50MHz或者100MHz所以分频系数要算清楚。以50MHz系统时钟、目标SCL为100kHz为例分频系数等于50_000_000除以(100_000乘以2)结果是250。为什么乘以2因为SCL的一个完整周期包括高电平和低电平分频器要在高电平和低电平各维持250个系统时钟周期。如果你想要400kHz分频系数就是50_000_000除以(400_000乘以2)等于62.5取整为62或者63实际SCL频率会有微小偏差但IIC对频率精度要求不高百分之几的误差完全能接受。位传输状态机是整个模块的核心。IIC的每一次写操作流程是这样的起始条件、发送7位从机地址加1位写标志、等待从机ACK、发送8位数据、等待ACK、发送停止条件。如果还要写多个字节就在停止条件之前重复“发送数据、等待ACK”这个循环。状态机我一般用三段式写法现态、次态、输出分开这样综合出来的时序干净也方便加断言做形式验证。移位寄存器负责把并行的字节数据转成串行在SCL低电平期间更新SDA在SCL高电平期间保持稳定。这里有个关键点SDA必须在SCL高电平期间保持稳定否则会被从机误判为起始或停止条件。所以移位寄存器的更新时机一定要卡在SCL低电平的中点附近留出足够的建立时间。ACK检测逻辑稍微麻烦一点。主机发送完8位数据后要释放SDA线让从机拉低表示应答。主机在第九个SCL高电平期间采样SDA如果读到低电平说明从机ACK了如果读到高电平说明从机没应答可能是地址不对或者从机忙。这时候主机可以选择重发或者直接发停止条件终止传输。我一般会在ACK检测失败时拉高一个错误标志方便上层逻辑判断。2. 核心细节解析与实操要点2.1 起始条件与停止条件的精确时序起始条件和停止条件是IIC协议里唯二允许SDA在SCL高电平期间变化的地方。起始条件是SCL为高时SDA从高变低停止条件是SCL为高时SDA从低变高。这两个条件的时序必须非常精确否则从机可能识别不到。在Verilog里实现起始条件我通常这样做先确保SCL和SDA都处于高电平空闲状态然后拉低SDA等待若干个系统时钟周期再拉低SCL。这个“若干个系统时钟周期”就是保持时间IIC标准要求起始条件的保持时间至少4微秒但实际上大部分从机都能容忍更短的时间。为了保险起见我一般让SDA拉低后等待四分之一分频周期再拉低SCL这样既满足时序要求又不会拖慢整体速度。停止条件反过来先确保SCL为低然后拉高SCL等待四分之一分频周期再拉高SDA。注意停止条件之后总线进入空闲状态SCL和SDA都保持高电平。这里有个容易踩的坑很多新手在写状态机的时候把起始条件和第一个数据位的发送混在一起导致SDA在SCL高电平期间变化了两次从机直接懵掉。我的建议是把起始条件单独作为一个状态持续至少一个完整的SCL周期确认总线稳定后再进入地址发送状态。2.2 数据位的建立与保持时间管理IIC协议对数据位的建立时间和保持时间有明确要求。标准模式下数据建立时间至少250纳秒保持时间至少300纳秒。快速模式下建立时间至少100纳秒保持时间至少300纳秒。这些时间看起来很短但在FPGA里如果逻辑延迟太大或者分频器设计不当很容易违反。我的做法是在SCL低电平期间更新SDA并且把更新时刻放在低电平的正中间。假设SCL低电平持续250个系统时钟周期那么在第125个周期时更新SDA。这样SDA在SCL拉高之前有125个周期的建立时间在SCL拉低之后有125个周期的保持时间余量非常充足。具体实现时我用一个计数器来跟踪SCL的相位。计数器从0计到分频系数减1前半段SCL为低后半段SCL为高。在计数器等于分频系数的一半时触发SDA更新。这个逻辑用组合逻辑或者时序逻辑都可以但我更倾向于时序逻辑因为组合逻辑容易产生毛刺而SDA上的毛刺会被从机误判为起始或停止条件。还有一个细节SDA是双向总线主机发送数据时要驱动它接收ACK时要释放它。在Verilog里我通常用一个三态门来实现输出使能信号控制是否驱动SDA。发送数据时使能输出接收ACK时关闭输出让从机驱动SDA。这个三态门在FPGA内部其实是用IOBUF实现的但写代码时只需要一个inout端口加一个输出使能信号就行。2.3 状态机的状态划分与转移条件状态机的状态划分直接决定了代码的可读性和可维护性。我一般把写操作的状态机分成八个状态IDLE、START、SEND_ADDR、WAIT_ACK1、SEND_DATA、WAIT_ACK2、STOP、DONE。IDLE是空闲状态SCL和SDA都保持高电平。当上层模块给出启动信号和从机地址、数据后状态机跳转到START。START状态拉低SDA然后拉低SCL持续一个SCL周期后跳转到SEND_ADDR。SEND_ADDR状态发送7位从机地址和1位写标志共8位。每发送一位移位寄存器左移一位计数器加一。发送完8位后跳转到WAIT_ACK1。WAIT_ACK1状态释放SDA等待从机拉低。在第九个SCL高电平期间采样SDA如果为低则跳转到SEND_DATA如果为高则跳转到STOP并置错误标志。SEND_DATA状态发送8位数据逻辑和SEND_ADDR类似。发送完后跳转到WAIT_ACK2。WAIT_ACK2状态和WAIT_ACK1类似检测从机应答。如果还有更多数据要发送就跳回SEND_DATA如果没有了就跳转到STOP。STOP状态拉高SCL然后拉高SDA持续一个SCL周期后跳转到DONE。DONE状态给出一个完成脉冲然后回到IDLE。这个状态划分的好处是每个状态只做一件事转移条件清晰仿真波形一目了然。如果你要加多字节写功能只需要在WAIT_ACK2里加一个数据计数器判断是否还有数据要发就行。3. 实操过程与核心环节实现3.1 时钟分频器的Verilog实现与参数计算先看时钟分频器的代码。假设系统时钟是50MHz目标SCL是100kHz分频系数是250。我定义一个参数DIV_CNT值为250然后用一个计数器div_cnt从0计到249循环。parameter DIV_CNT 250; reg [7:0] div_cnt; reg scl_r; always (posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt 8d0; scl_r 1b1; end else if (div_cnt DIV_CNT - 1) begin div_cnt 8d0; scl_r ~scl_r; end else begin div_cnt div_cnt 1b1; end end这段代码里scl_r每250个系统时钟周期翻转一次所以SCL的周期是500个系统时钟周期对应10微秒频率正好是100kHz。注意div_cnt的位宽要足够大250需要8位如果你用400kHz分频系数是626位就够了但为了通用性我一般统一用8位或者16位。这里有个经验分频系数最好是偶数这样SCL的高电平和低电平持续时间完全相等占空比50%。如果分频系数是奇数比如63那么高电平和低电平会差一个系统时钟周期虽然IIC对占空比要求不严但对称的波形看起来更舒服也方便计算建立保持时间。3.2 位传输状态机的完整代码与注释状态机的代码我写得比较详细每个状态都有注释方便后面维护。localparam IDLE 3d0; localparam START 3d1; localparam SEND_ADDR 3d2; localparam WAIT_ACK1 3d3; localparam SEND_DATA 3d4; localparam WAIT_ACK2 3d5; localparam STOP 3d6; localparam DONE 3d7; reg [2:0] cur_state, nxt_state; reg [7:0] shift_reg; reg [3:0] bit_cnt; reg sda_out; reg sda_oe; reg scl_out; always (posedge clk or negedge rst_n) begin if (!rst_n) cur_state IDLE; else cur_state nxt_state; end always (*) begin nxt_state cur_state; case (cur_state) IDLE: begin if (start_en) nxt_state START; end START: begin if (scl_phase_done) nxt_state SEND_ADDR; end SEND_ADDR: begin if (bit_cnt 4d8 scl_phase_done) nxt_state WAIT_ACK1; end WAIT_ACK1: begin if (scl_phase_done) begin if (ack_received) nxt_state SEND_DATA; else nxt_state STOP; end end SEND_DATA: begin if (bit_cnt 4d8 scl_phase_done) nxt_state WAIT_ACK2; end WAIT_ACK2: begin if (scl_phase_done) begin if (ack_received) begin if (data_cnt data_len - 1) nxt_state STOP; else nxt_state SEND_DATA; end else nxt_state STOP; end end STOP: begin if (scl_phase_done) nxt_state DONE; end DONE: begin nxt_state IDLE; end default: nxt_state IDLE; endcase end这段代码里scl_phase_done是一个脉冲信号在SCL的每个相位结束时拉高一个系统时钟周期。ack_received是在WAIT_ACK状态采样SDA得到的值。data_cnt和data_len用于多字节写操作。注意bit_cnt的位宽是4位因为最多计到84位足够。shift_reg是8位移位寄存器在SEND_ADDR和SEND_DATA状态加载数据然后每个SCL周期左移一位。3.3 SDA三态门控制与ACK采样时机SDA的三态门控制是IIC主机的关键。在发送地址和数据时主机驱动SDA在接收ACK时主机释放SDA。代码里用sda_oe信号控制输出使能。assign sda sda_oe ? sda_out : 1bz; always (posedge clk or negedge rst_n) begin if (!rst_n) sda_oe 1b0; else begin case (nxt_state) START, SEND_ADDR, SEND_DATA, STOP: sda_oe 1b1; WAIT_ACK1, WAIT_ACK2: sda_oe 1b0; default: sda_oe 1b0; endcase end endACK采样的时机非常重要。从机在第九个SCL高电平期间拉低SDA主机必须在这个高电平的中间时刻采样太早或太晚都可能采到错误的值。我的做法是在SCL高电平的中点产生一个采样脉冲用这个脉冲去锁存SDA的值。always (posedge clk or negedge rst_n) begin if (!rst_n) ack_received 1b0; else if (scl_high_mid_pulse (cur_state WAIT_ACK1 || cur_state WAIT_ACK2)) ack_received ~sda; else ack_received ack_received; endscl_high_mid_pulse是在SCL高电平正中间产生的脉冲。这个脉冲的生成逻辑和分频器配合当div_cnt等于分频系数的四分之三时拉高一个周期。这样采样点就在SCL高电平的正中间建立保持时间余量最大。4. 常见问题与排查技巧实录4.1 从机不ACK的几种可能原因从机不ACK是最常见的问题。你发完地址SDA在第九个SCL高电平期间还是高电平说明从机没有应答。原因通常有这几个第一从机地址错了。IIC从机地址是7位但很多芯片手册上写的是8位把读写位也算进去了。比如某个EEPROM手册写设备地址是0xA0这其实是8位格式7位地址是0x50。你如果直接把0xA0当7位地址发从机肯定不认。我的经验是拿到手册先看地址是7位还是8位如果是8位右移一位再发。第二从机电源没上或者没复位。有些从机上电后需要一段初始化时间这段时间内它不响应总线。你可以在上电后延时100毫秒再发起传输。第三上拉电阻太大或太小。IIC总线需要上拉电阻典型值是4.7kΩ。如果电阻太大SDA和SCL的上升沿太慢从机可能采不到正确的电平如果电阻太小总线电容充电太快但功耗会增加。我一般用4.7kΩ大部分场景都能工作。第四SCL频率太高。有些从机只支持100kHz你如果跑400kHz它就跟不上。这时候把分频系数改大降到100kHz再试。排查的时候我建议先用逻辑分析仪抓一次完整的波形看起始条件、地址、ACK位、数据、停止条件是否都正确。逻辑分析仪比示波器好用因为它能直接解码IIC协议把二进制数据显示出来一眼就能看出哪一位错了。4.2 SDA被意外拉低的排查思路有时候你会发现SDA在空闲状态被意外拉低导致总线无法启动。这种情况通常是以下几个原因一是三态门控制逻辑有bug。检查sda_oe信号在IDLE状态是否为0如果为1主机会一直驱动SDA从机想拉低也拉不低总线就冲突了。二是从机处于忙状态。有些从机在上电后或者上次传输未完成时会拉低SDA表示它还没准备好。这时候主机应该等待直到SDA释放后再发起传输。三是硬件问题。SDA线可能短路到地或者上拉电阻虚焊。用万用表量一下SDA对地的电阻如果是0欧姆那就是短路了。我遇到过一次SDA在空闲时一直是低电平查了半天代码没问题最后发现是开发板上另一个IIC从设备把SDA拉低了。因为IIC是总线结构多个从机挂在一起只要有一个从机拉低SDA整条总线就是低。所以调试时最好把其他从设备断开只留一个排除干扰。4.3 多字节写操作的注意事项多字节写操作看起来简单就是在WAIT_ACK2之后判断是否还有数据有就跳回SEND_DATA没有就发STOP。但实际调试时有几个坑第一数据计数器的复位时机。每次启动新的传输时data_cnt必须清零否则会沿用上一次的值导致发送的数据个数不对。我一般在IDLE状态或者START状态清零data_cnt。第二从机的地址自动递增。很多EEPROM支持页写模式你发完一个字节后从机内部地址自动加一你继续发下一个字节就行。但页写有边界限制比如每页8字节或者16字节跨页写会回卷到页首覆盖之前的数据。所以写多字节时要注意不要跨页或者分多次传输。第三ACK检测的时序。多字节写时每个字节后面都要检测ACK。如果某个字节从机没ACK主机应该立即发停止条件终止传输而不是继续发后面的数据。我的代码里在WAIT_ACK2状态检测到ack_received为0时直接跳转到STOP并且置一个错误标志上层逻辑可以根据这个标志决定是否重试。4.4 常见问题速查表问题现象可能原因排查方法解决方案从机不ACK地址错误核对手册地址是7位还是8位8位地址右移一位从机不ACK从机未初始化测量从机电源和复位引脚上电后延时100ms再传输从机不ACK上拉电阻不当测量SDA/SCL上升时间更换4.7kΩ上拉电阻SDA空闲时被拉低三态门控制错误检查sda_oe在IDLE状态的值确保IDLE时sda_oe为0SDA空闲时被拉低总线冲突断开其他从设备逐一排查只保留一个从设备调试数据错误建立保持时间不足用逻辑分析仪看SDA变化时刻调整SDA更新时机到SCL低电平中点多字节写失败跨页写回卷核对从机页大小分多次传输不跨页SCL无输出分频器未工作检查div_cnt是否计数确认复位信号和时钟输入这个表格是我在实际项目中总结出来的基本上覆盖了八成以上的问题。你如果遇到新的问题可以先对照这个表格排查大部分情况下都能找到原因。5. 仿真验证与上板调试的衔接5.1 用ModelSim做功能仿真的关键激励代码写完了先别急着上板一定要做功能仿真。我一般用ModelSim或者Vivado自带的仿真器写一个简单的testbench模拟一个IIC从机的行为。Testbench里需要做几件事产生50MHz时钟和复位信号在适当的时候给出start_en和从机地址、数据然后模拟从机在第九个SCL周期拉低SDA表示ACK。从机模型不需要太复杂能响应ACK就行。// 简化的从机ACK模型 always (posedge scl) begin if (bit_cnt 8) sda 1b0; // 拉低表示ACK else sda 1bz; end仿真的时候重点看几个地方起始条件的SDA下降沿是否在SCL高电平期间地址和数据的每一位是否在SCL高电平期间稳定ACK采样点是否在第九个SCL高电平的中间停止条件的SDA上升沿是否在SCL高电平期间。这些都没问题了再上板。5.2 上板调试时逻辑分析仪的抓波技巧上板调试时逻辑分析仪是必备工具。我用的是某款支持IIC协议解码的分析仪采样率至少100MHz这样才能看清100kHz的SCL波形。抓波的时候触发条件设成SDA的下降沿因为起始条件就是SDA在SCL高电平时的下降沿。抓一次完整的写传输然后看解码结果。如果解码出来的地址、数据、ACK都和预期一致说明模块工作正常。如果不一致就对照波形逐位排查。有个小技巧如果SCL频率是100kHz逻辑分析仪的采样率设成10MHz就够了这样抓取的时间窗口可以更长能抓到多次传输。如果采样率设得太高比如100MHz存储深度很快就被填满只能抓到很短的一段波形。另外逻辑分析仪的探头地线要尽量短否则会引入噪声导致波形畸变。我一般用弹簧地针直接插在开发板的GND引脚上比鳄鱼夹靠谱得多。5.3 从写模块扩展到读模块的思路写模块调通之后扩展读模块就简单了。读操作的流程是起始条件、发送7位从机地址加1位读标志、等待ACK、读取8位数据、发送NACK、发送停止条件。和写模块的区别在于发送完地址后主机释放SDA从机驱动SDA发送数据。主机在每個SCL高电平期间采样SDA移位寄存器右移或者左移把串行数据转成并行。读完8位后主机需要发送一个NACK也就是在第九个SCL周期保持SDA为高告诉从机数据接收完毕。然后发停止条件。代码上你只需要在状态机里增加READ_DATA和SEND_NACK两个状态复用现有的时钟分频器和三态门控制逻辑。移位寄存器的方向从输出改成输入采样时机从SCL低电平改成SCL高电平。改动量不大但调试时要注意读数据的采样点一定要在SCL高电平的中间太早或太晚都会读错。我个人在实际操作中的体会是IIC主机模块的难点不在代码本身而在时序的精确控制和调试工具的使用。你把起始条件、数据位、ACK、停止条件这四个关键点的波形抓清楚剩下的就是体力活了。另外不同从机的时序要求略有差异比如有些从机要求起始条件的保持时间更长有些从机对SCL频率容忍度更低。遇到问题时先查手册再用逻辑分析仪验证最后改代码这个顺序能帮你省下大量时间。