当前位置:首页 > 新闻资讯 > FPGA之家动态 >

FPGA逻辑设计入门:从状态机到三段式Verilog实现

时间:2026-09-15      来源:FPGA_UCY 关于我们 0

从近似0基础开始FPGA开发 -- part.6 逻辑设计状态机

不知不觉这个系列写到了第六篇。前面几篇我们聊了开发环境、Verilog语法基础、组合逻辑与时序逻辑的入门写法,也动手点过几个LED、跑过按键消抖和简单的计数器。有不少朋友私信问我说:感觉语法都看得懂,但一到自己动手写一个稍微完整点的功能,就不知道从哪下笔,总觉得代码是东拼西凑的,模块之间怎么协同、怎么控制节奏完全没概念。这个问题问到点子上了。今天这篇就专门解决它——逻辑设计的思维方式,以及状态机这个几乎所有数字电路里都绕不开的核心控制结构。

这篇内容不是讲状态机的教科书定义,而是从“拿到一个功能需求之后,大脑里应该怎么走一遍”开始,逐步拆解状态机的设计方法、代码模板、常见错误和调试技巧。学完之后,你应该能做到:看到一个稍微复杂点的逻辑功能,不再是眉毛胡子一把抓,而是能先把它拆成“状态”,然后用干净的三段式代码写出来。适合有一定Verilog基础、但还没有完整做过一个像样模块的朋友阅读,也适合准备面试时被问到状态机相关问题拿来复习。

1. 逻辑设计的第一步:不是写代码,而是画“数据怎么流动”

很多初学者有个通病:拿到需求直接开写,写到一半发现控制逻辑乱成一团,左改一处右改一处,最后能跑出结果但代码完全不能看,想加个功能就得重构。这个毛病我当初也有。后来看了不少老工程师的代码和设计文档,才慢慢意识到——真正专业的FPGA逻辑设计,花在代码之前的时间远比写代码的时间多。

1.1 先回答三个问题:输入是什么、输出是什么、什么时候更新输出

任何一个逻辑模块,不管功能多复杂,在动手之前都应该先逼自己回答清楚这三件事:

举个例子。你想做一个串口接收模块。输入是一个串行数据线上的电平信号,输出是你解析出来的一个字节数据。但“什么时候更新输出”就复杂了——不是每次电平变化都输出,而是要检测到起始位、移位接收8个数据位、校验可选的校验位、最后在一个正确的时机把并行数据打出来。这个“正确时机”本身就是一个控制问题,很难用几个else if搞定,于是状态机就登场了。

所以在写任何代码前,建议先在本子上画出模块的端口框图,标清楚每个信号的方向和位宽,再画一个简单的时序草图,说明关键信号什么时候拉高、什么时候变化。这个过程看似浪费时间,但对后续的编码效率提升是决定性的。

1.2 组合逻辑和时序逻辑的分工:谁负责算,谁负责记

FPGA逻辑设计里,组合逻辑负责“算”,时序逻辑负责“记”。这句话听起来简单,但怎么分配才算合理,很多人并没有想明白。

我的经验是: 凡是需要在某个特定时刻锁住的信号,一定要用时序逻辑;凡是只依赖当前输入的纯计算关系,则用组合逻辑。 组合逻辑最怕的是产生不期望的毛刺,而且它天然不携带“记忆”,上一时刻的输入变化了,输出立刻跟着变(忽略门延迟)。时序逻辑则不同,它只有在时钟边沿才会采样输入并更新输出,天然抗毛刺,也天然有“状态”。

早期我写代码时特别喜欢把所有信号都用reg、全写在always块里,以为这样最安全。后来发现,该用wire的用reg会把整个模块的时序路径搞得很难分析,综合后资源也偏高。正确的做法是,先明确哪个信号是“这个时钟节拍需要寄存的”,哪个信号是“根据当前条件立即算出来的”,然后再决定用always时序块还是assign。

