FPGA状态机入门:三段式写法与SPI协议解析实战
2026/9/8 12:54:05 网站建设 项目流程

从第一个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次熄灭,循环往复。在画状态图前,先定义状态:IDLE(LED灭,等待计数)、COUNT1(已按1次)、COUNT2(已按2次)、LIT(LED亮,等待熄灭按键)。跳转条件是按键的上升沿,每次按下从当前状态递增,在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 = 3'd0; localparam S_WAIT_A = 3'd1; localparam S_WAIT_B = 3'd2; localparam S_DONE = 3'd3; 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 <= 1'b0; else if (state == S_DONE) dout <= 1'b1; else dout <= 1'b0; 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 spifpga biss-cfpga fmc通信ad7606 fpga,本质上都是在做协议接口的接收和解析。状态机把SPI吃透,后面做BISS-C这类单向编码器协议,思路是一样的。

假设系统时钟50MHz,SPI主机的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_out:8位并行接收结果。
  • recv_cnt:3位计数器,记录已接收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 = 2'd0; localparam S_RECV = 2'd1; localparam S_DONE = 2'd2; 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 <= 1'b0; sclk_d2 <= 1'b0; csn_d1 <= 1'b1; csn_d2 <= 1'b1; 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 == 3'd7 && 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 <= 8'd0; bit_cnt <= 3'd0; end else if (state == S_RECV && sclk_pos) begin shift_reg <= {shift_reg[6:0], mosi}; bit_cnt <= bit_cnt + 1'b1; end else if (state == S_DONE) begin bit_cnt <= 3'd0; end end // 第三段:数据锁存与完成脉冲输出 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin data_out <= 8'd0; recv_done <= 1'b0; end else if (state == S_DONE) begin data_out <= shift_reg; recv_done <= 1'b1; end else begin recv_done <= 1'b0; 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的仿真波形里观察statestate_next,确认状态跳转是不是自己预期的那样,再看shift_regbit_cnt的变化是否符合协议波形。

这里分享一个仿真技巧:把statelocalparam中的名字显示。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综合工具默认会把未列举的编码处理为don't care,这会导致状态机跑到非法状态时行为完全随机。正规做法是default分支回到安全状态(通常是IDLE),并让所有输出处于无效电平。

对于安全性要求高的场景,比如电机控制、功率器件驱动,还要加看门狗逻辑:状态机进入同一状态超过设定时间没出去,就强制跳回IDLE,同时拉错错误标志。我在实际项目里常用一个额外的计数器来监测状态停留时间,这个耗时不多但非常管用,能在故障瞬间快速定位卡死状态。

状态编码的选择也有讲究:One-Hot编码下的非法状态检测逻辑比二进制编码更简单直接,这也是我推荐使用One-Hot的又一个原因。它的独热特性决定了你只需要检测“有没有且仅有一个bit为1”,就可以判断状态机是否发生了异常。

5. 状态机在大工程里的位置:从协议解析到图像处理

5.1 用状态机收敛复杂协议和接口时序

看过热词列表你会发现,FPGA的高频场景永远是协议和接口:fpga biss-cad7606 fpgafpga实现mipifpga光口收发fpga pcie。这些协议的核心难点不是计算复杂度,而是时序控制复杂度。

拿AD7606这种并行ADC来说,转换完成信号到来后,你需要经历读取、等待、锁存、切换通道一系列步骤。这几乎就是状态机的完美应用场景:IDLE等待转换信号,然后进入读状态,读满就切通道,再回到等待。整个数据采集流程被状态机编排得清清楚楚,主逻辑里不需要到处散落标志位。

再往大了说,stm32h743和fpga实现fmc通信这类异构通信项目,FMC总线访问的每一项操作——地址建立、数据读写、等待周期——都有明确的时序窗口,这些窗口用状态机搭建比用组合逻辑硬算可靠得多。你甚至可以把FMC的接口时序改成参数化的状态机,换不同类型的MCU时只改参数,不用重写框架。

5.2 状态机与嵌入式软件架构的相似性

热词里有句话很有意思:嵌入式软件架构第一课:用状态机收敛复杂度。做FPGA久了你会发现,硬件状态机和软件里的状态机思想其实是同一套东西。

软件里,你用switch-case处理事件循环;硬件里,你用case处理状态跳转。区别只在于硬件状态机是并行采样、时钟驱动,软件状态机是顺序执行、事件驱动。但两者共同点都是:提前定义清楚“状态集合”和“事件集合”,系统复杂度就在可控范围内展开。

我在项目里经常和做软件驱动的同事交流状态图,因为我们都需要从同一个协议文档里提取状态转移关系。如果FPGA这边和软件那边各自画各自的状态图,最后联调时必出事。先一起把状态图画对,软件和硬件只是用不同语言翻译同一张图而已。这种跨语言的沟通方式,比对着寄存器手册争吵高效太多。

5.3 数据流控制:状态机与运算模块的分工

卡尔曼滤波、图像处理这类计算密集型任务,热词里出现的卡尔曼滤波 fpgafpga图像处理对新手来说很容易产生误解,以为要用状态机把滤波公式一步步算出来。实际上在硬件工程里,状态机和运算模块是分工的。

运算部分用数据通路设计:乘法器、加法器、定点化参数全部做成流水线,每个时钟沿推进一个运算步骤。状态机在这里的角色是数据流管理器:什么时候通知数据通路开始计算,什么时候读取结果,什么时候把结果写入FIFO,什么时候发起一次DMA搬运。用图像处理举例,边缘检测算法的卷积核本身是纯组合逻辑加流水线,但整帧图像的行场同步解析、FIFO读写控制、以及不同算法的切换,全都要状态机来编排。

这个分层思想的本质是:状态机管时序,数据通路管运算。两者解耦之后,你想换算法、换接口,都只需要动其中一层,不会牵扯整个系统。这个思维方式如果从part.6就建立起来,后面做Zynq、做高速接口,会省掉大量返工。

最后说一点个人体会,也是带过这么多届新人之后的经验总结。写状态机最忌讳的就是拿起来直接写代码,打开编辑器前先在纸上把状态转移图画清楚,把每个跳转条件的来源验证清楚,代码只是把图画出来而已。真遇到调试不出结果的瓶颈,不要盯着代码干瞪眼,回到状态图上检查是否有漏掉的条件分支或者错误的状态语义,往往五分钟就能找出问题。这个习惯,越早建立,后续做复杂项目时受益越大。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询