☰
Verilog状态机三段式写法:流水灯实例拆解与工程避坑
2026/10/17 1:25:45 网站建设 项目流程

做FPGA开发的同学,十有八九是从流水灯开始入门的;但能把流水灯代码写得漂亮、把状态机理解透彻的人,其实不多。很多朋友一听说“状态机”就觉得抽象,觉得是高端项目才用得上的东西,实际上跑马灯/流水灯就是一个把状态机嚼碎喂到嘴边的绝佳案例。这篇文章我就以“跑马灯/流水灯Verilog HDL实现”为主线,把状态机从建模、编码、三段式写法到仿真上板、避坑排查完整拆一遍。无论你是刚学Verilog的学生,还是想系统梳理状态机工程写法的开发者,这套思路都能直接套到UART、SPI、协议解析等复杂模块里。

1. 状态机到底是什么,跑马灯为什么是最佳练手案例

1.1 状态机的三大要素和“写好”的标准

状态机,全称有限状态机(FSM),本质上就是一种“带记忆的流程控制逻辑”。它由三大要素组成:状态集合、转移条件、输出逻辑。状态集合决定系统处于哪个阶段,转移条件决定从当前阶段跳到下一个阶段的条件,输出逻辑决定每个阶段对外表现出的行为。

我用一个打游戏通关的例子来解释:玩家有“待机、移动、攻击、受伤、死亡”几个状态,按某个按键就会从待机切到攻击,攻击动画播放完自动回到待机,血量降到0就跳到死亡状态。每个状态对应屏幕上不同的角色表现,这就是“状态决定输出”。硬件里的状态机和这个道理完全一样,只是用寄存器和组合逻辑来实现。

把状态机“写对”很容易,让功能仿真通过就行;但要“写好”就需要考虑几件事:状态编码是否合理、转移逻辑是否完备、输出是否会产生毛刺、代码能否维护、非法状态能不能自恢复。很多同学写流水灯就是用一个计数器加移位寄存器,功能也对,但这种方式完全绕过状态机,对提升设计能力没有帮助。真正对工程有用的写法,是把流水灯当成一个最小的状态机系统来设计,麻雀虽小五脏俱全。

1.2 跑马灯/流水灯:简单外壳下的完整状态机知识

跑马灯/流水灯这个项目看起来简单,但它几乎覆盖了状态机开发的全部核心知识点。

第一是状态划分。四个LED依次点亮,每一颗LED亮起就是一个状态,需要定义S0到S3四个状态,这是状态建模的起点。第二是转移条件。状态不能无限快地切换,需要一个定时器或分频计数器产生节拍信号,相当于给状态机一个“心跳”。第三是输出逻辑。每个状态下点亮哪颗LED,就是把状态映射到LED引脚上。

更妙的是,这个项目可以轻松扩展出状态机各种进阶场景:加一个按键进去,变成“按键切换流水方向”,状态转移条件就从纯时间变成了“时间+外部输入”;加一个拨码开关,变成“快慢两种速度”,这就涉及状态机如何处理多条件组合;再往后抽象一层,UART接收状态机的“起始位、数据位、停止位”,I2C的“起始、地址、数据、应答”本质上和跑马灯的状态划分一模一样。

所以别小看跑马灯,把它吃透了,状态机的“三板斧”——状态转移、节拍控制、输出寄存化,你就全掌握了。下面我从建模开始,一步步拆解法。

2. 状态建模:写代码前先解决“画图”问题

2.1 摩尔型还是米利型:先确定输入输出关系

动手写Verilog之前,一定要先确定状态机属于摩尔型(Moore)还是米利型(Mealy),这直接决定输出逻辑怎么写。

摩尔型状态机的输出只取决于当前状态,不关心输入信号。米利型状态机的输出不仅取决于当前状态,还取决于当前输入。打个比方:摩尔型就像红绿灯,到什么颜色就显示什么灯,和外界无关;米利型就像电梯门,在“开门运行”状态下,既要知道当前是开门状态,还要判断“有没有人按了关门键”才能决定门关不关。

对于纯跑马灯来说,没有外部输入,只有时间和灯的对应关系,所以天然是摩尔型状态机。如果加了按键调速,状态机还是摩尔型,因为按键只是影响转移节奏,最终点亮哪个灯还是由当前状态决定;但要是你希望“按下按键的瞬间灯立刻切换”,不再等下一个节拍,那就变成米利型了,输出需要额外考虑按键信号。