举个例子,比如你要做一个“两个8位数的加法器,结果超过255时输出溢出指示”。被加的a和b是寄存器存好的数据,加法本身完全是组合逻辑,直接assign sum = a + b; 溢出标志 assign overflow = (a + b > 8'd255); 就行。完全不需要为了算这个加法单独写一个always块。如果加了流水线寄存器,那也只是把组合逻辑结果在时钟沿打一拍,但加法计算本身仍然是组合逻辑。

所以每次设计模块时,先在脑内把信号分成两类:一类是“状态/存储”,一类是“运算/生成”。这个分类清晰了,写Verilog时手就不会抖。

1.3 一个反直觉的观点:状态机不是“高级技巧”,而是“整理思路的工具”

很多人一听到状态机就觉得这是进阶内容,好像只有复杂协议才用得上。实际上,状态机是数字逻辑设计中最朴素的“把问题讲清楚”的方法。你完全可以不用状态机的代码结构,比如像天书一样写一堆if else的嵌套——但那样做,等于把状态信息藏在了分散的寄存器位和分支条件里,读你代码的人会非常痛苦,过了两个月你自己看也痛苦。

换个思路,如果我们把问题的“阶段”明确定义成一个个状态变量,每个状态里决定做什么、跳转条件是什么,代码就从“一堆条件判断”变成了一张清晰的流程表格。这种表格化的思考方式,在功能复杂起来时优势极其明显。

这里顺手说一个我在社区里看到很多新手犯的错误:觉得状态机一定比普通逻辑更“高级”、更“高性能”,于是连一个简单的“按键控制LED亮灭”也要写个状态机。没必要。状态机的代价是至少需要一个状态寄存器和相应的组合逻辑,对于极简单的控制逻辑,用一个触发器或者一条逻辑就够了。状态机的价值体现在 控制流程有多个阶段、有明确的先后顺序、每个阶段有不同行为 的时候——这个时候它是最好用的。

2. 状态机的两种基本模型:Moore与Mealy的取舍

当决定用状态机来组织逻辑后,下一步就要想清楚模型。教科书上会讲Moore型和Mealy型,很多初学者听完觉得这概念太理论。但实际写代码时,选对了模型能让逻辑清晰很多,选错了会导致输出时序怪怪的。

2.1 Moore型状态机的特征:输出只由当前状态决定

Moore型状态机的输出只跟当前状态有关,跟输入无关。它的好处是输出稳定,不会因为输入毛刺而立刻改变。坏处是,要想对某个输入产生响应,你通常需要多等一个或几个时钟节拍——状态必须先从某个状态跳到目标状态,输出才跟着变。

举个例子:设计一个序列检测器,检测到连续的“101”时输出一个高电平脉冲。用Moore型实现时,你可以设计四个状态:S0表示“还没有匹配到任何有效位”,S1表示“已经匹配到‘1’”,S2表示“已经匹配到‘10’”,S3表示“已经匹配到‘101’”。输出脉冲在状态等于S3时拉高。这样当你输入第三个位“1”时,状态在时钟沿跳到S3,输出才变高,天然地会晚一个周期。这个“晚一个周期”在有些场景下能接受,但在某些高速链路中就需要注意。

2.2 Mealy型状态机的特征:输出由当前状态和输入共同决定

Mealy型状态机的输出不仅跟当前状态有关,还跟当前输入有关。也就是说,输出可以在输入变化的同一时刻发生(不考虑门延迟),响应更快。但同时它也继承了组合逻辑的缺点——容易有毛刺,时序约束上要小心。

还是那个序列检测器。用Mealy型的话,状态可以少一些:S0、S1、S2三个状态,当在S2状态下输入又来了一个‘1’,组合逻辑立刻让输出拉高,不需要进入新的状态。这样电路会更“灵敏”,但输出脉冲会紧贴着输入变化,如果输入上有毛刺,输出也会跟着抖。

2.3 我的选型经验:控制路径用Moore,数据路径上的标志位用Mealy

实际工程中我不太会死守某一个模型,而是混合使用。多数情况下,主控制状态机我会倾向于Moore型,因为控制信号稳定、时序收敛更容易,代码可读性也好。但在数据通路中,有些组合产生的标志位(比如FIFO的空满信号、计数器比较器结果)天然就是Mealy式的,我不会强行把它们都寄存到状态里,而是直接用assign产生,然后根据后续模块的需要决定是否打一拍。

这里给个经验值:如果你的状态机输出是直接驱动外部引脚或者高速接口,尽量避免Mealy型的直接输出,最好把输出再寄存一级,也就是在状态机的输出之后加一个always块打一拍。这样哪怕逻辑本身存在毛刺风险,经过寄存器后也被滤掉了。代价是多一个时钟周期的延迟,但对绝大多数应用来说,这个延迟完全可接受。

3. 三段式状态机:新手最容易上手、老手也最常用的代码框架

市面上讲状态机的文章大多会提到一段式、二段式、三段式,初学者很容易被绕晕。我用最简单的话来解释:

我自己的开发习惯是: 主线状态机一律三段式 。它看起来代码量多一些,但每一段的职责极其清晰,调试和扩展都方便。

3.1 三段式的三个always块分别干什么

先看一个例子。假设我们要设计一个简单的自动售货机控制逻辑:投币口识别到1元硬币则总金额加1,金额大于等于3元时允许购买按钮按下,按下后扣除3元并输出饮料,找零逻辑省略,这个例子只用来演示状态机的骨架。

我们定义状态:

三段式代码的骨架如下:

// 状态编码,使用独热码
localparam IDLE    = 4'b0001;
localparam COUNTER = 4'b0010;
localparam ENOUGH  = 4'b0100;
localparam PAY_OUT = 4'b1000;
reg [3:0] state;
reg [3:0] next_state;
// 第一段:状态寄存器,时序逻辑,每个时钟沿更新为次态
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: begin
            if (coin_in)
                next_state = COUNTER;
        end
        COUNTER: begin
            if (total_amount >= 3'd3)
                next_state = ENOUGH;
            else if (coin_in)
                next_state = COUNTER;
            else
                next_state = IDLE; // 这里做了简化,实际可能停在COUNTER
        end
        ENOUGH: begin
            if (buy_button)
                next_state = PAY_OUT;
        end
        PAY_OUT: begin
            // 输出一个周期脉冲后回到IDLE
            next_state = IDLE;
        end
        default: next_state = IDLE;
    endcase
end
// 第三段:输出逻辑,时序逻辑,把输出都寄存一拍
reg buy_out_r;
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        buy_out_r <= 1'b0;
    else begin
        case (state)
            PAY_OUT: buy_out_r <= 1'b1;
            default: buy_out_r <= 1'b0;
        endcase
    end
end

这个例子比较简单,但已经可以看出三段式的分工了。

第一段完全机械,没什么可说的,就是“每个时钟把次态存进状态寄存器”。第二段是一个纯组合的case块,它的核心任务是“根据当前状态和输入,算出下一个状态”。第三段则是“根据当前状态,产生输出信号”,当然输出也完全可以改用组合逻辑,但寄存后更干净。

3.2 第二段里的“默认保持”为什么那么重要

很多人在写第二段组合逻辑时会漏掉默认赋值。比如直接写:

always @(*) begin
    case (state)
        IDLE: if (coin_in) next_state = COUNTER;
        COUNTER: if (...) next_state = ...;
        ...
    endcase
end

这种情况下,假如state是IDLE但coin_in为0,next_state没有被任何分支赋值,那综合时工具就会为next_state推导出一个锁存器(latch),因为组合逻辑的输出在某种输入组合下要保持“原来的值”。锁存器在FPGA里一般是不推荐使用的,它会影响时序分析,还容易产生奇怪的时序问题。

避免这个问题有两个办法,我推荐组合使用:

在case之前给next_state赋默认值为当前状态,写成 next_state = state; ,这样所有没有显式跳转的分支都会“保持当前状态”。 在case最后加default分支,把非法状态拉回初始态。

这两个都写上,代码更安全。特别是default分支,如果你的状态编码没有覆盖所有可能值(比如独热码4位其实有16个组合,但你只用了4个),仿真时一旦状态跳到了非法值,default能把状态拉回来。

3.3 独热码还是二进制码:别在这上面过度纠结

状态编码有三类常见选择:二进制码(格雷码也是它的变种)、独热码、九年级课本上还喜欢讲的“状态直接指定码”(比如十进制状态编号)。FPGA工程里最主流的就是两种: 二进制码 和 独热码 。

二进制码的优点是使用的寄存器数量少——n个状态只需要log2(n)个触发器;缺点是状态跳转时可能有多位同时变化,组合逻辑规模稍微大一点。独热码正好相反,每个状态一个触发器,n个状态需要n个触发器,寄存器数量多,但状态译码极其简单,通常只需要判断某个位的值即可,组合逻辑简单,速度也更快。

实际工程中,像Xilinx的Vivado和Altera的Quartus在新建设计文件时都会问你要不要自动做状态编码。我一般:

不要花太多时间在“到底哪种编码更优秀”的争论上。等你项目经验多了自然能感觉出来,现在重要的是先跑通一个正确的三段式状态机。

4. 从“状态图”到“Verilog”:搭一个实际例子——串口接收状态机

理论说再多,都不如手写一个完整的小模块来得实在。下面我用一个最经典的实战例子——UART串口接收,来完整演示从需求到状态图再到代码的流程。这个例子写出来,整个逻辑设计的状态机思路就活了一半。

4.1 串口接收的功能拆解

串口接收(UART RX)的输入是一个串行数据线RX,空闲时为高电平。数据传输以一帧为单位:先是1位起始位(低电平),然后是8个数据位(LSB first),最后是1位停止位(高电平)。波特率决定每一位的时间宽度。这里我们不讨论用计数器产生波特率时钟的细节,只把状态机的部分抽出来。

假如我们用波特率时钟(比数据位速率高16倍的采样时钟)来做,那么每个数据位的中心时刻附近要采样一次,以保证信号稳定。为了简化,我们直接设计一个按位滑动的状态机,状态切换频率等于波特率(即每位数据占一个时钟周期),重点看状态跳转。

状态可以划分为:

设计状态图时我习惯在纸上画一个圆圈表示状态,箭头表示跳转,箭头上标注跳转条件。这个工作看似是老古董,但真的有效。特别是状态多的时候,一张图比一千行注释都管用。

对应上面的功能,状态跳转大致是这样的:

4.2 核心代码:三段式实现串口接收状态机

下面是这个状态机的关键部分代码。为了聚焦状态机,把波特率计数和采样逻辑去掉了,聚焦在状态控制上。

module uart_rx_fsm (
    input wire clk,
    input wire rst_n,
    input wire rx,
    output reg rx_done,
    output reg [7:0] rx_data
);
localparam IDLE  = 4'b0001;
localparam START = 4'b0010;
localparam DATA  = 4'b0100;
localparam STOP  = 4'b1000;
reg [3:0] state;
reg [3:0] next_state;
reg [2:0] bit_cnt;
reg [7:0] shift_reg;
// 第一段:状态寄存器
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: begin
            if (rx == 1'b0)
                next_state = START;
        end
        START: begin
            next_state = DATA;
        end
        DATA: begin
            if (bit_cnt == 3'd7)
                next_state = STOP;
            else
                next_state = DATA;
        end
        STOP: begin
            next_state = IDLE; // 简化处理,正常/错误都回到IDLE
        end
        default: next_state = IDLE;
    endcase
end
// 数据和计数器控制:时序逻辑
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        bit_cnt <= 3'd0;
        shift_reg <= 8'd0;
    end else begin
        case (state)
            START: begin
                bit_cnt <= 3'd0;
                shift_reg <= 8'd0;
            end
            DATA: begin
                shift_reg <= {rx, shift_reg[7:1]};
                bit_cnt <= bit_cnt + 1'b1;
            end
        endcase
    end
end
// 第三段:输出寄存
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        rx_done <= 1'b0;
        rx_data <= 8'd0;
    end else begin
        rx_done <= 1'b0;
        if (state == STOP) begin
            rx_done <= 1'b1;
            rx_data <= shift_reg;
        end
    end
end
endmodule

这里我们少做了边沿消抖、采样点的精确控制,但对于理解状态机的构造足够用了。注意到我用了 next_state = state; 作为默认值,避免锁存器。另外,bit_cnt和shift_reg的控制我单独写了一个always块,并不一定非要写在第二段里。这种“状态机控制计数器、计数器影响状态机”的方式在实际工程中非常常见。

4.3 从代码反推状态图,检查有没有“死锁”

写完之后,最好的验证方式不是直接上板,而是先在仿真里跑。但在仿真之前,先拿状态图对着代码检查几件事:

比如上面代码里, rx_done 在 IDLE 状态时被默认置0,只有在STOP状态的下一个时钟沿才会拉高一个周期。严格来说,我们是“状态等于STOP时,在时钟沿后把rx_done拉高”,然后又在下个时钟沿前把它拉低。这样rx_done正好是一个周期脉冲。

如果你希望rx_done在STOP状态的时钟沿立即拉高且持续整个STOP周期(Moore型),那可以写成组合逻辑 assign rx_done = (state == STOP); ,但这样就可能和时序逻辑有区别。实际使用中要看下游的要求。我个人的习惯是,对外输出的握手信号尽量寄存一拍,给下游留出时序余量。

5. 状态机设计里的“隐藏坑”:复位、亚稳态、输出毛刺

写状态机写得多了,会发现真正的麻烦不是写本身,而是各种边界情况。

5.1 异步复位与同步复位的选择

状态机里第一段always块,我写的风格通常是 always @(posedge clk or negedge rst_n) ,这是异步复位。意思是复位信号一拉低,状态立即复位,不依赖时钟。同步复位则是只在时钟沿到来时检查复位,如:

always @(posedge clk) begin
    if (!rst_n)
        state <= IDLE;
    else
        state <= next_state;
end

两种复位在FPGA里都可以用,但要注意:

很多EDA工具(比如Vivado)会对异步复位的代码风格给出模板,推荐使用同步复位。但不管选哪种,一定要统一。我自己偏好在模块内部使用异步复位写状态寄存器,但对外部输入的异步信号会先做同步打拍处理。

5.2 输入信号跨时钟域或者来自引脚时,先进同步器再进状态机

如果你的状态机输入是外部引脚(比如按键、串口RX、编码器信号),一定一定先做“异步信号同步化”,也就是用时钟打两拍,消除亚稳态风险。以串口RX为例:

reg rx_d0, rx_d1;
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        rx_d0 <= 1'b1;
        rx_d1 <= 1'b1;
    end else begin
        rx_d0 <= rx;
        rx_d1 <= rx_d0;
    end
end
// 状态机里使用 rx_d1 而不是原始 rx

这样做之后,即使外部信号变化发生在时钟边沿附近,两拍延迟也能大大降低亚稳态传递到状态机的概率。我见过不少同学在开发板上直接把按键接到状态机输入,结果状态跳变时好时坏,加了同步器之后立刻变得稳定。这不是玄学,是数字设计的基本功。

5.3 状态机输出为什么需要“寄存一拍”?

前面也提到了寄存输出的好处。但对于驱动高速信号的场景,寄存输出还会引入一个问题:组合逻辑的timing路径可能穿过状态机的次态逻辑再传到输出,导致关键路径变长。把输出寄存成时序逻辑后,状态到输出路径就变成了“寄存器的Q端直接输出或仅经过一级寄存器”,时序会好很多。

在时序约束不严的场合,比如低速控制信号,寄存输出带来的一个周期延迟用户根本感知不到。所以我建议: 所有状态机输出,默认都寄存一拍 ,除非你对延迟极度敏感。这也是三段式相比二段式的一大优势——它天然引导你把输出逻辑写成时序逻辑。

6. 状态机仿真调试:用波形说话,别瞎猜

写完状态机,仿真这一步做不好,后面上板排查会让你怀疑人生。这里分享几个我调试状态机时常用的实操方法。

6.1 Testbench里用任务(task)把状态改成“无障碍模式”

我早期写Testbench时,总是先把输入激励一股脑写一大堆,然后跑完看波形哪里不对。其实状态机不太适合这么干。更好的方式是: 一个测试一个点,每个测试场景用task封装 。

比如对串口接收模块,可以写两个task:

task send_byte(input [7:0] data);
    integer i;
    begin
        rx = 1'b1;
        #10416; // 一个位周期,假设波特率9600
        rx = 1'b0; // 起始位
        #10416;
        for (i = 0; i < 8; i = i + 1) begin
            rx = data[i];
            #10416;
        end
        rx = 1'b1; // 停止位
        #10416;
    end
endtask

然后在initial块里分别调用 send_byte(8'hA5); send_byte(8'h3C); 等等。每次只做一个测试,看状态跳变、看输出数据、看rx_done脉冲。如果和预期不一致,缩小波形观察范围,只看状态机那几个信号,非常清晰。

6.2 仿真时把state信号导出来,就能直观看到状态跳转过程

如果你熟悉Vivado或者ModelSim的波形界面,可以把state信号以“Radix”方式显示为名字(把自己的名称映射到枚举),而不是二进制数字。用Vivado时,可以在仿真波形窗口右键信号,选择Radix -> ASII或者自定义枚举。这样一来,波形上直接显示 IDLE -> START -> DATA -> ... ,状态跳转一目了然,排查效率成倍提升。

另外,我习惯在状态机的第二段组合逻辑里临时加一个仿真断言:

// 仅仅用于仿真,不可综合
`ifdef SIM
always @(posedge clk) begin
    if (state == IDLE && rx_d1 == 1'b0)
        $display("INFO: IDLE detect start bit at time %0t", $time);
end
`endif

这种断言能帮助你在海量波形里快速定位关键事件。等仿真通过后删掉或者用`ifdef SIM包起来,不会影响综合结果。

6.3 状态机“卡死”了?先查这三处

状态机最常见的问题就是“仿真跑着跑着状态不跳了”,或者“复位后没有进入预期初始状态”。我的排查顺序是:

复位有没有正确释放? 用异步复位时,如果rst_n在仿真里还是低电平,状态机永远停在复位状态。查一下复位信号的时序。 第二段组合逻辑的默认赋值有没有被覆盖? 如果你在case的某个分支里写 next_state = state; 但在另一个分支里忘了写,综合器可能推锁存器。仿真时这类问题会表现为输出不确定(x态)。解决办法就是统一在case前赋默认值。 状态编码有没有冲突? 如果你自己定义了localparam,但位数写得不对,或者把状态赋值从3位写成了4位,波形上可能出现没有枚举到的状态值。这时检查状态编码位宽。 7. 写状态机的三条“实战军规”

最后把我在多个项目里攒下来的经验浓缩成三条军规,写代码的时候贴在屏幕边上都不过分。

7.1 统一使用“默认保持 + default兜底”

不管什么状态机,第二段组合逻辑的开头一定写:

next_state = state;

case的末尾一定写:

default: next_state = IDLE;

这两行能避免绝大多数组合逻辑锁存器问题,也能让你的状态机在遇到非法状态时自动回到安全状态。哪怕你的状态机状态数很少,也建议把default写上。“我用的独热码,非法状态不可能出现”这种话,在上电瞬间和单粒子翻转面前毫无意义。对于FPGA开发者来说,习惯性防御是最好的工程素养。

7.2 一个状态机管一件事,不要搞“超级状态机”

逻辑设计里最常见的坏味道,就是试图用一个巨型状态机搞定整个系统。比如一个图像采集项目,你可能会想把摄像头配置、行同步、像素缓存、帧输出全都放进一个状态机里。这样会导致状态数量爆炸,状态跳转条件极其复杂,稍微改一个功能就牵一发动全身。

更好的做法是“音节化”——把大流程拆成几个小状态机,模块之间通过握手信号交互。比如:

三个状态机各自的逻辑都简单清晰,模块间的接口也很明确。调试时可以先单独调通A,再调B,再调C,而不是一锅乱炖。这种设计风格也是大型FPGA工程的普遍做法。

7.3 给状态机的输出信号统一加后缀

在代码规范上,我习惯给状态机的信号命名加统一后缀:状态寄存器叫 state 或 fsm_state ,次态叫 next_state ,状态机的输出标志加 _done 、 _en 、 _req 等。这样整个工程的信号命名一看就知道该信号是从状态机出来的,作用是什么。命名统一后,团队协作时看代码非常省力。

另外,状态名我习惯全大写并用下划线分隔,比如 IDLE 、 SEND_ADDR 、 WAIT_ACK 。这样在波形里列举状态时也好认。这是QoR(Quality of Result)之外的“代码可读性规范”——代码不只是给综合器看的,更是给你自己和未来维护者看的。

8. 状态机之外:从“逻辑设计”视角聊聊模块划分

说了这么多状态机的技术细节,最后想回到“逻辑设计”这个更大的话题。状态机只是工具,真正让FPGA项目成功的,是对整个系统逻辑结构的理解。这里补充几个模块划分的思考。

8.1 控制通路和数据通路分离

一个成熟的模块,通常可以分成控制通路(Control Path)和数据通路(Data Path)。控制通路包含状态机、计数器、各种使能信号;数据通路包含数据寄存器、加法器、移位器、FIFO等。

在设计时,我会先在草稿上画出数据怎么从输入走到输出——哪个数据在哪个阶段被寄存、被运算、被选择——再画出控制信号在哪些节拍开启哪些数据操作。先把数据的流水结构定下来,再写控制逻辑。这样做的好处是,数据通路一旦确定,控制通路通常就变成一套有限状态的状态机,难度陡降。

8.2 握手信号:状态机之间对话的基本语言

如果一个项目里有多个状态机协同,它们之间的对话最好用“请求-应答”式的握手信号,而不是靠某个超长计数器估算时间。例如,状态机A要请求状态机B执行某个操作,可以发出 op_req 信号;B完成之后回一个 op_done 。A只有在等到op_done后才继续。这种方式对时序变化不敏感,也让模块之间的耦合降到最低。

握手信号的跨时钟域处理要特别注意。如果A和B在同一个时钟域,那没问题,直接用寄存器打拍即可。如果跨时钟域,就要用脉冲同步器或者异步FIFO来传导握手信号。千万别把一个域的脉冲直接接到另一个域的状态机输入上,否则大概率会偶发漏触发。

8.3 画时序图比画状态图更早

我知道很多人(包括我自己早期)拿到需求就开始画状态图,其实是绕了一步。更合理的顺序是:先画出关键信号的 时序图 ——也就是说,在时间轴上标出输入信号什么时候有效,输出信号期望什么时候产生,状态跳转的节拍点大致在哪个位置。时序图能让你明确每个动作发生的绝对时刻和相对关系。之后再从时序图中提取状态,画状态图。这样设计出的状态机才真的是“按时序工作”的,而不是自己脑补出来的空中楼阁。

比如串口接收的例子里,从时序图一眼就能看出,起始位开始时刻是下降沿,数据位采样点需要在位的中心附近,停止位结束时产生完成脉冲。有了这张图,状态划分几乎是顺理成章的。反之,如果上来就画状态图,很可能画出一个状态表示一个时间段,但具体何时采样、何时移位全都得靠后来猜,效率很低。

这也是为什么我在本篇开头强调“画数据流动”的原因。逻辑设计的本质,就是搞清楚“什么时候,什么信号,被谁,怎么处理”,而状态机只是这个过程落到代码上的一个载体。把思路理顺了,状态机自然水到渠成。

这一篇里的内容,是我真实在做FPGA项目、带新人、回答社区问题的过程中反复被验证的经验。状态机这个东西,刚学的时候觉得是一个具体知识点,做多了你会发现它其实就是逻辑设计思维的自然产物。希望大家在写完几个状态机之后,能慢慢忘掉“状态机模板”本身,而把重点放到“流程拆分”和“时序规划”上——那才算真正入了FPGA逻辑设计的门。


注明:本内容来源网络,不用于商业使用,禁止转载,如有侵权,请来信到邮箱:429562386ⓐqq.com 或联系本站客服处理,感谢配合!

用户登陆

    未注册用户登录后会自动为您创建账号

提交留言