FPGA实战:基于状态机的I2C从机设计核心细节与验证技巧

发布时间:2026/9/2 5:13:28
FPGA实战:基于状态机的I2C从机设计核心细节与验证技巧 简介一套面向 Verilog 数字逻辑学习者与 FPGA 初学者的 I2C 从设备I2C slave设计实现包目标是解决 I2C 总线从机时序控制与数据通路设计的核心问题。内容以 Verilog 源码为主围绕 SCL 边沿检测、状态机切换、数据缓冲、地址解析、应答位生成和错误恢复等关键模块展开同时给出 i2cAES 相关端点与 AES 子模块如 mixc_mul设计便于理解加密传输与 I2C 通信的结合方式。压缩包共 161 个文件、1.39MB除 .v/.vhd 源码外还包含 .prj 等 Xilinx/ModelSim 工程文件、.dat/.bin 仿真数据、.log/.xreport 报告日志以及 .bak 历史备份资源包内各类辅助文件齐全适合按模块对照学习与排错。已有 577 人学习下载。读者可借此快速搭建 I2C slave 验证环境掌握主从通信中的 ACK/NACK 时序、读写操作与状态机设计方法并从工程组织方式中积累可复用的调试思路。1. 需求拆解与整体架构设计1.1 I2C从机到底在解决什么问题I2C从机slave虽然代码量不大但设计难度一点都不比主机端低。主机端的事务是自己发起的节奏完全由自己控制而从机端是被动响应方外部时钟和数据的到来时机完全不可控主机可能在任意时刻发起通信、任意时刻终止通信从机必须在这条两线总线上“随机应变”。做一个可靠的i2c slave本质上就是在受限的资源里把一个外部异步事件驱动的协议栈转化为内部同步可控的读写逻辑。以最常见的7位地址模式为例从机需要处理的事情包括检测START和STOP条件、对比地址、产生ACK/NACK、接收写入的数据字节、发送读出的数据字节、处理主机端的重复起始条件Sr等。听起来不多但每个环节都对时序有硬性要求。比如SDA在SCL高电平期间必须保持稳定只有在SCL低电平期间才能变化又比如从机响应ACK时需要在一个严格的窗口内把SDA拉低早了会破坏数据晚了主机已经采样结束等于没应答。这些细节决定了从机模块不能只停留在“大概能用”的程度必须把每个时序窗口都吃透。我见过很多刚从MCU裸机开发转到FPGA/RTL设计的工程师第一版i2c slave往往会把“检测到地址匹配之后直接进入一个循环把数据读进来”这种思路带到RTL里结果综合出来的逻辑要么状态机漏状态要么hold timing一团糟。原因很简单MCU里跑I2C从机是软件顺序执行的状态天然有弹性而RTL是并行逻辑所有状态跳转和数据通道需要显式描述清楚否则综合器和时序分析根本不知道你想干什么。1.2 架构选型状态机为什么是主流方案设计i2c slave的架构方案我总结下来大致有三类纯状态机方案、计数器辅助状态机方案、内嵌微控制器方案。三类方案各有适用场景但绝大多数场景下纯状态机配合字节计数器是最稳妥的选择。纯状态机方案的核心思想是把协议过程拆成有限个稳定状态IDLE、地址匹配、ACK、数据接收/发送、停止位等每个状态对应一到两个SCL周期。优点是时序完全确定每一个SCL边沿做什么事都在RTL里显式写清楚了方便做静态时序分析也方便仿真验证。缺点是当需要处理大量寄存器的时候状态数会膨胀代码容易变得很长。计数器辅助状态机方案是在状态机的基础上增加一个bit计数器和byte计数器状态机只关注“当前字节的第几位”“当前字节是第几个字节”这两个维度。实际工程里我基本都用这种写法因为I2C协议里“过程”是分层的bit是底层节拍byte是上层单位寄存器地址和数据的语义都建立在byte层面。计数器可以把底层bit的重复劳动收拢到一起让状态机只处理“这个字节是地址还是数据”“这个数据是写入还是读出”这些高层逻辑。内嵌微控制器方案比如在FPGA里放一个软核靠软件模拟I2C从机只适合极少数需要复杂协议扩展的场景比如需要支持类似SMBus的PEC校验告警、需要动态处理多页寻址等。日常的寄存器配置类从机温度传感器、EEPROM、电源管理芯片等用状态机方案两三百行RTL就能搞定资源开销低时序又可控完全不必要上软核。1.3 模块划分与接口定义我习惯把i2c slave设计拆成三个子模块尽量保持单一职责。第一个是i2c_slave_core负责协议解析输出读写请求和字节流第二个是register_file负责寄存器读写语义这部分通常是项目定制的比如配置寄存器、状态寄存器等第三个是顶层wrapper负责时钟域处理、上下拉配置和外部IO连线。如果设计中还涉及中断我一般会在register_file里加中断状态和使能寄存器不在core层处理。接口定义上推荐把SDA分成sda_in、sda_out、sda_oe三根内部信号而不是直接在顶层用一根inout连到core。这样做的原因是内部逻辑本身没有真正的三态门三态控制需要在顶层IO buffer上做把SDA拆成三根信号后core里只需要维护sda_oe和sda_out即可sda_in永远读的是外部引脚的电平逻辑会清晰很多。SCL则直接作为输入只在内部做同步处理。下面是我常用的接口表格新的设计基本都复用这套定义信号名方向功能说明sclinI2C时钟需先打拍同步sda_ininSDA电平采样输入打拍后sda_outoutSDA驱动数据输出sda_oeoutSDA输出使能1为驱动总线scl_fallingout内部产生的SCL下降沿脉冲scl_risingout内部产生的SCL上升沿脉冲dev_addr[6:0]in从机地址配置可静态或动态reg_addr[7:0]out当前访问的寄存器地址wdata[7:0]out写入数据wr_pulseout写寄存器脉冲rd_data[7:0]in读取数据来自寄存器文件rd_reqout读请求脉冲这样的接口划分有一个好处就是core层完全不关心寄存器文件具体有多少寄存器、地址怎么编码只负责把字节流和读写信令输出。验证的时候可以直接用一个假的寄存器文件比如RAM替换很快能跑通协议层面的测试。2. 核心细节解析与实操要点2.1 输入同步与毛刺过滤别让亚稳态毁了你的第一版I2C是典型的异步总线外部SCL和SDA相对内部时钟完全是异步的所以进RTL之后第一件事不是去检测边沿而是先同步。我做过的小项目里最容易被忽视的就是这个环节有人直接拿外部SDA去触发状态机结果仿真波形无比漂亮一上板子就乱跳最后定位到是亚稳态和毛刺问题。标准做法是外部scl和sda各打两拍同步同步后的信号再送进边沿检测逻辑。如果内部时钟频率够高建议在打拍之后加一个简单的数字毛刺滤波用内部时钟对同步后的scl连续计数只有当scl连续保持某个电平超过N个周期比如3~5个周期才认为电平有效。这个滤波能滤掉总线上的窄毛刺尤其是当I2C线束较长、靠近电机或电源开关时这种毛刺非常常见。毛刺滤波的计数长度不是随便定的需要和内部时钟频率及I2C速率匹配。比如内部时钟50MHz、SCL跑400kHz一个SCL高电平时间约1.25us对应62个内部时钟周期这时把滤波窗口设成5个周期完全没问题不会把正常电平误滤掉。但如果内部时钟只有2MHz、SCL也跑400kHz一个高电平只有5个周期滤波窗口设3个周期就已经很极限了再大就会把有效边沿吃掉。所以滤波深度的选择要按“内部时钟频率 / I2C速率”来算留出至少5倍以上的余量。同步和滤波完成之后scl和sda都变成了内部时钟域的干净信号接下来才能做边沿检测。我习惯在module里统一产生scl_rising和scl_falling两个单周期脉冲后续所有状态机都以这两个脉冲为节拍信号。这样状态机的敏感列表直接是内部时钟但只有在scl边沿到来那一拍才会真正跳转既保证了同步性又让代码可读性高很多。2.2 起始/停止条件的实时检测I2C的START条件定义是SCL为高电平期间SDA发生一个高到低的跳变。STOP条件则是SCL为高电平期间SDA发生一个低到高的跳变。这两个条件的关键在于“SCL为高电平期间”所以检测逻辑可以这样设计当scl同步信号为高时对sda信号做边沿检测检测到下降沿就产生start_pulse检测到上升沿就产生stop_pulse。这里的坑在于START/STOP可能发生在任何总线状态包括从机正在发送数据、正在响应ACK的时候。比如主机在写数据的某个bit中间突然发一个STOP从机必须立刻回到IDLE状态把当前传输的一切上下文都清掉。所以start_pulse和stop_pulse的优先级要高于状态机的正常跳转逻辑最好在状态机的每一个分支里都优先判断这两个脉冲。还有一个小细节START条件本身也是一次“SDA变化”如果在从机发送数据的字节中间出现了非预期的START那其实就是主机要重新发起一次传输重复起始条件。这时从机需要回到地址接收阶段而不是回到完全IDLE因为整个传输还没有结束。正确的做法是检测到start_pulse时无论当前状态是什么都跳回地址检测状态同时清掉字节计数器和读写方向标志。2.3 地址比较与第一字节处理7位地址模式下主机发送的第一个字节由7位从机地址加1位读写标志LSB构成。0x1E这类地址实际总线上是0x3C写或0x3D读很多新手在这里犯迷糊寄存器里面配的DEV_ADDR到底要不要左移一位我的经验是在core内部保存的dev_addr_cfg[6:0]就是纯7位地址不做移位。接收到第一个字节后解析时取rx_byte[7:1]作为收到的地址rx_byte[0]作为读写方向。这样配置从机地址的时候直接写0x1E就是0x1E代码里对比也直观。如果你偏要在外部配置时左移一位也不是不行但一定要在寄存器说明里写清楚否则后续维护的人一定会踩坑。地址对比通过后需要在第9个SCL周期ACK周期拉低SDA。这个ACK时序的实现要点是在ADDR状态的bit_counter 8时即第8个数据位采样完成后把sda_oe拉高、sda_out拉低然后在第9个SCL时钟的下降沿之后的下一个状态再把sda_oe释放拉高让外部上拉电阻把SDA拉回高电平。简单说ACK在SCL高电平期间要稳定为低释放也要在SCL低电平期间做不要在SCL高电平期间去翻转sda_oe否则会被主机误判为数据。2.4 ACK与数据移位逻辑从机接收数据时每个字节是在SCL上升沿被采样SDA电平必须在SCL高电平期间保持稳定。所以数据接收逻辑应该以scl_rising为节拍在每个上升沿把sda_in移入移位寄存器。移位方向是MSB先接收也就是sda_in移入寄存器的最高位然后整个寄存器右移。等到bit_counter从0数到7shift_reg[7:0]刚好是一个完整的字节这时在下一个bit周期第9个bit拉低SDA表示ACK。从机发送数据时方向相反每个bit要在SCL下降沿之后、SCL高电平到来之前把数据放到SDA上。这里有个重要的时序经验SDA数据更新一定要用scl_falling来触发在SCL低电平期间就把SDA切到下一bit数据。不要等到SCL上升沿再去切SDA那样SDA在SCL高电平期间还在变化主机采样到的数据就错位了。读多字节时主机在最后一个字节结束后会发送NACK表示“别再发了我要停了”。从机需要检测这个NACK并在下一个STOP或START到达时退出发送状态。NACK检测的方法很简单在第9个bit的SCL高电平期间采样sda_in如果为高说明主机没有拉低SDA从机就知道传输要结束了。这个逻辑要放在发送状态的ACK/NACK采样分支里不要漏掉否则会出现“从机一直发、主机一直收错”的诡异现象。3. 实操过程与核心环节实现3.1 RTL代码骨架三段式状态机下面给出一个简化但完整的i2c slave core RTL骨架重点展示状态机的组织方式。代码故意省略了寄存器文件部分只保留协议解析层方便你把它嵌入到自己的设计里。module i2c_slave_core #( parameter DEV_ADDR 7h0F )( input wire clk, input wire rst_n, input wire scl_i, input wire sda_i, output reg sda_o, output reg sda_oe, output reg [7:0] reg_addr, output reg [7:0] wdata, output reg wr_pulse, output reg rd_req, input wire [7:0] rd_data ); // 同步打拍 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_i; scl_sync2 scl_sync1; sda_sync1 sda_i; sda_sync2 sda_sync1; end end // 边沿检测 wire scl_rising scl_sync2 ~scl_sync1; wire scl_falling ~scl_sync2 scl_sync1; wire start_pulse scl_sync2 ~sda_sync2 (sda_sync1); wire stop_pulse scl_sync2 sda_sync2 ~sda_sync1; // 状态定义 localparam IDLE 3d0; localparam ADDR 3d1; localparam ACK1 3d2; localparam DATA 3d3; localparam ACK2 3d4; localparam SEND 3d5; reg [2:0] state, next_state; reg [2:0] bit_cnt; reg [7:0] shift_reg; reg rw_flag; reg nack_flag; // 状态跳转 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else if (start_pulse) state ADDR; else if (stop_pulse) state IDLE; else state next_state; end // 次态逻辑节拍由scl_rising / scl_falling控制 always (*) begin next_state state; case (state) IDLE: if (start_pulse) next_state ADDR; ADDR: if (scl_rising bit_cnt 3d7) next_state ACK1; ACK1: if (scl_falling) next_state rw_flag ? SEND : DATA; DATA: if (scl_rising bit_cnt 3d7) next_state ACK2; ACK2: if (scl_falling) next_state rw_flag ? SEND : DATA; SEND: if (scl_rising bit_cnt 3d7) next_state ACK1; default: next_state IDLE; endcase end // 时序逻辑移位、bit计数、读写寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) begin bit_cnt 3d0; shift_reg 8d0; rw_flag 1b0; wr_pulse 1b0; rd_req 1b0; sda_o 1b1; sda_oe 1b0; end else begin wr_pulse 1b0; rd_req 1b0; if (start_pulse) begin bit_cnt 3d0; sda_oe 1b0; sda_o 1b1; end else begin case (state) ADDR: begin if (scl_rising) begin if (bit_cnt 3d7) bit_cnt bit_cnt 3d1; shift_reg {shift_reg[6:0], sda_sync2}; end end ACK1: begin bit_cnt 3d0; sda_oe 1b1; sda_o 1b0; // 产生ACK if (scl_falling) begin if (rw_flag) rd_req 1b1; // 从移位寄存器解析地址或数据 if (rw_flag) begin shift_reg rd_data; end end end DATA: begin if (scl_rising) begin if (bit_cnt 3d7) bit_cnt bit_cnt 3d1; shift_reg {shift_reg[6:0], sda_sync2}; end end ACK2: begin bit_cnt 3d0; sda_oe 1b1; sda_o 1b0; if (scl_falling) begin wr_pulse 1b1; wdata shift_reg; reg_addr reg_addr; // 由上层寄存器文件更新 end end SEND: begin if (scl_falling) begin if (bit_cnt 3d7) begin bit_cnt bit_cnt 3d1; shift_reg {shift_reg[6:0], 1b0}; sda_o shift_reg[7]; end else begin sda_oe 1b0; // 释放SDA准备接收NACK sda_o 1b1; end end end endcase end end end endmodule这个骨架虽然简化了但把关键跳转都保留下来了。需要说明的是状态机跳转在scl_rising / scl_falling脉冲下进行但状态寄存器本身由内部时钟驱动所以不能直接写成“只在scl_rising时跳转”的样子而是要在case里判断对应脉冲。上电后主机会先发START所以从机在IDLE状态的时长由主机决定没有任何问题。3.2 写时序与寄存器更新逻辑写方向上主机发出START后先发从机地址写标志比如0x3C从机ACK后再发寄存器地址字节从机ACK再发数据字节从机ACK主机发STOP。这里有个容易忽略的点第一个字节之后后续所有字节协议上是一样的但语义完全不同——第二个字节是寄存器地址第三个字节才是写入该寄存器的数据。所以master存储区必须维护一个“字节序列”的概念。实现上我一般用一个address_phase标志初始为1表示下一个数据字节是寄存器地址。当收到寄存器地址后address_phase清零并把地址锁存到reg_addr。之后到达的每一个数据字节都作为写数据产生wr_pulse的同时把reg_addr对应寄存器的值更新。如果设计支持自增像EEPROM那样连续写还要在每次wr_pulse后把reg_addr加1。wr_pulse这个信号的产生时机要特别注意。我习惯在ACK2状态的scl_falling沿产生wr_pulse因为这个时刻刚好是第9个bitACK bit结束、字节数据已经完全确定且从机已经拉低SDA应答了。这拍产生写脉冲寄存器文件在下一拍上升沿把wdata锁存进去时序刚好对得上。如果你在DATA状态的最后一个scl_rising就产生wr_pulsedata可能还没完全移位完会引入竞争冒险。3.3 读时序与数据回读读操作序列是START 从机地址读标志如0x3D从机ACK后主机直接开始读取数据字节。如果是要先写寄存器地址再读通常需要一次重复起始条件Restart先写地址写标志写入寄存器地址然后发Restart再发地址读标志。所以从机在有Restart输入时必须在start_pulse中清掉address_phase但要注意保留上一次写入的reg_addr不能连寄存器地址也一起清了。读数据的时候从机的SEND状态在第一个bit就要把寄存器文件返回的数据放到SDA上。我的做法是在ACK1状态响应地址后的scl_falling沿给寄存器文件发rd_req脉冲寄存器文件在下一拍返回rd_data然后在SEND的第一个bit周期把rd_data放入移位寄存器。最后一个字节发送完成后主机返回NACK。从机在SEND状态的第9个bit高电平期间采样sda_in如果为高表示主机不想再收了。此时从机要继续等待STOP或START不能立即回IDLE。一旦检测到STOP就彻底回到IDLE检测到START则回到ADDR阶段等待新的地址。这个逻辑在状态跳转的start_pulse/stop_pulse分支里已经覆盖了唯一要确认的是nack_flag不要影响下一次传输的初始状态。3.4 在FPGA上验证的最小测试流程写完RTL验证是重头戏。我习惯用一套带I2C master BFM总线功能模型的testbench来跑。BFM内部是一个简单的状态机能发START、发字节、收字节、发ACK/NACK、发STOP。testbench里配置好DEV_ADDR后依次发起四种典型操作单字节写、连续写两字节、当前地址读、先写地址再读。仿真时最值得关注的是两类信号一是sda_oe和sda_o的组合是否符合预期这决定了外部上拉电阻能否正确把总线拉回高二是状态机跳转时刻和scl边沿是否严格对应。我一般会在testbench里加入自动检查每个SCL高电平期间scl_sync2为1如果sda_in发生了变化就报错。这个检查能把SDA在SCL高电平期间抖动的问题在第一时刻抓住。在FPGA上实测时要注意IO约束。SCL和SDA建议设置在同一个bankIO电平标准按板卡实际电压3.3V或1.8V配置并且加上内部上拉如果外部没有上拉电阻的话。SDA作为inout端口在约束文件里要设置为open-drain模式很多FPGA的IO primitive需要单独例化不能直接写一个inout就完事否则综合时会报错或者行为异常。4. 常见问题与排查技巧实录4.1 SDA被拉死、总线“短路”的常见原因现象上电后逻辑分析仪抓SCL有正常的脉冲但SDA一直是低主机的访问永远不会成功。排除了外部上拉电阻虚焊后问题基本发生在RTL内部从机的sda_oe被错误拉高且sda_out为低导致从机一直占着总线。最常见的原因有两个一是状态机在IDLE状态下没有把sda_oe复位为0仿真时看起来没问题因为仿真里SDA默认是高阻但综合到FPGA后IO输出可能保持上一个状态二是检测到STOP时没有把一切状态清干净特别是sda_oe和sda_o导致下一轮START到来时总线还是被从机拉着。排查技巧在testbench里给SDA加上仿真上拉模型比如用pullup语句或者将一个10k电阻模型接到SDA然后观测sda_oe信号一旦sda_oe被拉高且sda_out为低立刻停止仿真查看状态机当前状态。我通常会在sda_oe拉高超过9个SCL周期时打印一条警告这种断言能在验证早期就把问题扼杀。4.2 读数据最后一个字节多了一个时钟现象主机读N个字节前N-1个字节完全正常最后一个字节读完后从机又额外输出了一位0导致下一帧数据的对齐全部错乱。这个问题几乎都是因为最后一个字节的NACK处理出了问题。主机在最后一个字节后返回NACK从机收到NACK后应该在下一个bit周期就把SDA释放并停止发送。但很多初版代码里从机在NACK之后仍然在scl_falling沿继续把sda_o切到下一个bit导致主机看到多余的一位。修正方法是在SEND状态的bit_cnt等于7且在第9个bit检测到NACK时直接把sda_oe清零、sda_o拉高并且阻塞后续的移位操作。同时可以顺手清掉bit_cnt让状态机进入等待STOP的静默状态。这里不要依赖主机的STOP来解决因为如果紧接着是Restart主机不会发STOP你必须在NACK后立即收手。4.3 高频SCL下出现建立时间不足这个坑我在400kHz转1MHz的项目里遇到过。现象是仿真一切正常板子上一跑主机偶尔读回错误数据且错误bit位置不固定。用示波器抓SDA会发现SCL下降沿之后SDA并不是立刻更新而是延迟了将近半个SCL周期才变导致SCL上升沿到来时SDA还没有稳定。根因是我在SEND状态用了组合逻辑把shift_reg[7]直接输出到sda_out而shift_reg的更新依赖scl_falling组合逻辑链上还有几级判断整体延迟累加后超过了时序预算。解决办法有两条路第一在scl_falling到来之前提前一拍把数据更新到输出寄存器让SDA在SCL下降沿后立刻稳定第二SEND状态下不要用组合逻辑直接输出而是增加一级寄存器专门做SDA输出锁存。对于低速I2C100k/400k组合逻辑直出问题不大但跑1MHz以上时一定要提前做寄存输出。4.4 和上拉电阻、总线电容相关的经验I2C总线是开漏结构上拉电阻的取值直接影响信号边沿的上升时间。电阻太大RC常数大SCL/SDA上升沿变得平缓在高速模式Fast-mode Plus 1MHz下会出现边沿不符合规格的情况甚至被从机毛刺滤波误判成多个脉冲。电阻太小总线灌电流过大低电平噪声和功耗飙升。我一般按总线电容估算标准模式100kHz建议上拉4.7k~10kFast模式400kHz建议2.2k~4.7kFast Plus 1MHz建议1k~2.2k。总线上每多挂一个设备电容会增加几pF到十几pF板级布线过长的话还得多留余量。调试时如果遇到不明原因的数据错误先拿示波器看SDA的上升沿如果上升沿时间超过1us400kHz下多半是上拉电阻选大了。4.5 总线异常后从机“锁死”的恢复策略I2C协议没有超时机制如果主机在传输中途掉电或者死机总线可能停留在SCL低或SDA低的中间态从机如果一直等某个SCL边沿就会卡死在当前状态。这在单板系统中影响不大但在热插拔或电源域独立的系统里总线异常恢复是必须考虑的问题。我的做法是在core里加一个超时计数器统计从最近一次有效SCL边沿scl_rising或scl_falling到当前的时间如果超出设定阈值比如25ms远大于任何正常I2C传输的位时间就强制把状态机复位到IDLE。这样即使总线被拉死从机也能在下一轮START到达时正常响应。具体阈值要根据系统里最慢的I2C速率来定如果总线上挂了实时性要求高的传感器阈值可以设成100ms避免误复位正常但慢速的传输。5. 从工程角度再聊几句写i2c slave的这些年我最大的体会是协议本身不难难的是把边界情况想全。数据正常收发谁都能写出来但START/STOP在任意bit位置到来、主机中途放弃传输、地址匹配后NACK、总线上多个从机同时响应这些异常场景才是真正拉开设计水平的差距。如果你是从零开始做建议先用100kHz模式把功能跑通再加毛刺滤波和超时复位最后再优化到400kHz/1MHz的时序。一步一步来每一个阶段都有明确的验证手段调试起来会轻松很多。仿真验证的时候不要把SPICE级的上拉模型做得太理想真实总线有噪声有电容testbench里模拟得越贴近实际上板后面临的意外就越少。最后再分享一个小技巧调试I2C时把SCL和SDA都引到示波器或逻辑分析仪上同时让排查软件能把RTL内部的state和sda_oe信号一起抓出来。这样协议波形和内部状态一一对应定位问题基本是分钟级的事。你要是手里有支持交叉触发的逻辑分析仪体验会更好可惜多数项目里我只能靠仿真波形硬扛后来养成了“RTL内部状态信号都要testbench导出”的习惯算是我踩过无数次坑之后的保留节目了。本文还有配套的精品资源点击获取