Menu

Verilog 有限状态机:标准的 FSM 写法

像专业工程师那样写 Verilog 状态机:一个时钟驱动的状态寄存器,一个组合逻辑的次态块,两者清晰分离,易读也易综合。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

FSM 是什么

有限状态机是这样一种控制器:

  • 在任一时刻处于一组固定的具名状态中的某一个。
  • 根据输入(也可能根据当前状态)在状态之间跳转。
  • 产生的输出取决于当前状态(也可能取决于当前输入)。

这涵盖了数字设计中多得惊人的部分:交通灯控制器、UART 发送器、存储器控制器、网络协议处理器、指令译码器,以及任何具有离散工作模式的东西。

FSM 和数据通路逻辑感觉不同的地方在于,它们处理的是状态,而不是数值。计数器依次走过 1, 2, 3, 4;FSM 依次走过 IDLE, FETCHING, BUSY, DONE。形式一样,但状态有名字,跳转有含义。

两段式写法

标准的 Verilog FSM 使用两个 always 块:

  1. 一个时钟驱动的块,在每个时钟沿锁存状态寄存器。使用非阻塞赋值。很小,通常只有三行。
  2. 一个组合逻辑块,对当前状态做 case,计算次态和输出。使用阻塞赋值。“有意思”的代码都在这里。

这种分离有三个好处:它迫使你明确地思考状态;生成的硬件能清晰地对应为“一个寄存器加一个组合逻辑块”;而且这是标准写法,任何读你 Verilog 代码的人都能一眼认出来。

完整示例:序列检测器

一个经典的教学用 FSM:在串行输入上检测位序列 1011,序列完成时输出一个单周期脉冲。

两段式结构就是上面列出的那样。几个让代码保持干净的细节值得一提:

  • 组合逻辑块开头的默认值。 next_state = state(保持原状态)和 detected = 1'b0(不输出脉冲)是“什么都不做”的赋值。之后每个 case 分支只需设置有变化的内容。这样就不可能推断出锁存器。
  • 用 localparam 定义状态名。 读这个模块的人想的是 S0、S1、S2、S3,而不是 3'd0、3'd1。替换的工作交给综合器。
  • 时钟块中没有输出。 所有“这个状态做什么”的逻辑都在组合逻辑块里。时钟块除了保存当前状态,不负责任何事情。

Moore 与 Mealy

Moore:输出只取决于当前状态。 Mealy:输出取决于当前状态和当前输入。

在上面的例子中,detected 是在 S3 分支里设置的,并且只有当 in 与预期的某个序列完成模式匹配时才会设置。这是一个 Mealy 输出:除了 state,它还依赖 in。Moore 版本会为“刚刚检测到”单独设一个状态,只要处于该状态就设置 detected = 1;输出会晚一个周期出现脉冲,但永远不会受到 in 上毛刺的影响。

两种风格都没问题。教科书默认用 Moore,因为输入在周期中途变化时,输出保证不会产生毛刺。Mealy 更快(由输入驱动的输出没有寄存器延迟),在很多情况下生成的硬件也更小。根据你要实现的协议来选择。

状态编码:二进制、独热码、格雷码

给每个状态分配的位模式会影响面积和速度:

  • 二进制(S0 = 3'd0、S1 = 3'd1 …):状态寄存器最小,NN 个状态只需 ⌈log⁡2N⌉\lceil \log_2 N \rceil 位。译码逻辑最多。
  • 独热码(S0 = 4'b0001、S1 = 4'b0010 …):N 个状态用 N 位。译码逻辑极其简单(每个状态就是一根线),跳转也快。FPGA 通常默认使用它。
  • 格雷码:相邻状态只相差一位。当状态位需要跨时钟域时很有用。

大多数现代综合工具会替你选择编码(Vivado、Quartus、Design Compiler 都有自动模式,会逐一尝试并选出最好的)。你很少需要手动指定。真要指定时,大多数工具都接受 attribute 注解或 (* fsm_encoding = "one_hot" *) 这样的编译指示。

