3、硬件描述语言基础(Verilog):模块化设计、组合逻辑与时序逻辑、状态机、仿真与测试平台

各位同学,欢迎来到第三章。这一章我们聊聊Verilog,这是咱们做嵌入式推理加速器的“砖瓦”。说白了,你脑子里再好的架构,再牛的算法,最后都得靠它变成电路。

我个人习惯把Verilog看作一种“描述硬件行为的语言”,而不是编程语言。你写C代码,编译器给你翻译成指令;你写Verilog,综合器给你映射成门电路。这个思维转变很重要。我见过不少新手,上来就用C语言的思维写Verilog,结果综合出来的电路一塌糊涂。

3.1 模块化设计:搭积木的艺术

模块化,说白了就是“分而治之”。一个复杂的加速器,比如卷积计算单元,你不可能在一个模块里写完所有逻辑。那样做,不仅你自己看着头疼,综合工具也会崩溃。

我建议你把每个功能单元都封装成一个独立的模块。比如,一个乘法器、一个加法树、一个累加器,各干各的。

核心原则:高内聚,低耦合。每个模块只做一件事,并且把它做好。

举个例子,一个简单的D触发器模块:

module d_flip_flop (
    input wire clk,
    input wire rst_n,
    input wire d,
    output reg q
);
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            q <= 1'b0;
        else
            q <= d;
    end
endmodule

你看,这个模块的接口很清晰:时钟、复位、数据输入、数据输出。内部逻辑也很简单。这就是模块化的好处——你可以单独测试它,然后放心地把它用到更大的系统里。

我的经验:在写模块之前,先画个接口图。把输入输出信号、位宽、功能都列清楚。这能避免后期大量返工。我曾经在一个项目里,因为接口定义不清,导致顶层连线连了三天,最后发现信号位宽对不上,那叫一个崩溃。

3.2 组合逻辑与时序逻辑:硬件的“阴阳”两面

这是Verilog里最基础,也最容易搞混的概念。你想想看,组合逻辑就是“输入一变,输出立马变”,没有记忆功能。时序逻辑则相反,它依赖于时钟,输出不仅取决于当前输入,还取决于之前的状态。

组合逻辑:assign 或者 always @(*) 来描述。比如一个加法器:

assign sum = a + b;

或者:

always @(*) begin
    case (sel)
        2'b00: out = a;
        2'b01: out = b;
        2'b10: out = c;
        default: out = 1'b0;
    endcase
end

时序逻辑:always @(posedge clk) 来描述。比如一个计数器:

always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        cnt <= 8'b0;
    else
        cnt <= cnt + 1'b1;
end

避坑指南:我曾经犯过一个低级错误——在组合逻辑的always块里用了非阻塞赋值 <=。综合出来的电路仿真怎么都不对。记住:组合逻辑用阻塞赋值 =,时序逻辑用非阻塞赋值 <=。这是铁律,别问为什么,先记住。

为什么会有这个区别?因为非阻塞赋值是“并行”的,它会在always块结束时统一更新。而阻塞赋值是“顺序”的,立即生效。在时序逻辑里,我们需要模拟寄存器的行为——所有寄存器在时钟沿同时采样。所以必须用非阻塞赋值。

3.3 状态机:控制逻辑的灵魂

在加速器里,状态机无处不在。比如,一个卷积计算单元,它需要经历“加载数据 -> 计算 -> 写回结果”这几个状态。状态机就是用来管理这些状态的。

我习惯把状态机分成三段式来写。这样结构清晰,综合效果也好。

三段式状态机模板:

// 第一段:状态寄存器
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 = LOAD;
            else
                next_state = IDLE;
        end
        LOAD: begin
            if (load_done)
                next_state = COMPUTE;
            else
                next_state = LOAD;
        end
        COMPUTE: begin
            if (compute_done)
                next_state = WRITE_BACK;
            else
                next_state = COMPUTE;
        end
        WRITE_BACK: begin
            if (write_done)
                next_state = IDLE;
            else
                next_state = WRITE_BACK;
        end
        default: next_state = IDLE;
    endcase
