FPGA三段式状态机设计详解:从基础概念到UART实战

📅 发布时间:2026/9/8 19:51:12
FPGA三段式状态机设计详解:从基础概念到UART实战
干了这么多年FPGA和数字IC我越来越觉得状态机就是数字逻辑设计的骨架。无论是按键消抖、UART收发、SPI从机还是DDR3控制器、Cache实现、FIR滤波器控制逻辑归根结底都是在用状态机组织时序、管理流程。很多初学者一上来就盯着三段式两段式背代码模板却搞不明白状态机解决的本质问题是什么。这篇文章我就从状态机的核心概念讲起一步步拆解三段式状态机的设计思路再给一个完整的UART接收状态机工程实例配合仿真验证尽量把状态机这个基础但极其重要的知识点讲透。我写这篇东西的初衷是因为带过不少新人也在论坛上看到大量关于状态机的提问。很多问题不是语法不会而是设计思路没转过来对为什么这么写、状态跳转该怎么规划、输出怎么才能不出毛刺这些关键点缺乏体系化的理解。希望通过这篇文章能把一个合格的数字电路工程师在处理状态机时的完整思考路径分享出来让新手少走弯路。1. 状态机到底在干什么从问题本质到设计选型1.1 状态机是用来组织时间顺序的胶水数字电路里有两类逻辑一类是组合逻辑只管当前输入到输出的映射没有记忆能力另一类是时序逻辑靠时钟驱动寄存器能记住历史信息。状态机的本质就是组合逻辑 寄存器 反馈路径的组合体它在每个时钟沿根据当前所处的状态和外部输入决定下一个状态是什么并产生相应的输出。说个生活化的类比就好像做饭的流程初始是备菜状态菜切好了进炒制状态炒完了进装盘状态。每个状态里做什么、做完往哪走都是由当前进度和锅里食材的情况共同决定的。状态机的价值就在于它能把一个复杂的时间顺序流程拆解成若干个稳定的状态点再明确定义每个状态之间的转移条件。状态拆分得越合理代码越容易写时序越容易收敛错误也越好排查。对于Verilog开发者来说我建议一开始就建立这样一个认知顶层模块的micro-architecture本质上就是在画状态转移图。你问自己几个问题——这个模块要经历哪几个阶段每个阶段需要满足什么条件才能进入下一个阶段输出信号是取决于当前状态还是取决于状态加输入这几个问题想清楚了状态机的大框架基本就稳了。1.2 Moore型与Mealy型输出逻辑的两种思维模式状态机从输出生成方式上分两大类Moore型和Mealy型。Moore型状态机的输出只由当前状态决定。它的好处是输出在状态转移之后才变化输出与时钟沿对齐天然稳定不容易出现组合逻辑毛刺穿透到输出端的情况。代价是需要多等一个周期存在一个周期的输出延迟而且有些情况下需要用更多的状态来表达相同的行为。Mealy型状态机的输出则同时由当前状态和输入共同决定。它的好处是响应快输入一变输出立即可变不需要等状态转移。坏处是输出由组合逻辑直接驱动一旦输入存在毛刺输出就可能跟着跳敏感信号一多组合逻辑很容易产生竞争冒险严重时会造成功能错误。实际工程里怎么选我个人的习惯是如果输出本身就是要参与对外协议握手对时序要求苛刻比如SPI的MOSI、I2C的SCL通常优先考虑Moore型配合寄存输出避免协议信号上出现毛刺。如果只是内部逻辑使用比如一个使能标志、一个数据选择信号Mealy型往往更紧凑。如果拿不准用Moore型准没错安全第一。1.3 状态编码二进制、格雷码还是独热码状态编码这里有个经典的三角权衡性能、面积、功耗。二进制编码状态数最少用floor(log2(N))个bit表示N个状态寄存器资源最省但状态跳转时多个bit可能同时翻转组合逻辑复杂容易出现中间态竞争时序收敛相对困难。适合状态数量中等比如十几个以内、对面积敏感的模块。格雷码编码保证相邻状态之间只有1个bit翻转功耗低抗干扰能力强适合状态转移路径相对固定、呈线性滑动的情况比如FIFO的读写指针比较多见。但状态一旦不是顺序跳转格雷码的优势就丧失还平白增加译码逻辑。独热码每个状态占1个bitN个状态需要N个寄存器。看起来浪费但换来的是速度极快、组合逻辑极其简单、状态比较就是1bit比较。FPGA的LUT资源丰富寄存器多综合工具普遍建议状态机使用独热码。比如在Vivado里综合选项默认会帮你做状态编码优化很多时候你写二进制它也会自动转成独热或格雷。对于状态数不超过16个的典型状态机我通常直接写独热码不仅综合资源好仿真波形里一眼就能看清当前处于哪个状态排错直观。三种编码方式的对比我整理在下表里编码方式寄存器开销组合逻辑复杂度速度适用场景二进制最省较高较慢状态多、面积敏感格雷码中等中等中等顺序跳转、低功耗独热码最费最简最快FPGA、状态少2. 三段式状态机为什么现在主流设计都推荐它2.1 一段式的问题在哪里先看一段式的写法状态跳转、状态译码、输出逻辑全挤在一个always块里。典型的代码是这样一个结构在时序逻辑块里用case语句判断当前状态然后根据输入决定next state同时直接给输出赋值。这种写法输入输出逻辑混在一起状态机的可读性非常差一旦状态多、输出多代码很难维护。更麻烦的是输出由时序逻辑生成部分路径上会有寄存器延迟部分路径上可能被综合成组合逻辑前后不一致时序检查麻烦输出毛刺也难控制。干活这些年我真的见过不少人在一段式里把输出写成组合逻辑——用了阻塞赋值结果仿真通不过或者上板后出现偶发错误查了一整天最后发现是输出逻辑里组合和时序混用导致的竞争问题。2.2 三段式的结构划分和代码模板三段式的核心思想就是让状态转移、次态判断和输出产生各司其职每段只干一件事。第一段是状态转移时序逻辑写成always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end第二段做次态判断组合逻辑根据当前state和输入信号算出next_statealways (*) begin next_state state; case (state) IDLE: if (start_rx) next_state START; START: if (mid_bit) next_state DATA; // ... default: next_state IDLE; endcase end第三段生成输出既可以是时序逻辑也可以是组合逻辑但推荐用时序逻辑寄存输出always (posedge clk or negedge rst_n) begin if (!rst_n) data_out 8d0; else begin case (state) DATA: if (data_valid) data_out rx_byte; // ... endcase end end这种拆分的好处是第一段就是简单的寄存器更新时序分析非常干净这一条路径上只有DFF建立时间约束极易收敛第二段是纯组合逻辑所有的决策逻辑集中在一起审查状态转移时只盯这一段就行第三段把输出寄存一拍彻底消除组合逻辑毛刺同时每个输出信号的路径都是FF to FF时序收敛压力最小。2.3 为什么第三段推荐用时序逻辑输出很多人问第三段如果输出只是一个简单的组合标志比如当前在IDLE状态输出一个idle_flag那用assign idling (state IDLE)不就行了确实可以但这属于Moore型状态机的组合输出在状态切换的瞬间因为其它信号路径的延迟差异这个输出上可能产生一个极窄的毛刺。如果这个标志被当作异步复位信号、使能信号或者握手信号毛刺在极少数情况下就会导致无法复现的bug而且极难排查。所以我的原则是只要输出对外部逻辑有意义一律加一级时序寄存哪怕多一个FF的资源。用时序输出后输出的变化严格对齐时钟沿即使状态跳转一瞬间组合逻辑不稳定输出也感知不到。这在协议模块、总线控制模块里尤其重要。后面UART的例子我会实际演示怎么处理。3. 实战操练用三段式状态机实现UART接收模块3.1 需求拆解和状态划分光讲概念不过瘾这里选一个非常有代表性的例子——UART接收。一方面是因为UART协议足够简单状态特征明显另一方面是因为UART接收里同时用到了输入信号边沿检测、分频计数、数据采样、状态跳转这些数字设计里最常用的手法非常适合当作状态机标准范例。需求设定为最常用的8-N-1格式1个起始位低电平、8个数据位LSB先发、无校验位、1个停止位高电平波特率9600bps系统时钟50MHz。状态怎么划分我按物理信号的变化阶段来分分成5个状态IDLE空闲状态等待RX线上出现下降沿。一旦检测到下降沿说明可能有起始位到来进入START。START确认起始位。这里用中点采样法确认在起始位的中间时刻再采样一次如果确实是低电平则进入DATA否则认为是一次干扰回IDLE。DATA接收8个数据位。每位中心时刻采样一次采到的bit按位拼装成字节。STOP停止位采样。如果在停止位采样点读到高电平说明一帧数据正常接收进入DONE否则标记帧错误。DONE输出接收完成标志和并行数据停留一个周期后回IDLE。这个状态划分逻辑上非常自然每个状态对应一个明确的协议阶段状态跳转条件也很清晰。你以后写复杂的协议模块比如SPI Slave、I2C、甚至DDR控制命令序列都是同样的拆法把协议时序阶段画出来一个阶段就是一个状态。3.2 分频计数参数计算50MHz时钟9600波特率每一位的时间是1/9600秒一个bit周期需要的时钟周期数N 50000000 / 9600计算一下是5208.33。取整为5208这样实际波特率是50000000/5208 9600.6bps误差约0.006%远小于UART要求的±2%误差范围完全没问题。这里有一个关键细节为了在bit中心点采样需要额外产生半个bit周期的延时。我的做法是把计数器分为两种情况启动时先计数N/2也就是2604个周期到达bit中心之后每走完一个完整bit周期5208再采样一次。用两个条件判断代码里我通常写成一个计数器加一个limit信号的做法看着清爽实际综合出来也简单。另外起始位下降沿检测这里也要注意。异步信号rx进来先打两拍做同步避免亚稳态。检测下降沿是把打拍后的信号用前一拍是高、当前拍是低来判断写成neg_edge rx_sync_d1 ~rx_sync_d2。这里不要直接在原始输入上做下降沿检测异步信号直接进组合逻辑是有风险的。3.3 Verilog实现完整可综合代码下面给出一套完整可综合的代码。这个代码我按可维护性做了拆分参数、状态、计数器、采样、输出依次写清楚module uart_rx_fsm ( input wire clk, input wire rst_n, input wire rx, output reg [7:0] data_out, output reg data_valid ); // 参数定义 localparam IDLE 5b00001, START 5b00010, DATA 5b00100, STOP 5b01000, DONE 5b10000; localparam CLK_FREQ 50_000_000; localparam BAUD_RATE 9_600; localparam BIT_CNT_MAX CLK_FREQ / BAUD_RATE - 1; // 5207 localparam HALF_BIT_CNT CLK_FREQ / BAUD_RATE / 2 - 1; // 2603 // 信号声明 reg [4:0] state, next_state; reg rx_sync_d1, rx_sync_d2; wire rx_negedge; reg [12:0] clk_cnt; reg [ 2:0] bit_idx; reg [ 7:0] shift_reg; reg sample_en; reg bit_done; wire mid_bit; // 第一段异步信号同步打拍 下降沿检测 always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_sync_d1 1b1; rx_sync_d2 1b1; end else begin rx_sync_d1 rx; rx_sync_d2 rx_sync_d1; end end assign rx_negedge rx_sync_d2 ~rx_sync_d1; // 第二段状态转移时序逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end // 第三段次态判断组合逻辑 always (*) begin next_state state; case (state) IDLE: begin if (rx_negedge) next_state START; end START: begin if (mid_bit) begin if (rx_sync_d2 1b0) next_state DATA; else next_state IDLE; end end DATA: begin if (bit_done (bit_idx 3d7)) next_state STOP; end STOP: begin if (mid_bit) next_state DONE; end DONE: begin next_state IDLE; end default: next_state IDLE; endcase end // 第四段计数器与采样点生成 always (posedge clk or negedge rst_n) begin if (!rst_n) begin clk_cnt 13d0; sample_en 1b0; bit_done 1b0; bit_idx 3d0; end else begin case (state) IDLE: begin clk_cnt 13d0; sample_en 1b0; bit_done 1b0; bit_idx 3d0; end START: begin if (clk_cnt HALF_BIT_CNT) begin clk_cnt 13d0; sample_en 1b1; bit_done 1b0; end else begin clk_cnt clk_cnt 13d1; sample_en 1b0; end end DATA: begin if (clk_cnt BIT_CNT_MAX) begin clk_cnt 13d0; sample_en 1b1; bit_done 1b1; bit_idx bit_idx 3d1; end else begin clk_cnt clk_cnt 13d1; sample_en 1b0; bit_done 1b0; end end STOP: begin if (clk_cnt HALF_BIT_CNT) begin clk_cnt 13d0; sample_en 1b1; bit_done 1b0; end else begin clk_cnt clk_cnt 13d1; sample_en 1b0; end end default: begin clk_cnt 13d0; sample_en 1b0; bit_done 1b0; bit_idx 3d0; end endcase end end // 采样与移位拼接 always (posedge clk or negedge rst_n) begin if (!rst_n) begin shift_reg 8d0; end else if (sample_en (state DATA)) begin shift_reg {rx_sync_d2, shift_reg[7:1]}; end end // 输出寄存 always (posedge clk or negedge rst_n) begin if (!rst_n) begin data_out 8d0; data_valid 1b0; end else if (state DONE) begin data_out shift_reg; data_valid 1b1; end else begin data_valid 1b0; end end assign mid_bit (clk_cnt HALF_BIT_CNT); endmodule这段代码有几处细节值得说清楚。移位寄存器方向这里UART是LSB先发第一bit到达的是bit0。我在DATA状态下检测到sample_en时把当前采集值拼到最高位、原数据右移也就是shift_reg {rx_sync_d2, shift_reg[7:1]}。经过8次采样后第一个采到的bit0就被移到了最低位最终数据正好拼成一个正常的字节。这个方向很多人搞反仿真出来数据不对又查不出原因。建议每次写完先拿0x55二进制01010101测一帧能一次过就说明方向和位序是对的。DATA状态下的bit_done信号含义是第i个bit采样完毕。当bit_idx到7且bit_done有效说明8个数据位都已经收完下一拍就该去STOP了。这里注意bit_idx在DATA下是每个bit周期加1不是只加到7就不动所以到第8个bit时bit_idx会从6变7配合bit_done作为跳转条件。还有一种写法是直接用bit_idx 3d7 clk_cnt BIT_CNT_MAX作为跳转条件也可以代码更省一个信号但语义稍微乱一点。我选择单独引出bit_done一是可读性好二是后续如果要做多字节FIFO缓存这个脉冲可以直接当写使能。START状态下只计数半个bit周期就采样是因为起始位的下降沿是在bit起点检测到的半个bit周期后正好是起始位的中心。确认是低电平后计数器清零并进入DATADATA的第一次采样计数还是从0开始数到一个完整bit周期这样采到的恰好是第一个数据位的中心。这个时序你画个波形图就明白了。3.4 编写Testbench并跑通仿真代码写完了必须验证直接写一个testbench来测。验证思路是先发一个0xA5二进制10100101验证基本收发再发一个0x55验证位序然后再模拟一次信号抖动验证START状态的中点采样能滤掉毛刺。timescale 1ns/1ps module tb_uart_rx_fsm; reg clk; reg rst_n; reg rx; wire [7:0] data_out; wire data_valid; uart_rx_fsm dut ( .clk(clk), .rst_n(rst_n), .rx(rx), .data_out(data_out), .data_valid(data_valid) ); // 50MHz时钟 initial clk 1b0; always #10 clk ~clk; // 用任务简化UART帧发送 task send_byte(input [7:0] data); integer i; begin // 发送起始位 rx 1b1; #(10416); // 先保持一段空闲 rx 1b0; #(10416); // 起始位 // 8个数据位 LSB first for (i 0; i 8; i i 1) begin rx data[i]; #(10416); end // 停止位 rx 1b1; #(10416); end endtask initial begin rst_n 1b0; rx 1b1; #100; rst_n 1b1; #200; // 发送字节0xA5 send_byte(8hA5); wait (data_valid 1b1); $display(received 0x%02x, expect 0xA5, data_out); #2000; // 发送字节0x55 send_byte(8h55); wait (data_valid 1b1); $display(received 0x%02x, expect 0x55, data_out); #2000; // 模拟毛刺在空闲态短时间拉低后恢复 rx 1b1; #100; rx 1b0; #1000; rx 1b1; #50000; $display(glitch test done); $stop; end endmodule仿真时钟周期是20ns50MHz每一位时间10416ns。run完整个测试正常情况下仿真输出应该显示两次接收正确并且第三个毛刺测试后状态机不会误入DATA状态。这个测试里有一个很重要的点使用了wait(data_valid 1b1)来同步握手防止直接sleep固定时长带来的时序不确定。真实工程里我建议在testbench里尽量少用固定延时判断结果改用事件驱动或wait这样仿真更稳健回归测试也不会因为时序微调而挂掉。Modelsim和Vivado Simulator都能直接跑这个testbench。如果是用命令行仿真注意在vsim阶段选好optimization选项否则正弦多项式无关的信号被优化掉波形里看不到想看的状态和data_valid新手容易白白折腾。给我个人经验第一版仿真建议把状态、计数器、rx_sync_d2等内部信号全加进波形。看状态机的波形重点关注IDLE到START的跳转是不是发生在下降沿之后DATA的8次采样是否均匀分布DONE出现时data_out和数据发送任务里的expect是否一致。这里我可以补充一个经验就是顶层模块的端口定义最好尽量把状态也引出来或者至少testbench里用层次引用tb.dut.state来看状态。我在实际调试中通常会把state定义成(* mark_debug true *)或者引到输出端口这样在ILA里抓波形时也能看到状态尤其是在上板调试时看到状态机卡死或跑飞能迅速定位。3.5 时序收敛与综合注意事项写完代码接下来就是综合和时序收敛。Vivado里default已经会帮你做状态编码优化但如果你希望严格按预定义的独热码来需要在综合属性里指定(* fsm_encoding one_hot *)或者直接在代码里用独热码并禁止综合器重新编码。说一个实际项目中常见的坑状态机的复位。很多模块只用全局复位没有做局部复位导致状态机从一个非法状态起跳但如果你的复位逻辑里没有处理所有分支state可能进入XYZ状态且无法返回。三段式的第二段组合逻辑必须写default分支确保任何非法状态都能回IDLE。这个default分支很多人偷懒不写仿真偶尔通过综合后FPGA上电偶发异常查半天都不知道原因。相信我default不是可选项是必选项。时钟频率方面这个模块工作在50MHz时完全无压力组合逻辑非常简单只包含状态译码和计数器比较时序余量很足。真正需要关注时序的是在系统频率很高超过200MHz或者分频计数器的limit信号路径上长距离走线时sample_en的生成路径是否成为关键路径。优化方法也不复杂可以把计数器limit判断拆到单独的寄存器提前一拍生成用流水线思想打一拍代价是采样时刻延后一个周期需要微调采样计数确认逻辑。4. 状态机的工程化进阶与常见坑状态机写多了以后你会发现自己真正遇到的问题不是不会写而是状态机跑飞、输出毛刺、仿真过综合不过等工程问题。这部分我把我实际踩过的坑和排查思路都列出来尤其适合想进数字IC或FPGA岗位的读者。4.1 常见问题速查表现象根因解决方案仿真无误上板偶发逻辑混乱状态机进入非法状态第二段加default回IDLE复位后initial状态统一输出波形上有细窄毛刺输出采用组合逻辑直接产生第三段改为时序逻辑寄存输出外部信号采样偶尔出错异步信号未同步产生亚稳态输入先打两拍同步再做边沿检测频率上不去时序违例状态译码组合逻辑过深状态编码改独热码关键输出打拍提前生成FPGA烧写后全0或全F异常复位信号未同步释放使用异步复位、同步释放电路仿真波形看不到内部状态综合优化把信号吞了加mark_debug属性或把关键信号引到顶层4.2 状态机跑飞的定位方法所谓跑飞就是状态机进了未定义状态或者卡在某个状态出不来了。定位方法我一般分三步。第一步看波形观察state信号是否出现预期之外的编码值。如果出现说明第二段的next_state计算不完整或者对非法状态没有做兜底。本质原因大概率是case分支缺少default或default里跳转目标写错。第二步如果state正常但功能出错把能观察的关键中间信号全加进波形比如clk_cnt、bit_idx、sample_en。UART这种模块典型问题出现在计数器没有按预期清零导致采样点漂移。举个例子START状态在起始位中点确认成功后直接进入DATA但如果这里的clk_cnt清0逻辑写错DATA状态下第一次采样不在bit中心后面八次全偏数据必然错。第三步如果仿真波形正常但上板异常优先怀疑异步信号同步和复位电路。异步信号RX直接进状态机做下降沿检测在上板时由于走线延迟、地弹影响极容易产生亚稳态导致误检测。我的处理习惯是任何对外的异步信号都先打两拍再使用且拍数宁多勿少。复位电路则统一使用异步复位、同步释放模板用系统级慢速复位保证工作时钟起来后所有状态和输出都是确定的。4.3 状态机的读写风格与维护性思考最后聊聊代码风格。状态机的可维护性很多时候比功能正确性更重要因为代码写出来不是给自己看的是给团队里其他人review、维护的。我有三条固定习惯。第一条状态命名必须语义化。IDLE、START、DATA、DONE这种名字一看就懂不要用S0、S1、S2。状态多的时候命名混乱会让review的人怀疑人生。第二条case分支要求覆盖全部状态default分支统一处理非法状态。不要为了省几行代码省略default这是一种偷懒也是埋雷。第三条三段式状态机禁止在一个状态内做太多事。如果一个状态里的输出条件超过三四个或者计数条件逻辑特别长就说明状态划分粒度太粗需要进一步拆分。比如UART接收里我没把采样、拼接全塞进一个状态而是通过sample_en这种控制信号解耦。日常review代码时我会对一坨状态里还带一串if-else的做法很警惕那基本是设计没想清楚的表现。4.4 从状态机出发的进阶路线状态机这个东西学的时候觉得只是个基础语法但用的时候会发现它贯穿整个数字设计。按键消抖可以用状态机实现滑动窗口滤波可以用状态机控制FIFO读写轮询仲裁器本质是对多个请求源的状态轮转DDR3读写控制器的命令调度更是复杂状态机的集大成者。所以我说状态机是数字逻辑的骨架一点都不夸张。写到这里正好提一句最近我看到有不少人开始用AI Agent自动生成Verilog代码Claude Code这类工具写状态机的速度确实快。但AI生成的代码最大的问题就是它不理解你系统的时序边界不知道你的模块在整体架构中处于什么地位生成的代码经常有逻辑正确但不可综合、不可复用的问题。所以我的建议是可以用AI加速编码但你必须自己能看得懂并修改状态机的每一个跳转、每一个输出这才是基本功。状态机的设计能力不是靠工具替代的是在一遍遍画图、写分支、仿真调试中练出来的。我见过不少简历上写着熟悉Verilog状态机设计的候选人一画状态转移图就露怯基本上就是平时用得少、只背过模板。多动手多画图多仿真这才是唯一的正路。