4、RTL仿真实践:搭建一个简单的ALU验证环境,编写测试用例

好了,前面几章我们把验证的理论基础、工具链、环境结构都过了一遍。说实话,光看不练是学不会验证的。这一章咱们直接动手,从零搭一个最简单的ALU验证环境。

为什么选ALU?因为它够简单。一个加法器、一个与门、一个或门,逻辑清晰,没有复杂的协议交互。你想想看,如果连ALU都验证不明白,后面遇到AXI总线、DDR控制器这些大家伙,那不得直接懵掉?

我个人习惯,每次接触新项目,都会先拿一个最小的模块练手。把整个流程跑通,心里才有底。

4.1 验证环境架构设计

先画个图,咱们的环境长什么样?

说白了就三层:

  • 顶层(testbench):负责例化DUT,连接接口,启动测试
  • 驱动层(driver):给DUT灌激励,比如操作数、控制信号
  • 监测层(monitor + scoreboard):抓输出,跟预期值做比对

嗯,这里要注意,初学者容易犯一个毛病——把所有代码都塞到testbench里。我刚开始也这么干过,结果一个文件写了上千行,改个信号名都要找半天。后来学乖了,该拆就拆,一个模块一个文件,清爽得很。

咱们的ALU接口定义如下:

// ALU接口定义
input  [31:0] a, b;      // 两个操作数
input  [2:0]  op;        // 操作码
output [31:0] result;    // 运算结果
output        zero;      // 结果是否为0

操作码对应关系:

op[2:0]操作说明
000ADDa + b
001SUBa - b
010ANDa & b
011ORa | b
100XORa ^ b
101SLTa < b ? 1 : 0
小提示:操作码编码尽量留余量。比如3位编码只用了5种,剩下3种可以留给后续扩展。我在项目中吃过亏,一开始把编码占满了,后面加功能只能改协议,那叫一个痛苦。

4.2 编写DUT(待测设计)

先写一个简单的ALU RTL代码。注意,这里咱们是验证工程师,不是设计工程师。但为了验证,你得能看懂甚至能写简单的RTL。不然你怎么知道DUT该输出什么?