这里有个实用判断方法:写输出逻辑时,观察case语句里是否只用state变量,还是混入了输入信号。只用state就是摩尔型,混入输入就是米利型。初学阶段尽量从摩尔型起步,因为输出只依赖状态,调试时看状态波形就能完全推断输出,问题定位最直观。

2.2 状态编码怎么选:二进制、格雷码和独热码的取舍

确定了状态机类型,下一步是状态编码。常见的编码方式有三种:二进制码、格雷码和独热码。很多同学在这里直接随便选,其实编码方式对综合面积、速度、功耗和可靠性都有影响。

编码方式状态数N所需位数主要优势主要劣势适用场景
二进制码ceil(log2(N))寄存器最少,节省面积状态跳转时多bit同时变化,易出现中间态状态数较少、对面积敏感的CPLD设计
格雷码ceil(log2(N))相邻状态只有1bit翻转,降低动态功耗状态跳转受限,不规则转移时编码复杂连续线性跳转的场合
独热码N译码简单,转移条件清晰,时序好寄存器最多,浪费面积FPGA内部寄存器资源丰富时常用

以四个状态为例,二进制码只需要2位寄存器:00、01、10、11;独热码需要4位寄存器:0001、0010、0100、1000。独热码看着“浪费”,但在FPGA里往往不亏,因为FPGA的寄存器资源本来就很充足,用独热码可以让每个状态独占一个bit,状态译码就是简单的“某bit为1”,省掉了状态比较器,时序收敛更容易。

跑马灯只有四个状态,我更推荐独热码,原因有三个:一是状态编码直观,看寄存器的值就知道当前是哪个状态,调试时不用换算;二是三态或四态转移时,综合工具能优化出更小的译码逻辑;三是从学习角度出发,独热码更容易理解“状态和输出一一对应”的思想,为后续复杂控制状态机打基础。当然,如果状态数达到几十个,独热码太费寄存器,那时候再考虑二进制码。

2.3 状态图和状态转移表:一份可直接照抄的模板

编码确定后,不要急着写代码,先在纸上画状态图。状态图的画法很固定:圆圈表示状态,圆圈里写状态名;箭头表示状态转移,箭头旁写转移条件;摩尔型输出写在圆圈里,米利型输出写在箭头旁。

四灯跑马灯的状态图是教科书级别的标准模板。假设四个状态S0、S1、S2、S3,每个状态输出分别对应LED为0001、0010、0100、1000,转移条件统一为“节拍脉冲到来”。状态转移关系是:S0在节拍到来时去S1,S1在节拍到来时去S2,S2去S3,S3回S0。如果不画图直接写代码,容易出现漏掉某个转移分支或者转移顺序错误的问题。

画完状态图,我建议再列一张状态转移表,把所有转移关系汇总。对于四灯流水灯,状态转移表长这样:

当前状态转移条件下一状态当前输出
S0tick == 1S14'b0001
S1tick == 1S24'b0010
S2tick == 1S34'b0100
S3tick == 1S04'b1000

这张表写清楚后,代码就是“翻译”过程:每个状态对应一个case分支,每个分支里写清转移条件和下一状态。到这一步,状态机的骨架就已经完整了,接下来的问题只是用哪种代码结构去描述它。

3. 一段式、两段式、三段式:FSM三种写法怎么选

3.1 一段式:代码最少,但工程上最不推荐

一段式FSM,就是把状态寄存器、状态转移、输出逻辑全部写在一个always块里。代码看起来最紧凑,很多初学者喜欢这么写,因为不用思考状态怎么分步。

always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S0; led <= 4'b0001; end else begin case (state) S0: begin led <= 4'b0001; state <= S1; end S1: begin led <= 4'b0010; state <= S2; end S2: begin led <= 4'b0100; state <= S3; end S3: begin led <= 4'b1000; state <= S0; end default: state <= S0; endcase end end

这段代码功能上没问题,但工程上不推荐,原因很实际。

第一,状态转移和输出混在一起,如果状态机有10个状态、每个状态有3路输出,这个always块会膨胀成几十行,读起来非常吃力。第二,输出和状态更新在同一拍完成,你很难把“状态的下一状态计算”和“状态对应输出”分开复用。第三也是最容易踩坑的:当输出逻辑复杂时,很容易出现阻塞赋值和非阻塞赋值混用的情况,产生竞争冒险。第四,想要增加一个“输出寄存打拍”或“输出组合译码”的需求,一段式代码改起来牵一发动全身。

