第四章 硬件描述语言基础(Verilog):模块化设计、组合逻辑与时序逻辑、状态机编写规范

各位同学,欢迎来到第四章。从这一章开始,我们正式进入硬件描述语言的世界。说实话,Verilog 这门语言,你把它当成软件来写,那肯定要踩坑。我见过太多人把 FPGA 当 CPU 用,结果综合出来的电路完全不是那么回事。

这一章我们聚焦三个核心:模块化设计组合逻辑与时序逻辑状态机编写规范。这三个东西,说白了就是 FPGA 设计的骨架、血肉和大脑。我们一个一个来拆解。

Verilog 设计三要素 模块化设计 接口清晰 · 层次分明 组合逻辑与时序逻辑 always块 · 阻塞与非阻塞 状态机编写规范 三段式 · 可综合 · 防冒险 目标:写出可综合、可维护、可复用的硬件代码

4.1 模块化设计:把大问题拆成小积木

模块化设计,说白了就是「分而治之」。一个大的 AI 加速器,你不可能在一个文件里写完所有逻辑。我个人的习惯是:一个模块只干一件事,接口要少,功能要纯

举个例子,我们要设计一个卷积计算单元。你想想看,如果我把输入缓存乘法累加激活函数输出缓冲全部写在一个 always 块里,那代码维护起来简直是噩梦。正确的做法是拆成四个模块:

  • input_buffer:负责数据重排和缓存
  • mac_array:负责乘加运算
  • activation_unit:负责 ReLU / Sigmoid 等激活
  • output_buffer:负责结果暂存和输出

每个模块独立仿真、独立验证。我在项目中遇到过一个问题:某个模块的时序不满足,结果因为耦合太紧,改一个地方牵连了七八个模块。从那以后,我强制要求团队:模块间用 valid-ready 握手信号通信,内部逻辑不许跨模块引用。

模块化设计三原则:

  1. 高内聚:一个模块只做一件事,并且做好
  2. 低耦合:模块间通过标准接口通信,不直接操作内部寄存器
  3. 层次化:顶层只做连线,不写逻辑

来看一个简单的模块定义示例:

// 一个简单的累加器模块
module accumulator #(
    parameter DATA_WIDTH = 16
)(
    input  wire                     clk,
    input  wire                     rst_n,
    input  wire                     valid_in,
    input  wire [DATA_WIDTH-1:0]   data_in,
    output reg  [DATA_WIDTH-1:0]   sum_out,
    output reg                      valid_out
);

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            sum_out   <= 'd0;
            valid_out <= 1'b0;
        end else if (valid_in) begin
            sum_out   <= sum_out + data_in;
            valid_out <= 1'b1;
        end else begin
            valid_out <= 1'b0;
        end
    end

endmodule

注意看,这个模块的接口非常清晰:时钟、复位、输入数据、输出结果。外部调用者只需要关心 valid 信号,不需要知道内部是怎么累加的。这就是模块化的精髓。

4.2 组合逻辑与时序逻辑:硬件工程师的左右手

很多初学者搞不清楚组合逻辑和时序逻辑的区别。我打个比方:组合逻辑就像水管,水流过去就立刻出来;时序逻辑就像水桶,要等时钟来了才舀一勺

在 Verilog 中,组合逻辑用 assign 或者 always @(*) 描述,时序逻辑用 always @(posedge clk) 描述。这里有个关键点:阻塞赋值 (=) 和非阻塞赋值 (<=) 绝对不能混用

我曾经在调试一个 AI 加速器的数据路径时,发现计算结果总是差一拍。查了两天才发现,有人在组合逻辑里用了非阻塞赋值,导致仿真和综合结果不一致。嗯,这个坑我替你们踩过了。

重要规则:

  • 组合逻辑 always 块内:使用 阻塞赋值 (=)
  • 时序逻辑 always 块内:使用 非阻塞赋值 (<=)
  • 同一个 always 块内:不要混用两种赋值方式

来看一个对比示例:

