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

好,咱们进入第四章。这一章是硬骨头,也是基本功。Verilog 这玩意儿,说白了就是硬件工程师的「普通话」。你设计再牛的神经网络加速器,最终都得靠它落实到门级电路上。我个人习惯把 Verilog 看作「用代码画电路图」,而不是写软件。这个思维转换不过来,后面会踩很多坑。

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

做芯片设计,最忌讳的就是「一把梭」。一个几百万门的加速器,你不可能在一个文件里写完。模块化设计,就是要把系统拆成功能独立、接口清晰的子模块。

我见过不少新手,喜欢把所有逻辑写在一个 always 块里。结果呢?仿真跑不动,综合报错看不懂,调试起来想砸电脑。你想想看,一个卷积计算单元,你把它拆成「输入缓冲模块」、「乘加树模块」、「累加模块」、「输出缓冲模块」,每个模块单独验证,最后再拼起来,是不是清爽多了?

一个标准的 Verilog 模块长这样:

module conv_mac #(
    parameter DATA_WIDTH = 8,
    parameter ACC_WIDTH  = 16
)(
    input  wire                     clk,
    input  wire                     rst_n,
    input  wire                     valid_in,
    input  wire [DATA_WIDTH-1:0]   weight,
    input  wire [DATA_WIDTH-1:0]   pixel,
    output reg  [ACC_WIDTH-1:0]    result,
    output reg                     valid_out
);

    // 内部逻辑写在这里

endmodule

注意看,我用 #(parameter ...) 定义了参数。这样设计出来的模块才是可复用的。我在项目中遇到过,同一个乘加模块,在卷积层用 8bit,在全连接层用 16bit。如果没有参数化,你得维护两套代码,改一个 bug 要改两遍,累死人。

小技巧: 模块的端口命名要有规律。我习惯用 _in_out 后缀,一看就知道方向。时钟和复位永远放在端口列表的最前面,这是行业惯例。

4.2 组合逻辑 vs 时序逻辑:一个管当下,一个管未来

这是 Verilog 里最容易搞混的概念。我简单粗暴地给你总结一下:

  • 组合逻辑: 输入一变,输出立刻变。没有记忆功能,不依赖时钟。用 assign 或者 always @(*) 实现。
  • 时序逻辑: 在时钟边沿采样输入,下一个时钟周期才输出。有记忆功能,用 always @(posedge clk) 实现。

举个例子,一个简单的加法器:

// 组合逻辑加法器
assign sum = a + b;

// 时序逻辑加法器(带寄存器输出)
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        sum_reg <= 0;
    else
        sum_reg <= a + b;
end

看到区别了吗?组合逻辑的 sum 是 wire 类型,时序逻辑的 sum_reg 是 reg 类型。但注意,reg 不一定代表寄存器!在 always @(*) 里赋值的 reg,综合出来还是组合逻辑。这个坑我曾经踩过,调试了整整一天才发现是综合工具把组合逻辑推断成了锁存器。

警告: 组合逻辑中,如果条件分支不完整,综合工具会推断出锁存器(latch)。锁存器在 ASIC 设计中是万恶之源,时序难分析,测试难覆盖。务必保证 if-elsecase 语句覆盖所有情况。

在神经网络加速器里,组合逻辑和时序逻辑各有各的用处。比如:

应用场景 推荐使用 原因
数据通路中的加法、乘法 组合逻辑 延迟小,适合流水线中的纯计算
状态机、计数器、累加器 时序逻辑 需要记忆当前状态
地址译码、数据选择 组合逻辑 输入决定输出,无需时钟
流水线寄存器、FIFO 时序逻辑 数据需要跨时钟域或打拍同步

4.3 仿真与测试平台:不仿真就流片?你疯了吗

写代码只占设计工作量的 30%,剩下 70% 都在仿真和调试。测试平台(Testbench)就是你的「虚拟实验台」。没有它,你根本不知道你的设计能不能跑。

一个基本的 Testbench 结构:

