6.2 有限状态机:三段式写法 本节摘要:有限状态机是控制逻辑的通用形态:状态寄存器、次态组合、输出与数据通路三段分离,各司其职。三段式写法让状态机的每一步都可评审、可综合优化、可追溯波形。写状态机的功夫在状态图,代码只是状态图的忠实转录。 规模问题有了 generate 这个工具,控制复杂度的对手登场。任何带步骤、带阶段、带协议的行为——总线事务、串口收发、指令流水——背后都是一台状态机。这一节把状态机的建模方法一次讲透:先讲清两种风格与编码选择,再用一个串口接收机做完整示范。 Moore 与 Mealy:输出从哪来 状态机按输出的来源分两派:Moore 型输出只由当前状态决定,稳定、易对时序,但响应输入要慢一拍;
本节摘要:有限状态机是控制逻辑的通用形态:状态寄存器、次态组合、输出与数据通路三段分离,各司其职。三段式写法让状态机的每一步都可评审、可综合优化、可追溯波形。写状态机的功夫在状态图,代码只是状态图的忠实转录。
规模问题有了 generate 这个工具,控制复杂度的对手登场。任何带步骤、带阶段、带协议的行为——总线事务、串口收发、指令流水——背后都是一台状态机。这一节把状态机的建模方法一次讲透:先讲清两种风格与编码选择,再用一个串口接收机做完整示范。
状态机按输出的来源分两派:Moore 型输出只由当前状态决定,稳定、易对时序,但响应输入要慢一拍;Mealy 型输出由状态与输入共同决定,响应快、状态数可能更少,但输出路径上多了输入信号,组合路径更长、还可能带毛刺。工程主流是 Moore 型或「Moore 为主、关键路径局部 Mealy」的混合,理由是可维护性:Moore 的输出与状态一一对应,看状态就知道输出,波形上极好对账。
状态编码同样要选:二进制编码省寄存器(N 个状态按二的幂算位数),但状态译码组合逻辑复杂;独热编码每位状态寄存器对应一个状态,译码只需看一位,路径短、时钟快,代价是寄存器多。FPGA 的触发器资源富裕、组合逻辑相对贵,独热常是默认选择;ASIC 里小状态机常用二进制省面积。编码本身用 localparam 定义常量即可,切换编码不用改逻辑结构——这是状态定义与编码分离的写法红利。
一段式写法把次态逻辑、状态更新、输出全塞进一个 always 块,代码短但评审困难;三段式把三者拆开,每段一个 always,职责清晰。先看状态图——写代码之前先有它:
三段式转录如下(次态逻辑与数据通路按段拆开,这是要综合的正确写法):
module uart_rx #( parameter CLKS_PER_BIT = 104 // 波特率分频比示例值 )( input wire clk, input wire rst_n, input wire rx, output reg [7:0] data, output reg done ); localparam S_IDLE = 3'd0, S_START = 3'd1, S_DATA = 3'd2, S_STOP = 3'd3; reg [2:0] state, next_state; reg [6:0] baud_cnt; reg [2:0] bit_idx; reg start_ok; // 起始位二次确认结果 // 第一段:状态寄存器(时序) always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= S_IDLE; else state <= next_state; end // 第二段:次态组合(组合块) always @(*) begin next_state = state; // 默认保持:防漏分支 case (state) S_IDLE: if (!rx) next_state = S_START; S_START: if (start_ok) next_state = S_DATA; else if (baud_cnt == (CLKS_PER_BIT-1)/2 && rx) next_state = S_IDLE; // 中点仍是高:毛刺,回空闲 S_DATA: if (baud_cnt == CLKS_PER_BIT-1 && bit_idx == 3'd7) next_state = S_STOP; S_STOP: if (baud_cnt == CLKS_PER_BIT-1 && rx) next_state = S_IDLE; // 停止位必须为高 default: next_state = S_IDLE; // 非法状态安全兜底 endcase end // 第三段:数据通路与输出(时序) always @(posedge clk or negedge rst_n) begin if (!rst_n) begin data <= 8'd0; done <= 1'b0; baud_cnt <= 7'd0; bit_idx <= 3'd0; start_ok <= 1'b0; end else begin done <= 1'b0; // 单拍脉冲默认值 case (state) S_IDLE: begin baud_cnt <= 7'd0; bit_idx <= 3'd0; start_ok <= 1'b0; end S_START: begin // 到中点做二次确认 if (baud_cnt == (CLKS_PER_BIT-1)/2) start_ok <= ~rx; if (baud_cnt == CLKS_PER_BIT-1) begin baud_cnt <= 7'd0; start_ok <= 1'b0; end else baud_cnt <= baud_cnt + 1'b1; end S_DATA: begin if (baud_cnt == CLKS_PER_BIT-1) begin baud_cnt <= 7'd0; data[bit_idx] <= rx; // 低位在先 bit_idx <= bit_idx + 1'b1; end else baud_cnt <= baud_cnt + 1'b1; end S_STOP: begin if (baud_cnt == CLKS_PER_BIT-1) begin done <= 1'b1; // 停止位收满,报告完成 baud_cnt <= 7'd0; end else baud_cnt <= baud_cnt + 1'b1; end default: ; endcase end end endmodule
三段各守其职:第一段只做状态更新,「沿、复位、赋次态」三行,审一眼就够;第二段纯组合算次态,第一行 next_state = state 是默认值兜底,分支漏了也只是「保持」而不是锁存器;第三段是数据通路计数器与输出,注意 done 在块头先清零再按条件置一——这是「单拍脉冲输出」的标准写法。
段间纪律必须钉死:次态变量 next_state 只允许在第二段赋值。初学者常在第三段的时序块里顺手改次态,结果次态逻辑出现两个驱动者,综合直接报多驱动错误;即使工具放行,状态机的评审结构也被破坏。所有转移条件(包括毛刺回退、异常分支)都收进第二段,第三段只管数据与输出——段间边界越硬,状态机越好审。

案例展开:串口接收机的毛刺防线。 背景:串口线经过连接器与长线,起始沿常带毛刺,直收误码率高。操作:在状态图与代码里加两道防线——起始位在半位宽处二次确认(start_ok 机制:中点再采一次,仍是低才算数),停止位必须为高(S_STOP 校验 rx 为高才返回空闲并置 done)。结果:同等噪声环境下误码率显著下降,且异常帧被停止位校验拦下,上层不做无效解析。解读:中点采样既确认了起始沿的真实性,又把后续每个数据位的采样点对齐到位的正中央,一次设计动作同时解决抗噪与采样相位两个问题。变式:更严苛的链路再加奇偶校验位与帧间空闲超时;校验失败时状态机走「丢弃帧」路径而不是置 done,让上层用计数器统计链路质量——状态机负责判废,管理层负责统计,职责分离。
从这份代码提炼一张评审清单:状态定义是否覆盖所有合法情形、非法编码是否有 default 兜底;次态是否只在第二段赋值;输出是否全部寄存器化(组合输出会向下游泄漏毛刺与长路径);单拍脉冲是否有「先清零」默认值;状态编码的位宽是否与状态数匹配;跨状态的计数器是否在每个进入路径上正确复位。这些问题问下来,常规状态机的毛病基本无处藏身——第九章的错案清单里它们还会列队出现。
控制复杂度就位。下一节进入并发世界:跨时钟域同步与仲裁器——错误成本最高、也最考验物理直觉的领域。