1. 先想清楚:状态机到底在解决什么问题
状态机这三个字,在数字逻辑里听起来挺唬人,但你要是用跑马灯/流水灯这个经典例子来学,十分钟就能把它的门道摸清楚。我见过不少人写流水灯,上来就是一大坨计数器 if else 嵌套,灯倒是能亮,可一旦想让灯换个跑法,整个人就崩溃了。而用 Verilog HDL 以状态机的方式实现流水灯,本质上就是把你对“灯序”的控制从一堆数字判断里抽出来,变成一张清晰的状态迁移图。这篇文章就围绕“怎么写好状态机”这件事,把流水灯从状态图、编码选择、三段式代码、仿真调试到避坑,从头到尾过一遍。想认真打数字逻辑基本功的初学者、准备面试的在校生,以及所有写 Verilog 写到“会跑但不敢改”的人,都值得花一顿饭的时间看完。
1.1 流水灯的本质是对“时间顺序”的控制
来想想最常见的需求:8 个 LED,从左到右依次点亮,每个灯亮 0.5 秒,然后循环。很多初学者会下意识地写一个计数器 cnt,从 0 加到 25_000_000,然后在某个区间把对应的 LED 置 1。比如 0~25M 亮 LED0,25M~50M 亮 LED1,以此类推。这么写不是不行,但两个问题非常现实。
第一,可读性差。整个代码里全是区间判断,别人看你的代码,得在脑子里把 25M、50M、75M 这些数字换算成人话,才能知道“哦原来是在跑灯”。三个月后的自己再看,同样一脸懵。第二,不可扩展。需求稍微一改,比如“两个灯交替亮灭”“先左后右来回扫”“从中间往两边扩散”,原来的 if else 嵌套基本就要推翻重写。一句“需求改一下”,计数器逻辑就崩了。
状态机的做法则是把“时间进展”和“灯序变化”拆开。灯序变化看状态,时间进展只看一个“时间到没到”的事件信号。系统设计就不再是一坨判断,而是有清晰边界的几个模块:计数器负责报时,状态机负责决定下一步灯长什么样。
这样的分离看起来是绕了远路,但对工程的意义非常大,因为变化点是孤立的。改灯序只需要改状态机的迁移关系,模块代码边界清楚,改完不担心影响计时部分。
1.2 用状态机的三要素拆解流水灯
状态机说得再玄,本质上就是三个东西:状态、事件、动作。状态是系统当前处于哪个稳定阶段;事件是让状态发生迁移的条件;动作是当前状态下对外部输出的表现。用流水灯对号入座就是:
| 三要素 | 流水灯里的实体 |
|---|---|
| 状态 | 当前轮到哪一路 LED 亮,比如 S0~S7 |
| 事件 | 定时脉冲 tick 到达,表示 0.5 秒已过 |
| 动作 | LED 输出表现为对应位置置 1 |
这个建模看似简单,其实已经把最重要的事想清楚了:状态的数目是固定的,不会跟随“0.5 秒”这个时间值变大而膨胀。时间多长,是计数器的事,状态机只管“tick 来没来”。
再套一个日常类比帮助理解。收银台找零时,收银员处于“收钱”状态,只有确认“钱到位”这个事件发生,才会跳到“找零”状态;找零做完等“顾客走人”事件回到“空闲”状态。他不需要在“收钱”状态里反复数 100 遍钱,只需要知道事件到没到、当前在哪一步。流水灯的状态机逻辑,和收银员的状态切换没有任何本质区别。
如果你想让流水灯有复杂的招式,比如一个状态是“奇数位 LED 全亮”,下一个状态是“偶数位全亮”,再下一个“全亮”再“全灭”,那状态的定义就不只是“灯1亮”,而是“某种显示模式”。这也是状态机的价值所在——它不关心你内部是什么含义,只负责把迁移关系描述清楚。
2. 写代码之前的两个决定:状态图和状态编码
模型已经清楚了,接下来是落笔前的两个关键决定。我建议不管多简单的状态机,都先把状态图和编码方式定了再写 Verilog,否则写着写着就会乱。
2.1 怎么把状态图画到能直接写代码
对于 8 个灯依次点亮的单灯流水,状态图非常简单:S0 亮第 1 个灯,tick 到来进 S1;S1 亮第 2 个灯,tick 到来进 S2……直到 S7 点亮最后一个灯,tick 到来回到 S0。画出来就是一条首尾相接的环。
画图的意义不只是“画个流程”,而是强迫你回答两个问题。第一,状态之间迁移条件是不是明确?第二,有没有状态永远出不去?比如如果你定义了 S8,迁移条件却只写了 S8 进 S9 而 S9 又回到 S8,那 S7 到 S8 之间一旦条件永远不满足,灯就永远停在第 8 个位置。画图时这些问题会暴露得非常快。
更复杂的图形你也要画。比如来回扫描:S0→S1→…→S7→S6→…→S1→S0。这个图里从 S1 出来有两条路,向左还是向右取决于方向标志,而不是单纯的时间点。画图时你会发现“来回扫”的状态机天然需要一个方向信号,或者需要把 S1、S6 这类状态拆成“S1_left”“S1_right”两个状态来处理边界。这些抽象工作如果不先做,直接写代码很容易写出满屏的 if 嵌套。
所以我的建议是,状态图画到“对着它就能机械地列出 case 分支”的程度。每个状态写一行,迁移箭头标注事件,这就是后面组合逻辑 case 的原型。流水灯虽小,这个习惯值得养成。
2.2 状态编码:二进制、格雷码、独热码怎么选
状态图定了,接下来是状态编码。常见有三种:二进制码、格雷码、独热码。简单对比一下:
| 编码方式 | 状态数所需位数 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 二进制码 | log2(n) | 寄存器最省 | 相邻状态可能多位翻转,组合逻辑稍复杂 | 状态多、资源紧张 |
| 格雷码 | log2(n) | 相邻状态只有 1 位翻转,功耗低 | 状态不是连续增序时编码难写 | 相邻迁移为主、低功耗场景 |
| 独热码 | n | 译码简单、时序好、可读性强 | 多占寄存器 | FPGA 寄存器多、状态数不太多 |
流水灯这里 8 个状态,二进制码只需要 3 位,独热码要 8 位。从资源角度二进制肯定更省,但我仍然推荐在流水灯这个例子里用独热码,原因是它有个天然优势:状态编码本身可以直接作为 LED 输出。
如果你定义 S0=8'b0000_0001、S1=8'b0000_0010、S2=8'b0000_0100……那么“当前状态”和“LED 输出”就是同一个东西。状态寄存器里存着什么,灯上就亮什么,连专门做输出的译码逻辑都省了。这个场景简直是独热码的教科书用法。代价是 8 位寄存器比 3 位寄存器多了 5 个触发器的开销,在 FPGA 里这点资源根本不必心疼,状态数量几十上百时再回头考虑二进制码不迟。
还有一个容易被忽略的点:独热码的非法状态特别多。8 位独热码合法状态只有 8 个,非法状态却有 248 种。所以后面写 case 时,default 分支绝对不能省,这是保证状态机“跑飞”后能救回来的最后防线。
3. 三段式状态机怎么写:一份能直接抄的模板
编码定了,模板也该上场了。Verilog 写状态机有几种常见写法,工程上我强烈建议用三段式。先把三种写法的差异讲清楚,然后给出一份完整的流水灯代码。
3.1 一段式、二段式、三段式的区别
一段式就是所有逻辑塞进一个 always 块里,既做状态寄存又做次态判断还要同时输出。这种写法对初学者来说最容易“一次通过”,但状态变量和输出混在一起,连修改一个 LED 亮灭规则都可能引入时序 bug。而且综合工具对这种写法识别出的状态机结构不够清晰,调试时非常难单步观察状态变化,代码规范评审也基本会被打回。
二段式把状态寄存器和次态组合逻辑放在一个 always 时序块里,输出另起一个块处理。常见写法是用一个 always 把当前态赋值到次态的同时完成跳转。优点是比一段式清楚,但次态计算依然和状态打拍耦合在一起,想单独对着状态图改迁移关系,难免要动时序逻辑的边界。
三段式则严格分成三段:第一段是时序逻辑,负责把 next_state 打一拍存成 state;第二段是组合逻辑,负责根据 state 和输入计算出 next_state;第三段是输出逻辑,负责把 state(或输入加 state)翻译成输出信号。这样做的好处是“迁移逻辑”和“时序打拍”彻底分开。想改状态图就只动第二段的 case,想调整输出就只动第三段,状态寄存那段基本一辈子不用改。仿真时只看 state 波形就能确认迁移关系有没有错,非常直观。
3.2 分频脉冲模块:先把“时间”变成“事件”
在写状态机主体之前,必须先解决时间问题。系统时钟通常几十兆赫兹,状态机如果每个时钟周期都跳,灯会闪得人眼完全感知不到。所以需要一个分频计数器,把长时间的计时转成一个“tick 脉冲”。
这里我特意强调“tick 脉冲”而不是“分频时钟”。很多教程喜欢直接生成一个低频时钟,然后用这个低频时钟的上升沿驱动状态机,这种做法在真正的工程项目里风险很高:分频时钟不是专用时钟网络产生的时钟,走线和扇出都可能不均衡,时序约束也不好做,板上极易出现偶发性的状态跳变。更稳的做法是保持状态机始终使用系统时钟,计数器只负责周期性地产出一个宽度为一个时钟周期的脉冲,状态机在系统时钟沿采样到这个脉冲才迁移。
下面是一个通用的脉冲生成模块:
module pulse_gen #( parameter CNT_MAX = 24_999_999 )( input wire clk, input wire rst_n, output reg pulse ); reg [24:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 25'd0; pulse <= 1'b0; end else if (cnt == CNT_MAX) begin cnt <= 25'd0; pulse <= 1'b1; // 输出一个时钟周期的脉冲 end else begin cnt <= cnt + 1'b1; pulse <= 1'b0; end end endmodule这样 pulse 是寄存器输出,不会因为组合比较逻辑产生毛刺。需要注意,如果系统时钟是 50MHz,要让每个灯亮 0.5 秒,那么从 0 数到 24_999_999 正好是 0.5 秒,CNT_MAX 取 24_999_999。计数范围是 0~24_999_999,一共 25_000_000 个时钟周期,这个数需要 25 位才能表示,所以计数器位宽写成 reg [24:0]。常见错误是写成 24 位,那样计数器数到 16M 后会自动回绕,永远等不到 tick,灯就一动不动。位宽问题后面还会专门讲。
你也完全可以把 CNT_MAX 做成可配置的 parameter,这样仿真时可以改小,板上可以改大,一套代码两种用法。
3.3 状态机主体代码逐段讲解
脉冲模块有了,接下来是状态机主体。我用独热码定义 8 个状态,LED 输出直接等于状态寄存器。
module led_ctrl #( parameter RST_LED = 8'b0000_0001 )( input wire clk, input wire rst_n, input wire tick, output wire [7:0] led ); localparam S0 = 8'b0000_0001; localparam S1 = 8'b0000_0010; localparam S2 = 8'b0000_0100; localparam S3 = 8'b0000_1000; localparam S4 = 8'b0001_0000; localparam S5 = 8'b0010_0000; localparam S6 = 8'b0100_0000; localparam S7 = 8'b1000_0000; reg [7:0] state; reg [7:0] next_state; // 第一段:状态寄存,时序逻辑 always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= S0; else if (tick) state <= next_state; end // 第二段:次态计算,纯组合逻辑 always @(*) begin next_state = state; // 默认保持当前状态 case (state) S0: next_state = S1; S1: next_state = S2; S2: next_state = S3; S3: next_state = S4; S4: next_state = S5; S5: next_state = S6; S6: next_state = S7; S7: next_state = S0; default: next_state = S0; // 非法状态一律回到起点 endcase end // 第三段:输出逻辑。独热码状态本身直接作为 LED 输出 assign led = state; endmodule逐段解释一下。第一段是时序逻辑,遵顼“复位到初始状态、tick 到达时更新为 next_state”的原则。之所以加“else if (tick)”,是为了让状态迁移和视觉节奏同步。tick 相当于事件,状态机只在事件发生时换状态,其余时间保持,这样 LED 才能稳定地亮够 0.5 秒,而不是每个时钟周期都变。
第二段是纯组合逻辑,这里有一个非常值得养成的习惯:always 块的第一行先给 next_state 一个默认值,然后再用 case 覆盖。上面代码里默认值是“保持当前状态”,也可以写“回到 S0”,效果略有差别。默认值是为了保证组合逻辑在所有分支下都有明确赋值,否则综合工具可能会推断出锁存器,仿真时也容易出现 X 态。
第三段是输出逻辑,这里因为独热码就是输出,所以直接 assign。如果 LED 是低电平点亮,就写成assign led = ~state;,具体要看开发板上 LED 的接法。
整个工程再把两个模块连起来:
module led_flow #( parameter STEP_CNT = 25_000_000 )( input wire clk, input wire rst_n, output wire [7:0] led ); wire tick; pulse_gen #(.CNT_MAX(STEP_CNT - 1)) u_pulse ( .clk (clk), .rst_n (rst_n), .pulse (tick) ); led_ctrl u_ctrl ( .clk (clk), .rst_n (rst_n), .tick (tick), .led (led) ); endmodule顶层把计时和驱动 LED 的两部分拼接在一起,参数 STEP_CNT 全工程可调。这就是一个完整的、可以直接上板点亮 8 个 LED 的流水灯工程。
从语法角度还有一个细节值得注意:pulse_gen 的位宽应该跟 CNT_MAX 匹配。上面我把计数器位宽写死成了 25 位,这在 CNT_MAX 固定时没问题,但如果想完全参数化,可以写一个 clog2 取整函数来自动计算位宽。我自己的习惯是先把参数改成需要的值,然后手动算一次位宽,注释写明“2^24 = 16,777,216,不够;2^25 = 33,554,432,够”,避免后面改参数忘了同步位宽。
4. 仿真验证:不看波形就等于白写
代码写完了,别急着上板,先仿真。流水灯这种设计,仿真多花十分钟,能省下上板后排错的一小时。
4.1 用参数重写缩短仿真时间
如果按真实参数跑仿真,tick 要等 25M 个时钟周期才产生一次,看一个完整循环就要近 2 亿个周期,哪怕仿真器能撑住,定位问题也不方便。所以真正实用的做法是在 testbench 里把 STEP_CNT 覆盖成一个很小的数,让每几个时钟周期就 tick 一次。
`timescale 1ns/1ps module tb_led_flow; reg clk = 1'b0; reg rst_n = 1'b0; wire [7:0] led; always #10 clk = ~clk; // 产生 20ns 周期时钟 led_flow #( .STEP_CNT(9) // 每 9 个时钟 tick 一次 ) u_dut ( .clk (clk), .rst_n (rst_n), .led (led) ); // 记录 LED 变化,方便在日志里直接观察 reg [7:0] led_d = 8'b0000_0001; always @(posedge clk) begin led_d <= led; if (led_d !== led) $display("t=%0t, led=%b", $time, led); end initial begin #30 rst_n = 1'b1; #2000 $finish; end endmodule把 STEP_CNT 设成 9,计数器从 0 数到 8,也就是每 9 个时钟 tick 一次,100 个时钟周期就能看到完整的 8 个灯轮换。注意 STEP_CNT - 1 会传入 pulse_gen 作为 CNT_MAX,所以设成 9 实际是 9 个周期一次脉冲。打印语句里用了一个寄存器 led_d 来寄存上一拍的 LED 值,然后和当前拍比较、检测变化。之所以用 led_d 而不是直接比较,是为了避免在组合逻辑里做波形沿监测造成误报。
4.2 波形里必须确认的五个关键点
仿真波形拉出来之后,别急着喊“灯亮了”,有几个点必须逐个核对。
第一,复位后的初始态。rst_n 释放后,led 应该在 0000_0001,绝对不能是 X 态。如果看到 X,说明复位没有把 state 初始化干净,或者输出逻辑引用了未初始化的中间信号。
第二,tick 脉冲的宽度和周期。tick 应该是一个时钟周期宽,每隔 STEP_CNT 个周期出现一次。如果 tick 一直为高,说明计数器比较逻辑写错了,或者把低有效信号和高有效信号搞反了。
第三,状态迁移的节奏。led 在两个 tick 之间必须保持稳定不变,tick 到达的那一拍才跳变到下一个独热值。如果 led 出现了半拍左右的毛刺,可能是输出组合逻辑存在问题。
第四,循环回跳。S7 之后的 tick 到达,led 应该从 1000_0000 回到 0000_0001,而不是停在某个状态不动。这一个点能直接暴露状态迁移图里有没有“断头路”。
第五,非法状态恢复。可以在仿真中人为把 state 强行赋成一个非法值(比如用 force 命令),观察下一个 tick 是不是被 default 分支拉回 S0。这种测试平时不起眼,真上板遇到外部干扰时,就是救命的保障。
5. 新手最容易踩的四个坑
看多了各式各样的流水灯代码之后,我发现大多数问题其实高度集中,真正常踩的坑就那几个。
5.1 没有 default 导致状态机“跑飞”
case 语句如果漏了 default 分支,状态机一旦进入未定义状态就直接“跑飞”。跑飞的后果是迁移关系变得不可预测,输出可能乱跳,也可能卡死在某个位置。尤其是独热码,非法状态比例极高,随便一个干扰或者复位不干净就可能进入 0000_0000 这种组合,default 就是唯一能把它拉回来的出口。
我见过一些代码把 default 写成 next_state = state,意思是“非法状态也保持”。这对独热码来说是很差的选择,等于默认“非法状态也要继续跑下去”,状态机永远回不到正常了。我的建议是 default 一律回到安全的初始状态,流水灯这种简单场景回 S0 最合理。对于更复杂的系统,也可以回到某个 idle 状态,原则是一样的:有一条明确的恢复路径。
5.2 分频时钟直接驱动状态机
有些老教程会教“生成一个低频时钟,再用它驱动状态机”。原理上没毛病,但工程上一定要谨慎。分频时钟如果是由计数器最高位翻转或者组合逻辑生成的,它在 FPGA 内部不会走专用时钟网络,扇出到多个触发器时各路径延迟不一致,板上很容易出现时序违例。
更隐蔽的是仿真通常“看不出来”。仿真器默认所有信号零延迟或固定延迟,分频时钟照样能推动状态跳变,而时序分析工具看到这种生成时钟会放宽检查,真正的风险只有上板后在示波器上才暴露。所以我一直强调:所有时序逻辑统一用系统时钟,计时电路只产生 tick 事件,让状态机自己去采样。这套做法在综合、实现、约束上都干净得多。
5.3 组合逻辑输出毛刺
如果 LED 输出是组合逻辑根据状态算出来的,比如assign led = (state == S3) ? 8'hF0 : 8'h0F;,在输入信号电平变化的瞬间就可能产生毛刺。虽然 LED 是人眼观察,毛刺大多无感,但一旦这套逻辑后面还接其他数字电路,毛刺被当成有效信号采进去就是隐患。
更干净的输出是寄存器输出。你可以像上面 led_ctrl 那样让状态编码本身就是输出,也可以专门再打一拍:
always @(posedge clk or negedge rst_n) begin if (!rst_n) led <= 8'b0000_0001; else if (tick) led <= next_state; end这样输出信号与时钟沿完全同步,后续接任何数字模块都更安心。代价只是多了一级寄存器延迟,流水灯这种低速应用完全无所谓。
5.4 计数器位宽算错
这个问题在仿真中很容易暴露,但到了上板阶段就隐蔽多了。很多初学者算位宽时只想到“25M 大概要 24 位吧”,结果 24 位计数器最大只能数到 16_777_215,根本到不了 24_999_999,于是 tick 永远不产生,灯不亮。查了半天却是这种低级问题,非常磨心态。
最直接的办法是口算:看 2 的多少次方覆盖到目标值。2^24=16_777_216,2^25=33_554_432,既然 25_000_000 落在两者之间,位宽就是 25。如果不想每次手算,可以在模块里加一个 clog2 函数动态计算位宽,或者干脆写注释标明“本参数对应 25 位计数器”。关键是别用一个大到离谱的位宽来“以防万一”,那会让比较逻辑白白浪费资源。
我把这几个坑整理成一句话版的速查:case 必须带 default;分频只做 tick;输出尽量寄存器化;位宽一定要覆盖计数上限。写代码前后各默念一遍,踩坑率能降一大截。
6. 从流水灯里提炼出的状态机通用方法论
流水灯做完只是开始。这个例子真正的价值在于,它把状态机设计的几个核心方法论浓缩到了最小规模,往后做更复杂的控制逻辑,骨架还是这一套。
6.1 事件驱动:把“计时”和“跳转”解耦
流水灯里最常用的模式是“计数器 + tick 事件 + 状态机”。计数器负责把长时间的等待浓缩成一个脉冲,状态机负责在脉冲到来时决定下一步要干什么。这两件事一旦耦合,比如在状态机里直接数时钟周期,代码很快就会失控。
我给大家一个判断标准:如果你发现状态图越画越臃肿,每个状态都在等待某种“超时”,那大概率是你没有把时间事件抽象出来。串口接收里的波特率时钟中断、按键消抖里的 debounce 计时完成、通信协议里的超时重传,本质上都是“计数器产生事件、状态机响应事件”。能把这个层次分清楚,状态机的复杂度和可维护性就会有质的差别。
流水灯的状态机不关心 0.5 秒是怎么来的,它只知道每个 tick 来到就换一个状态。这种对计时细节的“不关心”,恰恰是它稳定可靠的原因。以后你写更复杂的状态机,也尽量保持状态机内部不直接处理长计时,只为每个事件设计独立的产生模块。
6.2 花式流水和模式切换的扩展思路
代码如果停在“单灯向右”,那只是练了个手。真正体现状态机威力的是加点需求,比如:增加一个按键,按下后切换三种流水模式。
- 模式 0:单灯向右跑
- 模式 1:单灯来回扫
- 模式 2:奇数位全亮、偶数位全亮交替
实现上,计数器 tick 模块完全不用动。你只需要给 led_ctrl 增加一个 mode 输入,第二段的次态组合逻辑根据 mode 选择不同的状态图。切换模式时还可以让状态机从该模式的初始状态重新开始,避免上一模式的残留状态影响下一模式的显示。这种扩展对状态机来说,只是多画了几条迁移箭头,并没有破坏整个骨架。
再往深了做,你还可以把“模式切换”本身抽象成一个状态机:等待按键、消抖确认、切换模式、进入对应流水模式。两级状态机嵌套在复杂系统里非常常见。从流水灯这个最小例子出发,把这些扩展都跑通,你对状态机的理解就真正到位了。
最后说点个人体会。我刚接触状态机时也写过一段式,能跑但不敢动,后来强迫自己每回都画状态图、用三段式、默认分支加全、仿真先看 tick 再看 state,才慢慢形成肌肉记忆。流水灯是个很小的题目,但它逼你把状态机这条路上的所有细节都过了一遍。等哪天真遇到复杂的协议解析,你会感谢今天认真抠过的这盏灯。