第四讲:三段式状态机——状态寄存器、次态逻辑、输出逻辑三段分离
各位同学,今天我们来聊聊状态机的“三段式”写法。
说实话,我刚入行那会儿,写状态机都是“一段式”——一个 always 块搞定所有。那时候觉得挺爽,代码短啊。直到有一次,我负责一个通信协议的控制模块,状态多了,逻辑复杂了,一段式代码看得我头皮发麻。改一个状态,要小心翼翼翻半天。从那以后,我就彻底倒向了三段式。
三段式状态机,说白了就是把状态机拆成三个独立的部分:状态寄存器、次态逻辑、输出逻辑。各管各的,互不干扰。你想想看,这就像公司里的三个部门——人事管人、财务管钱、技术管代码,职责清晰,出了问题也好追责。
4.1 三段式结构总览
我们先看一张图,把整体框架搭起来。
这张图我画得比较直白。三个模块各司其职,数据流清晰可见。我个人习惯把这种结构叫做“三权分立”——每个模块只做一件事,做好一件事。
4.2 状态寄存器——最纯粹的时序逻辑
状态寄存器,它的任务只有一个:在每个时钟上升沿,把次态(next_state)锁存为当前状态(current_state)。
嗯,这里要注意:它不关心次态是怎么来的,也不关心输出是什么。它就是个“搬运工”。
核心要点:状态寄存器必须使用非阻塞赋值(<=),这是时序逻辑的铁律。
// 状态寄存器 —— 时序逻辑
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
current_state <= IDLE;
else
current_state <= next_state;
end
你看,就这么几行。我见过有些新手在状态寄存器里加一堆 if-else 判断,其实完全没必要。状态寄存器就该像一张白纸,干干净净。
个人经验:我曾经在一个项目中,把复位逻辑和状态跳转逻辑混在一起写。结果仿真没问题,综合后却出了奇怪的毛刺。后来排查了三天,才发现是状态寄存器里多写了一个组合逻辑。从那以后,我的状态寄存器永远只有两行赋值。
4.3 次态逻辑——组合逻辑的“大脑”
次态逻辑,说白了就是状态机的“大脑”。它根据当前状态和输入信号,决定下一个状态是什么。
这里有个关键点:次态逻辑是纯组合逻辑。也就是说,它不依赖时钟,输入一变,输出立刻变。
// 次态逻辑 —— 组合逻辑
always @(*) begin
next_state = current_state; // 默认保持
case (current_state)
IDLE: begin
if (start)
next_state = READ;
else
next_state = IDLE;
end
READ: begin
if (data_valid)
next_state = DONE;
else
next_state = READ;
end
DONE: begin
next_state = IDLE;
end
default: next_state = IDLE;
endcase
end
避坑指南:我曾经犯过一个低级错误——在次态逻辑里用了非阻塞赋值(<=)。结果仿真时状态跳来跳去,完全不对。后来才想起来,组合逻辑必须用阻塞赋值(=)。这个坑,我替你们踩过了。
另外,注意我写了一个默认赋值 next_state = current_state。为什么要这么做?因为组合逻辑容易产生锁存器(latch)。如果你在 case 语句里漏掉了某个分支,综合工具会给你“补”一个锁存器。这玩意儿在数字电路里就是个定时炸弹。
4.4 输出逻辑——干净利落的“执行者”
输出逻辑,根据当前状态产生控制信号。它也是组合逻辑。
我个人习惯把输出逻辑单独写一个 always 块,而不是和次态逻辑混在一起。为什么?因为可读性。你想想看,如果次态逻辑和输出逻辑写在一起,代码会变成什么样?
// 输出逻辑 —— 组合逻辑
always @(*) begin
// 默认值
data_out = 8'h00;
valid = 1'b0;
ready = 1'b0;
case (current_state)
IDLE: begin
ready = 1'b1;
end
READ: begin
ready = 1'b0;
end
DONE: begin
data_out = mem_data;
valid = 1'b1;
end
default: begin
data_out = 8'h00;
valid = 1'b0;
ready = 1'b0;
end
endcase
end
重要原则:输出逻辑只读 current_state,不读 next_state。如果你发现输出逻辑里出现了 next_state,那说明你的设计思路可能跑偏了。
这里还有一个细节:给所有输出信号赋默认值。这能有效避免锁存器。我见过很多新手不写默认值,结果综合报告里一堆 latch warning。虽然功能可能没错,但看着就不专业。
4.5 三段式的完整代码示例
好了,我们把三段拼起来,看看完整的代码长什么样。
// 三段式状态机完整示例
module fsm_example (
input wire clk,
input wire rst_n,
input wire start,
input wire data_valid,
input wire [7:0] mem_data,
output reg [7:0] data_out,
output reg valid,
output reg ready
);
// 状态编码
localparam IDLE = 2'b00;
localparam READ = 2'b01;
localparam DONE = 2'b10;
// 状态寄存器
reg [1:0] current_state;
reg [1:0] next_state;
// 第一段:状态寄存器
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
current_state <= IDLE;
else
current_state <= next_state;
end
// 第二段:次态逻辑
always @(*) begin
next_state = current_state;
case (current_state)
IDLE: begin
if (start)
next_state = READ;
end
READ: begin
if (data_valid)
next_state = DONE;
end
DONE: begin
next_state = IDLE;
end
default: next_state = IDLE;
endcase
end
// 第三段:输出逻辑
always @(*) begin
data_out = 8'h00;
valid = 1'b0;
ready = 1'b0;
case (current_state)
IDLE: begin
ready = 1'b1;
end
READ: begin
ready = 1'b0;
end
DONE: begin
data_out = mem_data;
valid = 1'b1;
end
default: begin
data_out = 8'h00;
valid = 1'b0;
ready = 1'b0;
end
endcase
end
endmodule
4.6 三段式的优势总结
说了这么多,三段式到底好在哪?我列个表,一目了然。
| 对比项 | 一段式 | 三段式 |
|---|---|---|
| 代码可读性 | 状态跳转和输出混在一起,逻辑复杂时难以阅读 | 三段分离,每段只做一件事,一目了然 |
| 维护难度 | 改一个状态要小心翼翼,容易引入 bug | 改状态跳转只动次态逻辑,改输出只动输出逻辑 |
| 综合结果 | 容易产生不必要的锁存器 | 结构清晰,综合工具优化更充分 |
| 团队协作 | 别人接手你的代码,得花时间“破译” | 规范统一,团队内任何人都能快速理解 |
| 调试效率 | 仿真波形里状态和输出混在一起 | 单独看 current_state 和 next_state 就能定位问题 |
我的建议:如果你刚开始学状态机,直接上三段式。别走一段式的弯路。我当年就是先学了一段式,后来改三段式时花了整整一周去“洗脑”。你直接学三段式,省掉这个痛苦的过程。
4.7 代码风格与可读性的几个小习惯
最后,分享几个我个人的代码风格习惯。这些不是硬性规定,但能让你的代码看起来更专业。
- 状态编码用 localparam,别用 `define。 localparam 的作用域只在当前模块,不会污染全局命名空间。
- 状态命名用大写。 IDLE、READ、DONE,一眼就能看出是状态。
- 每个 always 块只做一件事。 状态寄存器、次态逻辑、输出逻辑,各占一个 always 块,绝不混用。
- 组合逻辑里给所有输出赋默认值。 这能避免 90% 的锁存器问题。
- 次态逻辑用 case 语句,别用 if-else 嵌套太深。 case 语句的可读性远好于多层 if-else。
嗯,差不多就这些。三段式状态机,说白了就是“分而治之”的思想在数字电路设计里的体现。你把它拆开了、揉碎了,每个部分都简单到不可能出错,那整个设计也就稳了。
记住:好的代码,不是让机器觉得聪明,而是让人看得舒服。