6、状态机设计:Moore型与Mealy型状态机、三段式写法、状态编码(二进制/格雷码/独热码)、实战:交通灯控制器
状态机这东西,说白了就是数字电路里的“大脑”。你想想看,一个系统要按顺序干活,总得有个东西记住它干到哪一步了吧?状态机就是干这个的。我在FPGA项目里,几乎每个控制逻辑都离不开它。今天咱们就把状态机彻底聊透。
6.1 Moore型与Mealy型状态机
先说说两种基本类型。Moore型和Mealy型,区别其实就一句话:输出跟谁有关。
- Moore型:输出只取决于当前状态。说白了,状态变了,输出才变。
- Mealy型:输出不仅取决于当前状态,还取决于输入。输入一变,输出可能立刻跟着变。
我刚开始学的时候,总觉得Mealy型更“聪明”,因为它反应快。但后来在项目中吃过亏——Mealy型容易产生毛刺,因为输入信号本身可能不稳定。你想想看,输入抖一下,输出就跟着抖,这在时序要求严格的场合很要命。
我的建议:如果对输出时序要求严格,优先用Moore型。如果对响应速度要求高,且输入信号干净,可以考虑Mealy型。
举个简单的例子。一个检测“101”序列的检测器:
- Moore型:需要4个状态(S0~S3),每个状态对应一个固定输出。
- Mealy型:只需要3个状态,检测到“101”的瞬间就输出高电平。
你看,Mealy型状态少,但输出是组合逻辑,容易出毛刺。Moore型状态多,但输出是寄存器输出,干净得很。
6.2 三段式写法
写状态机,我个人强烈推荐三段式。为什么?因为好维护、好调试、不容易出错。
三段式就是把状态机拆成三个always块:
- 第一段:时序逻辑,描述状态跳转(当前状态 → 次态)。
- 第二段:组合逻辑,描述次态逻辑(根据输入和当前状态,计算下一个状态)。
- 第三段:时序逻辑,描述输出逻辑(根据当前状态或次态,产生输出)。
我曾经见过有人把这三段揉在一起写,结果代码改起来跟拆炸弹似的。三段式的好处是:每一段职责清晰,出问题了定位也快。
来看个例子,一个简单的两状态机:
// 第一段:状态寄存器
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
current_state <= IDLE;
else
current_state <= next_state;
end
// 第二段:次态逻辑
always @(*) begin
case (current_state)
IDLE: begin
if (start)
next_state = WORK;
else
next_state = IDLE;
end
WORK: begin
if (done)
next_state = IDLE;
else
next_state = WORK;
end
default: next_state = IDLE;
endcase
end
// 第三段:输出逻辑
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
out <= 1'b0;
else if (current_state == WORK)
out <= 1'b1;
else
out <= 1'b0;
end
小技巧:第二段用组合逻辑(@(*)),第三段用时序逻辑(@(posedge clk))。这样输出是寄存器输出,没有毛刺。我习惯把第三段也写成时序逻辑,这样输出更稳定。
6.3 状态编码:二进制/格雷码/独热码
状态编码这事儿,很多人不重视。其实选对了编码,能省不少事。
| 编码方式 | 特点 | 适用场景 | 状态数 |
|---|---|---|---|
| 二进制码 | 状态数少,但相邻状态跳变时多位变化 | 状态数少(<16)且对功耗不敏感 | N个状态需log2(N)位 |
| 格雷码 | 相邻状态只有1位变化,抗干扰好 | 状态连续跳变,对功耗/噪声敏感 | N个状态需log2(N)位 |
| 独热码 | 每个状态对应1位,译码简单,速度快 | 状态数多(>16),追求速度 | N个状态需N位 |
我在项目中遇到过一个问题:用二进制码写了一个16状态的控制器,结果综合后时序不满足。后来换成独热码,问题就解决了。为什么?因为独热码的译码逻辑简单,组合逻辑级数少,路径延迟小。
但独热码也有缺点——浪费寄存器资源。你想想看,16个状态需要16个触发器,而二进制码只需要4个。不过现在的FPGA资源都挺富裕的,一般不在乎这点。
注意:格雷码在跨时钟域传输时特别好用。我曾经用格雷码做异步FIFO的指针,完美解决了亚稳态问题。如果你有信号要从快时钟域传到慢时钟域,优先考虑格雷码。
6.4 实战:交通灯控制器
好了,理论说完了,咱们来点实战。交通灯控制器,这是状态机的经典案例。
需求是这样的:
- 主干道绿灯30秒,黄灯5秒,红灯40秒。
- 支干道绿灯25秒,黄灯5秒,红灯45秒。
- 两个方向交替通行。
我设计的时候,先把状态图画出来。嗯,这里要注意:状态不要画得太细,否则代码会很长。我习惯把“绿灯30秒”作为一个状态,而不是把每一秒都作为一个状态。
状态定义:
localparam IDLE = 4'b0001; // 初始状态
localparam MAIN_GREEN = 4'b0010; // 主干道绿灯
localparam MAIN_YELLOW = 4'b0100; // 主干道黄灯
localparam SIDE_GREEN = 4'b1000; // 支干道绿灯
// 支干道黄灯我直接用MAIN_GREEN的前一个状态表示,省一个状态
这里我用的是独热码。为什么?因为状态数少(4个),独热码译码简单,而且代码可读性好。
核心代码片段:
// 第二段:次态逻辑 + 计时器
always @(*) begin
next_state = current_state;
case (current_state)
IDLE: begin
next_state = MAIN_GREEN;
time_out = 30; // 30秒
end
MAIN_GREEN: begin
if (timer == 30)
next_state = MAIN_YELLOW;
else
next_state = MAIN_GREEN;
time_out = 5;
end
MAIN_YELLOW: begin
if (timer == 5)
next_state = SIDE_GREEN;
else
next_state = MAIN_YELLOW;
time_out = 25;
end
SIDE_GREEN: begin
if (timer == 25)
next_state = MAIN_GREEN; // 回到主干道绿灯
else
next_state = SIDE_GREEN;
time_out = 5; // 支干道黄灯时间
end
default: begin
next_state = IDLE;
time_out = 0;
end
endcase
end
// 第三段:输出逻辑
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
main_red <= 1'b0;
main_green <= 1'b0;
main_yellow <= 1'b0;
side_red <= 1'b0;
side_green <= 1'b0;
side_yellow <= 1'b0;
end else begin
case (current_state)
MAIN_GREEN: begin
main_green <= 1'b1;
main_yellow <= 1'b0;
main_red <= 1'b0;
side_red <= 1'b1;
side_green <= 1'b0;
side_yellow <= 1'b0;
end
MAIN_YELLOW: begin
main_green <= 1'b0;
main_yellow <= 1'b1;
main_red <= 1'b0;
side_red <= 1'b1;
side_green <= 1'b0;
side_yellow <= 1'b0;
end
SIDE_GREEN: begin
main_green <= 1'b0;
main_yellow <= 1'b0;
main_red <= 1'b1;
side_red <= 1'b0;
side_green <= 1'b1;
side_yellow <= 1'b0;
end
// 支干道黄灯我放在SIDE_GREEN的计时结束前处理
default: begin
// 默认输出红灯
main_red <= 1'b1;
side_red <= 1'b1;
end
endcase
end
end
避坑指南:我曾经在交通灯项目里犯过一个低级错误——忘记处理计时器的复位。结果每次状态跳转时,计时器还保留着上一个状态的计数值,导致灯的时间全乱了。记住:状态跳转时,计时器一定要清零。
这个交通灯控制器,我用的是Moore型。为什么?因为输出只跟当前状态有关,逻辑清晰,不容易出错。如果你想让支干道黄灯在最后3秒闪烁,那可以考虑用Mealy型,根据计时器的值来产生闪烁输出。
好了,状态机这块就聊这么多。记住:三段式是基本功,独热码是首选,Moore型更稳妥。下次写状态机的时候,试试我的方法,你会发现代码好写多了。