被问得最多的不是“怎么用移位寄存器写流水灯”,而是“怎么写好状态机”。借着跑马灯/流水灯这个经典题目,我把 Verilog HDL 里状态机的思考方式和落地写法完整梳理一遍。无论你是刚开始学 FPGA,还是写过一段代码但总被“时序跑飞”“状态乱跳”折磨,这篇都值得对照着看。
跑马灯之所以适合拿来练状态机,是因为它的状态数量少、LED 输出直观、转移条件可控,踩过的坑能直接迁移到按键消抖、UART 接收、交通灯这类真实控制器上。这篇文章会把状态图怎么画、Moore/Mealy 怎么选、编码方式怎么定、三段式代码怎么拆、仿真波形怎么看串起来讲,最后再给几个工程化扩展和实战翻车教训。
1. 状态机不是语法题:先画状态图再谈代码
很多初学者把状态机当成一种语法,看到always + case就以为会了。真正的问题从来不是怎么敲代码,而是你知不知道系统里到底有哪几个状态、每个状态在什么条件下离开、离开之后去往哪里。代码只是把状态图画出来而已,图画错了,代码写再规范也是错的。
1.1 跑马灯里到底藏着哪些状态
一个最基本的四灯跑马灯,需求通常是:系统复位后 LED 全灭;按下启动使能后,只有一盏灯亮;每隔固定时间(比如 100ms)亮灯位置向后移动一个;移动到最后一盏后回到第一盏,循环往复。
这个需求里,天然存在五个状态:一个“空闲/全灭”状态,四个“当前点亮第几盏灯”的状态。很多人只写四个状态,把复位后 LED 全灭这件事交给复位信号本身,漏掉空闲状态。这会导致一个很尴尬的问题:一旦使能信号拉低,LED 停在当前灯上,而不是回到全灭的初始状态。如果你希望“使能无效时停止闪烁并全灭”,那么空闲状态就是必须的。
| 状态 | 含义 | LED 输出 |
|---|---|---|
| IDLE | 空闲,全灭 | 4'b0000 |
| S_LED0 | 点亮第 0 盏灯 | 4'b0001 |
| S_LED1 | 点亮第 1 盏灯 | 4'b0010 |
| S_LED2 | 点亮第 2 盏灯 | 4'b0100 |
| S_LED3 | 点亮第 3 盏灯 | 4'b1000 |
如果再加方向控制,状态数量不变,变化的是每个状态离开时的目标:正向时S_LED0 -> S_LED1,反向时S_LED0 -> S_LED3。这个观察很重要,很多人一加“方向可调”就想把状态数量翻倍,实际上控制逻辑变了,状态并没有变多。
1.2 把需求翻译成状态表的具体步骤
写状态机之前,我习惯先做一张状态转移表。这张表不急着写case,而是用自然语言把每个状态的出口条件列清楚。
拿上面带方向控制的四灯跑马灯为例:
| 现态 | 转移条件 | 次态 |
|---|---|---|
| IDLE | en 为低 | IDLE |
| IDLE | en 为高 | S_LED0 |
| S_LED0 | 未到计时结束 | S_LED0 |
| S_LED0 | 计时结束且 dir=1 | S_LED1 |
| S_LED0 | 计时结束且 dir=0 | S_LED3 |
| S_LED1 | 未到计时结束 | S_LED1 |
| S_LED1 | 计时结束且 dir=1 | S_LED2 |
| S_LED1 | 计时结束且 dir=0 | S_LED0 |
| S_LED2 | 未到计时结束 | S_LED2 |
| S_LED2 | 计时结束且 dir=1 | S_LED3 |
| S_LED2 | 计时结束且 dir=0 | S_LED1 |
| S_LED3 | 未到计时结束 | S_LED3 |
| S_LED3 | 计时结束且 dir=1 | S_LED0 |
| S_LED3 | 计时结束且 dir=0 | S_LED2 |
这张表写完之后,case语句只是把它机械地翻译成 Verilog。很多人的状态机写得乱,不是因为 Verilog 水平差,而是跳过了表格这一步,直接在代码里“边写边想”,状态之间的关系全靠脑子硬记,状态一多就必出 bug。
1.3 为什么“一句移位”解决不了复杂需求
网上最常见的流水灯写法是那句led <= {led[2:0], led[3]}。这个写法对“固定方向、固定速度、一直跑”的演示确实够用,代码还短。但它把“灯的当前状态”和“下一次转移到哪里”揉在了一条赋值语句里,任何需求变化都会让你很难受。
比如要加方向控制,移位那句要么改成led <= dir ? {led[2:0], led[3]} : {led[0], led[3:1]},表面看也能写;但要继续加“使能暂停”“速度可调”“跑两圈后反转”“遇到某个按键亮灯方式改变”,这串移位就会膨胀成一大堆互相纠缠的 if 条件。状态机的好处是把“我现在在哪个状态”和“什么条件下我要去哪里”显式地拆开,每个状态独立看都很好理解,整体修改也只是增删几个分支,不会牵一发动全身。
2. Moore/Mealy 选型与状态编码:跑马灯该选哪种写法
状态图画完,下一步不是写代码,而是决定状态机的类型和状态编码。这两个选择会影响代码的复杂度和最终电路面积,尤其在状态多的时候差别非常明显。
2.1 Moore 和 Mealy 到底怎么选
状态机分 Moore 和 Mealy 两类。Moore 的输出只由当前状态决定,Mealy 的输出由当前状态和当前输入共同决定。区别可以用一句话概括:Moore 是“到了某个状态才做某件事”,Mealy 是“输入一变就立刻做某件事”。
跑马灯的 LED 输出只取决于“当前亮哪一盏灯”,跟使能、方向这些输入没有直接关系,所以应该用 Moore 结构。输出逻辑里只需要case (state),不需要把en和dir塞进输出表达式。这样输出非常干净,也不容易出现组合逻辑毛刺把灯闪得乱七八糟。
那什么时候用 Mealy?举个例子:如果要求“使能拉低的瞬间,LED 立刻全灭”,而不是等到下一个时钟沿状态切换过去才灭,Mealy 会更直接,因为输出可以直接写成led = en ? led_by_state : 4'b0000。但跑马灯这种低速电路里,晚一个时钟周期灭灯肉眼完全看不出来,没必要为了省一个周期引入额外复杂度。原则很简单:默认选 Moore,只有输出必须对输入即时响应时才考虑 Mealy。
2.2 二进制、格雷码、独热码怎么选
状态编码方式常见三种:二进制码、格雷码、独热码。它们各自的代价不同。
| 编码方式 | 触发器数量 | 组合逻辑复杂度 | 典型场景 |
|---|---|---|---|
| 二进制码 | 少 | 中等 | 状态少、追求资源最小的 ASIC |
| 格雷码 | 少 | 中等 | 相邻状态频繁切换、希望减少翻转功耗 |
| 独热码 | 多 | 低 | FPGA、状态多、希望逻辑简单 |
FPGA 的寄存器资源相对富裕,组合逻辑资源更宝贵,所以独热码是 FPGA 状态机的常见选择。独热码每个状态用一位触发器表示,状态判断变成“某一位是否为 1”,不需要像二进制那样编译出一大堆比较逻辑。四个有效状态加一个空闲状态,一共五个独热位,即使多花几个触发器也完全无所谓。
我在这篇文章的例子里会用手动定义的localparam独热码,而不是依赖综合器的fsm_encoding属性。不同 EDA 工具对fsm_encoding的写法不完全一样,与其记各家语法,不如直接自己在代码里把状态常量定义成独热码,再让所有判断都基于state变量。这样写出来的代码换工具也不会变味,综合器也无从“优化”你的状态结构。
2.3 复位策略、默认分支和非法状态
状态机必须有一个复位可到达的初始状态。我用低电平有效的异步复位rst_n,复位时state <= S_IDLE。异步复位的好处是复位信号一拉低,状态立刻回到初始态,不依赖时钟是否存在,这在调试时非常实用。
同时,case语句里必须写default分支。FPGA 上电后内部寄存器可能进入任意状态,如果代码只覆盖了合法状态,非法状态会把系统永久卡住。我习惯让非法状态一律回到S_IDLE,也就是“宁可重新开始,也不要死机”。另外,在组合逻辑块里面,赋初值也是一个必须养成的习惯:next_state = state; led = 4'b0000;这种“先给默认值,再写 case”的写法,可以从根源上避免综合出锁存器。
3. 三段式 Verilog 完整实现:代码逐段拆解
状态图、状态表、编码方式都定了,接下来的代码就顺理成章了。下面这段代码是一个带使能、方向控制、可参数化延时的四灯跑马灯模块。我用的是三段式状态机写法,这也是我建议你在工程里固定下来的风格。
3.1 三段式到底把状态机切成哪三段
三段式不是某种高深理论,而是把状态机的职责拆成三个always块:
- 第一段:时序逻辑,负责
state <= next_state,也就是状态寄存器的更新。 - 第二段:组合逻辑,根据当前状态和输入条件计算
next_state。 - 第三段:输出逻辑,根据当前状态生成输出信号。
输出逻辑可以写成时序的也可以写成组合的。流水灯的 LED 直接由当前状态决定,我用组合逻辑输出,这样state一更新,LED 立刻跟随变化,不额外延迟一拍。如果要输出的信号必须非常干净、不能有毛刺,可以把输出也放到时序块里寄存一拍,代价就是输出比状态变化晚一个时钟周期,实际工程里要根据需求取舍。
3.2 完整的可综合跑马灯模块
module led_runner #( parameter CLK_HZ = 12_000_000, parameter MS_PER_STEP = 100 )( input wire clk, input wire rst_n, input wire en, input wire dir, output reg [3:0] led ); function integer clog2; input integer n; integer i; begin clog2 = 0; for (i = 0; (1 << i) < n; i = i + 1) begin clog2 = i + 1; end end endfunction localparam integer CNT_MAX = CLK_HZ * MS_PER_STEP / 1000 - 1; localparam integer CNT_W = clog2(CNT_MAX + 1); localparam S_IDLE = 5'b00001; localparam S_LED0 = 5'b00010; localparam S_LED1 = 5'b00100; localparam S_LED2 = 5'b01000; localparam S_LED3 = 5'b10000; reg [4:0] state; reg [4:0] next_state; reg [CNT_W-1:0] cnt; wire tick; // 第一段:状态寄存器 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_IDLE; end else begin state <= next_state; end end // 计时器:使能有效且不在空闲态时计数 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= {CNT_W{1'b0}}; end else if (!en) begin cnt <= {CNT_W{1'b0}}; end else if (state == S_IDLE) begin cnt <= {CNT_W{1'b0}}; end else if (cnt == CNT_MAX) begin cnt <= {CNT_W{1'b0}}; end else begin cnt <= cnt + 1'b1; end end // 单周期节拍信号:只有计时满且使能有效时才拉高 assign tick = en && (state != S_IDLE) && (cnt == CNT_MAX); // 第二段:次态组合逻辑 always @(*) begin next_state = state; case (state) S_IDLE: begin if (en) next_state = S_LED0; end S_LED0: begin if (tick) next_state = dir ? S_LED1 : S_LED3; end S_LED1: begin if (tick) next_state = dir ? S_LED2 : S_LED0; end S_LED2: begin if (tick) next_state = dir ? S_LED3 : S_LED1; end S_LED3: begin if (tick) next_state = dir ? S_LED0 : S_LED2; end default: next_state = S_IDLE; endcase end // 第三段:Moore 组合输出 always @(*) begin led = 4'b0000; case (state) S_IDLE: led = 4'b0000; S_LED0: led = 4'b0001; S_LED1: led = 4'b0010; S_LED2: led = 4'b0100; S_LED3: led = 4'b1000; default: led = 4'b0000; endcase end endmodule如果你的综合器不支持函数做常量计算,直接把cnt定义成固定位宽也没问题。比如 50MHz 时钟下想让跑马灯最长停在 1 秒,计数器上限大约 5000 万,用reg [25:0] cnt就能覆盖。用clog2函数只是为了把位宽算得更精确,避免“看起来够用结果差一位”的边界问题。
3.3 代码里几个容易被忽略的设计点
tick是我故意抽出来的一个单周期脉冲信号。状态机转移条件里直接写tick,而不是把cnt == CNT_MAX散落在每个分支里。这样有两个好处:一是以后想改时长,只需要改CNT_MAX;二是如果将来想在同一个状态里同时判断“计时满了”和“是否暂停”,逻辑会更集中。
cnt的清零逻辑单独放在计时器块里,不是放在状态转移块里。很多初学会把“计时满”当成一个条件,在状态机里顺手把cnt清零,结果tick的宽度和状态切换的时序纠缠在一起,特别容易出“一个灯亮一下就跳过去”的诡异现象。独立处理计数器,状态机只关心“收到 tick 没有”,职责更清晰。
输出逻辑里那行led = 4'b0000;是防锁存器的关键。如果不给默认值,只在各个状态分支里给led赋值,综合器读到某些位没有被覆盖,就会推断出锁存器。仿真时锁存器还能跑,综合到 FPGA 上就会出现各种奇怪问题。先给默认值,再写case,是最简单的规避手段。
4. 怎么验证状态机没跑飞:仿真波形与上板排错
代码写完不代表状态机是对的。我见过太多人仿真都不跑,直接上板,看到灯乱跳就不知道从哪里下手。验证一个状态机,仿真阶段能解决 80% 的问题。
4.1 写一个能“骗过自己”的 testbench
下面这个 testbench 用比较低的CLK_HZ和比较小的MS_PER_STEP,让仿真的计时周期变短,波形里能很快看到几个完整循环。实际工程中参数可能要跑到十几万周期才能看到一次状态切换,仿真等不起,所以要学会用小参数做定向验证。
`timescale 1ns/1ps module led_runner_tb; reg clk = 1'b0; reg rst_n = 1'b0; reg en = 1'b0; reg dir = 1'b0; wire [3:0] led; led_runner #( .CLK_HZ (2000), .MS_PER_STEP(5) ) u_led_runner ( .clk (clk), .rst_n (rst_n), .en (en), .dir (dir), .led (led) ); always #5 clk = ~clk; initial begin rst_n = 1'b0; #30; rst_n = 1'b1; #20; en = 1'b1; #2000; dir = 1'b1; #4000; en = 1'b0; #500; $finish; end initial begin $monitor("%0t | state=%b led=%b en=%b dir=%b", $time, u_led_runner.state, led, en, dir); end endmodule$monitor会打印内部state和顶层led的变化。这样你能直接看到state是否按照S_IDLE -> S_LED0 -> S_LED1 -> S_LED2 -> S_LED3 -> S_LED0...的顺序变化,方向切换后是否倒序跑。
4.2 波形里要重点看哪几个信号
仿真跑完,先别急着感叹“灯亮了”,要对着波形逐项确认:
state是否出现非法值。独热码一共只有 5 个合法值,波形里如果出现00101这类状态,说明代码有问题或者复位没复位干净。tick是否真的是单周期脉冲。好的tick应该是拉高一拍就落下去,如果连续多拍为高,状态机很可能一个条件触发多次转移。cnt是否在到达CNT_MAX的那一拍被清零。如果计数上限是 9,那么波形里cnt最高就是 9,不会出现 10。led是否和当前状态一一对应。state是00010时led必须是0001,如果对应关系错位,优先检查输出块,而不是状态转移块。- 使能拉低后,
cnt是否马上归零、led是否保持当前值不再变化。如果不是,检查tick里有没有把en和状态条件一起放进去。
4.3 上板出现异常时的排查顺序
上板和仿真表现不一致,大多是外部信号处理或者时序约束的问题,而不是状态机逻辑问题。我通常按下面这个顺序排查:
- 先确认复位信号是不是一直有效或者一直无效。用逻辑分析仪或者开发板上的 LED 指示复位状态,这一步能排除一半问题。
- 再确认按键或者拨码开关有没有做同步和消抖。外部开关直接接到
en、dir上,金属触点的抖动会造成状态机在几个状态之间来回跳,肉眼看到的就是灯乱闪。 - 检查计数器位宽。如果
CNT_MAX计算出来超过了计数器最大值,cnt == CNT_MAX永远不成立,灯就永远不会动。拿笔算一下:2^N - 1是否大于等于CNT_MAX。 - 对比仿真和上板的时钟频率。仿真用了低
CLK_HZ,上板如果没改参数,跑马灯速度会完全不对,这是参数化设计最常见的疏忽。 - 最后看综合报告里有没有意外生成的锁存器。如果有,回到代码里检查是不是某个组合逻辑输出忘了赋默认值。
5. 状态机工程化扩展:速度可调、方向可调与通用节拍
跑马灯能跑起来只是第一步。实际开发中,你大概率会遇到“速度要能调”“方向要能变”“多组灯要一起跑”这类需求。状态机设计得好不好,这时候才真正见分晓。
5.1 把“延时计数器”从状态机里抽出来
很多状态机都带一个计数器,但计数器最好独立成一个节拍发生器,而不是和状态转移耦合在一起。状态机的职责是“什么时候做什么事”,计数器的职责是“每隔多少个时钟产生一次 tick”。两者解耦之后,状态机里所有跟延时相关的判断都浓缩成一个tick信号,代码一眼能看到转移条件。
节拍发生器也很容易参数化。如果追求更灵活的定时,可以改成比较器加装载值:
reg [CNT_W-1:0] limit; wire tick = en && (state != S_IDLE) && (cnt == limit); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= {CNT_W{1'b0}}; end else if (!en || state == S_IDLE || cnt == limit) begin cnt <= {CNT_W{1'b0}}; end else begin cnt <= cnt + 1'b1; end end这样状态机完全不需要知道某个速度档对应多少毫秒,它只关心tick来了没有。
5.2 速度可调和多组 LED 的扩展思路
速度可调的做法是给模块加一个speed输入,组合逻辑根据speed算出limit:
localparam LIMIT_FAST = 500; localparam LIMIT_MID = 1000; localparam LIMIT_SLOW = 2000; always @(*) begin case (speed) 2'b00: limit = LIMIT_FAST; 2'b01: limit = LIMIT_MID; 2'b10: limit = LIMIT_SLOW; default: limit = LIMIT_MID; endcase end如果你的板子时钟是 50MHz,500 个时钟对应 10 微秒,LED 会闪得非常快。扩展时的关键点不是怎么选LIMIT,而是保证三个档位都不会把limit设成 0 或 1,否则tick会变得过于密集,状态机根本停不下来。
多组 LED 的扩展也遵循同样的思路:每组的“当前亮灯状态”可以复用一个状态机结构,但状态是独立的。不要在一个状态机里强行塞进两组灯的转移逻辑,那会让状态组合爆炸。宁可例化两个跑马灯模块,各管各的灯,需要同步时再统一控制它们的en,这样可读性和可维护性都好很多。
5.3 状态机代码的维护清单
我在评审别人的状态机代码时,会按照下面这张清单快速过一遍,你也可以这么检查自己写的代码:
| 检查项 | 具体要求 |
|---|---|
| 默认状态 | case必须有default,非法状态回到复位态 |
| 输出默认值 | 组合输出块先赋默认值,防止锁存器 |
| 转移条件 | 状态转移只用单周期脉冲信号,不直接用电平条件 |
| 复位 | 状态和计数器都复位,复位源保持一致 |
| 编码 | 独热码常量定义完整,不重复不遗漏 |
| 计数器 | 计数上限不要越界,清零时机要明确 |
这张清单的价值不在于逐条打勾,而在于逼你提前想清楚:每个状态有没有出口、每个输出有没有兜底、每个条件有没有歧义。
6. 实战复盘:几个容易写废状态机的细节
最后聊几个我在实际项目中经常遇到、也经常帮别人擦屁股的坑。这些问题仿真不一定立刻暴露,但上板之后特别恶心。
6.1 计数到上限后不能留下“持续高电平”
有次我接手一个别人写的流水灯模块,现象是灯会在两个状态之间高频抖动。查了半天发现,他的tick是用cnt >= CNT_MAX写出来的,而且计数器没有在到达上限时清零。于是tick持续保持高电平,状态机在这个电平期间反复触发转移,一个灯还没稳定,状态就已经跳走了。
正确做法是让tick成为单周期脉冲:计数器到达CNT_MAX的同一拍把它清零,tick自然只拉高一拍。状态机看到这个脉冲,只执行一次状态转移,走人。时刻记住:状态机的转移条件应该“边沿化”“脉冲化”,不是“电平化”。
6.2 组合逻辑漏赋值导致锁存器
另一个很常见的坑是输出逻辑漏掉default。比如有人在写 LED 输出时,只在每个状态分支里给led赋值,一旦状态因为某种原因进了非法值,组合输出中某些位没有被赋值,综合工具就会推断出锁存器。锁存器在仿真里可能表现正常,到了 FPGA 上会因为时序问题出现毛刺。
防这个坑不需要什么高级技巧,就是两行代码的事:块里面先写一句led = 4'b0000;,然后写case。next_state也是同理,先next_state = state;再写条件覆盖。宁可多写两行“无用”的默认赋值,也不要让工具替你补一个你根本不想看到的锁存器。
6.3 外部按键直接进状态机
跑马灯工程几乎都会加按键控制启动或方向。最容易犯的错误是直接把按键引脚接到en和dir上,不做任何处理。机械按键按下和松开时会产生几毫秒到几十毫秒的抖动,这个抖动在数字电路里就是一串随机高低电平。状态机跑在几十兆赫兹时钟下,一抖就是成千上万个时钟周期,状态机会在多个状态之间来回乱穿,最后灯的表现完全不可控。
正确的做法是给输入加两级同步器,再加消抖计数器。同步器解决的是跨时钟域和亚稳态问题,消抖计数器解决的是机械抖动问题。比如检测到按键电平稳定持续 20ms 再认为有效,这就是最朴素也最好用的消抖方案。状态机本身不要承担消抖职责,它只负责接收已经“洗干净”的使能脉冲。
说到底,状态机写得好不好,不取决于你会不会用case,而取决于你愿不愿意在写代码之前把状态画清楚、把条件列明白、把边界想完整。跑马灯只是起点,这套思考方式用到任何控制器上都一样。