ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FPGA逻辑设计入门:从状态机到三段式写法的工程实践

FPGA逻辑设计入门:从状态机到三段式写法的工程实践 刚接触FPGA那会儿我最头疼的不是语法而是“不知道该怎么把脑子里的想法变成电路”。写软件习惯了一行一行从上往下执行到了FPGA这里全变了代码不是“运行”的是在“描述一块电路长什么样”。Part 5我们打通了时序逻辑的基本功寄存器、计数器、分频器这些都能上手了但真到了做一个稍微有点逻辑的东西比如按键消抖、串口接收、协议解析问题就来了这堆if-else写下去到底是在写电路还是在写C语言这篇part 6我们专门聊两件事逻辑设计的基本思维方式以及把逻辑“结构化”的核心手段——状态机FSM。为什么状态机这么重要因为FPGA里的逻辑一旦涉及多个步骤、多个阶段、多个分支靠堆if-else没有出路综合器最后给你的就是一团乱麻时序违例、毛刺、隐态、难调试。而状态机本质上是给硬件逻辑画了一张“地图”让每一个时刻都知道自己在哪里、下一步怎么走。这篇文章适合两类人一是跟我一样从中断、任务、循环这些软件思维里刚转过来的需要建立硬件的逻辑设计框架二是已经会写Verilog、但设计里老是出“莫名奇妙问题”的朋友多半就是状态机结构不合理。我尽量不堆理论从实际代码和踩坑出发把这部分讲透。1. 从软件思维到硬件思维逻辑设计的第一步是“换脑子”1.1 顺序执行 vs 并行发生写单片机代码你心里有一个天然的模型指令是一条一条执行的。就算有中断、有RTOS归根到底CPU在任意一个时刻也只处理一件事。这个思维惯性在FPGA里会严重误导你。FPGA里没有“执行”这个概念。你写了两行assign语句它们不是“先执行第一行再执行第二行”而是在芯片里同时存在两条物理连线。你写了三个always块它们也不是先后调用关系而是三块独立的电路在同一时刻各自跑各自的。很多人刚开始写Verilog时容易犯的一个错误就是把软件思路直接翻译过来比如// 错误示例软件思路写出来的“流程” reg flag 0; always (posedge clk) begin if (flag 1) begin // 做第一件事 flag 0; end else begin // 做第二件事 if (some_cond) flag 1; end end这样写当然能凑合实现某些功能但一旦步骤超过三层分支再一多代码就成了一锅粥。不想清楚“当前处于哪个阶段”全靠flag组合来判断后面的维护成本和调试痛苦指数会直线上升。1.2 组合逻辑和时序逻辑搞不清这个后面全是坑FPGA逻辑设计里最基础、也最重要的划分就是组合逻辑与时序逻辑。组合逻辑输出只取决于当前输入没有记忆能力。典型的就是各种门电路、多路选择器、加法器。它“实时”响应输入变化但也有延迟——信号从输入到输出要经过门电路这个延迟就是组合逻辑延迟。时序逻辑输出不仅取决于当前输入还取决于之前的状态。换句话说它“记住”了历史信息。寄存器是时序逻辑的核心所有状态变更都发生在时钟沿这样的设计才是同步设计在FPGA里最可靠。打一个不太严谨但很好用的比方组合逻辑是一根水管水信号流过去就过去了没有“记忆”时序逻辑是一个个闸门只有在时钟沿到来的时候闸门才打开一次把当前的水位记录下来下一次时钟沿再更新。FPGA设计里的黄金法则是能用时序逻辑就用时序逻辑组合逻辑越少越好组合逻辑只用来计算“下一个状态是什么”而不要用它去直接驱动重要的输出信号。这个原则在写状态机的时候会反复用到。1.3 小心隐形的锁存器latch不是你的朋友写组合逻辑的always块时最容易踩的坑就是无意中综合出latch锁存器。latch是电平敏感的存储单元在FPGA里没有专门的latch原语是靠查找表LUT资源拼出来的不仅浪费资源还极易产生时序问题和毛刺是设计中需要尽量避免的东西。什么时候会综合出latch典型就是if-else分支不完整、case没有default。比如always (*) begin if (en) q d; end这段代码在en为0的时候没有给q赋值综合器会怎么理解“那q保持住之前的值呗。” 于是它给你生成一个锁存器。这不是你的本意你大概率是想让q在没有en时等于某个固定值。正确的写法是always (*) begin q 1b0; // 先给默认值 if (en) q d; end所以写组合逻辑always块有两条铁律所有分支都必须覆盖也就是要有else要么先给变量赋默认值要么保证每个分支都给所有被赋值的变量赋值。这两条记住了latch问题基本可以杜绝。2. 状态机到底是什么最核心的两个模型2.1 什么是状态为什么需要状态机简单说状态就是“当前处于什么阶段”。任何有先后顺序、有分支判断、有重复过程的逻辑都可以用状态机来表达。比如说按键消抖。按键按下去不是你读到一次低电平就算完而是要在连续多次采样中都看到稳定的低电平才算“确实按下了”。这里就涉及几个阶段空闲等待IDLE、检测到可能有按下抖动PRESS_DETECT、确认稳定按下PRESS_CONFIRM、等待释放RELEASE、确认释放RELEASE_CONFIRM。每个阶段之间通过条件转移这就是状态机的雏形。状态机解决的问题本质是把复杂的、多步骤的逻辑拆解成有限的、清晰的“阶段”每个阶段只关心自己的一亩三分地什么时候跳走由明确的转移条件决定。2.2 Moore型 vs Mealy型怎么选教科书上把这俩定义得很学术我用大白话总结Moore型输出只由当前状态决定。意思就是“我在哪个阶段输出就是什么”输入只影响状态的跳转不直接改变输出。Mealy型输出由当前状态和输入共同决定。意思是“我在哪个阶段 我此刻收到了什么信号”两者一起决定输出。用一个例子帮你记假设一个状态机用来控制电梯门。Moore型思路空闲状态输出“关门”运行状态输出“开门”。输出只和状态有关哪怕这时候有人在门口伸手拦电梯只要状态没变门还是按照状态输出。Mealy型思路空闲状态默认“关门”但是如果检测到有人挡住门输入信号有效立刻输出“开门”。输出是状态和输入共同作用的结果。选择建议很简单对延迟敏感、希望输出对输入快速响应的用Mealy对输出稳定性要求高、不希望输入带来毛刺的用Moore输出比Mealy会晚一个周期但更干净。实际工程里绝大多数控制类逻辑用Moore型就足够了而且更安全。mealy虽然响应快但组合逻辑直接参与输出一旦输入有毛刺输出也会跟着毛刺。初学者建议从Moore型起步。2.3 状态机四要素写任何一个状态机先问自己四个问题答案清楚了代码就是翻译工作有哪些状态例如IDLE DATA STOP ERROR哪些输入信号会引起状态跳转例如start信号、数据有效信号、计数器溢出信号每个状态里要做什么事情例如移位接收、计数、产生输出哪些输出信号是状态或输入的函数例如RX_DONE、数据总线赋值这四个问题想透了状态机就跑不了了。3. 三种状态机写法为什么我强烈推荐三段式3.1 一段式、两段式、三段式区别在哪很多人学状态机的时候被“一段式、两段式、三段式”绕晕了。我用自己的话解释一下一段式整个状态机只用一个always块状态转移和输出全写在一起。两段式两个always块第一个负责状态寄存器和次态计算第二个负责输出逻辑。三段式三个always块第一段负责状态寄存器当前状态更新为次态第二段负责次态计算组合逻辑根据当前状态和输入决定下一个状态第三段负责输出可以是时序逻辑输出也可以是组合逻辑输出。从功能上说都能实现状态机但差异在“可读性”和“输出质量”上一段式最大的问题是输出信号是组合逻辑容易产生毛刺而且代码里状态转移和输出搅在一起状态一多根本没法看。两段式比一段式好很多但输出仍然是组合逻辑依然有毛刺风险同时如果输出逻辑稍复杂也容易在综合时产生latch。三段式通常建议把第三段输出逻辑也做成时序逻辑即寄存器输出这样输出信号干净稳定几乎没有毛刺时序收敛也容易得多。我在实际项目里基本只用三段式特别是要对外输出控制信号的场合宁可多一个寄存器的延迟也要保证输出信号的干净。3.2 三段式状态机的标准模板这里给一个完整可用的三段式状态机模板以“启动时执行一次任务完成后回到空闲”的场景为例module fsm_template ( input wire clk, input wire rst_n, // 低电平复位 input wire start, // 启动信号 input wire done_in, // 某个子任务完成信号 output reg done_out // 状态机完成标志 ); // 状态编码 localparam IDLE 3d0; localparam RUN 3d1; localparam DONE 3d2; // 状态寄存器 reg [2:0] state; reg [2:0] next_state; // 第一段状态转移时序逻辑 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 (start) next_state RUN; else next_state IDLE; end RUN: begin if (done_in) next_state DONE; else next_state RUN; end DONE: begin // DONE状态只持续一个周期然后回IDLE next_state IDLE; end default: next_state IDLE; endcase end // 第三段输出逻辑时序逻辑寄存器输出 always (posedge clk or negedge rst_n) begin if (!rst_n) done_out 1b0; else if (state DONE) done_out 1b1; else done_out 1b0; end endmodule你注意这个模板里有几个非常关键的小细节也是我用了很久之后才真正理解的第二段里第一句话next_state state;是“默认保持”的意思。这样就不必在每个分支里都写一次“否则就保持原状态”代码简洁而且从根上防止了latch生成。default: next_state IDLE;是给非法状态一个去处。万一状态寄存器因为某种原因跑到了一个未定义的值不至于满盘乱转回IDLE重新开始。3.3 第三段输出到底怎么写时序输出还是组合输出好多教程里第三段输出用组合逻辑直接以状态判断输出比如// 组合输出不推荐 always (*) begin case (state) IDLE: y 1b0; RUN: y 1b1; ... endcase end这种写法对y来说一旦state变化y立刻变化响应快但缺点是state本身是寄存器输出相对稳定可你的case里如果引用了外部输入信号做Mealy输出y就容易被输入毛刺感染。我更推荐的做法是第三段也用时序逻辑。输出信号从“当前状态”这个信号经过一个寄存器再打一拍换一个时钟周期但换来的是一个干净、无毛刺的输出。FPGA里时钟频率一般几十到几百MHz多一个周期延迟几乎不影响控制逻辑但信号质量天差地别。4. 状态编码不只是一个简单选择4.1 二进制、格雷码、独热码怎么选写状态机时每个状态在寄存器里用什么编码这是个经常被忽略但很重要的问题。二进制编码Sequential状态值0、1、2、3这样排下去。状态数少用的寄存器位数少组合逻辑相对复杂。适合状态数量多、且对时序要求不极端的场合。格雷码Gray相邻状态之间只有1个bit变化可以减小状态跳变时多个bit同时翻转造成的短暂不稳定现象竞争冒险。特别适合状态连续单向流转的场景比如计数器式的顺序流程。独热码One-hot每个状态占一个bit任意时刻只有一个bit为1。优点是状态译码逻辑极其简单——只要判断某个bit是1就知道在哪个状态缺点是寄存器用得多。FPGA的寄存器资源丰富牺牲一点寄存器换取时序和速度非常划算所以FPGA里高频设计基本都会用独热码。一个直观的对比项目二进制码格雷码独热码寄存器位数最少log2N少最多N位组合逻辑复杂度高中极低速度/时序收敛慢中快适用场景状态量多顺序转移FPGA高频、少量状态总的状态数不超过十几个的话无脑用独热码通常不会错。Vivado里你可以通过综合选项让工具自动帮你把状态机编码成独热码后面讲但你心里最好有一个优先级判断别什么都丢给工具。4.2 编码风格与综合工具的关系Vivado对Verilog的FSM有自动提取和优化能力。如果你的代码严格按照标准状态机模板写它通常能识别出来并按照设定的styleone-hot gray johnson等重新编码。但有些代码里状态判断和输出逻辑写得比较乱工具没法提取那就只能按你自己写的编码综合优化效果就差很多。想让工具完美识别FSM代码得满足几个条件状态寄存器和次态逻辑要规范就是我前面给的标准三段式状态转移只在一个case语句里完成不要在状态转移的case里混入复杂的非状态相关逻辑状态编码用localparam或parameter定义别用宏define。这样Vivado才能稳稳识别从而自动选择最优编码方案。4.3 防御性设计如果状态飞了怎么办FPGA里的寄存器也可能因为外部干扰、亚稳态或者未初始化而进入一个完全没定义的状态。如果没有防御措施你整个状态机就卡死了表现得极其诡异。防御手段主要有三个最好都做上复位所有状态寄存器和关键输出寄存器必须有异步复位或同步复位上电后立刻到一个已知状态。case的default分支把未定义态引导回IDLE。状态超时看门狗可选对于复杂系统再加一个看门狗计时器某个状态停留超过预设时长就强制复位。这在通信协议解析类逻辑里尤其有用比如对方发了一半数据就断开了你不希望状态机永远卡在“接收数据”这个状态里。5. 逻辑设计里绕不开的几个“坑”状态机本身搞定了还得处理它周围的配套逻辑。这一节把我在项目中反复踩、反复给新人讲的几个点集中说一下。5.1 阻塞赋值和非阻塞赋值绝对不能混着用Verilog里组合逻辑用阻塞赋值时序逻辑用非阻塞赋值这两句是入门第一天就该背下来的。但实际项目里总有人在一个always块里混用或者把组合逻辑用了时序逻辑用了结果仿真和综合行为完全对不上。我需要提醒的是仿真里能跑通不代表电路正确。非阻塞赋值是“在时钟沿瞬间同时更新”阻塞赋值是“立刻生效”混在同一个always块里最终的电路行为很难直观预期。最简单的纪律是时序逻辑的always块里一律用组合逻辑的always块里一律用每个always块里只写一种赋值类型。5.2 组合逻辑always块里敏感列表写全了吗老版本的Verilog代码里经常看到这种写法always (a or b) begin y a b c; // 敏感列表没写c end这种代码在仿真时c的变化不会触发always块重新计算仿真结果和实际电路不一致属于经典仿真与综合不一致问题。现在写RTL建议统一用always (*)让工具自动推断敏感列表别自己列信号省事也不容易错。5.3 边沿检测让外部任意信号变成稳定的脉冲状态机里经常需要“捕捉”一个外部信号比如按键按下、某个数据有效沿。这时候边沿检测就很重要。上升沿检测的标准写法// 上升沿检测 reg dly; wire rising; always (posedge clk or negedge rst_n) begin if (!rst_n) dly 1b0; else dly sig_in; end assign rising sig_in ~dly;原理非常简单dly是sig_in打了一拍的值rising在sig_in为高、dly为低的瞬间拉高一个时钟周期。这是时序设计里的经典操作把任意宽度的外部信号转换成跟时钟对齐的单周期脉冲非常实用。同理下降沿检测就是falling ~sig_in dly;。6. 完整案例用状态机实现UART接收逻辑前面讲了一堆这一步我们把所有东西串起来。我选一个非常有代表性的例子用状态机实现UART串口接收。这个案例不大不小正好能把状态机、计数器、移位寄存器、边沿检测全部用上而且做完以后可以直接烧到板子上去跟PC通信成就感拉满。6.1 需求分析和状态划分UART接收协议大家都清楚空闲时RX线为高电平起始位是低电平然后是8位数据LSB first最后是停止位高电平。波特率比如9600意味着每位持续约104.2us在50MHz时钟下就是5208个时钟周期。现在划分状态IDLE空闲等待RX线上出现低电平起始位的前沿。START检测到起始位用一个计数器定时到位的中间位置确保采样点对准数据位的中心。DATA连续接收8个bit每bit在中间位置采样。STOP接收停止位同时把接收完成标志拉高。这里有一个关键经验采样点要选在数据位的中心。如果在一个bit刚开始或者快结束的时候采样很容易采到信号变化的毛刺。我们通过计数器来控制采样时刻从起始位开始计数数到位周期的中点再采样。状态转移和计数器配合的细节我画个流程描述伪代码级IDLE: 若rx_dl 1 (检测到起始位下降沿)启动bit_cnt进入START START: 计数到BIT_MID半个位周期时确认rx确实是低电平进入DATA 若计数到BIT_MID时rx是高电平说明是毛刺回到IDLE DATA: 每计数到BIT_MID就采样1bit送入移位寄存器bit_cnt 采集满8bit后进入STOP STOP: 采样停止位若为高电平说明一帧正常结束产生done脉冲回到IDLE 若停止位为低电平帧错误记录错误状态也回到IDLE6.2 RTL实现片段我给出核心模块的简化实现重点看状态机的三段式用法和计数器的配合module uart_rx_fsm ( input wire clk, input wire rst_n, input wire rx, output reg [7:0] data_out, output reg rx_done ); localparam IDLE 3d0; localparam START 3d1; localparam DATA 3d2; localparam STOP 3d3; reg [2:0] state; reg [2:0] next_state; reg [12:0] cnt; // 位定时计数器 reg [3:0] bit_cnt; // 已经收到的数据位数 reg [7:0] shift_reg; // 移位寄存器 wire rx_fall rx_dl ~rx; // 下降沿检测 reg rx_dl; // 打一拍用于边沿检测 always (posedge clk or negedge rst_n) begin if (!rst_n) rx_dl 1b1; else rx_dl rx; end // 状态转移 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_fall) next_state START; end START: begin if (cnt 0) next_state DATA; end DATA: begin if (bit_cnt 4d8) next_state STOP; end STOP: begin next_state IDLE; end default: next_state IDLE; endcase end // 计数器、移位寄存器等工作时序 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 13d0; bit_cnt 4d0; shift_reg 8d0; data_out 8d0; rx_done 1b0; end else begin // 默认值 rx_done 1b0; case (state) START: begin cnt cnt 1b1; if (cnt 13d2604) begin // 半个位周期50MHz 9600bps cnt 13d0; // 这里进一步确认rx为低否则回IDLE end end DATA: begin cnt cnt 1b1; if (cnt 13d5208) begin // 完整位周期 cnt 13d0; shift_reg {rx, shift_reg[7:1]}; // LSB first bit_cnt bit_cnt 1b1; end end STOP: begin cnt cnt 1b1; if (cnt 13d5208) begin cnt 13d0; data_out shift_reg; rx_done 1b1; bit_cnt 4d0; end end default: begin cnt 13d0; bit_cnt 4d0; end endcase end end endmodule这段代码是我为了展示状态机与计数器配合的逻辑写的不是完整的UART工程但骨架已经对了。实际要用于产品的话你还需要处理采样点中心对齐的细节比如从START开始计数到半个位周期后把cnt清零然后后面的每一位都在cnt计数到5208时采样这样才能保证每个采样点都在位中心。上面这段里我在DATA里直接判断cnt5208其实需要从START结束对齐好cnt的起点这块留给你自己动手调整调试一遍理解比我看十遍都深。6.3 仿真验证的通用做法UART接收状态机写完强烈建议先仿真再上板。Testbench的思路产生50MHz时钟复位释放把rx初始拉高在某个时刻先拉低rx一个起始位时长然后依次输出8个bit可以预先存一个字节再拉高停止位观察data_out和rx_done是否符合预期。关于仿真我个人的习惯是状态机里的state必须拉出来看波形。你在Testbench里直接dump所有内部信号然后对照状态跳转的条件一步步确认。出了错不要急着改代码先看state卡在哪一个状态、为什么没有跳走。大多数情况下是你跳转条件里的计数器值算错了或者时序差了一个周期。7. 状态机调试的实操经验比写代码更花时间的环节7.1 仿真时最该看哪几个信号调试状态机第一要素是让所有内部状态可视化。Vivado的xsim或者ModelSim里把你定义的state、next_state、关键计数器、关键标志位都加到波形窗口。尤其是next_state它能帮你判断“下一步应该跳去哪”对比state就知道是跳转没发生还是跳转后又弹回来了。我有一次排查一个莫名其妙的偶发错误折腾了一下午最后发现是某个信号在状态机里被多个always块赋值了导致综合结果混乱。这种问题仿真阶段很难看出来因为仿真里多驱动可能会报warning甚至直接不更新但综合时究竟生成了什么电路完全看天吃饭。所以代码规范真的不是洁癖是保命。7.2 ILA在线调试上板以后怎么定位如果仿真都验证过了上板还有问题多半是外部信号时序或者亚稳态问题。此时把ILAIntegrated Logic Analyzer核拉进去观测真实的state信号变化。Vivado里操作很简单在综合后的网表上标记要观测的信号插入ILA重新布局布线烧录后通过JTAG实时查看波形。一个常见问题是ILA观察到state跳到了一个未定义值比如3b101而你的状态定义只用了3b000到3b011。这基本上就实锤了亚稳态或者复位不完整的问题。处理方法通常是外部异步信号进入FPGA后先打两拍做同步再参与状态机逻辑确保复位信号释放满足时序要求状态机加default防御前面已经强调过。7.3 我写状态机的个人习惯最后分享几个我自己的习惯不一定对所有项目都适用但对中规模逻辑设计来说是能显著降低返工率的写代码之前一定先画状态转移图。哪怕是画在草稿纸上把每个状态、每条转移条件、每个输出列出来再动手写Verilog。这个习惯帮我省掉的调试时间抵得上所有加班。状态编码用localparam并且用有意义的名字不要用魔法数字。所有状态机的输出信号除非有明确的性能要求一律用寄存器输出时序输出。每个状态机的模块边界要清晰输入输出信号集中在端口定义里内部信号不要跟其他模块混用。写Testbench时把异常输入也测一遍比如起始位毛刺、数据位中间跳变看看状态机会不会误动作。8. 进阶一步多状态机协作与状态机拆分8.1 一个状态机打天下多半会把自己绕死刚开始写状态机的同学很容易遇到需求“稍微复杂一点”就往状态机里加状态、加分支。结果状态从5个膨胀到20个状态转移条件多得自己都不想看可读性急剧下降时序也不好收敛。这时候最该做的不是继续加状态而是拆分状态机。比如一个通信协议解析模块可以拆成接收状态机负责把字节收进来、帧解析状态机负责把字节组合成帧、响应状态机负责回数据。三个状态机通过握手信号比如valid/ready连接起来。每个状态机都保持在5-8个状态以内逻辑清晰、调试容易。模块化思想在FPGA里比在软件里更关键因为硬件模块是物理上有边界的。每个模块可以单独综合、单独仿真出问题了也容易定位。8.2 状态机和数据通路怎么配合我见过很多人把状态机写成了“大管家”把所有数据操作都塞到状态判断里。实际上更好的设计是“状态机管控制数据通路管计算”。举个例子做一个图像处理算法假如需要三步流水计算。状态机负责把控“什么时候开始”、“什么时候结束”、“当前是哪一步”而具体的像素加乘运算用一个独立的数据通路模块组合逻辑或流水线寄存器去完成。状态机给数据通路发送enable信号数据通路给状态机返回done信号。二者各司其职代码才会好写、好调、好维护。这跟我们之前讲的三段式状态机模板是完全一致的第一段管状态寄存器第二段管跳转条件第三段管输出并没有把运算逻辑混进状态转移的case里。最后再分享一个实际项目里常用的经验。我做一个数据采集系统的时候每条通道的状态机都要处理“等待触发、采集、缓存、上报”这一套流程一开始把状态机写得非常“完备”每个状态里都带了一堆标志位和子计数器结果综合以后时序报告惨不忍睹。后来把状态机拆成主状态机和辅助计数器模块主状态机只关心阶段切换计数器模块独立负责各种定时信号立马干净了。这个思路值得你下次设计时尝试——状态机不该是“万能控制器”它只是一个清晰的“阶段管理器”。
返回列表