ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Verilog三段式状态机:模板、避坑与跨平台实践

Verilog三段式状态机:模板、避坑与跨平台实践 做 FPGA 或者数字 IC 的人基本绕不开状态机。我第一次独立写握手协议的时候把状态转移、条件判断、输出赋值全塞进了一个 always 块里仿真波形看着挺对一上板就时不时抽风查了很久才发现是组合输出上的毛刺被下游当成了有效脉冲。那次之后我才老老实实去啃三段式状态机的写法。所谓三段式状态机说白了就是把一个有限状态机拆成三个各司其职的 always 块一个专门锁存当前状态一个专门计算下一个状态一个专门产生输出。乍一听像是纯粹的编码风格问题但真正做过大项目的人都清楚这三个块分得清不清楚直接决定了你的代码是一份能交接给别人的工程还是一坨自己都不敢改的意大利面。这篇内容我打算从为什么要拆讲到具体怎么落地给出可以直接抄作业的 Verilog 模板再顺手聊聊同一套思维方式在 STM32、PLC 和上层业务代码里怎么变形。不管你是刚学 Verilog 的学生还是写了几年想反过头来补基础的工程师应该都能扒出点有用的东西。1. 先把三段式这件事讲透状态机到底在解决什么问题1.1 从一段能跑但不敢改的代码说起很多人的状态机启蒙都是从一段式开始的时钟沿一到判断当前状态根据输入决定跳到哪个状态顺手把输出也赋值了。这种写法在状态数少于三个、转移关系简单的时候确实够用仿真也能过。问题出在扩展的时候——你加了一个状态就得回头翻整段代码确认原来所有分支的赋值不会被新状态干扰你想复用某个状态的输出逻辑发现它和转移条件纠缠在一起根本抠不出来。我见过最典型的一段式反面案例是一个串口接收模块。作者把接收数据寄存、校验判断、应答输出全写在一个 always 块里最后这个块有两百多行缩进四层。后来需求改动要求在数据长度超过阈值时提前中止接收作者改了三天每次都修好一个 bug 又引入两个最后干脆推翻重写。他重写用的就是三段式新代码不到八十行逻辑一目了然。这段经历说明的核心问题是状态机里其实混着三种性质完全不同的逻辑。当前状态是记忆它必须由时序逻辑保持下一个状态是决策它本质上是当前状态和输入的组合函数输出是执行它既可以跟着状态走也可以跟着输入走。把三种逻辑揉在一起等于把记忆、决策、执行三个模块焊死在一块任何一处要改都得整体拆。1.2 三段式具体拆的是哪三段第一段状态寄存器段。这是一个纯时序逻辑每个时钟上升沿把next_state的值灌进cur_state复位时把cur_state打回初始状态。这一段里不出现任何 case 判断也不出现任何输入信号干净得像一行赋值。第二段次态组合逻辑段。这是一个always (*)块输入是cur_state和所有外部输入输出只有next_state一个信号。它的职责非常纯粹根据当前站在哪个状态、外部给了什么信号算出下一步应该去哪。这段逻辑是纯组合的写得好的话所有分支都能被覆盖综合出来就是一张干净的译码表。第三段输出逻辑段。这一段负责把状态翻译成实际的控制信号。它可以写成组合逻辑也可以写成时序逻辑可以只依赖cur_stateMoore 型也可以同时依赖输入Mealy 型。因为它是独立的一块你想调整输出时序、想给输出加一级寄存器消除毛刺都不用动前面两段。这三段之间只有两条线连着cur_state从第一段流向第二段和第三段next_state从第二段流向第一段。信号流向单一、清晰这也是三段式最舒服的地方——你读代码的时候可以分三次读每次只看一件事。1.3 为什么不是一段式、两段式一段式前面已经聊过了它的优势是代码行数少在极简场景下确实省事但代价是耦合度极高、输出必须是寄存器输出、状态转移和输出改一处动全身。对于只跑一次的学习例程无所谓对于要长期维护的工程一段式基本是不可接受的。两段式是很多人从一段式升级后的第一站一个时序块存状态一个组合块同时算次态和输出。它比一段式清爽但埋了一个隐蔽的雷——输出是组合逻辑直接跟着输入变化。如果输入信号没有和时钟同步或者输入上有竞争冒险输出就会跟着抖出一串毛刺。这些毛刺在仿真里未必看得出来因为它依赖布线延迟和信号到达顺序属于典型的综合前正常、综合后出事。三段式的价值在于它把输出这一块单独拎出来了你可以自由选择输出是组合还是寄存。需要快就用组合输出接受毛刺风险需要稳就给输出打一拍牺牲一个时钟周期换取完全干净的电平。两段式没有这个选择权三段式有。这就是我坚持用三段式的根本原因——它多给你的那点自由度在项目后期往往就是救命的。2. 三段式状态机的骨架与核心细节2.1 状态编码二进制、格雷码和独热码到底选哪个写状态机第一件事是决定状态怎么编码这个选择直接影响资源占用和时序表现。常见的三种方案各有各的适用场景不能一概而论。二进制编码用最少的位表示最多的状态比如六个状态只需要三位。优点是寄存器占用少缺点是从状态 011 到状态 100 需要三位同时翻转译码逻辑要比较多个位组合路径变长而且在状态跳变瞬间可能出现中间态虽然对同步逻辑无害但会增加无谓的翻转功耗。格雷码的特点正好相反相邻状态之间只有一位变化。这让它在状态按顺序循环转移的场景下翻转功耗极低也避免了多位同时跳变产生的瞬时中间态。但它有个硬性前提——状态必须按固定顺序走。一旦你的状态机需要从状态 2 直接跳到状态 5格雷码的优势就没了反而可能因为跳转距离大而失去意义。独热码是 FPGA 世界的常客N 个状态就用 N 位每个状态只有一位是 1。它的译码逻辑简单到极致判断当前是不是某个状态只需要看一位。这正好匹配 FPGA 内部的 LUT 结构路径短、速度快。代价是寄存器用量大但 FPGA 里触发器资源通常比较富裕用空间换速度很划算。我的一般原则是在 FPGA 上优先让综合工具自动选择或者显式指定独热码在 ASIC 上当状态数小于八个时用二进制或独热状态多且顺序性强时考虑格雷码。下面这段代码是在 Xilinx 工具里显式指定编码属性的写法供参考// SystemVerilog 风格用枚举类型 编码属性 (* fsm_encoding one-hot *) typedef enum logic [2:0] { IDLE 3d0, START 3d1, DATA 3d2, CHECK 3d3, DONE 3d4, ERR 3d5 } state_t;如果你用的是纯 Verilog-2001那就老老实实用localparam定义把编码方式写进注释让综合约束或者工具属性去处理。这里有个经验状态名和编码值一定要成对出现、集中定义在文件顶部绝对不要在代码中散落case (3d2)这种裸数字那是半年后自己都看不懂的写法。2.2 第一段状态寄存器的写法与复位策略第一段是所有三段式里最短的但细节一点不少。标准的写法是异步复位、同步加载always (posedge clk or negedge rst_n) begin if (!rst_n) cur_state IDLE; else cur_state next_state; end这里有两个经常被忽略的点。第一个是复位方式的选择。异步复位能在时钟没起来的时候就把状态机拉到确定状态适合上电初始化但异步复位释放的时刻如果离时钟沿太近容易产生亚稳态。工程上常见的做法是异步复位、同步释放也就是加一个复位同步器把外部复位信号先同步到时钟域再送给状态机。这个细节在低速板上无所谓在高速设计里能省掉很多随机的上电异常。第二个点是非阻塞赋值。状态寄存器必须用不能用。这不是风格问题而是语义问题阻塞赋值会让cur_state在本拍立即更新导致第二段的组合逻辑在同一拍里看到的是新状态仿真和综合结果不一致。这个坑我踩过现象是仿真波形完全正确、上板后行为偏移一个周期查了整整两天。另外复位值最好所有状态机都统一成 IDLE 或者一个专门的 SAFE 状态这样后续做在线调试的时候抓波形一眼就能看出状态机是从哪个点起来的。2.3 第二段次态组合逻辑的核心陷阱第二段是整个状态机里最容易出问题的地方主要原因是它用的是组合逻辑而组合逻辑有一个绕不开的敌人——锁存器。在always (*)块里如果某个信号在某种执行路径下没有被赋值综合工具就会推断出一个锁存器来保持它原来的值。锁存器在状态机里几乎总是非预期的它会带来难以分析的时序路径让静态时序分析工具报出一堆奇怪的违例。避免锁存器的办法有两种我推荐两种一起用。第一种是在块开头给next_state一个默认值always (*) begin next_state cur_state; // 默认保持当前状态杜绝锁存器 case (cur_state) IDLE: if (start) next_state START; START: next_state ready ? DATA : ERR; DATA: if (last) next_state CHECK; CHECK: next_state crc_ok ? DONE : ERR; DONE: next_state IDLE; ERR: if (clr) next_state IDLE; default: next_state IDLE; endcase end开头的next_state cur_state;是一句保险丝它的含义是如果没有被任何分支改写就保持当前状态不动。这样即使某些分支漏写了赋值也不会产生锁存器最差情况是状态机卡在原地。第二种是加default分支把没列出的状态统统导向安全状态。这两招配合基本可以杜绝锁存器。还有一个更隐蔽的坑case 语句没有覆盖所有状态。比如你的状态用三位编码实际只用了六个值那么剩下两个值就是悬空状态。如果因为某种干扰比如单粒子翻转或者上电时序问题跳到了这些悬空值上没有default分支的状态机会一直卡在那里不动而且没有任何输出。这就是为什么工业级设计里几乎都会有default分支而且会直接跳到 IDLE 或者 ERR。2.4 第三段输出逻辑Moore 与 Mealy 的取舍第三段是自由度最大的一段核心决策是 Moore 还是 Mealy以及输出要不要打拍。Moore 型状态机的输出只跟当前状态有关。它的好处是输出稳定、无毛刺、时序可预测坏处是响应慢一拍——因为状态要先变化输出才能变化。Mealy 型的输出同时依赖当前状态和输入响应快但组合路径从输入直接穿到输出容易引入毛刺而且路径延迟取决于输入的到达时间时序收敛更麻烦。我个人的经验是驱动外部管脚、控制使能信号、产生复位这类对毛刺敏感的输出一律用 Moore 加寄存输出只有在对延迟极其敏感的内部握手信号上才考虑 Mealy。下面这个是 Moore 加寄存输出的写法always (posedge clk or negedge rst_n) begin if (!rst_n) begin busy 1b0; valid 1b0; end else begin busy 1b0; valid 1b0; case (cur_state) // 用 cur_state输出滞后状态一拍 START: busy 1b1; DONE: valid 1b1; default: ; // 保持默认无锁存器问题时序块 endcase end end这里有个值得展开的细节第三段的 case 判断用cur_state还是next_state用cur_state输出比状态晚一拍路径是寄存器到寄存器时序最干净用next_state输出和状态同拍变化但因为next_state本身是组合信号综合出来的路径是cur_state经过第二段的组合逻辑再到第三段的寄存器路径更长时序压力更大。默认建议用cur_state只在协议对延迟有硬性要求时才切换到next_state并且要重新做时序分析。另外时序输出块里每个分支都要把输出赋一个确定值。上面代码里在 case 前统一给了busy 1b0; valid 1b0;这是一种先清零再置位的写法能避免某个分支漏赋值导致输出保持上一拍的值。虽然时序块不会推断锁存器但漏赋值会让输出行为不符合预期反而是更难查的问题。3. 完整实操一个可直接抄作业的三段式状态机3.1 需求定义带 CRC 校验和超时保护的接收状态机为了让代码不只是纸面示例我设计一个实际项目里经常出现的场景一个数据帧接收状态机功能是等待帧起始标志收到后进入数据接收接收完做 CRC 校验校验通过输出有效标志校验失败或超时进入错误状态等待清除信号后回到空闲。这个场景包含了几种典型需求顺序转移、条件分支、错误处理、超时保护、握手输出。它足够简单能让人一眼看懂状态图又足够完整覆盖了真实项目里大部分状态机要处理的情况。我把它设计成六个状态状态名编码含义关键行为IDLE3d0空闲等待等待 start 信号START3d1起始确认等待 ready 握手DATA3d2数据接收累计字节直到 lastCHECK3d3校验计算比较 CRC 结果DONE3d4完成输出拉高 valid 一拍ERR3d5错误停留等待 clr 清除这张表本身就是一个很好的文档——把状态和编码、含义放在一起代码写完了表还在后来接手的人看表比看代码快。这也是三段式的一个隐藏好处状态定义集中天然容易生成文档。3.2 完整代码与逐段拆解下面是完整的模块代码我按三段拆开写注释标明了每一段的职责边界。module frame_rx_fsm #( parameter TIMEOUT_MAX 16d50_000 // 超时阈值按 50MHz 时钟约 1ms )( input wire clk, input wire rst_n, input wire start, // 帧起始标志 input wire ready, // 起始确认 input wire last, // 最后一个字节 input wire crc_ok, // CRC 校验结果 input wire clr, // 错误清除 output reg busy, // 接收中标志 output reg valid, // 完成有效标志 output reg err_flag // 错误指示 ); // ---------- 状态定义 ---------- localparam IDLE 3d0, START 3d1, DATA 3d2, CHECK 3d3, DONE 3d4, ERR 3d5; reg [2:0] cur_state, next_state; reg [15:0] timeout_cnt; // ---------- 第一段状态寄存器 ---------- always (posedge clk or negedge rst_n) begin if (!rst_n) cur_state IDLE; else cur_state next_state; end // ---------- 第二段次态组合逻辑 ---------- always (*) begin next_state cur_state; // 默认保持杜绝锁存器 case (cur_state) IDLE: if (start) next_state START; START: if (ready) next_state DATA; else if (timeout_cnt TIMEOUT_MAX) next_state ERR; DATA: if (last) next_state CHECK; else if (timeout_cnt TIMEOUT_MAX) next_state ERR; CHECK: if (crc_ok) next_state DONE; else next_state ERR; DONE: next_state IDLE; ERR: if (clr) next_state IDLE; default: next_state IDLE; endcase end // ---------- 超时计数器 ---------- always (posedge clk or negedge rst_n) begin if (!rst_n) timeout_cnt 16d0; else if (cur_state ! next_state) timeout_cnt 16d0; // 状态发生跳转则清零 else if (cur_state START || cur_state DATA) timeout_cnt timeout_cnt 1b1; else timeout_cnt 16d0; end // ---------- 第三段输出逻辑Moore 寄存输出 ---------- always (posedge clk or negedge rst_n) begin if (!rst_n) begin busy 1b0; valid 1b0; err_flag 1b0; end else begin busy 1b0; // 先给默认值避免漏赋值 valid 1b0; err_flag 1b0; case (cur_state) START: busy 1b1; DATA: busy 1b1; DONE: valid 1b1; ERR: err_flag 1b1; default: ; endcase end end endmodule逐段看几个关键决策。第一段里复位用异步复位是因为这个模块要和外部的帧同步信号配合上电时必须立刻进入确定状态不能等时钟稳定。如果你所在的设计有全局复位同步器这里改成同步复位也可以但复位值要保证是 IDLE。第二段里每一个分支都明确写了if条件没有依赖任何隐含的优先级。这一点很重要case语句本身是并行比较的但if-else链是串行的综合出来的路径长度不同。我在这里把超时判断放在else if的位置意味着正常跳转的优先级高于超时这样正常数据流不会因为计数器的边界条件被误打断。超时计数器单独拿出来写没有塞进第二段。原因是它本身是一个时序逻辑放进组合块里会导致必须在多个分支里反复赋值容易漏。单独拿出来用状态发生跳转就清零这一条规则统一处理逻辑更清晰。这里也有个小技巧判断跳转用的是cur_state ! next_state而不是判断某个具体状态这样以后加新状态时计数器逻辑不用改。第三段用了先清零再置位的结构。busy只在 START 和 DATA 两个状态为高其余状态自动落回 0不需要在每个分支里手动写 0。这种写法的可维护性比每个分支都完整赋值高很多改动时也只需要关注需要置位的那几个状态。3.3 状态转移表与关键波形解读代码写完了验收的第一步是把状态转移关系整理成表再和需求逐条对照。这是我在项目里雷打不动的习惯因为代码能过综合不代表逻辑正确而表格能强迫你把每一条边都想一遍。当前状态触发条件下一状态输出变化IDLEstart 1START无IDLE其他IDLE无STARTready 1DATAbusy 拉高START超时ERRbusy 拉高后回落DATAlast 1CHECKbusy 拉高DATA超时ERRbusy 回落CHECKcrc_ok 1DONE无CHECKcrc_ok 0ERR无DONE无条件IDLEvalid 拉高一拍ERRclr 1IDLEerr_flag 拉高ERR其他ERRerr_flag 持续有了这张表验证的时候就可以按行设计激励。比如第二行IDLE 下给 start 之外的其他输入很多人写测试的时候只测正常路径不测 idle 保持结果真出了异常也没人发现。波形上要重点看三件事。第一valid是否只在 DONE 状态持续一个时钟周期宽度是否准确第二从 CHECK 到 DONE 的跳转是否和crc_ok的采样沿对齐有没有出现少采或重复采第三timeout_cnt在状态跳转的那一刻是否被清零有没有残留值带进下一个状态导致误超时。第三点是这个设计里最容易出问题的地方我后面还会展开讲。3.4 Testbench 怎么写才能验证到位写 Testbench 不需要多复杂但必须覆盖三类场景正常流程、错误分支、边界条件。下面这个精简版可以直接改改用module tb_frame_rx_fsm; reg clk 0, rst_n 0; reg start 0, ready 0, last 0, crc_ok 0, clr 0; wire busy, valid, err_flag; always #10 clk ~clk; // 50MHz frame_rx_fsm #(.TIMEOUT_MAX(100)) dut ( .clk(clk), .rst_n(rst_n), .start(start), .ready(ready), .last(last), .crc_ok(crc_ok), .clr(clr), .busy(busy), .valid(valid), .err_flag(err_flag) ); initial begin // 场景一正常收完整帧 #100 rst_n 1; (posedge clk); start 1; (posedge clk); start 0; ready 1; (posedge clk); ready 0; repeat (10) (posedge clk); last 1; (posedge clk); last 0; crc_ok 1; (posedge clk); crc_ok 0; if (!valid) $display([%0t] FAIL: valid 未拉高, $time); // 场景二校验失败进错误态 #500 start 1; (posedge clk); start 0; ready 1; (posedge clk); ready 0; last 1; (posedge clk); last 0; crc_ok 0; (posedge clk); crc_ok 0; if (!err_flag) $display([%0t] FAIL: err_flag 未拉高, $time); clr 1; (posedge clk); clr 0; // 场景三超时保护 #500 start 1; (posedge clk); start 0; repeat (300) (posedge clk); // 远超 TIMEOUT_MAX if (!err_flag) $display([%0t] FAIL: 超时未进错误态, $time); #1000 $finish; end endmodule除了这种人工检查我强烈建议加一条简单的断言监控cur_state是否出现未知值property p_state_known; (posedge clk) disable iff (!rst_n) !$isunknown(cur_state); endproperty assert property (p_state_known) else $error(state is X!);这条断言成本极低但能在状态机跑飞的第一时间报警。项目里如果用 SystemVerilog还可以加状态覆盖cover property来确认每个状态都被访问过防止某些错误分支实际上从来没被验证。4. 同一套思路换个平台STM32、PLC、Java 里的状态机变形4.1 STM32 按键消抖状态机状态机的思维方式不限于硬件描述语言。嵌入式 C 里实现按键消抖用状态机比用延时函数可靠得多因为延时函数会阻塞主循环而状态机是非阻塞的和系统滴答配合能同时处理多个按键。按键消抖的三段式映射是状态变量就是当前状态switch-case里的判断就是次态逻辑对事件标志的赋值就是输出逻辑。区别在于 C 语言没有时钟沿的概念状态转移由定时器中断周期性地驱动。typedef enum { KEY_IDLE 0, // 空闲等待按下 KEY_DEBOUNCE, // 消抖确认 KEY_PRESSED, // 按下保持 KEY_RELEASE // 等待释放 } key_state_t; volatile uint8_t key_event 0; // 输出 void key_fsm_tick(void) // 每 10ms 调用一次 { static key_state_t st KEY_IDLE; static uint8_t cnt 0; uint8_t level HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin); switch (st) { case KEY_IDLE: if (level 0) { cnt 0; st KEY_DEBOUNCE; } break; case KEY_DEBOUNCE: if (level 0) { if (cnt 3) { // 连续 3 次低电平约 30ms st KEY_PRESSED; key_event 1; // 产生一次按下事件 } } else { st KEY_IDLE; // 抖动回退 } break; case KEY_PRESSED: if (level 1) { cnt 0; st KEY_RELEASE; } break; case KEY_RELEASE: if (level 1) { if (cnt 3) st KEY_IDLE; } else { st KEY_PRESSED; // 释放过程中的抖动按回按下态 } break; default: st KEY_IDLE; break; } }这里有个和 Verilog 完全对应的细节default分支一定要有把异常状态拉回 IDLE。另外key_event只在一瞬间置 1主循环读到后要负责清零这就是典型的脉冲输出和 Verilog 里valid打一拍是同一个思路。我见过不少人把事件标志一直置着不清结果主循环每轮都当成一次新按键一次按下触发了好几次功能。4.2 PLC 里的步进顺序控制写法在 PLC 领域状态机通常叫步进顺序控制或者顺控。它的写法和 Verilog 三段式几乎是同构的用一个整型寄存器存放当前步号每个步用一个比较指令判断满足转移条件就改步号输出由步号驱动。最经典的写法是这样的伪代码结构// 第一步当前步号存寄存器 D100 当前步号 // 第二步步转移逻辑 IF D100 10 AND X0 ON THEN D100 : 20 IF D100 20 AND X1 ON THEN D100 : 30 IF D100 30 AND T0 ON THEN D100 : 10 // 第三步输出逻辑 Y0 (D100 10) Y1 (D100 20 OR D100 30)这种写法的好处是设备动作顺序在代码里一目了然现场调试的人不用懂编程也能对着步号查问题。它和三段式的对应关系很清晰D100 是状态寄存器那些 IF 语句是次态逻辑Y 的赋值是输出逻辑。区别在于 PLC 是扫描执行每个扫描周期把所有逻辑跑一遍所以不存在锁存器的问题但要注意同一扫描周期内步号被多次修改的坑——如果一个扫描周期里两个转移条件同时成立步号可能连跳两步。解决办法是在每个转移判断前加上一周期步号的快照比较。另外 PLC 里的顺控一般还会配一个步监视定时器某一步停留时间超过设定值就报警。这和我在 Verilog 里做的超时计数器是同一个思想状态机一定要有出口不能有可以无限停留的状态。4.3 上层业务代码里让用户灵活定义状态机业务系统里的状态机比如订单状态、工单流转、审批流程本质和三段式一模一样只不过载体是对象和配置。用 Java 写状态机最朴素的方式是枚举加 switch但这有个问题状态和转移关系写死在代码里业务方想加一个状态就得改代码、重新发版。想做到用户可以灵活定义核心是把转移关系从代码里抽出来变成配置。我一般会定义一张转移表运行时从数据库或者配置文件加载构建成内存里的映射结构public enum OrderState { CREATED, PAID, SHIPPED, RECEIVED, CLOSED } public enum OrderEvent { PAY, SHIP, RECEIVE, CLOSE } public class StateMachine { private final MapOrderState, MapOrderEvent, OrderState table new HashMap(); public void addTransition(OrderState from, OrderEvent event, OrderState to) { table.computeIfAbsent(from, k - new HashMap()).put(event, to); } public OrderState fire(OrderState current, OrderEvent event) { MapOrderEvent, OrderState row table.get(current); if (row null || !row.containsKey(event)) { throw new IllegalStateException(非法转移: current -- event --); } return row.get(event); } }对应的配置可以长这样业务人员照着填就行{ states: [CREATED, PAID, SHIPPED, RECEIVED, CLOSED], transitions: [ { from: CREATED, event: PAY, to: PAID }, { from: PAID, event: SHIP, to: SHIPPED }, { from: SHIPPED, event: RECEIVE, to: RECEIVED }, { from: RECEIVED, event: CLOSE, to: CLOSED } ] }这么一来新增一个状态或者改一条转移路径改配置就行不用动代码。但要注意几个工程上的坑第一配置加载后一定要做一次合法性校验检查每个状态是不是都在states列表里、有没有状态出度为0、有没有可达性死角第二转移表要加缓存和版本号配置变了要有通知机制第三fire之前最好加一个守卫条件guard比如金额大于0才能支付这部分属于业务规则不适合放进通用转移表单独用接口回调更清晰。这套东西和 Verilog 三段式的精神是完全一致的把当前状态是什么数据、能往哪跳规则、跳过去做什么动作分开存放。分开了就能各自演进。5. 常见问题与排查实录5.1 综合与仿真常见异常速查表状态机的问题往往表现相似但原因各异我整理了一张对照表遇到问题的时候可以按现象快速定位。现象可能原因排查手段解决办法上板后状态机卡死不动次态逻辑漏赋值推断出锁存器看综合报告的 latch 警告块首加默认赋值补default分支状态机跳到未定义编码状态编码有悬空值无复位路径加$isunknown断言补全default分支指向安全状态输出有窄脉冲毛刺输出用了组合逻辑且依赖输入抓输出和输入的时序关系输出改为寄存输出或改用 Moore仿真对、上板错偏移一拍状态寄存器用了阻塞赋值检查第一段是否用统一改成非阻塞赋值时序违例集中在状态机附近独热码位宽太大或组合路径过长看时序报告的关键路径换编码方式或给次态逻辑插寄存器超时保护误触发计数器没在状态跳转时清零抓计数器波形用cur_state ! next_state统一清零相邻状态多位同时翻转导致干扰二进制编码跳变位多观察状态跳变瞬间的电源噪声改用格雷码或独热码这张表里的每一条我都在项目里真实遇到过尤其第一条和第三条属于新手期几乎必然踩的坑。表里最值得记住的一点是绝大多数状态机的疑难杂症根子都在默认值和输出时机这两件事上。5.2 编码风格与综合结果的坑有几个编码习惯看起来无伤大雅实际会在综合阶段带来麻烦值得单独说。第一个是滥用综合指令。有些老代码会在 case 后面加// synopsys full_case parallel_case本意是告诉工具我的 case 是完整的、条件互斥的。问题是这些指令只影响综合不影响仿真一旦你的 case 实际上不完整仿真里状态机会保持在原状态综合后却变成了无所谓的样子两者行为不一致这种 bug 极难定位。我的态度很明确不写这些指令老老实实写全default。第二个是状态位宽随手给。很多人写reg [3:0] state说不上为什么四位只是觉得够用。正确的做法是用$clog2算出来或者干脆用独热码localparam STATE_NUM 6; localparam STATE_W $clog2(STATE_NUM); // 3位宽给多了不会报错但会让综合工具和时序分析多做一些无用的工作也让代码的意图不清晰。第三个是状态名和信号名过于相似。我见过IDLE既是状态名又是端口名的情况编译器不报错但读代码的人要停下来想一下这个IDLE到底是哪一个。约定上状态名用全大写加状态后缀比如S_IDLE信号用小写加下划线一眼可分。第四个是关于状态机的分层。当状态数超过十几个、转移关系开始出现某些子流程在多个状态下复用的时候就该考虑拆成主状态机加子状态机了。主状态机负责宏观流程子状态机负责具体某个阶段的细节。这也是三段式思路的自然延伸——拆分的粒度可以再往上走一层。5.3 我踩过的几个坑说完通用问题分享三个我印象最深的坑都是我在真实项目里花过时间才搞明白的。第一个坑是输出寄存带来的隐性延迟。我做过一个 SPI 主控状态机写完后一切正常但接上某个从设备就是通不了。示波器抓了半天发现片选信号比时钟晚了两个周期才拉低正好踩在从设备的建立时间之外。原因是我用了 Moore 加寄存输出状态变化一拍、输出再滞后一拍加起来就是两拍延迟。后来把片选这一路单独拎出来改用next_state直接驱动组合输出问题就没了。这件事让我彻底明白稳态优先但延迟敏感的信号必须单独分析不能一刀切。第二个坑是超时计数器进位。那个接收状态机的超时阈值设得比较极限结果计数器在某些边界条件下多计了一个数导致刚好在临界长度的帧上误触发超时。后来在 Testbench 里专门加了长度等于阈值和长度等于阈值加一两个边界用例才把问题复现出来。经验是凡是涉及计数的逻辑边界值一定要单独测正常范围里的测试永远抓不到这类问题。第三个坑是复位释放的时序。有个项目状态机在实验室一直很稳量产回来后偶尔有板子上电后进入错误状态。查了很久才发现是复位信号的释放时刻和时钟沿太接近状态机偶尔捕获到的是未定义电平。加了复位同步器之后问题消失。这个坑让我养成一个习惯只要设计里有异步复位就顺手加一级同步释放成本很低收益很大。最后分享一个调试小技巧在综合的时候给状态信号加上(* keep true *)属性或者直接在 FPGA 工具里用在线逻辑分析仪抓状态机的状态码比看一串控制信号的波形直观得多。状态码是几你就知道机器走到哪一步了配合前面那张状态转移表定位问题能快好几倍。我个人的体会是三段式状态机从来不是什么高深的技术它只是把记忆、决策、执行这三件本来就该分开的事恰当地分开放了。这个原则在 Verilog 里成立在单片机的 C 代码里成立在 PLC 的顺控里成立在业务系统的订单流转里同样成立。真正难的从来不是写出三个 always 块而是忍住把三件事揉在一起的冲动。
返回列表