资讯动态

Verilog三段式状态机:职责分离、编码选择与实战避坑

发布时间:2026/9/30 4:45:51 来源:尧图企业网站定制
大家都说状态机要写三段式但真正能把为什么讲清楚、把坑讲全的人不多。我见过不少项目代码能跑波形也没问题可一到时序收紧、跨时钟、或者换个人接手改需求的时候状态机就成了整块逻辑里最容易出事的地方。Verilog 里的三段式状态机不是什么新技巧而是被无数项目反复验证过的一种把风险隔离开的写法状态寄存器只管跳转次态逻辑只管条件判断输出逻辑单独收口。这三个 always 块各干各的事谁也别越界。这篇就围绕三段式状态机展开从它到底治什么病讲起把三种写法的代码骨架摆在一起对照说清楚状态编码怎么选、实战里一个带帧同步和超时保护的字节接收状态机怎么写、latch 和毛刺这些高频翻车点怎么躲最后再聊聊仿真收敛和综合报告怎么看。刚学 FPGA 的同学能照着抄结构做过几年设计的也能在里面找到几个容易忽略的细节。1. 一次状态机停摆的复盘三段式到底治的是什么病1.1 症状状态卡在中间态next_state 全是 X前阵子帮人看一个数据采集板子现象很典型上电后能正常跑跑个几十秒到几分钟数据流突然断掉重新上电又能撑一会儿。抓波形一看主状态机的状态位停在一个中间状态不动了输入信号该来的还在来可状态就是不走。再看仿真里复现的情况发现state停住的那一刻次态寄存器里原本应该是有效值结果每一位都是红色的 X。X 一旦进了状态寄存器后面的 case 匹配哪个分支都不成立整个状态机就失忆了。这类问题的根子往往不在状态机本身的设计思路而在写法上把不该混的东西混到了一起。状态转移条件、输出赋值、计数器更新全塞在一个时序 always 块里条件分支又写得东一榔头西一棒子只要有一条路径没覆盖到寄存器就可能保持 X 或者推出个非法值。状态机越复杂这种隐患越难从代码上肉眼发现。1.2 根因组合逻辑和时序逻辑挤在同一个块里一段式写法的典型形态是这样的一个always (posedge clk)里用 case 判断当前状态然后在每个分支里既写state 下一个状态又写输出信号busy 1b1还顺手把计数器加一。看着紧凑实际上把三类职责揉成了一团。第一类职责是状态的存储这必须是时序逻辑只在时钟沿更新第二类职责是下一个状态是什么这是纯粹的组合判断跟时钟沿没关系第三类职责是当前该输出什么又分 Moore 和 Mealy 两种。揉在一起最直接的代价是可读性和可维护性。你想知道什么条件下会从 A 状态跳到 B 状态得在 case 的一大堆分支里翻来翻去你想加一个中间状态改一处容易漏三处。更隐蔽的代价是综合和时序优化空间被压缩了因为综合工具看到的是一个巨大的时序块很难帮你做状态重编码或者把组合路径拎出来单独优化。1.3 三段式的本质是关注点分离三段式说白了就是把上面三类职责拆成三个独立的 always 块。第一个块只做一件事state next_state。第二个块是纯组合逻辑根据当前状态和输入算next_state。第三个块专门负责输出。拆开之后每个块的逻辑都变得很窄检查起来一目了然。状态转移对不对只盯第二个块的 case输出时序对不对只盯第三个块复位干不干净只盯第一个块。这种拆法带来的收益是可累积的。代码好读了评审能发现问题后面要加状态、改跳转条件改动都局限在次态块里综合工具也更容易识别出这是一台有限状态机从而给出合理的编码和面积优化。还有一个容易被忽略的好处形式验证和断言更容易挂上去因为next_state变成了一个可以独立观测的信号你甚至能在仿真里直接断言在 IDLE 状态下如果 start 拉高下一拍 next_state 必须是 WORK。一段式写法里根本没有这个中间信号断言也就无从下手。2. 一段式、两段式、三段式的代码骨架对照2.1 一段式能跑但输出和状态绑死先看一段式很多人入门时写的第一个状态机就是这个样子always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; busy 1b0; end else begin case (state) IDLE: begin if (start) begin state WORK; busy 1b1; end else busy 1b0; end WORK: begin if (done) begin state IDLE; busy 1b0; end end default: state IDLE; endcase end end这段代码本身没错输出 busy 和 state 在同一个时钟沿更新严格说是寄存器输出没有延迟。但它的问题在于状态和输出耦合得太紧任何一处修改都要在同一个 case 里动。更麻烦的是当输出条件复杂起来比如在 WORK 状态下且计数器过半且握手有效时才拉高 ready这个判断会被塞进时序块逻辑一长就容易漏分支而且这种组合判断嵌在时序块里综合出来的路径也不清晰。2.2 两段式把次态逻辑拆出来了两段式把次态计算拎出来变成一个独立组合块// 段1状态寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end // 段2次态组合逻辑 always (*) begin next_state state; // 默认保持 case (state) IDLE: if (start) next_state WORK; WORK: if (done) next_state IDLE; default: next_state IDLE; endcase end到这一步状态转移已经清了但输出仍然是个模糊地带。两段式里输出通常有两种放法要么跟次态逻辑挤在同一个组合块里那输出就是组合输出可能有毛刺要么再写一个时序块专门寄存输出那本质上已经是在往三段式靠了。两段式最大的遗留问题就是它没有明确告诉写代码的人输出该放哪于是同一个项目里不同模块的输出风格五花八门接手的人得挨个去猜。2.3 三段式三个 always 块各就各位三段式把输出也独立成块结构就完整了localparam IDLE 3d0, WORK 3d1, DONE 3d2; reg [2:0] state, next_state; reg busy; // 段1状态寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end // 段2次态组合逻辑 always (*) begin next_state state; // 默认保持 case (state) IDLE: if (start) next_state WORK; WORK: if (fin) next_state DONE; DONE: next_state IDLE; default: next_state IDLE; endcase end // 段3输出逻辑这里用寄存输出 always (posedge clk or negedge rst_n) begin if (!rst_n) busy 1b0; else busy (state WORK); end三个块摆在一起职责边界非常清楚第一个块里除了复位不许出现任何判断逻辑第二个块里只用绝不用第三个块决定输出是组合还是寄存。这种结构在团队协作里价值特别大一个人写次态一个人写输出互不干扰合并的时候冲突也少。3. 三个 always 块各自的职责与写法红线3.1 第一个块只做 state next_state第一个块是整台状态机的心脏它唯一的工作就是在时钟沿把次态灌进现态。这里的红线有几条。其一除了复位分支块里不能有任何其他条件判断不能写if (something) state A; else state B;那就等于把次态逻辑又搬回来了。其二复位只复位state不需要复位next_state因为next_state是组合信号复位一释放它就会被第二个块重新算出来给它写复位反而是画蛇添足。其三复位方式要和整个模块保持一致别这个状态机用异步复位旁边那个用同步复位混着用是跨模块时序问题的常见来源。还有个小细节值得提状态寄存器的位宽要和状态编码方式匹配。如果你打算用独热码位宽就得等于状态数如果用二进制码位宽是$clog2(状态数)。很多人习惯先写死一个位宽后面加状态时忘了改结果高位被截断状态机跳到一个不存在的编码上。3.2 第二个块默认值法和 default 分支缺一不可第二个块是纯组合逻辑写法上有两条必须遵守的规则。第一条叫默认值法块一开头就给next_state赋一个默认值通常是next_state state;表示没有特殊情况就保持。这样一来即使 case 里某个分支忘了写赋值也不会因为没赋值而综合出锁存器。第二条是 case 必须有default分支而且 default 里要给next_state赋一个安全的落脚点一般指向 IDLE 或者复位态。这两个规则看着简单但它们直接决定了状态机会不会跑飞。为什么默认值这么关键因为组合逻辑的赋值是敏感列表一变就重算如果没有默认值综合工具会认为某些输入组合下这个信号没有被驱动为了保持上一次的值它就给你插一个锁存器。锁存器在 FPGA 里是个麻烦东西它不受时钟控制时序分析难做跨时钟时更是灾难。默认值法就是把这个隐患从源头掐掉。default 分支则是对非法编码的兜底万一状态寄存器因为单粒子翻转或者复位异常跳到了没定义的编码上default 能把它拉回来。3.3 第三个块输出到底该寄存还是组合第三个块是最需要动脑子的地方核心决策是输出用组合逻辑还是寄存输出。Moore 型状态机的输出只跟当前状态有关组合输出写起来就是assign ready (state RUN);优点是当拍立刻响应没有延迟缺点是状态切换的瞬间多个输出位可能同时变化如果这几根线到了下游被同一个时钟采样一般没问题但如果它们驱动的是异步复位、时钟使能或者片外引脚毛刺就可能被当成有效信号。寄存输出的写法是把输出也过一拍时钟ready (state RUN);代价是输出比状态晚一个周期好处是输出信号干净、没有毛刺时序也好收敛。选择的原则可以简化成一句话如果输出只在同一时钟域内部被采样组合输出更省时间如果输出要去别的时钟域、要驱动异步控制、要出芯片引脚那就老老实实寄存一拍。Mealy 型状态机的输出跟状态和输入都有关组合输出的毛刺风险更高除非你非常确定输入是干净的、下游采样是同步的否则也建议寄存。4. 状态编码怎么选binary、gray、one-hot 的资源与时序账4.1 编码方式对资源和时序的实际影响状态编码不是写代码时才考虑的事它直接决定了综合出来的电路长什么样。二进制码binary用$clog2(N)位表示 N 个状态触发器省但译码逻辑复杂因为判断当前是不是状态 5需要比较所有位。独热码one-hot用 N 位表示 N 个状态每个状态只有一位是 1译码时判断当前是不是状态 5只需要看第 5 位组合逻辑极浅速度快但触发器用得多。格雷码gray介于两者之间相邻状态之间只有一位变化翻转少适合低功耗设计但它的前提是状态跳转大多是相邻的如果状态机跳转关系很乱格雷码反而不好设计。编码方式触发器占用译码逻辑深度翻转活动典型适用binarylog2(N)较深中等ASIC 面积敏感场景、状态数多graylog2(N)中等低低功耗、跳转以相邻为主one-hotN很浅每次跳转 2 位变化FPGA、小规模高速 FSM4.2 FPGA 偏爱独热码ASIC 未必FPGA 的底层结构决定了它偏爱独热码。FPGA 里触发器资源相对充裕一个 slice 里往往有好几个触发器而查找表LUT是更紧张的资源。独热码把译码逻辑压到极浅省下的是 LUT多用几个触发器反而更划算而且路径短、跑得快。所以 Vivado 这类工具在状态数比较多的时候默认就会帮你重编码成独热码。ASIC 就不一样了触发器面积直接换算成硅片面积独热码在状态数多时会迅速膨胀面积所以 ASIC 里二进制码和格雷码更常见。经验上可以这么把握状态数在 10 个以内binary 和 one-hot 的面积差别不明显选哪个都行状态数超过 16 个FPGA 上倾向 one-hotASIC 上倾向 binary 或 gray如果设计对功耗特别敏感又恰好是顺序推进为主的状态机格雷码值得考虑。这只是一个粗略的参考最终还是要看综合报告和时序报告的实际数据。4.3 手写 localparam 不等于手写编码这里有个非常容易被搞混的点很多人在代码里写localparam IDLE 3d0, WORK 3d1;以为这就指定了状态编码其实不是。这只是给状态起了名字综合工具会按照自己的策略去重编码可能全部改成独热码。你写的3d1在综合后的网表里可能根本不存在。真正想控制编码得用综合属性。Vivado 里可以这样写(* fsm_encoding one_hot *) reg [2:0] state;Synopsys 系工具用的是(* fsm_encoding gray *)或者枚举类型的enum声明。不过我的建议是除非有明确理由否则别去手动干预让工具自己选。工具的判断基于面积和时序的综合权衡往往比人拍脑袋准。你真正要做的是翻综合报告看它给你编成了什么确认是你想要的。5. 实战用三段式写一个带帧同步与超时的字节接收状态机5.1 需求拆解与状态定义前面讲的都是局部现在把它拼成一个完整的例子一个字节流帧接收状态机。帧格式约定为0xAA LEN PAYLOAD[LEN] XOR校验字节以rx_valid脉冲伴随rx_data输入。要求支持帧头同步、长度合法性检查、超时保护、校验判断。先定状态S_IDLE 找帧头S_LEN 收长度S_PAY 收数据S_CHK 收校验S_DONE 完成S_ERR 出错。跳转关系是 S_IDLE 收到 0xAA 进 S_LENS_LEN 收到合法长度进 S_PAYS_PAY 收满后进 S_CHK校验通过进 S_DONE否则进 S_ERRS_DONE 和 S_ERR 都回 S_IDLE。中间状态如果超时没收到新字节直接进 S_ERR这样能防止半个帧卡在那里。状态定义清楚之后接下来要把哪些东西属于状态、哪些属于数据通路分开。状态机只管跳转字节计数、校验累加、超时计数这些属于数据通路放在独立的时序块里根据当前状态更新。这样做的好处是次态逻辑保持精简不用去关心计数器到几了这种细节只判断计数器的比较结果。5.2 完整 Verilog 代码module pkt_rx_fsm #( parameter [15:0] TIMEOUT_CYC 16d50_000, parameter [7:0] MAX_LEN 8d64 )( input wire clk, input wire rst_n, input wire rx_valid, input wire [7:0] rx_data, output reg pkt_done, output reg pkt_err, output reg [7:0] pkt_len, output reg [7:0] pkt_data ); localparam [2:0] S_IDLE 3d0, S_LEN 3d1, S_PAY 3d2, S_CHK 3d3, S_DONE 3d4, S_ERR 3d5; reg [2:0] state, next_state; reg [7:0] cnt; reg [7:0] chk_acc; reg [15:0] timeout; // 段1状态寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) state S_IDLE; else state next_state; end // 段2次态组合逻辑 always (*) begin next_state state; // 默认保持杜绝锁存器 case (state) S_IDLE: if (rx_valid rx_data 8hAA) next_state S_LEN; S_LEN: begin if (rx_valid) next_state (rx_data 8d0 || rx_data MAX_LEN) ? S_ERR : S_PAY; else if (timeout TIMEOUT_CYC) next_state S_ERR; end S_PAY: begin if (rx_valid) next_state (cnt 8d1) ? S_CHK : S_PAY; else if (timeout TIMEOUT_CYC) next_state S_ERR; end S_CHK: begin if (rx_valid) next_state (chk_acc rx_data) ? S_DONE : S_ERR; else if (timeout TIMEOUT_CYC) next_state S_ERR; end S_DONE: next_state S_IDLE; S_ERR: next_state S_IDLE; default: next_state S_IDLE; endcase end // 数据通路计数、校验、超时 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 8d0; chk_acc 8d0; timeout 16d0; end else begin if (state S_IDLE || rx_valid) timeout 16d0; else if (timeout TIMEOUT_CYC) timeout timeout 16d1; if (state S_LEN rx_valid) cnt rx_data - 8d1; if (state S_PAY rx_valid cnt ! 8d0) cnt cnt - 8d1; if (state S_IDLE) chk_acc 8d0; else if (rx_valid (state S_LEN || state S_PAY)) chk_acc chk_acc ^ rx_data; end end // 段3输出逻辑寄存输出保证无毛刺 always (posedge clk or negedge rst_n) begin if (!rst_n) begin pkt_done 1b0; pkt_err 1b0; pkt_len 8d0; pkt_data 8d0; end else begin pkt_done (state S_DONE); pkt_err (state S_ERR); if (state S_LEN rx_valid) pkt_len rx_data; if (state S_PAY rx_valid) pkt_data rx_data; end end endmodule这段代码就是三段式的完整落地。段一极简段二只做跳转判断数据通路和输出各自独立。超时计数放在数据通路里次态逻辑只读它的比较结果职责清清爽爽。校验这里用了最简单的异或累加做示例真实项目里换成 CRC 或者求和校验都行改的只是数据通路那一块状态机纹丝不动这正是分段带来的好处。5.3 testbench 要覆盖哪些边界写状态机最容易犯的错是只测了正常路径。对这个帧接收机来说正常路径就是帧头、长度、若干数据、正确校验一路走通。但真正的风险都藏在边界上长度字段为 0 会怎样长度超过 MAX_LEN 会怎样校验字节填错会怎样收到帧头之后一直不来数据会不会被超时拉回 IDLE一帧还没收完紧接着来下一帧的帧头会不会串扰。这些 case 都要在 testbench 里造出来。我习惯把 testbench 写成任务task驱动的方式send_byte(8hAA)每次发一个字节并给出 valid 脉冲然后run_frame(len, chk_ok)负责把一帧完整地推一遍。这样写正常帧只要一行造异常帧就把 chk_ok 置成 0改 len 就能测边界。超时测试最容易被漏我是这么做的发完帧头之后什么都不发让仿真时间空跑超过 TIMEOUT_CYC然后检查状态机是不是回到了 IDLEpkt_err 有没有拉起来。把 TIMEOUT_CYC 在仿真里设成一个很小的值比如 16这样仿真跑得快又不会漏测。5.4 波形里怎么定位状态机问题状态机出问题时波形是最好的老师但要会看。我一般会把state、next_state、rx_valid、pkt_done、pkt_err这几根信号拖到同一组里用颜色区分。看的时候先看state的跳转顺序对不对再看每次跳转时刻的next_state是不是被正确计算出来了。如果next_state在某拍变成了 X顺着它往回查多半是某个条件用的是没复位的寄存器或者没覆盖的输入。把state设成枚举显示Vivado 里在波形上右键可以显示状态名比看二进制要直观得多。还有一个小技巧把next_state用下一拍的 state来对照如果发现某一拍next_state已经指向上游状态但state却没跟着变那问题多半出在第一个块检查时钟和复位有没有接对。统计信号也可以用上比如加一个跳转次数计数器跑一段长仿真看看状态机有没有反复进出 S_ERR这比盯着波形找要高效。6. 高频翻车点latch、复位、毛刺和仿真综合不一致6.1 组合块没兜底就是给锁存器开门前面提过默认值法这里再强调一次它的重要性。第二个块里如果写成always (*) begin case (state) S_IDLE: next_state S_LEN; S_LEN: next_state S_PAY; endcase end只覆盖了两个状态剩下的状态进来时next_state没有赋值综合工具就会推断出一个锁存器来锁住旧值。有的同学会说状态数就两个跑不到别的状态但复位异常、单粒子效应、验证不充分都可能让状态寄存器跳到你没有覆盖的编码上那一刻锁存器就把错误值冻住了状态机直接死给你看。所以默认值next_state state;和default分支要当成肌肉记忆来写不是可选项。6.2 复位只复状态别乱复组合信号复位策略上最常踩的坑是把不该复位的信号也复位了。next_state是组合信号复位它没有意义综合工具还会报警告。另一个坑是复位方式不统一。异步复位写起来爽但释放时刻如果和时钟沿挨得太近容易产生亚稳态。工程上稳妥的做法是异步复位、同步释放用一个简单的两级同步器把复位信号同步到目标时钟域再释放reg rst_sync1, rst_sync2; always (posedge clk or negedge rst_n) begin if (!rst_n) {rst_sync2, rst_sync1} 2b00; else {rst_sync2, rst_sync1} {rst_sync1, 1b1}; end wire rst_n_sync rst_sync2;状态机的复位端接rst_n_sync释放沿就被对齐到了时钟沿上稳得多。这个同步器本身也是一段小状态机思路的体现第一个触发器采样第二个触发器去抖动输出才敢给状态机用。6.3 Mealy 组合输出的毛刺什么时候会咬人Mealy 状态机的输出直接由状态和输入组合出来状态跳转的那一拍多个状态位可能同时变化组合输出的中间值就会出现毛刺。这些毛刺如果在建立保持时间窗口内不影响下游的同步采样就没事但如果它驱动的是异步复位、时钟使能、片外引脚或者另一个时钟域的输入那就是真出事。判断标准很简单下游如果是用同一个时钟的边沿采样一般安全如果是电平敏感或者异步必须寄存。我踩过一次典型的坑一个 Mealy 输出的fifo_rd_en直接连到了 FIFO 的读使能上毛刺导致 FIFO 多读了一个数据。后来把输出寄存一拍虽然读使能晚了一个周期但因为读地址也是同步给出的整体时序对齐了问题消失。这件事教会我Mealy 输出要么寄存要么在文档里写清楚此输出存在毛刺仅限同步采样。6.4 full_case、parallel_case 和仿真综合不一致full_case和parallel_case是两个老牌综合指令本意是告诉工具我的 case 分支全覆盖了别给我插多余逻辑。但它们有个致命问题仿真器根本不认这两个指令。你在 RTL 里加了full_case综合后电路按全覆盖处理仿真却按实际代码走一旦某个分支真的漏了仿真行为和上板行为就对不上了。这种仿真过、上板不过的问题最折磨人。我的建议很直接别用这两个指令。想要全覆盖就老老实实写default想要并行判断就保证分支条件互斥。RTL 代码里唯一应该出现的综合指令是那些对仿真无影响的比如状态编码属性。凡是能改变电路行为指令都要掂量三分。7. 验证与收敛仿真手段、断言和综合报告怎么读7.1 用断言把状态机的约束钉死状态机的很多约束是隐含的比如同一时刻只有一个状态有效跳转必须发生在相邻状态之间输出不能在所有状态下都为高。这些约束如果只在脑子里记着改代码时很容易破坏。解决办法是把它们写成断言。SystemVerilog 的立即断言和并发断言都能用// 非法编码检查state 必须是已定义的状态之一 always (posedge clk) if (rst_n) assert (state inside {S_IDLE, S_LEN, S_PAY, S_CHK, S_DONE, S_ERR}) else $error(FSM entered illegal state!); // 跳转合法性从 S_IDLE 只能跳到 S_LEN property p_idle_jump; (posedge clk) disable iff (!rst_n) (state S_IDLE) | (state S_IDLE || state S_LEN); endproperty assert property (p_idle_jump);断言的好处是它会在违规的那一刻立刻报出来并给出时间点比事后翻波形高效得多。状态机这种离散、跳转、时序相关的逻辑特别适合用断言来守。我现在的习惯是只要状态数超过三四个就一定要挂一个非法编码断言和一个关键跳转断言。7.2 综合报告里要盯哪几个数综合完之后报告里关于状态机的信息值得逐条看。Vivado 一般会在综合日志里给出检测到的 FSM 数量、每个 FSM 的状态数、采用的编码方式以及有没有被优化掉。如果某个 FSM 显示state register removed或者类似字样说明综合工具认为状态寄存器的某些位对输出没有贡献把它优化掉了这时候要警惕可能是状态定义冗余也可能是逻辑写漏了。Synopsys 系工具会输出FSM Encoding Algorithm和状态转移表可以直接对照你设计的跳转关系看有没有多出或缺失的转移。另外一个容易被忽略的地方是未到达状态unreachable state的报告如果工具告诉你某些状态永远到不了那多半是次态逻辑里写错了条件导致某个状态成了孤岛。更重要的是时序报告。三段式的好处在这里体现得最明显因为次态逻辑被单独拎出来了你可以直接在时序报告里找到状态到次态这条路径看它的逻辑级数和延迟。如果这条路径成了关键路径说明次态判断太复杂可以考虑把部分判断提前一拍算好或者对状态重新编码。7.3 我个人在项目里的几点体会最后分享几个用出来的经验。第一状态机的输入信号如果来自另一个时钟域一定要先同步过来再进状态机别让异步信号直接参与次态判断否则你会在波形里看到状态莫名其妙地抖。第二状态命名别偷懒IDLE、WAIT_ACK、SEND_DATA这种一眼能看懂的名字比S0、S1强太多三个月后再看代码你会感谢自己。第三如果状态机超过八九个状态画一张状态转移图贴在代码注释里比任何文档都管用别人接手时能省下大量时间。还有一点关于顺序的体会写三段式时我习惯先把状态转移图画出来再定编码和状态名然后才动手写代码。顺序反了往往写到一半发现状态定义不合理推倒重来。状态机这东西前期多想十分钟后期少调一晚上这是我在好几个项目里反复验证过的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