module alu (
    input  [31:0] a, b,
    input  [2:0]  op,
    output reg [31:0] result,
    output reg        zero
);

    always @(*) begin
        case (op)
            3'b000: result = a + b;
            3'b001: result = a - b;
            3'b010: result = a & b;
            3'b011: result = a | b;
            3'b100: result = a ^ b;
            3'b101: result = (a < b) ? 32'd1 : 32'd0;
            default: result = 32'd0;
        endcase
        zero = (result == 32'd0) ? 1'b1 : 1'b0;
    end

endmodule

这段代码很简单,对吧?但越简单的东西越容易藏坑。比如那个zero信号,它是组合逻辑产生的。如果后面做后仿真,组合逻辑的毛刺可能会让zero信号跳变。嗯,这个咱们到后仿真章节再细聊。

4.3 搭建验证环境

验证环境我习惯用SystemVerilog写,因为它的面向对象特性让代码复用性更好。当然,纯Verilog也能做,就是写起来累点。

先写一个简单的driver:

class alu_driver;
    virtual alu_if vif;
    mailbox #(alu_trans) gen2drv;

    task run();
        alu_trans trans;
        forever begin
            gen2drv.get(trans);
            vif.a <= trans.a;
            vif.b <= trans.b;
            vif.op <= trans.op;
            @(posedge vif.clk);
        end
    endtask
endclass

再写一个monitor,负责抓输出:

class alu_monitor;
    virtual alu_if vif;
    mailbox #(alu_trans) mon2scb;

    task run();
        alu_trans trans;
        forever begin
            @(posedge vif.clk);
            trans = new();
            trans.result = vif.result;
            trans.zero   = vif.zero;
            mon2scb.put(trans);
        end
    endtask
endclass
关键点:driver和monitor通过mailbox通信。这是UVM的基本思想——各组件之间解耦。你想想看,如果driver直接调用scoreboard的函数,那以后想换scoreboard就得改driver,多麻烦。

4.4 编写测试用例

测试用例怎么写?我一般分三步走:

  1. 冒烟测试:先跑几个最简单的case,确保环境能跑通
  2. 边界测试:测边界值,比如最大值、最小值、0
  3. 随机测试:用随机数覆盖各种组合

咱们先写一个冒烟测试:

class test_smoke extends alu_test_base;
    task run();
        alu_trans trans;
        // 测试加法:1 + 2 = 3
        trans = alu_trans::type_id::create("trans");
        trans.a = 32'd1;
        trans.b = 32'd2;
        trans.op = 3'b000;
        gen2drv.put(trans);
        
        // 测试减法:5 - 3 = 2
        trans.a = 32'd5;
        trans.b = 32'd3;
        trans.op = 3'b001;
        gen2drv.put(trans);
        
        // 测试与运算:0xFF & 0x0F = 0x0F
        trans.a = 32'h000000FF;
        trans.b = 32'h0000000F;
        trans.op = 3'b010;
        gen2drv.put(trans);
        
        #100;
        $finish;
    endtask
endclass

边界测试呢?我建议重点关注这些:

  • a和b都是0
  • a是最大值(0xFFFFFFFF),b是1
  • a是最小值(0x00000000),b是最大值
  • a和b相等的情况

我曾经在一个项目中,ALU的加法器在a=0x80000000、b=0x80000000时溢出了,但设计工程师说这是正常行为。结果呢?后续的模块拿这个结果去做地址计算,直接跑飞了。所以边界测试不是走过场,是真的能发现问题。

4.5 编写参考模型和比对逻辑

验证的核心是什么?比对。你得知道DUT该输出什么,才能判断它有没有错。

参考模型(reference model)就是干这个的。它用高级语言(比如C、Python)或者SystemVerilog实现同样的功能,但实现方式可以完全不同。比如ALU的参考模型:

function automatic [31:0] ref_alu(
    input [31:0] a, b,
    input [2:0]  op
);
    case (op)
        3'b000: return a + b;
        3'b001: return a - b;
        3'b010: return a & b;
        3'b011: return a | b;
        3'b100: return a ^ b;
        3'b101: return (a < b) ? 32'd1 : 32'd0;
        default: return 32'd0;
    endcase
endfunction

然后在scoreboard里做比对:

class alu_scoreboard;
    mailbox #(alu_trans) mon2scb;
    int pass_count, fail_count;

    task run();
        alu_trans trans;
        forever begin
            mon2scb.get(trans);
            trans.exp_result = ref_alu(trans.a, trans.b, trans.op);
            trans.exp_zero   = (trans.exp_result == 0);
            
            if (trans.result === trans.exp_result && 
                trans.zero   === trans.exp_zero) begin
                pass_count++;
                $display("[PASS] a=%0d b=%0d op=%b result=%0d", 
                         trans.a, trans.b, trans.op, trans.result);
            end else begin
                fail_count++;
                $display("[FAIL] a=%0d b=%0d op=%b result=%0d exp=%0d", 
                         trans.a, trans.b, trans.op, 
                         trans.result, trans.exp_result);
            end
        end
    endtask
注意:比对时一定要用全等比较符 ===,而不是 ==。为什么?因为 == 会把X态和Z态当成不关心,而 === 会严格比对。后仿真中经常出现X态传播,用 === 能帮你第一时间发现。

4.6 运行仿真并分析结果

环境搭好了,测试用例写好了,接下来就是跑仿真。命令很简单:

vlog -sv alu.sv alu_tb.sv
vsim -c -do "run -all; exit" alu_tb

跑完之后看什么?

  • 看log里有没有FAIL
  • 看波形,确认时序是否正确
  • 看覆盖率,有没有遗漏的场景

我记得第一次带新人时,他跑完仿真看到全是PASS,高兴得不行。我让他把DUT里的加法改成减法,再跑一遍。结果还是PASS。为什么?因为他参考模型也写错了,跟DUT错得一模一样。这就是典型的「对眼」错误。

怎么避免?我的做法是:参考模型和DUT由不同的人写,或者用不同的语言实现。比如DUT用Verilog,参考模型用Python。这样即使一个人犯错了,也很难在两个地方犯同样的错。

4.7 常见问题与避坑指南

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

  • 时钟没给:仿真跑起来一动不动,检查半天发现时钟忘了生成。用 always #5 clk = ~clk; 就能搞定。
  • 复位没释放:DUT一直处于复位状态,输出全是0。记得在初始化阶段释放复位。
  • 接口方向搞反:modport里input/output写反了,仿真报一堆高阻态。这个错误我犯过不止一次...
  • 比对时机不对:在时钟上升沿抓数据,但DUT的输出在上升沿之后才稳定。建议在时钟下降沿采样,或者加一个#1的延迟。
避坑指南:我曾经在比对时用了 == 而不是 ===,结果DUT输出X态,比对居然通过了。后来查了三天才找到原因。从那以后,我所有比对都强制用 ===,并且加了一个检查:如果结果里有X或Z,直接报ERROR。

好了,这一章的内容就到这里。你跟着我一步步搭下来,应该已经有一个能跑起来的ALU验证环境了。下一章咱们聊聊覆盖率驱动验证,看看怎么用覆盖率来指导我们写更高效的测试用例。