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

各位同学,咱们今天聊聊Verilog。说实话,我刚开始学FPGA那会儿,觉得Verilog不就是写代码嘛,跟C语言差不多。后来踩了无数坑才明白——硬件描述语言,核心是“描述硬件”,不是“写程序”。这个观念不转过来,后面会很难受。

好,咱们直接进入正题。这一章我打算从四个核心点展开:模块化设计、组合逻辑与时序逻辑的区别、阻塞赋值与非阻塞赋值的坑、以及Testbench怎么写。这些都是你写Verilog必须刻在脑子里的东西。

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

做FPGA工程,最忌讳的就是把所有逻辑写在一个大模块里。我见过一个同事,一个模块写了3000行,最后他自己都看不懂了。调试的时候,改一行代码要仿真半小时,那叫一个痛苦。

模块化设计说白了,就是“分而治之”。把复杂功能拆成多个小模块,每个模块只干一件事。比如一个视频处理系统,你可以拆成:

  • 输入接口模块:负责接收摄像头数据
  • 图像处理模块:做缩放、滤波、色彩转换
  • 输出显示模块:驱动HDMI或LCD屏幕
  • 控制模块:管理状态机、配置寄存器

每个模块独立设计、独立仿真、独立验证。最后再像搭积木一样拼起来。这样做的好处很明显:

  • 代码可读性强,别人一看就懂
  • 复用性高,这个模块下次还能用
  • 调试方便,出问题能快速定位到具体模块

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

module counter #(
    parameter WIDTH = 8
)(
    input  wire        clk,
    input  wire        rst_n,
    input  wire        en,
    output reg [WIDTH-1:0] count
);

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            count <= 0;
        else if (en)
            count <= count + 1;
    end

endmodule

嗯,这里要注意:模块的端口声明要清晰,输入输出分开。我个人习惯把时钟和复位放在最前面,这样一眼就能看到时序控制信号。

小技巧: 模块的接口尽量简单。如果一个模块的端口超过20个,你就要想想是不是拆得不够细。我在项目中遇到过,一个模块端口太多,连信号命名都容易搞混,后来拆成三个小模块,世界清静了。

2.2 组合逻辑与时序逻辑:一个“立刻”一个“等一拍”

这是新手最容易混淆的地方。我直接说结论:

  • 组合逻辑:输入一变,输出立刻变。没有时钟参与,没有记忆功能。
  • 时序逻辑:在时钟边沿触发,输出要等一个时钟周期才变化。有记忆功能。

你想想看,如果你把该用时序逻辑的地方写成了组合逻辑,那结果就是——输出乱跳,时序不满足,仿真和实际对不上。我曾经在一个项目里,把状态机的状态更新写成了组合逻辑,结果仿真跑得好好的,上板子就乱跳。查了两天才发现是这个问题,气得我差点把键盘摔了。

来看两个对比示例:

组合逻辑(用 assign 或 always @(*))

// 组合逻辑:输入a、b变化,输出y立刻变化
assign y = a & b;

// 或者用always块
always @(*) begin
    y = a & b;
end

时序逻辑(用 always @(posedge clk))

// 时序逻辑:在时钟上升沿,把a&b的结果赋值给y_reg
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        y_reg <= 1'b0;
    else
        y_reg <= a & b;
end

看到了吗?组合逻辑的赋值用 =,时序逻辑的赋值用 <=。这可不是随便写的,背后有深刻的硬件原理。

核心原则: 在 always 块中,描述组合逻辑用阻塞赋值(=),描述时序逻辑用非阻塞赋值(<=)。混着用?等着出bug吧。

2.3 阻塞赋值与非阻塞赋值:一字之差,天壤之别

这个知识点,我敢说90%的Verilog初学者都在这上面栽过跟头。我自己也不例外。

简单理解:

  • 阻塞赋值 (=):顺序执行,上一条赋值完,下一条才能开始。像C语言那样。
  • 非阻塞赋值 (<=):同时执行,所有赋值在时钟边沿同时发生。这才是硬件的真实行为。

为什么会这样?因为硬件里,所有寄存器都是在同一个时钟边沿同时更新的。你用阻塞赋值去描述时序逻辑,就会导致“先赋值给A,再用A的新值赋值给B”,这在硬件里是不存在的。

来看一个经典的错误示例:

// 错误写法:用阻塞赋值描述时序逻辑
always @(posedge clk) begin
    a = b;
    c = a;  // 这里c得到的是a的新值,不是旧值!
