IIC主机写模块Verilog实现:状态机设计与时序调试全解析
1. 从一个真实的调试现场说起为什么主机写模块值得单独拎出来讲搞过FPGA或者数字IC设计的人对IICInter-Integrated Circuit这个协议大概都有一种复杂的情感。它看起来简单——两根线一根SCL时钟一根SDA数据协议规范也就那么几十页。但真正动手写Verilog代码的时候你会发现坑远比想象中多。尤其是主机写模块作为整个IIC通信的发起方它要负责产生起始条件、发送从机地址、处理应答位、逐字节发送数据、产生停止条件每一步的时序都不能出错。我见过不少初学者拿到IIC协议之后第一反应是去网上找一个现成的代码复制过来改改参数就想跑通。结果上板之后发现从机根本不响应用逻辑分析仪抓波形一看要么是起始条件不满足建立时间要求要么是SCL频率太高导致从机跟不上要么是应答位采样时机不对。这些问题归根结底都是因为对主机写模块的内部机制理解不够深入。这篇文章的核心目标很明确把IIC主机写模块的Verilog实现从头到尾讲透。不是给你一个代码让你抄而是让你理解每一段逻辑背后的设计意图知道为什么这么写、不这么写会出什么问题。适合的读者包括正在学习FPGA数字设计的在校学生、需要自己写IIC控制器的嵌入式工程师以及想从模块化角度理解协议实现的从业者。读完之后你应该能够独立写出一个稳定可靠的主机写模块并且知道怎么去调试它。2. IIC主机写模块的功能边界与状态机设计思路2.1 主机写模块到底要干哪几件事在动手写代码之前先把主机写模块的职责范围界定清楚。IIC总线上的主机写操作本质上就是主机向从机发送数据的过程。这个过程可以拆解为以下几个原子操作产生起始条件START在SCL为高电平期间SDA从高变低。这个动作告诉总线上所有从机一次通信即将开始。发送从机地址和写方向位7位地址加1位读写标志位共8位高位先发。写操作的方向位是0。接收从机应答ACK发送完地址后主机释放SDA线在第9个SCL时钟周期读取SDA电平。低电平表示从机应答高电平表示非应答。发送数据字节每个数据字节8位高位先发发完之后同样要接收应答。产生停止条件STOP在SCL为高电平期间SDA从低变高。表示一次通信结束。这五步看起来简单但每一步都有严格的时序要求。比如起始条件的建立时间tSU;STA和保持时间tHD;STA在不同速率模式下有不同的最小值要求。标准模式100kHz下建立时间至少4.7微秒快速模式400kHz下至少0.6微秒。这些参数直接决定了你的分频系数和状态机中各个状态的持续时间。2.2 状态机该怎么划分才合理状态机是主机写模块的核心骨架。我见过两种常见的划分方式一种是按位来分状态比如IDLE、START、SEND_BIT、WAIT_ACK、STOP每个状态只处理一个bit另一种是按字节来分状态比如IDLE、START、SEND_ADDR、WAIT_ACK1、SEND_DATA、WAIT_ACK2、STOP。两种方式都能工作但我更推荐第二种。原因很简单按字节划分的状态机层次更清晰代码可读性更好而且在发送多字节数据的时候更容易扩展。按位划分的状态机虽然看起来更底层但实际上会把位计数和字节计数的逻辑混在一起调试的时候很痛苦。具体来说我通常会把状态机划分为以下几个状态状态名称功能说明持续时间IDLE空闲状态等待启动信号不定START产生起始条件1个SCL周期SEND_ADDR发送7位地址写位8个SCL周期WAIT_ACK1等待地址应答1个SCL周期SEND_DATA发送数据字节8个SCL周期WAIT_ACK2等待数据应答1个SCL周期STOP产生停止条件1个SCL周期这个划分的好处是每个状态的职责非常明确状态之间的跳转条件也很清晰。比如SEND_ADDR状态结束后无条件跳到WAIT_ACK1WAIT_ACK1状态下如果采样到SDA为低应答则根据是否还有数据要发送决定跳到SEND_DATA还是STOP。2.3 时钟分频的策略选择IIC总线的SCL时钟是由主机产生的所以主机写模块内部必须有一个时钟分频器把系统时钟分频到目标SCL频率。这里有一个关键决策分频系数怎么算假设系统时钟是50MHz目标SCL频率是400kHz。那么分频系数 50MHz / 400kHz 125。也就是说每125个系统时钟周期SCL翻转一次。但注意SCL的一个完整周期包括高电平和低电平两部分所以实际上你需要一个计数器从0数到124然后在计数值为0的时候拉高SCL在计数值为62的时候拉低SCL或者反过来。这里有个经验之谈不要直接把分频系数写成固定的数字而是用参数化设计。因为你的系统时钟频率可能变化目标SCL频率也可能需要调整。用parameter定义两个参数CLK_FREQ和IIC_SPEED然后在代码里自动计算分频系数这样代码的可移植性会好很多。parameter CLK_FREQ 50_000_000; // 系统时钟频率 parameter IIC_SPEED 400_000; // IIC目标速率 localparam DIV_CNT CLK_FREQ / IIC_SPEED / 4; // 四倍频采样注意这里我用了四倍频采样也就是说每个SCL的四分之一周期作为一个计数单位。这样做的好处是在SCL的高电平中间和低电平中间都能有明确的采样点方便处理SDA的建立和保持时间。3. 逐段拆解Verilog实现从端口定义到应答采样3.1 端口定义与信号命名规范端口定义看起来是小事但实际上很影响代码的可维护性。我的习惯是把所有端口分成三组系统信号、IIC物理接口、用户控制接口。module iic_master_write ( // 系统信号 input wire clk, input wire rst_n, // 用户控制接口 input wire start, // 启动写操作 input wire [6:0] dev_addr, // 从机地址 input wire [7:0] wr_data, // 待写数据 input wire wr_en, // 数据有效标志 output reg done, // 写操作完成 output reg ack_error, // 应答错误标志 // IIC物理接口 output reg scl, inout wire sda );这里有几个设计决策值得说明。第一sda定义为inout因为IIC是双向总线主机在发送数据时需要驱动SDA在接收应答时需要释放SDA。第二done信号用来通知上层模块写操作已经完成ack_error用来指示从机是否给出了非应答。第三dev_addr和wr_data分开定义而不是打包成一个总线这样上层模块用起来更直观。3.2 SDA三态门的处理技巧IIC的SDA线是双向的这是Verilog实现中最容易出错的地方之一。正确的做法是使用三态门控制reg sda_out; // SDA输出值 reg sda_oe; // SDA输出使能 assign sda sda_oe ? sda_out : 1bz;当sda_oe为1时主机驱动SDA线当sda_oe为0时主机释放SDA线此时SDA由从机驱动或者被上拉电阻拉高。在发送数据位的时候sda_oe应该为1在接收应答位的时候sda_oe应该为0。这里有个坑很多初学者忘记在接收应答时释放SDA线导致主机和从机同时驱动SDA产生总线冲突。表现就是波形上SDA电平异常从机可能直接锁死。所以每次状态机进入WAIT_ACK状态时第一件事就是把sda_oe拉低。3.3 起始条件和停止条件的精确时序起始条件和停止条件的时序要求非常严格因为它们是IIC通信的帧边界。先看起始条件// 起始条件SCL为高时SDA从高变低 // 状态机在START状态下的逻辑 START: begin sda_oe 1b1; if (div_cnt 0) begin scl 1b1; sda_out 1b1; end else if (div_cnt DIV_CNT) begin sda_out 1b0; // SDA拉低产生起始条件 end else if (div_cnt 2*DIV_CNT) begin scl 1b0; // SCL拉低准备发送数据 next_state SEND_ADDR; end end这段代码的关键在于SDA的下降沿必须发生在SCL为高电平期间。所以先拉高SCL等一段时间DIV_CNT个计数再拉低SDA。这个等待时间就是起始条件的建立时间。停止条件反过来SCL为高时SDA从低变高。STOP: begin sda_oe 1b1; if (div_cnt 0) begin scl 1b0; sda_out 1b0; end else if (div_cnt DIV_CNT) begin scl 1b1; end else if (div_cnt 2*DIV_CNT) begin sda_out 1b1; // SDA拉高产生停止条件 end else if (div_cnt 3*DIV_CNT) begin next_state IDLE; done 1b1; end end注意停止条件之后SDA和SCL都回到高电平总线进入空闲状态。这时候done信号拉高通知上层模块可以发起下一次通信了。3.4 数据位的发送与移位逻辑发送数据字节的时候需要一个位计数器来跟踪当前发送到第几位了。我的做法是用一个3位计数器bit_cnt从7数到0。SEND_ADDR: begin sda_oe 1b1; if (div_cnt 0) begin scl 1b0; sda_out addr_buf[bit_cnt]; // 输出当前位 end else if (div_cnt 2*DIV_CNT) begin scl 1b1; // SCL拉高从机采样SDA end else if (div_cnt 3*DIV_CNT) begin scl 1b0; if (bit_cnt 0) begin next_state WAIT_ACK1; end else begin bit_cnt bit_cnt - 1; end end end这里的关键点是SDA在SCL低电平期间变化在SCL高电平期间保持稳定。这是IIC协议的基本要求。所以我在div_cnt为0的时候更新sda_out在div_cnt为2*DIV_CNT的时候拉高SCL这样SDA的建立时间就得到了保证。地址和数据的发送逻辑几乎一样唯一的区别是地址只有7位第8位是写方向位0。所以我在addr_buf里把地址和写位拼在一起addr_buf {dev_addr, 1b0}; // 7位地址 写方向位3.5 应答位的采样时机与去抖处理应答位的采样是主机写模块中最容易出问题的地方。从机在收到8位数据后会在第9个SCL周期拉低SDA表示应答。主机需要在这个周期内采样SDA判断从机是否应答。WAIT_ACK1: begin sda_oe 1b0; // 释放SDA线 if (div_cnt 0) begin scl 1b0; end else if (div_cnt 2*DIV_CNT) begin scl 1b1; // SCL拉高从机输出应答位 end else if (div_cnt 3*DIV_CNT) begin scl 1b0; if (sda_in 1b0) begin // 收到应答 ack_error 1b0; if (wr_en) begin next_state SEND_DATA; end else begin next_state STOP; end end else begin // 非应答 ack_error 1b1; next_state STOP; end end end采样时机选在div_cnt为3*DIV_CNT的时候也就是SCL高电平即将结束的时候。这个时候SDA已经稳定了采样结果最可靠。如果在SCL刚拉高的时候就采样可能会因为从机的输出延迟导致误判。另外如果系统噪声比较大可以考虑对应答位做简单的多数表决去抖。比如连续采样三次取两次相同的结果。不过对于大多数应用场景来说IIC总线的噪声不会太大直接采样就够了。4. 仿真验证与上板调试那些文档里不会写的坑4.1 写一个靠谱的Testbench仿真验证是确保代码正确性的第一道关卡。对于IIC主机写模块Testbench需要做几件事产生系统时钟和复位信号、模拟从机的应答行为、检查主机发出的波形是否符合协议规范。// 模拟从机应答 initial begin sda 1bz; // 等待起始条件 wait(scl 1b1 sda 1b0); // 等待地址发送完成 repeat(8) (posedge scl); // 拉低SDA表示应答 sda 1b0; (negedge scl); sda 1bz; // 等待数据发送完成 repeat(8) (posedge scl); sda 1b0; (negedge scl); sda 1bz; end这个Testbench虽然简单但足以验证主机的基本功能。如果你想更严谨一些可以加入时序检查比如检查起始条件的建立时间是否满足要求。4.2 逻辑分析仪抓波形的正确姿势上板调试的时候逻辑分析仪是必不可少的工具。但很多人抓波形的方式不对导致看到的数据和实际不符。我的经验是采样率要足够高至少是SCL频率的10倍以上。比如SCL是400kHz采样率至少4MHz。如果采样率不够可能会漏掉窄脉冲。触发条件要设对用SDA的下降沿作为触发条件这样可以抓到起始条件。如果想抓特定地址的通信可以用地址作为触发条件。解码器要用对大多数逻辑分析仪都自带IIC解码器但要注意设置正确的地址位数和读写标志位。我遇到过一种情况逻辑分析仪抓到的波形看起来完全正确但实际从机就是不响应。后来发现是因为逻辑分析仪的探头电容太大导致SDA的上升沿变缓从机采样的时候电平还没到高电平门限。换了一根短一点的探头线就好了。所以探头的选择和接线方式也会影响调试结果这一点很多人会忽略。4.3 常见问题排查表现象可能原因排查方法从机完全不响应起始条件不满足时序用逻辑分析仪检查SDA下降时SCL是否为高地址发送后无应答地址错误或从机地址不匹配确认从机地址是否正确检查写方向位数据字节发送后无应答从机缓冲区满或地址错误检查从机数据手册确认写入地址是否合法SCL频率不对分频系数计算错误重新计算DIV_CNT确认系统时钟频率SDA一直为低总线冲突或从机拉死总线检查sda_oe信号确认主机是否正确释放SDA通信偶尔失败时序余量不足或噪声干扰降低SCL频率增加上拉电阻阻值这张表是我在实际项目中总结出来的基本上覆盖了80%以上的常见问题。遇到问题的时候按照表格逐项排查通常都能找到原因。4.4 上拉电阻的选择与总线电容的影响IIC总线的SDA和SCL都需要上拉电阻这是硬件设计的基本要求。但上拉电阻的阻值选择是有讲究的。阻值太小功耗大而且从机的灌电流能力可能不够阻值太大上升沿变缓高速通信时可能来不及拉高。一般来说标准模式100kHz下上拉电阻取4.7kΩ到10kΩ快速模式400kHz下取1kΩ到4.7kΩ。如果总线上的设备比较多总线电容增大上升时间会变长这时候需要减小上拉电阻。上升时间的估算公式是t_r ≈ 0.847 × R × C其中R是上拉电阻C是总线电容。比如R4.7kΩC100pF那么t_r ≈ 0.4微秒。对于400kHz的快速模式SCL的高电平时间只有1.3微秒左右0.4微秒的上升时间是可以接受的。但如果C增大到200pF上升时间就变成0.8微秒了余量就不够了。5. 从单字节到多字节主机写模块的扩展思路5.1 多字节连续写的状态机改造前面讲的是单字节写操作。实际应用中经常需要连续写多个字节比如向EEPROM写入一页数据。这时候状态机需要做一些改造。最简单的做法是在SEND_DATA和WAIT_ACK2之间加一个判断如果还有数据要发就回到SEND_DATA如果没有了就跳到STOP。这需要一个数据计数器或者一个FIFO来缓存待发送的数据。WAIT_ACK2: begin // ... 应答采样逻辑 ... if (ack_ok) begin if (data_cnt data_len) begin next_state SEND_DATA; data_cnt data_cnt 1; end else begin next_state STOP; end end else begin next_state STOP; end end这里data_len是上层模块告诉主机要发送多少个字节。data_cnt是当前已经发送的字节数。每次收到应答后如果还没发完就继续发下一个字节。5.2 超时检测与总线恢复机制在实际系统中从机可能会因为各种原因不给出应答比如从机正在忙、从机被复位了、总线被拉死了。如果主机一直等下去整个系统就卡住了。所以一个健壮的主机写模块必须包含超时检测机制。我的做法是加一个超时计数器在WAIT_ACK状态下如果超过一定时间还没有收到应答就强制产生停止条件并报错。// 超时计数器 reg [15:0] timeout_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin timeout_cnt 16d0; end else if (state WAIT_ACK1 || state WAIT_ACK2) begin if (timeout_cnt 16hFFFF) begin timeout_cnt timeout_cnt 1; end end else begin timeout_cnt 16d0; end end超时阈值的选择取决于SCL频率。比如SCL是400kHz一个SCL周期是2.5微秒那么超时阈值设为100微秒就足够了。对应到50MHz的系统时钟就是5000个周期。如果检测到超时除了报错之外还应该尝试恢复总线。恢复的方法很简单主机发送9个SCL脉冲然后发送一个停止条件。这样可以把被拉死的从机复位。5.3 参数化设计与代码复用一个好的主机写模块应该是参数化的方便在不同的项目中复用。除了前面提到的CLK_FREQ和IIC_SPEED还可以把地址位宽、数据位宽做成参数。虽然IIC标准规定地址是7位、数据是8位但有些变种协议可能会用到10位地址。parameter ADDR_WIDTH 7; parameter DATA_WIDTH 8;这样代码的通用性会更强。当然参数化也要适度不要为了参数化而参数化。如果一个参数在实际项目中从来不会变那就没必要做成参数。6. 写在最后一些个人体会IIC主机写模块的Verilog实现说难不难说简单也不简单。核心就是状态机加时钟分频但细节上的坑非常多。我自己的经验是先把单字节写操作调通再扩展到多字节。不要一上来就想着支持所有功能那样只会让自己陷入调试的泥潭。另外仿真验证一定要做充分。很多人觉得仿真太慢直接上板调试。但上板调试的效率其实更低因为你看不到内部信号只能靠逻辑分析仪猜。仿真虽然慢但你可以随时暂停、查看任何信号的值定位问题的速度快得多。最后说一个容易被忽略的点复位逻辑。IIC主机写模块的复位不仅仅是把状态机清零还要把SCL和SDA都释放到高电平。如果复位的时候SCL或者SDA被拉低总线上的其他设备可能会误判为起始条件。所以复位的时候一定要确保sda_oe为0、scl为1。这个模块后续还可以扩展的方向包括支持10位地址、支持高速模式3.4MHz、加入FIFO缓冲、支持DMA传输等。但不管怎么扩展核心的状态机和时序逻辑是不变的。把基础打牢后面的扩展就是水到渠成的事情。