news 2026/9/30 2:41:31

Verilog三段式状态机设计:状态编码、串口实现与跨平台映射

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Verilog三段式状态机设计:状态编码、串口实现与跨平台映射

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_000
  • TIMEOUT_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_RATE4349
超时周期TIMEOUT_MS × CLK_FREQ / 100050000019
状态编码取决于编码方式3 bit 二进制 / 6 bit 独热3 或 6
数据位索引固定 8 位0~73

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 的步进流程、后端的订单状态流转,会发现它们其实是同一个东西穿了不同的衣服。真要说有什么经验可分享,那就是:动手写之前先把状态图画在纸上,把每个状态、每条转移、每个输出都列清楚,代码只是转录工作——状态图没画对的工程,代码写多少遍都是白写。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 2:41:28

一键将图片转为炫酷字符画

在这篇文章里头, 我们会去用到下面这些个知识点的哦。完整的一个程序, 那个用来把图片转换成字符画的py脚本。请先在命令行工具中找到存放图片文件的那个目录位置, 接着把当前路径切换到那个地方去执行操作, 然后启动名为图片转字符画.py的这个程序脚本并在其后面跟上参数.png这…

作者头像 李华
网站建设 2026/9/30 2:41:03

在现代 Python 编程中,字符串格式化是数据展示、日志记录、Web 开发以及数据分析中最基础也最核心的操作之一

在现代 Python 编程中&#xff0c;字符串格式化是数据展示、日志记录、Web 开发以及数据分析中最基础也最核心的操作之一。随着 Python 语言的迭代&#xff0c;字符串格式化的方式经历了从 % 操作符到 str.format() 方法&#xff0c;再到 Python 3.6 引入的 f-string&#xff0…

作者头像 李华
网站建设 2026/9/30 2:41:03

Layer3/4与Layer7防护差异(实战笔记)最佳实践与踩坑记录

本文深入探讨Layer3/4与Layer7防护差异&#xff08;实战笔记&#xff09;&#xff0c;涵盖背景分析、原理剖析、实战步骤、配置示例、优化建议和避坑指南。 在DDoS与CC防护领域&#xff0c;Layer3/4与Layer7防护差异&#xff08;实战笔记&#xff09;是开发者和技术负责人持续关…

作者头像 李华
网站建设 2026/9/30 2:40:31

我妈不懂什么是系统,她只跟AI说了一句话

我妈开了九年裁缝铺&#xff0c;改衣、缝边、上拉链&#xff0c;街坊生意。她这辈子跟电脑的交集是收银机和家庭群语音。上个月&#xff0c;她拥有了一套自己的管理系统——全程她只说了一句话。这条视频记录的全过程&#xff0c;比我预想的离谱。 一、铺子的老毛病 单子记小本…

作者头像 李华
网站建设 2026/9/30 2:40:30

Docker 容器 OOM 被杀?内存限制与排查完整指南

容器跑着跑着突然消失&#xff0c;docker ps 里找不到了&#xff0c;应用日志最后一行还停在正常的业务输出上&#xff0c;没有任何报错——这是 OOM&#xff08;Out Of Memory&#xff09;被杀的典型表现。它和普通崩溃不一样&#xff1a;进程是被内核直接 SIGKILL 的&#xf…

作者头像 李华
网站建设 2026/9/30 2:40:28

大厂校招薪资揭秘:前端、AI、测开方向薪资大盘点,小白必看!

本文汇总了字节跳动、腾讯、阿里巴巴、京东、美团、百度、快手、网易、小米、滴滴等热门大厂校招薪资情况&#xff0c;涵盖前后端、AI、测开等方向。数据基于往届案例&#xff0c;包括公开信息和内部渠道。文章详细对比了各厂的薪资待遇、签字费、股票等福利&#xff0c;并提醒…

作者头像 李华