4. 状态机设计:一段式、两段式、三段式状态机的对比与选择

状态机这东西,说白了就是数字逻辑里的“大脑”。它决定你的模块在什么时候该干什么事。我见过不少新手,一上来就写一段式,结果代码又臭又长,调试起来想哭。今天咱们就把一段式、两段式、三段式掰开揉碎,再聊聊编码和安全。

核心观点:没有最好的写法,只有最适合当前场景的写法。我个人习惯,复杂控制逻辑必用三段式,简单计数器才用一段式。

4.1 三种状态机写法对比

先看个简单的例子:一个三状态循环机,IDLE → WORK → DONE → IDLE。咱们分别用三种风格实现,你马上就能看出区别。

4.1.1 一段式:全挤在一个always块里

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        state <= IDLE;
        out <= 1'b0;
    end else begin
        case (state)
            IDLE: begin
                out <= 1'b0;
                if (start) state <= WORK;
            end
            WORK: begin
                out <= 1'b1;
                if (done) state <= DONE;
            end
            DONE: begin
                out <= 1'b0;
                state <= IDLE;
            end
        endcase
    end
end

嗯,这段代码看着挺紧凑对吧?但问题来了:状态跳转和输出逻辑混在一起。我在项目中遇到过,一旦状态超过5个,这种写法改起来特别容易漏掉某个分支。而且你想想看,组合逻辑和时序逻辑混在一个块里,综合工具很难优化。

避坑:我曾经在一个UART接收模块里用了一段式,结果因为输出赋值时机不对,导致数据采样总差一个周期。后来改成三段式,问题立刻解决。

4.1.2 两段式:状态跳转和输出分开

// 第一段:时序逻辑,状态跳转
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) state <= IDLE;
    else        state <= next_state;
end

// 第二段:组合逻辑,状态转移条件和输出
always @(*) begin
    next_state = state;
    out = 1'b0;
    case (state)
        IDLE: begin
            if (start) next_state = WORK;
        end
        WORK: begin
            out = 1'b1;
            if (done) next_state = DONE;
        end
        DONE: begin
            next_state = IDLE;
        end
    endcase
end

两段式把状态寄存器单独拿出来,转移逻辑和输出放在组合逻辑里。这样结构清晰多了。但有个坑:组合逻辑里的输出容易产生毛刺。如果你的输出直接驱动外部模块,最好加一级寄存器打一拍。

4.1.3 三段式:状态、转移、输出彻底分离

// 第一段:状态寄存器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) state <= IDLE;
    else        state <= next_state;
end

// 第二段:次态组合逻辑
always @(*) begin
    next_state = state;
    case (state)
        IDLE:   if (start) next_state = WORK;
        WORK:   if (done)  next_state = DONE;
        DONE:              next_state = IDLE;
    endcase
end

// 第三段:输出寄存器(时序输出)
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) out <= 1'b0;
    else begin
        case (next_state)
            WORK: out <= 1'b1;
            default: out <= 1'b0;
        endcase
    end
end

三段式是我最推荐的写法。你看,状态跳转、转移条件、输出逻辑各管各的。输出还用了时序逻辑,彻底消除毛刺。你想想看,调试的时候只要看第三段就知道输出对不对,多省心。

小技巧:三段式的输出基于next_state而不是state,这样输出会比状态变化提前一个周期。如果你需要输出和状态同步,那就基于state判断。

4.2 状态编码:独热码、格雷码、二进制码

编码方式直接影响面积和功耗。我见过有人不管三七二十一全用独热码,结果状态多了面积爆炸。咱们得按场景选。

编码方式 位宽 功耗 速度 适用场景
二进制码 log₂(N),最省寄存器 中等,多位同时翻转 中等,需要译码 状态数多(>16),面积敏感
格雷码 log₂(N) 低,每次只变1位 中等 跨时钟域、低功耗场景
独热码 N位,最费寄存器 低,每次只变2位 快,无需译码 状态数少(≤8),高速场景

我个人习惯:状态少于8个用独热码,8到16个用格雷码,超过16个用二进制码。为什么?独热码的译码逻辑最简单,就一个与门,速度最快。但状态一多,寄存器数量线性增长,划不来。

经验之谈:我曾经在一个DMA控制器里用了16个状态的独热码,综合后面积比二进制码大了3倍。后来改成格雷码,面积降下来了,功耗也低了。

4.3 状态机安全

状态机跑飞了怎么办?我遇到过,芯片上电后莫名其妙进入了一个未定义状态,整个模块死在那。这就是状态机不安全的表现。

安全设计就两个要点:

  • 全覆盖case:用casez或casex配合default,确保所有状态都有归宿
  • 状态恢复:检测到非法状态时,自动跳回IDLE或复位
always @(*) begin
    next_state = state;
    case (state)
        IDLE:   next_state = WORK;
        WORK:   next_state = DONE;
        DONE:   next_state = IDLE;
        default: next_state = IDLE;  // 非法状态强制复位
    endcase
end

你看,default分支就是安。我还见过更狠的做法:加一个状态合法性检查器,一旦发现状态值不在合法集合里,直接拉高复位信号。

注意:综合工具默认会把未定义状态当成don't care,优化掉。如果你不加default,那些非法状态可能永远回不来。我曾经吃过这个亏,流片回来才发现。

4.4 知识结构总览

下面这张图把状态机设计的核心脉络串起来了。你对照着看,心里就有谱了。

状态机设计知识结构图 状态机设计知识结构 状态机设计 三种写法风格 状态编码方式 状态机安全 一段式 两段式 三段式 二进制码 格雷码 独热码 全覆盖case 状态恢复 选择建议 状态少用独热码+三段式 | 状态多用二进制码+两段式 | 跨时钟域用格雷码

4.5 总结与选择建议

说了这么多,最后给个实操建议:

  • 新手入门:一律用三段式+独热码,代码清晰,不容易出错
  • 面积敏感:状态数超过16个,换二进制码+两段式
  • 低功耗场景:格雷码+三段式,减少翻转率
  • 跨时钟域:格雷码+双寄存器同步,天然安全
  • 安全第一:不管哪种写法,default分支必须写,非法状态必须处理

最后一句:状态机写得好不好,直接决定你的模块稳不稳。别嫌三段式代码长,它省下的调试时间,够你喝好几杯咖啡的。