引言
如果说组合逻辑和触发器是硬件设计的「字母」,那么有限状态机(FSM)就是「句子」。几乎所有控制逻辑——协议握手、总线仲裁、DMA 调度、CPU 取指——最终都归结为一个状态机:根据当前状态和输入,决定下一个状态和输出。
状态机的难点不在于「怎么写」,而在于「怎么写得可综合、可验证、可时序收敛」。同一个功能,一段式写法可能因为组合路径过长而跑不到目标频率,两段式写法可能因为输出带毛刺而让下游误触发,状态编码选错可能让面积翻倍或让时序恶化。更棘手的是,状态机很少孤立存在——它往往横跨多个时钟域,而跨时钟域(CDC)是数字设计中最容易产生「随机、难复现」故障的地方。
本文按「表达方式 → 编码策略 → 时序工程 → 验证」的顺序展开。前四节讲怎么写一个正确清晰的状态机,中间四节讲怎么让它可靠地跑在真实时钟下,最后讲怎么验证它。代码用可综合的 Verilog-2001 风格,示例以 UART 发送器贯穿。
目录
- 状态机的三种描述风格
- Moore 与 Mealy 的本质区别
- 状态编码:binary、gray 与 one-hot
- 三段式 FSM 完整实例
- 状态机设计规范与常见反模式
- 跨时钟域:CDC 的三种基本结构
- 亚稳态与同步器设计
- 复位策略:同步与异步
- 时钟使能与时钟分频
- 关键路径与流水线插入
- FSM 的验证方法
- 实战:UART 发送状态机
1. 状态机的三种描述风格
同一个状态机可以有三种写法,它们在可读性、时序和综合结果上差异明显。
一段式:把状态转移和输出逻辑塞进同一个时序块。代码短,但输出是寄存器输出(无毛刺),可读性差,状态一多就难以维护。
两段式:一个组合块算次态,一个时序块更新状态,输出用组合逻辑直接由状态译码。次态逻辑清晰,但输出是组合的,带毛刺。
三段式:次态组合块 + 状态时序块 + 输出时序块。输出寄存一拍,无毛刺、时序好,代价是输出比状态晚一个周期。这是工程上最推荐的写法。
// 两段式:输出组合译码(有毛刺风险)
always @(*) begin
case (state)
IDLE: busy = 1'b0;
RUN: busy = 1'b1;
default: busy = 1'b0;
endcase
end
// 三段式:输出寄存器化(推荐)
always @(posedge clk or negedge rst_n) begin
if (!rst_n) busy <= 1'b0;
else case (next_state)
IDLE: busy <= 1'b0;
RUN: busy <= 1'b1;
default: busy <= 1'b0;
endcase
end
判断标准很简单:输出要驱动异步控制信号(复位、时钟使能、片选)时,必须用寄存器输出。
2. Moore 与 Mealy 的本质区别
Moore 机的输出只取决于当前状态;Mealy 机的输出取决于当前状态和当前输入。
| 维度 | Moore | Mealy |
|---|---|---|
| 输出依赖 | 仅状态 | 状态 + 输入 |
| 输出时序 | 与状态同步,晚一拍 | 输入变化即响应,早一拍 |
| 状态数 | 通常更多 | 通常更少 |
| 毛刺敏感性 | 低(输出寄存后) | 高(输入组合穿透) |
| 典型场景 | 控制流、时序要求宽松 | 需要快速响应、减少状态 |
一个直观的例子:检测输入序列 101。Moore 机需要 4 个状态(等待 1、已收 1、已收 10、已收 101),输出在进入第 4 个状态时才拉高;Mealy 机只需 3 个状态,在「已收 10」状态下看到输入 1 就立即输出。
// Mealy 风格输出:输入组合穿透
assign match = (state == S_RECV10) && din;
Mealy 的输出对输入毛刺敏感,如果输入来自异步域或未寄存,输出就会出现窄脉冲。工程上的折中做法是:用 Mealy 做逻辑,但把输出再寄存一拍,兼顾状态数少和输出干净。
3. 状态编码:binary、gray 与 one-hot
状态编码直接影响面积、功耗和时序。三种主流方案:
| 编码 | 位宽(N 状态) | 次态译码 | 面积 | 适合 |
|---|---|---|---|---|
| Binary | ceil(log2 N) | 复杂(多级译码) | 小 | 状态多、资源紧张 |
| Gray | ceil(log2 N) | 复杂 | 小 | 状态按序迁移、降功耗 |
| One-hot | N | 极简(一位一状态) | 大(FF 多) | FPGA、状态少、高频 |
FPGA 上默认选 one-hot。原因是 FPGA 的触发器资源远比 LUT 丰富:one-hot 用 N 个触发器,但次态译码只需一级 LUT;binary 省触发器,却需要多级译码逻辑,拖长关键路径。Xilinx/Vivado 的综合选项 -fsm_encoding auto 会自动做这个权衡,多数情况下会选 one-hot。
// one-hot 编码:综合器会自动识别,也可手动指定
localparam [3:0] S_IDLE = 4'b0001,
S_START = 4'b0010,
S_DATA = 4'b0100,
S_STOP = 4'b1000;
Gray 编码的价值在低功耗场景:相邻状态切换只有一位翻转,动态功耗正比于翻转次数,因此适合状态机大部分时间在做顺序迁移的设计。但如果状态跳转是任意的(如根据命令跳到任意状态),Gray 的优势就消失了。
4. 三段式 FSM 完整实例
下面是一个通用三段式状态机骨架,可以作为模板复用:
module fsm_template (
input wire clk, rst_n, start, done,
output reg busy, finish
);
localparam [1:0] IDLE = 2'b00, RUN = 2'b01, DONE = 2'b10;
reg [1:0] state, next_state;
// 第一段:状态寄存器(时序)
always @(posedge clk or negedge rst_n)
if (!rst_n) state <= IDLE;
else state <= next_state;
// 第二段:次态组合逻辑
always @(*) begin
next_state = state; // 默认保持,避免 latch
case (state)
IDLE: if (start) next_state = RUN;
RUN: if (done) next_state = DONE;
DONE: next_state = IDLE;
default: next_state = IDLE;
endcase
end
// 第三段:输出寄存器化(时序)
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin busy <= 1'b0; finish <= 1'b0; end
else begin
busy <= (next_state == RUN);
finish <= (next_state == DONE);
end
end
endmodule
注意第二段的 next_state = state; 默认赋值——它同时消除了锁存器风险,并让「未覆盖的分支保持当前状态」这一意图显式化。
5. 状态机设计规范与常见反模式
几条经过验证的规范:
- 状态定义用
localparam,不要用define或裸数字。define是全局文本替换,容易冲突;裸数字完全不可读。 - 每个 case 都有
default,且default应跳到安全状态(通常是 IDLE)或触发错误标志,而不是保持不动——保持不动会让非法状态永久锁死。 - 三段式输出用
next_state译码而非state,这样输出与状态同拍更新,不会晚一拍。 - 不要在状态机里做算术:把数据通路(如计数器、累加器)与状态机分离,状态机只管控制。
反模式清单:
// 反模式 1:default 保持不动 → 非法状态死锁
default: next_state = state;
// 反模式 2:在 case 里直接算 → 组合路径长
S_ADD: begin result = a + b + c + d; next_state = S_OUT; end
// 反模式 3:状态用裸数字 → 不可读、易错
case (state) 0: ...; 1: ...; 2: ...; endcase
6. 跨时钟域:CDC 的三种基本结构
当信号从一个时钟域传到另一个时钟域时,如果两个时钟没有固定相位关系,直接连接会导致亚稳态。处理方式取决于信号类型:
(1)单比特电平信号 → 两级同步器
// 两级触发器同步器:打两拍降低亚稳态传播概率
reg sync1, sync2;
always @(posedge clk_dst or negedge rst_n) begin
if (!rst_n) {sync2, sync1} <= 2'b00;
else {sync2, sync1} <= {sync1, src_signal};
end
assign dst_signal = sync2;
(2)单比特脉冲信号 → 脉冲同步器(toggle 法)
慢时钟域的一个脉冲在快时钟域里可能只持续不到一个周期而被漏采。标准解法是在源域把脉冲转成电平翻转,在目标域用边沿检测还原成脉冲:
// 源域:脉冲 → 翻转
always @(posedge clk_src) if (pulse_in) toggle <= ~toggle;
// 目标域:同步后做边沿检测,还原成脉冲
always @(posedge clk_dst) begin
{sync2, sync1} <= {sync1, toggle};
pulse_out <= sync2 ^ sync1; // 检测到变化即输出一个周期脉冲
end
(3)多比特数据 → 异步 FIFO 或握手
多比特信号不能逐位同步,因为各比特到达时间不同会导致「混合值」。必须用异步 FIFO(基于双端口 RAM + 格雷码指针)或握手协议(req/ack 四相握手)。这是 RISC-V 外设与中断控制器 里跨总线时钟域的标准做法。
7. 亚稳态与同步器设计
亚稳态(metastability) 指触发器在时钟边沿采样到恰好处于建立/保持窗口内变化的输入时,输出既不是 0 也不是 1,而是在中间电平振荡一段时间后才随机稳定。
关键事实:
- 亚稳态无法完全消除,只能把发生概率和传播时间降到可接受水平。
- 两级同步器把 MTBF(平均无故障时间)从秒级提升到数百年甚至更长。
- 同步器只能用于单比特信号;多比特必须用 FIFO 或握手。
MTBF ≈ e^(t_r / τ) / (T_0 × f_clk × f_data)
t_r = 留给亚稳态稳定的时间(≈ 一个时钟周期)
τ = 触发器的亚稳态时间常数(工艺相关)
T_0 = 亚稳态窗口宽度
f_clk, f_data = 时钟频率与数据变化频率
推论:时钟越快、数据变化越频繁,MTBF 越差
→ 高频设计里同步器要打到三拍,或用专门的 CDC 工具检查
形式化约束:Xilinx 的 set_max_delay -datapath_only、set_false_path、set_clock_groups -asynchronous 都是告诉 STA 工具「这条路径是异步的,不要按时钟周期分析」。漏写这些约束,工具会按同步路径分析,报出大量虚假违例。
8. 复位策略:同步与异步
| 维度 | 异步复位 | 同步复位 |
|---|---|---|
| 触发条件 | 复位信号电平 | 时钟边沿 + 复位电平 |
| 上电可靠性 | 高(无需时钟) | 依赖时钟存在 |
| 时序分析 | 需要 recovery/removal 检查 | 按普通路径分析 |
| FPGA 资源 | 触发器原生支持,免费 | 占用数据输入逻辑 |
| 复位释放 | 可能不同步释放 → 亚稳态 | 天然同步 |
工程上的标准做法是异步复位、同步释放:复位断言是异步的(保证上电即复位),释放时经过两级同步器(避免不同触发器退出复位的时间不一致)。
// 异步复位同步释放
reg rst_sync1, rst_sync2;
always @(posedge clk or negedge rst_async_n) begin
if (!rst_async_n) {rst_sync2, rst_sync1} <= 2'b00;
else {rst_sync2, rst_sync1} <= {rst_sync1, 1'b1};
end
wire rst_n = rst_sync2; // 用这个复位后续逻辑
另外一条重要原则:复位只用于控制路径,不用于数据路径。数据通路上大规模寄存器组(如 FIFO 存储体)不复位,靠写指针的有效性保证正确性——这样能省下大量复位布线资源。
9. 时钟使能与时钟分频
不要用分频出来的时钟去驱动逻辑。分频时钟会引入新的时钟域、增加偏斜、让 STA 复杂化。正确做法是用时钟使能(clock enable):逻辑仍跑在同一个时钟上,只在使能有效时更新。
// 好的做法:时钟使能实现 1/8 分频效果
reg [2:0] div_cnt;
wire tick = (div_cnt == 3'd7);
always @(posedge clk) div_cnt <= div_cnt + 1'b1;
always @(posedge clk) begin
if (tick) slow_reg <= slow_next; // 每 8 拍更新一次
end
// 坏的做法:门控时钟
// assign gated_clk = clk & enable; // 毛刺 + 偏斜 + 时序不可控
如果确实需要不同频率,用 FPGA 的 MMCM/PLL 硬核生成,它们提供专用的时钟网络、可配置相位和占空比,并且工具会自动为这些时钟做时序约束。用 LUT 分频得到的时钟走的是普通布线,偏斜可能达到纳秒级。
10. 关键路径与流水线插入
当状态机的次态逻辑成为关键路径时,标准解法是重定时(retiming)或插入流水线。
原路径: FF → [大组合逻辑 6ns] → FF @ 150MHz 违例
插流水线:FF → [3ns] → FF → [3ns] → FF @ 300MHz 满足
代价:延迟增加一拍,面积增加 N 个触发器
对于状态机,常见的流水线手法有:把输出译码寄存一拍(三段式天然如此)、把状态译码拆成两拍(第一拍算「下一状态类别」,第二拍算具体状态)、把与输入无关的部分提前预计算。流水线会改变时序行为,验证时必须重跑完整回归,因为很多测试用例对「第几拍出结果」是敏感的。
11. FSM 的验证方法
状态机的验证要覆盖三类问题:状态覆盖、转移覆盖、序列覆盖。
- 状态覆盖:每个状态是否都被访问过。
- 转移覆盖:每条状态转移边是否都被走过(尤其
default分支和异常转移)。 - 序列覆盖:关键序列(如「IDLE → RUN → DONE → IDLE」完整循环)是否被执行。
// 用 covergroup 统计状态覆盖(SystemVerilog)
covergroup cg_fsm @(posedge clk);
cp_state: coverpoint state {
bins idle = {IDLE};
bins run = {RUN};
bins done = {DONE};
illegal_bins bad = default; // 任何非法状态立即报错
}
cp_trans: coverpoint state {
bins idle_to_run = (IDLE => RUN);
bins run_to_done = (RUN => DONE);
}
endgroup
对于安全攸关的状态机,还应加入形式验证:用 SVA 断言「非法状态永不出现」「finish 拉高后必然回到 IDLE」等不变式。相关方法在 SystemVerilog 验证与 testbench 中详述。
12. 实战:UART 发送状态机
UART 发送是状态机的经典应用:空闲时线路为高,发送时先拉低一个起始位,然后按 LSB 优先发送 8 位数据,最后拉高停止位。
module uart_tx #(parameter CLK_FREQ = 50_000_000, BAUD = 115200) (
input wire clk, rst_n,
input wire tx_start,
input wire [7:0] tx_data,
output reg tx,
output reg tx_busy
);
localparam BAUD_DIV = CLK_FREQ / BAUD; // 50e6/115200 ≈ 434
localparam [1:0] S_IDLE = 2'd0, S_START = 2'd1, S_DATA = 2'd2, S_STOP = 2'd3;
reg [1:0] state;
reg [15:0] baud_cnt;
reg [2:0] bit_idx;
reg [7:0] shreg;
wire baud_tick = (baud_cnt == BAUD_DIV - 1);
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
state <= S_IDLE; tx <= 1'b1; tx_busy <= 1'b0;
baud_cnt <= 0; bit_idx <= 0; shreg <= 0;
end else begin
case (state)
S_IDLE: begin
tx <= 1'b1; tx_busy <= 1'b0; baud_cnt <= 0; bit_idx <= 0;
if (tx_start) begin
shreg <= tx_data; tx_busy <= 1'b1; state <= S_START;
end
end
S_START: begin
tx <= 1'b0; // 起始位
if (baud_tick) begin baud_cnt <= 0; state <= S_DATA; end
else baud_cnt <= baud_cnt + 1'b1;
end
S_DATA: begin
tx <= shreg[0]; // LSB 优先
if (baud_tick) begin
baud_cnt <= 0;
shreg <= {1'b0, shreg[7:1]};
if (bit_idx == 3'd7) state <= S_STOP;
else bit_idx <= bit_idx + 1'b1;
end else baud_cnt <= baud_cnt + 1'b1;
end
S_STOP: begin
tx <= 1'b1; // 停止位
if (baud_tick) begin baud_cnt <= 0; state <= S_IDLE; end
else baud_cnt <= baud_cnt + 1'b1;
end
default: state <= S_IDLE;
endcase
end
end
endmodule
这个状态机是一段式(状态、输出、计数器都在一个块里),因为输出与状态强耦合、逻辑简单,一段式反而更紧凑。当状态数超过 8 个或输出信号超过 5 个时,就应该改用三段式。
接收侧是它的镜像,但要额外做中点采样:在每个位周期的中间时刻采样,才能容忍收发两端的波特率误差。这是 RISC-V 外设与中断控制器 里 UART 外设的核心设计点。
权衡取舍
| 决策点 | 选项 A | 选项 B |
|---|---|---|
| 描述风格 | 一段式:紧凑、输出无毛刺 | 三段式:清晰、易维护、时序好 |
| 输出类型 | Moore:稳定、晚一拍 | Mealy:快一拍、状态少、怕毛刺 |
| 状态编码 | one-hot:FPGA 首选、译码快 | binary:面积小、译码慢 |
| 复位 | 异步复位同步释放:通用最佳 | 纯同步复位:简单、无 recovery 问题 |
| 跨时钟域 | 两级同步器:单比特最省 | 异步 FIFO:多比特唯一正确解 |
| 时钟 | 时钟使能:单时钟域、时序好 | MMCM 分频:确需异频时用 |
常见坑清单
- 次态组合块缺默认赋值:
next_state在部分分支未赋值,推断出锁存器且状态机行为异常,务必先next_state = state;。 default保持不动:非法状态(如辐射翻转或复位不完整导致)会永久锁死,应跳到安全状态。- 输出用组合逻辑直接驱动异步控制:毛刺会导致下游误触发,输出必须寄存。
- 多比特信号跨时钟域:逐位同步会得到「混合值」,必须用异步 FIFO 或握手。
- 漏写 CDC 约束:STA 把异步路径当同步分析,报大量虚假违例,或反过来漏掉真实违例。
- 用 LUT 分频时钟:偏斜大、时序不可控,应用 MMCM/PLL 或改用时钟使能。
- 复位覆盖不全:时序块里某个信号在复位分支没赋值,上电后为 X,行为随机。
- 同步器只打一拍:单级同步器 MTBF 极低,高频设计至少两拍、关键路径三拍。
- 状态机里混入数据通路:计数器、累加器与状态逻辑耦合,导致关键路径过长。
- 改了状态编码没重新验证:one-hot 改 binary 会改变非法状态集合,覆盖测试必须重跑。
小结
状态机是数字设计的通用控制骨架,写好它的关键有三点:用三段式分离状态、次态与输出,用寄存器输出隔离毛刺,把跨时钟域处理当作一等公民而非事后补丁。前两点决定设计的可维护性和时序收敛难度,第三点决定设计在真实系统中会不会随机出错。
时序工程的核心概念——亚稳态、同步器、复位策略、时钟使能——不仅适用于状态机,也适用于整个 SoC。理解它们之后,你就能看懂为什么一个「仿真完全正确」的设计上板会随机失败,以及该怎么定位这类问题。
下一步建议阅读 FPGA 时序约束与收敛 ,把这里提到的 CDC 约束、时钟约束、关键路径优化落到具体的 XDC/SDC 语法和时序报告上;如果你更想先补齐验证能力,可以直接跳到 SystemVerilog 验证与 testbench 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。