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

资讯详情

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

FPGA逻辑设计与状态机:从组合时序分工到三段式写法

FPGA逻辑设计与状态机:从组合时序分工到三段式写法 从上一篇文章写到这里Part 6 了。这一篇的主题是逻辑设计与状态机算是FPGA开发里最核心、也最容易被新手卡住的地方。说“近似0基础”意思是前面几篇文章你已经搞定了开发环境、板卡点亮、跑通了第一个流水灯或者UART回环手上有基本的Verilog语法概念但还没有系统性地想过一段逻辑到底应该怎么写才对组合逻辑和时序逻辑怎么配合状态机什么时候用、怎么写才不会跑飞这篇文章我会从逻辑设计的基本框架开始讲然后重点拆解状态机建模、三种写法对比、编码方式选择以及我在实际调试中踩过的一些坑。尽量讲清楚“为什么这么做”而不是只贴代码。1. 逻辑设计的整体思路先划分再描述FPGA的逻辑设计听起来很高大上本质上就是做一件事用硬件描述语言把数字电路搭出来。数字电路就两大类组合逻辑输出只取决于当前输入和时序逻辑输出还取决于历史状态靠时钟驱动。你写的每一行Verilog最后都会映射到LUT、触发器、布线资源上所以写代码之前心里要有电路图。1.1 组合逻辑与时序逻辑的正确分工很多新手上来就喜欢写一大坨always块里面又是赋值又是判断逻辑全搅在一起。时间长了会发现仿真对、上板错或者时序收敛不了其实就是组合逻辑和时序逻辑分工没想清楚。以一个简单的例子说明你要设计一个模块对输入的数据流做“当连续收到3个高电平时输出一个脉冲”的功能。组合逻辑写法核心思路是用assign语句描述输出和输入的关系不涉及时钟。assign pulse data_in data_in_d1 data_in_d2;这里data_in_d1、data_in_d2分别是输入延迟1拍、2拍的信号。这种写法本身没问题但要注意它的输出会产生毛刺因为data_in和两个延迟信号到达AND门的时间不一样。虽然仿真时波形好看上板后接示波器就看得到毛刺。时序逻辑写法则是把判断过程用时钟打拍reg [1:0] count; always (posedge clk or posedge rst) begin if (rst) count 2d0; else if (data_in) count (count 2d2) ? 2d0 : count 1b1; else count 2d0; end assign pulse (count 2d2) data_in;前后对比你会发现时序写法多了一个计数器输出由触发器产生不会因为组合逻辑路径上的微小延迟差异产生毛刺时序上更干净。所以我的建议是数据通路和控制逻辑尽量采用时序逻辑描述组合逻辑只做简单的数据选择、译码、运算拼接复杂判断放到时钟里去。这个原则贯穿所有项目简单的小模块、复杂的通信协议栈都一样。1.2 阻塞赋值与非阻塞赋值写Verilog的第一条红线这一节内容是绝大多数FPGA初学者遇到的第一个大坑我在这里吃过亏也帮别人改过不少bug。非阻塞赋值是 用于时序逻辑。阻塞赋值是 用于组合逻辑。问题在于很多初学者一开始不理解或者“反正仿真都能过”于是混着用。等到上板跑起来发现功能不对回头追查才意识到这里的问题。为什么时序逻辑要用非阻塞赋值核心原因是硬件触发器是并行工作的。以两个寄存器级联为例always (posedge clk) begin a_reg b_reg; c_reg a_reg; end如果是非阻塞赋值c_reg拿到的是a_reg的旧值上一拍的值b_reg才被更新为a_reg的旧值。这个行为完全符合两个触发器并行更新的物理特性时钟沿到来时所有触发器同时采样输入同时更新输出。如果你把这里换成阻塞赋值 always (posedge clk) begin a_reg b_reg; c_reg a_reg; end仿真时c_reg拿到的就是b_reg的新值行为变成了“先更新a再更新c”变成串行逻辑了。综合工具虽然可能帮你纠正但仿真和实际电路可能不一致尤其是复杂逻辑里这种写法会造成验证盲区。注意组合逻辑的always块不带时钟或用 * 通配符里用阻塞赋值是允许的时序逻辑的always块里用非阻塞赋值这是行业惯例。混用会导致仿真结果不可预测这是一条红线。2. 状态机逻辑设计的控制中枢状态机是干什么的一句话它把一个复杂的时序控制过程拆解为若干个稳定状态通过条件跳转来切换状态让系统在每个状态下只关注当前该做的事。这个思想不仅在FPGA里适用嵌入式软件也一样回调顺序、任务调度、协议解析都可以用状态机来收敛。FPGA里的状态机一般指有限状态机FSM因为可编程逻辑资源是有限的状态个数自然有限。常见分类有两种Moore型和Mealy型。Moore型的输出只取决于当前状态Mealy型的输出还取决于输入条件。两者各有适用场景后面结合实际例子讲。2.1 状态机三要素状态、跳转条件、输出设计状态机之前先想清楚三件事系统有哪些稳定的状态——比如一个UART接收模块可以拆成“空闲IDLE”、“接收起始位”、“接收数据位”、“接收停止位”几个状态。什么条件触发状态切换——比如检测到起始位下降沿从IDLE跳到接收起始位接收完8个数据位跳到停止位状态。每个状态下做什么输出——比如数据位状态下把移位寄存器里的bit依次移位锁存停止位状态下把完整字节输出到数据总线。很多人拿到项目直接写代码写着写着状态就多了跳转也乱了。我的习惯是无论多小的模块先用一张纸把状态图画出来状态是圆圈跳转是箭头标注跳转条件。画完再写代码代码就是“翻译”这张图。2.2 一段式、两段式、三段式状态机怎么选这是FPGA面试几乎必问的题但实际项目里没有绝对对错只有适不适合。我分别说一下三种写法的结构、优缺点、适用场景。一段式状态机——整个状态机写在一个always块里既有状态跳转又有输出赋值。代码最短但输出混杂在状态跳转逻辑里不易维护组合逻辑和时序逻辑容易耦合。适合非常简单的状态机比如只有两三个状态的复位序列控制器。两段式状态机——第一段用时序逻辑描述状态寄存器跳转现态到次态第二段用组合逻辑描述输出。结构清晰多了输出逻辑可以单独分析和仿真。但组合逻辑输出容易产生毛刺需要外部打拍处理。三段式状态机——第一段时序逻辑做状态跳转第二段组合逻辑算下一状态第三段时序逻辑做输出寄存。三段写法是目前业界最推荐的原因很直接输出经过寄存器打拍干净稳定不毛刺状态跳转和输出逻辑分离可读性和可维护性都强。代价是代码量多了一些但换来的是可靠性和清晰度值。我在实际项目里基本都用三段式偶尔用两段式处理非常简单的模块。一段式只有写几行测试代码或者临时验证时会用。2.3 一个完整可参考的三段式状态机模板下面这个例子是一个串行数据帧接收模块的简化版。功能是检测到起始位0连续采样8位数据再等待停止位1输出一个字节。这个例子麻雀虽小五脏俱全可以当作三段式状态机的模板。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 3d0; localparam START 3d1; localparam DATA 3d2; localparam STOP 3d3; reg [2:0] state; reg [2:0] next_state; reg [2:0] bit_cnt; reg [7:0] shift_reg; // 第一段状态寄存器时序逻辑 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 rx ? IDLE : START; START: next_state DATA; DATA: next_state (bit_cnt 3d7) ? STOP : DATA; STOP: next_state rx ? IDLE : START; default: next_state IDLE; endcase end // 第三段输出逻辑时序逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin bit_cnt 3d0; shift_reg 8d0; data_out 8d0; data_valid 1b0; end else begin data_valid 1b0; case (state) IDLE: begin bit_cnt 3d0; shift_reg 8d0; end START: begin // 起始位什么都不做准备接收数据 end DATA: begin shift_reg {rx, shift_reg[7:1]}; // 先收低位 if (bit_cnt 3d7) bit_cnt 3d0; else bit_cnt bit_cnt 1b1; end STOP: begin data_out shift_reg; data_valid 1b1; end default: ; endcase end end endmodule三个always块的分工看得很清楚第一个块只负责把next_state锁存到state第二个块只负责算next_state第三个块负责所有数据寄存器的更新和输出。三块的代码都短排查问题很容易定位。关于采样时序有一个细节要提醒实际UART信号往往有一个比特时间你需要在每一位的中间点采样最稳。一般用一个波特率分频计数器在每位中间产生采样脉冲上面的例子为了简化逻辑没有画出来实际使用要在DATA状态里加一个采样脉冲条件只在采样脉冲为高时取样。2.4 状态机编码方式独热码、二进制码、格雷码这一节很多人容易忽略考虑不周全会导致时序问题。二进制码Sequential就是状态0、1、2、3顺序编码用的触发器数量少适合状态数多、逻辑复杂的模块。代价是相邻状态跳转时可能有多个bit同时变化容易产生中间态对组合逻辑的竞争和毛刺要更小心。独热码One-Hot是每个状态用一个bit表示任何时候只有1个bit为1。状态跳转时只有两个bit在变旧状态清0、新状态置1时序上更干净不需要额外译码电路速度更快。代价是状态数N需要N个触发器资源占用多。格雷码Gray保证相邻状态间只有1个bit变化适合状态连续跳转的场合比如计数器遍历。但状态机一般跳转路径多不一定都满足格雷相邻条件所以用得少。选择建议状态数少于8个对时序有要求比如高速接口的控制用独热码。状态数多、逻辑复杂或者资源紧张用二进制码。FPGA里LUT资源很充裕触发器也很多现代综合工具对独热码的优化也成熟所以中小规模控制器我基本都用独热码。localparam IDLE 4b0001; localparam START 4b0010; localparam DATA 4b0100; localparam STOP 4b1000;3. 从需求到状态机一个完整的设计过程可能有人觉得讲了这么多理论还是不知道一个实际项目拿到手里该怎么下手。这一节我用一个可测的例子走一遍全流程按键消抖单次脉冲输出模块。功能听起来简单却涵盖了状态机设计的完整套路。3.1 需求分析到底要解决什么问题按键按下和松开时机械触点会产生多次抖动弹跳持续时间大约5~20ms。直接接逻辑信号会在一段时间内反复跳变导致计数、触发逻辑误动作。所以要做消抖等信号稳定后再输出一个单脉冲。这个场景很典型因为用户的需求一开始往往是模糊的“做按键控制”。实际落到FPGA里变成三个明确问题如何判断按键已经稳定答信号连续N个时钟周期保持同一电平。按下有效还是释放有效答这里定义为按下瞬间输出一个周期高脉冲。消抖期间怎么处理答忽略抖动不输出任何东西。拆到这里状态机的框架已经出来了。3.2 状态定义与状态图构建这个模块可以分成三个状态IDLE等待按键按下检测到低电平后进入消抖计数。WAIT_STABLE持续检测到低电平达到消抖周期说明按下确认输出脉冲进入等待释放。WAIT_RELEASE等待按键释放高电平释放后回到IDLE。按下过程中有抖动表现为高低电平反复但只要没有连续达到消抖周期就一直在WAIT_STABLE里重新计数。这样简单、可靠。状态图画法IDLE --(低电平)-- WAIT_STABLE WAIT_STABLE --(连续N个周期为低)-- WAIT_RELEASE并输出脉冲 WAIT_STABLE --(出现高电平)-- IDLE判定为抖动 WAIT_RELEASE --(高电平)-- IDLE3.3 完整代码逐行拆解我直接给出一份可综合的代码module key_filter ( input wire clk, input wire rst_n, input wire key_in, output reg key_pulse ); localparam IDLE 2d0; localparam WAIT_STABLE 2d1; localparam WAIT_RELEASE 2d2; parameter CLK_FREQ 50_000_000; parameter FILTER_MS 20; parameter FILTER_CNT CLK_FREQ / 1000 * FILTER_MS; reg [1:0] state; reg [1:0] next_state; reg [25:0] cnt; // 第一段状态寄存器 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: if (!key_in) next_state WAIT_STABLE; WAIT_STABLE: if (cnt FILTER_CNT) next_state WAIT_RELEASE; else if (key_in) next_state IDLE; WAIT_RELEASE: if (key_in) next_state IDLE; default: next_state IDLE; endcase end // 第三段输出和计数逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 26d0; key_pulse 1b0; end else begin key_pulse 1b0; case (state) IDLE: cnt 26d0; WAIT_STABLE: begin if (!key_in) cnt cnt 1b1; else cnt 26d0; end WAIT_RELEASE: begin if (cnt FILTER_CNT) begin key_pulse 1b1; cnt 26d0; end end default: ; endcase end end endmodule注意几个细节第二段的写法里默认next_state state这样没列出的跳转条件就会保持当前状态不会误跳。这个技巧叫“默认保持”写组合逻辑时非常重要可以避免锁存器和意外跳转。第三段里key_pulse每次进来先清0然后条件置1。这样保证输出只有一个时钟周期宽不会因为状态停留时间长而持续拉高。关于消抖时间的参数50MHz时钟下20ms对应100万个时钟周期计数器的位宽至少需要20位我写成26位是留了余量方便后面调参数。如果真按20ms消抖按下到确认会有20ms延迟这在按键场景完全无感但如果做急停类信号就得重新考虑这个方案了。注意有同学会把消抖写成“检测到边沿后开始计数”的流程那也是一种方案。但连续N周期为低才判稳定天然滤掉了大部分抖动逻辑上更符合“信号稳定”的本意。4. 状态机调试与常见问题排查实录状态机写好了仿真也能跑但上板总出问题或者仿真时报错这是每个FPGA开发者必经的阶段。这里我把状态机调试中最常遇到的三类问题整理出来配合排查思路。4.1 仿真正常上板不工作时钟与复位的问题这是新手最崩溃的场景仿真波形完美板子一跑就没有反应。遇到这种情况先不要怀疑状态机逻辑大概率是顶层时钟或者复位没接对。排查步骤用逻辑分析仪或在线逻辑分析仪比如Vivado里的ILAQuartus里的SignalTap抓内部状态信号看state是否在变化。确认时钟频率对不对很多开发板板载晶振可能是50MHz也可能是100MHz而代码里参数用的是不是对应频率。确认复位是高有效还是低有效有些板上按钮按下为低你的rst_n低有效就没问题如果接反了状态机永远停在复位态。检查时钟复位是否有毛刺时钟不稳定时状态跳转逻辑可能进不了想要的next_state。4.2 状态机卡死在某个状态跳转条件永远不满足卡死问题通常出在组合逻辑的次态计算里。原因无外乎几种状态变量没有初始化上电后state处于不确定值case里没有default分支覆盖状态机直接跑飞。次态计算中用到了被外部条件复位的信号比如计数器清零逻辑和跳转条件互斥导致条件永远不成立。信号跨时钟域没有同步异步信号直接进了状态机的跳转条件出现亚稳态状态机偶发卡死。第三点值得多说一句按键、外部接口信号、其他时钟域的信号进状态机之前一定要用两个触发器打两拍做同步。比如按键消抖模块里key_in在进入状态机之前要先做两级寄存器同步不然机械抖动外加异步采样的亚稳态叠加状态机大概率不稳定。4.3 输出信号有毛刺组合逻辑输出的坑如果你用的是两段式状态机或者输出逻辑写在组合逻辑里输出毛刺几乎是必然的。因为状态跳转时多个触发器输出同时变化组合逻辑的输出在短暂时间内会有中间态。解决方式就两条把输出拉到三段式的时序逻辑里让输出经过寄存器打拍。如果输出必须立即响应不允许多打一拍则要检查输出信号路径上是否允许一定延迟。很多通信协议里“立即响应”其实可以打一拍不差这个时钟周期硬要用组合输出反而把自己坑了。4.4 一个具体调试案例恼人的乱序数据之前调一个SPI Slave接收模块数据总是不对时好时坏逻辑分析仪抓到的波形显示接收字节里的bit顺序乱掉了。后来排查发现我在DATA状态下是用bit_cnt判断当前接收第几位但bit_cnt的更新逻辑写在另外一个always块里状态跳转的时序和bit_cnt更新的时序差了一拍。结果就是状态机已经进了第5位bit_cnt还指向第4位数据自然错位。后来我统一改成三段式——状态跳转、次态计算、数据计数更新全部集中在同一个状态机的三个块里bit_cnt更新严格跟state匹配问题就消失了。这个经验告诉我状态机相关的所有寄存器和状态跳转要保持一致的节拍体系不要东一块西一块各写各的。4.5 常见问题速查表现象可能原因排查方法仿真正常上板无反应时钟频率、复位极性、引脚约束错误在线逻辑分析仪抓状态寄存器状态机卡死缺default分支、复位未覆盖全部状态寄存器检查状态编码与case完整性数据错位bit_cnt等辅助寄存器与状态跳转不同步统一到三段式结构检查打拍输出毛刺组合逻辑输出改三段式输出打拍偶发性跑飞异步信号未同步输入信号打两拍再进入状态机5. 一些提高状态机设计效率的小技巧这一节聊几个在实战中积累的做法不是教科书内容的扩展但确实能显著提升设计效率和可靠性。第一不要上来就写代码。哪怕只是几十行的状态机也先在纸上把状态图、输出条件、计数器边界画清楚。我发现很多人卡住的原因不是Verilog不会写而是状态转移本身没想明白。先图后码写代码变成翻译速度快一倍。第二仿真时一定要覆盖状态机的非法状态和复位后的初始行为。用随机激励跑几百拍看会不会跳进default、state会不会变成未定义值。很多上板才暴露的问题仿真阶段就能抓出来。第三加状态诊断输出。在顶层模块里留一个测试端口实时输出当前状态编码用逻辑分析仪或板载LED显示状态变化。配合在线逻辑分析仪能够快速定位状态机是否按要求跳转。第四参数化状态机的计数阈值。比如消抖时间、超时时间不要硬编码成常数写成parameter顶部统一管理。不同项目复用模块时直接改参数就完事不用动核心逻辑。第五状态命名要可读。用IDLE、WAIT_STABLE不要用STATE0、STATE1。虽然编译出来都一样但维护三个月后再看代码含义清晰的名字能省下大量回忆时间。代码是给人看的不是只给综合工具看的。
返回列表