所以一段式只适合写不超过三五个状态、输出极简单的验证性Demo。从学习状态机结构的角度看,它帮助也不大,因为把三个组成部分焊死在一起,你看不出状态机的层次。

3.2 两段式:清晰但不解决输出毛刺

两段式FSM把状态转移和输出分别放到两个always块里。第一个块是时序逻辑,描述状态寄存器如何更新;第二个块是组合逻辑,既计算下一状态也计算输出。

// 第一段:状态寄存器和下一状态计算 always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= S0; else state <= next_state; end // 第二段:组合逻辑,计算下一状态和输出 always @(*) begin next_state = state; led = 4'b0001; case (state) S0: begin led = 4'b0001; next_state = S1; end S1: begin led = 4'b0010; next_state = S2; end S2: begin led = 4'b0100; next_state = S3; end S3: begin led = 4'b1000; next_state = S0; end default: next_state = S0; endcase end

两段式把状态转移逻辑和输出逻辑分离了,层次比一段式清晰,但它有一个非常典型的问题:输出是组合逻辑。组合逻辑输出在输入变化的瞬间可能出现毛刺,也就是短暂的不稳定抖动。对于跑马灯驱动LED来说,毛刺可能只是肉眼看不到的闪烁,但如果这个输出信号是送给外部芯片的控制信号,毛刺会导致误动作。

另外,两段式里led是用阻塞赋值写的组合逻辑,如果后续把led连到下游时序逻辑,组合逻辑路径的延时可能会破坏下游的建立时间。所以两段式经常需要用reg打一拍来消除毛刺,这就自然引出了三段式的思路。

3.3 三段式:最推荐,输出寄存器化是核心

三段式FSM是工程中最常用、也最推荐的结构。它把状态寄存器、次态组合逻辑、输出逻辑分成三个独立的部分。第一部分是标准的时序逻辑,负责把next_state锁存到state;第二部分是纯组合逻辑,只计算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 = S0; default: next_state = S0; endcase end // 第三部分:输出寄存器,做输出译码 always @(posedge clk or negedge rst_n) begin if (!rst_n) led <= 4'b0001; else if (tick) case (next_state) S0: led <= 4'b0001; S1: led <= 4'b0010; S2: led <= 4'b0100; S3: led <= 4'b1000; default: led <= 4'b0001; endcase end

为什么推荐三段式?核心原因有两个:一是所有输出都是寄存器输出,有效消除组合逻辑毛刺,信号干净稳定;二是状态转移逻辑和输出逻辑完全分离,增加状态或修改输出时互不影响,代码可维护性大幅提升。

很多同学会纠结第三段里到底该用state还是next_state做输出译码。这里我直接说结论:在“输出需要和状态同步对齐”的场景下,用next_state。因为next_state已经包含了下一状态的信息,在同一个时钟沿,状态寄存器更新到next_state,输出寄存器也同时采样next_state对应的输出值,这样一来状态和输出在同一拍内对齐,不会出现输出比状态晚一拍的延迟感。

4. 可综合的流水灯工程:从参数计算到仿真上板

4.1 模块划分与分频计数位宽计算

真正上板之前,要先把整个工程的结构想清楚。我习惯把流水灯拆成三个模块:顶层模块、分频计数器、状态机主体。顶层模块负责例化子模块并连接时钟和复位;分频计数器负责产生节拍脉冲tick;状态机主体就按三段式编写。

分频计数器是很多人忽略细节的地方。假设开发板时钟是50MHz,我们希望LED每500ms切换一次,那么需要计数多少次?频率50MHz表示每秒有50000000个时钟周期,500ms就是一半,即25000000个周期。计数器从0计数到24999999后归零,同时产生一个宽度为一个时钟周期的高脉冲tick。

位宽怎么算?计数器最大值是25000000,2的24次方是16777216,不够;2的25次方是33554432,刚好够。所以需要25位寄存器,代码里写成reg [24:0] cnt。很多同学不看数值直接写reg [31:0] cnt,功能确实没问题,但养成计算位宽的习惯,在资源紧张的芯片上能省不少寄存器。同理,如果时钟不是50MHz,只用把CLK_FREQ参数换成对应数值,位宽根据公式clog2(CLK_FREQ / 1000 * FLASH_MS)重新估算。

分频计数器还有一个细节:产生tick脉冲时,不能简单写成assign tick = (cnt == CNT_MAX),因为计数器归零和tick产生如果不同步,状态机可能错过脉冲。我习惯把tick也写成寄存器,在计数到CNT_MAX的那一拍置1,其他时间置0:

