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
几个容易踩坑的点:
- 第二段必须给
nxt_state默认赋值,否则综合出锁存器; - 第三段同样要给输出默认值,理由相同;
case里加default分支,防止状态机跑飞后无法恢复;- 状态编码用
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. 小结)