第4章 状态机硬件描述语言实现:模板与代码风格

状态机写得好不好,直接决定你能不能睡得安稳。我见过太多同学在仿真里跑得欢,综合之后一塌糊涂。嗯,其实问题多半出在代码风格上。

4.1 状态编码方式——选对了省一半的麻烦

我个人习惯在项目早期就定好编码方式。编码选错,后期排查就像在迷宫里找出口。常见的有三种:

编码方式 优点 缺点 适用场景
二进制编码 状态寄存器位数最少(log₂N) 跳变时多位同时翻转,易产生毛刺 资源紧张、时序余量大
格雷码 相邻状态只有1位跳变,无毛刺 状态数不为2ⁿ时复杂 跨时钟域、异步接口
独热码 译码简单、速度最快 寄存器用量大(N位对应N个状态) 高速、FPGA综合友好

我在项目中遇到过独热码状态机因为漏写 default 导致综合出锁存器,仿真跑了两周才发现。后来我强制团队在 case 末尾加 default,并给独热码分配 unused 状态归到 IDLE。

4.2 三段式状态机——最不容易出问题的套路

你想想看,状态机最常见的问题是什么?输出组合逻辑和状态跳变混在一起,一不小心就生成了不想要的锁存器。所以我强烈推荐三段式:

  • 第一段:时钟沿触发的当前状态寄存器更新
  • 第二段:组合逻辑计算下一状态
  • 第三段:输出逻辑(组合或时序均可)

下面是一个典型的 Verilog 模板,我每次写新项目都会先敲这段框架:

module fsm_example (
  input  clk,
  input  rst_n,
  input  a, b,
  output reg y
);

  // 状态定义(独热码)
  localparam IDLE  = 3'b001;
  localparam WAIT  = 3'b010;
  localparam DONE  = 3'b100;

  reg [2:0] state, next_state;

  // 第一段:状态更新(时序逻辑)
  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 (a)          next_state = WAIT;
      WAIT: if (b)          next_state = DONE;
      DONE:                 next_state = IDLE;
      default:              next_state = IDLE;
    endcase
  end

  // 第三段:输出(组合逻辑,也可用时序)
  always @(*) begin
    y = 0;
    case (state)
      DONE: y = 1;
      default: ;
    endcase
  end

endmodule
注意:第二段和第三段是组合逻辑,记得用 @(*) 并赋初值 default。漏写 default 或者没赋初值,综合结果就会多出锁存器——我吃过这个亏,一次流片因为锁存器导致功耗超标。

4.3 VHDL 里的状态机——风格同样重要

VHDL 的写法比 Verilog 啰嗦一些,但思路一致。我曾经帮同事排查过一个 VHDL 状态机,他用了两个 process,一个时钟一个组合,但组合块内部的 else 漏了,结果多了一个隐含状态。记住,VHDL 里组合 process 也要把所有分支覆盖全。

下面是一个推荐结构:

type state_type is (IDLE, WAIT, DONE);
signal state, next_state : state_type;

-- 第一段:时序
process (clk, rst_n)
begin
  if rst_n = '0' then
    state <= IDLE;
  elsif rising_edge(clk) then
    state <= next_state;
  end if;
end process;

-- 第二段:组合(次态 + 输出)
process (state, a, b)
begin
  next_state <= state;
  y <= '0';
  case state is
    when IDLE =>
      if a = '1' then next_state <= WAIT; end if;
    when WAIT =>
      if b = '1' then next_state <= DONE; end if;
    when DONE =>
      next_state <= IDLE;
      y <= '1';
  end case;
end process;

我个人比较喜欢把次态和输出放在同一个组合 process 里,这样信号关系一目了然,排查时不用跳来跳去。

4.4 代码风格里藏着的坑

  • 非阻塞赋值混用:时序块用 <=,组合块用 =。混淆了综合结果直接乱掉。
  • 敏感列表不全:组合过程如果漏了输入信号,仿真能跑但硬件对不上。我用脚本检查敏感列表,确保没有遗漏。
  • 未初始化状态:上电后状态必须可预测。复位信号一定要把状态拉到已知值。
  • 状态机图与代码不一致:我习惯先画状态转移图,写代码时对照图每根线检查。图要保留作为文档。
我的一个小技巧:写状态机代码时,每个状态后面加一行注释,标注从该状态出发的所有转移条件。排查时你直接对照注释就能发现缺少的条件。

4.5 状态机编码选择流程

下面这张图可以帮你快速决策:

状态机编码选择流程 开始分析需求 寄存器资源是否紧张? 是 二进制编码 否 需要高速还是抗干扰? 高速 独热码 抗干扰 格雷码 完成编码选择 注意:独热码在FPGA中有时钟约束时最稳妥;格雷码适合跨时钟域,但需保证状态数对齐。 二进制编码虽然省寄存器,但组合逻辑延迟大,要留足时序余量。

这张图是我自己画决策时的逻辑。你拿来套用基本不会踩雷。

4.6 调试状态机——“三板斧”

如果你在排查一个跑偏的状态机,试试这三步:

  1. 读图对码:把代码状态转移画出来,和设计图对比。我经常发现代码里多了一个不该有的自环。
  2. 仿真加$monitor:在每个状态变化时打印当前状态和输入。用波形看太慢,打印更快定位到第一次出错的位置。
  3. 对综合报告:看综合后状态编码是否和你写的一致。我曾经遇到综合工具把独热码优化成了二进制,导致仿真和硬件对不上。

最后说一句实在的:状态机代码不是写给别人看的,是写给综合工具和自己的未来看的。风格清晰的人,排查时间至少减半。你按上面模板来写,后续无论是改需求还是定位 bug,都会轻松很多。