ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

主模式RTL设计:状态机与三态驱动的完整实现指南

主模式RTL设计:状态机与三态驱动的完整实现指南 前阵子帮一个团队查 I2C 总线偶发锁死最后定位到根因并不是协议层的问题而是 RTL 状态机复位策略和三态驱动配合失当主机在 ACK 阶段误判了从机应答输出使能没有及时释放SDA 一直被拉着低电平外部上拉电阻完全救不回来。这个案例让我想起很多刚开始接触“主模式”设计的工程师都会卡在同一个地方——明明状态机跳转是对的但一接上真实总线就出问题。借“第05讲主模式 RTL 设计——从状态机到三态驱动”这个题目我把从状态机骨架到 inout 端口驱动这条线完整捋一遍。内容适合正在学 FPGA / 数字 IC 设计的学生、准备笔试面试的校招同学以及写了不少逻辑但总在三态端口上翻车的在职工程师。1. 主模式设计的底层逻辑状态机为什么绕不开1.1 主模式到底在忙什么主模式听起来抽象落到 RTL 里其实就是三件事按协议把总线时序处理得可预测、管理好总线的“使用权”、以及确保在需要释放时真的把引脚放开。以最常见的 I2C 主模式为例主设备负责产生 SCL 时钟发起 START / STOP决定传输方向和从机地址。SCL 由谁拉起、SDA 上的应答信号怎么判断这些都不是简单的“输出一个字节”而是一个阶段化的流程总线空闲、起始、发送地址、等待应答、传数据、结束。阶段之间被明确的事件隔开比如“SCL 高电平期间 SDA 被拉低”就是一个 START 事件。这种离散事件驱动结构正好是时序状态机的舒适区。用状态机表达是因为每个周期都可以重新评估“现在处于什么阶段、下一步该做什么”这和硬件时钟天然匹配。1.2 一段式、二段式与三段式怎么选RTL 里状态机主要有三种写法很多人面试时背过定义但到实际项目里就不知道用哪种。一段式把状态转移和输出写进同一个 always 块代码最短适合极小的模块但输出往往直接从组合逻辑出来状态跳变沿上很容易出现毛刺。二段式把状态寄存器和组合逻辑拆开面积省也比一段式清晰但输出仍可能带组合竞争需要小心阻塞赋值和非阻塞赋值的位置。三段式则把状态寄存器、次态组合逻辑、输出时序逻辑完全拆成三块是我在主模式控制器里的默认选择。原因在于主模式输出直接驱动总线SCL 和 SDA 上的一点毛刺都可能被外部从机误判成 START 或 STOP。三段式把输出寄存一拍总线信号在状态跳变沿处稳定得多。调试时也直观波形里能看到输出相对于状态跳变晚一拍不会出现毫无来由的跳变。顺便提一句软件里写状态机可以用 switch 硬扛甚至用 Java 的枚举类做状态模式但 RTL 状态机每个周期都在跑还要考虑综合器会怎么实现要求比软件严格得多。1.3 画状态图时要有“环岛思维”有段时间网上常提“环岛状态机”这个词我第一次看到还愣了一下后来发现用它类比 RTL 状态机特别贴切。状态机就像城市环岛环岛不允许你停在路中间每个路口必须有明确的出口如果某个方向没有车过来车辆默认在当前状态继续转圈。RTL 状态机也是如此每个状态都必须有完整的转移条件不满足条件时默认回到自身。这就是我常说的“默认转移”概念case 里每个分支都要先赋当前状态否则综合工具会认为存在“未定义路径”插入不必要的 latch。设计状态图时我习惯额外画出“非法状态回到 IDLE”的路径并配一个总线超时计数器。主模式最怕状态机跑到一个没有出口的状态里总线锁定板卡上其它设备全部跟着遭殃。这些细节看起来不起眼却是一版 RTL 能不能从仿真顺利跑到板上的分水岭。2. 状态机的编码、转移条件与输出控制2.1 状态定义与转移条件以 I2C 主模式控制器为例状态集合通常是IDLE、START、SEND_ADDR、ACK_CHECK、SEND_DATA、RECV_DATA、STOP。不要试图把每一个 bit 都设计成一个状态那样状态数量爆炸维护成本极高。正确做法是用状态机管宏观阶段用移位寄存器和 bit_cnt / byte_cnt 管位计数。状态主模式动作转移条件IDLE释放总线等待命令start1 → STARTSTARTSCL 高电平期间拉低 SDA产生起始条件时序完成 → SEND_ADDRSEND_ADDR逐位发送地址和方向位bit_cnt 到 0 → ACK_CHECKACK_CHECK释放 SDA采样从机应答ack0 → 继续传输ack1 → STOPSEND_DATA逐位发送数据字节bit_cnt 到 0 → ACK_CHECKRECV_DATA逐位接收数据字节并决定 ACK / NACKbit_cnt 到 0 → STOPSTOPSCL 高电平期间拉高 SDA产生停止条件完成 → IDLE这里最容易被忽略的是 ACK_CHECK 状态进入该状态前就必须把 sda_oe 释放也就是把总线控制权交出去否则从机根本没有能力把 SDA 拉低。很多第一版 RTL 都栽在这里后面三态部分我会专门展开。2.2 转移条件里的仲裁与时钟拉伸主模式不等于“想干嘛就干嘛”还要处理两种例外多主仲裁和时钟拉伸。多主环境下当主机试图发送高电平但总线上被另一个主机拉着低电平时说明仲裁失败必须立即放弃控制权回到 IDLE。这个检测点要放在 SCL 高电平的采样窗口每个 bit 都做比较不能只在数据中间查一次。时钟拉伸则是从机需要更多时间把 SCL 拉低主机必须等待 SCL 真正回到高再继续。很多初版代码的毛病是 scl_oe 一直有效SCL 被强驱动从机根本拉不动。真正的 I2C 控制器里 SCL 也应该做成三态或开漏驱动这个点在后面三态部分会重点说明。另外外部引脚信号属于异步输入进入状态机前建议先做两级同步避免亚稳态导致状态跳错。2.3 状态编码独热、二进制还是格雷码主模式状态数量通常在十几个以内FPGA 上我一般用独热码。独热码每个状态对应一个独立寄存器位译码逻辑最简单时序路径短代价是寄存器占用更多。ASIC 项目则更多用二进制编码省寄存器面积但组合译码复杂一些。格雷码适合跨时钟域场景——每次跳变只有一位翻转但主模式 FSM 通常在一个时钟域内工作优势不明显而且编码映射容易把人绕晕。状态参数建议统一用 localparam 命名不要写魔法数字。复位值必须明确不能依赖上电初值。FPGA 上电后寄存器值不固定如果状态机没有异步复位或同步复位第一个周期就可能跑飞。主模式控制器尤其要注意上电复位时所有 OE 都要为 0总线保持释放状态。2.4 三段式 FSM 骨架下面给一个教学用的三段式骨架重点看结构协议细节用注释代替。这个写法可以直接套到大多数主模式控制器里。module main_mode_fsm_skeleton #(parameter IDLE3d0, START3d1, SEND_ADDR3d2, ACK_CHECK3d3, SEND_DATA3d4, STOP3d5)( input wire clk, rst_n, input wire start, ack_in, bit_done, output reg fsm_busy, scl_oe, sda_oe, sda_out ); reg [2:0] state, next_state; // 第一段状态寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end // 第二段次态组合逻辑 always (*) begin next_state state; // 默认回到当前态防止 latch case (state) IDLE: if (start) next_state START; START: if (bit_done) next_state SEND_ADDR; SEND_ADDR: if (bit_done) next_state ACK_CHECK; ACK_CHECK: next_state ack_in ? STOP : SEND_DATA; SEND_DATA: if (bit_done) next_state ACK_CHECK; STOP: next_state IDLE; default: next_state IDLE; endcase end // 第三段时序输出按下一状态控制总线 always (posedge clk or negedge rst_n) begin if (!rst_n) begin fsm_busy 1b0; scl_oe 1b0; sda_oe 1b0; sda_out 1b1; end else begin case (next_state) IDLE: begin fsm_busy 1b0; scl_oe 1b0; sda_oe 1b0; end START: begin fsm_busy 1b1; sda_oe 1b1; sda_out 1b0; end SEND_ADDR: begin // addr_bit 由移位寄存器在 SEND_ADDR 阶段提供 sda_oe 1b1; sda_out addr_bit; end ACK_CHECK: begin scl_oe 1b1; sda_oe 1b0; // 释放 SDA等从机应答 end SEND_DATA: begin scl_oe 1b1; sda_oe 1b1; sda_out tx_bit; end STOP: begin scl_oe 1b0; sda_oe 1b1; sda_out 1b0; end default: begin fsm_busy 1b0; scl_oe 1b0; sda_oe 1b0; sda_out 1b1; end endcase end end endmodule这个骨架里第二段必须有next_state state;这个默认赋值否则综合工具会推断出锁存器。第三段基于next_state输出会比state提前一拍配合输出寄存器保证总线信号在有效窗口内稳定。如果这里改用state判断输出会晚一个周期放在 I2C 上就可能错过 SCL 高电平采样窗口。3. 三态驱动的实现inout 端口不是简单读或写3.1 三态到底是什么为什么要用它三态就是“高、低、高阻”。I2C 的 SDA 是双向信号主设备不光要发送数据还要读取从机的应答和数据如果把 SDA 设计成普通 output就永远读不到外面进来的信号。用生活类比一条公交线路在高峰时段既要上客也要下客不可能永远只开一个门三态缓冲就是那个“门控制开关”OE 有效时把内部数据推到总线OE 无效时把引脚释放成高阻让外部设备或上拉电阻决定总线电平。这三态驱动的全部核心就在 OE 信号上比数据本身重要得多。3.2 三态缓冲的 RTL 写法wire sda_in; reg sda_oe; // 1驱动总线0释放总线 reg sda_out; // 需要输出到总线的数据 assign sda sda_oe ? sda_out : 1bz; assign sda_in sda; // 读取总线上当前电平两个要点。第一1bz是高阻态逻辑仿真里表现为 Z综合后由 IOBUF 原语实现。不要把sda当普通 reg 直接往 always 里放inout 端口必须由连续赋值 assign 驱动。第二assign sda_in sda;会把自己输出的电平也读回来所以在 ACK 采样时务必先把 sda_oe 拉低并给上拉电阻一点时间让总线恢复高电平再采样。很多 ACK 一直读到 0 的问题就出在这里。3.3 外部上拉与 RC 时间常数的影响RTL 写对了外面没有上拉照样跑不通。I2C 是漏极开路加上拉电阻结构SDA / SCL 静态电平靠上拉电阻维持。上拉电阻越大上升沿越慢负载电容越大上升沿越慢。粗略估算用 RC 时间常数公式t_r 0.8473 × R × C。假设总线负载电容 50 pF上拉电阻 4.7 kΩ上升时间约 199 ns如果总线上挂了多个器件C_bus 到 100 pF同样电阻上升时间就到 398 ns。在标准 400 kbps 模式下上升沿要求通常在 300 ns 以内所以看不到波形上的“毛刺”不代表没问题。RTL 端的应对办法是不要在上拉后的第一拍立刻采样而是在 SCL 高电平的中间或后半段打开采样窗口或者按低速模式计算分频系数留足裕量。举例系统时钟 50 MHz希望 SCL 为 400 kHz一个 SCL 周期需要 125 个时钟周期每半个 SCL 周期约 62 个时钟周期。在这 62 个周期里建议至少空出 5 到 10 个周期再读 SDA才不会在 RC 上升沿上采到错误电平。3.4 仿真环境中的三态testbench 里必须有上拉在仿真里高阻 Z 如果没有外部驱动力波形会显示成 Z 或 X看起来像故障。I2C 的 testbench 必须做两件事一是给 sda 和 scl 加 pullupVerilog 里直接写pullup(sda); pullup(scl);二是在需要从机响应的地方用真实从机模型把总线拉低。否则主机的 ACK 检测永远读不到正确值。还有一种常见错误把 pullup 写在模块内部且 sda 又在上层通过三态赋值仿真器端口连接会乱。建议只在 testbench 层加上拉。3.5 “Modelsim 里能不能查看 RTL 电路图”这个热搜问题我经常在群里看到。Modelsim 确实有 RTL 视图但不同版本菜单位置差别大而且查看结果主要是模块例化、基本门级结构对调试状态机帮助不大。更推荐的是 Quartus 的 RTL Viewer / State Machine Viewer或者 Vivado 的 Schematic 窗口。在 Quartus 里打开工程后菜单 Tools → Netlist Viewers → RTL Viewer / State Machine Viewer可以直接看到 FSM 状态跳转图和三态缓冲结构Vivado 综合后在 Open Synthesized Design 里也能打开 Schematic。Modelsim 的正确打开方式是如果想看 RTL 视图可以从 View 菜单里切换窗口但我个人更建议把精力放在波形上。直接看 sda_oe、sda_in、state 三者的关系比看电路图更能定位三态问题。4. 实操过程把一个可用的主模式模块拆开看4.1 模块端口与参数设计主模式模块与 CPU / MCU 交互需要一组清晰的握手信号。端口大致如下信号方向位宽含义clkinput1系统时钟rst_ninput1异步复位低有效startinput1启动一次传输rwinput11读0写dev_addrinput7从机地址wdatainput8写入数据rdataoutput8读出数据busyoutput1传输忙标志doneoutput1传输完成脉冲sdainout1数据线sclinout1时钟线设计原则是上层应用不需要知道 SCL 具体怎么翻转、SDA 什么时候释放它只要拉高 start 然后观察 busy 和 done 即可。内部状态机、计数器、移位寄存器、三态 OE 全部封装在模块里。参数方面CLK_DIV由系统时钟和目标 SCL 频率计算得到TIMEOUT_CYCLES用来做总线超时保护。4.2 状态转移表与三态控制表一起填很多工程师画状态图只画状态和转移条件不写每个状态的输出控制。主模式设计里三态控制必须和状态转移表同步设计。下面是一个典型的发送场景状态主要行为sda_oesda_outscl_oe转移条件IDLE释放总线010start1STARTSCL1 时 SDA1→0100完成SEND_ADDR发送地址1地址位1bit_cnt0ACK_CHECK释放 SDA采样0x1ack0 继续SEND_DATA发送数据1数据位1bit_cnt0STOPSCL1 时 SDA0→1110完成异常超时回 IDLE010timer 溢出要注意 ACK 采样窗口进入 ACK_CHECK 之前一拍就要释放 sda_oe否则从机应答沿到来时总线还被主机占着采样结果必然错。这个“提前一拍”不是在最终输出 case 里简单置 0而是在状态转移前就把 OE 处理掉。4.3 真正落盘三段式 RTL 与三态缓冲拼接我习惯把状态机核心和三态 IO 分开写。状态机核心只管输出数据、输出使能、读回输入三态缓冲放在顶层用连续赋值完成。这样好处很明显仿真时外面可以接一个总线功能模型综合时也方便工具推断成 IOBUF。下面是顶层拼接的简化结构module i2c_top ( input wire clk, rst_n, input wire start, rw, input wire [6:0] dev_addr, input wire [7:0] wdata, output wire [7:0] rdata, output wire busy, done, inout wire sda, inout wire scl ); wire sda_oe, sda_out, sda_in; wire scl_oe, scl_out, scl_in; // SDA 三态驱动 assign sda sda_oe ? sda_out : 1bz; assign sda_in sda; // 生产级 SCL 应支持时钟拉伸也要三态 assign scl scl_oe ? scl_out : 1bz; assign scl_in scl; i2c_master_core u_core ( .clk(clk), .rst_n(rst_n), .start(start), .rw(rw), .dev_addr(dev_addr), .wdata(wdata), .rdata(rdata), .busy(busy), .done(done), .sda_out(sda_out), .sda_oe(sda_oe), .sda_in(sda_in), .scl_out(scl_out), .scl_oe(scl_oe), .scl_in(scl_in) ); endmodule这里要注意很多简化教程只把 SDA 做成三态SCL 直接推挽输出这在没有从机时钟拉伸的场景下还能跑通但只要从机拉低 SCL总线就等于被两个源强驱动轻则通信异常重则损伤引脚。真正接板之前SCL 也必须释放。4.4 连接到 FPGA 引脚时的工程约束三态逻辑在 RTL 里写对只是第一步。实际工程里 inout 引脚还要在约束文件中设置 I/O standard、drive strength、slew rate。FPGA 内部资源通常会生成 IO buffer 原语比如 Xilinx 的 IOBUF如果芯片引脚本身支持 PULLUP也可以把内部上拉打开但一般 I2C 项目还是建议外部放一颗合适的上拉电阻。管脚规划也需要注意。总线信号尽量避开高频翻转的相邻引脚避免串扰导致 SDA 上的毛刺。综合后打开 RTL Viewer如果看到模块端口直接连着 IOBUF 而不是一个普通 output buffer就说明三态推断成功了。4.5 仿真验证怎么证明三态时序没问题验证阶段我常做的检查项有三个START 阶段 SCL 为高时 SDA 由 1 变 0ACK 采样窗口 sda_oe 为 0 且 sda_in 被从机拉低STOP 阶段 SCL 为高时 SDA 由 0 变 1。整个传输结束后总线空闲高电平高阻 Z 被上拉电阻拉高。testbench 里最基本的框架是顶层例化i2c_top外部添加pullup(sda); pullup(scl);用真实从机模型响应地址并拉低 ACKclk 产生 50 MHzrst_n 先拉低再释放发送 start 命令后等待 done 脉冲在波形里对照 sda_oe 和 sda_in确认 ACK 窗口释放 OE。如果没有真实从机模型可以用force sda 1b0;模拟从机 ACK记得release sda;。否则后面读取数据的场景会一直被强制拉低看起来像是主机异常。5. 常见问题与排查技巧实录5.1 SDA 在仿真里一直是 X怎么办先看有没有给 sda 加 pullup。I2C 是开漏总线上拉结构高阻 Z 如果没有外部驱动仿真器就会给 X。其次看 sda_oe 的复位值。如果复位时 sda_oe 是 1 且 sda_out 是 0总线就会被你自己的 RTL 拉死。最稳妥的方式是上电复位时状态为 IDLEsda_oe 和 scl_oe 都为 0。我曾经见过一个项目里 oe 在复位状态悬空结果仿真一直出 X板子也随机锁死查到最后就是复位值没写全。5.2 ACK 检查永远失败或者永远成功这个问题的根因通常有三个。第一是采样点太早SCL 上升沿刚过就去读 SDA从机还没有把总线拉稳第二是 sda_oe 没有提前释放从机根本没有控制权第三是从机压根没收到正确地址SCL 时序不对导致它一直处于未启动状态。排查技巧很直接把波形放大到 SCL 上升沿附近对比 sda_oe 和 sda_in。如果 ACK 窗口里 sda_oe 还是 1问题一定在主机自己如果 sda_oe 已经是 0 但读到 1可以再检查 RC 上升沿和采样点偏移。5.3 状态机卡死总线被锁住最常见的两种原因一是 FSM 缺少 default 分支进入非法状态后没有出口二是转移条件里没有超时保护。主模式在真实总线上什么怪事都可能发生比如从机突然不响应 ACK或者在传输中途把时钟拉低不放。状态机如果没有超时回 IDLE 的机制整个总线就死锁了。我的习惯是在模块里加一个TIMEOUT_CYCLES参数配一个独立计数器定时器溢出后强制 state 回到 IDLE并释放所有 OE 信号同时向外输出 err 标志。这个看门狗和状态机写在同一个 module 里但用手动 enable 控制避免误复位正常传输。5.4 综合后 RTL 视图里发现状态数变少输出被优化掉了这不是 bug但第一次遇到确实会吓一跳。综合工具发现某些状态不可达或者某些输出在逻辑上可以被吸收就会自动精简导致 RTL 视图里的状态编码和源码不一致。排查时先看综合报告里的 FSM encodingQuartus 和 Vivado 都会列出状态编码也可以直接打开 State Machine Viewer 看跳转图。如果确实要保留所有状态可以用综合属性强制编码方式比如(* synthesis fsm_encoding one-hot *)但大多数情况下没必要强留。5.5 三态端口报错多驱动或者不能作为 reg 使用inout 端口不能在 always 块里被直接赋值必须通过 assign 连续赋值。多个模块往同一个 inout 端口驱动时必须保证 OE 逻辑互斥不能两个模块同时输出。调试时如果用force强制过某个引脚结束后一定要release否则后续仿真会被一个隐藏的强制值污染。这个错误很低级但越累越容易忽略我自己的教训是每次结束仿真前查一遍force语句。下面把最常踩的坑整理成速查表方便后面排查现象根因快速定位方法SDA 显示 Xtestbench 没有 pullup或 OE 复位未释放检查 testbench 层上拉检查复位时 oesACK 永远为 0sda_oe 未提前释放看 ACK 窗口 sda_oe 波形ACK 永远为 1采样点太早或地址发送错误检查采样时刻与 SCL 沿相对位置总线一直低电平sda_out0 且 oe 常高看状态机和 OE 是否卡在 SEND 状态状态机跑飞缺少 default或没有非法状态恢复代码审查 看状态变量波形综合后 RTL 视图和源码不一致工具自动编码优化打开 FSM Viewer对照编码表板级偶发死锁没有超时保护或 SCL 推挽驱动加 watchdogSCL 也使用三态这个主题讲到底其实就一句话状态机决定什么时候开口三态决定什么时候松口。我自己写主模式 RTL 有个习惯新代码不会一上来就堆协议细节而是先跑一版空白状态机加三态引脚的空转测试在 10 万个周期里反复触发 start/stop看有没有莫名的 X、有没有总线冲突。经历几次“跑到 20ms 就死锁”的事故后你就会明白骨架和手脚配合不好协议跟你没关系。先把复位值写干净把默认转移补全把每个 OE 的释放点列清楚再复杂的主模式模块也能稳稳跑起来。
返回列表