Verilog三段式状态机详解:从原理到UART串口接收实战与仿真排坑

发布时间:2026/9/7 10:54:52
Verilog三段式状态机详解:从原理到UART串口接收实战与仿真排坑 状态机这个东西写Verilog的没人能绕开。出去面试数字IC或者FPGA岗位十家里有八家会问三段式状态机怎么写进公司做项目串口、SPI、I2C、以太网随便哪个接口协议的核心控制逻辑拆开一看全是状态机。可以说状态机就是数字逻辑里把“顺序”和“决策”落到硬件上的最核心手段。这篇就从头把Verilog状态机讲透重点放在三段式写法、完整的串口接收实例以及仿真调试里那些文档里不会写的坑。不管是刚入门Verilog的新手还是写过一阵子但总觉得自己状态机写得不够利索的同学这篇都值得仔细看一遍。1. 状态机的本质从需求到数字电路设计的桥梁1.1 为什么数字设计绕不开状态机先回答一个最基本的问题状态机到底解决了什么问题答案只有四个字——记住进度。组合逻辑的输出只跟当前输入有关给什么出什么没有记忆能力。但真实世界的协议处理不是这样的我要等起始位、收8个数据位、再等停止位每一步都依赖之前走到了哪里这就是“顺序”的概念。而“顺序”落到数字电路里就是时钟沿到来时把当前阶段记录下来再根据输入决定下一个阶段往哪走。这个“记录当前阶段决定下一阶段”的结构就是状态机。我用生活里的例子来解释。你家用的是滚筒洗衣机它的控制逻辑就是一个典型状态机初始是待机状态按了启动键之后进入进水状态水位到了进入洗涤状态洗涤时间到了进入排水状态排水结束进入脱水状态最后回到待机。每一步能不能往下走完全取决于当前在哪个阶段、以及这个阶段的完成条件是否满足。如果你非要用纯组合逻辑去描述洗衣机的整个流程那代码会乱到根本没法维护。状态机之所以是“桥梁”是因为它给了我们一种思维方式拿到一个复杂协议或者复杂控制需求时第一步不是写代码而是把整个流程拆成若干个稳定阶段再把阶段之间的跳转条件列清楚。这一步做完代码怎么写其实已经定了。1.2 Moore型与Mealy型两种输出方式的取舍状态机按输出生成方式分为两种面试最喜欢问这个区别类型输出取决于特点典型场景Moore型当前状态输出稳定、无毛刺但可能多等一个周期按键消抖、协议帧解析Mealy型当前状态 当前输入响应快但组合逻辑输出易产生毛刺总线仲裁、数据通路控制Moore型的输出只跟现态有关相当于输出是状态的函数输出在整个状态周期内保持稳定。Mealy型的输出还跟输入有关输入一变输出立刻变好处是省周期、响应快坏处是输入上的毛刺会直接串到输出上。实际工程里我个人的习惯是能用Moore尽量用Moore特别是输出信号要送给其他模块或者对外接口时时序上更干净不容易出一些莫名其妙的毛刺问题。Mealy型更多用在性能敏感、需要提前一拍给出应答信号的场景。如果你刚学状态机先把Moore型写熟再碰Mealy也来得及。2. 三段式状态机业界最主流的标准写法2.1 一段式、二段式、三段式到底差在哪网上一搜“状态机写法”跳出来的基本都是“三段式”听起来像个玄学其实拆开看特别简单。三段式就是把状态机的三种逻辑分成三个always块分别描述第一段时序逻辑把次态打一拍变成现态第二段组合逻辑根据现态和输入算出次态第三段输出逻辑根据现态或现态输入给出输出那为什么非要分成三段直接一段写完不行吗早期很多教材上的“一段式”就是把状态跳转、状态寄存、输出逻辑全写在一个always块里。这种写法最大的问题是输出被寄存器打了一拍和状态跳转混在一起代码可读性极差。你很难一眼看出某个输出在哪个状态生效改动一个输出条件可能把整段逻辑都带崩。二段式把状态跳转和状态寄存写成一段时序、把输出写成另一段组合比一段式清晰不少但输出如果是组合逻辑容易产生毛刺而且“输出写在组合逻辑里”这件事本身对新手就不太友好。三段式把状态寄存、次态计算、输出逻辑彻底分离每一段只干一件事。它的优势是结构清晰、容易维护、时序可控。你在公司里看到的老项目状态机大概率也是这个风格。下面逐段拆开讲。2.2 第一段现态到次态的时序更新// 第一段状态寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) current_state IDLE; else current_state next_state; end这段代码是所有状态机的地基逻辑上只有两件事复位时回到初始状态时钟上升沿来到时把计算好的次态赋给现态。注意这里一定要用非阻塞赋值因为它要和其他时序逻辑一起在时钟沿统一更新非阻塞赋值能保证同一个时钟沿上所有寄存器同时更新不会出现变量先后覆盖的竞争问题。复位方式这里我用的异步复位也就是negedge rst_n一拉低状态立刻回到IDLE不用等时钟沿。工业界更推荐“异步复位、同步释放”的做法避免复位信号释放时和时钟沿产生竞争但对入门阶段来说先学会用异步复位把电路复位干净这一点更重要。2.3 第二段次态计算纯组合逻辑// 第二段次态组合逻辑 always (*) begin next_state current_state; // 默认保持当前状态 case (current_state) IDLE: if (start_sig) next_state WORK; WORK: if (done_sig) next_state IDLE; default: next_state IDLE; endcase end第二段是整个状态机的“大脑”负责根据当前状态和输入条件算出下一个时钟沿应该跳到哪个状态。两个关键点第一开头先写next_state current_state;。这叫默认赋值意思是“没有任何跳转条件满足时留在当前状态”。这个习惯能省掉大量的else嵌套不然每个状态里都要把“不跳转”的情况写一遍代码能肥一倍。第二case语句必须写default。组合逻辑里如果case没覆盖所有状态综合工具会认为有些情况“没有赋值”自动给你生成一个锁存器。锁存器在数字设计里是个大坑——时序难分析、容易出毛刺、还浪费面积。写default就是为了告诉综合工具“没列到的情况我也处理过了”从根上杜绝锁存器。这一段的赋值用阻塞赋值因为它是纯组合逻辑需要立即生效让next_state在当前时间步内就更新完这样时序逻辑那一端才能在时钟沿来临时拿到正确的值。2.4 第三段输出逻辑组合还是时序第三段是状态机的“手脚”根据当前状态向外输出控制信号。这里有两种选择// 方式一组合逻辑输出Moore型 always (*) begin case (current_state) WORK: data_valid 1b1; default: data_valid 1b0; endcase end // 方式二时序逻辑输出寄存器输出 always (posedge clk or negedge rst_n) begin if (!rst_n) data_valid 1b0; else if (current_state WORK) data_valid 1b1; else data_valid 1b0; end方式一是组合输出好处是输出对状态变化的响应零延迟状态一变输出立刻变适合对时序要求苛刻的场景。但组合逻辑输出有个众所周知的毛病如果状态信号本身带有毛刺输出也会跟着毛刺。方式二是把输出打了一拍输出信号完全由寄存器驱动干净、稳定、没有任何毛刺风险。代价是输出相对于状态变化晚了一个时钟周期。在很多协议场景中这个延迟是完全可以接受的甚至协议本身就需要这种“时序对齐”的输出。我的建议是除非有明确的时序要求第三段优先用时序逻辑输出。宁可多等一拍也别让一个毛刺把下游模块搞崩。后面串口接收的例子就用了这种寄存器输出方式。3. 实战演练完整实现一个串口接收状态机3.1 为什么拿UART接收开刀讲完理论必须上手写一个完整的东西不然全是纸上谈兵。我选了UART串口接收来做完整案例原因有三个第一UART接收本身就是状态机最典型的应用场景IDLE等待→起始位→数据位→停止位阶段清晰非常适合练手。第二串口协议人人都在用写完这个状态机等于手里多了一个可以直接复用的模块。第三它涉及同步采样、计数分频、标志位生成这些状态机设计里绕不开的细节练一个顶三个。需求定义我们要接收一帧标准UART数据1位起始位、8位数据位低位在前、1位停止位无校验位。系统时钟50MHz波特率设为115200。3.2 波特率分频与采样点计算写代码之前先把最关键的参数算明白。UART每个bit持续的时间是波特率的倒数比特时间 1 / 115200 ≈ 8.68us系统时钟50MHz周期是20ns所以一个比特宽度对应多少个时钟周期BAUD_CNT 50_000_000 / 115200 ≈ 434这意味着计数器要从0数到433刚好一个bit的时间。那采样点设在什么时候最稳答案是每个bit的中间位置也就是计数到217的时候。因为信号刚跳变时不稳定、可能有振铃踩在中间采样最安全。我在代码里定义了两个参数localparam BAUD_CNT 50_000_000 / 115_200; // 434一个bit宽度 localparam MID_CNT BAUD_CNT / 2; // 217bit中点采样localparam的好处是只在当前模块内可见不会污染其他模块。而且用表达式直接算以后换时钟频率或者波特率只改一个数字就行。3.3 完整代码三段式状态机实现UART接收先定义状态。我把接收过程拆成五个状态localparam IDLE 3d0; // 空闲等待起始位 localparam START 3d1; // 检测到起始位移动到bit中点 localparam DATA 3d2; // 接收8个数据位 localparam STOP 3d3; // 接收停止位 localparam DONE 3d4; // 一帧接收完成产生valid信号多了一个DONE状态是为了把“接收完成”这个事件变成一个干净的、持续一个周期的脉冲信号避免在STOP状态里既要接收停止位又要输出valid逻辑混在一起。接下来是完整模块代码module uart_rx_fsm #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115_200 )( input wire clk, input wire rst_n, input wire rx, output reg [7:0] rx_data, output reg rx_valid ); // 状态编码 localparam IDLE 3d0; localparam START 3d1; localparam DATA 3d2; localparam STOP 3d3; localparam DONE 3d4; // 分频参数 localparam BAUD_CNT CLK_FREQ / BAUD_RATE; // 434 localparam MID_CNT BAUD_CNT / 2; // 217 // 内部信号 reg [2:0] current_state; reg [2:0] next_state; reg [8:0] baud_cnt; // 波特率计数器 reg [2:0] bit_cnt; // 已接收数据位数 reg [7:0] rx_data_temp; // 移位寄存器 // 第一段时序逻辑状态更新 always (posedge clk or negedge rst_n) begin if (!rst_n) current_state IDLE; else current_state next_state; end // 第二段组合逻辑次态计算 always (*) begin next_state current_state; case (current_state) IDLE: begin // 检测到rx从高拉低说明起始位到来 if (rx 1b0) next_state START; end START: begin // 计数到达bit中点完成起始位采样 if (baud_cnt MID_CNT) next_state DATA; end DATA: begin // 每个bit中点采一次收满8位后进入STOP if (baud_cnt BAUD_CNT - 1 bit_cnt 3d7) next_state STOP; end STOP: begin // 停止位采样完成后一帧结束 if (baud_cnt MID_CNT) next_state DONE; end DONE: begin // valid只拉高一个周期下一拍回到空闲 next_state IDLE; end default: next_state IDLE; endcase end // 辅助计数器波特率计数和位计数 always (posedge clk or negedge rst_n) begin if (!rst_n) begin baud_cnt 9d0; bit_cnt 3d0; end else begin case (current_state) START: begin if (baud_cnt MID_CNT) baud_cnt 9d0; else baud_cnt baud_cnt 1b1; end DATA: begin if (baud_cnt BAUD_CNT - 1) begin baud_cnt 9d0; bit_cnt bit_cnt 1b1; end else begin baud_cnt baud_cnt 1b1; end end STOP: begin if (baud_cnt MID_CNT) baud_cnt 9d0; else baud_cnt baud_cnt 1b1; end default: begin baud_cnt 9d0; bit_cnt 3d0; end endcase end end // 数据采样在DATA状态bit中点时把rx值移入寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) rx_data_temp 8d0; else if (current_state DATA baud_cnt MID_CNT) rx_data_temp {rx, rx_data_temp[7:1]}; // 低位先行右移 end // 第三段时序逻辑输出 always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_data 8d0; rx_valid 1b0; end else begin if (current_state DONE) begin rx_valid 1b1; rx_data rx_data_temp; end else begin rx_valid 1b0; end end end endmodule有几个细节必须说清楚。关于START状态的计数起点IDLE时我并没有把baud_cnt清零但IDLE的default分支已经把baud_cnt清零了所以进入START时计数器是从0开始的。START只需要计到MID_CNTbit中点就可以进DATA因为起始位的剩余时间会在DATA状态的第一个计数周期里“浪费”掉。关于数据存放方向UART低位先发我用的是右移{rx, rx_data_temp[7:1]}这样第一个收到的bit会落在最高位接收完8个bit后rx_data_temp[7:0]正好对应发送端的原始数据序。这个细节很值得记一下很多人第一次写串口接收就在这栽了收到的数据bit序是反的。关于输出时序我刻意把rx_data和rx_valid用寄存器输出在DONE状态拉高valid并锁存数据。这样后续模块看到rx_valid为高时rx_data上一定是稳定的数据不会出现组合逻辑导致的数据端毛刺。3.4 Testbench编写与Modelsim仿真要点光有设计代码不算完状态机必须仿真验证过才能说“写好了”。Testbench的核心任务就是给DUT被测模块喂激励、观察输出是否和预期一致。timescale 1ns / 1ps module tb_uart_rx_fsm(); reg clk; reg rst_n; reg rx; wire [7:0] rx_data; wire rx_valid; // 待发送数据例如 0xA5 8b1010_0101 parameter [7:0] DATA 8hA5; // 时钟生成50MHz - 20ns周期 initial clk 0; always #10 clk ~clk; // 串口发送任务低位先发1起始位 8数据位 1停止位 task send_byte(input [7:0] data); integer i; begin // 起始位拉低1bit时间 rx 1b0; #8680; // 8个数据位先从最低位开始 for (i 0; i 8; i i 1) begin rx data[i]; #8680; end // 停止位 rx 1b1; #8680; end endtask initial begin rst_n 0; rx 1b1; #100; rst_n 1; #100; // 发送一个字节 0xA5 并等待接收完成 send_byte(DATA); wait (rx_valid 1b1); $display(Received: 0x%02X, rx_data); // 发送第二个字节 0x3C send_byte(8h3C); wait (rx_valid 1b1); $display(Received: 0x%02X, rx_data); #1000; $finish; end // 初始化波形导出 initial begin $dumpfile(tb_uart_rx_fsm.vcd); $dumpvars(0, tb_uart_rx_fsm); end endmoduleTask是Testbench里特别实用的语法把“发送一个字节”这个动作封装起来主流程可以连续发多个字节代码非常简洁。#8680就是8.68us的比特时间用#延时来模拟真实串口线的电平变化。这里我要特别强调Modelsim仿真时的一个关键操作把内部状态信号加到波形窗口里查看。很多人仿真时只看顶层输入输出发现数据不对却不知道卡在哪个状态调试效率极低。正确做法是在Modelsim里对DUT实例选择current_state、next_state、baud_cnt、bit_cnt这些内部信号Add to Wave然后在Wave窗口把这些信号设置成Radix为Symbolic。这样波形上显示的是 IDLE、START、DATA 这些状态名而不是冰冷的3d2状态跳转过程一目了然。还有一个常用技巧是强制跳状态。如果想让状态机跳过某段逻辑直接在Modelsim命令行或者.do脚本里用force命令把current_state改成任意值比如force -freeze /tb/uart_rx_fsm/dut/current_state 3d4 0然后接着跑仿真观察行为。这在定位问题时特别有用不用改代码就能测任意状态下的逻辑。4. 状态机设计经验与常见坑整理4.1 编码方式二进制、格雷码、独热码状态编码这件事面试必问工程里也影响巨大。三种主流编码方式各有优劣编码方式原理优点缺点适用场景二进制编码状态值按0、1、2、3顺序编码触发器数量最少逻辑简单状态跳转时可能有多个bit同时变化增加毛刺风险CPLD、逻辑资源紧张的设计格雷码相邻状态只差一个bit每次跳转只有一个bit翻转毛刺少功耗低任意两个状态间跳转的“距离”可能很远状态连续跳转的场景如计数器式状态机独热码每个状态对应一个bit为1表示当前状态译码逻辑最简单速度快时序好触发器数量多N个状态需要N个触发器FPGA设计首选FPGA里为什么默认用独热码因为FPGA本身有大量的触发器资源缺的不是触发器而是组合逻辑。独热码省掉了复杂的译码电路状态判断只需要判断某一个bit是否为1组合逻辑层级少、路径短时序更好收敛。Quartus和Vivado的综合选项里甚至有默认的“State Machine Encoding”设置一般都推荐选One-Hot就是这个原因。但要注意独热码资源开销是二进制的好几倍状态特别多时比如二三十个触发器消耗就很可观了这时候要用回二进制或者格雷码。我个人的经验阈值是状态数少于10个优先独热码多于15个老老实实二进制。4.2 常见的诡异问题与排查办法状态机写多了各种奇怪的bug都见过。我整理了最常踩的几个坑症状可能原因排查与解决状态机卡死死在一个状态出不来跳转条件永远不满足或者输入信号被自己模块驱动用$display打印状态和关键输入确认条件是1还是0输出信号有毛刺第三段用了组合逻辑输出或者输入信号未同步第三段改时序逻辑输出输入先打两拍同步数据接收错位总是错一个周期状态跳转时刻和采样时刻没对齐检查是“进入状态后第几个周期采样”建议在状态中点采样一上电状态机乱跳复位没复位干净或者default分支缺失确认复位信号时序检查case是否完整综合后资源暴增状态编码不合适或者第二段产生了锁存器检查综合报告确认编码方式检查always块是否所有分支都已赋值这里单说一个最容易踩的坑输入信号没有同步。外部进来的信号比如按键、串口rx相对于你的时钟域是异步的如果不打拍直接用状态机的跳转条件可能采到亚稳态导致状态机随机跳转。标准做法是所有外部输入信号进模块后先打两级触发器同步再进状态机逻辑。不要嫌这两拍浪费它能帮你挡掉95%的诡异问题。4.3 那些不写在教科书里的经验最后分享几条我在实际项目里总结出来的状态机设计心得这些内容教科书上不会专门写但都是实打实有用的。第一写代码之前先画状态图。我见过太多人拿到需求直接开写写到一半发现漏了状态然后开始打补丁最后代码乱成一团。状态机的设计重点从来不是代码而是状态的划分和跳转条件的梳理。哪怕只是拿草稿纸画几个圈、几根箭头把跳转条件标清楚后续写代码就是体力活了。第二状态命名要有语义。用IDLE、READ_ADDR、READ_DATA、WRITE_RESP这种名字比S1、S2、S3强一百倍。代码是写给人看的三个月后你回来看自己的代码看到S2你还得翻注释才知道是什么状态看到READ_DATA一眼就明白。第三仿真时把状态名显示出来。前面提过把current_state设置成Symbolic显示这一步真的能大幅提升调试效率。看着波形上IDLE→START→DATA→STOP一步步走比对着二进制数值猜状态快太多了。第四给状态机加一个超时保护。一些总线协议场景下如果对端设备异常状态机可能一直停在某个状态等一个永远等不到的条件。高端设计里会专门加一个超时计数器超时强制回到IDLE。这属于进阶玩法但能体现设计思维面试时能主动讲出这一点会加分不少。我个人在实际项目中体会到状态机写得好不好分水岭不在代码技巧而在对“时间”和“阶段”的理解。每个状态什么时候进、什么时候出、输出信号在哪一拍生效这些想清楚了状态机自然就稳了。写Verilog这行能老老实实把状态机写到滴水不漏就已经超越大部分人了。