尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Verilog三段式状态机设计:状态编码、串口实现与跨平台映射
1. 为什么一个状态机要拆成三段从一次改不动的一段式代码说起状态机这三个字在数字设计圈子里几乎是入门第一课但真正让我意识到写法比功能更重要的是几年前接手的一块通信板卡工程。那个工程里有个负责链路握手的状态机被完整塞进了一个always块case里面套ifif里面又塞计数器和输出赋值一共四百多行。改一个状态要通读整段代码改完还得担心别处的输出被顺手带跑偏。前后重构了三轮最后落到三段式结构代码量没少多少但可读性和敢改的信心完全不是一个量级。三段式状态机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 3d0, S_START 3d1, S_DATA 3d2, S_STOP 3d3, S_DONE 3d4, S_ERROR 3d5;这里我用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 3d7) 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 8d0; rx_valid 1b0; rx_error 1b0; 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 1b1; // 实际工程中由外部握手信号驱动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、波特率是 11520025_000_000 / 115200 ≈ 217实际波特率变成 115207.4误差同样很小。真正会出问题的是高波特率加低时钟比如 1 MHz 时钟跑 115200分频系数只有 8量化误差会飙到 8% 以上这时要么换时钟要么用小数分频。4.2 超时时间的换算与位宽超时 10 ms时钟 50 MHzTIMEOUT_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_RATE4349超时周期TIMEOUT_MS × CLK_FREQ / 100050000019状态编码取决于编码方式3 bit 二进制 / 6 bit 独热3 或 6数据位索引固定 8 位0~734.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} 3b111; 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、状态位宽和常量位宽不匹配、以及复位没覆盖到所有状态寄存器。位宽不匹配特别隐蔽比如状态定义用3d5但cur_state声明成[1:0]综合工具会截位S_ERROR实际变成了2d1和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} 2b00; else {rst_n_d2, rst_n_d1} {rst_n_d1, 1b1}; end wire rst_n rst_n_d2;这样复位沿和时钟沿不会打架释放时刻是同步的避免了复位释放时的亚稳态传播。5.4 常见问题速查表现象可能原因排查动作状态卡死在某一个状态次态逻辑缺少该状态的转移条件检查case分支完整性仿真对、上板错输入未同步、缺少default查同步器、查综合报告 latch输出出现窄脉冲组合输出未寄存输出改到第三段时序块计数器周期不对位宽截断导致回绕用$clog2重算位宽复位后行为随机复位未覆盖全部寄存器检查复位分支里的赋值列表综合面积异常大独热码状态数过多改用二进制编码或加 fsm_encoding 属性提示调试状态机时我习惯在仿真里把cur_state和nxt_state都拉到波形窗口再把所有输出和计数器一起排布。只要这两个信号对不上拍问题一定出在第二段如果对得上但输出不对问题一定在第三段。这套二分法能省掉大量翻代码的时间。6. 同一套思路换个平台STM32、PLC、Java 与 QP6.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_stateswitch里的判断就是次态逻辑赋值给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 的步进流程、后端的订单状态流转会发现它们其实是同一个东西穿了不同的衣服。真要说有什么经验可分享那就是动手写之前先把状态图画在纸上把每个状态、每条转移、每个输出都列清楚代码只是转录工作——状态图没画对的工程代码写多少遍都是白写。
RELATED

相关推荐

DETR深度解析:Transformer如何颠覆目标检测的端到端范式

DETR深度解析:Transformer如何颠覆目标检测的端到端范式

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/9/30 2:41:38
从输入输出切入手撕Transformer:PyTorch代码实现与避坑指南

从输入输出切入手撕Transformer:PyTorch代码实现与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/9/30 2:41:38
【小白也能轻松用】最新版OpenClaw v2.7.9配置方法:Windows下用TaoToken统一Key接入数字员工(含最新安装包)

【小白也能轻松用】最新版OpenClaw v2.7.9配置方法:Windows下用TaoToken统一Key接入数字员工(含最新安装包)

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/9/30 2:36:38
MORE NEWS

更多资讯

📰

CodeBuddy + WorkBuddy 实战:AI IDE 与 Agent 工作台如何打通开发全链路

1. 从写代码到管周报:这套组合到底在解决什么问题第一次听到“CodeBuddy WorkBuddy”这个组合的时候,我正被两件事同时折磨:一边是手头一个 Vue 项目里腾讯地图的 SDK 接入反复报错,另一边是每周五下午要手动汇总五个人的周报&am…

📰

Model-Optimizer:面向AI工程落地的模型交付决策框架

1. 这不是又一个“模型压缩工具”,而是工程落地前的必经手术台“Model-Optimizer”——光看名字,很多人第一反应是“哦,又一个剪枝量化蒸馏三件套打包工具”。我去年在三个不同行业的AI项目里都撞过这个认知陷阱:客户拿着竞品宣传…

📰

大数据入门实战:Linux操作与Hadoop伪分布式搭建实验指南

简介:这份实验报告PDF面向大数据技术入门学习者,对应《大数据技术原理与应用》课程,围绕Linux操作系统与Hadoop平台两大基础模块展开,适合高校学生完成课程实验、课后复盘或自学打底。资源共1个文件,为PDF格式&#xf…

📰

基于YOLOv11的无人机电力设备异常检测与定位系统设计

简介:这份PDF文档面向电力巡检、无人机应用与目标检测方向的工程师、研究人员及高校学生,系统讲解如何以YOLOv11为核心构建电力设备异常检测与定位系统,帮助读者理解从算法原理到工程落地的完整链路。文档共38页,支持目录章节跳转…

📰

gpt-image-1生产实践:蒙版、Alpha通道与透明背景生成

1. 项目背景与方案设计1.1 为什么是 gpt-image-1 而不是 DALLE先说结论:如果你的业务还停留在 DALLE 3 时代的文本生图,那倒不用急着迁移;但一旦涉及“给已有图片做局部重绘”“抠图换背景”“透明 PN G 输出”这类编辑场景,gpt-i…

📰

双塔模型:推荐系统中解耦用户与物品表征的工业级召回范式

1. 什么是双塔模型?它为什么成了推荐系统里的“基建级”设计你有没有想过,当你在美食App上刷到“附近3公里内评分4.8的川菜馆”,或者点开外卖平台首页看到“你可能爱吃的辣子鸡丁配冰啤酒”——这些看似随口一说的推荐,背后其实是…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