最近好几个读者几乎同时问我同一个问题:按键消抖代码明明照着例程写的,为什么按十次有五次失灵?我把代码拿过来一看,问题不在消抖算法,而在逻辑设计的基本功上——状态机虽然写了,但状态跳转和输出全糊在同一个always块里,中间还夹着组合逻辑直接驱动寄存器。这不是个例,而是很多初学者从“会写语法”迈向“会做设计”时必然会撞上的一堵墙。
这是FPGA开发系列的第6篇,面向的就是“近似0基础但已经能写一点Verilog”的读者。如果你已经会点灯、会做计数器,但一到多步骤控制逻辑就头疼,或者仿真没问题一上板就行为异常,那么这篇就是为你准备的。我会从逻辑设计最基本的概念讲起,然后完整走一遍状态机设计流程,并给出可以直接仿真复现的代码和调试方法。文章会有些长,但每一步都有原因。
1. 从“能跑”到“跑对”:逻辑设计思维的第一道坎
1.1 组合逻辑与时序逻辑的分界线凭什么存在?
FPGA内部可编程逻辑分成两大块:查找表LUT和触发器FF,它们分别承载组合逻辑和时序逻辑。组合逻辑是纯函数,输入一变,输出立刻跟着变,硬件上就是一堆LUT串联;时序逻辑则靠触发器在时钟边沿采样输入,把结果锁存到输出,直到下一个时钟沿到来前保持不变。
很多新手把这两种逻辑混着写:在always @(posedge clk)里用阻塞赋值给多个变量连续赋值,以为像C语言一样按顺序执行;或者在always @(*)里漏掉某些分支赋值,综合出一堆Latch。这些错误在仿真时不一定暴露,但一上板就是各种“玄学”。按键消抖失灵只是其中一种表现,严重的时候整个模块状态机卡死,只有重新上电才能恢复。
为什么一定要有时钟沿?因为任何组合逻辑都有物理延迟。信号经过十几级LUT之后,到达终点的时间有几纳秒甚至更多的不确定性。如果没有统一的节拍来采样,不同路径上信号到达先后不同,结果就会乱套。时钟沿就是那个统一节拍,触发器只在边沿瞬间“拍照”,把当时的输入电平锁存住,之后输入怎么变,输出都保持稳定。这个“拍照”的动作,是数字系统能够稳定运行的根本前提。
1.2 近似0基础最容易卡住的三个概念
很多刚入门的朋友会问:posedge clk是不是clk为高电平就触发一次?不是。posedge指电平从低到高的跳变沿,一个周期内只在跳变那一个瞬间触发。比如100MHz时钟周期是10ns,每10ns会有一个上升沿事件,在这个边沿上所有时序逻辑同时采样、同时更新。理解这一点,才能理解FPGA里的“并行”是天然属性,和单片机的顺序执行完全是两码事。
第二个容易卡住的是always块的两种触发方式。always @(*)表示括号内任何信号变化都会触发块内语句执行,通常用来描述组合逻辑;always @(posedge clk)只在时钟上升沿执行,通常用来描述时序逻辑。常见错误是把次态组合逻辑也写成always @(posedge clk),结果“当前状态更新”和“下一状态计算”的边界被抹掉,行为完全变味。
第三个是阻塞赋值和非阻塞赋值。时序always块里必须用非阻塞赋值<=。看这个经典例子:
always @(posedge clk) begin a <= b; b <= a; end非阻塞赋值下,两个寄存器在同一时钟沿互换数据。如果换成阻塞赋值,a会先变成b的值,然后b再变成新的a值,结果两个寄存器保存了同一个值,交换失败。硬件上触发器本身就是并行采样,非阻塞赋值恰好模拟这种并行性。新手如果在这块卡很久,只要记住:时序逻辑用非阻塞,组合逻辑用阻塞,先写对再谈优化。
2. 状态机:把“流程”翻译成电路的语言
2.1 状态机的三个构成要素
状态机是一种建模工具,用来描述系统在不同阶段之间的迁移以及每个阶段的行为。任何可以用“当前状态+输入 → 下一状态+输出”描述的流程,都可以用状态机表达。FPGA里的状态机由三部分构成:状态寄存器保存当前处于哪个状态;次态组合逻辑根据当前状态和输入计算下一个状态;输出逻辑根据当前状态和输入产生输出。这三部分正好对应三段式代码的骨架,也是电路最自然的划分。
新手最常见的误区是跳过画状态图直接写代码。有些简单模块不画图也能写,但只要状态数超过5个,不画图很容易漏转移条件,甚至写出不可达状态。我习惯把状态画成圆圈,用箭头标记转移条件和输出,画完图之后写代码就是按图翻译,基本不会错。状态图画得好不好,直接决定代码写起来顺不顺。一个状态机如果图画得乱,代码一定乱。
2.2 Moore还是Mealy?不是单选题
Moore机输出只和当前状态有关;Mealy机输出同时取决于当前状态和输入。大白话讲,Moore是“慢性子”,必须等时钟沿进入新状态后输出才变;Mealy是“急性子”,输入一变输出立刻跟着变,不用等时钟。
实际怎么选?我通常按这张表判断:
| 特点 | Moore | Mealy |
|---|---|---|
| 输出决定因素 | 当前状态 | 当前状态 + 输入 |
| 输出变化时刻 | 时钟沿后 | 输入变化后立即 |
| 抗毛刺能力 | 强 | 弱 |
| 状态数量 | 通常较多 | 可能更少 |
| 典型应用 | 协议状态机、外部输出 | 边沿检测、快速判断 |
我的建议是新手默认用Moore,尤其输出要驱动外部设备时。Mealy虽然能少几个状态,但输出是组合逻辑直接生成,输入上任何一个毛刺都可能直接出现在输出上。如果采用Mealy又想让输出干净,通常会在输出端再接一级寄存器打一拍,本质上回到类似Moore的时序输出。
2.3 状态编码:独热码、二进制码的实际取舍
状态编码有三种常用方式。二进制编码用最少的触发器,8个状态只需要3个寄存器,但状态跳转需要比较逻辑,组合逻辑较长;独热码每个状态对应一个寄存器,8个状态要8个触发器,但次态逻辑极简单,判断“是否处于某个状态”只需要看对应的寄存器是否为1,组合路径短,速度快;格雷码则在相邻状态之间只有一位变化,适合状态按顺序循环走的场景,能减少翻转次数,降低动态功耗和毛刺。
FPGA里的触发器资源通常比LUT充足,很多高速设计反而优先选择独热码,用触发器换时序收敛。如果状态数在十几二十个以内,独热码的压力不大。状态数多到几十个时,再考虑二进制编码,否则寄存器消耗会很难看。
选好编码后,用localparam定义状态常量,不要用define。localparam只在模块内生效,不会污染全局命名空间。同时每个状态机都要写default分支,防止非法状态卡死,这个下一章会展开讲。
3. 三段式状态机实战:序列检测器从0到1
3.1 为什么要拆三段
状态机代码常见三种写法:一段式、两段式、三段式。一段式把所有逻辑写在一个always @(posedge clk)块里,状态跳转和输出混在一起,写起来快,但可维护性很差;两段式把状态寄存器和次态逻辑分开,清晰一些,但输出逻辑要么放在状态块里,要么单独组合逻辑,边界仍然不够干净;三段式则把次态组合逻辑、状态寄存器、输出逻辑分成三个独立部分,每一段职责单一,调试时定位问题很快。
三段式还有一个实际好处:输出逻辑可以独立选择组合输出或寄存器输出,而不影响状态跳转部分。比如需要即时反应的标志,第三段就写组合逻辑;需要稳定寄存的输出,就改成always @(posedge clk)。两段式想这样做,很容易把状态寄存器块改乱。对新手来说,直接学三段式是性价比最高的路径。
3.2 先画状态转移表,再写三段逻辑
以“检测1011重叠序列”为例。先定义状态含义:
| 状态 | 含义 |
|---|---|
| IDLE | 等待第一个1 |
| S1 | 已经收到1 |
| S2 | 已经收到10 |
| S3 | 已经收到101 |
| S4 | 已经收到1011,拉高detect |
状态转移关系如下:
| 当前状态 | 输入0 | 输入1 |
|---|---|---|
| IDLE | IDLE | S1 |
| S1 | S2 | S1 |
| S2 | IDLE | S3 |
| S3 | IDLE | S4 |
| S4 | S2 | S1 |
这个表是整套代码的灵魂。S4收到1要跳到S1,因为刚刚结束的1011后面紧跟的1可以看作新序列的第一个1;S4收到0要跳到S2,因为末尾两位“10”可以作为新序列的“10”。如果做非重叠检测,S4收到任意输入都回IDLE,把这两行改成默认跳IDLE就行。
为什么要用这张表而不是直接写代码?因为代码很容易写错,但表格是按照“当前在哪里、来了什么、要去哪里”的逻辑一层层推出来的。表格检查一遍,每个转移条件都核对过,后面写代码就只是翻译工作。
3.3 完整Verilog代码逐行讲解
完整模块如下:
module seq_detect_1011 ( input wire clk, input wire rst_n, input wire bit_in, output reg detect ); localparam IDLE = 3'd0, S1 = 3'd1, S2 = 3'd2, S3 = 3'd3, S4 = 3'd4; reg [2:0] state; reg [2:0] next_state; // 第一段:次态组合逻辑 always @(*) begin next_state = state; case (state) IDLE: next_state = bit_in ? S1 : IDLE; S1: next_state = bit_in ? S1 : S2; S2: next_state = bit_in ? S3 : IDLE; S3: next_state = bit_in ? S4 : IDLE; S4: next_state = bit_in ? S1 : S2; default: next_state = IDLE; endcase end // 第二段:状态寄存器 always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= IDLE; else state <= next_state; end // 第三段:输出逻辑(Moore) always @(*) begin detect = 1'b0; if (state == S4) detect = 1'b1; end endmodule逐行说明几个容易被忽略的点。
模块声明里output reg detect是因为第三段用always块给它赋值。只要信号在always里被赋值,在Verilog 2001里就应该声明成reg,哪怕它综合出来是组合逻辑。这个问题在面试题里经常出现。
第一段组合逻辑里,第一行先写next_state = state;,这是防止锁存器的重要习惯。如果某个case条件没覆盖到,next_state会保持原来的值,综合器就可能推断出锁存器。先赋默认值再写case,等于给所有漏分支兜底。
第二段用异步复位negedge rst_n,和板上常见的低有效复位一致。注意state只在复位或时钟沿发生变化,其他情况下它都是稳定的,这就是状态寄存器的职责。
第三段输出逻辑里,同样先给detect赋默认值0,再判断state == S4。这里没有用else,因为默认值已经处理了其他情况。这种“先默认,后覆盖”的组合逻辑写法,比每个分支都写detect = 1'b0干净得多。
3.4 仿真激励与测试要点
仿真测试是最快验证状态机的手段。testbench我习惯这样写:
`timescale 1ns/1ps module tb_seq_detect_1011; reg clk = 0; reg rst_n = 0; reg bit_in = 0; wire detect; always #5 clk = ~clk; initial begin rst_n = 0; repeat(3) @(posedge clk); rst_n = 1; @(posedge clk); bit_in = 1; @(posedge clk); bit_in = 0; @(posedge clk); bit_in = 1; @(posedge clk); bit_in = 1; // 第1次检测到1011 @(posedge clk); bit_in = 0; @(posedge clk); bit_in = 1; @(posedge clk); bit_in = 1; // 第2次,重叠检测 @(posedge clk); bit_in = 0; @(posedge clk); bit_in = 1; @(posedge clk); bit_in = 1; // 第3次 @(posedge clk); repeat(3) @(posedge clk); $finish; end seq_detect_1011 dut( .clk(clk), .rst_n(rst_n), .bit_in(bit_in), .detect(detect) ); endmodule这版testbench里,每次数据变化都放在@(posedge clk)之后,保证信号在时钟沿之后才改变,不会在建立时间内跳变。很多初学者习惯用#10 bit_in = 1;这种方式,但那样数据变化时刻和时钟沿是错开的,一旦时钟周期调整就得跟着改数字,很难维护。用@(posedge clk); bit_in = xxx;这种写法,数据沿严格跟着时钟走,仿真时序干净得多。
仿真时记得把state和next_state加到波形窗口。Vivado里可以把state信号的radix改成“State”,波形会直接显示IDLE、S1这类状态名,而不是数字。看到detect在进入S4后拉高,就说明状态机基本对了。
4. 状态机常见Bug与排查链路
4.1 组合逻辑漏分支导致锁存器
现象:仿真功能看起来正常,但综合时报告“Latch inferred”,或者上板后行为偶发异常。原因:组合always块里某些路径没有对全部输出变量赋值,综合器为了“记住”最后一个值,自动生成锁存器。
排查路径:先看综合日志,搜索Latch;再到综合后的schematic里找锁存器符号。代码层面,像前面那样在组合逻辑里先给目标赋默认值就能避免。这个习惯对三段式状态机的第一段和第三段都有效。尤其是第三段输出逻辑,如果只写了if (state == S4) detect = 1;而没有默认赋值,综合结果里极有可能多出一个锁存器,上板后输出波形会出现莫名其妙的保持现象。
4.2 输出时序不对:组合输出vs寄存输出
现象:外部逻辑采样detect时,有时能采到,有时采不到;或者整体逻辑晚了一个周期。原因:没搞清第三段输出是组合输出还是寄存器输出。组合输出在状态变化的同时跟随变化,会因输入毛刺产生窄脉冲;寄存器输出要等下一个时钟沿才更新,晚一拍但更稳定。
排查路径:先看波形,找出detect拉高的时刻和state进入S4的时刻差了多少。如果同一拍就拉高,就是组合输出;如果晚一拍,就是寄存器输出。再根据外部逻辑的时序要求决定用哪种。如果外部逻辑也用同源时钟采样,寄存器输出更稳妥。我曾因为一个状态机的输出没有打拍,直接送到外部ADC的片选信号上,结果毛刺导致采到了几次错误数据。后来在第三段改成一个简单的寄存器输出,问题立刻消失。
4.3 状态跑飞与非法状态处理
现象:状态机偶尔卡死,state停在奇怪数字(比如状态编码之外的7),不复位不恢复。原因:上电或外部扰动使状态寄存器进入非法状态,case没有default处理,状态机失去跳转方向。
排查路径:检查所有状态是否覆盖完整;检查复位逻辑是否可靠;如果在跨时钟域场景,先检查异步信号是否打了拍子。代码层面,default分支必须跳回IDLE或某个安全状态。如果需要更高的可靠性,可以再加一个“看门狗”机制,定时检查状态是否在合法集合内,不在就强制复位。不过对绝大多数项目来说,把case的default写对就已经解决了90%的问题。
4.4 跨时钟域与亚稳态:状态机遇到异步信号怎么办
现象:状态机输入来自按键或另一个时钟域,偶尔状态机出现不可复现的错误。原因:异步信号相对本地时钟不满足建立保持时间,触发器的输出进入亚稳态,下游逻辑可能采到错误值。
排查链路:先用逻辑分析仪或波形观察输入信号在时钟沿附近是否变化。如果是,就需要在进入状态机前做同步处理。最常见的方法是打两拍:
reg sync_ff1, sync_ff2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sync_ff1 <= 1'b0; sync_ff2 <= 1'b0; end else begin sync_ff1 <= async_in; sync_ff2 <= sync_ff1; end end wire async_in_syn = sync_ff2;打两拍后,亚稳态概率下降到绝大多数工程可以接受的水平。如果异步信号是短脉冲,还要再加边沿检测逻辑,否则状态机可能漏采。这些内容将来写跨时钟域专题时再展开,这里先知道这个坑就行。
5. 从序列检测器走向真实项目:用状态机拆解复杂交互
5.1 UART接收协议怎么拆状态
UART接收一帧数据的过程:空闲时RX线为高,起始位拉低一个位时间,然后是8个数据位,最后停止位恢复高。用状态机拆分,就是IDLE、START、DATA、STOP。和序列检测器不同,UART每个位持续多个时钟周期,所以不能只看某一拍的输入,还要配合计数器来确定当前处于位时间内部的哪个时刻,通常选择位中间采样,避开数据翻转附近的信号不稳定区。
这种“状态+计数器”的组合,是几乎所有串行协议状态机的通用套路。状态机负责流程切换,计数器负责时间度量。把两者拆开,代码层次非常清楚。我见过不少新手把计数器塞进状态机转移条件里,一个状态内部套各种计数判断,代码难读也难改。正确做法是状态机在某个状态里等待“计数器溢出”这个条件,计数器本身独立工作,到时间了就向状态机报告。
5.2 一个按键消抖状态机例子
回到开头的按键消抖。机械按键按下和释放过程中,电平会抖动10~20ms,不能直接采样。用状态机可以这样设计:IDLE等待初始状态,检测到键按下低电平后不马上确认,而是进入CHECK_PRESS并启动20ms计数;如果20ms内电平一直为低,确认按下,输出一次按键事件,进入CHECK_RELEASE;如果中途电平回高,返回IDLE。释放过程同理,进入CHECK_RELEASE后连续20ms为高才认为释放完成,回到IDLE。
状态转移表可以写成:
| 状态 | 条件 | 下一状态 |
|---|---|---|
| IDLE | 按键低 | CHECK_PRESS |
| CHECK_PRESS | 20ms内一直低 | 确认按下,进CHECK_RELEASE |
| CHECK_PRESS | 期间变高 | 回IDLE |
| CHECK_RELEASE | 20ms内一直高 | 回IDLE |
| CHECK_RELEASE | 期间变低 | 留在CHECK_RELEASE |
注意这里状态机只负责“确认按下/释放”,20ms定时可以由计数器完成,状态机在CHECK_PRESS期间查询计数器是否溢出即可。这种职责分离,比把计数逻辑直接写进状态条件里要好理解得多。我当时给读者改代码时,最重要的改动就是把消抖逻辑拆成这样,按键失灵的问题马上消失。
5.3 状态机与抖动的工程取舍
状态机虽好,但不是万能的。如果功能可以用一个简单计数器完成,比如LED流水灯,就不要套一个十几个状态的状态机。判断标准是:流程分支多不多、条件组合复不复杂、以后会不会经常改逻辑。分支多、条件杂,状态机的价值就体现出来;结构过于简单时,计数器反而更省资源更直观。
另一个取舍是状态粒度。UART接收可以用DATA一个大状态,内部用计数器数8次;也可以拆成DATA0到DATA7八个状态。前者明显更易维护。经验法则是:同一个状态下如果有重复循环的相同操作,用计数器;如果每个状态做的事情有本质区别,保持独立状态。这样写的状态机,既不会过于碎片化,也不会“一坨到底”。
最后再说一个我自己受用很久的习惯:写完状态机,先不要急着写testbench,用十分钟把状态转移图从头到尾走一遍,把每个箭头都编上号,然后对照代码检查每一个箭头是否都能落到case分支里。我早期好几次仿真通过、上板出错,最后都发现是状态图里漏了一个“输入为0留在原状态”的箭头,而代码默认保持了原状态,表面看起来没问题,可一旦组合逻辑出现毛刺,状态机就会乱跳。后来养成这个习惯,排查问题的时间少了非常多。
下一篇我准备聊FPGA的时序约束,也就是“为什么你的设计仿真正确却上不了高频”,有兴趣的话可以留言说说你卡在哪一步。这篇就先到这里,希望对你手里的状态机项目有帮助。