三段式变体

偶尔你会看到 FSM 被拆成三部分:一个时钟块负责状态,一个组合逻辑块负责次态,另一个组合逻辑块负责输出。这其实就是把两段式写法里的输出计算单独提取到一个块中:

// State register
always @(posedge clk) ...

// Next-state logic
always @(*) ...

// Output logic
always @(*) begin
    case (state)
        ...
    endcase
end

当输出很多、和次态逻辑放在同一个块里会显得杂乱时,单独写输出的风格很有用。对于小型 FSM 来说就没必要了。

default 在 FSM 中的作用

每个 FSM 的 case 语句都应该有一个 default 分支。原因有二:

  1. 安全:如果状态寄存器因为某种原因取到了非法值(数据损坏、bug、部分复位),default 能把它带回一个已知状态。
  2. 给综合器的提示:当显式列出的分支已经穷尽所有情况时(例如 2 位状态的 4 个值都处理了),default: next_state = 'x; 是在告诉综合器“我保证 default 不可达,尽管优化”。如果这条不可达路径在仿真中真的被走到了,产生的 x 会传播开来,立刻暴露 bug。
default: begin
    next_state = S0;   // safe recovery
    // or
    next_state = 'x;   // unreachable, optimize freely
end

根据你是否已经证明 default 真的不可达来选择。

需要注意的问题

忘记在组合逻辑块开头写默认值。 没有 next_state = state 和输出的默认值,只要某个分支没有给所有信号赋值,就会漏出一个锁存器。

把输出放进时钟块。 如果 detected <= 1 写在 always @(posedge clk) 块里,输出就会被寄存,晚一个周期才出现。这可以是有意为之(“寄存的 Mealy”输出),但当规格要求立即输出脉冲时,这是一个常见的设计失误。

混用阻塞和非阻塞赋值。 时钟块用 <=,组合逻辑块用 =。在同一个块中混用两者会造成竞争条件。

组合逻辑 always 块引用 next_state 又给 state 赋值。 这会形成一个仿真器无法解析的反馈环。state 归时钟块所有,next_state 归组合逻辑块所有,绝不要让任何一方去动另一方的变量。

接下来

现在你可以构建任何能描述出来的控制器了。下一章暂时离开可综合设计,讲解用来验证设计的测试平台:如何施加激励、观察输出、转储波形,以及确认你的模块真的做了你以为它在做的事。

常见问题

Verilog 中的有限状态机是什么?

FSM 是一种控制器,它在任一时刻处于一小组具名状态中的某一个,并根据输入在状态之间跳转。在 Verilog 中,标准实现由两个块组成:一个时钟驱动的 always,在每个时钟沿更新状态寄存器;一个组合逻辑的 always,根据当前状态和输入计算次态和输出。

Verilog 中标准的 FSM 写法是什么?

两段式 FSM:一个时钟驱动的 always @(posedge clk) 块保存状态寄存器,使用非阻塞赋值;一个组合逻辑的 always @(*) 块对当前状态做 case,计算次态和输出。这样分开写,代码易读、易于 lint 检查,也能干净地综合。

Mealy 状态机和 Moore 状态机有什么区别?

Moore 状态机的输出只取决于当前状态。Mealy 状态机的输出同时取决于当前状态和当前输入。Mealy 机的响应快一个周期(依赖输入的输出没有寄存器延迟),但如果输入在周期中途变化,可能产生毛刺。Moore 机慢一个周期,但更可预测,除非你需要那点速度,否则默认选它。

Verilog 中如何对状态进行编码?

在模块内用 localparam 常量,例如 localparam IDLE = 3'd0;。常见的编码有三种:二进制(状态依次为 0、1、2 …,状态寄存器最小)、独热码(每个状态一位,每次跳转的逻辑级数更少)和格雷码(相邻状态只差一位,毛刺最少)。综合工具通常会替你选择编码,很少需要手动指定。

Coddy 编程语言插图

和 Coddy 一起学编程

开始