Verilog三段式状态机设计:从核心原理到FPGA工程实践

📅 2026/8/8 3:12:38
Verilog三段式状态机设计:从核心原理到FPGA工程实践
1. 从“跑马灯”到“状态机”为什么你的课设需要它如果你正在准备数字电路与逻辑设计的课程设计并且老师要求用Verilog实现那么“状态机”这个词你大概率是绕不过去的。很多同学的第一反应可能是我能不能用计数器加组合逻辑或者用一堆if-else嵌套来实现当然可以对于简单的流水灯、按键消抖这些方法确实能跑通。但当你面对一个稍微复杂点的系统比如一个自动售货机、一个交通灯控制器或者一个串口通信的接收模块时你就会发现用传统“硬连线”逻辑去描述代码会迅速变得臃肿、混乱且难以调试。状态机就是来解决这个问题的。简单来说状态机Finite State Machine, FSM是一种数学模型它把系统抽象成有限个“状态”以及在这些状态之间跳转的“条件”。在数字电路设计中它提供了一种清晰、结构化、可维护的方式来描述具有时序逻辑的系统行为。你的课设题目里直接点明了“Verilog状态机”这其实是一个明确的信号老师希望你掌握数字系统设计的核心建模思想而不仅仅是写几行能综合的代码。这不仅是完成一个课设更是为你后续接触更复杂的FPGA/ASIC设计打下坚实的基础。接下来我会以一个典型的“自动饮料售卖机”作为贯穿始终的案例带你从零开始理解状态机的核心并用Verilog实现一个健壮、可读、可扩展的设计。2. 状态机核心三要素与两种写法在动手写代码之前我们必须把状态机的“灵魂”搞清楚。任何状态机都离不开三个核心要素状态State、转移条件Transition Condition和输出Output。以我们的饮料机为例假设它只卖一种饮料价格3元接受1元和2元硬币。状态系统在某个时刻所处的“模式”。比如IDLE空闲等待投币、ONE_YUAN已投1元、TWO_YUAN已投2元、THREE_YUAN已投3元或以上可出货。转移条件导致状态发生改变的事件。比如在IDLE状态下投入1元硬币假设输入信号coin_in 2‘b01状态转移到ONE_YUAN。输出在某个状态下或伴随状态转移系统产生的动作。比如在THREE_YUAN状态产生一个脉冲信号drop_out 1‘b1驱动电磁阀出货并产生change_out信号找零如果投入了4元。在Verilog中描述状态机主要有两种经典写法一段式One-always、两段式Two-always和三段式Three-always。业内普遍推荐的是三段式因为它结构最清晰综合结果可靠且利于时序约束。一段式在一个always块里同时描述状态转移和输出逻辑。代码紧凑但容易产生组合逻辑和时序逻辑混合导致综合结果不可预测产生毛刺是大型项目的“禁忌”。两段式第一个always块用时序逻辑描述状态寄存器当前状态current_state的更新第二个always块用组合逻辑描述次态next_state和输出。它分离了时序和组合逻辑比一段式好但输出如果是组合逻辑仍可能产生毛刺。三段式推荐这是最规范、最安全的方法。第一个always块时序逻辑负责状态寄存器的更新。在每个时钟上升沿将next_state赋值给current_state。第二个always块组合逻辑负责根据current_state和输入信号计算next_state。这里是一个大的case(current_state)语句。第三个always块时序逻辑负责根据current_state或next_state产生输出。将输出用寄存器打一拍可以消除毛刺输出更稳定。注意对于课设和小型项目两段式也完全可以接受。但如果你希望代码风格更专业或者输出信号需要直接驱动外部设备如LED、电机三段式是更好的选择它能提供“寄存器输出”避免毛刺带来的误动作。3. 三段式状态机Verilog实现详解我们以饮料售卖机为例用三段式风格编写完整的Verilog代码。假设系统时钟为clk低电平复位rst_n投币输入coin_in[1:0]2‘b01表示1元2’b10表示2元2‘b00表示无投币输出为出货信号drop_out和找零信号change_out假设找零也是脉冲。3.1 状态定义与模块声明首先我们使用参数parameter或宏定义define来给状态编码。这里推荐使用独热码One-Hot或格雷码Gray Code对于FPGA独热码更常用因为其译码简单触发器多但组合逻辑少。module vending_machine_fsm ( input wire clk, input wire rst_n, input wire [1:0] coin_in, // 投币输入 output reg drop_out, // 出货脉冲 output reg change_out // 找零脉冲 ); // 状态定义 - 使用独热码One-Hot Encoding parameter IDLE 4b0001; parameter ONE_YUAN 4b0010; parameter TWO_YUAN 4b0100; parameter THREE_YUAN 4b1000; // 状态寄存器 reg [3:0] current_state, next_state;为什么用独热码假设有N个状态独热码就用N个比特位每个状态只有一位为1。对于4个状态需要4个触发器。虽然消耗的触发器比二进制编码2个触发器多但它的状态译码逻辑非常简单直接比较在FPGA中布线更优能跑出更高的时钟频率并且状态机不会进入非法状态即使进入也能在下一时钟周期恢复。对于课设这种规模独热码是简单可靠的选择。3.2 第一段状态寄存器时序更新这部分代码非常简单就是标准的D触发器模型。在每一个时钟上升沿如果复位有效就回到初始状态IDLE否则将计算好的next_state锁存到current_state。// 第一段状态寄存器时序逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin current_state IDLE; end else begin current_state next_state; end end3.3 第二段次态组合逻辑这是状态机的“大脑”它根据当前状态和输入决定下一个时钟周期应该去往哪个状态。这里必须使用组合逻辑所以敏感列表要包含所有输入变量current_state和coin_in。使用case语句是标准做法。// 第二段次态组合逻辑 always (*) begin // 默认情况下保持当前状态避免产生锁存器Latch next_state current_state; case (current_state) IDLE: begin if (coin_in 2b01) begin next_state ONE_YUAN; end else if (coin_in 2b10) begin next_state TWO_YUAN; end // 如果coin_in为00next_state保持IDLE已由默认赋值覆盖 end ONE_YUAN: begin if (coin_in 2b01) begin next_state TWO_YUAN; end else if (coin_in 2b10) begin next_state THREE_YUAN; // 投1元后再投2元共3元 end // 注意这里没有处理投币为0的情况意味着如果不再投币状态将永远停在ONE_YUAN。 // 实际可能需要一个超时复位机制这是课设中常被忽略的“坑”。 end TWO_YUAN: begin if (coin_in 2b01) begin next_state THREE_YUAN; // 投2元后再投1元共3元 end // 同样需要处理超时或取消交易的情况 end THREE_YUAN: begin // 出货完成后无论有无找零都应回到空闲状态 // 我们假设出货是一个瞬时动作在下一个时钟周期就回到IDLE next_state IDLE; end default: begin // 这是一个非常重要的安全机制如果状态机因为某种原因如上电不稳定、噪声 // 进入了未定义的状态如4‘b0011default语句能将其拉回已知状态IDLE。 next_state IDLE; end endcase end这里有几个关键点默认赋值next_state current_state;这一行至关重要。它确保了在case语句没有覆盖的所有分支条件下next_state都有一个明确的值从而避免了综合出我们不希望的“锁存器”Latch。锁存器在ASIC中可能有用但在FPGA同步设计中通常会导致时序问题是bug的主要来源。条件覆盖在ONE_YUAN和TWO_YUAN状态我只处理了继续投币的情况。一个健壮的售货机还需要“取消按钮”或“超时复位”功能。这通常是课设的加分项你需要思考如何增加一个CANCEL状态或引入一个计时器。default语句这是代码健壮性的体现。独热码虽然安全但也不能保证绝对不出错。default语句像是一个安全网确保状态机在任何意外情况下都能自恢复。3.4 第三段输出时序逻辑输出逻辑决定了在某个状态下系统要做什么。在三段式中我们通常用时序逻辑寄存器来产生输出。这样做的好处是输出和时钟同步没有毛刺时序性能更好。输出可以是摩尔型Moore输出仅与当前状态有关或米利型Mealy输出与当前状态和输入都有关。我们这里采用摩尔型。// 第三段输出时序逻辑 (Moore型输出) always (posedge clk or negedge rst_n) begin if (!rst_n) begin drop_out 1b0; change_out 1b0; end else begin // 默认输出为0只有特定状态才产生脉冲 drop_out 1b0; change_out 1b0; case (current_state) THREE_YUAN: begin // 在THREE_YUAN状态我们产生出货脉冲。 // 但这里有个细节THREE_YUAN状态只持续一个时钟周期吗 // 假设是那么drop_out会是一个时钟周期的高脉冲。 drop_out 1b1; // 找零逻辑我们需要记忆投币总额。这里简化处理假设有单独的模块记录金额。 // 例如如果总金额为4元则还需要产生change_out脉冲。 // 这引出了另一个重要概念状态机需要搭配数据路径Datapath。 // if (total_amount 3) change_out 1b1; end // 其他状态输出保持为0 default: begin // 这里不需要做任何事情因为输出已经在always块开头被赋默认值0了。 end endcase end end输出逻辑的陷阱与技巧脉冲宽度drop_out脉冲持续多久在我们的设计里THREE_YUAN状态如果只持续一个周期那么drop_out也就是一个时钟周期的高电平。这能驱动电磁阀吗可能需要一个更长的脉冲或者用drop_out信号去触发一个计时器。这需要根据被控对象来调整。输出类型我们的输出是寄存器型的在时钟上升沿后才会更新。这意味着输入变化导致状态变化输出要到下一个时钟沿才会改变。这种同步输出非常稳定。数据路径分离你会发现单纯的THREE_YUAN状态并不知道投了多少钱无法判断是否需要找零。这说明状态机控制单元经常需要和一个负责计算、存储的数据路径Datapath协同工作。例如可以有一个独立的计数器total_reg来累加投币金额状态机根据total_reg的值来决定是否跳转到THREE_YUAN以及是否进入GIVE_CHANGE状态。这就是经典的“控制器-数据路径”分离设计思想。4. 状态机设计中的常见“坑”与调试技巧写完代码只是第一步仿真和调试才是重头戏。以下是我在多年项目和带学生课设中总结的几个高频问题。4.1 锁存器Latch的意外生成这是Verilog新手最常踩的坑也是综合工具警告里最常见的内容。锁存器会在你的组合逻辑always块我们第二段的状态转移逻辑中意外产生。产生条件在组合逻辑always (*)块中如果你没有在所有的输入条件下为某个寄存器变量赋值那么综合工具就会推断出一个锁存器来“保持”它之前的值。看一个错误示例// 第二段逻辑中一个危险的片段 always (*) begin case (current_state) IDLE: if (coin_in1) next_state S1; // 只写了if没写else S1: next_state IDLE; // 没有default分支 endcase end问题在哪在IDLE状态当coin_in ! 1时next_state没有被赋值。综合工具会想“那我得把next_state之前的值存起来”于是就生成了一个锁存器。同样如果current_state意外进入了非IDLE、非S1的值比如上电时的未知态next_state也没有被赋值也会生成锁存器。解决方法为组合逻辑块中的所有输出变量设置默认值正如我们在3.3节所做的next_state current_state;。写完整的if-else或case-default分支确保逻辑路径全覆盖。4.2 状态编码冲突与非法状态恢复即使你用了独热码也不能高枕无忧。在FPGA上电配置阶段或者受到严重干扰时触发器可能处于亚稳态导致状态寄存器current_state的值不是合法的独热码比如4‘b0110。如果你的状态机没有处理这种情况它就可能“卡死”或行为错乱。如何防范使用default分支如3.3节所示在case语句中一定要写default: next_state IDLE;。这样任何非法状态都会在下一个时钟周期被拉回IDLE。使用专门的安全编码对于可靠性要求极高的场合可以使用格雷码相邻状态间只有一位变化减少亚稳态传播概率或者使用工具如Synopsys的syn_encoding属性指定安全实现方式。但对于课设default语句通常足够了。4.3 仿真与调试看波形图说话代码写完后必须用仿真验证。我推荐使用ModelSim或Vivado/Quartus自带的仿真工具。测试平台Testbench编写要点初始化在初始阶段施加复位信号rst_n0保持至少几个时钟周期然后释放rst_n1。构造测试序列模拟真实的操作流程。例如测试正常流程投币1元 - 投币2元 - 观察是否出货。测试异常流程投币1元后等待很长时间模拟超时再投币观察状态机是否还正常。测试边界情况快速连续投币时钟周期内输入变化看状态机能否正确处理。关键信号监控在波形图中一定要把clk,rst_n,coin_in,current_state用模拟显示设置为二进制或独热码next_state,drop_out,change_out这些信号都加进来。观察current_state和next_state的变化是否符合状态转移图。一个实用的调试技巧在仿真中打印状态名。在Testbench中你可以定义一个函数将current_state的数值转换成字符串打印出来这样看波形更直观。// 在testbench中 task print_state; input [3:0] state; begin case(state) 4‘b0001: $display(“[%t] State: IDLE”, $time); 4’b0010: $display(“[%t] State: ONE_YUAN”, $time); 4‘b0100: $display(“[%t] State: TWO_YUAN”, $time); 4’b1000: $display(“[%t] State: THREE_YUAN”, $time); default: $display(“[%t] State: ILLEGAL (%b)”, $time, state); endcase end endtask // 在监控的地方调用 always (posedge clk) begin print_state(uut.current_state); // uut是你的状态机实例 end5. 课设进阶从状态机到小型数字系统一个完整的课设项目 rarely 只是一个孤立的状态机。它通常是一个由“控制单元”就是我们的状态机和“数据路径”共同构成的小型系统。5.1 控制器与数据路径的协同回到饮料机的找零问题。我们需要知道总共投了多少钱。这需要一个数据路径一个加法器累加投币金额。total total coin_value。一个寄存器存储当前总金额total_reg。一个比较器判断total_reg 3。这时状态机的角色就变了。它不再直接判断coin_in而是判断来自数据路径的“状态标志”比如total_ge_3总金额大于等于3。同时状态机输出控制信号给数据路径如add_en允许累加、clear_total交易完成后清零。状态转移条件升级示例状态: IDLE 若 (投币事件发生) - 输出 add_en1, 次态仍为 IDLE (由数据路径累加) 若 (数据路径标志 total_ge_3 1) - 次态 DELIVER (出货) 状态: DELIVER 输出 drop_out1, clear_total1 次态 IDLE你看状态机变得更简洁它只做决策控制具体的运算交给数据路径。这就是数字系统设计的模块化思想。5.2 添加异步输入处理与去抖课设中你的输入信号如coin_in很可能来自物理按键或开关它们是异步的与系统时钟clk不同步并且带有机械抖动。直接将其接入状态机会导致在抖动期间状态多次误跳转。标准解决方案同步器边沿检测消抖。两级同步器用两个串联的D触发器将异步信号同步到本地时钟域。这能极大降低亚稳态发生的概率。reg coin_sync1, coin_sync2; always (posedge clk) begin coin_sync1 coin_async; // 来自外部按键的异步信号 coin_sync2 coin_sync1; end // 现在 coin_sync2 是同步化后的信号边沿检测检测同步后信号的上升沿或下降沿得到一个单时钟周期的脉冲。reg coin_sync2_dly; wire coin_posedge; always (posedge clk) coin_sync2_dly coin_sync2; assign coin_posedge coin_sync2 ~coin_sync2_dly; // 上升沿检测消抖对于按键仅检测边沿还不够还需要过滤掉抖动期间的毛刺。常用方法是计时器消抖检测到边沿后启动一个20ms左右的计时器计时结束后再采样一次如果信号仍稳定则认为是一次有效按键。这个计时器本身就可以用一个小的状态机或计数器来实现。将处理后的干净脉冲coin_valid_pulse作为状态机的输入你的系统可靠性会大大提升。5.3 状态机的可视化状态转移图在动手写代码之前强烈建议先在纸上或使用绘图工具如Draw.io Visio画出状态转移图。圆圈代表状态箭头代表转移条件和输出。这能帮你理清所有可能的状态和路径避免逻辑遗漏。这也是你课设报告中的重要组成部分。一个清晰的状态转移图能向评审者证明你完全理解了自己设计的系统。6. 总结与个人心得状态机是数字逻辑设计的灵魂。通过这个饮料机的例子我们从概念到代码从基本实现到避坑进阶走完了一个完整的设计流程。课设的要求可能千变万化可能是电梯控制器、数字钟、密码锁但其内核都是状态机。我个人的几点深刻体会 第一规划重于编码。花70%的时间画状态图、理清输入输出、考虑异常情况剩下的30%写代码会非常顺畅。很多同学debug的时间远超写代码的时间根源就在于前期没想清楚。 第二仿真是最好的老师。不要满足于“看起来能工作”。构造尽可能多的测试用例包括正常的、异常的、极端的。波形图不会说谎它能暴露你逻辑中所有隐藏的角落。 第三规范即效率。坚持使用三段式写法坚持加default语句坚持对异步输入做同步处理。这些看似繁琐的规范是无数前人踩坑总结出来的最佳实践能帮你避开90%的诡异问题。 最后理解状态机不仅仅是完成课设它培养的是一种“状态化”的思维方式。当你面对任何复杂的顺序流程问题时尝试把它拆解成状态、条件、动作你会发现问题的结构瞬间清晰。这种能力在软件编程比如游戏AI、协议解析、自动化控制等领域同样至关重要。希望这篇长文能帮你不仅搞定课设更能真正领略到状态机设计的精妙之处。