always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; tick <= 1'b0; end else if (cnt == CNT_MAX) begin cnt <= 0; tick <= 1'b1; end else begin cnt <= cnt + 1'b1; tick <= 1'b0; end end

4.2 完整代码:参数化设计 + 三段式FSM

下面给出一个完整的、可直接综合的流水灯模块。这个代码把时钟频率和流水间隔做成参数,仿真时可以调小参数快速验证,上板时再改成真实参数,非常实用。

module flow_led #( parameter CLK_FREQ = 50_000_000, // 输入时钟频率,单位Hz parameter FLASH_MS = 500 // LED切换间隔,单位ms )( input wire clk, // 时钟 input wire rst_n, // 异步复位,低有效 output reg [3:0] led // 4颗LED输出 ); // 分频计数最大值:50MHz * 500ms localparam integer CNT_MAX = CLK_FREQ / 1000 * FLASH_MS - 1; // 独热码定义四个状态 localparam S0 = 4'b0001; localparam S1 = 4'b0010; localparam S2 = 4'b0100; localparam S3 = 4'b1000; reg [24:0] cnt; // 25位计数器 reg tick; // 节拍脉冲 reg [3:0] state; // 当前状态 reg [3:0] next_state; // 下一状态 // 分频计数:产生节拍脉冲 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; tick <= 1'b0; end else if (cnt == CNT_MAX) begin cnt <= 0; tick <= 1'b1; end else begin cnt <= cnt + 1'b1; tick <= 1'b0; end end // 状态寄存器 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 = S0; default: next_state = S0; endcase end // 输出寄存器:用next_state译码,保证输出与状态同步 always @(posedge clk or negedge rst_n) begin if (!rst_n) led <= 4'b0001; else if (tick) case (next_state) S0: led <= 4'b0001; S1: led <= 4'b0010; S2: led <= 4'b0100; S3: led <= 4'b1000; default: led <= 4'b0001; endcase end endmodule

这个代码里有几个实战中总结出来的要点。

第一,localparam用CLK_FREQ / 1000 * FLASH_MS计算CNT_MAX,所有参数集中管理,改一个数就能适配不同开发板。我建议把CNT_MAX声明成integer或足够宽度的localparam,避免位宽不够被截断。第二,次态组合逻辑里第一句先写next_state = state;,这是防止综合出锁存器的经典写法,因为它保证了case没覆盖到的分支也能保持原状态,不用额外写default在每种情况下单独赋值。第三,输出寄存器加else if (tick)条件,避免每个时钟周期都冗余更新LED,能降低一部分动态功耗,也让波形更清晰。

4.3 仿真测试平台与上板验证要点

写完代码不能直接上板,要先跑仿真验证状态转移和输出时序。写testbench有一个核心技巧:把参数调小,让仿真时间可控。下面这个测试平台把时钟频率设成1000Hz、闪烁间隔设成5ms,这样计数器最大值只有4,几个时钟周期就能看到一次状态切换。

