1. 为什么一个状态机要拆成三段:从一次改不动的一段式代码说起
状态机这三个字,在数字设计圈子里几乎是入门第一课,但真正让我意识到"写法比功能更重要"的,是几年前接手的一块通信板卡工程。那个工程里有个负责链路握手的状态机,被完整塞进了一个always块:case里面套if,if里面又塞计数器和输出赋值,一共四百多行。改一个状态要通读整段代码,改完还得担心别处的输出被顺手带跑偏。前后重构了三轮,最后落到三段式结构,代码量没少多少,但可读性和"敢改"的信心完全不是一个量级。
三段式状态机(Verilog Three-Segment FSM)说的其实是一件很朴素的事:把一个状态机的运行拆成三块互相不越界的逻辑——状态寄存、次态判断、输出赋值。前者只管"现在是什么状态",中者只管"下一个时钟该变成什么状态",后者只管"在当前状态下输出该是什么"。三块各司其职,谁也别去碰别人的变量。
很多人第一次听到"三段式",会以为这是某种性能优化技巧,其实它解决的主要是可维护性和综合可预测性。FPGA 工程的生命周期动辄五到十年,中间换人、换需求是常态,一段式状态机在单人小项目里能跑,一旦进了多人协作,就是定时炸弹。这篇文章会从原理讲到落地:三段式到底怎么分、状态编码怎么选、参数怎么算、上板前后容易踩哪些坑,最后再聊聊同一套思路在 STM32 按键处理、PLC 步进编程、Java 业务状态机、QP 活动对象框架里是怎么映射的。不管你是刚开始写 Verilog 的学生,还是写了几年想回头把基础捋顺的工程师,应该都能拿到点东西。
注意:三段式的"三段"是逻辑分工,不是三个物理模块。它不增加面积开销,综合后的触发器数量和一段式写法基本一致,差别只在于综合器能否把你的意图推断得干净。
2. 三段式三个 always 块的分工与状态编码选择
2.1 一段式和两段式到底差在哪
先看一段式。一段式把状态寄存器更新、次态判断、输出赋值全放在一个时序always块里,用非阻塞赋值完成。这种写法最直观,初学者最容易上手,问题也最集中:
- 组合逻辑被迫走时序路径。次态判断本来是一堆组合逻辑,被塞进时序块后,综合工具依然会把它放在触发器前面,但你没法再用
always @(*)的方式单独约束它、单独仿真它。 - 输出和状态同步变化。因为输出在时钟沿更新,功能上"看起来"是干净的,可一旦需要输出提前一个周期有效,代码就得打补丁。
- 调试时无法观察次态。次态只是个中间变量,波形里看不见,出问题只能靠猜。
两段式把状态寄存器和次态组合逻辑拆开,输出仍然放在次态逻辑里用组合方式给出。这已经解决了大部分可读性问题,但组合输出会带毛刺:状态经过译码,输出信号在状态切换的瞬间会产生窄脉冲。如果这个输出直接接到别的模块的异步输入、或者接到片外引脚,毛刺就是实打实的风险。
2.2 三段式的分界逻辑:组合逻辑只管"想",时序逻辑只管"存"
三段式的分界线非常清楚:
| 段号 | 块类型 | 职责 | 赋值方式 | 变量 |
|---|---|---|---|---|
| 第一段 | 时序 | 状态寄存器更新 | 非阻塞 | cur_state |
| 第二段 | 组合 | 次态判断 | 阻塞 | nxt_state |
| 第三段 | 时序 | 输出寄存 | 非阻塞 | 输出端口 |
| 辅助 | 时序/组合 | 数据路径、计数器 | 非阻塞/阻塞 | 中间寄存器 |
第二段用always @(*)加阻塞赋值,把所有"下一个状态是什么"的决策写在一处。这样做有个隐性好处:综合工具会把它当成一个纯组合云来处理,不会混入触发器,时序分析里的路径也清晰。第一段和第三段是纯时序,只做搬运。
我个人的判断标准是:只要一个状态机的状态数超过 4 个、或者有超过 3 个输出信号、或者有超时/计数器之类的机制,就应该直接上三段式。为省那几行代码用一段式,后面修 bug 花的时间远超写代码的时间。
2.3 输出寄存的价值:毛刺和时序收敛
第三段输出寄存,是很多人忽略的关键。组合输出在状态切换瞬间会出现译码毛刺,如果这个输出是给到外部器件的使能信号(比如片选、读写使能),毛刺可能被外部器件当成一次有效的短脉冲。寄存器输出后,输出只在时钟沿变化,毛刺被完全滤掉。
代价是输出延迟一个时钟周期。这在大多数同步设计里不是问题,只要在系统级时序上把这个周期算进去就行。但如果你的状态机输出要和另一个模块在同一个周期里配合,就要注意相位对齐。
提示:如果确实需要"当前周期就有效"的组合输出,可以把状态位本身直接当输出用,也就是所谓的独热码输出。独热码下每个状态对应一个触发器,状态位就是天然的无毛刺译码结果,这也是独热码在 FPGA 上流行的一个原因。
2.4 状态编码怎么选:二进制、格雷码、独热码
状态编码不是随便写的,它直接影响资源占用和时序表现。三种主流编码的取舍如下:
| 编码方式 | 触发器数 | 译码逻辑 | 典型场景 | 主要问题 |
|---|---|---|---|---|
| 二进制 | $clog2(N) | 较复杂 | ASIC、状态数多 | 多 bit 同时翻转,功耗和毛刺大 |
| 格雷码 | $clog2(N) | 中等 | 低功耗、状态顺序执行 | 只能用于相邻跳转为主的场景 |
| 独热码 | N | 极简 | FPGA、状态数少(<16) | 触发器多,ASIC 不划算 |
FPGA 里触发器资源相对充裕,而查找表和布线资源更紧张,所以独热码往往是默认选择。你甚至不用手动指定,综合工具在fsm_encoding属性缺省时经常会自动选独热。ASIC 里触发器面积和功耗都要算钱,状态数超过 8 个就一般回退到二进制或格雷码。
具体到代码,我的习惯是写localparam让综合工具自己决定编码,只在有明确低功耗或时序要求时才用(* fsm_encoding = "one_hot" *)强制指定。手动写死二进制常量虽然可控,但会在代码里埋下"状态顺序变了要改好几处"的隐患。
3. 手把手实现:一个带超时保护的串口接收状态机
3.1 需求定义与接口规划
为了把三段式讲透,我直接给一个能上板的例子:一个简化的串口接收状态机,支持起始位检测、8 位数据采样、停止位校验,并且带一个超时保护——如果线路一直拉低超过设定时间,状态机主动回到空闲态并报错。
接口定义:
module uart_rx_fsm #( parameter integer CLK_FREQ = 50_000_000, parameter integer BAUD_RATE = 115200, parameter integer TIMEOUT_MS = 10 )( input wire clk, input wire rst_n, input wire rx, output reg [7:0] rx_data, output reg rx_valid, output reg rx_error );选择用参数化写法,而不是写死常数,是为了后续换晶振、改波特率时不用动逻辑代码。这个习惯我建议从第一天写状态机就养成。
3.2 状态定义与常量计算
localparam integer BAUD_DIV = CLK_FREQ / BAUD_RATE; localparam integer BAUD_CNT_W = $clog2(BAUD_DIV); localparam integer TIMEOUT_CYC = TIMEOUT_MS * (CLK_FREQ / 1000); localparam integer TIMEOUT_W = $clog2(TIMEOUT_CYC); localparam [2:0] S_IDLE = 3'd0, S_START = 3'd1, S_DATA = 3'd2, S_STOP = 3'd3, S_DONE = 3'd4, S_ERROR = 3'd5;这里我用localparam而不是define。define是全局文本替换,容易在跨文件时撞名;localparam有作用域,综合和仿真行为一致,调试时也能在波形里看到名字。
3.3 三段代码逐段拆解
第一段:状态寄存器,全模块唯一一个负责"记住当前状态"的时序块。
reg [2: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这段短到几乎不需要解释,但它就是整个三段式的"锚"。所有关于状态的记忆都只在这里发生,别的地方一律只读不写。
第二段:次态组合逻辑,纯组合,阻塞赋值,只写nxt_state。
always @(*) begin nxt_state = cur_state; // 默认保持,防锁存器 case (cur_state) S_IDLE: if (!rx) nxt_state = S_START; S_START: if (baud_tick) nxt_state = S_DATA; S_DATA: if (baud_tick && bit_idx == 3'd7) nxt_state = S_STOP; S_STOP: if (baud_tick) nxt_state = S_DONE; S_DONE: if (done_flag) nxt_state = S_IDLE; S_ERROR: if (timeout_done) nxt_state = S_IDLE; default: nxt_state = S_IDLE; endcase end三个细节值得说。第一,开头给nxt_state赋默认值,这是防综合出锁存器的常规操作,case里没覆盖的分支会保持原值,避免了不完整的条件推断。第二,default分支必须写,状态跑飞时能自恢复。第三,次态判断里我只引用信号,不产生新的时序行为,这样综合出来的就是一张干净的真值表。
提示:次态逻辑里千万不要写
nxt_state <= ...。非阻塞赋值放在组合块里,仿真时会引入一拍延迟,波形和综合结果对不上,这类问题排查起来极其痛苦。
第三段:输出寄存,也是时序块,输出只在时钟沿变化。
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rx_data <= 8'd0; rx_valid <= 1'b0; rx_error <= 1'b0; end else begin rx_valid <= (nxt_state == S_DONE); rx_error <= (nxt_state == S_ERROR); if (cur_state == S_DATA && baud_tick) rx_data <= {rx, rx_data[7:1]}; // 低位先收 end end注意这里用nxt_state而不是cur_state判断输出有效性。这样输出信号和状态在同一拍生效,避免了"状态到了但 valid 晚一拍"的错位。这是三段式输出段最容易被忽略的对齐技巧。
辅助逻辑:波特率计数器和超时计数器,属于数据路径,我习惯单独放一个时序块,不混进三段里。
reg [BAUD_CNT_W-1:0] baud_cnt; reg [2:0] bit_idx; reg [TIMEOUT_W-1:0] timeout_cnt; wire baud_tick = (baud_cnt == BAUD_DIV - 1); wire timeout_done = (timeout_cnt == TIMEOUT_CYC - 1); wire done_flag = 1'b1; // 实际工程中由外部握手信号驱动3.4 仿真验证要点
写完代码不要急着上板,先跑仿真。我的 Testbench 一定会覆盖四类场景:正常接收一帧、起始位误触发后超时、停止位错误、以及复位过程中 rx 保持低电平。最后一项最容易漏,很多状态机在复位期间会因为输入引脚状态未知而误进 S_START。
仿真时重点看三处波形:cur_state和nxt_state是否只差一拍、rx_valid是否只在S_DONE期间为高且只持续一拍、rx_error出现后是否在一个超时周期内回到S_IDLE。如果nxt_state出现 X,八成是case有分支没覆盖。
4. 参数怎么算:位宽、时钟、超时时间的实操推导
4.1 波特率分频与计数器位宽
以 50 MHz 时钟、115200 波特率为例:
- 分频系数
BAUD_DIV = 50_000_000 / 115200 ≈ 434 - 计数器位宽
BAUD_CNT_W = $clog2(434) = 9
也就是说计数器从 0 数到 433 就是一位的时间。这里有个常见错误:直接用CLK_FREQ / BAUD_RATE的整数结果去比较,忽略了整除误差。434 对应的实际波特率是50_000_000 / 434 ≈ 115207,误差约 0.006%,完全可以接受。但如果时钟是 25 MHz、波特率是 115200,25_000_000 / 115200 ≈ 217,实际波特率变成 115207.4,误差同样很小。真正会出问题的是高波特率加低时钟,比如 1 MHz 时钟跑 115200,分频系数只有 8,量化误差会飙到 8% 以上,这时要么换时钟,要么用小数分频。
4.2 超时时间的换算与位宽
超时 10 ms,时钟 50 MHz:
TIMEOUT_CYC = 10 × (50_000_000 / 1000) = 500_000TIMEOUT_W = $clog2(500_000) = 19
$clog2是向上取整的对数,2^19 = 524288 > 500000,够用。这里提醒一点:$clog2在综合工具里是支持的系统函数,但早期某些工具对参数化表达式里的$clog2支持不好,稳妥做法是写一个function integer clog2自己算,或者直接把位宽写成常量。我在老工程里吃过这个亏,综合能过但仿真报错,最后发现是工具版本问题。
4.3 计数器位宽不够的隐蔽故障
计数器位宽少一位是什么表现?它会周期性回绕。比如你需要数到 500000,但只给了 18 位(最大 262143),那么计数器会在 262143 处归零,超时时间直接缩水一半。这种 bug 在仿真里跑短时间看不出来,上板后表现为"偶发误报错",非常难定位。所以我的习惯是:所有计数器位宽都用$clog2(上限)自动推导,绝不手写数字。
| 参数 | 计算式 | 示例值 | 位宽 |
|---|---|---|---|
| 波特分频 | CLK_FREQ / BAUD_RATE | 434 | 9 |
| 超时周期 | TIMEOUT_MS × CLK_FREQ / 1000 | 500000 | 19 |
| 状态编码 | 取决于编码方式 | 3 bit 二进制 / 6 bit 独热 | 3 或 6 |
| 数据位索引 | 固定 8 位 | 0~7 | 3 |
4.4 时钟域与跨时钟处理
如果rx信号来自异步的外部时钟域,直接进状态机就是灾难。正确做法是先用两级触发器做同步,再做边沿检测。
reg rx_d1, rx_d2, rx_d3; always @(posedge clk or negedge rst_n) begin if (!rst_n) {rx_d3, rx_d2, rx_d1} <= 3'b111; else {rx_d3, rx_d2, rx_d1} <= {rx_d2, rx_d1, rx}; end wire rx_negedge = rx_d2 & ~rx_d3; // 检测下降沿,即起始位两级同步解决亚稳态,第三级用来做边沿检测。同步器本身应该放在状态机模块内部还是外部?我的建议是放在模块内部靠近使用点,这样模块自包含,移植时不用额外记着加同步。
注意:状态机的所有输入如果来自不同的时钟域,都必须单独同步。不要为了省资源共用一套同步器,同步器的输入必须来自同一个源。
5. 上板前后最容易翻车的几个坑
5.1 状态跑飞与综合后仿真不一致
状态跑飞最常见的原因有三个:case缺少default、状态位宽和常量位宽不匹配、以及复位没覆盖到所有状态寄存器。位宽不匹配特别隐蔽,比如状态定义用3'd5,但cur_state声明成[1:0],综合工具会截位,S_ERROR实际变成了2'd1,和S_START撞车。
综合后仿真不一致,八成是组合逻辑里的锁存器引起的。检查方法很简单:看综合报告里的 latch 数量,如果不是零,就回去把每个always @(*)块的默认赋值补齐。另一个可能是x态传播,仿真里x被当成假值走了一条分支,综合后变成确定的 0 或 1,行为就变了。
5.2 输出毛刺与亚稳态
毛刺来自组合译码。如果你的输出直接给到片外引脚或者异步复位端,一定要寄存一拍。亚稳态来自跨时钟域采样,前面已经讲了同步器。还有一个容易被忽略的点:状态机输出的多位信号如果同时变化,在接收端可能被采到中间态。解决办法是加握手信号或者用格雷码编码,让每次只有一个 bit 变化。
5.3 复位策略:同步复位还是异步复位
| 复位方式 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 异步复位 | 无时钟也能复位 | 释放时可能亚稳态 | 上电初始化 |
| 同步复位 | 释放干净,时序好分析 | 需要时钟在跑 | 主流推荐 |
| 异步复位同步释放 | 兼顾两者 | 多两级触发器 | 工程首选 |
我现在的默认写法是"异步复位、同步释放":
reg rst_n_d1, rst_n_d2; always @(posedge clk or negedge rst_n_async) begin if (!rst_n_async) {rst_n_d2, rst_n_d1} <= 2'b00; else {rst_n_d2, rst_n_d1} <= {rst_n_d1, 1'b1}; end wire rst_n = rst_n_d2;这样复位沿和时钟沿不会打架,释放时刻是同步的,避免了复位释放时的亚稳态传播。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 状态卡死在某一个状态 | 次态逻辑缺少该状态的转移条件 | 检查case分支完整性 |
| 仿真对、上板错 | 输入未同步、缺少default | 查同步器、查综合报告 latch |
| 输出出现窄脉冲 | 组合输出未寄存 | 输出改到第三段时序块 |
| 计数器周期不对 | 位宽截断导致回绕 | 用$clog2重算位宽 |
| 复位后行为随机 | 复位未覆盖全部寄存器 | 检查复位分支里的赋值列表 |
| 综合面积异常大 | 独热码状态数过多 | 改用二进制编码或加 fsm_encoding 属性 |
提示:调试状态机时,我习惯在仿真里把
cur_state和nxt_state都拉到波形窗口,再把所有输出和计数器一起排布。只要这两个信号对不上拍,问题一定出在第二段;如果对得上但输出不对,问题一定在第三段。这套二分法能省掉大量翻代码的时间。
6. 同一套思路换个平台:STM32、PLC、Java 与 QP
6.1 STM32 按键状态机:把延时消抖彻底干掉
做 STM32 的人几乎都写过这种代码:检测到按键按下,delay_ms(20),再检测一次确认。这种写法在裸机小项目里能跑,一旦上了 RTOS 或者需要同时处理多个任务,阻塞延时就变成了系统卡顿的元凶。用状态机改写思路非常直接:把"消抖等待"变成一个状态,靠定时器周期调用状态机函数,每次只推进一小步。
typedef enum { KEY_IDLE, KEY_DEBOUNCE, KEY_PRESSED, KEY_RELEASE } key_state_t; void key_fsm_tick(key_fsm_t *k, uint8_t level, uint32_t now_ms) { switch (k->state) { case KEY_IDLE: if (level == 0) { k->state = KEY_DEBOUNCE; k->t0 = now_ms; } break; case KEY_DEBOUNCE: if (now_ms - k->t0 >= 20) { if (level == 0) { k->state = KEY_PRESSED; k->pressed = 1; } else k->state = KEY_IDLE; } break; case KEY_PRESSED: if (level == 1) { k->state = KEY_RELEASE; k->t0 = now_ms; } break; case KEY_RELEASE: if (now_ms - k->t0 >= 20) k->state = KEY_IDLE; break; } }这套写法和 Verilog 三段式在结构上高度一致:state就是cur_state,switch里的判断就是次态逻辑,赋值给pressed就是输出寄存。区别只是 C 里没有时钟沿,推进靠定时器调用。把这个函数挂到 1 ms 的定时中断里,多个按键互不干扰,也不会阻塞主循环。
6.2 PLC 的步进状态机写法:SFC 与 ST 语言的对应
PLC 编程里的状态机思想更早成熟,最典型的是顺序功能图(SFC),把工艺流程画成一个个步(Step),步之间有转换条件(Transition)。用结构化文本(ST)写出来,和 Verilog 三段式几乎可以一一对应:
CASE step OF 0: IF start_btn AND NOT emergency THEN step := 10; END_IF; 10: IF cylinder_back_sensor THEN step := 20; END_IF; 20: IF timer_done THEN step := 30; END_IF; 30: IF part_detected THEN step := 0; END_IF; END_CASE;很多品牌的 PLC 还提供"步进梯形图"指令(比如 SET/RST 成对使用),本质就是把状态寄存器的更新显式化。工业现场调试时,状态机写法最大的好处是故障可追溯:设备停在某一步,直接看步号就知道卡在哪个动作上,比一堆互锁逻辑好排查得多。
6.3 Java/QP/OMAC:业务层的状态机与工业标准
Java 里的状态机这几年被讨论得很多,核心诉求是"状态流转能不能由用户灵活定义"。常见方案有三种:用枚举加状态转移表硬编码、用 Spring StateMachine 这类框架配置化、或者自定义注解加 DSL 从配置文件加载转移关系。三种方案的取舍很直接——硬编码最稳但改一次要重新发版,配置化灵活但运行期错误更难查。我倾向于把"状态"和"转移条件"分离:状态用枚举,转移条件用策略接口,具体规则从数据库或配置中心加载,这样既有类型安全,又能动态调整。
QP 系列框架(QP/C、QP/C++)走的是另一条路,它把状态机升级成层次状态机(HSM),支持状态嵌套和进入/退出动作,用 UML 状态图建模后自动生成代码。适合事件驱动型的嵌入式系统,比如多任务的设备控制器。OMAC 则是工业自动化领域的一套设备状态模型,定义了一台机器从 Stopped、Idle、Starting 到 Execute、Holding、Suspended 的标准状态流转,PackML 就是它的落地形式。这些不同领域的方案,底层逻辑和 Verilog 三段式完全一致:状态要集中管理,转移要集中判断,动作要和状态解耦。
写到这里,我个人最大的体会是:状态机写法的价值不在于跑得多快,而在于它把"什么时候做什么"这件事压缩成了人可以一眼看懂的表格。Verilog 三段式不过是用硬件描述语言把这个表格拆成了三块代码。你在 FPGA 上把三段式写顺了,再去看 STM32 的按键处理、PLC 的步进流程、后端的订单状态流转,会发现它们其实是同一个东西穿了不同的衣服。真要说有什么经验可分享,那就是:动手写之前先把状态图画在纸上,把每个状态、每条转移、每个输出都列清楚,代码只是转录工作——状态图没画对的工程,代码写多少遍都是白写。