end

在硬件里,a和c应该同时更新。a拿到b的旧值,c拿到a的旧值。但上面这段代码,c拿到的是a的新值。仿真结果和实际硬件完全不一样!

正确的写法:

// 正确写法:用非阻塞赋值描述时序逻辑
always @(posedge clk) begin
    a <= b;
    c <= a;  // c得到的是a的旧值,符合硬件行为
end
避坑指南: 我曾经在一个通信项目里,因为把非阻塞赋值写成了阻塞赋值,导致数据流水线错位,整个系统的吞吐量下降了一半。查了三天才找到原因。从那以后,我写时序逻辑always块时,第一件事就是检查赋值符号。

总结一下我的个人习惯:

  • 描述组合逻辑的 always @(*),用阻塞赋值 =
  • 描述时序逻辑的 always @(posedge clk),用非阻塞赋值 <=
  • 同一个 always 块里,不要混用两种赋值方式

2.4 Testbench编写:没有仿真,你就是在盲人摸象

写FPGA不写Testbench?那跟闭着眼睛开车有什么区别。我见过太多人,写完代码直接上板子调,结果一调就是一周。其实花半小时写个Testbench,很多问题在仿真阶段就能发现。

一个标准的Testbench包含这几部分:

  • 实例化待测模块(DUT)
  • 生成时钟和复位信号
  • 产生测试激励
  • 检查输出结果(可选,但强烈建议)

来看一个完整的Testbench示例,测试我们刚才写的计数器:

`timescale 1ns / 1ps

module tb_counter;

    // 参数
    parameter WIDTH = 8;
    
    // 信号声明
    reg        clk;
    reg        rst_n;
    reg        en;
    wire [WIDTH-1:0] count;
    
    // 实例化DUT
    counter #(
        .WIDTH(WIDTH)
    ) u_dut (
        .clk   (clk),
        .rst_n (rst_n),
        .en    (en),
        .count (count)
    );
    
    // 生成时钟:周期10ns
    initial begin
        clk = 0;
        forever #5 clk = ~clk;
    end
    
    // 生成复位和激励
    initial begin
        // 初始化
        rst_n = 0;
        en    = 0;
        
        // 释放复位
        #20;
        rst_n = 1;
        
        // 使能计数
        #10;
        en = 1;
        
        // 计数20个周期后停止
        #200;
        en = 0;
        
        // 再等一会儿,观察结果
        #50;
        
        // 结束仿真
        $finish;
    end
    
    // 打印结果(可选)
    initial begin
        $monitor("time=%0t, count=%d", $time, count);
    end

endmodule

嗯,这里有几个要点:

  • timescale 定义了时间单位和精度,一定要写
  • 时钟生成用 forever 循环,简单高效
  • 激励用 initial 块,按时间顺序写
  • $monitor 可以实时打印信号变化,调试利器
我的经验: 写Testbench时,不要只测正常情况。一定要测边界条件——比如复位释放瞬间、使能信号毛刺、时钟抖动等。我在项目中遇到过,仿真全通过,上板子就挂,就是因为没测复位释放时的亚稳态问题。

2.5 本章知识体系总览

为了让你更直观地理解本章的知识结构,我画了一张图。这张图展示了Verilog基础的四块内容,以及它们之间的关系:

Verilog硬件描述语言基础 模块化设计 • 分而治之,拆解复杂功能 • 每个模块只干一件事 • 端口清晰,参数化设计 • 独立仿真,最后拼装 组合逻辑 • 输入变,输出立刻变 • 无时钟参与,无记忆 • 用 assign 或 always @(*) • 赋值用 = (阻塞赋值) 时序逻辑 • 时钟边沿触发,等一拍 • 有记忆功能(寄存器) • 用 always @(posedge clk) • 赋值用 <= (非阻塞赋值) Testbench编写 • 实例化DUT,生成时钟复位 • 产生测试激励 • 检查输出结果 • 仿真通过再上板 核心:理解硬件行为,而不是写软件代码

这张图把本章的四个知识点串起来了。你注意看,组合逻辑和时序逻辑是并列关系,它们分别对应不同的赋值方式。而模块化设计是顶层思想,Testbench是验证手段。四者缺一不可。

好了,这一章的内容就到这里。记住我反复强调的那句话:Verilog是描述硬件,不是写程序。把心态调整好,后面学起来会轻松很多。

可可精品资料库