3. 测试向量生成:定向测试与随机测试策略,约束求解器的使用技巧

测试向量怎么生成?这是验证工程师每天都要面对的问题。我刚入行那会儿,总觉得写测试用例就是对着协议手册一条条列出来,后来发现——嗯,事情没那么简单。

你想想看,一个复杂的硬件接口协议,状态机可能有几十个状态,组合起来的状态空间比银河系的星星还多。你不可能把所有情况都测一遍。那怎么办?

说白了,就是两招:定向测试和随机测试。再加上一个神器——约束求解器。

3.1 定向测试:稳扎稳打的基础

定向测试,就是你知道某个功能点,专门写一个测试用例去覆盖它。比如你要验证 AXI 总线的 WRAP 传输,那就构造一笔 WRAP 的读写请求,盯着它走完整个流程。

我个人的习惯是,先把协议里的必测场景列出来:

  • 复位后的初始状态
  • 每个命令的合法序列
  • 边界值(比如地址对齐、长度最大/最小)
  • 错误注入(比如非法命令、超时)

这些场景用定向测试来覆盖,效率最高。为什么?因为你知道答案,写出来就能跑,跑完就能看结果。

定向测试的核心价值:不是覆盖率,而是功能正确性的基线。你先把这些基础场景跑通,后面的随机测试才有意义。

举个例子,验证 SPI 接口的读操作:

// 定向测试:SPI 单字节读
spi_cmd = 0x03;          // 读命令
spi_addr = 0x00A5;       // 目标地址
spi_dummy = 0x00;        // 空周期
spi_read_data = spi_transfer(spi_cmd, spi_addr, spi_dummy);
assert(spi_read_data == expected_data);

这段代码很简单,但它验证了一个核心功能:SPI 读操作能正确返回数据。如果这个都跑不过,后面的随机测试就是浪费时间。

3.2 随机测试:发现你没想到的 bug

定向测试有个致命问题——你只能测到你想到的。那些你没想到的边界情况、时序交错、资源冲突,定向测试根本碰不到。

这时候就需要随机测试了。我记得有一次做 PCIe 验证,定向测试跑了上千个用例,全部通过。结果一上随机测试,不到 10 分钟就崩了——原因是两个 DMA 引擎同时访问同一块内存,产生了读写冲突。这个场景我写定向测试时根本没想到。

随机测试的核心思路是:让工具帮你生成大量合法或非法的输入组合,然后看 DUT 能不能正确处理。

对比维度 定向测试 随机测试
测试目的 验证已知功能 发现未知 bug
编写成本 高(每个用例手写) 低(写约束即可)
覆盖率 低(只能覆盖想到的) 高(能覆盖大量组合)
调试难度 低(场景可复现) 高(需要种子复现)
适用阶段 前期功能验证 后期压力测试

但随机测试不是乱测。你想想看,如果完全随机生成数据,大部分都是非法场景,DUT 直接报错,根本测不到正常功能。所以我们需要约束。

3.3 约束求解器:让随机变得聪明

约束求解器是什么?说白了,就是一个能帮你在合法范围内生成随机值的工具。你告诉它「地址必须是 4 字节对齐的」,它就不会生成 0x03 这种地址;你告诉它「长度不能超过 256」,它就会在 1~256 之间随机选。

我最早用 SystemVerilog 的 rand 和 constraint 时,觉得这东西就是个语法糖。后来做 USB 3.0 验证,需要生成各种包类型、长度、CRC 的组合,手写枚举根本写不完。用约束求解器,几行代码就搞定了。

使用技巧:约束求解器的核心是约束的合理性。约束太松,生成的场景没有意义;约束太紧,又失去了随机性。我建议先写一个「合法范围」的约束,跑一轮随机,看看覆盖率;再逐步收紧约束,聚焦到边界和异常场景。

来看一个实际的例子。验证 AHB 总线,我们需要生成随机的传输序列:

class AhbTransaction;
    rand bit [31:0] addr;
    rand bit [2:0]  burst;
    rand bit        write;
    rand bit [7:0]  data[];

    // 约束:地址必须对齐到 burst 长度
    constraint addr_align {
        if (burst == 3'b000) addr[1:0] == 2'b00;  // SINGLE
        else if (burst == 3'b001) addr[2:0] == 3'b000; // INCR4
        else if (burst == 3'b010) addr[3:0] == 4'b0000; // INCR8
    }

    // 约束:burst 长度不能超过 16
    constraint burst_len {
        data.size() <= 16;
        data.size() >= 1;
    }

    // 约束:写操作时数据不能为 X
    constraint write_data {
        if (write) foreach(data[i]) data[i] != 8'hXX;
    }
endclass

这段代码里,约束求解器会自动生成满足所有条件的随机值。你不需要手动枚举每个地址、每个 burst 长度——工具帮你做了。

注意:约束求解器不是万能的。如果约束之间互相矛盾(比如既要地址对齐到 4 字节,又要地址是奇数),求解器会报错。我曾经遇到过这种情况,查了半天才发现是约束写冲突了。所以写约束时,一定要检查可解性。

3.4 定向 + 随机:混合策略才是王道

你可能会问:到底用定向还是随机?我的答案是:都要用。

我一般这样安排:

  1. 第一阶段:写 20~30 个定向测试,覆盖协议的核心功能点。确保 DUT 能正常工作。
  2. 第二阶段:写约束随机测试,覆盖大量组合场景。跑 1000~10000 个随机用例。
  3. 第三阶段:分析覆盖率,找到未覆盖的区域。针对这些区域写定向测试或收紧约束。

这个流程我用了很多年,效果一直不错。你想想看,定向测试保证了「该有的功能都有」,随机测试保证了「不该有的 bug 没有」。

3.5 知识结构图

下面这张图总结了测试向量生成的完整策略:

测试向量生成策略 测试向量生成 定向测试 随机测试 功能基线 边界值 错误注入 约束求解 覆盖率驱动 压力测试 混合策略:定向 + 随机 定向测试保证功能正确性,随机测试发现隐藏 bug,两者互补

3.6 避坑指南

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

  • 约束写得太死:我曾经把地址约束写成 addr % 4 == 0,结果随机出来的地址永远是 0、4、8……完全失去了随机性。后来改成 addr[1:0] == 2'b00,效果就好多了。
  • 忘了设置种子:随机测试跑出 bug 后,如果没保存种子,你就没法复现。我现在每个随机用例都打日志,记录种子值。
  • 过度依赖随机:随机测试不能替代定向测试。有些关键路径,比如复位后的第一次操作,必须用定向测试来覆盖。

总结一句话:定向测试是骨架,随机测试是血肉,约束求解器是大脑。三者结合,才能写出高质量的测试向量。