Verilog 三段式状态机:原理、模板与实战

TL;DR

  • 职责分离:三段式把状态机拆成「状态寄存器(时序)→ 次态计算(组合)→ 输出逻辑」三块,跳变、译码、输出各管一摊,可读性和可维护性都更好。
  • 默认赋值防锁存器 :第二段进 case 前先写 nxt_state = cur_state;,第三段先给输出默认值,保证组合块每条路径都有赋值,避免综合出 latch。
  • 时序输出防毛刺 :输出跨模块或出片时优先用 always @(posedge clk) 寄存一拍,牺牲一个周期换取干净无毛刺的信号。
  • 状态编码按场景选 :状态少、追求速度用独热码(FPGA 常用),顺序迁移用格雷码降功耗,省寄存器用二进制码,可用 (* fsm_encoding = "one_hot" *) 显式指定。

1. 为什么推荐三段式状态机

在 Verilog 里写状态机(FSM),常见写法有一段式、两段式和三段式。一段式把状态跳转、次态计算和输出全塞进一个 always 块,代码短但可读性差、容易产生锁存器、综合后时序也难分析;两段式把时序逻辑和组合逻辑分开,但输出往往还是组合逻辑,容易在输出上产生毛刺。

三段式状态机把逻辑拆成三块,各司其职:

  • 第一段 :时序逻辑,只负责 current_state <= next_state 的状态寄存器更新;
  • 第二段 :组合逻辑,根据 current_state 和输入计算 next_state;
  • 第三段:输出逻辑,可以是组合输出,也可以是时序输出(寄存一拍)。

这样写的好处是结构清晰、状态跳转与输出解耦、便于综合工具优化,也方便后期插入流水线或做时序约束。代价是多写一些代码,但对可维护性来说完全值得。

2. 三段式的标准模板

先给出一个通用骨架,后面再填具体业务。假设状态用 localparam 编码,状态寄存器命名为 cur_state / nxt_state。

verilog 复制代码
module fsm_template (
    input  wire clk,
    input  wire rst_n,
    input  wire start,
    output reg  done
);

    // 状态编码:建议用 localparam,便于综合工具识别
    localparam S_IDLE  = 3'd0,
               S_WORK  = 3'd1,
               S_DONE  = 3'd2;

    reg [2:0] cur_state;
    reg [2:0] nxt_state;

    // 第一段:状态寄存器(时序逻辑)
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            cur_state <= S_IDLE;
        else
            cur_state <= nxt_state;
    end

    // 第二段:次态计算(组合逻辑)
    always @(*) begin
        nxt_state = cur_state;          // 默认保持,避免锁存器
        case (cur_state)
            S_IDLE: if (start) nxt_state = S_WORK;
            S_WORK:            nxt_state = S_DONE;
            S_DONE:            nxt_state = S_IDLE;
            default:           nxt_state = S_IDLE;
        endcase
    end

    // 第三段:输出逻辑(这里用组合输出)
    always @(*) begin
        done = 1'b0;                    // 默认值,避免锁存器
        case (cur_state)
            S_DONE: done = 1'b1;
            default: done = 1'b0;
        endcase
    end

endmodule

几个容易踩坑的点:

  1. 第二段必须给 nxt_state 默认赋值,否则综合出锁存器;
  2. 第三段同样要给输出默认值,理由相同;
  3. case 里加 default 分支,防止状态机跑飞后无法恢复;
  4. 状态编码用 localparam 而不是 define,作用域更安全。

3. 一个完整的实战例子:串口字节接收

下面用一个「检测到起始位后接收 8 位数据」的简化例子,把三段式落到实际场景。为了聚焦状态机本身,这里省略波特率分频,假设 bit_tick 是已经对齐好的采样脉冲。

