尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

FPGA状态机设计详解:从三段式写法到UART接收实战

FPGA状态机设计详解:从三段式写法到UART接收实战 刚开始接触FPGA的时候我一度觉得写代码跟写软件差不多不就是“if-else加寄存器”的事吗直到被一个串口接收模块折磨了三个晚上才彻底想明白FPGA里的“逻辑设计”不是靠脑子按顺序想而是靠状态机把硬件该有的节拍感给立起来。如果你正处于“Verilog语法都懂、一写复杂功能就懵”的阶段这篇part.6就是给你准备的。这篇我打算把状态机这事彻底聊透——从为什么需要它、三种写法的本质区别、到真实工程里怎么调试排查一步不落。1. 逻辑设计的核心骨架为什么绕不开状态机1.1 没有状态机你的逻辑就是一盘散沙先说个最直白的道理FPGA里跑的是并行硬件不是顺序执行的CPU程序。CPU里写代码写串行调用比如“先读数据、再判断、再输出”每一步天然就是按顺序走的。但FPGA不行你写的一堆always块是同时工作的没有“先后顺序”这个概念。那如果我想做一件有几拍、几个阶段的事情怎么办比如接收一帧串口数据要先等起始位、再收8个数据位、再等停止位。这明显是一个有阶段、有先后、有依赖关系的过程。如果不用状态机你大概率会把逻辑写成“一堆if叠着、互相抢信号优先级、各种条件相互打架”的巨型组合逻辑仿真勉强能跑一综合上板就全是毛刺和时序违例。状态机本质上就是给并行硬件立了一套“程序计数器”。它用一个寄存器记住当前在哪一步然后下一步的走向完全由当前状态和输入决定。这样逻辑就变成了“分阶段执行”这也正是FPGA里绝大多数时序控制、协议解析、接口交互功能能稳定跑起来的基础。1.2 状态机能做什么从协议解析到图像处理如果你拆过任何一个实际工程最终都能把一个模块收敛成状态机。最常见的几类场景串行协议解析UART、SPI、I2C接收端本质上都是靠状态机把比特流按格式拼成字节。接口时序控制读写SRAM、SDRAM、Flash每一笔读写的片选、地址、数据时序阶段都是状态机驱动的。图像处理流水线行场同步信号的生成、像素数据的对齐、帧缓存的读写控制背后都有状态机在调度。数学运算调度比如卡尔曼滤波、FFT运算内部有多个计算步骤状态机负责决定每一步算到哪了、下一个操作数去哪取。所以你看状态机不是FPGA逻辑设计里“一种写法”而是“骨架”。骨架搭不好后面的功能再花哨也是豆腐渣工程。2. 状态机的编程范式与选型单段、两段、三段到底怎么选2.1 状态机的基本要素先统一一下基础概念写状态机无非就是这几样状态寄存器用来保存当前状态。本质上就是一个触发器组每个时钟沿更新一次。状态转移条件根据当前状态和输入信号决定下一个时钟周期跳到哪个状态。输出逻辑根据当前状态或当前状态输入产生模块输出信号。围绕这三个要素业界写出来了几种不同的代码组织方式。新手最容易犯的错是觉得“只要功能对就行”但恰恰是组织方式决定了代码能不能综合出高效率的电路、能不能方便调试。2.2 单段式入门必踩的坑所谓单段式就是用一个always块把状态转移和输出逻辑全写完。代码风格大概是这个意思always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; data_out 0; end else begin case (state) IDLE: begin if (start_sig) state WORK; else state IDLE; data_out 0; end WORK: begin state IDLE; data_out 1; end endcase end end说实话这种方式写小demo、状态就三五个的时候特别顺代码量最少也好理解。但一旦状态多起来问题就暴露了输出逻辑和状态转移混在一起没法单独约束时序容易产生时序违例。输出信号是从时序逻辑里直接出来的虽然不会毛刺但状态更新和输出更新绑得太死想调整一拍就很难受。你没法一眼看出“这个输出到底是由哪个状态产生的”维护起来极其痛苦。单段式不是不能用而是不适合工程化设计。我见过面试题里很多人拿单段式写就挂了不是因为语法错而是因为硬件设计思路不清晰。2.3 两段式把状态转移和输出分开两段式就是把“状态转移逻辑”和“输出逻辑”分开写。状态转移逻辑用一个时序always块更新当前状态值输出逻辑用一个组合always块或者assign语句组根据当前状态决定输出。// 状态转移 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end // 次态判断组合 always (*) begin case (state) IDLE: next_state start_sig ? WORK : IDLE; WORK: next_state IDLE; default: next_state IDLE; endcase end // 输出组合 always (*) begin case (state) WORK: data_out 1; default: data_out 0; endcase end两段式的好处是分工清晰硬件电路对应关系也很直观一段时序状态寄存器、一段组合译码输出。它在学习阶段是很好的过渡也能很容易观察出状态如何变化。但两段式的输出是组合逻辑容易产生毛刺。如果这个输出信号是直接给外部接口用的比如作为片选、使能信号那毛刺就可能让外部设备误动作。2.4 三段式工程上最稳的写法三段式是前两种写法的优化版关键区别在于输出逻辑本身也变成了时序逻辑。也就是用Combinational逻辑算“次态”用时序逻辑寄存“当前状态”再用时序逻辑寄存“当前状态的输出”。// 第一段状态寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end // 第二段次态组合逻辑 always (*) begin case (state) IDLE: next_state start_sig ? WORK : IDLE; WORK: next_state IDLE; default: next_state IDLE; endcase end // 第三段输出时序逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) data_out 0; else begin case (next_state) WORK: data_out 1; default: data_out 0; endcase end end注意这里的输出逻辑不是依赖当前state而是依赖next_state。这有什么好处输出的更新比状态寄存器的更新晚半拍——也就是说当状态刚刚切换到WORK时data_out已经准备好了。这种写法消除了组合毛刺还天然形成了一级输出寄存器时序上非常干净。从硬件视角看三段式对应的电路是寄存器当前状态 组合逻辑次态输出译码 寄存器输出锁存。这个结构和时序分析工具Vivado/Quartus的Timing Report的配合度最好你不需要额外再打一级寄存器去处理输出冲突。所以我的结论很简单做工程、做复杂模块、给外部接口出信号用三段式写学习案例、快速验证逻辑、状态数很少两段式也够用单段式最好别用于工程除非你就写个几十行的玩具。3. 状态机设计的细节要点从状态编码到时序收敛3.1 状态编码选型独热码、二进制、格雷码状态编码是状态机设计里最容易被忽略、却对资源与速度影响极大的环节。常见的编码方式有三类编码方式原理资源占用速度典型场景二进制编码状态按递增数位编码比如0、1、2、3寄存器数量最少约log2(n)个由于状态变化需要多bit翻转组合逻辑可能更复杂一些状态量特别多、寄存器资源紧张格雷码相邻状态间只有1个bit跳变寄存器数量少翻转功耗低抗毛刺好状态连续跳转较多的场景如计数器状态循环独热码每个状态占用一位寄存器只有一位为1寄存器数量最多n个状态要n个寄存器状态译码快组合逻辑简单FPGA中最为推荐尤其状态数少于20个时FPGA本质上是个“寄存器资源丰富、逻辑单元相对受限”的器件。独热码看似浪费寄存器但状态译码简单直接用1bit判断就知道是不是某个状态不需要多位组合比较LUT资源占用少运行频率也能提上去。所以除非你的FPGA寄存器紧张到爆炸否则工程上默认首选独热码。在Xilinx Vivado里你可以在状态机的case定义上使用如下综合属性显式指定编码方式(* fsm_encoding one_hot *) reg [3:0] state;Altera/Intel Quartus十几年前就把状态机综合和编码优化当卖点了你甚至不用自己指定编译器会告诉你它选了哪种编码。但自己心里要有数别把控制逻辑写成几百个状态的二进制编码然后抱怨Vivado时序收敛不了。3.2 复位方式与初始化状态机复位有两种主流选择异步复位和同步复位。老工程师常说“异步复位、同步释放”实际在状态机里最简单的做法是复用全局复位信号做一个异步复位的时序逻辑always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end有几个细节要注意上电后的初始状态必须明确。不要指望寄存器上电默认是0就行了最好在复位里回到IDLE状态。复位信号需要保证足够的脉冲宽度能被时钟采到。如果系统中复位来自按键消抖要小心消抖过于干净导致复位信号宽度只有几十纳秒结果有些触发器压根没被复位到。FPGA内部没有专门的全局复位时序约束你要在XDC/SDC里面对复位信号做约束避免时序不收敛的问题。3.3 状态机的默认态与安全态设计写状态机的case语句时十个人里有八个会忘记写default分支。之前我见过一个同事的SPI控制器因为协议解析出错状态跳转到了未定义状态整个模块卡死在那里主控那边等数据等了超时才发现出问题了。预防方法很简单always (*) begin case (state) IDLE: next_state ...; SEND: next_state ...; default: next_state IDLE; endcase enddefault分支把非法状态拉回IDLE这样即使发生异常比如电磁干扰导致寄存器翻转也能自恢复。这个习惯一定要养成它不花你任何成本但能让系统稳定性上一个台阶。更进一步的做法是设计一个看门狗超时状态。比如你在等待某个外部信号时如果等了太长时间还没到就做一个超时跳转。这种设计对通信协议类模块特别有用能避免状态机死等。3.4 时序收敛视角下的状态机状态机设计能不能通过时序分析取决于状态转移的组合逻辑够不够短。如果状态机里“从一个状态到下一个状态”要经过一个超长的多级比较电路那这条路径的时序会非常紧张最终导致Fmax上不去或Vivado里出现时序违例。我通常这样处理状态机的次态逻辑尽量简单复杂的跨周期依赖不要全部耦合在状态转移里。大数据通路上的运算比如乘法、比较不要在状态转移的组合逻辑里直接展开用流水线拆开或者先用寄存器算完再作为状态机的转移条件。状态编码用独热码也有利于时序短路径因为1bit判断比多bit组合判断快得多。4. 实操案例用三段式状态机实现UART接收4.1 功能需求与状态划分UART接收是最适合入门状态机的工程案例功能明确、状态清晰、调试方便。假设我们的接收参数是波特率96008位数据位1位停止位无校验。先把状态划分清楚。UART总线空闲时为高电平发送方先拉低1位起始位再依次发送8个数据位低位在前最后拉高1位停止位。对应的状态机划分如下IDLE等待起始位。START确认起始位有效准备接收数据。DATA接收8个数据位。STOP接收停止位输出完整字节。可能你会问为什么不把起始位和停止位也合并进DATA状态实际上你可以合并但在状态里单独把协议阶段拆出来写代码的可读性和调试便利性都会好得多。4.2 波特率时钟生成UART接收的关键是采样时刻。我们需要在数据位的中间点采样避免在信号跳变沿附近采到不稳定电平。做法是用一个波特率分频计数器生成一个“采样脉冲”。// 以50MHz系统时钟为例9600波特率每个bit时长约5208个时钟周期 localparam BAUD_CNT_MAX 50_000_000 / 9600 - 1; reg [12:0] baud_cnt; wire baud_pulse; always (posedge clk or negedge rst_n) begin if (!rst_n) baud_cnt 0; else if (baud_cnt BAUD_CNT_MAX) baud_cnt 0; else baud_cnt baud_cnt 1; end assign baud_pulse (baud_cnt BAUD_CNT_MAX);每个bit用计数器产生一次脉冲状态机以这个脉冲为节拍进行状态迁移。这比用一个高频时钟去采样RX引脚更稳定也更符合实际工程中的做法。关于计数器要不要清零不需要让它自由计数、周期产生脉冲就行状态机只关心脉冲出现的时刻。这种设计也方便在仿真中缩短计数来加快仿真速度比如把BAUD_CNT_MAX改成9来模拟时序。4.3 三段式UART接收代码状态机代码分成三个always块来写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 3b001; localparam START 3b010; localparam DATA 3b100; localparam STOP 3b101; reg [2:0] state, next_state; reg [3:0] bit_cnt; reg [7:0] rx_shift; reg rx_d0, rx_d1; // 打两拍消除亚稳态 always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_d0 1b1; rx_d1 1b1; end else begin rx_d0 rx; rx_d1 rx_d0; end end wire rx_sync rx_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_sync) next_state START; end START: begin if (baud_pulse) next_state DATA; end DATA: begin if (baud_pulse bit_cnt 4d7) next_state STOP; end STOP: begin if (baud_pulse) next_state IDLE; end default: next_state IDLE; endcase end // 数据接收移位寄存器与位计数 always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_shift 8d0; bit_cnt 4d0; end else if (state DATA baud_pulse) begin rx_shift {rx_sync, rx_shift[7:1]}; bit_cnt bit_cnt 1; end else if (state IDLE) begin bit_cnt 4d0; end end // 第三段输出时序逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin data_out 8d0; data_valid 1b0; end else begin if (state STOP baud_pulse) begin data_out rx_shift; data_valid 1b1; end else begin data_valid 1b0; end end end endmodule三段式输出逻辑里我把data_valid在非停止位时刻拉低。这样它的行为是一个周期脉冲在收到停止位的那一拍产生一个高电平有效信号外部模块看到这个信号就锁存data_out。如果你需要的是电平信号而不是脉冲可以根据需要调整输出逻辑。4.4 仿真要点与波形分析写完了代码仿真一定要做仔细。我入门的时候经常犯一个错状态机逻辑不对根本看不出来只能靠仿真的波形图去定位。仿真测试的三个关键环节正常收发流程用板卡或上位机发送一个字节0x55二进制01010101看接收数据是否符合预期。0x55的高低电平交替特性特别好能一目了然看出位序是否正确。起始位附近干扰RX信号在IDLE状态产生毛刺是否可以忽略。连续字节接收发送多个字节中间只有很短的间隔看状态机是否能正确切回IDLE。用Vivado或ModelSim仿真时重点观察state信号、baud_pulse和rx_sync的时序关系。尤其是数据采样点应该在每位数据的正中央。如果采样点偏到数据跳变沿附近说明波特率计数器或启动判断逻辑有问题。4.5 上板调试从ILA到串口助手如果仿真没问题但板上跑不通优先检查三件事时钟频率你的分频参数是否和实际板载时钟一致很多板子是50MHz但也有12MHz、100MHz的别想当然。引脚约束RX引脚是否约束到了FPGA的正确bank和引脚上有没有和原理图对应。复位极性你的板卡复位是高有效按键还是低有效按键很多外设模块的复位输入是低有效别把复位接反。另外建议在Vivado里例化一个ILA集成逻辑分析仪核把state、rx_sync、data_valid、data_out这几个信号拉出来看。板上跑起来后用串口助手发送一个字节观察ILA波形。这个习惯比你在代码里加各种自检逻辑高效得多能看到真实的硬件时序到底是怎样的。5. 状态机调试中的常见问题和排查心得5.1 状态机卡死不动怎么办状态机卡死的常见原因就是进入了非法状态。最常见的原因是case分支条件不完整某些输入组合没有匹配到任何分支。比如你在次态组合逻辑里写case但没有default仿真时变量默认值不明确综合后电路就锁死在某个不存在的状态。排查方法仿真里直接把状态寄存器初始化成非法值看它会不会自动恢复。不会恢复说明安全态处理没做好。加状态计数超时在每个状态停留超过N个周期就强制跳回IDLE这在调试不稳定协议时很好用。ILA抓state信号板上跑飞了看看ILA里的state到底停在了哪个数值。对照状态编码表就能定位。5.2 输出信号毛刺严重怎么解决前面提到两段式的组合输出容易有毛刺其实还有另一个容易忽略的点如果你的状态机输出信号没有经过寄存而直接驱动片选、使能等控制信号一旦组合逻辑的输入不同步到达毛刺就出现了。解决思路有两条把输出逻辑改成时序逻辑就是用三段式的第三段写法输出比状态晚半拍但干净稳定。如果这个输出必须和状态同步比如作为读使能和地址同时给出可以再把输出打一拍或者用FIFO的读写指针这种成熟的同步机制。5.3 时序违例频繁出现怎么收敛时序违例十有八九出在次态逻辑过长。一个状态跳转的判断条件里包含了大量输入信号之间的运算综合后这条路径的延迟就会超。处理办法把转移条件的计算提前一拍完成存到寄存器里再给状态机用。在状态机前面加一级输入同步和去毛刺不用原始信号直接做判断。对于状态特别多的控制器考虑拆分成多个嵌套状态机主状态机调度子状态机避免单个状态机的规模失控。5.4 综合时出现latch警告这个警告几乎是新手必见组合逻辑always块里某个分支没有给所有输出变量赋值。综合器就会认为你意图“保持上一个值”用一个锁存器来实现。而FPGA里是没有默认latch原语的综合器会硬生生生成一个由LUT加MUX构成的latch时序无法保证。比如这样写always (*) begin case (state) IDLE: begin next_state WORK; data_valid 0; end WORK: next_state IDLE; // 只赋值了next_statedata_valid没赋值 default: next_state IDLE; endcase end这种时候Vivado综合报告会弹出“inferring latch for data_valid”的警告。我的习惯是组合逻辑always块内一开始就给所有变量赋默认值case里只改需要改的那些信号。这样既能避免latch也让代码意图更明确。6. 状态机设计进阶从单状态机到多状态机协作6.1 什么时候需要拆成多个状态机之前说过状态太多会让单个状态机膨胀。那什么算“太多”我个人的经验值是超过20个状态就算偏复杂了超过30个就该考虑拆分。比如你要实现一个完整的SPI Flash控制器里面包含命令阶段、地址阶段、数据读写阶段每个阶段都有若干子步骤。如果把这些全部塞进一个状态机光状态跳转就能把人绕晕。正确的做法是拆成三层顶层状态机负责整笔操作的调度比如“开始命令、发送地址、读写数据、结束”。字节发送子状态机负责将一个字节按位发送出去为顶层的“发送命令/地址”提供服务。字节接收子状态机类似地负责按位接收。顶层每次需要发送命令时给子状态机一个起始脉冲等子状态机完成打回一个握手信号顶层再进入下一个阶段。这种握手协作的模式就是大型FPGA设计中常见的“状态机嵌套与通信”思想。6.2 状态机和数据流的关系FSMDatapath只控制“什么时候做什么事”还不够一个模块还需要实际处理数据。这就引出了FPGA逻辑设计另一个重要思路控制通路FSM和数据通路Datapath分离。比如一个图像二值化模块数据通路做的事情是读入像素、计算阈值、输出二值结果。控制通路做的事情是管理行场同步、决定当前处于有效图像区域还是消隐区域。这种分离设计的好处是控制逻辑里不掺杂运算时序容易收敛。数据通路可以做成流水线吞吐率高。调试时可以从控制通路波形和数据通路波形分别判断问题所在。基本上你去看那些优秀的开源IP核内部结构都是这个套路。所以你写RTL时别把状态机和数据处理揉在一个always块里那是“伪硬件思维”。6.3 状态机的可维护性命名、注释与重构最后聊个看起来不重要但很有用的点状态机的代码可维护性。我见过有的人写的状态机状态名全是S0、S1、S2没有注释过了一个月自己都看不懂。我的建议是状态宏定义用有意义的名称比如ST_IDLE、ST_CMD_PHASE不要用S0这种。注释里画一个简单的ASCII状态转移图不用太复杂能表达跳转条件就行。状态变量信号名统一为state_fsm或current_state不要一会儿叫cur_state一会儿叫st。三段式代码的顺序固定状态寄存器、次态逻辑、输出逻辑别这段代码里换个顺序下段代码又换个风格。这些习惯看起来婆婆妈妈但对一个几个月后还要回来改Bug的你来说绝对是最有回报的投资。写在最后的调试心得我在刚开始用状态机的时候最崩溃的一次是把一个I2C控制器的状态机debug了整整两天最后发现只是某个状态跳转条件里的时序判断前后差了一拍。那时候才真正明白写状态机不是把case写对就行而是要把每一个状态的进入条件、离开条件、输出动作在时间轴上都想清楚。现在我做设计习惯先画状态转移图再写代码仿真通过后还要在板上用逻辑分析仪验证一遍关键的跳变路径。这套流程看起来慢但实际上是最快、最不折腾的路径。希望这篇part.6能让你少走点我当年走过的弯路。
返回列表