`timescale 1ns/1ps module tb_flow_led; reg clk; reg rst_n; wire [3:0] led; // 例化被测模块,参数调小便于观察 flow_led #( .CLK_FREQ(1000), .FLASH_MS(5) ) u_dut ( .clk (clk), .rst_n (rst_n), .led (led) ); // 生成1MHz时钟:周期1ns initial clk = 0; always #1 clk = ~clk; // 复位激励 initial begin rst_n = 0; #10; rst_n = 1; #1000; $finish; end // 显示状态变化 always @(posedge clk) begin if (u_dut.tick) $display("tick at %0t, state=%b, next_state=%b, led=%b", $time, u_dut.state, u_dut.next_state, led); end endmodule

仿真时重点看三件事:复位后led是否正确初始化为0001;每个tick脉冲到来后,led是否按照0001、0010、0100、1000的顺序循环;复位信号释放瞬间有没有出现输出毛刺。

上板验证时,约束文件要留意。时钟引脚必须约束到开发板晶振对应的引脚,四颗LED引脚要与板卡原理图对应。时钟约束写上时钟频率,比如50MHz,这样综合工具才能正确做时序分析。如果不写时钟约束,工具会按默认约束分析,信号路径的延时可能超限,极端情况下跑马灯会出现“跳变不稳定”的现象。

5. 状态机开发中的高频坑与排查三板斧

5.1 坑一:组合逻辑输出毛刺,LED乱闪

跑马灯最常见的异常现象是LED在切换瞬间出现不规则的短暂闪烁,或者某种状态下LED亮得和预期不一致。这通常不是状态机逻辑错误,而是输出带了组合毛刺。

毛刺产生的原因,是组合逻辑输出在多个输入信号同时变化时,存在竞争冒险。比如状态从S0到S1,如果用二进制码,state从00变成01,理论上只有一位变化;但如果从01变成10,两位同时翻转,由于布线延迟不同,组合译码电路可能短暂输出一个错误的中间值。独热码虽能减少这种情况,但次态组合逻辑的传播延迟依然存在。

排查毛刺有一个笨但有效的办法:在仿真里给输出信号加一个很小的延迟观察窗口,或者直接综合后做门级仿真。上板调试时,可以用逻辑分析仪抓LED控制信号,看毛刺是否出现在时钟沿附近。解决毛刺的办法很直接:所有对外输出都打一拍,用寄存器输出。所以三段式要从一开始就养成习惯,不要图省事把输出写在组合逻辑块里。

5.2 坑二:状态跑飞,跑着跑着就“抽风”

状态跑飞是所有状态机设计者都会遇到的问题,表现为:系统运行一段时间后突然跳到某个未定义的输出模式,复位后才能恢复。

状态跑飞的根本原因有两个:一是状态寄存器进入了未定义状态,二是非法状态没有自恢复能力。未定义状态可能来自复位释放时寄存器初始化不一致,也可能来自外部干扰或亚稳态。如果代码里没有default分支,或者综合工具认为某些状态不可达而将其优化掉,状态机一旦落到非法状态,就会因为没有转移路径而“卡死”或“乱跳”。

解决状态跑飞的标准做法有三层。第一层:写case时必须有default分支,把未定义状态引导到安全状态S0,这是代码层面的兜底。第二层:状态编码上考虑留出冗余,比如四个状态用独热码时,剩下的12个非法编码都应该在default里跳回S0。第三层:综合属性设置,很多综合工具支持FSM编译指令,可以指定安全状态策略,让工具自动为非法状态增加恢复逻辑。

我见过不少同学不加default也能跑通仿真,因为仿真器对未定义态的处理比较“宽容”,但综合工具会做一些状态规约优化,两者行为可能不一致。所以从第一天写状态机开始,就把default刻在脑子里,它能救你无数次。

5.3 坑三:仿真正常、上板异常,怎么定位

还有一种很头疼的情况:testbench里波形完美,LED在仿真里按预期循环,一上板就乱。仿真和上板不一致,最常见的有三个原因:复位问题、时钟问题和输入同步问题。

复位问题,尤其是异步复位。仿真里复位信号释放瞬间是理想的,寄存器瞬间恢复;实际芯片上,复位信号释放也有个上升时间,如果复位释放相对时钟沿太近,寄存器可能采到亚稳态。解决办法是增加“异步复位、同步释放”电路,让复位信号经过两级触发器同步后再接入状态机复位端。这段代码短小但在工程里非常重要:

reg rst_n_r1, rst_n_r2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rst_n_r1 <= 1'b0; rst_n_r2 <= 1'b0; end else begin rst_n_r1 <= 1'b1; rst_n_r2 <= rst_n_r1; end end

时钟问题,主要是分频计数器带来的延迟。如果你用分频后的慢时钟直接驱动状态机,而不是用节拍脉冲使能,那状态机的时钟域就和输入按键等信号不在一个域里,很容易出现判断沿丢失。我前面代码里用的是“主时钟+使能脉冲”方案,就是避免这个问题。

输入同步问题,是针对带按键的扩展版本。按键是异步信号,直接进状态机极大概率产生亚稳态,轻则按键失灵,重则状态跑飞。解决办法是按键信号先经过两级甚至三级触发器同步,再送入状态机。这个坑在跑马灯里体现不明显,但一扩展到按键控制流水方向就会遇到。

最后再分享一条排查经验:上板异常时,优先看状态寄存器的实时值,而不要只盯着LED。很多FPGA开发环境支持逻辑分析仪核,把state信号引出来抓波形,一眼就能看出状态是卡在某个值,还是跑进了非法状态。这比瞎猜效率高得多。如果你用的板卡不支持内部逻辑分析,那就在状态机里预留下测试引脚,把state引到空余IO上,用示波器或逻辑分析仪观察,同样能定位问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询