1. 从“其他状态机题目”说起:为什么这类题最容易翻车
“其他状态机题目”这个标题乍一看很不起眼,像是某个系列教程里凑数的一篇。但真正做过Verilog笔试题、面试题或者课程设计的人都知道,状态机相关的“其他题目”往往才是区分水平的地方——交通灯、序列检测、电梯控制这些经典题大家都背过,反而是那些描述含糊、约束刁钻的“其他”题目,最能暴露一个人对状态机设计流程的理解深度。
我在带新人和复盘自己的笔试题时发现一个规律:凡是状态机题目做错的人,错误几乎不集中在“状态图画不出来”上,而是集中在几个固定的地方——状态编码选错导致时序违例、输出逻辑写成组合逻辑毛刺、复位策略没想清楚、以及最致命的:把“能仿真出波形”当成“设计正确”。这几个坑,恰恰是“其他状态机题目”最爱考的点,因为它们不像序列检测器那样有标准答案可以对照,你必须自己判断设计是否合理。
这篇内容面向的是已经写过几个状态机、但一遇到非典型题目就心里没底的朋友。我会把这类题目拆成几个可复用的判断维度:题目描述里哪些信息决定了状态编码方式、输出到底该用Moore还是Mealy、复位和默认分支怎么写才不留隐患、以及仿真通过之后还要检查什么。每个维度我都会给出为什么这样选的理由,而不是甩一段代码让你抄。代码我会用Verilog-2001风格写,工具链以常见的仿真和综合环境为准,不绑定任何特定平台。
先说一个我自己的教训。早年做一道“按键长短按识别”的状态机题,功能仿真全对,上板之后偶尔会多计一次。查了很久才发现是按键抖动期间状态机在IDLE和PRESS之间来回跳,而我的输出是直接挂在状态上的组合逻辑,毛刺被下游计数器采到了。这个问题在纯功能仿真里根本看不出来,因为测试激励是理想的。从那以后我看任何状态机题目,第一反应不再是“状态怎么画”,而是“输出会不会抖、复位从哪来、时钟域干不干净”。这三个问题问完,题目的一半坑就已经避开了。
2. 题目描述里的隐藏信息:先判断它到底在考什么
2.1 从文字约束反推状态编码策略
很多人拿到题目直接开始画状态图,画完随手写parameter IDLE = 3'd0;这种二进制编码,觉得能跑就行。但“其他状态机题目”经常在文字里埋了线索,比如“要求单比特错误可检测”“状态数较多但转移稀疏”“要求复位后一个周期内进入确定状态”。这些描述直接决定了你该用二进制、格雷码还是独热码。
我一般按下面这个表来判断,这是多年做题和实际项目里总结出来的经验值,不是教科书上的绝对结论,但覆盖了绝大多数场景:
| 题目特征 | 推荐编码 | 理由 |
|---|---|---|
| 状态数 ≤ 8,转移密集 | 二进制或格雷码 | 触发器少,面积优先 |
| 状态数 8~16,时序紧张 | 独热码(One-hot) | 组合逻辑浅,速度快 |
| 状态转移呈环状/顺序 | 格雷码 | 相邻状态只变一位,降低翻转功耗与毛刺风险 |
| 题目提到“安全”“容错”“单比特纠错” | 独热码 + 合法性检查 | 非法状态易识别 |
| FPGA 目标,LUT 资源充足 | 独热码 | 综合工具对独热码优化好 |
为什么独热码在时序紧张时更优?因为它的次态逻辑本质上是“每个状态一个与项”,组合逻辑层数基本固定为两级,而二进制编码的次态逻辑会随状态数增加而变深。代价是触发器数量翻倍,但在FPGA里触发器相对富裕,这个交换通常是划算的。反过来,如果是ASIC或者资源受限的CPLD,二进制编码就更合适。这个取舍必须结合题目给的“目标器件”信息来判断,题目没说就按通用原则选,但要在注释里写清楚你的假设。
2.2 输出到底挂状态还是挂转移:Moore与Mealy的实战取舍
教科书上讲Moore输出只与当前状态有关、Mealy输出与状态和输入都有关,然后给个波形对比就结束了。但实际做题时,真正的判断标准是:输出需不需要在输入变化的同一拍就响应。
举个典型场景:题目要求“检测到序列101后立即拉高标志,且标志只维持一个周期”。如果输入是串行逐位进入,用Moore实现的话,标志会在进入“已检测”状态后的下一拍才拉高,晚了一拍;用Mealy则可以在最后一位1到来的同一拍输出。这种“立即”二字就是Mealy的信号。但如果题目说“检测完成后进入等待状态,在等待状态期间输出有效”,那就是Moore更自然,因为输出需要稳定维持。
我的经验是:优先用Moore,除非题目明确要求输出与输入同拍变化。原因是Moore的输出天然寄存器化(如果输出直接取自状态寄存器),没有组合毛刺问题,时序分析也简单。Mealy虽然响应快,但输出是组合逻辑,一旦输入有抖动或者来自异步源,毛刺直接传到下游。前面说的按键长短按那个坑,本质就是把Mealy式输出用在了有抖动的输入上。
如果非要用Mealy,我的做法是给Mealy输出再打一拍寄存器,变成“寄存后的Mealy”,牺牲一拍延迟换取输出干净。这在题目没有严格延迟要求时是稳妥选择,但要在设计说明里注明这一拍延迟,避免评审时被当成错误。
2.3 复位策略:同步还是异步,题目不会明说但会考
“其他状态机题目”几乎必考复位,但很少明说用同步还是异步。我的默认选择是异步复位、同步释放,也就是复位信号直接进触发器的异步复位端,但在复位释放路径上加两级同步器。理由很实在:纯异步复位释放时如果刚好靠近时钟沿,可能进入亚稳态;纯同步复位又要求复位信号宽度足够被时钟采到,短脉冲会漏掉。异步复位同步释放兼顾了两者。
代码上大概是这样:
// 复位同步器:异步置位,同步释放 reg rst_sync_r1, rst_sync_r2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rst_sync_r1 <= 1'b0; rst_sync_r2 <= 1'b0; end else begin rst_sync_r1 <= 1'b1; rst_sync_r2 <= rst_sync_r1; end end wire rst_sync_n = rst_sync_r2;然后状态机用rst_sync_n做异步复位。这里有个细节很多人忽略:复位同步器本身要用同一个时钟,如果状态机跨时钟域,每个域都要有自己的同步器。题目如果只给一个时钟,那简单;如果给了两个时钟还要求状态机交互,那就是在考跨时钟域,得另加握手或异步FIFO,这已经超出单纯状态机的范畴,但“其他题目”恰恰爱这么综合着考。
注意:如果题目明确要求“纯同步复位”或综合库不支持异步复位,那就老老实实写同步复位,但要在复位信号进入状态机前做脉宽展宽,保证至少被采到一个时钟沿。
3. 状态图到代码的落地:三段式为什么仍然是首选
3.1 一段式、两段式、三段式的真实差异
网上关于状态机写法的争论很多,一段式把状态转移和输出全塞一个always块,两段式把时序和组合分开,三段式再拆出输出寄存。我做过对比实验,在同一个中等复杂度状态机(约10个状态、转移较密)上分别用三种写法综合,结果差异很明显:
- 一段式:代码最短,但状态寄存器和输出逻辑混在一起,综合后关键路径往往落在“次态计算+输出”这条链上,时序余量最小,而且调试时很难单独观察次态。
- 两段式:时序块只负责状态寄存,组合块算次态和输出。可读性好,但输出如果是组合的,仍有毛刺风险。
- 三段式:时序块存状态、组合块算次态、再一个时序块寄存输出。多了一级寄存器延迟,但输出干净、时序路径短、波形上状态和输出一一对应,调试极其方便。
我的结论是:除非题目对面积或延迟有极端要求,否则三段式是默认答案。多一级寄存器在绝大多数题目里无所谓,换来的是可维护性和抗毛刺能力。特别是“其他状态机题目”经常要求你解释设计思路,三段式的结构本身就能说明你考虑过输出稳定性。
3.2 一个可复用的三段式骨架
下面这个骨架我用了很多年,改改状态和转移就能套到大部分题目上。注意我特意把“默认次态”和“默认输出”写在组合块开头,这是防止锁存器的关键:
module fsm_template ( input wire clk, input wire rst_n, input wire din, output reg dout ); // 状态编码:独热码,便于识别非法状态 localparam S_IDLE = 4'b0001, S_CHECK = 4'b0010, S_HOLD = 4'b0100, S_DONE = 4'b1000; reg [3:0] cur_state, nxt_state; // 第一段:状态寄存(异步复位同步释放后的复位信号) always @(posedge clk or negedge rst_n) begin if (!rst_n) cur_state <= S_IDLE; else cur_state <= nxt_state; end // 第二段:次态组合逻辑,先给默认值防锁存 always @(*) begin nxt_state = cur_state; // 默认保持 case (cur_state) S_IDLE: if (din) nxt_state = S_CHECK; S_CHECK: nxt_state = din ? S_HOLD : S_IDLE; S_HOLD: nxt_state = S_DONE; S_DONE: nxt_state = S_IDLE; default: nxt_state = S_IDLE; // 非法状态回安全态 endcase end // 第三段:输出寄存,输出只依赖当前状态(Moore) always @(posedge clk or negedge rst_n) begin if (!rst_n) dout <= 1'b0; else begin case (nxt_state) // 注意:用次态寄存,输出与状态同步 S_DONE: dout <= 1'b1; default: dout <= 1'b0; endcase end end endmodule这里有个容易争议的点:第三段我用了nxt_state而不是cur_state来判断输出。这样做的效果是输出比“基于cur_state寄存”早一拍,整体延迟和“基于cur_state的组合输出”相同,但输出是寄存过的、无毛刺。如果你希望输出严格滞后状态一拍,把nxt_state换成cur_state即可。两种都对,取决于题目对延迟的描述,但一定要在注释里写明白你选的是哪种,这是评审时加分的地方。
3.3 非法状态处理:default分支不是摆设
独热码有个天然优势:合法状态只有一个比特为1,非法状态很容易识别。但很多人写default: nxt_state = S_IDLE;就完事了,觉得已经处理了。实际做题时我会多做一步——在仿真里故意注入非法状态,看状态机能不能在一个周期内回到安全态。因为综合工具可能对default做优化,尤其是当它认为非法状态“不可能出现”时。稳妥做法是加一个显式的合法性判断:
wire state_valid = (cur_state == S_IDLE) || (cur_state == S_CHECK) || (cur_state == S_HOLD) || (cur_state == S_DONE);然后在次态逻辑里,if (!state_valid) nxt_state = S_IDLE;放在case之前。这样即使综合工具想优化,这个判断也会保留。二进制编码下非法状态更多,这个检查更有必要。题目如果提到“可靠性”“容错”,这就是必须写的。
4. 仿真通过不等于设计正确:上板前必须补的检查
4.1 功能仿真覆盖不到的三类问题
我见过太多人(包括早期的自己)跑完一个漂亮的波形就交差,结果上板出问题。功能仿真用的是理想激励,覆盖不到三类真实问题:毛刺、复位释放时序、以及跨时钟域。针对状态机题目,我固定会补三个检查。
第一,输出毛刺检查。如果输出是组合逻辑,在仿真里把输入在时钟沿附近做微小抖动,观察输出有没有窄脉冲。有的话就说明需要寄存输出。这个检查用简单的testbench就能做,不需要上板。
第二,复位释放检查。把复位释放时刻故意放在时钟上升沿附近(比如延迟几个纳秒),看状态机是否进入确定状态。如果用的是纯异步复位且没做同步释放,这里很可能出现状态不确定。修法就是前面说的复位同步器。
第三,状态覆盖检查。在testbench里加一段覆盖率统计,确认每个状态都被访问过、每条转移都走过。我习惯用简单的计数器数组记录每个状态的进入次数,仿真结束时打印出来。没被覆盖到的转移,要么是激励没给到,要么是状态图本身有冗余,两种情况都值得回头看。
4.2 用断言把“隐含假设”显式化
“其他状态机题目”的描述往往有隐含假设,比如“同一时间只有一个请求有效”“两个输入不会同时为高”。这些假设如果不写进代码,评审时没人知道你考虑过;如果假设错了,上板就是灾难。我的做法是用简单的断言(assertion)把假设写出来,仿真时自动检查:
// 假设:req_a 和 req_b 不会同时有效 always @(posedge clk) begin if (rst_n) assert (!(req_a && req_b)) else $error("req_a and req_b asserted simultaneously!"); end这段代码在支持SystemVerilog断言的环境里直接可用;如果只支持纯Verilog,可以用if (...) $display("ERROR...")代替。关键是养成习惯:题目里每一句“假设”“要求”“不会发生”,都对应一条检查。这不仅是做题技巧,实际项目里也是防止接口误用的有效手段。
4.3 综合后的资源与时序复核
功能对了、仿真过了,还有一步很多人跳过:看综合报告。状态机题目经常考“用最少的资源实现”或者“满足某频率”,这时候综合报告就是判卷依据。我会重点看三个数:触发器数量(是否和状态编码预期一致)、LUT数量(次态逻辑复杂度)、以及关键路径延迟(是否满足题目给的时钟约束)。
如果触发器数量比预期多很多,多半是输出寄存或复位逻辑引入了额外寄存器;如果LUT数量异常大,可能是状态编码选错导致次态逻辑膨胀,这时候换成独热码往往能降下来。关键路径如果卡在次态计算上,可以考虑把复杂的条件判断拆成流水,或者调整编码让常用转移路径变短。这些优化在题目里不一定要求,但写进设计说明里,能体现你不是“能跑就行”的水平。
5. 几类高频“其他题目”的拆解思路
5.1 带优先级的多请求仲裁
这类题目的描述通常是“三个请求同时到来时按固定优先级响应,响应期间忽略新请求”。核心考点不是状态图,而是优先级编码和忙状态的处理。我的做法是把优先级判断放在次态组合逻辑里,用一个grant信号表示当前授权,忙状态期间锁住grant不变。关键细节是:响应结束后要留一个“释放”状态,确保下游看到完整的握手周期,而不是授权信号一闪而过。这个释放状态是很多人漏掉的,导致下游采样不到。
5.2 序列检测的“重叠”与“不重叠”
序列检测是经典题,但“其他题目”会加“允许重叠”或“不允许重叠”的约束。允许重叠时,检测到序列后状态要回到能复用后缀的状态(比如检测101,收到101后再来01应能继续匹配);不允许重叠则直接回IDLE。这个差异直接改变状态图的回边,写代码前必须在纸上画清楚回边指向哪个状态,否则仿真时会出现“少检测一次”或“多检测一次”。我的习惯是在状态图旁边标注每个状态对应的“已匹配后缀”,回边就按最长后缀原则连,基本不会错。
5.3 状态机与计数器的混合结构
有些题目要求“状态机控制流程,计数器控制时长”,比如“按下启动后等待N个周期再进入下一状态”。这时候状态机里嵌一个使能计数器是常见做法,但要注意计数器清零的时机——是在状态跳转的同一拍清零,还是下一拍?如果状态机用次态寄存输出、计数器用当前状态使能,两者可能差一拍。我的做法是让计数器也基于nxt_state判断使能,和输出段保持同一节奏,这样波形上状态、输出、计数三者对齐,调试时一眼就能看出问题。
6. 我个人踩过的两个坑和对应的检查清单
第一个坑前面提过,按键抖动导致Mealy输出毛刺。修复方案是输出寄存加输入同步,但更根本的教训是:任何来自外部的输入,进状态机前先做两级同步,哪怕题目说“输入是同步的”。题目说的同步是指功能层面,实际板子上引脚进来的信号永远要当异步处理。这个习惯让我后来少走了很多弯路。
第二个坑是复位。有次题目要求“复位后所有输出为低”,我用了异步复位,但输出寄存器在复位释放后第一个时钟沿前处于不确定态,仿真因为初值恰好是0没暴露,上板偶尔输出高。后来养成习惯:复位期间不仅复位状态寄存器,所有输出寄存器一并复位,并且在testbench里检查复位释放后第一个周期的所有输出。这个检查现在是我每道状态机题必做的收尾动作。
基于这些经验,我整理了一个做题时的自查清单,每次交卷前过一遍:
- 状态编码是否和题目约束匹配,注释里是否写明选择理由
- 输出是Moore还是Mealy,是否有毛刺风险,是否需要寄存
- 复位是同步还是异步,是否做了同步释放,所有输出是否被复位
- 次态和输出组合块是否都有默认赋值,是否会产生锁存器
- 非法状态是否有显式处理,能否一个周期回安全态
- 题目里的每一条“假设”“要求”是否都有对应断言或检查
- 综合报告的触发器、LUT、关键路径是否和预期一致
这套清单不复杂,但能挡住“其他状态机题目”里八成以上的失分点。状态机本身不难,难的是把那些没写在题目里、但评审和实际硬件都会检验的细节想周全。把这些细节变成肌肉记忆,再遇到什么“其他题目”都不慌。