尧图精选

状态机与三态驱动:FPGA主模式RTL设计实战解析

🕒 发布时间:2026/9/28 2:09:36 📁 来源:尧图网络
状态机和三态驱动是主模式 RTL 设计中绕不开的两个硬骨头。这一讲把两件事串在一起讲状态机决定“主控逻辑下一步该干什么”三态驱动解决“主控怎么安全地把数据送到总线上”。适合正在学 FPGA/Verilog 的兄弟也适合刚接触总线级设计、想弄明白 inout 端口怎么处理的工程师。看完这一篇你至少能独立写出一套带方向控制的总线主控模块不再怕仿真里的 X 态和综合后的 latch。1. 主模式 RTL 设计到底在解决什么问题1.1 主从模式与总线握手的基本盘“主模式”这个词在数字逻辑设计里基本等同于一个身份总线上的发起方。你是主设备就意味着由你决定什么时候开始一次传输、传输多长、地址是多少、读还是写从设备只能被拉着走给你数据你就收让你收数据你就得把总线让出来。类似打电话主叫方就是主模式被叫方是被动响应唯一的区别是总线上一堆设备共享物理线路谁发起谁掌控时钟和方向。做主模式 RTL 设计第一步是把“主从模式”在协议层面的差异落到代码结构上。主模式模块一般需要这几样东西一个状态机作为控制核心一组寄存器/计数器记录传输状态一组方向控制信号管理数据总线以及和从设备配套的握手信号。从模式反而简单通常只需要解码主设备发过来的命令按地址响应即可不需要主动控制方向。学习初期很多人习惯从上手写从模式那是因为从模式只需要“看到什么就做什么”而主模式必须先定义“先做什么后做什么”本质上就是在设计一个状态机。方向搞反了后面所有模块全部白搭。1.2 状态机为什么是主控逻辑的绝对核心把主模式的控制逻辑拆开看几乎就是一个纯顺序流程发地址、等应答、切方向、读数据、发下一次请求。组合逻辑适合做计算但“先后顺序”这种事情必须由时序控制来管理这就轮到状态机上场。它负责把分散的使能信号、方向信号、握手信号按时间轴依次拉高或拉低其他逻辑只需要看着当前状态去执行。这也是为什么很多人在做软件时也用状态机比如 C 语言状态机用 switch-case 加一个 state 变量本质上和 Verilog 里的 case 一模一样。我经常在项目里先用 C 语言把主协议流程跑通一遍确认状态跳转没有死锁再照着这个模型去写 RTL。MCU 里的固件状态机、JTAG 的 TAP 状态机、Simulink 里的 Stateflow 建模都是同一个套路状态、事件、转移条件。区别只是描述语言和实现载体不同。RTL 里状态机之所以更“硬”是因为所有状态寄存器都是并行的、每个周期都有可能跳变而且要同时应对复位和异步事件稍不注意就给你跑进非法状态。1.3 从状态机到三态驱动这一讲的递进逻辑光有状态机只能控制内部逻辑一旦主模式需要和外部总线打交道比如挂在双向数据线上访问外部 RAM 或外设芯片就必须面对“三态”问题。“三态”指的是输出四种电平状态之外的高阻 Z三态驱动用高阻实现“不驱动总线”。主模式的状态机负责产生读写控制信号三态驱动器负责把这些控制信号转换成真正的总线行为。两个知识点拼起来才是一套完整的主模式 IO 设计。如果你只写了状态机不管三态数据永远送不出去如果你只搭了输出缓冲不看状态跳变方向切换就会在总线上制造毛刺甚至打架。这一讲的标题把两件事并列因为工程里它们本来就是你中有我、我中有你的结合体。2. 状态机设计三段式写法为什么是工程首选2.1 Moore 和 Mealy建模时先想清楚输出依赖状态机建模前先问自己是 Moore 型还是 Mealy 型。Moore 型的输出只取决于当前状态输出在一个状态下始终保持统一稳定但多一拍延迟Mealy 型的输出由“状态 输入”共同决定响应快但组合逻辑上容易出毛刺时序约束也更难做。工程上主模式控制总线的场合我默认优先选 Moore因为三态方向控制这种信号忌讳乱变越稳定越安全。只有在一些高吞吐、对响应速度敏感的场合比如 AXI 读数据的最后一拍才会用 Mealy 提前一拍给出拉低信号。选型不是写代码时才想的事而是结构设计阶段就要定下来的事。你用独热还是二进制编码用几段式写法输出要不要寄存都和这个选型强相关。比如用 Moore 设计天然适合三段式中的“输出寄存”那一拍输出的方向信号可以稳稳地保持一个周期总线不会因为组合逻辑的毛刺产生多余电平翻转。2.2 二段式与三段式的结构拆解状态机的写法分二段式和三段式工程上三种都有人用但主流团队基本都要求三段式。二段式通常只有两个 always一个时序逻辑完成状态寄存一个组合逻辑同时做次态判断和输出产生。好处是代码短坏处是输出是组合逻辑出来的仿真里容易有毛刺而且如果组合逻辑里没有把所有输出都覆盖到立刻给你综合出一堆 latch项目后期排起来很要命。三段式的标准划分是这样的第一个 always 用时序逻辑把次态打拍到现态第二个 always 用组合逻辑根据当前状态和输入产生次态第三个 always 用时序逻辑把状态相关的信号寄存后再输出。很多初学者以为三段式就是“三个 always”这么简单其实精髓是职责分离状态更新一个块次态计算一个块输出寄存一个块。看代码比文字更好懂// 第一段状态寄存 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end // 第二段次态计算组合逻辑 always (*) begin next_state state; case (state) IDLE: if (start) next_state ADDR; ADDR: if (ready) next_state DATA; default: next_state IDLE; endcase end // 第三段输出寄存 always (posedge clk or negedge rst_n) begin if (!rst_n) data_oen 1b1; else begin case (next_state) DATA: data_oen 1b0; default: data_oen 1b1; endcase end end细看第三段它基于next_state而不是state产生输出这样输出能提前一拍稳定总线上的方向信号在状态切换的时钟边沿前后不会出现毛刺。如果基于state总线的输出切换会晚一个周期一些时序紧张的外部设备就报错。这是三段式最容易被忽略、也最实用的一点。2.3 状态编码独热码、格雷码和二进制怎么选状态编码没有一个万能解得看状态数量、时序要求和综合工具的习惯。状态少于 8 个我不太纠结直接二进制编码寄存器位宽最省状态数量多比如 JTAG TAP、复杂协议状态机独热码就体现出优势每个状态一个寄存器位状态译码逻辑简单路径延迟小复位后进入唯一状态很快代价是寄存器数量多。JTAG TAP 状态机虽然逻辑不复杂但标准里每个状态都有多重跳转用独热码可以少做一堆冗余逻辑工具综合起来更从容。格雷码适合状态连续跳转、相邻状态到时序上有严格约束的场景比如计数器类的状态链。MCU 里的外设状态机有时也会用格雷码来减少多位翻转带来的毛刺但那通常是在异步时钟域下的选择。RTL 设计里大多数状态机是在同步时钟下工作毛刺主要来自组合输出而不是状态跳变所以格雷码并不比独热码更常用。我个人的经验法则是状态数量、跳转复杂度和输出时序重要性三个维度里跳转复杂度高选独热状态数少选二进制输出要求干净就一定要做输出寄存跟编码方式解耦。2.4 状态机复位与 DFT 的坑很多人写状态机只处理了rst_n复位到 IDLE看着没问题等 DFT 插扫描链时才发现麻烦大了。DFT 插入复位信号时测试模式会直接控制全局复位端要求 RTL 里所有寄存器能被复位到确定状态。如果你的复位逻辑里面有内部组合逻辑、时钟门控或者自定义的复位置位优先级扫描模式下状态机就可能被复位到一个出发不了的状态整条扫描链就完蛋了。这也是热词里“dft插复位怎么改rtl”被搜爆的原因。我给的建议很简单状态机的复位统一用异步复位、同步释放复位信号只从顶层专用复位引脚进来不要在模块内部用if (a b) state IDLE;这种组合条件去复位。DFT 工具插扫描链时会把复位端强制拉成扫描控制信号你的设计必须是“无论复位怎么给状态都能回到 IDLE”。如果你在做可测试性设计最好提前和 DFT 工程师对齐再来看状态机的复位策略否则后端阶段要改代码代价比你现在多写两行 default 大得多。3. 三态驱动inout 端口设计的门道3.1 为什么是“三态”高、低、高阻的用途三态指的是一个引脚可以在三种状态之间切换输出高电平、输出低电平、高阻输入。高阻 Z 的意思是“我不驱动总线”表现为一个引脚既不当电源、也不接地而是像断开一样让别的设备能驱动这条线。总线只要有多个设备共享就必须有高阻态否则当两个设备一个想写高、一个想写低时总线就会被强驱动冲突轻则逻辑错误重则直接烧芯片。举个例子你就明白了会议室里所有人都想说话话筒只有一根线语权必须轮流来。正在说话的人就是驱动总线其他人必须把话筒嘴闭上——这就是高阻态。如果你也说话、他也说话出来的声音就是两段叠加的鬼东西总线上的电平也会变成一个不确定的中间值仿真里表现为 X 或竞争。所以三态不是设计上要不要用的问题而是共享总线存在的前提。3.2 三态缓冲器与方向控制信号在 RTL 里描述一个双向端口其实非常短module io_master ( inout [7:0] data_bus, output reg [7:0] data_tx, input wire [7:0] data_rx, input wire data_oe ); assign data_bus data_oe ? data_tx : 8bz; assign data_rx data_bus; endmodule核心逻辑只有那行assign data_bus data_oe ? data_tx : 8bz;。当data_oe为 1总线被模块驱动为data_tx的电平当data_oe为 0总线输出高阻外部设备可以接管总线。与此同时data_rx随时采集总线电平供内部逻辑使用。这样就把“输出数据”和“读入数据”两个方向分开了。方向控制信号data_oe应该从哪里来没错从状态机来。这就是为什么标题要把状态机和三态驱动绑在一起没有方向控制的三态只是一堆悬浮引脚有了状态机才算完整的读写时序。这里有个关键细节对自己输出高阻的引脚你要在内部逻辑里避免把data_bus直接当输入参与计算。严谨的做法是像上面代码一样先assign data_rx data_bus;再让内部模块使用data_rx。这样仿真时你可以单独监控data_rx的值排查问题更直观。3.3 双向端口仿真和综合陷阱仿真阶段三态最容易翻车的就是 X 态。如果data_bus被两个模块同时驱动或者测试平台里没有给初始值仿真器就会在总线上画出 X。遇到这种问题先看波形里data_oe有没有打架再看是不是初始状态未复位。另一个容易踩的坑是 testbench 里的 force/release你 force 了总线但 DUT 内部还是继续驱动跑出来的 X 会让你误以为设计有问题实际上是 force 没释放完。综合阶段工具会把inout端口拆成 input 和 output 两组RTL 内部的data_rx data_bus会被当作输入路径处理data_bus data_oe ? data_tx : 8bz会被换成专用 IO buffer。所以你在工程里不要用inout做内部模块互联只在顶层用。内部模块如果需要“双向”通信就把输入和输出分开做成单向端口。这样综合工具才能正确区分方向后端也不会给你报 IO 端口方向约束错误。4. 合体一个主模式从状态机到三态驱动的实例4.1 场景定义主控对一个 8bit 并行总线器件发起读写光讲理论不好落地我拿一个很常见的接口来看一个并行存储器件8 根数据线是双向的主模式需要发起“写一个字节”和“读一个字节”两种操作。控制信号很简单CS_N片选、WR_N写使能、RD_N读使能。写操作时主模式把数据放到data_bus上并拉低WR_N读操作时主模式先释放data_bus方向改成输入态再拉低RD_N在外部器件驱动总线后采回数据。这个设计最核心的点就是状态机不只控制读和写还得管理三态方向。写的时候data_oe 1输出数据读的时候data_oe 0释放总线。如果状态机里忘了切方向读回来的数据永远是 X仿真里一眼就能看出来。4.2 RTL 代码与注释简洁但完整我写了一个简化但完整可仿真的主模式状态机大家可以对照自己工程去改module parallel_master ( input wire clk, input wire rst_n, input wire req, input wire wr, // 1写0读 input wire [7:0] addr_in, input wire [7:0] data_in, output reg [7:0] data_out, output reg done, // 外部并行总线 output reg CS_N, output reg WR_N, output reg RD_N, inout wire [7:0] data_bus ); localparam IDLE 3d0, SETUP 3d1, WRITE 3d2, READ 3d3, HOLD 3d4; reg [2:0] state, next_state; reg [2:0] wait_cnt; reg [7:0] data_tx; reg data_oe; // 三态驱动方向由状态机控制 assign data_bus data_oe ? data_tx : 8bz; // 第一段状态寄存 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end // 第二段次态计算 always (*) begin next_state state; case (state) IDLE: if (req) next_state SETUP; SETUP: next_state wr ? WRITE : READ; WRITE: next_state HOLD; READ: next_state HOLD; HOLD: if (wait_cnt 3d0) next_state IDLE; endcase end // 计数与输出寄存 always (posedge clk or negedge rst_n) begin if (!rst_n) begin wait_cnt 3d0; done 1b0; end else begin case (state) IDLE: done 1b0; SETUP: begin CS_N 1b0; end WRITE: begin data_tx data_in; data_oe 1b1; WR_N 1b0; end READ: begin data_oe 1b0; // 释放总线准备接收 RD_N 1b0; end HOLD: begin if (wait_cnt 3b000) done 1b1; else wait_cnt wait_cnt - 1b1; end default: begin CS_N 1b1; WR_N 1b1; RD_N 1b1; data_oe 1b0; wait_cnt 3d4; end endcase data_out data_bus; end end endmodule这个代码为了讲清楚状态机和三态的关系省略了地址输出部分但核心结构很典型状态机决定什么时候打开三态、什么时候释放三态输出寄存保证data_oe、WR_N、RD_N这些关键信号不会因为组合逻辑毛刺乱跳。特别是 READ 状态里data_oe 1b0释放总线必须先于拉低RD_N否则总线还在被自己驱动时就启动读操作外部器件根本抢不过你。4.3 时序与握手参数怎么定时序参数不能拍脑袋。假设时钟是 50MHz周期 20ns外部器件手册要求片选建立时间t_CS至少 20ns写脉冲t_WP最小宽度 60ns读完成后数据保持t_HD至少 10ns。20ns 一个周期那么 SETUP 至少占 1 拍20ns 20ns刚好满足但做设计我不会卡在极限上至少放 2 拍做裕量WRITE 这个状态里WR_N拉低持续整个状态至少要持续 3 拍才能凑够 60nsHOLD 状态要让总线稳定、解除片选至少再占 2 拍。这样算下来一次写操作约需 1326 个周期120ns。如果你直接把wait_cnt初始化成常数记住它是从 N 倒数到 0实际等待拍数是 N1。比如wait_cnt 3d4等待 5 拍。这些细节在仿真里都能验证但在写代码前先算一遍能省掉后面一堆调试时间。4.4 状态机输出编码与三态切换的时序对齐用三段式状态机时状态寄存输出在时钟沿之后更新输出寄存又在下一个时钟沿才反映next_state。这个“一拍延迟”正是三态切换需要的当状态机当前还在WRITE组合逻辑已经算出next_state HOLD第三段在这个时钟沿把data_oe拉低此时总线刚好完成最后一笔写操作。如果你用二段式data_oe由组合逻辑直接生成data_bus上的数据可能在状态切换瞬间和方向控制交错总线出现一个短暂的不稳定电平外部芯片对这种毛刺很敏感轻则多采一拍重则把写入数据写错。实操中我一般把方向控制、输出使能这类和三态强相关的信号都放进三段式的输出寄存块里让它们和状态跳变同步。方向切换不要当拍改要提前一拍比如总线上当前是输出态下一个周期要切输入那data_oe应在切换状态那个沿就拉低。这个原则适用于所有主模式总线设计不只是这个并行例子。5. 调试与避坑真实项目里最容易翻车的地方5.1 状态机跑飞与 default 分支状态机跑飞是 RTL 调试里的“幽灵问题”仿真跑得好好的上板跑几分钟就死机。最常见的元凶就是状态寄存用了独热码或二进制编码但case里没有写default。工具遇见你没定义的状态会默认生成 lock-up 逻辑把未覆盖状态导向一个确定的次态如果这个次态是非法状态状态机就永远回不了家。我在自检清单里有一条固定检查项case 语句有没有 defaultdefault 是否跳回 IDLE状态位宽是否比所用状态多。另外综合时如果发现报告里出现了inferred latch别只盯着case看。时序逻辑里缺少某个分支赋值或者组合逻辑块里某个信号没有在所有分支被赋值都会产生锁存器。写三段式状态机组合逻辑块里的next_state state;这行很关键它可以保证默认情况下跳到总线上所有分支都不会产生 latch。第二段里先把默认赋值写前面剩下的 case 只写跳转条件这样结构清爽很多。5.2 三态总线的多驱动与 X 态排查三态总线的 X 态排查我按这个顺序来。先看 testbench 里总线有没有被其他模块同时驱动比如你 DUT 里data_oe 1testbench 却又在总线上 force 了一个值仿真器给出的 X 其实属于 testbench 竞争不是你设计的问题。再看复位阶段双向总线没上电之前会先出 Z这是正常的出现 X 才有问题。最后看方向切换那一个周期总线前一刻还是data_tx高电平后一刻被外部拉低中间会有一个很短的样本周期这个瞬间采集到的数据不稳定所以外部器件的数据有效窗口一定要避开三态切换沿。真要在仿真里抓这种问题我习惯打印一组状态导致的方向变化记录每当状态机跳到 READ就$display一下data_oe和data_bus的值对比前后三个周期确认总线是在释放完成之后才被外部拉起来。如果你有 Questa/Verilator 这些支持覆盖率工具的环境加上assert property检查data_oe为高时data_bus不能同时被外部驱动能省很多人工看波形的功夫。5.3 复位与 DFT 场景下的状态机对策再展开讲一次 DFT 和复位的问题。全扫描测试时工具会通过复位端口控制所有寄存器进入确定状态要求复位逻辑不能被功能信号阻塞。我遇到过最坑的写法是always (posedge clk) begin if (we addr xxx) state IDLE; else ... endDFT 工具看了直接报约束冲突因为扫描模式下找不到一个可控的“复位”点。正确的做法是状态机的复位信号端只连接顶层复位输入只要复位有效状态无条件回到 IDLE扫描模式下强制复位时功能逻辑根本不会挡路。如果你用了异步复位记得加同步释放逻辑避免复位撤除时产生亚稳态。这一点写 RTL 时先想好省得版图都做完再回头改状态机。5.4 仿真平台与 C 模型联动验证状态机设计完验证方式不只有写 testbench。我习惯先把同样的状态转移逻辑在 C 语言里实现一版用一个run_state()函数模拟一次读写事务然后和 RTL 仿真跑同一个激励文件两边每个状态停留的周期数对比一致才放心。C 语言状态机的好处是调试环境简单加打印不用重新编译半天 FPGA 工程适合快速验证状态数量和转移条件是否和自己想的一样。更高级一点的可以在 SystemVerilog 的 testbench 里用DPI-C把 C 状态机模型挂进来RTL 每跳一个状态就调一次 C 模型校验当前状态和输出是否匹配跑上千个随机测试比人肉盯波形可靠得多。如果你想用图形化拖拽的方式建状态机模型Simulink 里的 Stateflow 可以快速建模、自动生成 C 代码但生成的代码风格和手写 RTL 差别很大直接搬到 FPGA 上容易风格混乱。我通常只用它做早期算法验证拿到产品级 RTL 还是要手写三次式编码。建模工具是推导器不是替代品RTL 设计思维还是要回到状态寄存器本身。6. 最后聊几句实战体会做过的总线主控模块越多越能感受到状态机和三态驱动之间的微妙关系。状态机写得再漂亮方向控制信号晚一个周期总线照样给你看 X三态缓冲搭得再标准状态机没有 default上板照样跑飞。这两块必须同时正确才能让主模式真正工作起来。我个人的习惯是规定所有三态相关的输出使能信号必须由三段式状态机的输出寄存块产生不允许在组合逻辑里直接给data_oe赋值。这是踩过几次总线毛刺换来的教训规则简单效果立竿见影。如果你在做一个新协议的主模式控制器强烈建议先花半天时间把协议里的读写时序画成状态转移图标注每一拍的data_oe和方向变化再动手写代码。时序图不完整状态机必然缺分支而三态切换的细节只有在时序图上一格一格标出来才不会漏。下一讲如果想继续深入我们可以聊聊多主模式仲裁那种场景下状态机和三态驱动的复杂性会再上一个台阶。先把这一讲的状态跳转和方向控制跑顺后面就顺了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →