Verilog三段式状态机设计:从摩尔/米利原理到SPI控制器实践
1. 项目概述为什么状态机是数字逻辑设计的灵魂在数字电路和FPGA/ASIC设计领域状态机Finite State Machine, FSM绝对是一个绕不开的核心概念。无论你是想实现一个简单的按键消抖还是构建一个复杂的通信协议控制器状态机都是将复杂时序逻辑行为清晰化、模块化的最佳工具。我见过太多新手工程师写的代码满屏的if-else嵌套和计数器时序混乱调试起来如同大海捞针。而一个设计良好的状态机其逻辑清晰得像一张地图状态流转一目了然。Verilog作为硬件描述语言其精髓在于“描述”硬件电路而非编写软件程序。用Verilog编写状态机就是在用代码“画”出电路的时序和组合逻辑。网上教程很多但往往只讲“三段式”的模板却不深究其背后的硬件电路对应关系导致很多人照猫画虎写出了看似正确却存在潜在风险的代码。比如状态编码选择不当可能导致功耗激增输出处理不当可能引入毛刺。这篇文章我将结合十多年的数字前端设计经验从状态机的核心思想讲起彻底拆解用Verilog实现状态机的各种方法、陷阱和最佳实践。我们的目标不仅是“写出来”更是要写出“完全可综合”、“时序干净”、“易于维护”的工业级代码。无论你是正在学习Verilog的学生还是初入行业的工程师相信这篇详尽的指南都能让你对状态机有一个脱胎换骨的理解。2. 状态机核心思想与分类摩尔与米利之辨在动手写代码之前我们必须从根本上理解状态机是什么。简单说状态机是一种数学模型它包含一组有限的状态在特定输入条件下从一个状态转换到另一个状态并可能产生输出。在硬件中状态就是用一组触发器Flip-Flop记忆下来的当前工作模式。2.1 摩尔型与米利型状态机这是状态机最基础的分类理解它们的区别是正确设计的前提。摩尔型状态机输出仅由当前状态决定。你可以把它想象成一个反应比较“慢”或“沉稳”的系统。例如一个控制交通灯的状态机红灯、绿灯、黄灯各自对应一个状态。只要系统处于“红灯”状态输出就是红灯亮与它刚才是不是绿灯、或者有没有行人按钮按下输入无关只与它现在处在哪个状态有关。在电路上输出是状态寄存器经过组合逻辑直接产生的。米利型状态机输出由当前状态和当前输入共同决定。它更像一个反应“敏捷”的系统。还用交通灯类比不太恰当我们举一个更贴切的例子一个序列检测器用于检测输入序列中的“101”。当它处于“已接收到‘10’”的状态时如果此时输入是‘1’则不仅状态会跳转到“检测成功”状态输出也会立即产生一个有效的脉冲。这个输出脉冲的产生直接依赖于当前状态已接收10和当前的输入1。为什么这个区别至关重要时序路径米利机的输出路径短因为它不经过状态寄存器直接从输入和当前状态经过组合逻辑产生。这可能导致输出出现毛刺因为输入信号可能是异步的、不稳定的。摩尔机的输出由于经过了状态寄存器同步输出更干净与时钟沿对齐但会慢一个时钟周期。代码风格与综合在Verilog中两种状态机的写法结构有差异。错误地将米利机输出写成摩尔机形式可能导致功能错误反之则可能引入不必要的时序问题。我的经验之谈在复杂的FPGA或ASIC设计中我强烈推荐优先使用摩尔型状态机。虽然它响应慢一拍但其输出是寄存器输出的无毛刺时序更容易满足对静态时序分析STA友好。米利机除非在那些对响应延迟极其苛刻且输入信号质量很高的场景否则尽量用摩尔机替代。例如可以将米利机的输出逻辑转化为下一个状态的一部分在下一个时钟周期输出本质上就变成了摩尔机。2.2 状态编码的艺术二进制、格雷码与独热码状态本身需要一组二进制码来表示这就是状态编码。选择哪种编码方式直接影响电路的面积、速度和功耗。二进制编码最紧凑的编码方式。n个状态只需要log2(n)个触发器。例如8个状态只需3个触发器。缺点是状态跳转时可能有多位同时变化如从011跳转到100三位全变在异步电路中风险高即使在同步电路中也可能因触发器偏移导致短暂误状态增加瞬态功耗。格雷码相邻状态间只有一位变化。这大大减少了状态跳变时的开关活动能有效降低动态功耗并且对避免亚稳态有一定好处。常用于计数器、低功耗设计以及一些异步状态机接口。独热码n个状态就用n个触发器每个状态只有一位为‘1’其余为‘0’。例如4个状态编码为0001001001001000。这种编码方式非常“奢侈”但有其巨大优势译码简单判断当前是什么状态几乎不需要组合逻辑直接看哪一位是‘1’即可。输出逻辑也简单。速度可能更快因为状态比较逻辑简单在含有大量状态的复杂状态机中关键路径可能更短。FPGA友好FPGA内部有丰富的触发器资源而组合逻辑资源相对紧张。独热码用触发器换组合逻辑往往是FPGA设计中的首选。如何选择小型状态机状态数8二进制或格雷码即可面积最优。中型到大型状态机状态数8尤其是在FPGA中强烈建议使用独热码。它能带来更好的时序性能和更简单的代码逻辑。超低功耗设计考虑格雷码。控制密集型设计独热码的优势明显。在Verilog中我们通常用parameter或localparam来定义这些状态编码增强代码可读性。// 独热码示例一个简单的4状态机IDLE, START, WORK, DONE localparam [3:0] S_IDLE 4b0001; localparam [3:0] S_START 4b0010; localparam [3:0] S_WORK 4b0100; localparam [3:0] S_DONE 4b1000;3. Verilog实现状态机的经典范式一段、二段与三段式这是Verilog状态机编写的核心方法论。网上常说的“三段式”是最推荐、最安全的结构但理解其演变过程一段式、二段式能让你更清楚为什么它是最佳实践。3.1 一段式状态机不推荐将所有逻辑状态转移、状态寄存器更新、输出产生都写在一个always块中通常由时钟触发。always (posedge clk or negedge rst_n) begin if (!rst_n) begin state S_IDLE; out 1b0; end else begin case (state) S_IDLE: begin if (start) begin state S_START; out 1b1; // 输出可能随状态立即变化是米利型输出 end end S_START: begin state S_WORK; out 1b0; end // ... 其他状态 default: state S_IDLE; endcase end end问题混合了时序和组合逻辑代码冗长不易维护。输出out在S_IDLE状态下直接因start信号变化这是典型的米利输出容易产生毛刺。综合工具虽然能处理但设计师对电路结构的控制力最弱。3.2 二段式状态机用两个always块一个时序逻辑块用于状态寄存器更新一个组合逻辑块用于计算次态和输出。// 时序逻辑部分状态寄存器更新 always (posedge clk or negedge rst_n) begin if (!rst_n) state_cur S_IDLE; else state_cur state_next; end // 组合逻辑部分次态和输出逻辑 always (*) begin // 默认值避免锁存器 state_next state_cur; out 1b0; case (state_cur) S_IDLE: begin if (start) begin state_next S_START; end out 1b0; // 摩尔输出仅依赖state_cur end S_START: begin state_next S_WORK; out 1b1; end // ... default: begin state_next S_IDLE; out 1b0; end endcase end优点结构清晰了一些时序和组合逻辑分离。缺点输出out仍然在组合逻辑块中产生。如果是摩尔输出它只依赖于state_cur但state_cur是寄存器所以问题不大。但若设计者不小心仍可能写出依赖于输入的米利输出导致毛刺。最大的问题是组合逻辑块容易因case语句分支赋值不全而综合出不希望的锁存器。3.3 三段式状态机强烈推荐这是工业界的主流和最佳实践。使用三个always块严格分离次态组合逻辑、状态寄存器时序逻辑、输出时序逻辑。// 第一段同步时序逻辑状态寄存器更新 always (posedge clk or negedge rst_n) begin if (!rst_n) state_cur S_IDLE; else state_cur state_next; end // 第二段组合逻辑计算次态 always (*) begin state_next state_cur; // 默认保持当前状态 case (state_cur) S_IDLE: if (start) state_next S_START; S_START: state_next S_WORK; S_WORK: if (work_done) state_next S_DONE; S_DONE: state_next S_IDLE; default: state_next S_IDLE; endcase end // 第三段同步时序逻辑或组合逻辑产生输出 always (posedge clk or negedge rst_n) begin if (!rst_n) begin out1 1b0; out2 4b0; end else begin case (state_next) // 注意这里常用state_next S_IDLE: begin out1 1b0; out2 4h0; end S_START: begin out1 1b1; out2 4h1; end // 输出提前一拍准备 S_WORK: begin out1 1b1; out2 data_in; end S_DONE: begin out1 1b0; out2 4hF; end default: begin out1 1b0; out2 4h0; end endcase end end三段式的精妙之处无毛刺输出第三段的输出是寄存器输出always (posedge clk)这意味着输出信号干净与时钟同步没有毛刺。这是摩尔机的实现。输出基于state_next这是一个关键技巧。输出逻辑根据state_next下一个状态来赋值而不是state_cur。这意味着输出比状态“提前一拍”被确定。当state_cur跳转到S_START时对应于S_START的输出已经在时钟沿被锁存好了。这有时能更好地对齐输出时序尤其当输出需要驱动复杂逻辑时。如果希望输出严格与状态同拍也可以基于state_cur但基于state_next是更常见的做法。安全性组合逻辑块第二段只负责状态转移功能单一。只要给state_next赋了默认值就绝不会产生锁存器。第三段是时序逻辑更安全。综合结果最优这种结构被所有综合工具完美支持通常会得到面积和速度最优的电路。实操心得养成习惯所有状态机都用三段式写。它就像一套“铠甲”保护你的设计免受毛刺、锁存器和时序问题的困扰。在第三段输出逻辑中我强烈建议使用时序逻辑always (posedge clk)这是获得稳定输出的基石。基于state_next赋值能让你的输出控制更精准。4. 从理论到实践设计一个可综合的SPI主机控制器状态机让我们用一个实际例子——一个简易的SPI主机控制器——来贯穿上述所有概念。SPI通信需要产生SCK时钟、片选CS、发送数据MOSI并读取MISO数据。我们将设计一个支持单字节发送/接收的状态机。4.1 系统需求与状态定义假设系统时钟clk为50MHzSPI时钟sck为5MHz。我们需要以下状态S_IDLE空闲状态CS拉高SCK为初始电平假设CPOL0则SCK空闲为低。S_START启动传输拉低CS准备第一个数据位。S_SHIFT移位状态。在这个状态我们需要产生SCK时钟边沿并在正确的边沿移位数据。由于SCK频率较低我们需要一个计数器来分频。S_STOP传输结束拉高CS恢复空闲。我们选择独热码编码因为状态数不多但逻辑清晰。localparam [3:0] S_IDLE 4b0001; localparam [3:0] S_START 4b0010; localparam [3:0] S_SHIFT 4b0100; localparam [3:0] S_STOP 4b1000;4.2 模块接口与内部信号定义module spi_master_fsm ( input wire clk, // 50MHz 系统时钟 input wire rst_n, // 低电平复位 input wire start, // 启动传输脉冲 input wire [7:0] tx_data, // 待发送数据 output reg [7:0] rx_data, // 接收到的数据 output reg sck, // SPI 时钟 output reg cs_n, // SPI 片选低有效 output reg mosi, // SPI 主机输出 input wire miso // SPI 主机输入 ); // 状态寄存器定义 reg [3:0] state_cur, state_next; // 移位寄存器与位计数器 reg [7:0] tx_shift_reg, rx_shift_reg; reg [2:0] bit_cnt; // 计数0-7共8位 reg [3:0] div_cnt; // 分频计数器50M/5M/2 5需要计数到4 // 常量定义 localparam DIV_MAX 4; // 分频比-1关键点tx_shift_reg和rx_shift_reg用于在S_SHIFT状态进行移位操作。bit_cnt记录已传输的位数8位满则结束。div_cnt用于从50MHz系统时钟产生5MHz的SCK。因为SCK一个周期需要10个系统时钟50M/5M每个半周期边沿需要5个时钟。我们将在S_SHIFT状态用这个计数器来翻转sck。4.3 三段式状态机完整实现第一段状态寄存器always (posedge clk or negedge rst_n) begin if (!rst_n) state_cur S_IDLE; else state_cur state_next; end第二段次态组合逻辑这是状态机的核心决策逻辑。always (*) begin state_next state_cur; // 默认保持原状态 case (state_cur) S_IDLE: begin if (start) // 收到启动信号 state_next S_START; end S_START: begin // 启动状态只保持一个周期进入移位状态 state_next S_SHIFT; end S_SHIFT: begin // 当8位数据都移完且分频计数器在一个周期结束时跳出 if ((bit_cnt 3d7) (div_cnt DIV_MAX)) state_next S_STOP; end S_STOP: begin // 停止状态保持一个周期回到空闲 state_next S_IDLE; end default: state_next S_IDLE; endcase end第三段输出与时序逻辑这是最复杂的一段包含了输出生成和内部计数器逻辑。always (posedge clk or negedge rst_n) begin if (!rst_n) begin sck 1b0; // CPOL0空闲低电平 cs_n 1b1; // 空闲时片选无效 mosi 1b0; tx_shift_reg 8h00; rx_shift_reg 8h00; rx_data 8h00; bit_cnt 3d0; div_cnt 4d0; end else begin case (state_next) // 注意是state_next S_IDLE: begin cs_n 1b1; sck 1b0; // 保持空闲电平 bit_cnt 3d0; div_cnt 4d0; // 可以在这里锁存发送数据 if (start) tx_shift_reg tx_data; end S_START: begin cs_n 1b0; // 拉低片选启动传输 // 其他信号保持或初始化 end S_SHIFT: begin cs_n 1b0; // --- 分频计数器逻辑 --- if (div_cnt DIV_MAX) begin div_cnt 4d0; sck ~sck; // 翻转SCK产生边沿 end else begin div_cnt div_cnt 1; end // --- 在SCK的特定边沿进行数据移位 --- // 假设CPHA0数据在第一个边沿上升沿变化在第二个边沿下降沿采样 // 我们这里在sck的下降沿即div_cnt计数到一半时改变MOSI在上升沿采样MISO if (div_cnt (DIV_MAX/2)) begin // 下降沿附近 // 准备下一位MOSI数据最高位先发 mosi tx_shift_reg[7]; // 左移为下一位做准备 tx_shift_reg {tx_shift_reg[6:0], 1b0}; end if (div_cnt 4d0 sck 1b1) begin // 上升沿时刻 // 采样MISO数据 rx_shift_reg {rx_shift_reg[6:0], miso}; bit_cnt bit_cnt 1; // 增加位计数 end end S_STOP: begin cs_n 1b1; // 拉高片选结束传输 sck 1b0; // 恢复空闲电平 // 将接收到的移位寄存器值赋给输出寄存器 rx_data rx_shift_reg; end default: begin // 安全默认值 cs_n 1b1; sck 1b0; end endcase end end这段代码的详细解读复位所有信号初始化为确定值。S_IDLE等待start信号。当start有效时锁存tx_data到发送移位寄存器。这是一个很好的数据缓冲点。S_START拉低cs_n。这个状态通常只持续一个时钟周期用于产生一个干净的片选下降沿。S_SHIFT核心工作状态。div_cnt循环计数用于产生SCK。DIV_MAX4计数0,1,2,3,4然后归零并翻转sck。这样sck的半周期是5个clk全周期10个clk即5MHz。数据移位时机是关键。我们模拟CPHA0模式在div_cnt计数到一半DIV_MAX/2时此时sck即将或刚变为低电平下降沿我们更新mosi为要发送位的值tx_shift_reg[7]然后移位寄存器左移。在div_cnt归零且sck为高电平时上升沿我们采样miso数据到接收移位寄存器并递增bit_cnt。bit_cnt用于跟踪已传输的位数达到7时表示最后一位正在传输。S_STOP传输结束拉高cs_n将接收到的完整字节rx_shift_reg赋值给输出端口rx_data。这个状态也通常只维持一个周期。注意事项SPI的时钟极性CPOL和相位CPHA有四种模式。上述代码实现了模式0CPOL0 CPHA0。如果要支持其他模式需要调整sck的初始值和数据移位的边沿。最好的做法是将CPOL和CPHA作为模块参数parameter然后在代码中用if-else或case语句根据参数选择不同的边沿控制逻辑。5. 状态机设计的进阶技巧与常见陷阱掌握了基本写法我们还需要关注那些让状态机更稳健、更高效的细节。5.1 状态机初始化与“死机”恢复一个健壮的状态机必须能从上电或异常中恢复。复位信号必须对所有状态寄存器、内部计数器和输出寄存器进行复位。异步复位同步释放是常用技巧。default分支在第二段的case (state_cur)语句中务必添加default: state_next S_IDLE;。这能确保综合工具不会生成锁存器并且当状态机因干扰进入非法状态如独热码变成了4‘b0011时能在下一个时钟周期回到初始状态。对于独热码非法状态很多这个default分支是安全的保障。看门狗对于极其关键的状态机可以添加一个超时计数器。如果在一个状态停留时间远超预期强制跳回S_IDLE。5.2 输出寄存与消除毛刺这是三段式状态机最大的优势之一。第三段的输出是寄存器输出这意味着无毛刺输出信号的变化只发生在时钟边沿消除了组合逻辑产生的毛刺。毛刺在高速系统中是致命的可能导致后续电路误动作。时序可预测输出延迟是固定的一个时钟周期寄存器Tco便于系统级时序分析。如果输出需要立即响应输入米利特性怎么办尽量避免。如果必须可以单独为该输出设计一个短路径的组合逻辑但必须清楚其风险。更好的方法是重新设计状态将输入的影响纳入状态跳转条件在下一个周期输出。5.3 状态转移图与代码的对应关系在动手写代码前先用纸笔画一个状态转移图。圆圈代表状态箭头代表转移条件和输出。这个图是你的设计蓝图。工具可以用Visio、Draw.io甚至白纸手绘。检查画完后检查是否所有状态都有出路是否存在无法进入的“孤岛”状态复位状态是否明确对应状态转移图中的每一个箭头都对应第二段case语句里的一个if条件。图中的输出标注对应第三段case语句里的赋值。做到图码一致是调试的基石。5.4 仿真与调试技巧写状态机不难难的是验证它是否正确。仿真波形在仿真工具如ModelSim, VCS中将state_cur、state_next、关键输入输出、内部计数器如bit_cnt,div_cnt都添加到波形窗口。查看状态跳转确保状态跳转与设计意图一致特别是在边界条件下如start脉冲过短、过长、与时钟不同步。检查输出时序确认mosi数据是否在正确的SCK边沿变化miso是否在正确的边沿被采样。检查cs_n的建立和保持时间。编写全面的测试平台测试平台应覆盖所有状态转移路径包括正常流程和异常情况如复位发生在传输中、连续启动等。6. 综合与实现中的实际问题代码写得好还要综合工具“认得准”。6.1 综合属性与指令有时需要指导综合工具如何优化状态机。full_case与parallel_case在Verilog中case语句如果不完整会综合出锁存器。我们已经通过赋默认值避免了。full_case指令告诉工具所有情况已覆盖parallel_case指令告诉工具分支是互斥的。但谨慎使用在Synopsys DC中这些指令会影响优化。在FPGA综合中如Vivado, Quartus现代工具很智能通常不需要。滥用可能导致功能错误。状态编码约束如果你想强制使用独热码可以在综合工具中设置。例如在Xilinx Vivado的XDC文件中set_property FSM_ENCODING one_hot [get_cells -hierarchical *state_cur_reg*]。在代码中用(* fsm_encoding one_hot *)reg [3:0] state_cur; 这样的属性语句也可以工具支持的话。6.2 面积与速度的权衡独热码 vs 二进制码独热码用更多的触发器换更简单的组合逻辑。在FPGA中触发器资源丰富这通常是好交易。在ASIC中需要更仔细地评估。工具的综合报告会告诉你用了多少LUTs和FFs。输出逻辑优化第三段的输出逻辑如果很复杂可能会成为关键路径。如果时序紧张可以考虑对输出进行流水线打拍或者将部分输出逻辑计算提前到第二段组合逻辑部分但要注意这可能会将毛刺引入计算路径。6.3 跨时钟域与亚稳态处理如果状态机的输入信号来自另一个时钟域比如start信号来自一个更慢的按钮时钟直接使用是危险的会导致亚稳态。同步器必须使用两级或多级触发器进行同步。reg start_sync1, start_sync2; always (posedge clk or negedge rst_n) begin if (!rst_n) {start_sync2, start_sync1} 2b00; else {start_sync2, start_sync1} {start_sync1, start_async}; end // 然后使用 start_sync2 作为状态机的输入边沿检测对于脉冲信号同步后还需要进行边沿检测确保每个启动脉冲只被捕获一次。reg start_sync2_dly; always (posedge clk) start_sync2_dly start_sync2; wire start_pulse start_sync2 ~start_sync2_dly; // 上升沿检测7. 状态机设计模式与复杂场景应对简单的线性状态机容易但现实工程中状态机往往更复杂。7.1 层次化状态机当状态太多时一个大的状态机会变得难以维护。可以采用层次化设计即“大状态机”包含“小状态机”。主从状态机一个主状态机如S_IDLE,S_TX_CMD,S_TX_DATA,S_RX_DATA控制高层流程。在每个主状态下可以激活一个独立的子状态机如S_TX_DATA状态下运行一个具体的字节发送状态机。子状态机完成工作后给主状态机一个完成信号。优点结构清晰模块化易于调试和复用。7.2 超时与错误处理机制健壮的状态机必须处理异常。超时计数器在可能“卡住”的状态如S_SHIFT等待外部响应添加一个计数器。如果超时则跳转到错误处理状态如S_ERROR或S_IDLE并拉高一个错误标志error_flag。错误状态设计一个专门的S_ERROR状态。进入该状态后可以尝试恢复操作如重试几次或者通知上层系统并等待复位。7.3 状态机与数据路径的分离这是一个重要的设计哲学控制流与数据流分离。控制流由状态机实现负责产生控制信号如shift_en,load_en,cnt_en。数据路径由独立的模块或逻辑实现如移位寄存器、计数器、FIFO等。它们受状态机产生的控制信号指挥。好处状态机变得简洁只关注状态跳转条件数据路径可以独立优化和验证。例如上面的SPI例子中tx_shift_reg和rx_shift_reg的移位操作就是在状态机产生的shift_en隐含在div_cnt条件中控制下进行的。更清晰的写法是状态机输出明确的shift_en和sample_en信号。最后记住状态机设计是一个迭代过程设计 - 画图 - 编码 - 仿真 - 调试 - 优化。从简单的摩尔型三段式开始严格遵守寄存器输出原则处理好复位和默认情况你的状态机就成功了一大半。在后续的实践中你会遇到更复杂的场景但万变不离其宗理解状态、转移、输出这三个核心要素你就能驾驭任何复杂的时序逻辑设计。