verilog 复制代码
module uart_rx_fsm (
    input  wire       clk,
    input  wire       rst_n,
    input  wire       rx,          // 串行输入
    input  wire       bit_tick,    // 每位采样脉冲
    output reg  [7:0] rx_data,
    output reg        rx_valid
);

    localparam S_IDLE  = 3'd0,   // 等待起始位
               S_START = 3'd1,   // 起始位确认
               S_DATA  = 3'd2,   // 接收 8 位数据
               S_STOP  = 3'd3;   // 停止位

    reg [2:0] cur_state, nxt_state;
    reg [2:0] bit_cnt;               // 已接收位数
    reg [7:0] shift_reg;             // 移位寄存器

    // 第一段:状态寄存器
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            cur_state <= S_IDLE;
        else
            cur_state <= nxt_state;
    end

    // 第二段:次态计算
    always @(*) begin
        nxt_state = cur_state;
        case (cur_state)
            S_IDLE:  if (!rx)          nxt_state = S_START;
            S_START: if (bit_tick)     nxt_state = S_DATA;
            S_DATA:  if (bit_tick && bit_cnt == 3'd7) nxt_state = S_STOP;
            S_STOP:  if (bit_tick)     nxt_state = S_IDLE;
            default:                   nxt_state = S_IDLE;
        endcase
    end

    // 数据通路:位计数与移位(时序逻辑,与状态机并行)
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            bit_cnt   <= 3'd0;
            shift_reg <= 8'd0;
        end else if (cur_state == S_START && bit_tick) begin
            bit_cnt   <= 3'd0;
            shift_reg <= 8'd0;
        end else if (cur_state == S_DATA && bit_tick) begin
            bit_cnt   <= bit_cnt + 3'd1;
            shift_reg <= {rx, shift_reg[7:1]};   // LSB 先到
        end
    end

    // 第三段:输出逻辑(时序输出,避免毛刺)
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            rx_data  <= 8'd0;
            rx_valid <= 1'b0;
        end else begin
            rx_valid <= 1'b0;                    // 默认拉低,单周期脉冲
            if (cur_state == S_STOP && bit_tick) begin
                rx_data  <= shift_reg;
                rx_valid <= 1'b1;
            end
        end
    end

endmodule

这里第三段用了时序输出 :rx_valid 只在采样到停止位后的下一个时钟沿拉高一拍。相比组合输出,时序输出不会因为 cur_state 的译码组合逻辑产生毛刺,下游模块拿到的信号更干净。

把上面的 case 分支画成状态图,四个状态和跳转条件一目了然:
#mermaid-svg-L5gZNtXwQAxv8MIu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-L5gZNtXwQAxv8MIu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-L5gZNtXwQAxv8MIu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-L5gZNtXwQAxv8MIu .error-icon{fill:#552222;}#mermaid-svg-L5gZNtXwQAxv8MIu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-L5gZNtXwQAxv8MIu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-L5gZNtXwQAxv8MIu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-L5gZNtXwQAxv8MIu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-L5gZNtXwQAxv8MIu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-L5gZNtXwQAxv8MIu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-L5gZNtXwQAxv8MIu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-L5gZNtXwQAxv8MIu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-L5gZNtXwQAxv8MIu .marker.cross{stroke:#333333;}#mermaid-svg-L5gZNtXwQAxv8MIu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-L5gZNtXwQAxv8MIu p{margin:0;}#mermaid-svg-L5gZNtXwQAxv8MIu defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-L5gZNtXwQAxv8MIu g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-L5gZNtXwQAxv8MIu g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-L5gZNtXwQAxv8MIu g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-L5gZNtXwQAxv8MIu g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-L5gZNtXwQAxv8MIu g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-L5gZNtXwQAxv8MIu .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-L5gZNtXwQAxv8MIu .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-L5gZNtXwQAxv8MIu .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-L5gZNtXwQAxv8MIu .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-L5gZNtXwQAxv8MIu .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-L5gZNtXwQAxv8MIu .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-L5gZNtXwQAxv8MIu .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-L5gZNtXwQAxv8MIu .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-L5gZNtXwQAxv8MIu .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-L5gZNtXwQAxv8MIu .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-L5gZNtXwQAxv8MIu .edgeLabel .label text{fill:#333;}#mermaid-svg-L5gZNtXwQAxv8MIu .label div .edgeLabel{color:#333;}#mermaid-svg-L5gZNtXwQAxv8MIu .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-L5gZNtXwQAxv8MIu .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-L5gZNtXwQAxv8MIu .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-L5gZNtXwQAxv8MIu .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-L5gZNtXwQAxv8MIu .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-L5gZNtXwQAxv8MIu .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-L5gZNtXwQAxv8MIu .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-L5gZNtXwQAxv8MIu #statediagram-barbEnd{fill:#333333;}#mermaid-svg-L5gZNtXwQAxv8MIu .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-L5gZNtXwQAxv8MIu .cluster-label,#mermaid-svg-L5gZNtXwQAxv8MIu .nodeLabel{color:#131300;}#mermaid-svg-L5gZNtXwQAxv8MIu .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-L5gZNtXwQAxv8MIu .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-L5gZNtXwQAxv8MIu .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-L5gZNtXwQAxv8MIu .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-L5gZNtXwQAxv8MIu .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-L5gZNtXwQAxv8MIu .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-L5gZNtXwQAxv8MIu .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-L5gZNtXwQAxv8MIu .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-L5gZNtXwQAxv8MIu .note-edge{stroke-dasharray:5;}#mermaid-svg-L5gZNtXwQAxv8MIu .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-L5gZNtXwQAxv8MIu .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-L5gZNtXwQAxv8MIu .statediagram-note text{fill:black;}#mermaid-svg-L5gZNtXwQAxv8MIu .statediagram-note .nodeLabel{color:black;}#mermaid-svg-L5gZNtXwQAxv8MIu .statediagram .edgeLabel{color:red;}#mermaid-svg-L5gZNtXwQAxv8MIu #dependencyStart,#mermaid-svg-L5gZNtXwQAxv8MIu #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-L5gZNtXwQAxv8MIu .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-L5gZNtXwQAxv8MIu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} "!rx(检测到起始位低电平)"
"bit_tick(起始位采样确认)"
"bit_tick && bit_cnt != 7(继续移位接收)"
"bit_tick && bit_cnt == 7(8 位收满)"
"bit_tick(停止位采样完成)"
"rx 保持高电平(线路空闲)"
S_IDLE
S_START
S_DATA
S_STOP

各状态含义与跳转时机如下:

  • S_IDLE(空闲) :等待起始位。只要 rx 保持高电平就一直停在这里;一旦检测到 rx == 0(起始位下降沿后的低电平),立即跳到 S_START。
  • S_START(起始位确认) :不立刻采数据,而是等一个 bit_tick 把采样点对齐到起始位中心,确认这是真正的起始位而非毛刺,随后进入 S_DATA。
  • S_DATA(数据接收) :每个 bit_tick 采样一位并左移进 shift_reg,bit_cnt 同步加一;当 bit_cnt == 7 且 bit_tick 有效时说明 8 位已收满,跳到 S_STOP,否则原地自循环。
  • S_STOP(停止位) :等 bit_tick 采样停止位,同时把 shift_reg 锁存到 rx_data 并拉高 rx_valid 一拍,然后回到 S_IDLE 等待下一帧。

注意 S_DATA 的自循环是状态图里容易被忽略的一条边------它对应代码中 nxt_state = cur_state 的默认保持行为,画出来才能完整表达「收满 8 位才离开」的逻辑。

为了把状态图和代码一一对应起来,下面按状态逐行列出第二段 case 分支的代码片段、跳转条件表达式,以及第三段对应的输出行为:

状态 第二段 case 分支代码 跳转条件表达式 第三段输出行为
S_IDLE S_IDLE: if (!rx) nxt_state = S_START; !rx(检测到起始位低电平) 无输出动作,rx_valid 保持默认低电平
S_START S_START: if (bit_tick) nxt_state = S_DATA; bit_tick(起始位采样确认) 无输出动作;数据通路在此刻清零 bit_cnt 与 shift_reg
S_DATA S_DATA: if (bit_tick && bit_cnt == 3'd7) nxt_state = S_STOP; bit_tick && bit_cnt == 3'd7(8 位收满);否则保持 S_DATA 无输出动作;数据通路每个 bit_tick 移位采样并累加 bit_cnt
S_STOP S_STOP: if (bit_tick) nxt_state = S_IDLE; bit_tick(停止位采样完成) rx_data <= shift_reg; rx_valid <= 1'b1; 拉高一拍后回到 S_IDLE

对照时注意两点:一是 S_DATA 的「不满足条件就原地保持」来自 nxt_state = cur_state 的默认赋值,而不是单独写了一条分支;二是第三段的输出只在 S_STOP && bit_tick 时触发,其余状态都走 rx_valid <= 1'b0 的默认拉低,所以 rx_valid 是严格单周期脉冲。

4. 组合输出还是时序输出

第三段到底该用 always @(*) 还是 always @(posedge clk),是实际工程里最常纠结的问题。

对比项 组合输出 时序输出
延迟 无额外时钟延迟 晚一拍
毛刺 可能有 无
资源 省寄存器 多一级寄存器
时序收敛 输出路径长,易成关键路径 输出已寄存,易收敛
适用场景 输出直接驱动组合逻辑、对延迟敏感 输出跨模块、需要干净信号

以第 3 节串口接收例子里的 rx_valid 为例,两种输出方式在波形上的差异非常直观。假设 cur_state 在某个时钟沿从 S_DATA 跳到 S_STOP,随后在 S_STOP && bit_tick 成立时准备拉高有效信号:

  • 组合输出 :rx_valid 由 always @(*) 直接对 cur_state 译码得到,一旦 cur_state 的某几位在跳变过程中先后翻转(尤其是二进制编码下 S_DATA = 3'd2 → S_STOP = 3'd3 这类多位同时变化的情况),译码结果会在极短时间内出现中间态,rx_valid 上就可能冒出一个窄脉冲毛刺。这个毛刺宽度取决于组合逻辑延迟,仿真里未必能复现,但上板后足以让下游误采一次。
  • 时序输出 :rx_valid 是寄存器输出,只在时钟沿按 cur_state 的稳定值更新,因此波形上是一个与时钟严格对齐、宽度恰好一个周期的干净脉冲,代价是比状态跳变晚一拍出现。

下游模块的采样时机要跟着这两种情况分别配合:

  • 若上游是组合输出 ,下游不要用 rx_valid 直接做电平敏感的判断或异步触发,而应在时钟沿先把它打一拍(同步化)再使用,或者干脆要求上游改成时序输出;同时避免把组合输出直接接到异步复位、时钟使能这类对毛刺敏感的端口上。
  • 若上游是时序输出 ,下游可以放心地在时钟沿直接采样 rx_valid,但要注意它比状态跳变晚一拍------如果下游同时用到了 rx_data,应保证两者在同一拍被采样(本例中 rx_data 与 rx_valid 同在第三段寄存,天然对齐),不要出现「数据早一拍、有效晚一拍」的错位。

一句话总结:组合输出省寄存器但要靠下游同步化兜底,时序输出多一级寄存器却把时序契约变得明确------跨模块传递时,后者几乎总是更省心的选择。

经验做法:状态机输出如果直接送到别的模块或片外,优先用时序输出;如果只是本模块内部做条件判断,组合输出也可以接受。很多团队干脆统一用时序输出,牺牲一拍换稳定。

5. 状态编码方式怎么选

常见三种编码,综合工具通常也能自动选择,但显式指定更可控:

  • 二进制编码(Binary):状态少时省寄存器,但状态译码组合逻辑深,速度慢;
  • 格雷码(Gray):相邻状态只变一位,降低翻转功耗,适合状态按顺序迁移的场景;
  • 独热码(One-hot):每个状态一个触发器,译码逻辑最浅、速度最快,FPGA 里非常常用,代价是寄存器多。

在 FPGA 上,状态数不多(比如 10 个以内)时独热码往往是默认最优解;ASIC 里则要结合面积和功耗权衡。可以用综合属性显式指定:

verilog 复制代码
(* fsm_encoding = "one_hot" *) reg [3:0] cur_state;

6. 常见错误清单

写完三段式,建议对照下面这张表逐条自查,每条都给出代码级的修复写法:

错误现象 根本原因 修复方法
综合报告出现 latch,仿真正常但上板行为异常 第二段组合块里 nxt_state 只在部分分支被赋值,未覆盖所有路径,综合工具推断出锁存器 在 case 之前先给默认值:nxt_state = cur_state;,再进入 case,保证每条路径都有赋值
状态跑飞后无法自恢复,仿真出现 x 或卡死在非法状态 case 缺少 default 分支,非法状态编码没有对应的次态 补上 default: nxt_state = S_IDLE;,让非法状态自动回到空闲态
上电后移位寄存器、计数器初值不确定,首帧数据错乱 复位只复位了 cur_state,数据通路(bit_cnt、shift_reg)没有复位 在数据通路的时序块里同样加复位分支:if (!rst_n) begin bit_cnt <= 3'd0; shift_reg <= 8'd0; end
综合报错或行为与预期不符,寄存器被组合逻辑驱动 在第二段 always @(*) 里对时序信号用了阻塞/非阻塞赋值,组合块误驱动寄存器 组合块只给 nxt_state 赋值,寄存器更新一律放到 always @(posedge clk or negedge rst_n) 里用 <= 赋值
输出比状态晚一拍或早一拍,与预期时序不符 输出逻辑混用 cur_state 与 nxt_state,或时序输出未考虑寄存一拍 统一用 cur_state 译码;若需与状态同拍,改用组合输出 always @(*),若用时序输出则明确接受晚一拍
状态数超过 2^位宽,编码冲突、状态互相覆盖 状态寄存器位宽不足,localparam 编码超出可表示范围 按状态数确定位宽,如 4 个状态用 reg [2:0],8 个以上用 reg [3:0],并检查最大编码值 < 2^位宽

7. 小结

三段式状态机的核心思想是职责分离:状态寄存器只管跳变,组合逻辑只管算次态,输出逻辑单独处理。它不是什么高深技巧,而是一种让代码在综合、时序分析和后期维护上都更友好的工程习惯。

实际项目中,建议把上面的模板固化成团队规范:统一命名(cur_state / nxt_state)、统一默认赋值、统一 default 分支、统一输出方式。规范统一之后,读别人的状态机就像读自己的代码一样快。

目录

  • [1. 为什么推荐三段式状态机](#1. 为什么推荐三段式状态机)
  • [2. 三段式的标准模板](#2. 三段式的标准模板)
  • [3. 一个完整的实战例子:串口字节接收](#3. 一个完整的实战例子:串口字节接收)
  • [4. 组合输出还是时序输出](#4. 组合输出还是时序输出)
  • [5. 状态编码方式怎么选](#5. 状态编码方式怎么选)
  • [6. 常见错误清单](#6. 常见错误清单)
  • [7. 小结](#7. 小结)
相关推荐
XMAIPC_Robot2 小时前
NXP i.MX95 + FPGA 异构计算平台 — 工业多域融合 AI 加速嵌入式控制解决方案
人工智能·分布式·fpga开发·能源·rk3576+fpga
FPGA小迷弟14 小时前
用 Claude Code 重构遗留系统:从评估到落地的完整指南
fpga开发·dsp开发·fpga·1024程序员节·sdr·无线电
专注仿真20 小时前
Vivado FPGA 开发实战项目:从入门到上板
fpga开发
NamoAmitabha1281 天前
BSP,板级支持包--deepseek
单片机·嵌入式硬件·fpga开发
szxinmai主板定制专家1 天前
ARM 还是 x86?CODESYS 数控机床控制器选型、FPGA 取舍与实时性真相
arm开发·人工智能·fpga开发·zynq·1024程序员节·codesys
星夜离尘562 天前
FPGA 小数据字如何纠错:完整实现并验证扩展 Hamming (13,8) SECDED
fpga开发
通信王662 天前
ZYNQ架构下的AXI_Lite 总线详解(1)
fpga开发
星夜离尘562 天前
FPGA 不用浮点开平方:逐位恢复整数平方根、余数与完整握手验证
fpga开发
Enclustra_China2 天前
FPGA设计的隐藏难题:算力、功耗与散热如何平衡?
fpga开发·fpga·fpga解决方案