`timescale 1ns / 1ps

module tb_conv_mac();

    // 参数定义
    localparam DATA_WIDTH = 8;
    localparam ACC_WIDTH  = 16;
    localparam CLK_PERIOD = 10; // 10ns = 100MHz

    // 信号声明
    reg                     clk;
    reg                     rst_n;
    reg                     valid_in;
    reg  [DATA_WIDTH-1:0]  weight;
    reg  [DATA_WIDTH-1:0]  pixel;
    wire [ACC_WIDTH-1:0]   result;
    wire                   valid_out;

    // 实例化待测模块
    conv_mac #(
        .DATA_WIDTH(DATA_WIDTH),
        .ACC_WIDTH(ACC_WIDTH)
    ) u_dut (
        .clk      (clk),
        .rst_n    (rst_n),
        .valid_in (valid_in),
        .weight   (weight),
        .pixel    (pixel),
        .result   (result),
        .valid_out(valid_out)
    );

    // 时钟生成
    initial begin
        clk = 0;
        forever #(CLK_PERIOD/2) clk = ~clk;
    end

    // 测试激励
    initial begin
        // 初始化
        rst_n = 0;
        valid_in = 0;
        weight = 0;
        pixel = 0;

        // 复位释放
        #20 rst_n = 1;

        // 发送第一个数据
        @(posedge clk);
        weight = 8'h05;
        pixel = 8'h03;
        valid_in = 1;

        @(posedge clk);
        valid_in = 0;

        // 等待结果
        #100;

        // 检查结果
        if (result == 16'h0F)
            $display("Test Passed: 5 * 3 = 15");
        else
            $display("Test Failed: expected 15, got %d", result);

        #50 $finish;
    end

    // 波形 dump(用于 GTKWave 查看)
    initial begin
        $dumpfile("tb_conv_mac.vcd");
        $dumpvars(0, tb_conv_mac);
    end

endmodule

这里有几个要点:

  • 时钟生成:forever 循环产生时钟,别手动写几百个 #5 clk = ~clk,那是自虐。
  • 复位时序: 先拉低复位,等几个时钟周期再释放。我习惯至少等 2 个时钟周期,确保所有寄存器都初始化完毕。
  • 自检机制:$display 打印结果,用 if 判断对错。别傻盯着波形图看,眼睛会瞎的。
  • 波形 dump: 生成 VCD 文件,用 GTKWave 或 Verdi 查看。这是调试的救命稻草。
核心原则: 写 Testbench 时,把自己当成「挑剔的甲方」。不仅要测正常情况,还要测边界情况——比如输入全 0、输入全 1、复位期间有输入、连续背靠背输入。我曾经因为没测「复位期间输入有效」这个场景,导致芯片在特定条件下死机,教训深刻。

4.4 仿真工具的选择与使用

工欲善其事,必先利其器。市面上主流的仿真工具有:

工具名称 厂商 特点 适用场景
Vivado Simulator (xsim) AMD Xilinx 与 Vivado 深度集成,免费 中小规模设计,FPGA 原型验证
ModelSim / QuestaSim Siemens EDA 业界标准,支持 VHDL/Verilog/SystemVerilog 中大规模设计,团队协作
VCS Synopsys 速度快,容量大,价格贵 大规模 ASIC 设计,SoC 验证
Icarus Verilog (iverilog) 开源 免费,轻量级 学习入门,小模块验证

我个人建议,学习阶段用 Icarus Verilog + GTKWave 就够了。完全免费,功能也不差。等你要做真正的项目了,再上 ModelSim 或 VCS。

用 iverilog 跑仿真的命令很简单:

# 编译
iverilog -o tb_conv_mac.vvp tb_conv_mac.v conv_mac.v

# 运行仿真
vvp tb_conv_mac.vvp

# 查看波形
gtkwave tb_conv_mac.vcd

嗯,就这么三行命令。别被那些花里胡哨的 GUI 吓到,命令行才是硬核工程师的浪漫。

4.5 避坑指南:我流过的血,你就不用再流了

最后,分享几个我亲身踩过的坑:

  • 阻塞赋值与非阻塞赋值混用: 在同一个 always 块里,组合逻辑用 =,时序逻辑用 <=。混用的话,仿真结果和综合结果可能不一致。我曾经因为这个,仿真跑得好好的,综合出来功能全错。
  • 敏感列表不完整: 组合逻辑的 always 块,敏感列表要写 @(*),别手写信号列表。漏掉一个信号,仿真结果就是错的。
  • 忘记初始化: 仿真时 reg 变量默认是 X(未知态)。如果不初始化,所有依赖它的信号都会变成 X,然后像病毒一样扩散。复位信号一定要在仿真开始就拉低。
  • 仿真时间设置太短: 我见过有人设 #100 $finish,结果模块还没跑完就结束了。先设一个足够长的时间,比如 #10000,等调试稳定了再优化。

好了,这一章的内容就到这里。模块化设计是骨架,组合逻辑和时序逻辑是血肉,仿真测试是灵魂。这三样东西掌握好了,你就能用 Verilog 搭建出任何数字电路——包括我们后面要讲的神经网络加速器核心。

下一章,咱们开始真正动手,设计一个简单的乘加单元。到时候我会带着你一步步写代码、搭 Testbench、看波形。准备好了吗?