4. 硬件描述语言基础 (Verilog)

各位同学好,我是老李。今天咱们聊聊Verilog里最核心的几个概念。说实话,很多新手一上来就被阻塞赋值和非阻塞赋值搞晕了。别急,咱们一个一个来拆解。

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

模块化设计,说白了就是“分而治之”。你想想看,一个复杂的芯片动辄上亿门电路,要是全写在一个文件里,别说调试了,光看代码就能把人看吐。

我个人习惯,每个模块只干一件事。比如一个计数器模块,就只管计数;一个FIFO模块,就只管数据缓存。这样每个模块的代码量控制在200行以内,调试起来特别方便。

模块化设计三原则:

  • 高内聚:一个模块内部的功能要紧密相关
  • 低耦合:模块之间的接口要尽量简单
  • 接口清晰:用input/output把端口定义清楚

来看一个简单的例子。这是一个4位加法器,我把它拆成了两个半加器模块:

// 半加器模块
module half_adder (
    input  a, b,
    output sum, carry
);
    assign sum   = a ^ b;
    assign carry = a & b;
endmodule

// 顶层模块:4位加法器
module adder_4bit (
    input  [3:0] a, b,
    input  cin,
    output [3:0] sum,
    output cout
);
    wire c1, c2, c3;
    
    half_adder u0 (.a(a[0]), .b(b[0]), .sum(sum[0]), .carry(c1));
    half_adder u1 (.a(a[1]), .b(b[1]), .sum(sum[1]), .carry(c2));
    half_adder u2 (.a(a[2]), .b(b[2]), .sum(sum[2]), .carry(c3));
    half_adder u3 (.a(a[3]), .b(b[3]), .sum(sum[3]), .carry(cout));
endmodule

看到没?顶层模块只是把四个半加器“拼”在一起。每个半加器只有几行代码,逻辑一目了然。我在项目中遇到过有人把整个CPU写在一个module里,结果仿真跑一次要半小时……后来拆成取指、译码、执行、访存、写回五个模块,调试效率直接翻倍。

4.2 组合逻辑与时序逻辑——数字电路的两大支柱

组合逻辑,输出只取决于当前输入。时序逻辑,输出还取决于之前的状态。嗯,这里要注意,很多新手容易把两者混在一起写。

我画了一张图,帮你理清思路:

组合逻辑 输出 = f(当前输入) 无记忆,无时钟 时序逻辑 输出 = f(当前输入, 历史状态) 有记忆,有时钟 典型例子 与门、或门、加法器、多路选择器 触发器、计数器、状态机、寄存器

写组合逻辑时,我用assign或者always @(*)。写时序逻辑时,我用always @(posedge clk)。这个习惯帮我避免了很多bug。

小技巧:如果你不确定一个信号是组合还是时序,问自己一个问题——这个信号需要“记住”过去的值吗?如果需要,那就是时序逻辑。

4.3 阻塞赋值与非阻塞赋值——最容易踩的坑

这个知识点,我当年学的时候也迷糊过。阻塞赋值用=,非阻塞赋值用<=。区别在哪?

  • 阻塞赋值(=):顺序执行,写完上一句才能写下一句。适合组合逻辑。
  • 非阻塞赋值(<=):并行执行,所有赋值同时发生。适合时序逻辑。

我曾经在项目中犯过一个低级错误——在时序逻辑里用了阻塞赋值。结果仿真波形看起来是对的,但综合出来的电路完全不是我想的那样。查了两天才发现是赋值方式用错了。

铁律:

  • 写组合逻辑(always @(*))时,用阻塞赋值 =
  • 写时序逻辑(always @(posedge clk))时,用非阻塞赋值 <=
  • 千万别混用!

来看一个对比例子:

// 错误写法:时序逻辑用了阻塞赋值
always @(posedge clk) begin
    a = b;  // 阻塞赋值
    c = a;  // 此时a已经更新,c拿到的是新值
end
// 综合出来可能只有一个寄存器,而不是两个

// 正确写法:时序逻辑用非阻塞赋值
always @(posedge clk) begin
    a <= b;  // 非阻塞赋值
    c <= a;  // 此时a还是旧值,c拿到的是a的旧值
end
// 综合出来是两个寄存器,符合预期

为什么会这样?因为非阻塞赋值在时钟上升沿“同时”采样和更新。你想想看,如果两个寄存器要串联传递数据,用非阻塞赋值才能保证每个时钟周期只传递一次。

4.4 Testbench编写——验证你的设计对不对

写Testbench,说白了就是给你的设计“喂”输入,然后看输出对不对。我一般按这个套路来:

  1. 例化待测模块(DUT)
  2. 生成时钟和复位信号
  3. 构造测试激励
  4. 检查输出结果

来看一个完整的Testbench例子,测试我们刚才写的4位加法器:

module tb_adder_4bit;
    reg  [3:0] a, b;
    reg  cin;
    wire [3:0] sum;
    wire cout;
    
    // 例化待测模块
    adder_4bit u_dut (
        .a   (a),
        .b   (b),
        .cin (cin),
        .sum (sum),
        .cout(cout)
    );
    
    // 生成时钟(如果需要)
    reg clk;
    always #5 clk = ~clk;
    
    // 测试激励
    initial begin
        // 初始化
        a = 0; b = 0; cin = 0;
        #10;
        
        // 测试1:1 + 2 = 3
        a = 4'd1; b = 4'd2; cin = 0;
        #10;
        $display("1 + 2 = %d, carry = %b", sum, cout);
        
        // 测试2:15 + 1 = 0(溢出)
        a = 4'd15; b = 4'd1; cin = 0;
        #10;
        $display("15 + 1 = %d, carry = %b", sum, cout);
        
        // 测试3:带进位输入
        a = 4'd5; b = 4'd5; cin = 1;
        #10;
        $display("5 + 5 + 1 = %d, carry = %b", sum, cout);
        
        #20;
        $finish;
    end
    
    // 自动检查结果
    always @(a or b or cin) begin
        #1;  // 等待组合逻辑稳定
        if ({cout, sum} !== a + b + cin) begin
            $error("Mismatch: %d + %d + %d = %d, expected %d", 
                   a, b, cin, {cout, sum}, a + b + cin);
        end
    end
endmodule

我的习惯:Testbench里一定要加自动检查。手动看波形太累了,而且容易漏掉边界情况。用$error或者assertion,仿真跑完就知道有没有问题。

嗯,到这里Verilog基础就讲完了。记住:模块化设计让你代码更清晰,组合逻辑和时序逻辑要分清楚,阻塞和非阻塞赋值别搞混,Testbench要写自动检查。这些基本功打扎实了,后面上板联调才能少踩坑。

本章核心要点:

  • 模块化设计:高内聚、低耦合、接口清晰
  • 组合逻辑:输出只依赖当前输入,用assign或always @(*)
  • 时序逻辑:输出依赖历史状态,用always @(posedge clk)
  • 阻塞赋值(=)用于组合逻辑,非阻塞赋值(<=)用于时序逻辑
  • Testbench要包含时钟生成、激励构造和自动结果检查

可可精品资料库