首页 / 资讯中心 / 文章详情

Verilog状态机实现流水灯:从状态图到三段式代码实战

Verilog状态机实现流水灯:从状态图到三段式代码实战 ★ FEATURED ARTICLE
1. 先想清楚状态机到底在解决什么问题状态机这三个字在数字逻辑里听起来挺唬人但你要是用跑马灯/流水灯这个经典例子来学十分钟就能把它的门道摸清楚。我见过不少人写流水灯上来就是一大坨计数器 if else 嵌套灯倒是能亮可一旦想让灯换个跑法整个人就崩溃了。而用 Verilog HDL 以状态机的方式实现流水灯本质上就是把你对“灯序”的控制从一堆数字判断里抽出来变成一张清晰的状态迁移图。这篇文章就围绕“怎么写好状态机”这件事把流水灯从状态图、编码选择、三段式代码、仿真调试到避坑从头到尾过一遍。想认真打数字逻辑基本功的初学者、准备面试的在校生以及所有写 Verilog 写到“会跑但不敢改”的人都值得花一顿饭的时间看完。1.1 流水灯的本质是对“时间顺序”的控制来想想最常见的需求8 个 LED从左到右依次点亮每个灯亮 0.5 秒然后循环。很多初学者会下意识地写一个计数器 cnt从 0 加到 25_000_000然后在某个区间把对应的 LED 置 1。比如 0~25M 亮 LED025M~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 到来进 S1S1 亮第 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 输出。如果你定义 S08b0000_0001、S18b0000_0010、S28b0000_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 25d0; pulse 1b0; end else if (cnt CNT_MAX) begin cnt 25d0; pulse 1b1; // 输出一个时钟周期的脉冲 end else begin cnt cnt 1b1; pulse 1b0; 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 8b0000_0001 )( input wire clk, input wire rst_n, input wire tick, output wire [7:0] led ); localparam S0 8b0000_0001; localparam S1 8b0000_0010; localparam S2 8b0000_0100; localparam S3 8b0000_1000; localparam S4 8b0001_0000; localparam S5 8b0010_0000; localparam S6 8b0100_0000; localparam S7 8b1000_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 1b0; reg rst_n 1b0; 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 8b0000_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 1b1; #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) ? 8hF0 : 8h0F;在输入信号电平变化的瞬间就可能产生毛刺。虽然 LED 是人眼观察毛刺大多无感但一旦这套逻辑后面还接其他数字电路毛刺被当成有效信号采进去就是隐患。更干净的输出是寄存器输出。你可以像上面 led_ctrl 那样让状态编码本身就是输出也可以专门再打一拍always (posedge clk or negedge rst_n) begin if (!rst_n) led 8b0000_0001; else if (tick) led next_state; end这样输出信号与时钟沿完全同步后续接任何数字模块都更安心。代价只是多了一级寄存器延迟流水灯这种低速应用完全无所谓。5.4 计数器位宽算错这个问题在仿真中很容易暴露但到了上板阶段就隐蔽多了。很多初学者算位宽时只想到“25M 大概要 24 位吧”结果 24 位计数器最大只能数到 16_777_215根本到不了 24_999_999于是 tick 永远不产生灯不亮。查了半天却是这种低级问题非常磨心态。最直接的办法是口算看 2 的多少次方覆盖到目标值。2^2416_777_2162^2533_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才慢慢形成肌肉记忆。流水灯是个很小的题目但它逼你把状态机这条路上的所有细节都过了一遍。等哪天真遇到复杂的协议解析你会感谢今天认真抠过的这盏灯。
阅读完成 · 觉得有帮助?
咨询建站