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] | 操作 | 说明 |
|---|---|---|
| 000 | ADD | a + b |
| 001 | SUB | a - b |
| 010 | AND | a & b |
| 011 | OR | a | b |
| 100 | XOR | a ^ b |
| 101 | SLT | a < b ? 1 : 0 |
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
4.4 编写测试用例
测试用例怎么写?我一般分三步走:
- 冒烟测试:先跑几个最简单的case,确保环境能跑通
- 边界测试:测边界值,比如最大值、最小值、0
- 随机测试:用随机数覆盖各种组合
咱们先写一个冒烟测试:
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验证环境了。下一章咱们聊聊覆盖率驱动验证,看看怎么用覆盖率来指导我们写更高效的测试用例。