从第一个LED点亮到现在你大概已经能用always块、assign语句和阻塞/非阻塞赋值拼出不少逻辑了。但真要做点带“流程”的东西——比如解析串口协议、驱动ADC采样、控制外部存储器的读写——你会发现光靠组合逻辑加寄存器硬凑代码很快就散成一盘沙。这时候就该认真学状态机了。作为这套FPGA系列的第6篇我默认你已经有了最基本的数字逻辑基础但还谈不上系统掌握状态机设计。这篇文章就把逻辑设计里最核心的一块——状态机——从建模思路到三段式写法从仿真调试到实际项目落地完完整整讲透。状态机解决的核心问题是把“接下来要干什么”这种串行流程翻译成硬件能跑的并行电路。FPGA里所有逻辑都是并行执行的但真实世界的需求往往有先后顺序先等按键按下再计数再输出高电平再等待复位……这种顺序逻辑如果用一堆标志位和always块去拼写到最后自己都看不懂。状态机就是那个让硬件“按剧本走”的标准答案。1. 需求到电路先把“脑子里的流程”画出来1.1 为什么组合逻辑和普通时序逻辑搞不定流程控制很多初学者会有一个困惑我明明可以用计数器实现很多功能为什么要用状态机计数器本质上是单调递增的时序逻辑它回答的是“从0数到N了吗”这个问题。但真实系统的流程往往不是一条直线而是有分支、有条件、有并发的。举个通俗例子你在奶茶店下单系统要等支付成功、然后通知后厨、后厨做完再通知取餐。这个流程里支付成功事件可能随时到来后厨出杯时间也不固定系统必须时刻知道自己“当前处于哪个环节”。用计数器能表达这种分支跳转吗很别扭。FPGA里的每条逻辑都是时钟沿驱动的并行电路没有“先执行A再执行B”这种天然顺序。状态机就是人为地引入一个“当前在哪一步”的寄存器所有逻辑都围绕这个状态寄存器做转移和输出。本质上你是在用硬件描述语言里的case语句搭一套可预测、可综合的“流程引擎”。这段逻辑对新手很重要不要试图用嵌套if和计数器去模拟流程那样代码会越来越难维护时序也很难收敛。状态机才是硬件流程控制的正统解。1.2 从状态转移图开始建模而不是直接写代码我见过太多新手拿到需求第一时间就敲代码结果写了200行发现逻辑对不上又删了重写。正确流程是先画状态图把条件和动作理清再翻译成Verilog。状态图有三要素状态系统所处的稳定阶段比如空闲、等待、接收中、完成。跳转条件什么事件发生才允许离开当前状态。输出在某个状态下对外的信号是拉高还是拉低。画图时大家最容易搞混的是“动作”和“状态”的区别。动作是瞬间发生的状态是持续存在的。打个比方电梯在“运行中”这个状态持续几秒钟而“到达楼层、开门”是一个动作。逻辑设计里我们要把关键动作抽取为独立状态而不是在一个状态里用计数器硬憋。以一个最简单的需求为例按键按满3次LED亮起再按1次熄灭循环往复。在画状态图前先定义状态IDLELED灭等待计数、COUNT1已按1次、COUNT2已按2次、LITLED亮等待熄灭按键。跳转条件是按键的上升沿每次按下从当前状态递增在LIT状态下按键跳回IDLE。画出来之后代码怎么写都跑不偏。这个习惯养成之后再复杂的协议比如SPI、I2C、BISS-C拆开看也都是状态图加上计数器和移位寄存器的事。1.3 Moore型与Mealy型状态机选谁更合适状态机按输出方式分两类。Moore型输出只取决于当前状态与输入无关。Mealy型输出同时取决于当前状态和输入。实际工程里我推荐优先选Moore尤其在写协议解析和时序控制的时候。原因是Moore型输出稳定状态和信号一一对应仿真时波形特别好排查。Mealy型在输入变化瞬间就可能影响输出容易产生不想要的毛刺新手很难驾驭。但Mealy型也不是一无是处它能用更少的状态完成同样功能适合对状态数敏感的场景。比如一些低速接口协议用Mealy可以少写一两个状态。我的建议很直接FPGA资源没那么紧张靠近0基础阶段先用Moore把输出做稳等你把三段式写熟了再去挑战Mealy不迟。2. 三段式状态机为什么是主流写法2.1 一段式、二段式、三段式到底差在哪你搜FPGA状态机教程大概率会看到“一段式”“二段式”“三段式”这几个名词。很多文章只教模板不讲原理导致新手只知道照着抄。这三种写法的本质区别是状态寄存器、次态逻辑、输出逻辑这三件事拆成了几段代码。一段式把所有东西塞进一个always块里状态跳转和输出混在一起写好处是代码短坏处是状态多起来之后可读性暴降而且输出很容易被写成组合逻辑产生毛刺和时序问题。二段式把状态跳转和输出分开但输出通常还是组合逻辑。三段式则完全拆开第一段时序逻辑完成状态寄存器的同步更新第二段组合逻辑根据当前状态和输入计算次态第三段专门处理输出。这里的关键认知是三段式不是花架子它把“状态跳转”和“输出产生”彻底解耦综合工具能优化的路径更清晰时序收敛更容易。而且第三段输出用寄存器打一拍输出波形干净没有组合毛刺。对于追求稳定性的项目这个价值极大。2.2 三段式状态机的标准模板我直接给一个最小但完整的三段式例子功能是输入信号a拉高后进入等待b拉高后完成一次握手输出一个脉冲module fsm_demo( input wire clk, input wire rst_n, input wire a, input wire b, output reg dout ); localparam S_IDLE 3d0; localparam S_WAIT_A 3d1; localparam S_WAIT_B 3d2; localparam S_DONE 3d3; reg [2:0] state, state_next; // 第一段状态寄存器时序逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) state S_IDLE; else state state_next; end // 第二段次态逻辑组合逻辑 always (*) begin state_next state; // 默认保持当前状态 case (state) S_IDLE: if (a) state_next S_WAIT_A; S_WAIT_A: if (b) state_next S_WAIT_B; S_WAIT_B: state_next S_DONE; S_DONE: state_next S_IDLE; default: state_next S_IDLE; endcase end // 第三段输出逻辑使用寄存器输出 always (posedge clk or negedge rst_n) begin if (!rst_n) dout 1b0; else if (state S_DONE) dout 1b1; else dout 1b0; end endmodule这段代码里有两个容易被新手忽略的细节我重点说。第一第二段组合逻辑里一定要有state_next state;这个默认赋值。很多人不写结果case的某些分支没有覆盖所有输入条件综合时推不出正确的组合逻辑大概率给你生成一个latch功能直接错乱。时序逻辑用阻塞赋值时序块用非阻塞赋值这个纪律在这里体现得尤其明显。第二第三段输出逻辑建议直接改用寄存器输出。很多人写第三段时喜欢用always (*)做组合输出但在FPGA里组合输出对毛刺和时序都不友好。要输出脉冲就用时序逻辑打一拍最多晚一个时钟周期换来的是干净稳定的信号。实际项目中宁可晚一拍也绝不冒险。2.3 状态编码二进制、格雷码还是One-Hot状态编码也是容易被忽视的决策。二进制编码状态数少触发器用最少但状态跳转时多个bit同时翻转复杂状态下容易有竞争风险。格雷码相邻状态只翻转1bit适合状态连续递增的场景比如ADC采样计数。One-Hot独热码每个状态单独占一个寄存器状态多时寄存器开销大但次态逻辑最简单时序最容易收敛。FPGA厂商的推荐很明确中小型状态机直接用One-Hot。理由很实在FPGA的触发器资源比LUT丰富独热码用面积换时序综合工具能推出更干净的逻辑。Xilinx在Vivado里还专门有fsm_encoding这个综合属性可以指定one_hot可见这条路是被官方认证的。新手阶段不用过度纠结编码方式统一用localparam定义即可。但心里要有根弦如果状态总数超过8个或者项目里有高速接口对时序要求苛刻One-Hot是更稳妥的选择。3. 从框架到实践手写一个SPI从机接收状态机3.1 为什么拿SPI练手最合适讲完模板直接进入一个能落地的例子。我选SPI从机接收因为这个协议足够简单只有时钟、片选、数据三根线但又完整包含了边沿采样、计数器、片选控制这些状态机必用的技术点。而且你看现在热词里全是fpga spi、fpga biss-c、fpga fmc通信、ad7606 fpga本质上都是在做协议接口的接收和解析。状态机把SPI吃透后面做BISS-C这类单向编码器协议思路是一样的。假设系统时钟50MHzSPI主机的sclk频率是1MHz数据格式为片选cs_n拉低表示传输开始每个sclk上升沿采样一次mosi连续8个bit组成一个字节。我们要做的就是从码流里把8bit收进来输出一个8位并行数据并在接收完成时给一个单周期脉冲。3.2 状态划分与信号定义这个从机接收功能的状态划分很简单状态行为IDLE等待cs_n拉低RECV每个sclk上升沿采样1bit计数8次后完成DONE锁存并行数据输出完成脉冲等cs_n拉高后回IDLE有人会问为什么需要DONE状态不能RECV状态里数到8就直接输出数据吗可以但那样输出脉冲和计数器边界耦合时序不好控制。多一个DONE状态完成信号与移位计数完全解耦上板调试时看波形一眼就能分清“数据稳定了”和“数据正在变化中”这两个时刻。信号定义如下sclk_r对sclk做的打拍同步信号用于检测上升沿。byte_done计数到8产生的完成标志。data_out8位并行接收结果。recv_cnt3位计数器记录已接收bit数。这里有个关键设计sclk来自外部主机与系统时钟不同源属于跨时钟域信号。新手最容易犯的错是直接用sclk做always块的时钟或者用sclk直接做组合逻辑判断。正确做法是先用系统时钟打两拍同步再检测上升沿。用了同步边沿检测之后sclk就被“翻译”成了系统时钟域里的一个高有效单脉冲信号。3.3 代码实现与逐段讲解下面给出核心代码我刻意控制篇幅但注释保留关键思路module spi_slave_recv( input wire clk, // 50MHz 系统时钟 input wire rst_n, input wire sclk, input wire cs_n, input wire mosi, output reg [7:0] data_out, // 接收到的并行数据 output reg recv_done // 接收完成脉冲 ); localparam S_IDLE 2d0; localparam S_RECV 2d1; localparam S_DONE 2d2; reg [1:0] state, state_next; reg [2:0] bit_cnt; reg [7:0] shift_reg; reg sclk_d1, sclk_d2; reg csn_d1, csn_d2; wire sclk_pos, csn_fall, csn_rise; // 输入同步打拍 always (posedge clk or negedge rst_n) begin if (!rst_n) begin sclk_d1 1b0; sclk_d2 1b0; csn_d1 1b1; csn_d2 1b1; end else begin sclk_d1 sclk; sclk_d2 sclk_d1; csn_d1 cs_n; csn_d2 csn_d1; end end assign sclk_pos sclk_d2 !sclk_d1; // sclk上升沿 assign csn_fall csn_d1 !csn_d2; // cs_n下降沿 assign csn_rise !csn_d1 csn_d2; // cs_n上升沿 // 第一段状态寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) state S_IDLE; else state state_next; end // 第二段次态逻辑 always (*) begin state_next state; case (state) S_IDLE: if (csn_fall) state_next S_RECV; S_RECV: if (bit_cnt 3d7 sclk_pos) state_next S_DONE; S_DONE: if (csn_rise) state_next S_IDLE; default: state_next S_IDLE; endcase end // 移位接收逻辑RECV状态下每个sclk上升沿采样 always (posedge clk or negedge rst_n) begin if (!rst_n) begin shift_reg 8d0; bit_cnt 3d0; end else if (state S_RECV sclk_pos) begin shift_reg {shift_reg[6:0], mosi}; bit_cnt bit_cnt 1b1; end else if (state S_DONE) begin bit_cnt 3d0; end end // 第三段数据锁存与完成脉冲输出 always (posedge clk or negedge rst_n) begin if (!rst_n) begin data_out 8d0; recv_done 1b0; end else if (state S_DONE) begin data_out shift_reg; recv_done 1b1; end else begin recv_done 1b0; end end endmodule几个实操心得说给你听。移位寄存器shift_reg {shift_reg[6:0], mosi};每次左移一位把新bit放最低位。数据恢复时有个字节序问题如果主机先发最高位那8次采样后shift_reg的最高位就是最先收到的bit直接把整个shift_reg输出就是正确字节。如果主机先发最低位你需要把bit逆序或者用mosi填最高位、整体右移。这部分完全看协议定义写代码前一定要确认。bit_cnt在DONE状态下清零是必要的。否则下次传输开始计数不是从0出发状态机必然错乱。有人把清零放在IDLE里也行但你得确认时序别让计数清零和状态跳转互相打架。还有一个我踩过坑的点sclk边沿检测的打拍级数。两级同步可能产生边沿脉冲延迟不固定的问题但单bit慢速信号完全够用。如果是高速sclk或者对时序极敏感的信号建议用三级同步并考虑输入信号自身的建立保持时间。这个知识点在调试时最容易被忽视等到波形不对再查就更费劲了。3.4 从SPI到更复杂协议的迁移思路把SPI从机接收的代码梳理顺畅你会发现BISS-C这类单向编码器协议也只是换了信号名和时序参数。BISS-C要求接收数据帧、校验CRC、解析出位置值状态机天然适合做这种分帧解析IDLE等待起始位然后逐bit接收收够固定长度进入校验校验通过就锁存数据。伪随机噪声、FMC通信、LVDS接收统统是这个套路。迁移时真正要改的是三块状态数量、跳转条件、输出定义。模板结构可以原封不动搬过去这也是为什么我建议你把三段式模板练到脱稿能写它属于FPGA开发里的“兵家必争之技”。4. 状态机调试与常见问题排查4.1 先仿真后上板顺序不能乱我见过太多人写完状态机直接下板发现问题后用示波器乱量一气效率极低。状态机的调试仿真阶段就能发现80%的问题。仿真时不需要写复杂的testbench核心是制造几个关键事件复位释放、片选拉低、给几组sclk上升沿、片选拉高。在Vivado的仿真波形里观察state和state_next确认状态跳转是不是自己预期的那样再看shift_reg和bit_cnt的变化是否符合协议波形。这里分享一个仿真技巧把state用localparam中的名字显示。Vivado的waveform窗口里右键信号选择Radix可以设置成ASCII或者自己定义的状态名。更省事的办法是在仿真顶层用wire [1:0] debug_state spi_inst.state;把内部状态引出来观察。Modelsim里也可以用virtual signal工具。总之一句话先看见状态再谈调BUG。4.2 状态跑飞、卡死、输出不稳定的常见原因我把实际调试中遇到的高频问题整理成一张速查表每个问题后面都附了排查路径。现象常见原因排查方法状态机跑飞跳到未知状态缺少default分支或者状态编码与合成参数不一致检查次态逻辑case是否有default查看综合原理图状态卡在某个状态出不来跳转条件永远不满足多见于跨时钟域信号断连加计数器观察该状态停留时间用波形确认输入信号输出有毛刺输出用了组合逻辑块或者没有统一打拍第三段改时序逻辑输出重看代码上板结果不稳定sclk、cs_n等外部信号没有同步打拍检查输入信号是否做了跨时钟域同步计数器数值不对复位、清零位置放错导致计数先于状态跳转确认计数清零发生的状态和时钟沿这些坑的共同根源是对“哪个时刻哪个信号生效”这个概念不清晰。状态机调试必须养成逐拍推演的习惯每个时钟上升沿到来时哪些信号先变化、哪些信号被采样、哪些信号到下一拍才生效。把这个时间线画出来很多问题一眼就看到了。4.3 悬空状态与安全状态的设计FPGA综合工具默认会把未列举的编码处理为dont care这会导致状态机跑到非法状态时行为完全随机。正规做法是default分支回到安全状态通常是IDLE并让所有输出处于无效电平。对于安全性要求高的场景比如电机控制、功率器件驱动还要加看门狗逻辑状态机进入同一状态超过设定时间没出去就强制跳回IDLE同时拉错错误标志。我在实际项目里常用一个额外的计数器来监测状态停留时间这个耗时不多但非常管用能在故障瞬间快速定位卡死状态。状态编码的选择也有讲究One-Hot编码下的非法状态检测逻辑比二进制编码更简单直接这也是我推荐使用One-Hot的又一个原因。它的独热特性决定了你只需要检测“有没有且仅有一个bit为1”就可以判断状态机是否发生了异常。5. 状态机在大工程里的位置从协议解析到图像处理5.1 用状态机收敛复杂协议和接口时序看过热词列表你会发现FPGA的高频场景永远是协议和接口fpga biss-c、ad7606 fpga、fpga实现mipi、fpga光口收发、fpga pcie。这些协议的核心难点不是计算复杂度而是时序控制复杂度。拿AD7606这种并行ADC来说转换完成信号到来后你需要经历读取、等待、锁存、切换通道一系列步骤。这几乎就是状态机的完美应用场景IDLE等待转换信号然后进入读状态读满就切通道再回到等待。整个数据采集流程被状态机编排得清清楚楚主逻辑里不需要到处散落标志位。再往大了说stm32h743和fpga实现fmc通信这类异构通信项目FMC总线访问的每一项操作——地址建立、数据读写、等待周期——都有明确的时序窗口这些窗口用状态机搭建比用组合逻辑硬算可靠得多。你甚至可以把FMC的接口时序改成参数化的状态机换不同类型的MCU时只改参数不用重写框架。5.2 状态机与嵌入式软件架构的相似性热词里有句话很有意思嵌入式软件架构第一课:用状态机收敛复杂度。做FPGA久了你会发现硬件状态机和软件里的状态机思想其实是同一套东西。软件里你用switch-case处理事件循环硬件里你用case处理状态跳转。区别只在于硬件状态机是并行采样、时钟驱动软件状态机是顺序执行、事件驱动。但两者共同点都是提前定义清楚“状态集合”和“事件集合”系统复杂度就在可控范围内展开。我在项目里经常和做软件驱动的同事交流状态图因为我们都需要从同一个协议文档里提取状态转移关系。如果FPGA这边和软件那边各自画各自的状态图最后联调时必出事。先一起把状态图画对软件和硬件只是用不同语言翻译同一张图而已。这种跨语言的沟通方式比对着寄存器手册争吵高效太多。5.3 数据流控制状态机与运算模块的分工卡尔曼滤波、图像处理这类计算密集型任务热词里出现的卡尔曼滤波 fpga、fpga图像处理对新手来说很容易产生误解以为要用状态机把滤波公式一步步算出来。实际上在硬件工程里状态机和运算模块是分工的。运算部分用数据通路设计乘法器、加法器、定点化参数全部做成流水线每个时钟沿推进一个运算步骤。状态机在这里的角色是数据流管理器什么时候通知数据通路开始计算什么时候读取结果什么时候把结果写入FIFO什么时候发起一次DMA搬运。用图像处理举例边缘检测算法的卷积核本身是纯组合逻辑加流水线但整帧图像的行场同步解析、FIFO读写控制、以及不同算法的切换全都要状态机来编排。这个分层思想的本质是状态机管时序数据通路管运算。两者解耦之后你想换算法、换接口都只需要动其中一层不会牵扯整个系统。这个思维方式如果从part.6就建立起来后面做Zynq、做高速接口会省掉大量返工。最后说一点个人体会也是带过这么多届新人之后的经验总结。写状态机最忌讳的就是拿起来直接写代码打开编辑器前先在纸上把状态转移图画清楚把每个跳转条件的来源验证清楚代码只是把图画出来而已。真遇到调试不出结果的瓶颈不要盯着代码干瞪眼回到状态图上检查是否有漏掉的条件分支或者错误的状态语义往往五分钟就能找出问题。这个习惯越早建立后续做复杂项目时受益越大。
