FPGA 逻辑设计与状态机:把“会亮的灯”变成“会干活的逻辑”
学FPGA这件事,“能点亮LED”只是入门仪式,真正让你从“会写Verilog语法”跨到“会做逻辑设计”的分水岭,是状态机。我自己的学习体会是:跑通一个流水灯、写好一个串口回环,都不难;难的是当逻辑需要“按流程走”、需要根据历史状态决定下一步行为时,脑子里很容易糊成一团。状态机就是解决这个问题的最标准、最通用的手段。这一章我会把状态机的建模思路、三段式写法、状态编码选型、常见故障排查一并理清楚,适合已经会Verilog基础语法、想开始做正经模块设计的FPGA入门读者。
1. 逻辑设计的分水岭:从“裸逻辑”到“有状态”
1.1 组合逻辑与时序逻辑到底差在哪
数字电路的基础知识里,逻辑电路被分成两大类:组合逻辑和时序逻辑。组合逻辑的输出只取决于当前输入,比如一个加法器:你给什么数,它就输出什么和,没有记忆能力。时序逻辑则不同,输出不仅看当前输入,还和之前的状态有关。FPGA里最核心的时序逻辑单元就是D触发器,它们能保存一位信息,无数个D触发器组合在一起,就构成了能“记住事情”的电路。
这里我常打一个比方:组合逻辑像一台不带存储的计算器,你输入什么,它算完就完事;时序逻辑像账本,你记录的每一笔流水都会影响后面怎么对账。状态机正好站在两者交汇处——它用一组寄存器保存当前状态,再用组合逻辑根据当前状态和输入决定下一步跳到哪个状态,本质上是给硬件赋予了“流程意识”。
很多初学者会把“时序逻辑”简单理解成“写了always @(posedge clk)的代码就是时序逻辑”,这是不对的。你写的always块里如果只是对输入做赋值,没有用到寄存器反馈,它综合出来仍然是组合逻辑。真正让电路有状态的是“反馈”:寄存器的输出经过组合逻辑之后,又回到寄存器的输入。状态机的结构,就是把这个反馈回路画得明明白白,让设计者不至于写出一团惯性循环。
1.2 状态机是硬件里的“流程控制”
在写软件时,复杂的顺序流程我们会用状态机来组织,比如一个通信协议栈、一个任务调度器。硬件没有CPU那种“从内存取指令、顺序执行”的机制,它天然是并行的,是并发的。可我们总有需要“一步一步来”的场景:先等按键,再配置寄存器,再等待应答,再做数据处理。这时就需要状态机站出来,把流程的“当前进展”保存在状态寄存器里,再根据输入跳转到合适的阶段。
没有状态机,强行用一大坨if else把流程写到一起,结果往往是组合逻辑层级太深、时序收敛困难、代码没法维护。有了状态机,设计就变成了一张清晰的转移图:每个状态是什么、什么条件跳走、跳到哪里、在这个状态里做什么输出,一目了然。你去看市面上的FPGA项目,串口控制器、SPI主机、I2C从机、DDR控制器、图像处理流水线,几乎没有一个复杂模块能绕开状态机。
这一章我会把重点放在“三段式状态机”这个FPGA工程里最主流的写法上,然后带着大家从一个按键长短按检测的完整案例出发,梳理状态机的建模思路和仿真调试方法。
2. 三段式状态机:写法、编码与选型
2.1 一段式、二段式、三段式,到底在说什么
状态机的代码组织方式,业界常按always块的数量分成一段式、二段式、三段式。很多新手看到这几个名词一脸懵,我换个方式说明:状态机的每一拍要干三件事——记住现在在哪个状态、决定下一步去哪个状态、根据当前状态给出输出。一段式把三件事写在一个always块里;二段式把“状态迁移”和“次态计算”分开;三段式则把状态迁移、次态计算、输出三块尽量拆开。
一段式最简单,小状态机图省事可以用,但缺点很明显:状态寄存器和输出逻辑搅在一起,可读性差,而且输出经常是组合逻辑直接给出去,容易产生毛刺,也不好加寄存器打拍。二段式常见于很多老教程,第一段时序逻辑专门做状态迁移,第二段组合逻辑做次态计算和输出,比一段式清晰一些,但输出逻辑夹在组合逻辑块里,该打拍的时候不那么方便。三段式是我在实际项目里最推荐的:第一段时序逻辑专门把next_state寄存成curr_state,第二段纯组合逻辑只算next_state,第三段做输出,可以根据需要选择组合输出或者寄存器打拍输出。
用生活化的方式理解三段式:它相当于把“我现在在哪”“我下一步去哪”“到了之后该干什么”三个问题彻底分开回答,每个always块只回答一个问题,后头排查问题的时候非常痛快。
2.2 标准三段式状态机模板
看代码比看一百句解释都有效。一个最简单的三状态循环示例:
localparam S_IDLE = 2'd0; localparam S_RUN = 2'd1; localparam S_DONE = 2'd2; reg [1:0] curr_state; reg [1:0] next_state; // 第一段:状态迁移 always @(posedge clk or negedge rst_n) begin if (!rst_n) curr_state <= S_IDLE; else curr_state <= next_state; end // 第二段:组合逻辑计算次态 always @(*) begin next_state = curr_state; // 默认保持当前状态 case (curr_state) S_IDLE: if (start) next_state = S_RUN; S_RUN : if (done) next_state = S_DONE; S_DONE: next_state = S_IDLE; default: next_state = S_IDLE; endcase end第二段里第一行next_state = curr_state是我每次都强调的细节,它保证了在没有任何跳转条件命中时,次态就是当前状态。如果漏了这行,组合逻辑的case又没覆盖全,综合工具会推断出锁存器,轻则多出多余硬件,重则状态机行为完全不对。case里的default分支同样不能省,它是状态机在非法状态下“翻车”时的最后救命稻草。
2.3 状态编码怎么选:独热码、二进制码、格雷码
状态编码是新手容易忽略但实际很关键的问题。编码方式直接影响到资源占用、时序收敛和功耗。最常用的三种是独热码、二进制码和格雷码。
独热码(one-hot)每个状态只有一个bit为1,其他全0。比如4个状态就是4个寄存器位:1000、0100、0010、0001。它的优势是译码逻辑极简单,状态判断就是判断某一位是否为1,组合逻辑路径短,时序上比较容易收敛。FPGA本身寄存器资源丰富,状态机一般状态数量不多,所以Xilinx、Altera的推荐做法都是独热码。缺点是状态位多,6个状态要6个触发器,但FPGA里触发器多得不值钱,这个劣势几乎可以忽略。
二进制码用最少的触发器位表示状态,比如4个状态只需要2位:00、01、10、11。这种编码省寄存器,但状态译码的组合逻辑复杂一些,状态一多时序压力就上来,更适合CPLD或者寄存器资源紧张的低功耗场景。格雷码的特点是在两个相邻状态之间切换时只有1个bit变化,能减少翻转功耗,常用于状态切换频繁的省电设计,但状态不按顺序跳转时优势就没了。
新手我直接给结论:在FPGA里做状态机,无脑用独热码,把localparam写成2'b01、2'b10这种形式,或者干脆让综合工具自己去选,用综合属性控制。等你将来做低功耗芯片设计再回头研究格雷码也不迟。
3. 状态机建模实战:从需求分析到可综合代码
3.1 先画状态转移图,再写代码
我见过太多人一上手就开写代码,结果改来改去,最后连自己都说不清状态怎么跳。正确习惯是先画状态转移图。画图的过程其实就是在做需求分析:输入信号有哪些,输出信号有哪些,系统要经历哪些阶段,每个阶段的退出条件是什么。
画的时候有几个点要特别注意。一是所有输入条件要考虑完整,不能只画正常路径,还要画异常路径和复位路径。二是每个状态下的输出要想清楚,是电平信号还是脉冲信号,输出何时有效何时无效。三是检查有没有条件冲突的情况,比如同一个状态下两个跳转条件同时为真,case的优先级是按书写顺序来的,但你自己得先想清楚优先级。
画状态图的工具有没有无所谓,纸上画也行,draw.io也行,关键是画完给人看能讲清楚。我在项目里要求所有状态机都先画图评审再写代码,这一个习惯至少帮我砍掉了三成逻辑bug。
3.2 状态划分的经验与方法
状态划分没有绝对标准,但有几条经验可供参考。第一,每个状态尽量只做一件事,把“等数据”和“处理数据”拆成两个状态,这样状态职责单一,代码干净,调试也方便。第二,不要为了省状态把流程强行压在一起,比如串口接收的“接收数据位”和“接收停止位”拆开写,比合并成一个状态里用计数器区分要清晰得多。第三,错误处理也要画进状态图,比如等待外部应答超时、校验失败、总线错误,这些异常路径最好都有对应状态,否则状态机就只能卡死在正常流程里。
一个判断状态划分好不好用的简单方法:如果某个状态下有两三个互相纠缠的判断条件,而且还要依赖一个额外计数器才能区分阶段,说明状态划分太粗了。反过来,如果状态数量多到有几十个、转移图和蜘蛛网一样,说明划分又太细,需要适当合并。
3.3 完整案例:按键短按和长按检测状态机
我选这个案例的原因是它足够贴近入门场景,而且覆盖了状态机常用的几乎所有要素:输入电平变化、计时、阈值判断、脉冲输出。需求是这样:一个按键低有效,短按(按下持续时间小于200ms)松开后输出一个单周期short_pulse;长按(按下持续时间达到200ms)输出一个单周期long_pulse,并在松开后回到待机状态。
先把状态图想清楚,我设计了3个状态。S_IDLE空闲态,等待按键按下;S_COUNT计时态,持续统计按下时间,如果中途松开就根据计时结果判定短按并回到空闲态,如果计时达到阈值就跳转到S_LONG;S_LONG长按态,表示已经确认是长按,等待按键释放后输出长按脉冲并回到空闲态。状态图想清楚了,代码才不会乱。
三段式代码实现如下:
module key_detect_fsm ( input wire clk, input wire rst_n, input wire key_n, output reg short_pulse, output reg long_pulse ); localparam S_IDLE = 2'd0; localparam S_COUNT = 2'd1; localparam S_LONG = 2'd2; parameter LONG_CNT = 32'd4_000_000; // 200ms @ 20MHz reg [1:0] curr_state; reg [1:0] next_state; reg [31:0] hold_cnt; // 第一段:状态迁移 always @(posedge clk or negedge rst_n) begin if (!rst_n) curr_state <= S_IDLE; else curr_state <= next_state; end // 第二段:次态组合逻辑 always @(*) begin next_state = curr_state; case (curr_state) S_IDLE: begin if (!key_n) next_state = S_COUNT; end S_COUNT: begin if (key_n) next_state = S_IDLE; else if (hold_cnt >= LONG_CNT - 1) next_state = S_LONG; end S_LONG: begin if (key_n) next_state = S_IDLE; end default: next_state = S_IDLE; endcase end // 计数器更新:S_COUNT下递增,S_IDLE清零 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin hold_cnt <= 32'd0; end else begin case (curr_state) S_IDLE: hold_cnt <= 32'd0; S_COUNT: hold_cnt <= hold_cnt + 1'b1; default: hold_cnt <= hold_cnt; endcase end end // 第三段:输出打拍,单周期脉冲 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin short_pulse <= 1'b0; long_pulse <= 1'b0; end else begin short_pulse <= 1'b0; long_pulse <= 1'b0; case (curr_state) S_COUNT: begin // 释放时计时不足200ms,判定为短按 if (key_n && (hold_cnt < LONG_CNT)) short_pulse <= 1'b1; end S_LONG: begin // 长按确认后,等待释放并给脉冲 if (key_n) long_pulse <= 1'b1; end default: ; endcase end end endmodule解释几个关键设计点。计数器更新的位置我特意单独写了一个always块,没有并进状态迁移块里,这样看起来更清楚。第三段输出用时序逻辑打了一拍,每个周期先清脉冲再按状态置位,这样只要进入对应条件,输出就稳定保持一个时钟周期的高电平,不会出现组合逻辑的尖峰毛刺。
3.4 仿真验证要点
写好的状态机一定要先仿真再上板。testbench里至少覆盖4个场景:短按(key_n拉低50个时钟周期再拉高,观察short_pulse);长按(key_n拉低超过LONG_CNT个时钟周期,观察进入S_LONG状态并在释放时产生long_pulse);异步复位(在任意状态下拉低rst_n,确认回到S_IDLE);快速连按(短按后立刻再次按下,确认状态机能正常回到S_IDLE并再次进入)。
在Vivado或者ModelSim里仿真时,最舒服的一步是把curr_state信号设置为状态名显示。Vivado的Waveform窗口里右键信号,选择Radix,然后改成State Enumeration或ASCII,就能直接看到S_IDLE、S_COUNT这些名字,而不是一堆二进制数。这个操作看起来小,实际调试效率差别非常大,强烈建议一上来就养成习惯。
上好板验证的时候,也要在板上留出观测信号。把short_pulse和long_pulse接到两个LED上,或者用ILA抓内部信号,按键按下去拉电源,如果LED响应不对,优先打开ILA看curr_state的跳转情况和hold_cnt计数是否符合预期。
4. 状态机常见故障与排查实录
4.1 状态跑飞:非法状态与default兜底
状态机跑飞是新手最常见的噩梦:表现是逻辑偶尔乱跳、输出乱来、复位后恢复,看起来完全随机。追根到底一般是进了没有定义的状态。比如独热码状态机有两个状态位,理论上有4种组合,可你只定义了3个状态,多出来的那个组合就是非法状态。信号受干扰、跨时钟域产生亚稳态、复位释放时序不好,都有可能把状态寄存器打进非法状态里。
解决手段分两层。第一层是代码里必须有default,这个前面已经强调过。default不仅仅是为了防止综合出锁存器,更是让状态机在遇到未知状态时有路可退。第二层是状态机外部加保护机制,正规设计里常用“看门狗”思路:核心状态机长时间没有按预期跳出某个状态,就强制复位一次。实现上可以在状态机旁边加一个超时计数器,进入某些状态就开始计时,超时后拉一个错误信号或者直接复位状态机。
我自己调试过一个UART接收模块,偶尔出现收到错误字节然后整个接收流程卡死的情况,最后就是用超时计数+状态机复位搞定的。这个思路在通信类模块里特别重要,因为总线上什么错误都可能发生,状态机必须能自恢复。
4.2 输出毛刺:仿真正常但板上误触发
仿真正常、上板偶发问题,这类问题最让人头疼。状态机里常见的脏东西就是组合逻辑输出毛刺。原因是状态机的状态寄存器在时钟边沿同时翻转,组合逻辑译码的路径长短不一,输出信号就会在极短时间里出现不稳定的中间值。如果你的输出直接驱动异步复位、时钟使能或者触发其他模块的敏感信号,毛刺就可能被捕捉到,造成偶发误动作。
解决办法非常简单:输出打一拍。也就是第三段输出不要用组合逻辑直接给,而是全部用always @(posedge clk)寄存后输出。代价是多一个时钟周期的延迟,但只要设计时把这个延迟考虑进时序里,稳定性收益远远大于代价。上面例子里的输出写法就是打了一拍,这也正式三段式状态机优于二段式的一点:输出天然适合寄存器化。
4.3 跨时钟域信号直接进状态机
FPGA设计里,外部按键、外部传感器的信号基本都是异步信号,和FPGA内部时钟不是一个时钟域。如果你直接把外部信号接到状态机的跳转条件上,存在极大的亚稳态风险。亚稳态的意思是触发器的建立保持时间没有得到满足,输出在0和1之间来回摆动,最终稳定成哪个值不确定。因为你没办法保证信号翻转沿和内部时钟边沿的关系,所以这部分风险无法靠代码完全消除。
工程上的标准做法是异步信号先经过同步器。最简单的同步器就是两级D触发器串联,俗称“打两拍”。第一拍把异步信号同步到本地时钟域,第二拍减小亚稳态传播概率。打两拍之后,再进行边沿检测或者电平判断,然后再作为状态机的跳转条件使用。对按键这种机械信号,还要在打两拍之后加软件消抖或状态机消抖。我在实际排查按键误触发问题时,发现很多时候不是状态机逻辑写错了,而是外部信号没有先同步进来。
4.4 排查效率翻倍的调试技巧
状态机调试不顺利,多数原因是观测手段太弱。如果你上板调试时只靠看输出引脚,那和蒙着眼睛修电路差不多。我的经验是,只要FPGA资源够用,就把ILA(集成逻辑分析仪)用起来,在综合时把curr_state、next_state、关键输入信号和关键计数器都拉进探针,触发条件设置为可疑状态,捕获一段实时波形。
ILA的使用有一个容易踩坑的点:默认触发位置在采样窗口的最中间,你抓到的波形不一定包含触发发生前的数据。调试时要记得把触发位置改到窗口末尾,这样能看到触发前的完整历史,更容易定位问题来源。另外,多个信号一起观察时,把curr_state的radix改成枚举显示,把计数器改成无符号十进制显示,波形里的信息密度完全不一样。
还有一个很实用的习惯,就是在状态机的第二段模块里保留一个state_dbg寄存器,把curr_state透明输出到一个空闲引脚或寄存器,方便逻辑分析仪直接钩。真实项目中这个习惯能帮你省很多事,尤其在没有ILA使用权限的小型FPGA场合。
写在最后:状态机是FPGA逻辑设计的“成人礼”
从开始学FPGA到现在,我一直觉得状态机是第一个真正需要你“设计”的东西。点亮LED是照着资料抄,跑通UART是拼接别人的代码,但当你独立把一个需求拆成状态、画成状态图、写成可综合的三段式代码,并且在上板调通的那一刻,你对“逻辑设计”的理解才算真正落地。状态机不是Verilog特有的概念,它从数字电路设计一直延伸到嵌入式软件架构,你会一次次见到它的身影,所以趁入门阶段把它弄扎实,后面整个硬件思维都会顺很多。
最后分享一个我踩过几次坑才养成的习惯:写完状态机不要急着综合上板,先在纸上把状态转移图完整走一遍,尤其把“异常分支”走一遍。仿真验证时宁可多写几个定向测试场景,也别只跑一条happy path。逻辑设计这个行业,绝大多数难修的问题都是因为在设计阶段少问了一句“如果这边出现异常怎么办”。
这一节的内容先聊到这儿,下一部分我们接着把状态机放进一个完整的FPGA工程里去,到时候会涉及顶层模块划分、时序约束和板上实测,有兴趣的话可以继续跟着往下走。