1. 从组合逻辑到状态机:为什么你的FPGA迟早要用它
我记得第一次接触Verilog状态机,是在一个按键控制的流水灯项目里。当时刚学会always块和assign,觉得写逻辑没什么大不了的——LED从左往右亮,用一个计数器打拍子就行,简单。结果产品经理轻飘飘加了一句话:“按一下按键,流水方向反过来。”现场就凉了半截。计数器还在那跑,我临时加了一个方向标志位,然后发现按键按下去那一下方向是变了,可计数值也在变,流水灯跳了好几格。再往下折腾“暂停”“变速”“复位到初始态”这些需求,代码里的标志位越堆越多,状态之间互相影响,查一个bug要来回翻几个always块。
那个项目最终被我推倒,用状态机重写了一遍,才算是真正消停了。后来带过不少做数字IC和FPGA的应届生,很多人遇到的第一个分水岭也是“什么时候意识到自己该用状态机了”。Verilog状态机不是一种语法糖,更不是考试八股,它是把“一堆复杂的、带时序的控制逻辑”整理成一张可以被人类理解、被工具综合、被波形验证的图纸。凡是控制为主、时序纠缠的逻辑,用计数器加标志位堆,代码量翻一倍,bug量翻两倍;用状态机,逻辑清晰,边界清楚,仿真也好写。
这篇内容不打算讲FIFO、不聊握手协议,就只讲最核心的Verilog状态机基础:它是什么、有哪几种写法、怎么选编码、怎么写不会跑飞、以及一个完整的串口接收案例。适合刚学Verilog想搞懂状态机的学生,也适合已经写了几个小模块但总是“跑飞”“出现latch警告”“波形乱跳”的开发者。
1.1 状态机的三个要素,和一台自动售货机
有限状态机(FSM)的核心思想是:系统在任意时刻处于有限个状态中的一个,当满足某些条件时,从一个状态跳转到另一个状态,并在过程中产生输出。很多人第一次接触这个概念觉得抽象,换个说法就通了——自动售货机。
一台只卖可乐的自动售货机,投币5元出一罐,投币10元呢?出两罐再找零也行,我们简化成:它始终处在两个状态里,一个是待机,一个是出货。你投了5元硬币,这个事件就是状态转移条件,机器从待机跳到出货,出了可乐再回到待机。如果机器卡住了,停在出货状态不动,那就是“状态机卡死”,对应到FPGA里就是状态跳到了未定义态再也回不来。
所以状态机三要素就三样:
- 状态集合:系统当前可能处在的各个稳定阶段,对应Verilog里的
localparam枚举常量。 - 转移条件:从当前状态跳到下一状态需要满足的条件,对应组合逻辑或时序逻辑里的
case分支判断。 - 输出:在某个状态下或某次转移时对外产生的信号,对应摩尔型(Moore)和米利型(Mealy)两种输出方式。
理解了这三要素,再去看代码就不会晕。状态机的代码再怎么变,无非是把“状态如何转移”和“输出如何产生”这两件事用不同的风格写出来。
1.2 状态机到底解决什么问题
再回想一下那个流水灯反方向的需求。不用状态机时,你的脑子里要同时维护“计数器走到哪了”和“方向是哪个”两个变量;再加上“暂停”“复位”之后,你还得考虑“计数器和方向之间有没有时序耦合”。这种失控感,本质上是逻辑的先后顺序没有被显式表达出来——方向翻转和计数器归零到底哪个先发生,代码里没有任何一条约束来保证。
状态机把这种先后顺序写死了:每一个状态是一个明确的时间切片,只有状态跳转发生时,内部变量才允许大范围变化;在同一个状态里,计数器哪怕清零,也是这个状态自身的行为。这等于给时序逻辑加了一堵墙,把混乱关在里面。
1.3 手写状态机的通用骨架
虽然我后面会讲一段式、两段式、三段式的区别,但先给一个最通用的三段式模板(这是工业项目里最常见、也最不容易出问题的写法),先把“骨架”立起来。
// 状态定义 localparam S_IDLE = 2'd0; localparam S_WORK = 2'd1; localparam S_DONE = 2'd2; reg [1:0] state_c; // 组合逻辑里计算出的下一状态 reg [1:0] state_n; // 时序逻辑里寄存后的当前状态 // 第一段:状态寄存器,时钟沿打拍 always @(posedge clk or negedge rst_n) begin if (!rst_n) state_n <= S_IDLE; else state_n <= state_c; end // 第二段:组合逻辑,计算下一状态和输出 always @(*) begin state_c = state_n; case (state_n) S_IDLE: begin if (start_i) state_c = S_WORK; else state_c = S_IDLE; end S_WORK: begin if (done_cnt) state_c = S_DONE; else state_c = S_WORK; end S_DONE: state_c = S_IDLE; default: state_c = S_IDLE; endcase end // 第三段:时序逻辑,寄存器输出 always @(posedge clk or negedge rst_n) begin if (!rst_n) work_done_o <= 1'b0; else if (state_n == S_DONE) work_done_o <= 1'b1; else work_done_o <= 1'b0; end先别急着理解每一段的细节,后面会拆开讲。记住一个结论:能只用计数器加if解决的简单逻辑可以用计数器,一旦涉及到“多阶段流程控制、分支条件较多”,直接用状态机,别挣扎。
2. 状态机的三种经典写法:一段式、两段式、三段式怎么选
拿到一个新的状态机需求,新人问得最多的问题是:“用一段式两段式还是三段式?”网上答案各有偏向,有些教程说三段式好,有些说两段式简单。我这些年写下来,结论是:没有绝对好坏,但不同写法的可维护性、出bug的难易程度确实差异很大。
2.1 一段式:全部塞进一个always块,写起来爽,查起来哭
一段式把所有逻辑——状态转移、状态寄存、输出产生——全部写在一个always @(posedge clk)块里。
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_IDLE; work_done_o <= 1'b0; end else begin case (state) S_IDLE: begin work_done_o <= 1'b0; if (start_i) state <= S_WORK; end S_WORK: begin if (done_cnt) begin state <= S_DONE; work_done_o <= 1'b1; end end S_DONE: begin state <= S_IDLE; work_done_o <= 1'b0; end endcase end end一段式的优点是直观,所有行为都写在同一个时钟节拍里;缺点也很明显:当状态机和输出逻辑交织在一起,你想要单独看“输出在什么条件下有效”时,必须在case的各个分支里翻来翻去找。而且一段式很容易漏掉对输出的默认赋值——就像上面代码里,S_WORK状态里work_done_o保持上一次的值,综合后可能生成锁存器(latch),报一堆警告。工程上一个状态机动辄七八个状态、十几个输出信号,一段式根本维护不住。
2.2 两段式:状态寄存器一段,状态转移与输出一段
两段式是把一段式拆开:第一段时序逻辑负责状态寄存,第二段组合逻辑负责计算下一状态和产生输出。
reg [1:0] state; reg [1:0] next_state; always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= S_IDLE; else state <= next_state; end always @(*) begin next_state = state; work_done_o = 1'b0; // 组合逻辑直接用阻塞赋值 case (state) S_IDLE: begin if (start_i) next_state = S_WORK; end S_WORK: begin if (done_cnt) begin next_state = S_DONE; work_done_o = 1'b1; end end S_DONE: next_state = S_IDLE; endcase end两段式比一段式清晰不少,状态转移和输出都在组合逻辑里集中描述,不容易漏默认输出。但它有一个坑:组合逻辑产生的输出可能有毛刺。如果你的work_done_o要被外部电路采样,而外部电路对毛刺敏感(比如接异步复位、接跨时钟域逻辑),组合输出直接驱动外部就不太稳。另外,组合逻辑里的case如果不用default兜底,或者漏掉某个状态分支,综合器可能生成意想不到的锁存器。
2.3 三段式:状态转移、下一状态计算、输出寄存分开
三段式在前面模板里已经见过,这里重点说说它解决了两段式什么问题。
三段式把输出单独放到一个时序逻辑always块里,通过判断state_n来寄存输出。这样做有几个直接好处:
- 输出信号是寄存器输出,经过时钟打拍,消除了组合毛刺。
- 组合逻辑块只管算下一状态,case语句里写转移条件即可,不需要考虑输出。
- 状态寄存、转移计算、输出寄存三个部分彼此独立,任何一个环节出问题,定位速度极快。
代价是代码量稍微多一点。但对工程维护来说,这点代价完全值得。尤其在状态机后期加入状态扩展(比如从8个状态变成12个),三段式只需要在组合逻辑块里加case分支,输出块里加对应的判断,不会牵一发动全身。
always @(posedge clk or negedge rst_n) begin if (!rst_n) work_done_o <= 1'b0; else if (state_n == S_DONE) work_done_o <= 1'b1; else work_done_o <= 1'b0; end有人会问:“输出寄存一拍,会不会导致时序上慢了一个周期?”确实会,但绝大多数控制信号对一拍延迟不敏感。如果实在对时序敏感,可以在设计时把状态转移条件和输出逻辑的时序在文档里标清楚,别等到综合之后再去补。
2.4 三种写法的选型对照
| 写法 | 代码量 | 可读性 | 输出毛刺 | 适用场景 |
|---|---|---|---|---|
| 一段式 | 最少 | 差,逻辑混杂 | 有 | 极简单的时序控制,教学演示 |
| 两段式 | 中等 | 较好 | 有,组合输出 | 功能简单、输出不受毛刺影响的模块 |
| 三段式 | 稍多 | 最好,三部分职责清晰 | 无,寄存输出 | 工程主流,推荐所有新项目采用 |
个人经验:除非你只是想快速跑通一个仿真,否则直接上三段式。我见过很多开发者一开始图省事用一段式,后来加需求加到崩溃再翻写成三段式,中间的返工成本远大于一开始多写10行代码。
2.5 摩尔型与米利型:输出到底跟谁有关
状态机还有一个绕不开的分类——摩尔型(Moore)和米利型(Mealy)。
- 摩尔型:输出只取决于当前状态,与输入无关。好处是输出稳定,不会随输入瞬间抖动;坏处是“反应慢半拍”,因为要等状态跳转完成后才能改变输出。
- 米利型:输出同时取决于当前状态和输入。好处是响应快,输入一变化,组合逻辑立刻反映到输出;坏处是容易出毛刺,输入的一个毛刺会直接穿到输出端。
实际工程里,三段式状态机配合寄存器输出,很多人写的是摩尔型思路;米利型的输出通常在组合逻辑块里直接产生,或者单独再包一层寄存器。选型建议:控制类逻辑优先用摩尔型,因为稳定;对响应速度要求极端的场景(比如总线仲裁)才用米利型,并且一定要在关键输出路径上加时序约束。
3. 编码方式与跳转细节:状态机跑飞的全部可能原因
状态机写好了,仿真一跑,波形乱跳,状态从S_IDLE一下蹦到了一个没定义的数,从此再也回不来。相信每个写FPGA的人都见过这种场面。要么是复位没做干净,要么是case漏分支,要么是状态编码和状态位宽不匹配。这里把跑飞的原因一个个拆开。
3.1 二进制编码、独热码、格雷码:什么时候选哪个
状态编码是三段式状态机里最先要决定的事。常见三种:
| 编码方式 | 状态数N需要宽度 | 特点 | 适用场景 |
|---|---|---|---|
| 二进制编码(Sequential) | clog2(N)位 | 位宽最小,状态转换逻辑较多 | 状态少、逻辑资源紧张 |
| 独热码(One-hot) | N位,每状态只有1位为1 | 译码简单,速度快,功耗高 | 状态多、时序要求高,FPGA推荐 |
| 格雷码(Gray) | clog2(N)位 | 相邻状态只有1位变化 | 跨时钟域时降低毛刺风险,状态顺序跳转场景 |
FPGA里为什么常用独热码?因为FPGA的寄存器资源丰富,而且独热码的状态判断就是一个位的判断:if (state[2])就代表在第2个状态,根本不需要复杂的译码组合逻辑。状态数一多,独热码的时序往往比二进制编码更好收敛。代价就是位宽变宽,但FPGA不在乎这点寄存器。
二进制编码更适合资源紧张或状态数非常少(3~4个)的场景。格雷码一般用在状态连续递增/递减的计数器式状态机,或者需要跨时钟域传递状态值的场景。
3.2 case语句的default分支与状态变量位宽:两个最基础的坑
第一个坑:漏写default。Verilog里case语句如果没有匹配项,组合逻辑输出的state_c会保持原值,这等于给状态锁存器提供条件,综合器会给你一个latch警告。有了default,未定义的状态会跳回复位态,状态机才具备自恢复能力。
case (state_n) S_IDLE: ... S_WORK: ... S_DONE: ... default: state_c = S_IDLE; // 必须有,兜底自恢复 endcase第二个坑:状态枚举和位宽不匹配。比如定义了8个状态,用独热码需要8位,你写成reg [2:0] state,8个状态根本装不下,状态直接重叠,综合时工具会报warning甚至error。这个错误在仿真阶段很容易被漏掉,因为状态数量少、波形看着“好像还能跑”,但上板后行为完全不对。写完状态定义后,先自己算一遍位宽够不够,别等综合报warning。
3.3 未定义状态与自恢复机制
跑飞的本质是:状态寄存器里出现了一个既不对应任何localparam、也不在case任何分支里的值。可能是上电时复位没完全清干净、跨时钟域毛刺打进了寄存器、也可能是状态位宽有冗余但没处理。
解决思路是双保险:
case里写default,把未知状态强制引导到复位态。- 在状态机里加一个“状态非法检测”:用组合逻辑判断当前状态是否落在合法集合里,如果不在,直接触发复位或跳到安全状态。
代码示意(检测非法状态并告警):
wire state_valid = (state_n == S_IDLE) || (state_n == S_WORK) || (state_n == S_DONE); always @(posedge clk or negedge rst_n) begin if (!rst_n) state_error_o <= 1'b0; else if (!state_valid) state_error_o <= 1'b1; // 拉高告警 else state_error_o <= 1'b0; end这条告警信号建议接到测试口或者LED上,在调试阶段极有用。状态机为什么跑飞、什么时候跑飞,从这个信号上一眼就能看到。
4. 串口接收状态机实战:从协议到RTL的完整推导
前面讲了一堆理论,这里用一个非常典型的案例——UART串口接收模块,把状态机的设计流程完整过一遍。选串口接收做例子,是因为它的状态划分足够典型,既有空闲等待、又有中间采样、还有结束判断,非常贴合“状态机是控制协议翻译器”这个本质。
4.1 协议层拆解:状态从哪里来
看一眼UART一帧数据的时序,就明白状态是怎么来的了。以最常见的8N1格式为例:空闲时线路为高电平,发送方先拉低1位时间作为起始位,接着发送8个数据位(LSB在前),最后拉高1位时间作为停止位。
也就是说,接收端从头到尾经历的过程是:空闲(等起始位)→ 检测到起始位下降沿 → 采样8个数据位 → 等停止位 → 回到空闲。这不就是四个状态吗?
| 状态 | 含义 | 跳转条件 |
|---|---|---|
| IDLE | 空闲,线路为高,等待起始位 | 检测到rx_line拉低,进入START |
| START | 已检测到起始位,等待采样中点 | 计数到中点,进入DATA |
| DATA | 依次采样8个数据位 | 8位采完,进入STOP |
| STOP | 停止位阶段,确认线路拉高 | 计数结束,回IDLE |
状态划分遵循一个原则:每个状态对应协议里的一个明确阶段,状态的迁移对应协议阶段之间的明确事件。有的同学会把DATA按bit再拆成8个子状态,写起来很烦也没必要——用一个计数器在DATA状态里数8次就够了。
4.2 波特率分频与采样点计算
UART常用波特率9600,意味着每秒传输9600个bit,每个bit的持续时间是1/9600 ≈ 104.167us。FPGA系统时钟假设50MHz(周期20ns),那么每个bit持续50MHz / 9600 ≈ 5208个时钟周期。
采样时机要注意,为了避开数据位的边沿抖动,接收端通常在数据位的中点采样。所以START状态里计数器计到中点(约2604)时确认起始位有效,DATA状态里计数器每计满5208采样一次,一共采8次。
这个“中点采样”的细节非常能体现状态机的价值:如果不用状态,你得同时维护“现在是第几个bit”和“当前的采样时间窗”,两个计数逻辑缠在一起;用了状态机,DATA状态只需要管“采满8次没有”,每次采样的间隔由内部的cnt统一维护。
4.3 完整RTL设计:三段式串口接收状态机
模块io定义:clk系统时钟,rst_n低复位,rx_line是串口输入,rx_data_o是接收到的并行8bit输出,rx_done_o是接收完成脉冲。
module uart_rx_fsm ( input wire clk, input wire rst_n, input wire rx_line, output reg [7:0] rx_data_o, output reg rx_done_o ); // 参数定义 localparam CLK_FREQ = 50_000_000; localparam BAUD_RATE = 9_600; localparam BAUD_CNT_MAX = CLK_FREQ / BAUD_RATE - 1; // 5207 localparam BAUD_CNT_MID = CLK_FREQ / BAUD_RATE / 2 - 1; // 采样中点 // 状态定义,用独热码 localparam S_IDLE = 4'b0001; localparam S_START = 4'b0010; localparam S_DATA = 4'b0100; localparam S_STOP = 4'b1000; reg [3:0] state_c; reg [3:0] state_n; reg [12:0] baud_cnt; // 波特率计数,2^13=8192 够用 reg [2:0] bit_cnt; // 数据位计数,0~7 // 第一段:状态寄存器 always @(posedge clk or negedge rst_n) begin if (!rst_n) state_n <= S_IDLE; else state_n <= state_c; end // 第二段:组合逻辑计算下一状态和关键控制信号 always @(*) begin state_c = state_n; case (state_n) S_IDLE: begin if (rx_line == 1'b0) state_c = S_START; // 检测到起始位下降沿 else state_c = S_IDLE; end S_START: begin if (baud_cnt == BAUD_CNT_MID) state_c = S_DATA; // 到达起始位中点,确认起始有效 else state_c = S_START; end S_DATA: begin if (bit_cnt == 3'd7 && baud_cnt == BAUD_CNT_MAX) state_c = S_STOP; // 8位采完 else state_c = S_DATA; end S_STOP: begin if (baud_cnt == BAUD_CNT_MAX) state_c = S_IDLE; // 停止位结束,回空闲 else state_c = S_STOP; end default: state_c = S_IDLE; endcase end // 第三段:时序逻辑,分别产生波特率计数、位计数和输出 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin baud_cnt <= 13'd0; bit_cnt <= 3'd0; end else begin case (state_n) S_IDLE: begin baud_cnt <= 13'd0; bit_cnt <= 3'd0; end S_START: begin if (baud_cnt == BAUD_CNT_MID) baud_cnt <= 13'd0; else baud_cnt <= baud_cnt + 1'b1; end S_DATA: begin if (baud_cnt == BAUD_CNT_MAX) begin baud_cnt <= 13'd0; bit_cnt <= bit_cnt + 1'b1; end else baud_cnt <= baud_cnt + 1'b1; end S_STOP: begin if (baud_cnt == BAUD_CNT_MAX) begin baud_cnt <= 13'd0; bit_cnt <= 3'd0; end else baud_cnt <= baud_cnt + 1'b1; end default: begin baud_cnt <= 13'd0; bit_cnt <= 3'd0; end endcase end end // 输出寄存:DATA状态下,在采样点把rx_line存入移位寄存器 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rx_data_o <= 8'd0; end else if (state_n == S_DATA && baud_cnt == BAUD_CNT_MAX) begin rx_data_o <= {rx_line, rx_data_o[7:1]}; // 右移,先收到的LSB排最高位?注意顺序 end end // 停止位结束产生接收完成脉冲 always @(posedge clk or negedge rst_n) begin if (!rst_n) rx_done_o <= 1'b0; else if (state_n == S_STOP && baud_cnt == BAUD_CNT_MAX) rx_done_o <= 1'b1; else rx_done_o <= 1'b0; end endmodule这里有个很细节、也很容易搞错的地方:串口数据LSB先发。假设发送方依次发bit0、bit1……bit7到rx_line,接收端移位寄存器的拼接方向要保证最后rx_data_o[0]是bit0。上面代码里用{rx_line, rx_data_o[7:1]},第一次采样rx_line进入最高位,第二次采样第二次的rx_line进入最高位,同时原来的最高位移到次高位。这样8次之后,第1次采到的bit0被挤到了最低位,刚好符合预期。这个方向反了,整个串口收到的字节就是按位镜像的,查bug时非常隐蔽。
4.4 仿真验证的几个关键点
写testbench时,不要只盯着最终rx_done_o是否拉高,中间三个地方一定要看:
- 起始位检测:发送端把
rx_line拉低后,状态是否在下一个时钟沿从S_IDLE跳到了S_START。 - 采样时刻:
DATA状态下,baud_cnt计数到BAUD_CNT_MAX产生采样脉冲时,rx_data_o是否逐位更新。 - 停止位窗口:
S_STOP状态里,如果线路是低电平(发送端出错,没拉回高),状态机是否还坚持等完整个时间片。这验证了状态机对协议异常的基本容忍。
仿真命令示例(Vivado或ModelSim大同小异):
$dumpfile("uart_rx_fsm.vcd"); $dumpvars(0, uart_rx_fsm_tb);testbench里用任务发送一帧数据,比如0xA5:
task uart_send_byte(input [7:0] data); integer i; begin rx_line = 1'b1; #104167; rx_line = 1'b0; // 起始位 #104167; for (i = 0; i < 8; i = i + 1) begin rx_line = data[i]; // LSB在前 #104167; end rx_line = 1'b1; // 停止位 #104167; end endtask我实操的经验是:仿真里把发送端的10个bit(起始位+8数据+停止位)用等间隔的2个#104167隔开,但实际中如果时钟频率和波特率分频不匹配,采样点在每个bit里的位置会偏移。夸张一点,把主时钟频率改成50.001MHz,你就能看到采样点逐渐向bit边界偏移,最后在第7个bit上采到错误值。这个仿真手法能直观验证状态机的采样窗口设计是否留了裕量。
5. 我在状态机上踩过的五个经典坑
理论和案例都讲完了,最后分享一些实践里反复踩出来的经验。这些坑在教科书里很少被提到,但每一个我都真实遇到过,有些甚至带来过整板联调时半夜排查的经历。
5.1 不画状态转移图直接写代码
这是我见过最多新手的通病,包括当年的自己。拿到需求,脑子还没把状态梳理清楚,就直接打开编辑器敲localparam。结果写到一半发现还缺一个状态,又回头改枚举定义,改完case分支,又开始纠结输出是哪个状态下拉高,整个模块越写越乱。
建议是:任何状态机,动手前先在纸上画气泡图,或者用文本列一个“状态-转移条件-输出”三列表。状态少于5个的,画图10分钟;状态多于10个的,画图30分钟,但可以帮你省掉的返工时间是以天计的。这个习惯我一直用到现在,简单的小模块也会先列个表。
5.2 状态变量既有组合逻辑也有时序逻辑,代码里写混了
上面三段式模板里我特意用了state_n(寄存的当前状态)和state_c(组合算出的下一状态)两个变量。有同学会问:能不能只用state一个变量?可以,但代价是你得在一个always块里同时处理寄存和跳转,回到一段式的老路上,输出又容易出锁存器。
更隐蔽的问题是:在组合逻辑块里对state_n赋值时漏了某些路径。比如:
always @(*) begin state_c = state_n; case (state_n) S_A: if (cond) state_c = S_B; // 如果cond不满足,走默认保持 S_B: state_c = S_A; endcase end看起来没问题,因为state_c = state_n已经做了默认保持。但如果我把这个默认赋值写在case之后而非之前,某些分支里又提前return式地赋值(Verilog不支持return,但容易用if-else嵌套出漏路径),就可能出现某个输入条件下state_c没有被赋值,组合逻辑的输出开始依赖历史,生成锁存器。写组合逻辑块时,第一行就先写默认保持+默认输出,后面case里只写需要改变的部分,这是最稳的写法。
5.3 状态枚举和信号位宽长短不匹配
用独热码时,4个状态写reg [3:0]是正确的;但如果是二进制编码,4个状态只需要reg [1:0]。很多新手搞混,或者干脆统一用reg [7:0]图省事。位宽写宽了不会报错,但浪费资源还容易让仿真波形里出现一堆冗余的高位;位宽写窄了直接爆。
建议:状态变量统一用宏定义或者localparam,位宽单独算好,不要凭感觉。独热码状态数就是位宽,二进制编码向上取log2加1(比如6个状态需要3位,8个状态需要3位)。
5.4 输出信号没有寄存,导致外部电路莫名其妙误触发
两段式的组合输出毛刺问题,前面反复提过。这里给一个真实案例:我做过一个ADC采样控制模块,用两段式状态机产生片选信号,结果示波器上一看,片选线下行沿有一串几十纳秒的毛刺,ADC直接多触发了好几次采样。排查了很久,最终把片选输出改成寄存器输出,毛刺消失。
规约:状态机的对外控制输出,一律加一级寄存器。除非你能确认下游是组合逻辑且对毛刺不敏感,否则别省这一个always块。三段式的第三段就是干这个用的。
5.5 复位风格不统一,异步复位和同步复位混用
写状态机必然涉及复位,但新手常常一会儿用negedge rst_n做异步复位,一会儿又依赖同步复位,整个模块里风格不统一。这在功能上不一定会立刻出错,但综合时容易产生复位树问题,而且在仿真时复位时序稍微没对好,状态机就可能拿着一个未复位的初值开始跑。
建议:一个模块统一用一种复位风格。FPGA工程里最常用的是异步复位、同步释放(异步复位信号先打两拍,再用作同步复位),既避免了异步复位的亚稳态问题,也保持复位风格的统一。低电平复位就用negedge rst_n,每个时序always块都要带复位分支,别留裸奔的寄存器。
我把这五个坑对照整理成了表格,调试时可逐一排查:
| 坑 | 现象 | 排查方法 |
|---|---|---|
| 没画状态图硬写 | 模块越改越乱 | 停下来画气泡图,梳理状态-转移-输出表 |
| 状态变量赋值漏路径 | 出现latch警告,状态跳转随机 | 组合逻辑块首行写默认赋值 |
| 状态枚举位宽不匹配 | 综合warning,状态重叠 | 核对编码方式与位宽计算 |
| 输出未寄存 | 外部信号毛刺、误触发 | 输出改为三段式寄存器输出 |
| 复位风格不统一 | 仿真复位异常,上板状态不明 | 统一异步复位+同步释放 |
最后分享一个调试小技巧
写这篇文章时,我想起一个几乎每次调试状态机都会用到的小工具:把状态编码映射成reg然后接到测试口或LED上,同时用localparam里的名字在仿真波形里显示。比如Vivado仿真里,右键信号,选择“Radix”再改成“ASCII”,或者在testbench里用$display打印状态名:
always @(posedge clk) begin if (state_n != state_c) $display("Time=%0t FSM: %s -> %s", $time, state_name(state_c), state_name(state_n)); end配合一个把状态值转字符串的函数,跑完仿真直接看状态跳转日志,几分钟就能定位是哪一步跳错了,比盯着波形猜快太多。这个技巧陪伴了我好几年,每次写新状态机都会第一时间把这个打印逻辑挂上,等模块稳定了再摘掉。希望你也能借着这篇文章,把Verilog状态机这个基本功打扎实。