end

// 第三段:输出逻辑(组合逻辑或时序逻辑)
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        data_out <= 32'b0;
        done <= 1'b0;
    end else begin
        case (current_state)
            IDLE: begin
                done <= 1'b0;
            end
            COMPUTE: begin
                data_out <= result;
                done <= 1'b1;
            end
            default: begin
                data_out <= 32'b0;
                done <= 1'b0;
            end
        endcase
    end
end

为什么用三段式?第一段是时序逻辑,负责状态跳转。第二段是组合逻辑,负责计算下一个状态。第三段是输出逻辑。这样做的最大好处是:避免产生“组合逻辑反馈环路”,而且代码可读性极强。你想想看,如果所有逻辑都写在一个always块里,后期维护起来有多痛苦。

嗯,这里要注意:第二段是组合逻辑,所以用阻塞赋值 =。第三段我习惯用时序逻辑,这样输出会稳定一个时钟周期,不容易出现毛刺。

3.4 仿真与测试平台:验证你的设计

写完了代码,不仿真验证一下,你敢直接拿去流片吗?反正我不敢。仿真测试平台(Testbench)就是用来干这个的。

一个基本的Testbench结构:

module tb_conv_unit;

    // 定义信号
    reg clk;
    reg rst_n;
    reg [31:0] data_in;
    wire [31:0] data_out;
    wire done;

    // 实例化待测模块
    conv_unit u_conv_unit (
        .clk(clk),
        .rst_n(rst_n),
        .data_in(data_in),
        .data_out(data_out),
        .done(done)
    );

    // 生成时钟
    always #5 clk = ~clk; // 10ns周期,100MHz

    // 测试激励
    initial begin
        // 初始化
        clk = 0;
        rst_n = 0;
        data_in = 32'b0;

        // 复位
        #20 rst_n = 1;

        // 加载数据
        #10 data_in = 32'hA5A5A5A5;

        // 等待计算完成
        @(posedge done);

        // 检查结果
        if (data_out == 32'h5A5A5A5A)
            $display("Test Passed!");
        else
            $display("Test Failed!");

        // 结束仿真
        #100 $finish;
    end

    // 监控信号
    initial begin
        $monitor("Time=%0t, clk=%b, rst_n=%b, data_in=%h, data_out=%h, done=%b",
                 $time, clk, rst_n, data_in, data_out, done);
    end

endmodule

我的习惯:写Testbench时,我会先写一个“冒烟测试”——就是最简单的功能验证,确保模块能跑起来。然后再写“边界测试”——比如输入最大值、最小值、零值等。最后写“随机测试”——用 $random 生成随机激励,看看模块会不会在极端情况下崩溃。我曾经用随机测试发现过一个隐藏很深的溢出bug,要是没发现,芯片回来肯定是个废片。

仿真工具方面,我个人推荐用Vivado Simulator或者ModelSim/Questa。开源的Icarus Verilog(iverilog)也够用,配合GTKWave看波形,学习阶段完全没问题。

注意:仿真通过不代表综合后也能通过。因为仿真器是“理想化”的,它不考虑门延迟、布线延迟。所以,仿真通过后,一定要做“后仿真”(门级仿真),带上SDF标准延迟文件。这一步不能省,我吃过亏。

好了,这一章的内容就到这里。模块化设计让你能驾驭复杂系统,组合逻辑和时序逻辑是硬件的基石,状态机是控制逻辑的核心,仿真测试平台是你最后的防线。把这几点吃透了,你就能写出靠谱的Verilog代码。

下一章,我们会把这些知识串起来,开始设计一个真正的卷积加速器单元。到时候,你会看到这些基础概念是如何在实际项目中发挥作用的。