状态机设计三要素:状态、输入、输出

做FPGA设计这些年,我见过不少新手一上来就撸代码,结果状态机跑飞了都不知道怎么查。其实状态机设计没那么玄乎,说白了就是三样东西:状态、输入、输出。把这三样理清楚,你的状态机就稳了一半。

1. 状态:系统的“记忆”

状态是什么?我习惯把它理解成系统当前所处的“工作模式”。比如按键消抖,按键没按下是一个状态,按下后抖动是一个状态,稳定按下又是一个状态。每个状态都代表了系统在某个时刻的“记忆”。

这里有个关键点:状态是离散的。你不能说“稍微有点抖动”这种模糊描述,必须明确是“抖动中”还是“稳定了”。我在项目中遇到过有人把状态定义得太模糊,结果仿真怎么都过不了,最后发现是状态边界没划清楚。

状态设计原则:

  • 互斥性:同一时刻只能处于一个状态
  • 完备性:所有可能的情况都要覆盖到
  • 可观测性:每个状态都能通过输出信号看出来

2. 输入:驱动状态变化的“导火索”

输入就是外界给系统的信号。在按键消抖里,输入就是按键引脚的电平变化。但要注意,不是所有输入变化都会引起状态跳转。比如按键在稳定按下状态时,偶尔的毛刺应该被忽略。

嗯,这里有个坑。我曾经设计过一个通信协议的状态机,把输入信号直接连到了状态跳转逻辑上,结果输入毛刺导致状态机乱跳。后来加了一级同步和边沿检测才搞定。所以记住:输入信号进状态机之前,一定要先同步到系统时钟域。

我的经验:对于按键消抖这种低速信号,输入采样频率不用太高。一般用1kHz左右的时钟去采样就足够了,太高反而容易把噪声采进来。

3. 输出:状态机的“动作”

输出是状态机对外界的响应。它分为两种:

  • 摩尔型输出:只和当前状态有关。比如在“稳定按下”状态时,输出一个高电平表示按键有效。
  • 米利型输出:和当前状态以及输入都有关。比如在“抖动检测”状态时,如果检测到连续N个采样都是高电平,就提前输出一个“即将稳定”的指示信号。

我个人习惯在按键消抖这种简单场景下用摩尔型输出,逻辑清晰,不容易出错。米利型虽然灵活,但组合逻辑容易产生毛刺,调试起来比较头疼。

4. 状态转移表:把设计“画”出来

状态转移表是描述状态机行为最直观的方式。它把状态、输入、输出三要素的关系用表格形式固定下来。我每次设计状态机,第一件事就是画这个表,画清楚了再写代码。

下面是一个按键消抖状态机的状态转移表示例:

当前状态 输入(按键采样值) 下一状态 输出(按键有效信号)
空闲(IDLE) 0(按下) 抖动检测(DEBOUNCE) 0
空闲(IDLE) 1(松开) 空闲(IDLE) 0
抖动检测(DEBOUNCE) 连续N次采样为0 稳定按下(PRESSED) 0
抖动检测(DEBOUNCE) 出现1(抖动) 空闲(IDLE) 0
稳定按下(PRESSED) 0(保持按下) 稳定按下(PRESSED) 1
稳定按下(PRESSED) 1(松开) 空闲(IDLE) 0

你看,这个表把状态机的所有行为都描述清楚了。写代码的时候,直接照着表翻译成case语句就行,基本不会出错。

注意:状态转移表一定要覆盖所有输入组合。比如上表中,如果按键在“抖动检测”状态时,输入既不是连续0也不是出现1(比如采样值不稳定),那状态机就会卡住。所以实际设计中,我会加一个“其他”分支,跳转到安全状态。

5. 用状态图可视化

除了表格,我习惯再画一张状态图。图比表更直观,一眼就能看出状态机的整体结构。下面是用SVG画的一个按键消抖状态机示例:

空闲 IDLE 抖动检测 DEBOUNCE 稳定按下 PRESSED 按下(0) 出现抖动(1) 连续N次0 松开(1) 松开(1) 保持按下(0)

这张图里,三个状态用圆圈表示,箭头表示状态跳转方向,箭头上的文字标注了触发条件。你看,从“空闲”到“抖动检测”只需要一个“按下”信号,而从“抖动检测”到“稳定按下”则需要连续N次采样为0——这就是消抖的核心逻辑。

6. 从表到代码的映射

有了状态转移表,写Verilog代码就是体力活了。我一般这样写:

// 状态编码
localparam IDLE     = 2'b00;
localparam DEBOUNCE = 2'b01;
localparam PRESSED  = 2'b10;

// 状态寄存器
reg [1:0] state, next_state;

// 状态跳转逻辑(组合逻辑)
always @(*) begin
    case (state)
        IDLE: begin
            if (key_in == 1'b0)  // 按下
                next_state = DEBOUNCE;
            else
                next_state = IDLE;
        end
        DEBOUNCE: begin
            if (debounce_done)   // 连续N次采样为0
                next_state = PRESSED;
            else if (key_in == 1'b1)  // 出现抖动
                next_state = IDLE;
            else
                next_state = DEBOUNCE;
        end
        PRESSED: begin
            if (key_in == 1'b1)  // 松开
                next_state = IDLE;
            else
                next_state = PRESSED;
        end
        default: next_state = IDLE;  // 安全处理
    endcase
end

// 状态更新(时序逻辑)
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        state <= IDLE;
    else
        state <= next_state;
end

// 输出逻辑(摩尔型)
assign key_valid = (state == PRESSED);

你看,代码结构和状态转移表完全对应。我习惯把状态跳转和输出逻辑分开写,这样调试的时候可以单独看状态有没有跑偏,输出有没有毛刺。

一个小技巧:仿真的时候,把state信号拉出来看波形。如果状态跳转和预期不一致,先查输入信号有没有同步,再查状态转移条件有没有写对。我80%的bug都是在这两步找到的。

好了,状态机三要素和状态转移表就讲到这里。记住:先画表,再写代码,这是我从无数次翻车中总结出来的经验。下一节我们会用按键消抖这个具体例子,把今天讲的东西串起来跑一遍。


可可精品资料库