// 组合逻辑:多路选择器
always @(*) begin
    case (sel)
        2'b00: mux_out = a;
        2'b01: mux_out = b;
        2'b10: mux_out = c;
        default: mux_out = 'd0;
    endcase
end

// 时序逻辑:带使能的寄存器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        data_q <= 'd0;
    end else if (en) begin
        data_q <= data_d;
    end
end

你想想看,如果我把组合逻辑里的 = 换成 <=,仿真时可能看不出问题,但综合出来的电路会多出很多不必要的锁存器。这就是为什么我一直强调:写代码的时候就要想清楚,你描述的是硬件,不是软件

4.3 状态机编写规范:让控制逻辑井井有条

状态机是 FPGA 设计的核心控制单元。在大模型加速器中,状态机负责调度数据流、控制计算单元、管理存储访问。我个人强烈推荐三段式状态机,它把状态跳转、状态判断、输出逻辑分得清清楚楚。

三段式状态机的结构:

段落 描述 always 块类型
第一段 状态寄存器更新(当前状态 → 下一状态) 时序逻辑 (posedge clk)
第二段 下一状态组合逻辑判断(根据当前状态和输入) 组合逻辑 @(*)
第三段 输出逻辑(根据当前状态产生控制信号) 组合逻辑 @(*) 或时序逻辑

来看一个实际的状态机示例,这是一个简单的数据包处理状态机:

// 三段式状态机示例
module packet_fsm (
    input  wire         clk,
    input  wire         rst_n,
    input  wire         sop,      // start of packet
    input  wire         eop,      // end of packet
    input  wire         data_val,
    output reg          fifo_wr,
    output reg          pkt_done
);

    // 状态编码(用独热码,综合效率高)
    localparam IDLE     = 3'b001;
    localparam HEADER   = 3'b010;
    localparam PAYLOAD  = 3'b100;

    reg [2:0] state, next_state;

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

    // 第二段:下一状态组合逻辑
    always @(*) begin
        case (state)
            IDLE: begin
                if (sop && data_val)
                    next_state = HEADER;
                else
                    next_state = IDLE;
            end
            HEADER: begin
                // 头处理完成后进入负载
                next_state = PAYLOAD;
            end
            PAYLOAD: begin
                if (eop)
                    next_state = IDLE;
                else
                    next_state = PAYLOAD;
            end
            default: next_state = IDLE;
        endcase
    end

    // 第三段:输出逻辑(组合逻辑)
    always @(*) begin
        fifo_wr  = 1'b0;
        pkt_done = 1'b0;
        case (state)
            HEADER: begin
                fifo_wr = data_val;  // 写头信息到FIFO
            end
            PAYLOAD: begin
                fifo_wr = data_val;  // 写负载数据
                if (eop)
                    pkt_done = 1'b1; // 包结束标志
            end
            default: begin
                fifo_wr  = 1'b0;
                pkt_done = 1'b0;
            end
        endcase
    end

endmodule

状态机编写避坑指南:

  • 状态编码:状态少用二进制,状态多用独热码(综合后更省资源)
  • 默认状态:case 语句一定要加 default,防止综合出锁存器
  • 输出赋值:第三段输出逻辑中,先给所有输出赋默认值,再根据状态改写
  • 避免冒险:组合逻辑输出要小心毛刺,必要时加一级寄存器打拍

我曾经接手过一个项目,里面的状态机把三段混在一起写,状态跳转和输出逻辑全在一个 always 块里。结果呢?代码可读性极差,改一个状态要小心翼翼,生怕影响输出。后来我花了两天时间重构,拆成三段式,问题迎刃而解。

最后说一句:写状态机的时候,脑子里要有一张状态转移图。先画图,再写代码,这是最稳妥的做法。你想想看,如果连状态图都没想清楚,写出来的代码能靠谱吗?

好了,这一章的内容就到这里。模块化设计让你搭好框架,组合逻辑和时序逻辑让你填好血肉,状态机让你赋予设计灵魂。这三样东西练扎实了,后面的大模型算子加速设计,你就能游刃有余。


公众号:蓝海资料掘金营,微信deep3321