第三章 硬件描述语言基础(Verilog):模块化设计、组合逻辑与时序逻辑、阻塞与非阻塞赋值、Testbench编写

各位同学,欢迎来到第三章。说实话,Verilog 这门语言,你把它当成硬件描述,而不是软件编程,很多问题就迎刃而解了。我见过太多新手把 Verilog 当 C 语言写,结果综合出来的电路完全不是那么回事。今天我们就来把这些核心概念掰开揉碎了讲清楚。

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

模块化设计,说白了就是把一个复杂系统拆成一个个小功能块。每个模块各司其职,最后通过连线拼起来。我在做金融风控的 FPGA 加速卡时,就把整个交易校验逻辑拆成了十几个小模块,每个模块只做一件事,比如检查订单金额、验证用户身份、计算风险评分。这样调试起来特别方便,哪个环节出问题,直接定位到那个模块就行。

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

module risk_checker (
    input  wire        clk,
    input  wire        rst_n,
    input  wire [31:0] trade_amount,
    input  wire [15:0] user_risk_score,
    output reg         alarm
);

    // 内部逻辑写在这里

endmodule

注意看,模块的端口就是它的「接口」。我习惯把输入输出分开写,每个信号加注释说明用途。你想想看,如果三个月后你自己回来看这段代码,没有注释,你还能记得每个信号是干嘛的吗?

我的习惯:每个模块只做一件事,端口数量控制在 20 个以内。如果超过 20 个,说明这个模块太复杂了,需要继续拆分。

3.2 组合逻辑与时序逻辑:两种思维方式

这是新手最容易混淆的地方。组合逻辑的输出只取决于当前输入,没有记忆功能。时序逻辑则依赖时钟边沿,能记住过去的状态。

组合逻辑——用 assign 或者 always @(*) 实现。举个例子,判断一笔交易是否超过风控阈值:

// 组合逻辑:输出立即反映输入变化
assign is_high_risk = (trade_amount > 100000) && (user_risk_score > 80);

时序逻辑——用 always @(posedge clk) 实现。比如记录过去 10 笔交易的总金额:

// 时序逻辑:每个时钟上升沿更新一次
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        total_amount <= 32'd0;
    else
        total_amount <= total_amount + trade_amount;
end

嗯,这里要注意:组合逻辑里用阻塞赋值(=),时序逻辑里用非阻塞赋值(<=)。为什么?我们接着往下看。

3.3 阻塞与非阻塞赋值:一个让我吃过亏的话题

我曾经在一个项目里,因为把非阻塞赋值用在了组合逻辑中,导致仿真结果和实际电路行为不一致。排查了整整两天,最后发现是赋值方式用错了。从那以后,我对这个规则记得特别牢。

简单来说:

  • 阻塞赋值(=):顺序执行,后面的语句等前面的执行完才执行。用于组合逻辑。
  • 非阻塞赋值(<=):并行执行,所有赋值同时发生。用于时序逻辑。

看个对比例子:

// 错误示范:时序逻辑里用阻塞赋值
always @(posedge clk) begin
    a = b;   // 先执行
    c = a;   // 此时 a 已经更新,c 拿到的是新值
end
// 综合出来可能是一个寄存器链,但行为和你预期的不一样

// 正确做法:时序逻辑用非阻塞赋值
always @(posedge clk) begin
    a <= b;   // 同时执行
    c <= a;   // 同时执行,c 拿到的是 a 的旧值
end
// 这才是两个独立的寄存器
铁律:always @(posedge clk) 里只能用 <=,always @(*) 里只能用 =。这条规则我建议你刻在桌子上。

3.4 Testbench 编写:验证你的设计

写 Testbench 其实就是在模拟外部环境,给你的设计喂数据,看它输出对不对。我一般会先写一个简单的测试用例,验证基本功能,然后再加边界条件和异常情况。

一个基本的 Testbench 结构:

module tb_risk_checker;

    // 1. 声明信号
    reg        clk;
    reg        rst_n;
    reg  [31:0] trade_amount;
    reg  [15:0] user_risk_score;
    wire       alarm;

    // 2. 实例化被测试模块
    risk_checker u_risk_checker (
        .clk             (clk),
        .rst_n           (rst_n),
        .trade_amount    (trade_amount),
        .user_risk_score (user_risk_score),
        .alarm           (alarm)
    );

    // 3. 生成时钟
    initial begin
        clk = 0;
        forever #5 clk = ~clk;  // 10ns 周期
    end

    // 4. 生成激励
    initial begin
        // 复位
        rst_n = 0;
        trade_amount = 32'd0;
        user_risk_score = 16'd0;
        #20;
        rst_n = 1;

        // 测试用例1:正常交易
        #10;
        trade_amount = 32'd50000;
        user_risk_score = 16'd50;
        #10;
        // 检查 alarm 是否为 0

        // 测试用例2:高风险交易
        #10;
        trade_amount = 32'd200000;
        user_risk_score = 16'd90;
        #10;
        // 检查 alarm 是否为 1

        #50;
        $finish;
    end

    // 5. 监控输出(可选)
    initial begin
        $monitor("time=%0t, alarm=%b", $time, alarm);
    end

endmodule

我个人习惯在 Testbench 里加一些自动检查的逻辑,比如用 assert 语句,这样仿真跑完就能知道结果对不对,不用肉眼去看波形。

知识体系总览

下面这张图把本章的核心知识点串起来了,你可以对照着复习:

Verilog 基础 模块化设计 端口定义 模块实例化 接口规范 组合逻辑 assign 语句 always @(*) 阻塞赋值 = 时序逻辑 always @(posedge clk) 非阻塞赋值 <= 寄存器/计数器 Testbench 时钟生成 激励产生 结果检查 核心原则 组合逻辑用阻塞赋值,时序逻辑用非阻塞赋值 每个模块只做一件事,做好一件事

本章小结:

  • 模块化设计是基础,把大问题拆成小问题
  • 组合逻辑没有记忆,时序逻辑有记忆
  • 阻塞赋值用于组合逻辑,非阻塞赋值用于时序逻辑——这条规则千万别搞反
  • Testbench 是验证的利器,花时间写好 Testbench 能省下大量调试时间

好了,这一章的内容就到这里。记住,Verilog 不是写代码,是在画电路。你写的每一行代码,都要能在脑子里想象出对应的硬件结构。多写、多仿真、多思考,慢慢就有感觉了。


专注资料整理