这是本节的多页打印视图。 点击此处打印.

返回本页常规视图.

学习资源

本学习资源介绍验证相关的基本概念、技术,以及如何使用本项目提供的开源工具进行芯片验证

学习本材料前,假定您已经拥有linux、python等相关基础知识。

相关学习材料:

  1. 《Linux 101》在线讲义
  2. Python官方教程
  3. Javatpoint上的Git基础

若计划参与本平台上发布的“开源开放验证项目”,建议提前完本材料的学习。

1 - 快速开始

如何使用开放验证平台的环境参与到硬件验证中来。

在开始前本页会 简单的介绍什么是验证,以及示例里面用到的概念,如 DUT (Design Under Test) 和 RM (Reference Model) 。

芯片验证

芯片验证是确保芯片设计正确性和可靠性的重要环节,主要包括功能验证、形式验证和物理验证等形式,本学习材料仅仅包含对功能验证的介绍,且侧重于基于仿真器的芯片功能验证。芯片功能验证的流程和方法与软件测试有比较大的共同点,例如都有单元测试、系统测试、黑盒测试、白盒测试等。在验证指标上也有共同特点,例如功能覆盖率、代码覆盖率等等。从某种形式上说,除了使用的工具和编程语言不一样外,他们的目标和流程几乎相同。因此,在不考虑工具和编程语言的情况下,会软件测试的工程师应当就会芯片验证。 但在实际工作中,软件测试和芯片验证属于两个完全不相交的行业,其主要原因是验证工具和验证语言的不同,导致软件测试工程师很难实现跨界。在芯片验证领域,通常使用硬件描述语言进行验证(例如 Verilog 或者 System Verilog),使用专业商业工具进行电路仿真。硬件描述语言不同于C++/Python等高级软件编程语言,具有独特的“时钟”特性,对于软件领域的工程师不友好,学习成本高。

为了打通芯片验证与传统软件测试之间的壁垒,让更多的人参与到芯片验证,本项目提供如下内容:

多语言验证工具(Picker),让用户可以使用自己擅长的编程语言进行芯片验证

验证框架(toffee),如何在不关心时钟的情况下进行功能验证

介绍基本电路、验证知识,方便软件背景爱好者更能容易的理解电路特征

提供基本学习材料,学习基本验证知识

提供真实高性能芯片验证案例,让爱好者可以远程参与验证工作

基本术语

DUT: DUT(Design Under Test)指待测试设计,通常指设计好的RTL代码。

RM: Reference Model (RM)指代待测试单元对应的参考模型,参考模型通常被认为是标准的,没有错误的。

RTL: 指寄存器传输级(Register Transfer Level),通常指代芯片设计对应的 verilog 或者 vhdl 代码。

覆盖率: 测试覆盖率是指测试范围与整个需求范围的百分比。在芯片验证领域,通常有代码行覆盖率、函数覆盖率、功能覆盖率等。

DV: DV中的D通常指设计(Desgin),V指验证(Verification)。合在一起指设计与验证协同工作。

差分测试(difftest): 选取两个(或以上)功能相同的被测对象,选取符合被测对象要求的同一测试用例分别提交被测对象进行执行,以观测执行结果是否存在差异的过程。

工具介绍

本学习材料用到的核心工具为 picker (https://github.com/XS-MLVP/picker),它的作用是将RTL编写的设计模块自动提供高级编程语言接口(Python/C++等)。基于该工具,软件开发(测试)背景的验证人员可以不用去学习 Verilog/VHDL 等硬件描述语言进行芯片验证。

系统需求

建议操作系统:Ubuntu 22.04 LTS

在系统结构开发、科研的过程中,Linux 是最为常用的平台,这主要是因为 Linux 拥有丰富的软件、工具资源:由于 Linux 的开源性,各大重要工具软件(如 Verilator)可以很容易地面向 Linux 进行开发。 在本课程的实验中,多语言验证工具Picker、Swig等工具都可以在 Linux 上稳定运行。

1.1 - 搭建验证环境

安装相关依赖,下载、构建并安装对应的工具。

源码安装Picker工具

依赖安装

  1. cmake ( >=3.11 )
  2. gcc ( 支持c++20,至少为gcc版本10, 建议11及以上 )
  3. python3 ( >=3.8 )
  4. verilator ( >=4.218 )
  5. verible-verilog-format ( >=0.0-3428-gcfcbb82b )
  6. swig ( >=4.2.0, 用于多语言支持 )

请注意,请确保verible-verilog-format等工具的路径已经添加到环境变量$PATH中,可以直接命令行调用。

下载源码

git clone https://github.com/XS-MLVP/picker.git --depth=1
cd picker
make init

构建并安装

cd picker
make
# 可通过 make BUILD_XSPCOMM_SWIG=python,java,scala,golang 开启其他语言支持。
# 各语言需要自己的开发环境,需要自行配置,例如javac等
sudo -E make install

默认的安装的目标路径是 /usr/local, 二进制文件被置于 /usr/local/bin,模板文件被置于 /usr/local/share/picker。 如果需要修改安装目录,可以通过指定ARGS给cmake传递参数,例如make ARGS="-DCMAKE_INSTALL_PREFIX=your_instal_dir" 安装时会自动安装 xspcomm基础库(https://github.com/XS-MLVP/xcomm),该基础库是用于封装 RTL 模块的基础类型,位于 /usr/local/lib/libxspcomm.so可能需要手动设置编译时的链接目录参数(-L) 如果开启了Java等语言支持,还会安装 xspcomm 对应的多语言软件包。

picker也可以编译为wheel文件,通过pip安装

通过以下命令把picker打包成wheel安装包:

make wheel # or BUILD_XSPCOMM_SWIG=python,java,scala,golang make wheel

编译完成后,wheel文件位于dist目录,然后通过pip安装,例如:

pip install dist/xspcomm-0.0.1-cp311-cp311-linux_x86_64.whl
pip install dist/picker-0.0.1-cp311-cp311-linux_x86_64.whl

安装完成后,执行picker命令可以得到以下输出:

XDut Generate.
Convert DUT(*.v/*.sv) to C++ DUT libs.

Usage: ./build/bin/picker [OPTIONS] [SUBCOMMAND]

Options:
  -h,--help                   Print this help message and exit
  -v,--version                Print version
  --show_default_template_path
                              Print default template path
  --show_xcom_lib_location_cpp
                              Print xspcomm lib and include location
  --show_xcom_lib_location_java
                              Print xspcomm-java.jar location
  --show_xcom_lib_location_scala
                              Print xspcomm-scala.jar location
  --show_xcom_lib_location_python
                              Print python module xspcomm location
  --show_xcom_lib_location_golang
                              Print golang module xspcomm location
  --check                     check install location and supproted languages

Subcommands:
  export                      Export RTL Projects Sources as Software libraries such as C++/Python
  pack                        Pack UVM transaction as a UVM agent and Python class

安装测试

当前picker有export和pack两个子命令。

export 子命令用于将RTL设计转换成其他高级编程语言对应的“库”,可以通过软件的方式进行驱动。

picker export --help
Export RTL Projects Sources as Software libraries such as C++/Python
Usage: picker export [OPTIONS] file...

Positionals:
  file TEXT ... REQUIRED      DUT .v/.sv source file, contain the top module

Options:
  -h,--help                   Print this help message and exit
  --fs,--filelist TEXT ...    DUT .v/.sv source files, contain the top module, split by comma.
                              Or use '*.txt' file  with one RTL file path per line to specify the file list
  --sim TEXT [verilator]      vcs or verilator as simulator, default is verilator
  --lang,--language TEXT:{python,cpp,java,scala,golang} [python]
                              Build example project, default is python, choose cpp, java or python
  --sdir,--source_dir TEXT    Template Files Dir, default is ${picker_install_path}/../picker/template
  --sname,--source_module_name TEXT ...
                              Pick the module in DUT .v file, default is the last module in the -f marked file
  --tname,--target_module_name TEXT
                              Set the module name and file name of target DUT, default is the same as source.
                              For example, -T top, will generate UTtop.cpp and UTtop.hpp with UTtop class
  --tdir,--target_dir TEXT    Target directory to store all the results. If it ends with '/' or is empty,
                              the directory name will be the same as the target module name
  --internal TEXT             Exported internal signal config file, default is empty, means no internal pin
  -F,--frequency TEXT [100MHz]
                              Set the frequency of the **only VCS** DUT, default is 100MHz, use Hz, KHz, MHz, GHz as unit
  -w,--wave_file_name TEXT    Wave file name, emtpy mean don't dump wave
  -c,--coverage               Enable coverage, default is not selected as OFF
  --cp_lib,--copy_xspcomm_lib BOOLEAN [1]
                              Copy xspcomm lib to generated DUT dir, default is true
  -V,--vflag TEXT             User defined simulator compile args, passthrough.
                              Eg: '-v -x-assign=fast -Wall --trace' || '-C vcs -cc -f filelist.f'
  -C,--cflag TEXT             User defined gcc/clang compile command, passthrough. Eg:'-O3 -std=c++17 -I./include'
  --verbose                   Verbose mode
  -e,--example                Build example project, default is OFF
  --autobuild BOOLEAN [1]     Auto build the generated project, default is true

pack子命令用于将UVM中的 sequence_item 转换为其他语言,然后通过TLM进行通信(目前支持Python,其他语言在开发中)

picker pack --help
Pack uvm transaction as a uvm agent and python class
Usage: picker pack [OPTIONS] file...

Positionals:
  file TEXT ... REQUIRED      Sv source file, contain the transaction define

Options:
  -h,--help                   Print this help message and exit
  -e,--example                Generate example project based on transaction, default is OFF
  -c,--force                  Force delete folder when the code has already generated by picker
  -r,--rename TEXT ...        Rename transaction name in picker generate code

参数解释

export:
  • file TEXT ... REQUIRED:必须。位置参数,DUT.v/.sv 源文件,包含顶层模块
  • -h,--help: 可选。打印此帮助信息并退出
  • --fs,--filelist TEXT ...: 可选。DUT .v/.sv 源文件,包含顶层模块,逗号分隔。或使用 ‘*.txt’ 文件,每行指定一个 RTL 文件路径来指定文件列表
  • --sim TEXT [verilator]: 可选。使用 vcs 或 verilator 作为模拟器,默认是 verilator
  • --lang,--language TEXT:{python,cpp,java,scala,golang} [python]: 可选。构建示例项目,默认是 python,可选择 cpp、java 或 python
  • --sdir,--source_dir TEXT: 可选。模板文件目录,默认是 ${picker_install_path}/../picker/template
  • --sname,--source_module_name TEXT ...: 可选。在 DUT .v 文件中选择模块,默认是 -f 标记的文件中的最后一个模块
  • --tname,--target_module_name TEXT: 可选。设置目标 DUT 的模块名和文件名,默认与源相同。例如,-T top 将生成 UTtop.cpp 和 UTtop.hpp,并包含 UTtop 类
  • --tdir,--target_dir TEXT: 可选。代码生成渲染文件的目标目录,默认为DUT的模块名。如果该参数以’/‘结尾,则在该参数指定的目录中创建以DUT模块名的子目录。
  • --internal TEXT: 可选。导出的内部信号配置文件,默认为空,表示没有内部引脚
  • -F,--frequency TEXT [100MHz]: 可选。设置 仅 VCS DUT 的频率,默认是 100MHz,可以使用 Hz、KHz、MHz、GHz 作为单位
  • -w,--wave_file_name TEXT: 可选。波形文件名,空表示不导出波形
  • -c,--coverage: 可选。启用覆盖率,默认不选择为 OFF
  • --cp_lib,--copy_xspcomm_lib BOOLEAN [1]: 可选。将 xspcomm 库复制到生成的 DUT 目录,默认是 true
  • -V,--vflag TEXT: 可选。用户定义的模拟器编译参数,透传。例如:’-v -x-assign=fast -Wall –trace’ 或 ‘-C vcs -cc -f filelist.f’
  • -C,--cflag TEXT: 可选。用户定义的 gcc/clang 编译命令,透传。例如:’-O3 -std=c++17 -I./include’
  • --verbose: 可选。详细模式
  • -e,--example: 可选。构建示例项目,默认是 OFF
  • --autobuild BOOLEAN [1]: 可选。自动构建生成的项目,默认是 true

静态多模块支持:

picker在生成dut_top.sv/v的封装时,可以通过--sname参数指定多个模块名称和对应的数量。例如在a.v和b.v设计文件中分别有模块A和B,需要DUT中有2个A,3个B,生成的模块名称为C(若不指定,默认名称为A_B),则可执行如下命令:

picker path/a.v,path/b.v --sname A,2,B,3 --tname C

环境变量:

  • DUMPVARS_OPTION: 设置$dumpvars的option参数。例如DUMPVARS_OPTION="+mda" picker .... 开启vcs中数组波形的支持。
  • SIMULATOR_FLAGS: 传递给后端仿真器的参数。具体可参考所使用的后端仿真器文档。
  • CFLAGS: 设置后端仿真器的-cflags参数。
pack:
  • file: 必需。待解析的UVM transaction文件
  • --example, -e: 可选。根据UVM的transaction生成示例项目。
  • --force, -c: 可选。若已存在picker根据当前transaction解析出的文件,通过该命令可强制删除该文件,并重新生成
  • --rename, -r: 可选。配置生成文件以及生成的agent的名称,默认为transaction名。

测试Examples

编译完成后,在picker目录执行以下命令,进行测试:

bash example/Adder/release-verilator.sh --lang cpp
bash example/Adder/release-verilator.sh --lang python

# 默认仅开启 cpp 和 Python 支持
#   支持其他语言编译命令为:make BUILD_XSPCOMM_SWIG=python,java,scala,golang
bash example/Adder/release-verilator.sh --lang java
bash example/Adder/release-verilator.sh --lang scala
bash example/Adder/release-verilator.sh --lang golang

bash example/RandomGenerator/release-verilator.sh --lang cpp
bash example/RandomGenerator/release-verilator.sh --lang python
bash example/RandomGenerator/release-verilator.sh --lang java

参考材料

如何基于picker进行芯片验证,可参考:https://open-verify.cc/mlvp/docs/

1.2 - 案例一:简单加法器

基于一个简单的加法器验证展示工具的原理和使用方法,这个加法器内部是简单的组合逻辑。

RTL源码

在本案例中,我们驱动一个 64 位的加法器(组合电路),其源码如下:

// A verilog 64-bit full adder with carry in and carry out

module Adder #(
    parameter WIDTH = 64
) (
    input [WIDTH-1:0] a,
    input [WIDTH-1:0] b,
    input cin,
    output [WIDTH-1:0] sum,
    output cout
);

assign {cout, sum}  = a + b + cin;

endmodule

该加法器包含一个 64 位的加法器,其输入为两个 64 位的数和一个进位信号,输出为一个 64 位的和和一个进位信号。

测试过程

在测试过程中,我们将创建一个名为 Adder 的文件夹,其中包含一个 Adder.v 文件。该文件内容即为上述的 RTL 源码。

将RTL导出为 Python Module

生成中间文件

进入 Adder 文件夹,执行如下命令:

picker export --autobuild=false Adder.v -w Adder.fst --sname Adder --tdir picker_out_adder/ --lang python -e --sim verilator

*注:–tdir 指定的是目标构建目录,如果该参数值为空或者以“/”结尾,picker则会自动以DUT的目标模块名创建构建目录。例如 --tdir picker_out_adder指定了当前目录下的picker_out_adder为构建目录,而参数--tdir picker_out_adder/则指定picker在当前目录picker_out_adder中创建Adder目录作为目标构建目录。

该命令的含义是:

  1. 将 Adder.v 作为 Top 文件,并将 Adder 作为 Top Module,基于 verilator 仿真器生成动态库,生成目标语言为 Python。
  2. 启用波形输出,目标波形文件为Adder.fst。
  3. 包含用于驱动示例项目的文件(-e),同时codegen完成后不自动编译(-autobuild=false)。
  4. 最终的文件输出路径是 picker_out_adder

在使用该命令时,还有部分命令行参数没有使用,这些命令将在后续的章节中介绍。

输出的目录结构如下,请注意这部分均为中间文件,不能直接使用:

picker_out_adder/
└── Adder
    |-- Adder.v # 原始的RTL源码
    |-- Adder_top.sv # 生成的Adder_top顶层封装,使用DPI驱动Adder模块的inputs和outputs
    |-- Adder_top.v # 生成的Adder_top顶层封装,因为Verdi不支持导入SV源码使用,因此需要生成一个Verilog版本
    |-- CMakeLists.txt # 用于调用仿真器编译基本的cpp class并将其打包成有裸DPI函数二进制动态库(libDPIAdder.so)
    |-- Makefile # 生成的Makefile,用于调用CMakeLists.txt,并让用户可以通过make命令编译出libAdder.so,并手动调整Makefile的配置参数。或者编译示例项目
    |-- cmake # 生成的cmake文件夹,用于调用不同仿真器编译RTL代码
    |   |-- vcs.cmake
    |   `-- verilator.cmake
    |-- cpp # CPP example目录,包含示例代码
    |   |-- CMakeLists.txt # 用于将libDPIAdder.so使用基础数据类型封装为一个可直接操作的类(libUTAdder.so),而非裸DPI函数。
    |   |-- Makefile
    |   |-- cmake
    |   |   |-- vcs.cmake
    |   |   `-- verilator.cmake
    |   |-- dut.cpp # 生成的cpp UT封装,包含了对libDPIAdder.so的调用,及UTAdder类的声明及实现
    |   |-- dut.hpp # 头文件
    |   `-- example.cpp # 调用UTAdder类的示例代码
    |-- dut_base.cpp # 用于调用与驱动不同仿真器编译结果的基类,通过继承封装为统一的类,用于隐藏所有仿真器相关的代码细节。
    |-- dut_base.hpp
    |-- filelist.f # 多文件项目使用的其他文件列表,请查看 -f 参数的介绍。本案例中为空
    |-- mk
    |   |-- cpp.mk # 用于控制以cpp为目标语言时的Makefile,包含控制编译示例项目(-e,example)的逻辑
    |   `-- python.mk # 同上,目标语言是python
    `-- python
        |-- CMakeLists.txt
        |-- Makefile
        |-- cmake
        |   |-- vcs.cmake
        |   `-- verilator.cmake
        |-- dut.i # SWIG配置文件,用于将libDPIAdder.so的基类与函数声明,依据规则用swig导出到python,提供python调用的能力
        `-- dut.py # 生成的python UT封装,包含了对libDPIAdder.so的调用,及UTAdder类的声明及实现,等价于 libUTAdder.so

构建中间文件

进入 picker_out_adder/Adder 目录并执行 make 命令,即可生成最终的文件。

Makefile 定义的自动编译过程流如下:

  1. 通过 cmake/*.cmake 定义的仿真器调用脚本,编译 Adder_top.sv 及相关文件为 libDPIAdder.so 动态库。
  2. 通过 CMakelists.txt 定义的编译脚本,将 libDPIAdder.so 通过 dut_base.cpp 封装为 libUTAdder.so 动态库。并将1、2步产物拷贝到 UT_Adder 目录下。
  3. 通过 dut_base.hppdut.hpp 等头文件,利用 SWIG 工具生成封装层,并最终在 UT_Adder 这一目录中构建一个 Python Module。
  4. 如果有 -e 参数,则将预先定义好的 example.py 置于 UT_Adder 目录的上级目录,作为如何调用该 Python Module 的示例代码。

最终目录结果为:

picker_out_adder/
└── Adder
    |-- _UT_Adder.so # Swig生成的wrapper动态库
    |-- __init__.py # Python Module的初始化文件,也是库的定义文件
    |-- libDPIAdder.a # 仿真器生成的库文件
    |-- libUTAdder.so # 基于dut_base生成的libDPI动态库封装
    |-- libUT_Adder.py # Swig生成的Python Module
    `-- xspcomm # xspcomm基础库,固定文件夹,不需要关注

配置测试代码

在picker_out_adder中添加 example.py


from Adder import *
import random

# 生成无符号随机数
def random_int(): 
    return random.randint(-(2**63), 2**63 - 1) & ((1 << 63) - 1)

# 通过python实现的加法器参考模型
def reference_adder(a, b, cin):
    sum = (a + b) & ((1 << 64) - 1)
    carry = sum < a
    sum += cin
    carry = carry or sum < cin
    return sum, 1 if carry else 0

def random_test():
    # 创建DUT
    dut = DUTAdder()
    # 默认情况下,引脚赋值不会立马写入,而是在下一次时钟上升沿写入,这对于时序电路适用,但是Adder为组合电路,所以需要立即写入
    #   因此需要调用AsImmWrite()方法更改引脚赋值行为
    dut.a.AsImmWrite()
    dut.b.AsImmWrite()
    dut.cin.AsImmWrite()
    # 循环测试
    for i in range(114514):
        a, b, cin = random_int(), random_int(), random_int() & 1
        # DUT:对Adder电路引脚赋值,然后驱动组合电路 (对于时序电路,或者需要查看波形,可通过dut.Step()进行驱动)
        dut.a.value, dut.b.value, dut.cin.value = a, b, cin
        dut.RefreshComb()
        # 参考模型:计算结果
        ref_sum, ref_cout = reference_adder(a, b, cin)
        # 检查结果
        assert dut.sum.value == ref_sum, "sum mismatch: 0x{dut.sum.value:x} != 0x{ref_sum:x}"
        assert dut.cout.value == ref_cout, "cout mismatch: 0x{dut.cout.value:x} != 0x{ref_cout:x}"
        print(f"[test {i}] a=0x{a:x}, b=0x{b:x}, cin=0x{cin:x} => sum: 0x{ref_sum}, cout: 0x{ref_cout}")
    # 完成测试
    dut.Finish()
    print("Test Passed")

if __name__ == "__main__":
    random_test()

运行测试

picker_out_adder 目录下执行 python3 example.py 命令,即可运行测试。在测试完成后我们即可看到 example 示例项目的输出。

[...]
[test 114507] a=0x7adc43f36682cffe, b=0x30a718d8cf3cc3b1, cin=0x0 => sum: 0x12358823834579604399, cout: 0x0
[test 114508] a=0x3eb778d6097e3a72, b=0x1ce6af17b4e9128, cin=0x0 => sum: 0x4649372636395916186, cout: 0x0
[test 114509] a=0x42d6f3290b18d4e9, b=0x23e4926ef419b4aa, cin=0x1 => sum: 0x7402657300381600148, cout: 0x0
[test 114510] a=0x505046adecabcc, b=0x6d1d4998ed457b06, cin=0x0 => sum: 0x7885127708256118482, cout: 0x0
[test 114511] a=0x16bb10f22bd0af50, b=0x5813373e1759387, cin=0x1 => sum: 0x2034576336764682968, cout: 0x0
[test 114512] a=0xc46c9f4aa798106, b=0x4d8f52637f0417c4, cin=0x0 => sum: 0x6473392679370463434, cout: 0x0
[test 114513] a=0x3b5387ba95a7ac39, b=0x1a378f2d11b38412, cin=0x0 => sum: 0x6164045699187683403, cout: 0x0
Test Passed

1.3 - 案例二:随机数生成器

基于一个16bit的LFSR随机数生成器展示工具的用法,该随机数生成器内部存在时钟信号、时序逻辑与寄存器。

RTL源码

在本案例中,我们驱动一个随机数生成器,其源码如下:

module RandomGenerator (
    input wire clk,
    input wire reset,
    input [15:0] seed,
    output [15:0] random_number
);
    reg [15:0] lfsr;

    always @(posedge clk or posedge reset) begin
        if (reset) begin
            lfsr <= seed;
        end else begin
            lfsr <= {lfsr[14:0], lfsr[15] ^ lfsr[14]};
        end
    end
 
    assign random_number = lfsr;
endmodule

该随机数生成器包含一个 16 位的 LFSR,其输入为一个 16 位的种子数,输出为一个 16 位的随机数。LFSR 的更新规则为:

  1. 将当前的 LFSR 的最高位与次高位异或,称为new_bit。
  2. 将原来的 LFSR 向左平移一位,将 new_bit 放在最低位。
  3. 丢弃最高位。

测试过程

在测试过程中,我们将创建一个名为 RandomGenerator 的文件夹,其中包含一个 RandomGenerator.v 文件。该文件内容即为上述的 RTL 源码。

将RTL构建为 Python Module

生成中间文件

进入 RandomGenerator 文件夹,执行如下命令:

picker export --autobuild=false RandomGenerator.v -w RandomGenerator.fst --sname RandomGenerator --tdir picker_out_rmg/ --lang python -e --sim verilator

该命令的含义是:

  1. 将RandomGenerator.v作为 Top 文件,并将RandomGenerator作为 Top Module,基于 verilator 仿真器生成动态库,生成目标语言为 Python。
  2. 启用波形输出,目标波形文件为RandomGenerator.fst
  3. 包含用于驱动示例项目的文件(-e),同时codegen完成后不自动编译(-autobuild=false)。
  4. 最终的文件输出路径是 picker_out_rmg

输出的目录类似加法器验证-生成中间文件,这里不再赘述。

构建中间文件

进入 picker_out_rmg 目录并执行 make 命令,即可生成最终的文件。

备注:其编译过程类似于 加法器验证-编译流程,这里不再赘述。

最终目录结果为:

picker_out_rmg
└── RandomGenerator
    |-- RandomGenerator.fst # 测试的波形文件
    |-- UT_RandomGenerator
    |   |-- RandomGenerator.fst.hier
    |   |-- _UT_RandomGenerator.so # Swig生成的wrapper动态库
    |   |-- __init__.py  # Python Module的初始化文件,也是库的定义文件
    |   |-- libDPIRandomGenerator.a # 仿真器生成的库文件
    |   |-- libUTRandomGenerator.so # 基于dut_base生成的libDPI动态库封装
    |   `-- libUT_RandomGenerator.py # Swig生成的Python Module
    |   `-- xspcomm  # xspcomm基础库,固定文件夹,不需要关注
    `-- example.py # 示例代码

配置测试代码

在picker_out_rmg中创建 example.py

from RandomGenerator import *
import random

# 定义参考模型
class LFSR_16:
    def __init__(self, seed):
        self.state = seed & ((1 << 16) - 1)

    def Step(self):
        new_bit = (self.state >> 15) ^ (self.state >> 14) & 1
        self.state = ((self.state << 1) | new_bit ) & ((1 << 16) - 1)

if __name__ == "__main__":
    dut = DUTRandomGenerator()            # 创建DUT 
    dut.InitClock("clk")                  # 指定时钟引脚,初始化时钟
    seed = random.randint(0, 2**16 - 1)   # 生成随机种子
    dut.seed.value = seed                 # 设置DUT种子
    ref = LFSR_16(seed)                   # 创建参考模型用于对比

    # reset DUT
    dut.reset.value = 1                   # reset 信号置1
    dut.Step()                            # 推进一个时钟周期(DUTRandomGenerator是时序电路,需要通过Step推进)
    dut.reset.value = 0                   # reset 信号置0
    dut.Step()                            # 推进一个时钟周期

    for i in range(65536):                # 循环65536次
        dut.Step()                        # dut 推进一个时钟周期,生成随机数
        ref.Step()                        # ref 推进一个时钟周期,生成随机数
        assert dut.random_number.value == ref.state, "Mismatch"  # 对比DUT和参考模型生成的随机数
        print(f"Cycle {i}, DUT: {dut.random_number.value:x}, REF: {ref.state:x}") # 打印结果
    # 完成测试
    print("Test Passed")
    dut.Finish()    # Finish函数会完成波形、覆盖率等文件的写入

运行测试程序

picker_out_rmg 目录下执行 python example.py 即可运行测试程序。在运行完成后,若输出 Test Passed,则表示测试通过。完成运行后,会生成波形文件:RandomGenerator.fst,可在bash中通过以下命令进行查看。

gtkwave RandomGenerator.fst

输出示例:

···
Cycle 65529, DUT: d9ea, REF: d9ea
Cycle 65530, DUT: b3d4, REF: b3d4
Cycle 65531, DUT: 67a9, REF: 67a9
Cycle 65532, DUT: cf53, REF: cf53
Cycle 65533, DUT: 9ea6, REF: 9ea6
Cycle 65534, DUT: 3d4d, REF: 3d4d
Cycle 65535, DUT: 7a9a, REF: 7a9a
Test Passed, destroy UT_RandomGenerator

1.4 - 案例三:双端口栈(回调)

双端口栈是一个拥有两个端口的栈,每个端口都支持push和pop操作。本案例以双端口栈为例,展示如何使用回调函数驱动DUT

双端口栈简介

双端口栈是一种数据结构,支持两个端口同时进行操作。与传统单端口栈相比,双端口栈允许同时进行数据的读写操作,在例如多线程并发读写等场景下,双端口栈能够提供更好的性能。本例中,我们提供了一个简易的双端口栈实现,其源码如下:

module dual_port_stack (
    input clk,
    input rst,

    // Interface 0
    input in0_valid,
    output in0_ready,
    input [7:0] in0_data,
    input [1:0] in0_cmd,
    output out0_valid,
    input out0_ready,
    output [7:0] out0_data,
    output [1:0] out0_cmd,

    // Interface 1
    input in1_valid,
    output in1_ready,
    input [7:0] in1_data,
    input [1:0] in1_cmd,
    output out1_valid,
    input out1_ready,
    output [7:0] out1_data,
    output [1:0] out1_cmd
);
    // Command definitions
    localparam CMD_PUSH = 2'b00;
    localparam CMD_POP = 2'b01;
    localparam CMD_PUSH_OKAY = 2'b10;
    localparam CMD_POP_OKAY = 2'b11;

    // Stack memory and pointer
    reg [7:0] stack_mem[0:255];
    reg [7:0] sp;
    reg busy;

    reg [7:0] out0_data_reg, out1_data_reg;
    reg [1:0] out0_cmd_reg, out1_cmd_reg;
    reg out0_valid_reg, out1_valid_reg;

    assign out0_data = out0_data_reg;
    assign out0_cmd = out0_cmd_reg;
    assign out0_valid = out0_valid_reg;
    assign out1_data = out1_data_reg;
    assign out1_cmd = out1_cmd_reg;
    assign out1_valid = out1_valid_reg;

    always @(posedge clk or posedge rst) begin
        if (rst) begin
            sp <= 0;
            busy <= 0;
        end else begin
            // Interface 0 Request Handling
            if (!busy && in0_valid && in0_ready) begin
                case (in0_cmd)
                    CMD_PUSH: begin
                        busy <= 1;
                        sp <= sp + 1;
                        out0_valid_reg <= 1;
                        stack_mem[sp] <= in0_data;
                        out0_cmd_reg <= CMD_PUSH_OKAY;
                    end
                    CMD_POP: begin
                        busy <= 1;
                        sp <= sp - 1;
                        out0_valid_reg <= 1;
                        out0_data_reg <= stack_mem[sp - 1];
                        out0_cmd_reg <= CMD_POP_OKAY;
                    end
                    default: begin
                        out0_valid_reg <= 0;
                    end
                endcase
            end

            // Interface 1 Request Handling
            if (!busy && in1_valid && in1_ready) begin
                case (in1_cmd)
                    CMD_PUSH: begin
                        busy <= 1;
                        sp <= sp + 1;
                        out1_valid_reg <= 1;
                        stack_mem[sp] <= in1_data;
                        out1_cmd_reg <= CMD_PUSH_OKAY;
                    end
                    CMD_POP: begin
                        busy <= 1;
                        sp <= sp - 1;
                        out1_valid_reg <= 1;
                        out1_data_reg <= stack_mem[sp - 1];
                        out1_cmd_reg <= CMD_POP_OKAY;
                    end
                    default: begin
                        out1_valid_reg <= 0;
                    end
                endcase
            end

            // Interface 0 Response Handling
            if (busy && out0_ready) begin
                out0_valid_reg <= 0;
                busy <= 0;
            end

            // Interface 1 Response Handling
            if (busy && out1_ready) begin
                out1_valid_reg <= 0;
                busy <= 0;
            end
        end
    end

    assign in0_ready = (in0_cmd == CMD_PUSH && sp < 255|| in0_cmd == CMD_POP && sp > 0) && !busy;
    assign in1_ready = (in1_cmd == CMD_PUSH && sp < 255|| in1_cmd == CMD_POP && sp > 0) && !busy && !(in0_ready && in0_valid);

endmodule

在该实现中,除了时钟信号(clk)和复位信号(rst)之外,还包含了两个端口的输入输出信号,它们拥有相同的接口定义。每个端口的信号含义如下:

  • 请求端口(in)
    • in_valid 输入数据有效信号
    • in_ready 输入数据准备好信号
    • in_data 输入数据
    • in_cmd 输入命令 (0:PUSH, 1:POP)
  • 响应端口(out)
    • out_valid 输出数据有效信号
    • out_ready 输出数据准备好信号
    • out_data 输出数据
    • out_cmd 输出命令 (2:PUSH_OKAY, 3:POP_OKAY)

当我们想通过一个端口对栈进行一次操作时,首先需要将需要的数据和命令写入到输入端口,然后等待输出端口返回结果。

具体地,如果我们想对栈进行一次 PUSH 操作。首先我们应该将需要 PUSH 的数据写入到 in_data 中,然后将 in_cmd 设置为 0,表示 PUSH 操作,并将 in_valid 置为 1,表示输入数据有效。接着,我们需要等待 in_ready 为 1,保证数据已经正确的被接收,此时 PUSH 请求已经被正确发送。

命令发送成功后,我们需要在响应端口等待栈的响应信息。当 out_valid 为 1 时,表示栈已经完成了对应的操作,此时我们可以从 out_data 中读取栈的返回数据(POP 操作的返回数据将会放置于此),从 out_cmd 中读取栈的返回命令。当读取到数据后,需要将 out_ready 置为 1,以通知栈正确接收到了返回信息。

如果两个端口的请求同时有效时,栈将会优先处理端口 0 的请求。

构建驱动环境

与案例一和案例二类似,在对双端口栈进行测试之前,我们首先需要利用 Picker 工具将 RTL 代码构建为 Python Module。在构建完成后,我们将通过 Python 脚本驱动 RTL 代码进行测试。

首先,创建名为 dual_port_stack.v 的文件,并将上述的 RTL 代码复制到该文件中,接着在相同文件夹下执行以下命令:

picker export --autobuild=true dual_port_stack.v -w dual_port_stack.fst --sname dual_port_stack --tdir picker_out_dual_port_stack/ --lang python -e --sim verilator

生成好的驱动环境位于 picker_out_dual_port_stack 文件夹中, 其中 dual_port_stack 为生成的 Python Module。

若自动编译运行过程中无错误发生,则代表环境被正确构建。

利用回调函数驱动 DUT

在本案例中,为了测试双端口栈的功能,我们需要对其进行驱动。但你可能很快就会发现,仅仅使用案例一和案例二中的方法很难对双端口栈进行驱动。因为在此前的测试中,DUT只有一条执行逻辑,给DUT输入数据后等待DUT输出即可。

但双端口栈却不同,它的两个端口是两个独立的执行逻辑,在驱动中,这两个端口可能处于完全不同的状态,例如端口0在等待DUT返回数据时,端口1有可能正在发送新的请求。这种情况下,使用简单的串行执行逻辑将很难对DUT进行驱动。

因此我们在本案例中我们将以双端口栈为例,介绍一种基于回调函数的驱动方法,来完成此类DUT的驱动。

回调函数简介

回调函数是一种常见的编程技术,它允许我们将一个函数传入,并等待某个条件满足后被调用。构建产生的 Python Module 中,我们提供了向内部执行环境注册回调函数的接口 StepRis,使用方法如下:

from dual_port_stack import DUTdual_port_stack

def callback(cycles):
    print(f"The current clock cycle is {cycles}")

dut = DUTdual_port_stack()
dut.StepRis(callback)
dut.Step(10)

你可以直接运行该代码来查看回调函数的效果。

在上述代码中,我们定义了一个回调函数 callback ,它接受一个参数 cycles ,并在每次调用时打印当前的时钟周期。接着通过 StepRis 将该回调函数注册到 DUT 中。

注册回调函数后,每运行一次 Step 函数,即每个时钟周期,都会在时钟信号上升沿去调用该回调函数,并传入当前的时钟周期。

通过这种方式,我们可以将不同的执行逻辑都写成回调函数的方式,并将多个回调函数注册到 DUT 中,从而实现对 DUT 的并行驱动。

基于回调函数驱动的双端口栈

通过回调函数的形式来完成一条完整的执行逻辑,通常我们会使用状态机的模式进行编写。每调用一次回调函数,就会引起状态机内部的状态变化,多次调用回调函数,就会完成一次完整的执行逻辑。

下面是一个基于回调函数驱动的双端口栈的示例代码:

import random
from dual_port_stack import *
from enum import Enum

class StackModel:
    def __init__(self):
        self.stack = []

    def commit_push(self, data):
        self.stack.append(data)
        print("push", data)

    def commit_pop(self, dut_data):
        print("Pop", dut_data)
        model_data = self.stack.pop()
        assert model_data == dut_data, f"The model data {model_data} is not equal to the dut data {dut_data}"
        print(f"Pass: {model_data} == {dut_data}")

class SinglePortDriver:
    class Status(Enum):
        IDLE = 0
        WAIT_REQ_READY = 1
        WAIT_RESP_VALID = 2
    class BusCMD(Enum):
        PUSH = 0
        POP = 1
        PUSH_OKAY = 2
        POP_OKAY = 3

    def __init__(self, dut, model: StackModel, port_dict):
        self.dut = dut
        self.model = model
        self.port_dict = port_dict

        self.status = self.Status.IDLE
        self.operation_num = 0
        self.remaining_delay = 0

    def push(self):
        self.port_dict["in_valid"].value = 1
        self.port_dict["in_cmd"].value = self.BusCMD.PUSH.value
        self.port_dict["in_data"].value = random.randint(0, 2**32-1)

    def pop(self):
        self.port_dict["in_valid"].value = 1
        self.port_dict["in_cmd"].value = self.BusCMD.POP.value

    def step_callback(self, cycle):
        if self.status == self.Status.WAIT_REQ_READY:
            if self.port_dict["in_ready"].value == 1:
                self.port_dict["in_valid"].value = 0
                self.port_dict["out_ready"].value = 1
                self.status = self.Status.WAIT_RESP_VALID

                if self.port_dict["in_cmd"].value == self.BusCMD.PUSH.value:
                    self.model.commit_push(self.port_dict["in_data"].value)

        elif self.status == self.Status.WAIT_RESP_VALID:
            if self.port_dict["out_valid"].value == 1:
                self.port_dict["out_ready"].value = 0
                self.status = self.Status.IDLE
                self.remaining_delay = random.randint(0, 5)

                if self.port_dict["out_cmd"].value == self.BusCMD.POP_OKAY.value:
                    self.model.commit_pop(self.port_dict["out_data"].value)

        if self.status == self.Status.IDLE:
            if self.remaining_delay == 0:
                if self.operation_num < 10:
                    self.push()
                elif self.operation_num < 20:
                    self.pop()
                else:
                    return

                self.operation_num += 1
                self.status = self.Status.WAIT_REQ_READY
            else:
                self.remaining_delay -= 1

def test_stack(stack):
    model = StackModel()

    port0 = SinglePortDriver(stack, model, {
        "in_valid": stack.in0_valid,
        "in_ready": stack.in0_ready,
        "in_data": stack.in0_data,
        "in_cmd": stack.in0_cmd,
        "out_valid": stack.out0_valid,
        "out_ready": stack.out0_ready,
        "out_data": stack.out0_data,
        "out_cmd": stack.out0_cmd,
    })

    port1 = SinglePortDriver(stack, model, {
        "in_valid": stack.in1_valid,
        "in_ready": stack.in1_ready,
        "in_data": stack.in1_data,
        "in_cmd": stack.in1_cmd,
        "out_valid": stack.out1_valid,
        "out_ready": stack.out1_ready,
        "out_data": stack.out1_data,
        "out_cmd": stack.out1_cmd,
    })

    dut.StepRis(port0.step_callback)
    dut.StepRis(port1.step_callback)

    dut.Step(200)


if __name__ == "__main__":
    dut = DUTdual_port_stack()
    dut.InitClock("clk")
    test_stack(dut)
    dut.Finish()

在上述代码中,实现了这样的驱动过程:每个端口独立对DUT进行驱动,并在一个请求完成后添加随机延迟,每个端口分别完成了 10 次 PUSH 操作与 10 次 POP 操作。

PUSHPOP 请求生效时,会调用同一个 StackModel 中的 commit_pushcommit_pop 函数,以模拟栈的行为,并在每次 POP 操作完成后对比 DUT 的返回数据与模型的数据是否一致。

为了实现对单个端口的驱动行为,我们实现了 SinglePortDriver 类,其中实现了一个接口进行收发的完整过程,通过 step_callback 函数来实现内部的更新逻辑。

在测试函数 test_stack 中,我们为双端口栈的每一个端口都创建了一个 SinglePortDriver 实例,传入了对应的接口,并通过 StepRis 函数将其对应的回到函数其注册到 DUT 中。之后调用 dut.Step(200) 时,每个时钟周期中都会自动调用一次回调函数,来完成整个驱动逻辑。

SinglePortDriver 驱动逻辑

上面提到,一般使用回调函数的形式需要将执行逻辑实现为状态机,因此在 SinglePortDriver 类中,需要记录包含端口所处的状态,它们分别是:

  • IDLE:空闲状态,等待下一次操作
    • 在空闲状态下,需要查看另一个状态 remaining_delay 来判断当前是否已经延时结束,如果延时结束可立即进行下一次操作,否则继续等待。
    • 当需要执行下一次操作时,需要查看状态 operation_num (当前已经执行的操作数)来决定下一次操作时 PUSH 还是 POP。之后调用相关函数对端口进行一次赋值,并将状态切换至 WAIT_REQ_READY
  • WAIT_REQ_READY:等待请求端口准备好
    • 当请求发出后(in_valid 有效),此时需要等待 in_ready 信号有效,以确保请求已经被正确接受。
    • 当请求被正确接受后,需要将 in_valid 置为 0,同时将 out_ready 置为 1,表示请求发送完毕,准备好接收回复。
  • WAIT_RESP_VALID:等待响应端口返回数据
    • 当请求被正确接受后,需要等待 DUT 的回复,即等待 out_valid 信号有效。当 out_valid 信号有效时,表示回复已经产生,一次请求完成,于是将 out_ready 置为 0,同时将状态切换至 IDLE

运行测试

在picker_out_dual_port_stack中创建exmaple.py文件,将上述代码复制到其中,然后执行以下命令:

cd picker_out_dual_port_stack
python3 example.py

可直接运行本案例的测试代码,你将会看到类似如下的输出:

...
push 77
push 140
push 249
push 68
push 104
push 222
...
Pop 43
Pass: 43 == 43
Pop 211
Pass: 211 == 211
Pop 16
Pass: 16 == 16
Pop 255
Pass: 255 == 255
Pop 222
Pass: 222 == 222
Pop 104
...

在输出中,你可以看到每次 PUSHPOP 操作的数据,以及每次 POP 操作的结果。如果输出中没有错误信息,则表示测试通过。

回调函数驱动的优劣

通过使用回调函数,我们能够完成对 DUT 的并行驱动,正如本例所示,我们通过两个回调函数实现了对拥有两个独立执行逻辑的端口的驱动。回调函数在简单的场景下,为我们提供了一种简单的并行驱动方法。

但是通过本例也可以看出,仅仅实现一套简单的“请求-回复”流程,就需要维护大量的内部状态,回调函数将本应完整的执行逻辑拆分为了多次函数调用,为代码的编写和调试增加了诸多复杂性。

1.5 - 案例四:双端口栈(协程)

双端口栈是一个拥有两个端口的栈,每个端口都支持push和pop操作。本案例以双端口栈为例,展示如何使用协程驱动DUT

双端口栈简介与环境构建

本案例中使用的双端口栈与案例三中的实现完全相同,请查看案例三中的双端口栈简介构建驱动环境

利用协程驱动 DUT

在案例三中,我们使用了回调函数的方式来驱动DUT,回调函数虽然给我们提供了一种能够完成并行操作的方式,然而其却把完成的执行流程割裂为多次函数调用,并需要维护大量中间状态,导致代码的编写及调试变得较为复杂。

在本案例中,我们将会介绍一种通过协程驱动的方法,这种方法不仅能够做到并行操作,同时能够很好地避免回调函数所带来的问题。

协程简介

协程是一种“轻量级”的线程,通过协程,你可以实现与线程相似的并发执行的行为,但其开销却远小于线程。其实现原理是,协程库实现了一个运行于单线程之上的事件循环(EventLoop),程序员可以定义若干协程并且加入到事件循环,由事件循环负责这些协程的调度。

一般来说,我们定义的协程在执行过程中会持续执行,直到遇到一个需要等待的“事件”,此时事件循环就会暂停执行该协程,并调度其他协程运行。当事件发生后,事件循环会再次唤醒该协程,继续执行。

对于硬件验证中的并行执行来说,这种特性正是我们所需要的,我们可以创建多个协程,来完成验证中的多个驱动任务。我们可以将时钟的执行当做事件,在每个协程中等待这个事件,当时钟信号到来时,事件循环会唤醒所有等待的协程,使其继续执行,直到他们等待下一个时钟信号。

我们用 Python 中的 asyncio 来实现对协程的支持:

import asyncio
from dual_port_stack import *

async def my_coro(dut, name):
    for i in range(10):
        print(f"{name}: {i}")
        await dut.AStep(1)

async def test_dut(dut):
    asyncio.create_task(my_coro(dut, "coroutine 1"))
    asyncio.create_task(my_coro(dut, "coroutine 2"))
    await asyncio.create_task(dut.RunStep(10))

dut = DUTdual_port_stack()
dut.InitClock("clk")
asyncio.run(test_dut(dut))
dut.Finish()

你可以直接运行上述代码来观察协程的执行过程。在上述代码中我们用 create_task 创建了两个协程任务并加入到事件循环中,每个协程任务中,会不断打印一个数字并等待下一个时钟信号到来。

我们使用 dut.RunStep(10) 来创建一个后台时钟,它会不断产生时钟同步信号,使得其他协程能够在时钟信号到来时继续执行。

基于协程驱动的双端口栈

利用协程,我们就可以将驱动双端口栈中单个端口逻辑写成一个独立的执行流,不需要再去维护大量的中间状态。

下面是我们提供的一个简单的使用协程驱动的验证代码:

import asyncio
import random
from dual_port_stack import *
from enum import Enum

class StackModel:
    def __init__(self):
        self.stack = []

    def commit_push(self, data):
        self.stack.append(data)
        print("Push", data)

    def commit_pop(self, dut_data):
        print("Pop", dut_data)
        model_data = self.stack.pop()
        assert model_data == dut_data, f"The model data {model_data} is not equal to the dut data {dut_data}"
        print(f"Pass: {model_data} == {dut_data}")

class SinglePortDriver:
    class BusCMD(Enum):
        PUSH = 0
        POP = 1
        PUSH_OKAY = 2
        POP_OKAY = 3

    def __init__(self, dut, model: StackModel, port_dict):
        self.dut = dut
        self.model = model
        self.port_dict = port_dict

    async def send_req(self, is_push):
        self.port_dict["in_valid"].value = 1
        self.port_dict["in_cmd"].value = self.BusCMD.PUSH.value if is_push else self.BusCMD.POP.value
        self.port_dict["in_data"].value = random.randint(0, 2**8-1)
        await self.dut.AStep(1)

        await self.dut.Acondition(lambda: self.port_dict["in_ready"].value == 1)
        self.port_dict["in_valid"].value = 0

        if is_push:
            self.model.commit_push(self.port_dict["in_data"].value)

    async def receive_resp(self):
        self.port_dict["out_ready"].value = 1
        await self.dut.AStep(1)

        await self.dut.Acondition(lambda: self.port_dict["out_valid"].value == 1)
        self.port_dict["out_ready"].value = 0

        if self.port_dict["out_cmd"].value == self.BusCMD.POP_OKAY.value:
            self.model.commit_pop(self.port_dict["out_data"].value)

    async def exec_once(self, is_push):
        await self.send_req(is_push)
        await self.receive_resp()
        for _ in range(random.randint(0, 5)):
            await self.dut.AStep(1)

    async def main(self):
        for _ in range(10):
            await self.exec_once(is_push=True)
        for _ in range(10):
            await self.exec_once(is_push=False)

async def test_stack(stack):
    model = StackModel()

    port0 = SinglePortDriver(stack, model, {
        "in_valid": stack.in0_valid,
        "in_ready": stack.in0_ready,
        "in_data": stack.in0_data,
        "in_cmd": stack.in0_cmd,
        "out_valid": stack.out0_valid,
        "out_ready": stack.out0_ready,
        "out_data": stack.out0_data,
        "out_cmd": stack.out0_cmd,
    })

    port1 = SinglePortDriver(stack, model, {
        "in_valid": stack.in1_valid,
        "in_ready": stack.in1_ready,
        "in_data": stack.in1_data,
        "in_cmd": stack.in1_cmd,
        "out_valid": stack.out1_valid,
        "out_ready": stack.out1_ready,
        "out_data": stack.out1_data,
        "out_cmd": stack.out1_cmd,
    })

    asyncio.create_task(port0.main())
    asyncio.create_task(port1.main())
    await asyncio.create_task(dut.RunStep(200))

if __name__ == "__main__":
    dut = DUTdual_port_stack()
    dut.InitClock("clk")
    asyncio.run(test_stack(dut))
    dut.Finish()

与案例三类似,我们定义了一个 SinglePortDriver 类,用于驱动单个端口的逻辑。在 main 函数中,我们创建了两个 SinglePortDriver 实例,分别用于驱动两个端口。我们将两个端口的驱动过程放在了入口函数 main 中,并通过 asyncio.create_task 将其加入到事件循环中,在最后我们通过 dut.RunStep(200) 来创建了后台时钟,以推动测试的进行。

该代码实现了与案例三一致的测试逻辑,即在每个端口中对栈进行 10 次 PUSH 和 10 次 POP 操作,并在操作完成后添加随机延迟。但你可以清晰的看到,利用协程进行编写,不需要维护任何的中间状态。

SinglePortDriver 逻辑

SinglePortDriver 类中,我们将一次操作封装为 exec_once 这一个函数,在 main 函数中只需要首先调用 10 次 exec_once(is_push=True) 来完成 PUSH 操作,再调用 10 次 exec_once(is_push=False) 来完成 POP 操作即可。

exec_once 函数中,我们首先调用 send_req 函数来发送请求,然后调用 receive_resp 函数来接收响应,最后通过等待随机次数的时钟信号来模拟延迟。

send_reqreceive_resp 函数的实现逻辑类似,只需要将对应的输入输出信号设置为对应的值,然后等待对应的信号变为有效即可,可以完全根据端口的执行顺序来编写。

类似地,我们使用 StackModel 类来模拟栈的行为,在 commit_pushcommit_pop 函数中分别模拟了 PUSH 和 POP 操作,并在 POP 操作中进行了数据的比较。

运行测试

在picker_out_dual_port_stack文件夹中创建example.py将上述代码复制到其中,然后执行以下命令:

cd picker_out_dual_port_stack
python3 example.py

可直接运行本案例的测试代码,你将会看到类似如下的输出:

...
Push 141
Push 102
Push 63
Push 172
Push 208
Push 130
Push 151
...
Pop 102
Pass: 102 == 102
Pop 138
Pass: 138 == 138
Pop 56
Pass: 56 == 56
Pop 153
Pass: 153 == 153
Pop 129
Pass: 129 == 129
Pop 235
Pass: 235 == 235
Pop 151
...

在输出中,你可以看到每次 PUSHPOP 操作的数据,以及每次 POP 操作的结果。如果输出中没有错误信息,则表示测试通过。

协程驱动的优劣

通过协程函数,我们可以很好地实现并行操作,同时避免了回调函数所带来的问题。每个独立的执行流都能被完整保留,实现为一个协程,大大方便了代码的编写。

然而,在更为复杂的场景下你会发现,实现了众多协程,会使得协程之间的同步和时序管理变得复杂。尤其是你需要在两个不与DUT直接交互的协程之间进行同步时,这种现象会尤为明显。

在这时候,你就需要一套协程编写的规范以及验证代码的设计模式,来帮助你更好地编写基于协程的验证代码。因此,我们提供了 toffee 库,它提供了一套基于协程的验证代码设计模式,你可以通过使用 toffee 来更好地编写验证代码,你可以在这里去进一步了解 toffee。

2 - 基础工具

开放验证平台的基础工具的详细使用方法。

在开始前本页会 简单的介绍什么是验证,以及示例里面用到的概念,如 DUT (Design Under Test) 和 RM (Reference Model) 。

2.1 - 工具介绍

验证工具的基本使用。

为满足开放验证的环境要求,我们开发了 Picker 工具,用于将 RTL 设计转换为多语言接口,并在此基础上进行验证,我们将会使用 Picker 工具生成的环境作为基础的验证环境。接下来我们将介绍 Picker 工具,及其基础的使用方法。

Picker 简介

picker 是一个芯片验证辅助工具,具有两个主要功能:

  1. 打包RTL设计验证模块: picker 可以将 RTL 设计验证模块(.v/.scala/.sv)打包成动态库,并提供多种高级语言(目前支持 C++、Python、Java、Scala、Golang)的编程接口来驱动电路。
  2. UVM-TLM代码自动生成: picker 能够基于用户提供的 UVM sequence_item 进行自动化的 TLM 代码封装,提供 UVM 与其他高级语言(如 Python)的通信接口。 该工具允许用户基于现有的软件测试框架,例如 pytest、junit、TestNG、go test 等,进行芯片单元测试。

基于 Picker 进行验证的优点:

  1. 不泄露 RTL 设计:经过 Picker 转换后,原始的设计文件(.v)被转化成了二进制文件(.so),脱离原始设计文件后,依旧可进行验证,且验证者无法获取 RTL 源代码。
  2. 减少编译时间:当 DUT(设计待测)稳定时,只需要编译一次(打包成 .so 文件)。
  3. 用户范围广:提供的编程接口多,覆盖不同语言的开发者。
  4. 使用丰富的软件生态:支持 Python3、Java、Golang 等生态系统。
  5. 自动化的 UVM 事务封装:通过自动化封装 UVM 事务,实现 UVM 和 Python 的通信。

Picker 目前支持的 RTL 仿真器:

  1. Verilator
  2. Synopsys VCS

Picker的工作原理
Picker的主要功能就是将Verilog代码转换为C++或者Python代码,以Chisel开发的处理器为例:先通过Chisel自带的工具将其转换为Verilog代码,再通Picker提供高级编程语言接口。

Picker的工作原理

Python 模块生成

生成模块的过程

Picker 导出 Python Module 的方式是基于 C++ 的。

  • Picker 是 代码生成(codegen)工具,它会先生成项目文件,再利用 make 编译出二进制文件。
  • Picker 首先会利用仿真器将 RTL 代码编译为 C++ Class,并编译为动态库。(见C++步骤详情)
  • 再基于 Swig 工具,利用上一步生成的 C++ 的头文件定义,将动态库导出为 Python Module。
  • 最终将生成的模块导出到目录,并按照需求清理或保留其他中间文件。

Swig 是一个用于将 C/C++ 导出为其他高级语言的工具。该工具会解析 C++ 头文件,并生成对应的中间代码。 如果希望详细了解生成过程,请参阅 Swig 官方文档。 如果希望知道 Picker 如何生成 C++ Class,请参阅 C++

  • 该这个模块和标准的 Python 模块一样,可以被其他 Python 程序导入并调用,文件结构也与普通 Python 模块无异。

Python 模块使用

  • 参数 --language python--lang python 用于指定生成Python基础库。
  • 参数 --example, -e 用于生成包含示例项目的可执行文件。
  • 参数 --verbose, -v 用于保留生成项目时的中间文件。

使用工具生成Python的DUT类

以案例一中的简单加法器为例:

  • Picker会自动生成Python的一个基础类,我们称之为DUT类,以前加法器为例,用户需要编写测试用例,即导入上一章节生成的 Python Module,并调用其中的方法,以实现对硬件模块的操作。 目录结构为:
picker_out_adder
├── Adder                   # Picker 工具生成的项目
│   ├── _UT_Adder.so
│   ├── __init__.py
│   ├── libUTAdder.so
│   ├── libUT_Adder.py
│   └── signals.json
└── example.py              # 用户需要编写的代码
  • 在DUT对应的DUTAdder类中共有8个方法(位于Adder/init.py文件),具体如下:
class DUTAdder:
    def InitClock(name: str)    # 初始化时钟,参数时钟引脚对应的名称,例如clk
    def Step(i:int = 1)         # 推进电路i个周期
    def StepRis(callback: Callable, args=None,  args=(), kwargs={})  # 设置上升沿回调函数
    def StepFal(callback: Callable, args=None,  args=(), kwargs={})  # 设置下降沿回调函数
    def SetWaveform(filename)   # 设置波形文件
    def SetCoverage(filename)   # 设置代码覆盖率文件
    def RefreshComb()           # 推进组合电路
    def Finish()                # 销毁电路
  • DUT对应的引脚,例如reset,clock等在DUTAdder类中以成员变量的形式呈现。如下所示,可以通过value进行引脚的读取和写入。
from Adder import *
dut = DUTAdder()
dut.a.value = 1  # 通过给引脚的.value属性赋值完成对引脚的赋值
dut.a[12] = 1    # 对引脚输入a的第12bit进行赋值
x = dut.a.value  # 读取引脚a的值
y = dut.a[12]    # 读取引脚a的第12bit的值

驱动DUT的一般流程

  1. 创建DUT,设置引脚模式。默认情况下,引脚是在一下个周期的上升沿进行赋值,如果是组合逻辑,需要设置赋值模式为立即赋值。
  2. 初始化时钟。其目的是将时钟引脚与DUT内置的xclock进行绑定。组合逻辑没有时钟可以忽略。
  3. reset电路。大部分时序电路都需要reset。
  4. 给DUT输入引脚写入数据。通过“pin.Set(x)”接口,或者pin.vaulue=x进行赋值。
  5. 驱动电路。时序电路用Step,组合电路用RefreshComb。
  6. 获取DUT各个引脚的输出进行检测。例如和参考模型进行的结果进行assert对比。
  7. 完成验证,销毁DUT。调用Finish()时,会把波形,覆盖率等写入到文件。

对应伪代码如下:


# Python DUT 的名字可通过 --tdir 指定
from DUT import *

# 1 创建
dut = DUT()

# 2 初始化
dut.SetWaveform("test.fst")
dut.InitClock("clock")

# 3 reset
dut.reset = 1
dut.Step(1)
dut.reset = 0
dut.Step(1)

# 4 输入数据
dut.input_pin1.value = 0x123123
dut.input_pin3.value = "0b1011"

# 5 驱动电路 
dut.Step(1)

# 6 得到结果
x = dut.output_pin.value
print("result:", x)

# 7 销毁
dut.Finish()

其他数据类型

一般情况下,通过上述DUT类自带的接口就能完成绝大部分DUT的验证,但一些特殊情况需要其他对应的接口,例如自定义时钟、异步操作、推进组合电路并写入波形、修改引脚属性等。

在picker生成的DUT类中,除了XData类型的引脚成员变量外,还有XClock类型的xclockXPort类型的xport

class DUTAdder(object):
    xport: XPort         # 成员变量 xport,用于管理DUT中的所有引脚
    xclock: XClock       # 成员变量 xclock,用于管理时钟
    # DUT 引脚
    a: XData
    b: XData
    cin: XData
    cout: XData

XData 类

  • DUT引脚中的数据通常位宽不确定,且有四种状态:0、1、Z和X。为此picker提供了XData进行电路引脚数据表示。

主要方法

class XData:
    #拆分XData,例如把一个32位XData中的第7-10位创建成为一个独立XData
    #  name:名称,start:开始位,width:位宽,例如auto sub = a.SubDataRef("sub_pin", 0, 4)
    def SubDataRef(name, start, width): XData
    def GetWriteMode():WriteMode     #获取XData的写模式,写模式有三种:Imme立即写,Rise上升沿写,Fall下降沿写
    def SetWriteMode(mode:WriteMode) #设置XData的写模式 eg: a.SetWriteMode(WriteMode::Imme)
    def DataValid():bool             #检测数据是否有效(Value中含有X或者Z态返回false否者true)
    def W():int             #获取XData的位宽(如果为0,表示XData为verilog中的logic类型,否则为Vec类型的位宽)
    def U():int             #获取XData的值(无符号,同 x = a.value)
    def S():int             #获取XData的值(有符号类型)
    def String():str        #将XData转位16进制的字符串类型,eg: "0x123ff",如果出现?,表现对应的4bit中有x或z态
    def Equal(xdata):bool   #判断2个XData是否相等
    def Set(value)          #对XData进行赋值,value类型可以为:XData, string, int, bytes等
    def GetBytes(): bytes   #以bytes格式获取XData中的数
    def Connect(xdata):bool #连接2个XData,只有In和Out类型的可以连接,当Out数据发生变化时,In类型的XData会自动写入
    def IsInIO():bool       #判断XData是否为In类型,改类型可读可写
    def IsOutIO():bool      #判断XData是否为Out类型,改类型只可读
    def IsBiIO():bool       #判断XData是否为Bi类型,改类型可读可写
    def IsImmWrite(): bool  #判断XData是否为Imm写入模式
    def IsRiseWrite(): bool #判断XData是否为Rise写入模式
    def IsFallWrite(): bool #判断XData是否为Fall写入模式
    def AsImmWrite()        #更改XData的写模式为Imm
    def AsRiseWrite()       #更改XData的写模式为Rise
    def AsFallWrite()       #更改XData的写模式为Fall
    def AsBiIO()            #更改XData为Bi类型
    def AsInIO()            #更改XData为In类型
    def AsOutIO()           #更改XData为Out类型
    def FlipIOType()        #将XData的IO类型进行取反,例如In变为Out或者Out变为In
    def Invert()            #将XData中的数据进行取反
    def At(index): PinBind  #获取第index, eg: x = a.At(12).Get() or a.At(12).Set(1)
    def AsBinaryString()    #将XData的数据变为二进制字符串,eg: "1001011"

为了简化赋值操作,XData 对 Set(value) 和 U() 方法进行了属性赋值重载,可以通过pin.value=xx=pin.value进行赋值和取值。

# 使用.value可以进行访问
# a 为XData类型
a.value = 12345        # 十进制赋值
a.value = 0b11011      # 二进制赋值
a.value = 0o12345      # 八进制赋值
a.value = 0x12345      # 十六进制赋值
a.value = -1           # 所有bit赋值1, a.value = x 与 a.Set(x) 等价
a[31] = 0              # 对第31位进行赋值
a.value = "x"          # 赋值高阻态
a.value = "z"          # 赋值不定态
x = a.value            # 获取值,与 x = a.U() 等价

XPort 类

  • 在处理少数几个XData引脚时,直接操作XData是比较清晰和直观的。但是,当涉及到多个XData时,进行批量管理就不太方便了。XPort是对XData的一种封装,它允许我们对多个XData进行集中操作。我们还提供了一些方法来方便地进行批量管理。

初始化与添加引脚

port = XPort("p") #创建前缀为p的XPort实例

主要方法

class XPort:
    def XPort(prefix = "")      #创建前缀为prefix的port, eg:p = XPort("tile_link_")
    def PortCount(): int        #获取端口中的Pin数量(即绑定的XData个数)
    def Add(pin_name, XData)    #添加Pin, eg:p.Add("reset", dut.reset)
    def Del(pin_name)           #删除Pin
    def Connect(xport2)         #链接2个Port
    def NewSubPort(std::string subprefix): XPort # 创建子Port,以subprefix开头的所有Pin构成子Port
    def Get(key, raw_key = False): XData         # 获取XData
    def SetZero()                                #设置Port中的所有XData为0

XClock 类

  • XClock是电路时钟的封装,用于驱动电路。在传统仿真工具(例如Verilator)中,需要手动为clk赋值,并通过step_eval函数更新状态。但在我们的工具中,我们提供了相应的方法,可以将时钟直接绑定到XClock上。只需使用我们的Step()方法,就可以同时更新clk和电路状态。

初始化与添加引脚

# 初始化
clk = XClock(stepfunc)  #参数stepfunc为DUT后端提供的电路推进方法,例如verilaor的step_eval等

主要方法

class XClock:
    def Add(xdata)       #将Clock和时钟进行绑定, eg:clock.Add(dut.clk)
    def Add(xport)       #将Clock和XData进行绑定
    def RefreshComb()                     #推进电路状态,不推进时间,不dump波形
    def RefreshCombT()                    #推进电路状态(推进时间,dump波形)
    def Step(int s = 1)                   #推进电路s个时钟周期,  DUT.Step = DUT.xclock.Step
    def StepRis(func, args=(), kwargs={}) #设置上升沿回调函数,DUT.StepRis = DUT.xclock.StepRis
    def StepFal(func, args=(), kwargs={}) #设置下降沿回调函数,DUT.StepFal = DUT.xclock.StepFal
    # 异步方法
    async AStep(cycle: int)      #异步推进cycle个时钟, eg:await dut.AStep(5)
    async ACondition(condition)  #异步等待conditon()为true
    async ANext()                #异步推进一个时钟周期,等同AStep(1)
    async RunStep(cycle: int)    #持续推进时钟cycle个时钟,用于最外层

2.2 - 波形生成

生成电路波形

使用方法

在使用 Picker 工具封装 DUT 时,使用选项-w [wave_file]指定需要保存的波形文件。 针对不同的后端仿真器,支持不同的波形文件类型,具体如下:

  1. Verilator
    • .vcd格式的波形文件。
    • .fst格式的波形文件,更高效的压缩文件。
  2. VCS
    • .fsdb格式的波形文件,更高效的压缩文件。

需要注意的是,如果你选择自行生成 libDPI_____.so 文件,那么波形文件格式不受上述约束的限制。因为波形文件是在仿真器构建 libDPI.so 时决定的,如果你自行生成,那么波形文件格式也需要自行用对应仿真器的配置指定。

Python 示例

正常情况下,dut需要被显式地声明完成任务,以通知进行模拟器的后处理工作(写入波形、覆盖率等文件)。 在Python中,需要在完成所有测试后,调用dut的.finalize()方法以通知模拟器任务已完成,进而将文件flush到磁盘。

加法器为例,以下为测试程序:

from UT_Adder import *

if __name__ == "__main__":
    dut = DUTAdder()

    for i in range(10):
        dut.a.value = i * 2
        dut.b.value = int(i / 4)
        dut.Step(1)
        print(dut.sum.value, dut.cout.value)

    dut.finalize() # flush the wave file to disk

运行结束后即可生成指定文件名的波形文件。

查看结果

GTKWave

使用 GTKWave 打开 fstvcd 波形文件,即可查看波形图。

GTKWave

Verdi

使用 Verdi 打开 fsdbvcd 波形文件,即可查看波形图。

Verdi

2.3 - 多文件输入

处理多个Verilog源文件

多文件输入输出

在许多情况中,某文件下的某个模块会例化其他文件下的模块,在这种情况下您可以使用Picker工具的-f选项处理多个verilog源文件。例如,假设您有Cache.sv, CacheStage.sv以及CacheMeta.sv三个源文件:

文件列表

Cache.sv

// In 
module Cache(
    ...
);
    CacheStage s1(
        ...
    );

    CacheStage s2(
        ...
    );

    CacheStage s3(
        ...
    );

    CacheMeta cachemeta(
        ...
    );
endmodule

CacheStage.sv

// In CacheStage.sv
module CacheStage(
    ...
);
    ...
endmodule

CacheMeta.sv

// In CacheMeta.sv
module CacheMeta(
    ...
);
    ...
endmodule

应用方式

其中,待测模块为Cache,位于Cache.sv中,则您可以通过以下命令生成DUT:

命令行指定

picker export Cache.sv --fs CacheStage.sv,CacheMeta.sv --sname Cache

通过文件列表文件指定

您也可以通过传入.txt文件的方式来实现多文件输入:

picker export Cache.sv --fs src.txt --sname Cache

其中src.txt的内容为:

CacheStage.sv
CacheMeta.sv

注意事项

  1. 需要注意的是,使用多文件输入时仍需要指定待测顶层模块所在的文件,例如上文中所示的Cache.sv
  2. 在使用多文件输入时,Picker会将所有文件都交给仿真器,仿真器同时进行编译,因此需要保证所有文件中的模块名不重复。

2.4 - 覆盖率统计

覆盖率工具

Picker 工具支持生成代码行覆盖率报告,toffee-test(https://github.com/XS-MLVP/toffee-test) 项目支持生成功能覆盖率报告。

代码行覆盖率

目前 Picker 工具支持基于 Verilator 仿真器生成的代码行覆盖率报告。

Verilator

Verilator 仿真器提供了覆盖率支持功能。
该功能的实现方式是:

  1. 利用 verilator_coverage 工具处理或合并覆盖率数据库,最终针对多个 DUT 生成一个 coverage.info 文件。
  2. 利用 lcov 工具的 genhtml 命令基于coverage.info和 RTL 代码源文件,生成完整的代码覆盖率报告。

使用时的流程如下:

  1. 使用 Picker 生成 dut 时,使能 COVERAGE 功能 (添加-c选项)。
  2. 仿真器运行完成后,dut.finalize() 之后会生成覆盖率数据库文件 V{DUT_NAME}.dat
  3. 基于 verilator_coverage 的 write-info 功能将其转换成 .info文件。
  4. 基于 lcovgenhtml 功能,使用.info文件和文件中指定的rtl 源文件,生成 html 报告。

注意: 文件中指定的rtl 源文件是指在生成的DUT时使用的源文件路径,需要保证这些路径在当前环境下是有效的。简单来说,需要编译时用到的所有.sv/.v文件都需要在当前环境下存在,并且目录不变。

verilator_coverage

verilator_coverage 工具用于处理 DUT 运行后生成的 .dat 的覆盖率数据。该工具可以处理并合并多个 .dat 文件,同时具有两类功能:

  1. 基于 .dat 文件生成 .info 文件,用于后续生成网页报告。

    • -annotate <output_dir>:以标注的形式在源文件中呈现覆盖率情况,结果保存到output_dir中。形式如下:

      100000  input logic a;   // Begins with whitespace, because
                              // number of hits (100000) is above the limit.
      %000000  input logic b;   // Begins with %, because
                              // number of hits (0) is below the limit.
      
    • -annotate-min <count>:指定上述的 limit 为 count

  2. 可以将 .dat 文件,结合源代码文件,将覆盖率数据以标注的形式与源代码结合在一起,并写入到指定目录。

    • -write <merged-datafile> -read <datafiles>:将若干个.dat(datafiles)文件合并为一个.dat 文件
    • -write-info <merged-info> -read <datafiles>:将若干个.dat(datafiles)文件合并为一个.info 文件

genhtml

locv 包提供的 genhtml 可以由上述的.info 文件导出可读性更好的 html 报告。命令格式为:genhtml [OPTIONS] <infofiles>。 建议使用-o <outputdir>选项将结果输出到指定目录。

加法器为例。

adder.jpg

使用示例

如果您使用 Picker 时打开了-c选项,那么在仿真结束后,会生成一个V{DUT_NAME}.dat文件。并且顶层目录会有一个 Makefile 文件,其中包含了生成覆盖率报告的命令。

命令内容如下:

coverage:
    ...
    verilator_coverage -write-info coverage.info ./${TARGET}/V${PROJECT}_coverage.dat
    genhtml coverage.info --output-directory coverage
    ...

在 shell 中输入make coverage,其会根据生成的.dat 文件生成 coverage.info,再使用genhtml再 coverage 目录下生成 html 报告。

VCS

VCS 对应的文档正在完善当中。

2.5 - 多时钟

多时钟示例

部分电路有多个时钟,XClock 类提供了分频功能,可以通过它实现对多时钟电路的驱动。

XClock 中的 FreqDivWith 接口

XClock 函数提供如下分频接口

void XClock::FreqDivWith(int div,   // 分频数,,即绑定的XClock的频率为原时钟频率的div分之1
                     XClock &clk,   // 绑定的XClock
                     int shift=0)   // 对波形进行 shift 个半周期的移位

XClock 的一般驱动流程

  1. 创建 XClock,绑定 DUT 的驱动函数
# 假设已经创建了DUT,并将其命名为dut
# 创建XClock
xclock = XClock(dut.dut.simStep)
  1. 绑定关联 clk 引脚
# clk是dut的时钟引脚
xclock.Add(dut.clk)
# Add方法具有别名:AddPin
  1. 通过 XPort 绑定与 clk 关联的引脚

因为在我们的工具中,对于端口的读写是通过 xclock 来驱动的,所以如果不将与 clk 关联的引脚绑定到 XClock 上,那么在驱动时,相关的引脚数值不会发生变化。
比如,我们要进行复位操作,那么可以将 reset 绑定到 xclock 上。

方法:

class XClock:
    def Add(xport)       #将Clock和XData进行绑定

举例:

# xclock.Add(dut.xport.Add(pin_name, XData))
xclock.Add(dut.xport.Add("reset", dut.reset))

在经过了前面的绑定之后,接下来可以使用了。
我们根据需要来设置回调、设置分频。当然,时序电路肯定也要驱动时钟。
这些方法都可以参照工具介绍

下面是举例:

# func为回调函数,args为自定义参数
#设置上升沿回调函数
dut.StepRis(func, args=(), kwargs={})
#设置下降沿回调函数
dut.StepFal(func, args=(), kwargs={})
# 假设xclock是XClock的实例
xclock.FreqDivWith(2, half_clock)           # 将xclock的频率分频为原来的一半
xclock.FreqDivWith(1, left_clock -2)      # 将xclock的频率不变,对波形进行一个周期的左移
dut.Step(10) #推进10个时钟周期

多时钟案例

例如多时钟电路有 6 个 clock,每个 clock 都有一个对应的计数器,设计代码如下:

module multi_clock (
    input wire clk1,
    input wire clk2,
    input wire clk3,
    input wire clk4,
    input wire clk5,
    input wire clk6,
    output reg [31:0] reg1,
    output reg [31:0] reg2,
    output reg [31:0] reg3,
    output reg [31:0] reg4,
    output reg [31:0] reg5,
    output reg [31:0] reg6
);
    initial begin
        reg1 = 32'b0;
        reg2 = 32'b0;
        reg3 = 32'b0;
        reg4 = 32'b0;
        reg5 = 32'b0;
        reg6 = 32'b0;
    end
    always @(posedge clk1) begin
        reg1 <= reg1 + 1;
    end
    always @(posedge clk2) begin
        reg2 <= reg2 + 1;
    end
    always @(posedge clk3) begin
        reg3 <= reg3 + 1;
    end
    always @(posedge clk4) begin
        reg4 <= reg4 + 1;
    end
    always @(posedge clk5) begin
        reg5 <= reg5 + 1;
    end
    always @(posedge clk6) begin
        reg6 <= reg6 + 1;
    end
endmodule

通过picker导出:

picker export multi_clock.v -w mc.fst --tdir picker_out/MultiClock --lang python

可以通过如下 Python 进行多时钟驱动:


from MultiClock import *
from xspcomm import XClock

def test_multi_clock():
    # 创建DUT
    dut = DUTmulti_clock()
    # 创建主时钟
    main_clock = XClock(dut.dut.simStep)
    # 创建子时钟
    clk1, clk2, clk3 = XClock(lambda x: 0), XClock(lambda x: 0), XClock(lambda x: 0)
    clk4, clk5, clk6 = XClock(lambda x: 0), XClock(lambda x: 0), XClock(lambda x: 0)
    # 给子时钟添加相关的clock引脚及关联端口
    clk1.Add(dut.xport.SelectPins(["reg1"])).AddPin(dut.clk1.xdata)
    clk2.Add(dut.xport.SelectPins(["reg2"])).AddPin(dut.clk2.xdata)
    clk3.Add(dut.xport.SelectPins(["reg3"])).AddPin(dut.clk3.xdata)
    clk4.Add(dut.xport.SelectPins(["reg4"])).AddPin(dut.clk4.xdata)
    clk5.Add(dut.xport.SelectPins(["reg5"])).AddPin(dut.clk5.xdata)
    clk6.Add(dut.xport.SelectPins(["reg6"])).AddPin(dut.clk6.xdata)
    # 将主时钟频率分频到子时钟
    main_clock.FreqDivWith(1, clk1)
    main_clock.FreqDivWith(2, clk2)
    main_clock.FreqDivWith(3, clk3)
    main_clock.FreqDivWith(1, clk4, -1)
    main_clock.FreqDivWith(2, clk5, 1)
    main_clock.FreqDivWith(3, clk6, 2)
    # 驱动时钟
    main_clock.Step(100)
    dut.Finish()

if __name__ == "__main__":
    test_multi_clock()

上述代码输出的波形如下:

multi_clock

可以看到:

  • clk2 的周期是 clk1 的 2 倍
  • clk3 的周期是 clk1 的 3 倍,
  • clk4 的周期和 clk1 相同,但是进行了半个周期的右移
  • clk5 的周期和 clk2 相同,但是进行了半个周期的左移
  • clk6 的周期和 clk3 相同,但是进行了一个周期的左移

2.6 - 多实例

多实例示例

在Verilog中,一个module只有一个实例,但很多测试场景下需要实现多个module,为此picker提供了动态多实例和静态多实例的支持。

动态多实例

动态多实例相当于类的实例化,在创建dut的同时实例化对应的module,所以用户无感知。支持最大16个实例同时运行。

例子:

以Adder为例,我们可以在测试时根据需要在合适的位置创建多个dut,来动态创建多个Adder实例。 当需要销毁一个dut时,也不会影响后续创建新的dut。

创建一个名为 picker_out_adder 的文件夹,其中包含一个 Adder.v 文件。该文件的源码参考案例一:简单加法器

运行下述命令将RTL导出为 Python Module:

picker export Adder.v --autobuild true -w Adder.fst --sname Adder

在picker_out_adder中添加 example.py,动态创建多个Adder实例:

from Adder import *

import random

def random_int():
    return random.randint(-(2**127), 2**127 - 1) & ((1 << 127) - 1)

def main():
    dut=[]
    # 可以通过创建多个dut,实例化多个Adder,理论上支持最大16个实例同时运行
    for i in range(7):
        # 这里通过循环创建了7个dut
        dut.append(DUTAdder(waveform_filename=f"{i}.fst"))
    for d in dut:
        d.a.value = random_int()
        d.b.value = random_int()
        d.cin.value = random_int() & 1
        d.Step(1)
        print(f"DUT: sum={d.sum.value}, cout={d.cout.value}")
        # 通过Finish()函数在合适的时机撤销某个dut,也即销毁某个实例
        d.Finish()
    # 可以根据需要在合适的时机创建新的Adder实例
    # 下面创建了一个新的dut,旨在说明可以在程序结束前的任何时机创建新的dut
    dut_new = DUTAdder(waveform_filename=f"new.fst")
    dut_new.a.value = random_int()
    dut_new.b.value = random_int()
    dut_new.cin.value = random_int() & 1
    dut_new.Step(1)
    print(f"DUT: sum={dut_new.sum.value}, cout={dut_new.cout.value}")
    dut_new.Finish()

if __name__ == "__main__":
    main()

注:目前仅支持 verilator模拟器

静态多实例

静态多实例的使用不如动态多实例灵活,相当于在进行dut封装时就创建了n个目标模块。 需要在使用 picker 生成 dut_top.sv/v 的封装时,通过–sname参数指定多个模块名称和对应的数量。

单个模块需要多实例

同样以Adder为例,在使用picker对dut进行封装时执行如下命令:

picker export Adder.v --autobuild true -w Adder.fst --sname Adder,3

通过–sname参数指定在dut中创建3个Adder,封装后dut的引脚定义为:

# init.py 
# 这里仅放置了部分代码
class DUTAdder(object):
        ...
        # all Pins
        # 静态多实例
        self.Adder_0_a = xsp.XPin(xsp.XData(128, xsp.XData.In), self.event)
        self.Adder_0_b = xsp.XPin(xsp.XData(128, xsp.XData.In), self.event)
        self.Adder_0_cin = xsp.XPin(xsp.XData(0, xsp.XData.In), self.event)
        self.Adder_0_sum = xsp.XPin(xsp.XData(128, xsp.XData.Out), self.event)
        self.Adder_0_cout = xsp.XPin(xsp.XData(0, xsp.XData.Out), self.event)
        self.Adder_1_a = xsp.XPin(xsp.XData(128, xsp.XData.In), self.event)
        self.Adder_1_b = xsp.XPin(xsp.XData(128, xsp.XData.In), self.event)
        self.Adder_1_cin = xsp.XPin(xsp.XData(0, xsp.XData.In), self.event)
        self.Adder_1_sum = xsp.XPin(xsp.XData(128, xsp.XData.Out), self.event)
        self.Adder_1_cout = xsp.XPin(xsp.XData(0, xsp.XData.Out), self.event)
        self.Adder_2_a = xsp.XPin(xsp.XData(128, xsp.XData.In), self.event)
        self.Adder_2_b = xsp.XPin(xsp.XData(128, xsp.XData.In), self.event)
        self.Adder_2_cin = xsp.XPin(xsp.XData(0, xsp.XData.In), self.event)
        self.Adder_2_sum = xsp.XPin(xsp.XData(128, xsp.XData.Out), self.event)
        self.Adder_2_cout = xsp.XPin(xsp.XData(0, xsp.XData.Out), self.event)
        ...

可以看到在 picker 生成 dut 时,就在 DUTAdder 内创建了多个Adder实例。

下面是简单的多实例代码举例:

from Adder import *

import random

def random_int():
    return random.randint(-(2**127), 2**127 - 1) & ((1 << 127) - 1)

def main():
    # 在dut内部实例化了多个Adder
    dut = DUTAdder(waveform_filename = "1.fst")
    dut.Adder_0_a.value = random_int()
    dut.Adder_0_b.value = random_int()
    dut.Adder_0_cin.value = random_int() & 1
    dut.Adder_1_a.value = random_int()
    dut.Adder_1_b.value = random_int()
    dut.Adder_1_cin.value = random_int() & 1
    dut.Adder_2_a.value = random_int()
    dut.Adder_2_b.value = random_int()
    dut.Adder_2_cin.value = random_int() & 1
    dut.Step(1)
    print(f"Adder_0: sum={dut.Adder_0_sum.value}, cout={dut.Adder_0_cout.value}")
    print(f"Adder_1: sum={dut.Adder_1_sum.value}, cout={dut.Adder_1_cout.value}")
    print(f"Adder_2: sum={dut.Adder_2_sum.value}, cout={dut.Adder_2_cout.value}")
    # 静态多实例不可以根据需要动态的创建新的Adder实例,三个Adder实例的周期与dut的生存周期相同
    dut.Finish()

if __name__ == "__main__":
    main()

多个模块需要多实例

例如在 Adder.v 和 RandomGenerator.v 设计文件中分别有模块 Adder 和 RandomGenerator,RandomGenerator.v文件的源码为:

module RandomGenerator (
    input wire clk,
    input wire reset,
    input [127:0] seed,
    output [127:0] random_number
);
    reg [127:0] lfsr;

    always @(posedge clk or posedge reset) begin
        if (reset) begin
            lfsr <= seed;
        end else begin
            lfsr <= {lfsr[126:0], lfsr[127] ^ lfsr[126]};
        end
    end

    assign random_number = lfsr;
endmodule

需要 DUT 中有 2 个 Adder,3 个 RandomGenerator,生成的模块名称为 RandomAdder(若不指定,默认名称为 Adder_Random),则可执行如下命令:

picker export Adder.v,RandomGenerator.v --sname Adder,2,RandomGenerator,3 --tname RandomAdder -w randomadder.fst

得到封装后的dut为DUTRandomAdder,包含2个Adder实例和3个RandomGenerator实例。

封装后dut的引脚定义为:

# init.py 
# 这里仅放置了部分代码
class DUTRandomAdder(object):
        ...
        # all Pins
        # 静态多实例
        self.Adder_0_a = xsp.XPin(xsp.XData(128, xsp.XData.In), self.event)
        self.Adder_0_b = xsp.XPin(xsp.XData(128, xsp.XData.In), self.event)
        self.Adder_0_cin = xsp.XPin(xsp.XData(0, xsp.XData.In), self.event)
        self.Adder_0_sum = xsp.XPin(xsp.XData(128, xsp.XData.Out), self.event)
        self.Adder_0_cout = xsp.XPin(xsp.XData(0, xsp.XData.Out), self.event)
        self.Adder_1_a = xsp.XPin(xsp.XData(128, xsp.XData.In), self.event)
        self.Adder_1_b = xsp.XPin(xsp.XData(128, xsp.XData.In), self.event)
        self.Adder_1_cin = xsp.XPin(xsp.XData(0, xsp.XData.In), self.event)
        self.Adder_1_sum = xsp.XPin(xsp.XData(128, xsp.XData.Out), self.event)
        self.Adder_1_cout = xsp.XPin(xsp.XData(0, xsp.XData.Out), self.event)
        self.RandomGenerator_0_clk = xsp.XPin(xsp.XData(0, xsp.XData.In), self.event)
        self.RandomGenerator_0_reset = xsp.XPin(xsp.XData(0, xsp.XData.In), self.event)
        self.RandomGenerator_0_seed = xsp.XPin(xsp.XData(128, xsp.XData.In), self.event)
        self.RandomGenerator_0_random_number = xsp.XPin(xsp.XData(128, xsp.XData.Out), self.event)
        self.RandomGenerator_1_clk = xsp.XPin(xsp.XData(0, xsp.XData.In), self.event)
        self.RandomGenerator_1_reset = xsp.XPin(xsp.XData(0, xsp.XData.In), self.event)
        self.RandomGenerator_1_seed = xsp.XPin(xsp.XData(128, xsp.XData.In), self.event)
        self.RandomGenerator_1_random_number = xsp.XPin(xsp.XData(128, xsp.XData.Out), self.event)
        self.RandomGenerator_2_clk = xsp.XPin(xsp.XData(0, xsp.XData.In), self.event)
        self.RandomGenerator_2_reset = xsp.XPin(xsp.XData(0, xsp.XData.In), self.event)
        self.RandomGenerator_2_seed = xsp.XPin(xsp.XData(128, xsp.XData.In), self.event)
        self.RandomGenerator_2_random_number = xsp.XPin(xsp.XData(128, xsp.XData.Out), self.event)
        ...

可以看到在 picker 生成 dut 时,就在 DUTAdder 内创建了多个Adder实例。

对应的测试代码举例为:

from RandomAdder import *

import random

def random_int():
    return random.randint(-(2**127), 2**127 - 1) & ((1 << 127) - 1)

def main():
    # 在dut内部实例化了多个Adder
    dut = DUTRandomAdder()
    dut.InitClock("RandomGenerator_0_clk")
    dut.InitClock("RandomGenerator_1_clk")
    dut.InitClock("RandomGenerator_2_clk")
    dut.Adder_0_a.value = random_int()
    dut.Adder_0_b.value = random_int()
    dut.Adder_0_cin.value = random_int() & 1
    dut.Adder_1_a.value = random_int()
    dut.Adder_1_b.value = random_int()
    dut.Adder_1_cin.value = random_int() & 1
    
    # 在dut内部实例化了多个RandomGenerator
    seed = random.randint(0, 2**128 - 1)
    dut.RandomGenerator_0_seed.value = seed
    dut.RandomGenerator_0_reset.value = 1
    dut.Step(1)
    for i in range(10):
        print(f"Cycle {i}, DUT: {dut.RandomGenerator_0_random_number.value:x}")
        dut.Step(1)
    dut.RandomGenerator_1_seed.value = seed
    dut.RandomGenerator_1_reset.value = 1
    dut.Step(1)
    for i in range(10):
        print(f"Cycle {i}, DUT: {dut.RandomGenerator_1_random_number.value:x}")
        dut.Step(1)
    dut.RandomGenerator_2_seed.value = seed
    dut.RandomGenerator_2_reset.value = 1
    dut.Step(1)
    for i in range(10):
        print(f"Cycle {i}, DUT: {dut.RandomGenerator_2_random_number.value:x}")
        dut.Step(1)
    print(f"Adder_0: sum={dut.Adder_0_sum.value}, cout={dut.Adder_0_cout.value}")
    print(f"Adder_1: sum={dut.Adder_1_sum.value}, cout={dut.Adder_1_cout.value}")
    # 静态多实例各个模块多个实例的生命周期与dut的生命周期相同
    dut.Finish()

if __name__ == "__main__":
    main()

2.7 - 内部信号

内部信号示例

内部信号指的是不在模块的IO端口中暴露,但会在模块内部发挥控制、数据传输、状态跟踪功能的信号。一般来说,在picker将rtl转换成dut的过程中,只有IO端口才会被暴露,这些信号不会被主动暴露。

然而,当验证人员需要寻求对模块内部逻辑更精细的验证,或者需要根据已知的bug进一步定位问题时,就需要接触硬件模块内部的信号,此时除了使用verilator和VCS这些传统工具以外,也可以采用picker提供的内部信号提取机制作为辅助。

动机

以一个自带上限的计数器为例:


module UpperCounter (
    input wire clk,           
    input wire reset,         
    output reg [3:0] count   
);
    wire upper;

    assign upper = (count == 4'b1111);

    always @(posedge clk) begin
        if (reset) begin
            count = 4'b0000;
        end else if (!upper) begin
            count = count + 1;
        end
    end
endmodule

模块的IO信号指的是直接写在模块定义中的信号,也就是:

module UpperCounter (
    input wire clk,           
    input wire reset,         
    output reg [3:0] count   
);

该部分中的clk,reset和count即IO信号,是可以暴露出来的。

而紧接着的"wire upper;“也就是内部信号,其值是由模块的输入和模块内部的行为共同决定的。

本案例的计数器逻辑相对简单,然而,对于规模较大的硬件模块,则存在以下痛点:

当模块最终的输出和预期不符,存在问题的代码范围较大,亟需快速缩小问题范围的手段,

模块内部逻辑复杂,理解存在困难,此时也需要一些内部标记理清模块的关键逻辑。

对于以上痛点,都可以考虑诉诸内部信号。传统的查看内部信号的方式包括使用verilator和VCS。为进一步降低验证人员的使用门槛,我们的picker也提供了以下两种导出内部信号的方法: DPI直接导出和VPI动态导出。

DPI 直接导出

DPI即Direct Programming Interface,是verilog与其他语言交互的接口,在picker的默认实现中,支持了为待测硬件模块的IO端口提供DPI。在执行picker时,如果添加了--internal 选项,则可同样为待测模块的内部信号提供DPI。此时,picker将会基于预定义的内部信号文件,在将verilog转化为DUT时,同步抽取rtl中的内部信号和IO端口一并暴露出来。

编写信号文件

信号文件是我们向picker指定需要提取的内部信号的媒介,它规定了需提取内部信号的模块和该模块需要提取的内部信号。

我们创建一个internal.yaml,内容如下:

UpperCounter:
  - "wire upper"

第一行是模块名称,如UpperCounter,第二行开始是需要提取的模块内部信号,以“类型 信号名”的格式写出。比如,upper的类型为wire,我们就写成“wire upper” (理论上只要信号名符合verilog代码中的变量名就可以匹配到对应的信号,类型随便写都没问题,但还是建议写verilog语法支持的类型,比如wire、log、logic等)

内部信号提取的能力取决于模拟器,譬如,verilator就无法提取下划线_开头的信号。

注:多位宽的内部信号需要显式写出位宽,所以实际的格式是“类型 [宽度] 信号名”

UpperCounter:
  - "wire upper"
  - "reg [3:0] another_multiples" # 本案例中这个信号不存在,只是用于说明yaml的格式

选项支持

写好信号文件之后,需要在运行picker时显式指定内部文件,这通过internal选项完成:

--internal=[internal_signal_file]

完整命令如下:

picker export --autobuild=true upper_counter.sv -w upper_counter.fst --sname UpperCounter \
--tdir picker_out_upper_counter/ --lang python -e --sim verilator --internal=internal.yaml

我们可以找到picker为DUT配套生成的signals.json文件:

{
    "UpperCounter_upper": {
        "High": -1,
        "Low": 0,
        "Pin": "wire",
        "_": true
    },
    "clk": {
        "High": -1,
        "Low": 0,
        "Pin": "input",
        "_": true
    },
    "count": {
        "High": 3,
        "Low": 0,
        "Pin": "output",
        "_": true
    },
    "reset": {
        "High": -1,
        "Low": 0,
        "Pin": "input",
        "_": true
    }
}

这个文件展示了picker生成的信号接口,可以看到,第一个信号UpperCounter_upper就是我们需要提取的内部信号, 其中第一个下划线之前的部分是我们在internal.yaml中的第一行定义的模块名UpperCounter,后面的部分则是内部信号名。

访问信号

picker完成提取之后,内部信号的访问和io信号的访问就没有什么区别了,本质上他们都是dut上的一个XData,使用“dut.信号名”的方式访问即可。


print(dut.UpperCounter_upper.value)

优点

DPI直接导出在编译dut的过程中完成内部信号的导出,没有引入额外的运行时损耗,运行速度快。

局限

1、在编译DUT时,导出的内部信号就已经确定了,如果在测试中需要修改调用的内部信号,则需要重新修改内部信号文件并用picker完成转化。

2、导出的内部信号只可读取,不可写入,如果需要写入,则需要考虑接下来要介绍的VPI动态获取方法。

VPI 动态获取

TBD

优点:动态获取,能读能写 缺点:速度慢,请谨慎使用

2.8 - 集成测试框架

可用软件测试框架

在芯片验证的传统实践中,UVM等框架被广泛采用。尽管它们提供了一整套验证方法,但通常只适用于特定的硬件描述语言和仿真环境。本工具突破了这些限制,能够将仿真代码转换成C++或Python,使得我们可以利用软件验证工具来进行更全面的测试。
因为Python具有强大的生态系统,所以本项目主要以Python作为示例,简单介绍Pytest和Hypothesis两个经典软件测试框架。Pytest以其简洁的语法和丰富的功能,轻松应对各种测试需求。而Hypothesis则通过生成测试用例,揭示出意料之外的边缘情况,提高了测试的全面性和深度
我们的项目从一开始就设计为与多种现代软件测试框架兼容。我们鼓励您探索这些工具的潜力,并将其应用于您的测试流程中。通过亲身实践,您将更深刻地理解这些工具如何提升代码的质量和可靠性。让我们一起努力,提高芯片开发的质量。

2.8.1 - PyTest

可用来管理测试,生成测试报告

软件测试

在正式开始pytest 之间我们先了解一下软件的测试,软件测试一般分为如下四个方面

  • 单元测试:称模块测试,针对软件设计中的最小单位——程序模块,进行正确性检查的测试工作
  • 集成测试:称组装测试,通常在单元测试的基础上,将所有程序模块进行有序的、递增测试,重点测试不同模块的接口部分
  • 系统测试:将整个软件系统看成一个整体进行测试,包括对功能、性能以及软件所运行的软硬件环境进行测试
  • 验收测试:指按照项目任务书或合同、供需双方约定的验收依据文档进行的对整个系统的测试与评审,决定是否接收或拒收系统

pytest最初是作为一个单元测试框架而设计的,但它也提供了许多功能,使其能够进行更广泛的测试,包括集成测试,系统测试,他是一个非常成熟的全功能的python 测试框架。 它通过收集测试函数和模块,并提供丰富的断言库来简化测试的编写和运行,是一个非常成熟且功能强大的 Python 测试框架,具有以下几个特点:

  • 简单灵活:Pytest 容易上手,且具有灵活性。
  • 支持参数化:您可以轻松地为测试用例提供不同的参数。
  • 全功能:Pytest 不仅支持简单的单元测试,还可以处理复杂的功能测试。您甚至可以使用它来进行自动化测试,如 Selenium 或 Appium 测试,以及接口自动化测试(结合 Pytest 和 Requests 库)。
  • 丰富的插件生态:Pytest 有许多第三方插件,您还可以自定义扩展。一些常用的插件包括:
    • pytest-selenium:集成 Selenium。
    • pytest-html:生成HTML测试报告。
    • pytest-rerunfailures:在失败的情况下重复执行测试用例。
    • pytest-xdist:支持多 CPU 分发。
  • 与 Jenkins 集成良好
  • 支持 Allure 报告框架

本文将基于测试需求简单介绍pytest的用法,其完整手册在这里,供同学们进行深入学习。

Pytest安装

# 安装pytest:
pip install pytest
# 升级pytest
pip install -U pytest
# 查看pytest版本
pytest --version
# 查看已安装包列表
pip list
# 查看pytest帮助文档
pytest -h
# 安装第三方插件
pip install pytest-sugar
pip install pytest-rerunfailures
pip install pytest-xdist
pip install pytest-assume
pip install pytest-html

Pytest使用

命名规则

# 首先在使用pytest 时我们的模块名通常是以test开头或者test结尾,也可以修改配置文件,自定义命名规则
# test_*.py 或 *_test.py
test_demo1
demo2_test

# 模块中的类名要以Test 开始且不能有init 方法
class TestDemo1:
class TestLogin:

# 类中定义的测试方法名要以test_开头
test_demo1(self)
test_demo2(self)

# 测试用例
class test_one:
    def test_demo1(self):
        print("测试用例1")

    def test_demo2(self):
        print("测试用例2")

Pytest 参数

pytest支持很多参数,可以通过help命令查看

pytest -help

我们在这里列出来常用的几个:

-m: 用表达式指定多个标记名。 pytest 提供了一个装饰器 @pytest.mark.xxx,用于标记测试并分组(xxx是你定义的分组名),以便你快速选中并运行,各个分组直接用 and、or 来分割。

-v: 运行时输出更详细的用例执行信息 不使用-v参数,运行时不会显示运行的具体测试用例名称;使用-v参数,会在 console 里打印出具体哪条测试用例被运行。

-q: 类似 unittest 里的 verbosity,用来简化运行输出信息。 使用 -q 运行测试用例,仅仅显示很简单的运行信息, 例如:

.s..  [100%]
3 passed, 1 skipped in 9.60s

-k: 可以通过表达式运行指定的测试用例。 它是一种模糊匹配,用 and 或 or 区分各个关键字,匹配范围有文件名、类名、函数名。

-x: 出现一条测试用例失败就退出测试。 在调试时,这个功能非常有用。当出现测试失败时,停止运行后续的测试。

-s: 显示print内容 在运行测试脚本时,为了调试或打印一些内容,我们会在代码中加一些print内容,但是在运行pytest时,这些内容不会显示出来。如果带上-s,就可以显示了。

pytest test_se.py -s

Pytest 选择测试用例执行

在 Pytest 中,您可以按照测试文件夹、测试文件、测试类和测试方法的不同维度来选择执行测试用例。

  • 按照测试文件夹执行
# 执行所有当前文件夹及子文件夹下的所有测试用例
pytest .
# 执行跟当前文件夹同级的tests文件夹及子文件夹下的所有测试用例
pytest ../tests

# 按照测试文件执行
# 运行test_se.py下的所有的测试用例
pytest test_se.py

# 按照测试类执行,必须以如下格式:
pytest 文件名 .py:: 测试类其中::是分隔符用于分割测试module和测试类
# 运行test_se.py文件下的,类名是TestSE下的所有测试用例
pytest test_se.py::TestSE

# 测试方法执行,必须以如下格式:
pytest 文件名 .py:: 测试类 :: 测试方法其中 :: 是分隔符用于分割测试module测试类以及测试方法
# 运行test_se.py文件下的,类名是TestSE下的,名字为test_get_new_message的测试用例 
pytest test_se.py::TestSE::test_get_new_message

# 以上选择测试用例的方法均是在**命令行**,如果您想直接在测试程序里执行可以直接在main函数中**调用pytest.main()**,其格式为:
pytest.main([模块.py::::方法])

此外,Pytest 还支持控制测试用例执行的多种方式,例如过滤执行、多进程运行、重试运行等。

使用Pytest编写验证

  • 在测试过程中,我们使用之前验证过的加法器,进入Adder文件夹,在picker_out_adder目录下新建一个test_adder.py文件,内容如下:
# 导入测试模块和所需的库
from UT_Adder import *
import pytest
import ctypes
import random

# 使用 pytest fixture 来初始化和清理资源
@pytest.fixture
def adder():
    # 创建 DUTAdder 实例,加载动态链接库
    dut = DUTAdder()
    # 执行一次时钟步进,准备 DUT
    dut.Step(1)
    # yield 语句之后的代码会在测试结束后执行,用于清理资源
    yield dut
    # 清理DUT资源,并生成测试覆盖率报告和波形
    dut.Finish()

class TestFullAdder:
    # 将 full_adder 定义为静态方法,因为它不依赖于类实例
    @staticmethod
    def full_adder(a, b, cin):
        cin = cin & 0b1
        Sum = ctypes.c_uint64(a).value
        Sum += ctypes.c_uint64(b).value + cin
        Cout = (Sum >> 64) & 0b1
        Sum &= 0xffffffffffffffff
        return Sum, Cout

    # 使用 pytest.mark.usefixtures 装饰器指定使用的 fixture
    @pytest.mark.usefixtures("adder")
    # 定义测试方法,adder 参数由 pytest 通过 fixture 注入
    def test_adder(self, adder):
        # 进行多次随机测试
        for _ in range(114514):
            # 随机生成 64 位的 a 和 b,以及 1 位的进位 cin
            a = random.getrandbits(64)
            b = random.getrandbits(64)
            cin = random.getrandbits(1)
            # 设置 DUT 的输入
            adder.a.value = a
            adder.b.value = b
            adder.cin.value = cin
            # 执行一次时钟步进
            adder.Step(1)
            # 使用静态方法计算预期结果
            sum, cout = self.full_adder(a, b, cin)
            # 断言 DUT 的输出与预期结果相同
            assert sum == adder.sum.value
            assert cout == adder.cout.value

if __name__ == "__main__":
    pytest.main(['-v', 'test_adder.py::TestFullAdder'])
  • 运行测试之后输出如下:
collected 1 item                                                               

 test_adder.py ✓                                                 100% ██████████

Results (4.33s):

测试成功表明,在经过114514次循环之后,我们的设备暂时没有发现bug。然而,使用多次循环的随机数生成测试用例会消耗大量资源,并且这些随机生成的测试用例可能无法有效覆盖所有边界条件。在下一部分,我们将介绍一种更有效的测试用例生成方法。

2.8.2 - Hypothesis

可用来生成激励

Hypothesis

在上一节中,我们通过手动编写测试用例,并为每个用例指定输入和预期输出。这种方式存在一些问题,例如测试用例覆盖不全面、边界条件 容易被忽略等。它是一个用于属性基于断言的软件测试的 Python 库。Hypothesis 的主要目标是使测试更简单、更快速、更可靠。它使用了一种称为属性基于断言的测试方法,即你可以为你的代码编写一些假(hypotheses),然后 Hypothesis 将会自动生成测试用例并验证这些假设。这使得编写全面且高效的测试变得更加容易。Hypothesis 可以自动生成各种类型的输入数据,包括基本类型(例如整数、浮点数、字符串等)、容器类型(例如列表、集合、字典等)、自定义类型等。然后,它会根据你提供的属性(即断言)进行测试,如果发现测试失败,它将尝试缩小输入数据的范围以找出最小的失败案例。通过 Hypothesis,你可以更好地覆盖代码的边界条件,并发现那些你可能没有考虑到的错误情况。这有助于提高代码的质量和可靠性。

基本概念

  • 测试函数:即待测试的函数或方法,我们需要对其进行测试。
  • 属性:定义了测试函数应该满足的条件。属性是以装饰器的形式应用于测试函数上的。
  • 策略:用于生成测试数据的生成器。Hypothesis 提供了一系列内置的策略,如整数、字符串、列表等。我们也可以自定义策略
  • 测试生成器:基于策略生成测试数据的函数。Hypothesis 会自动为我们生成测试数据,并将其作为参数传递给测试函数。

本文将基于测试需求简单介绍Hypothesis的用法,其完整手册在这里,供同学们进行深入学习。

安装

使用pip安装,在python中导入即可使用

pip install hypothesis

import hypothesis

基本用法

属性和策略

Hypothesis 使用属性装饰器来定义测试函数的属性。最常用的装饰器是 @given,它指定了测试函数应该满足的属性。
我们可以通过@given 装饰器定义了一个测试函数 test_addition。并给x 添加对应的属性,测试生成器会自动为测试函数生成测试数据,并将其作为参数传递给函数,例如

def addition(number: int) -> int:
    return number + 1

@given(x=integers(), y=integers())  
def test_addition(x, y):     
	assert x + 1 == addition1

其中integers () 是一个内置的策略,用于生成整数类型的测试数据。Hypothesis 提供了丰富的内置策略,用于生成各种类型的测试数据。除了integers ()之外,还有字符串、布尔值、列表、字典等策略。例如使用 text () 策略生成字符串类型的测试数据,使用 lists (text ()) 策略生成字符串列表类型的测试数据

@given(s=text(), l=lists(text()))
def test_string_concatenation(s, l):     
	result = s + "".join(l)     
	assert len(result) == len(s) + sum(len(x) for x in l)

除了可以使用内置的策略以外,还可以使用自定义策略来生成特定类型的测试数据,例如我们可以生产一个非负整形的策略

def non_negative_integers():
  return integers(min_value=0)
@given(x=non_negative_integers())
  def test_positive_addition(x):
  assert x + 1 > x

期望

我们可以通过expect 来指明需要的函数期待得到的结果

@given(x=integers())
def test_addition(x):
    expected = x + 1
    actual = addition(x)

假设和断言

在使用 Hypothesis 进行测试时,我们可以使用标准的 Python 断言来验证测试函数的属性。Hypothesis 会自动为我们生成测试数据,并根据属性装饰器中定义的属性来运行测试函数。如果断言失败,Hypothesis 会尝试缩小测试数据的范围,以找出导致失败的最小样例。

假如我们有一个字符串反转函数,我们可以通过assert 来判断翻转两次后他是不是等于自身

def test_reverse_string(s):
    expected = x + 1
    actual = addition(x)
	assert actual == expected

编写测试

  • Hypothesis 中的测试由两部分组成:一个看起来像您选择的测试框架中的常规测试但带有一些附加参数的函数,以及一个@given指定如何提供这些参数的装饰器。以下是如何使用它来验证我们之前验证过的全加器的示例:

  • 在上一节的代码基础上,我们进行一些修改,将生成测试用例的方法从随机数修改为integers ()方法,修改后的代码如下:

from Adder import *
import pytest
from hypothesis import given, strategies as st

# 使用 pytest fixture 来初始化和清理资源
@pytest.fixture(scope="class")
def adder():
    # 创建 DUTAdder 实例,加载动态链接库
    dut = DUTAdder()
    # yield 语句之后的代码会在测试结束后执行,用于清理资源
    yield dut
    # 清理DUT资源,并生成测试覆盖率报告和波形
    dut.Finish()

class TestFullAdder:
    # 将 full_adder 定义为静态方法,因为它不依赖于类实例
    @staticmethod
    def full_adder(a, b, cin):
        cin = cin & 0b1
        Sum = a
        Sum += b + cin
        Cout = (Sum >> 128) & 0b1
        Sum &= 0xffffffffffffffffffffffffffffffff
        return Sum, Cout
    # 使用 hypothesis 自动生成测试用例
    @given(
        a=st.integers(min_value=0, max_value=0xffffffffffffffff),
        b=st.integers(min_value=0, max_value=0xffffffffffffffff),
        cin=st.integers(min_value=0, max_value=1)
    )
    # 定义测试方法,adder 参数由 pytest 通过 fixture 注入
    def test_full_adder_with_hypothesis(self, adder, a, b, cin):
        # 计算预期的和与进位
        sum_expected, cout_expected = self.full_adder(a, b, cin)
        # 设置 DUT 的输入
        adder.a.value = a
        adder.b.value = b
        adder.cin.value = cin
        # 执行一次时钟步进
        adder.Step(1)
        # 断言 DUT 的输出与预期结果相同
        assert sum_expected == adder.sum.value
        assert cout_expected == adder.cout.value

if __name__ == "__main__":
    # 以详细模式运行指定的测试
    pytest.main(['-v', 'test_adder.py::TestFullAdder'])

这个例子中,@given 装饰器和 strategies 用于生成符合条件的随机数据。st.integers() 是生成指定范围整数的策略,用于为 a 和 b 生成 0 到 0xffffffffffffffff 之间的数,以及为 cin 生成 0 或 1。Hypothesis会自动重复运行这个测试,每次都使用不同的随机输入,这有助于揭示潜在的边界条件或异常情况。

  • 运行测试,输出结果如下:
collected 1 item                                                               

 test_adder.py ✓                                                 100% ██████████

Results (0.42s):
       1 passed

可以看到在很短的时间里我们已经完成了测试

3 - 验证基础

介绍开放验证平台工作所需要的基础知识。

介绍芯片验证,以果壳 Cache 为例,介绍基本的验证流程、报告撰写。

3.1 - 芯片验证

关于芯片验证的基本概念

本页简单介绍什么是芯片验证,以及示例里面用到的概念,如 DUT (Design Under Test) 和 RM (Reference Model) 。

芯片验证过程需要和企业、团队的实际情况契合,没有符合所有要求,必须参考的绝对标准。

什么是芯片验证


芯片从设计到成品的过程主要包括芯片设计、芯片制造、芯片封测试三大阶段。在芯片设计中,又分前端设计和后端设计,前端设计也称之为逻辑设计,目标是让电路逻辑达到预期功能要求。后端设计也称为物理设计,主要工作是优化布局布线,减小芯片面积,降低功耗,提高频率等。芯片验证(Chip Verification)是芯片设计流程中的一个重要环节。它的目标是确保设计的芯片在功能、性能和功耗等方面都满足预定的规格。验证过程通常包括功能验证、时序验证和功耗验证等多个步骤,使用的方法和工具包括仿真、形式验证、硬件加速和原型制作等。针对本文,芯片验证仅包含对芯片前端设计的验证,验证设计的电路逻辑是否满足既定需求(“Does this proposed design do what is intended?"),通常也称为功能验证(Functional verification),不包含功耗、频率等后端设计

对于芯片产品,一旦设计错误被制造出来修改成本将会非常高昂,因为可能需要召回产品,并重新制造芯片,无论是经济成本还是时间成本都十分昂贵。经典由于芯片验证不足导致失败的典型案例如下:

Intel Pentium FDIV Bug:在1994年,Intel的Pentium处理器被发现存在一个严重的除法错误,这个错误被称为FDIV bug。这个错误是由于在芯片的浮点单元中,一个查找表中的几个条目错误导致的。这个错误在大多数应用中不会出现,但在一些特定的计算中会导致结果错误。由于这个错误,Intel不得不召回了大量的处理器,造成了巨大的经济损失。

Ariane 5 Rocket Failure:虽然这不是一个芯片的例子,但它展示了硬件验证的重要性。在1996年,欧洲空间局的Ariane 5火箭在发射后不久就爆炸了。原因是火箭的导航系统中的一个64位浮点数被转换为16位整数时溢出,导致系统崩溃。这个错误在设计阶段没有被发现,导致了火箭的失败。

AMD Barcelona Bug:在2007年,AMD的Barcelona处理器被发现存在一个严重的转译查找缓冲(TLB)错误。这个错误会导致系统崩溃或者重启。AMD不得不通过降低处理器的频率和发布BIOS更新来解决这个问题,这对AMD的声誉和财务状况造成了重大影响。

这些案例都强调了芯片验证的重要性。如果在设计阶段就能发现并修复这些错误,那么就可以避免这些昂贵的失败。验证不足的案例不仅发生在过去,也发生在现在,例如某新入局 ASIC 芯片市场的互联网企业打造一款 55 纳米芯片,极力追求面积缩减并跳过验证环节,最终导致算法失败,三次流片皆未通过测试,平均每次流片失败导致企业损失约 50 万美元。

芯片验证流程


验证在芯片设计中的位置

芯片设计和验证的耦合关系如上图所示,设计和验证有同样的输入,即规范文档(specification)。参考规范,设计与验证人员双方按照各自的理解,以及各自的需求进行独立编码实现。设计方需要满足的前提是编码的RTL代码“可综合”,需要考虑电路特性,而验证方一般只要考虑功能是否满足要求,编码限制少。双方完成模块开发后,需要进行健全性对比测试(Sanity Test),判定功能是否表现一致,若不一致需要进行协同排查,确定问题所在并进行修复,再进行对比测试,直到所有功能点都满足预期。由于芯片设计和芯片验证耦合度很高,因此有些企业在研发队伍上也进行了直接耦合,为每个子模块的设计团队都配置了对应的验证团队(DV)。上图中的设计与验证的耦合流程为粗粒度的关系,具体到具体芯片(例如Soc、DDR)、具体企业等都有其适合自身的合作模式。

在上述对比测试中,设计方的产出的模块通常称为DUT(Design Under Test),验证方开发的模型通常称为RM(Reference Model)。针对图中的验证工作,按照流程可以有:编写验证计划、创建验证平台、整理功能点、构建测试用例、运行调试、收集Bug/覆盖率、回归测试、编写测试报告等多个阶段。

验证计划: 验证计划描述了如何进行验证,以及如何保证验证质量,达到功能验证要求。在文档结构上通常包含验证目标,验证策略、验证环境、验证项、验证过程、风险防范、资源及时间表、结果和报告等部分。验证目标明确需要验证的功能或性能指标,这些目标应该直接从芯片的规范文档中提取。验证策略描述如何进行验证,包括可能使用的验证方法,例如仿真、形式化、FPGA加速等,以及如何组织验证任务。验证环境用于描述具体的测试环境,例如验证工具类型,版本号等。验证项包含了需要验证的具体项以及预期结果。验证计划可以有总计划,也可以针对具体验证的子任务进行编写。

平台搭建: 验证平台是具体验证任务的执行环境,同一类验证任务可以使用相同的验证平台。验证平台的搭建是验证流程中的关键步骤、具体包含验证工具选择(例如是采用软件仿真,还是采用形式化验证,或者硬件加速)、环境配置(例如配置服务器环境,FPGA环境)、创建测试环境、基本测试案例等。创建好基本测试平台,跑通基本测试案例,也通常称为“冒烟测试”。后继具体的测试代码,都将基于该测试平台进行,因此测试平台需要具有可重用性。验证平台通常包含测试框架和被测试代码,以及对应的基本信号激励。

功能点整理: 根据规范手册(spec)列出DUT的基本功能,并对其进行明确的描述,以及如何对该功能点进行测试。功能点整理过程中,需要根据重要性、风险、复杂性等因数对其进行优先级排序。功能点整理还需要对各个功能点进行追踪和状态,如果发现原始功能点有更新需要及时进行对应计划的同步。

测试用例: 测试用例是指一组条件或变量,用于确定DUT是否满足特定需求并能正确运行。每个测试用例通常包含测试条件,输入数据,预期结果,实际结果和测试结果。通过运行测试用例并比较预期结果和实际结果,可以确定系统或应用是否正确实现了特定的功能或需求。在芯片验证中,测试用例是用来验证芯片设计是否满足规格要求的重要工具。

编码实现: 编码实现即测试用例的具体执行过程,包括测试数据生成、测试框架选择、编程语言选择、参考模型编写等。编码实现是对功能点和测试用例充分理解后工作,如果理解不到位,可能导致DUT无法驱动,不能发现潜在bug等问题。

收集bug/覆盖率: 验证的目标就是提前发现设计中存在的bug,因此需要对发现的bug进行收集和管理。每发现一个新缺陷,需要给定唯一标号,并同设计工程师进行bug定级,然后进行状态追踪。能发现bug最好,但在实际验证中不是每次测试都能发现bug,因此需要另外一个指标评价验证是否到位。该指标通常采用覆盖率,当覆盖率超过一点阈值(例如代码覆盖率大于90%)后方可任务进行了充分验证。

回归测试: 验证和设计是一个相互迭代的过程,因此当验证出bug后,需要设计进行修正,且需要保证修正后的DUT仍然能正常工作。这种测试的目的是捕获可能由于修改而引入的新错误,或者重新激活旧错误。回归测试可以是全面的,也就是说,它涵盖了所有的功能,或者可以是选择性的,只针对某些特定的功能或系统部分。

测试报告: 测试报告是对整个验证过程的总结,它提供了关于测试活动的全面视图,包括测试的目标、执行的测试用例、发现的问题和缺陷、测试覆盖率和测试效率等。

芯片验证层次


按照验证对象的大小,芯片验证通常包含UT、BT、IT、ST四个层次。

单元测试(Unit Testing, UT): 这是最低的验证层次,主要针对单个模块或组件进行。目标是验证每个模块或组件的功能是否正确。

块测试(Block Testing,BT): 很多时候,单个模块和其他模块存在紧耦合,如果进行单独UT测试,可能存在信号处理复杂,功能验证不准确等问题,这时候可以把多个有耦合关系的模块合并成一个DUT块进行测试。

集成测试(Integration Testing): 在单元测试的基础上,将多个模块或组件组合在一起,验证它们能否正确地协同工作,通常用于测试子系统功能是否正常。

系统测试(System Testing): ST通常也称为Top验证,在集成测试的基础上,将所有的模块或组件组合在一起,形成一个完整的系统,验证系统的功能是否正确,以及系统的性能是否满足要求。

理论上,这些层次的验证通常按照从低到高的顺序进行,每个层次的验证都建立在前一个层次的验证的基础上。但实际验证活动中,需要根据企业验证人员的规模、熟练度,功能需求等进行选择,不一定所有层次的验证都需要涉及。在每个层次,都需要编写相应的测试用例,运行测试,收集和分析结果,以确保芯片设计的正确性和质量。

芯片验证指标


芯片验证的指标,通常包含功能正确性、测试覆盖率、缺陷密度、验证效率、验证成本等多个方面。功能正确性是最基本的验证指标,即芯片是否能够正确地执行其设计的功能。这通常通过运行一系列的功能测试用例来验证,包括正常情况下的功能测试,以及异常情况下的鲁棒性测试。测试覆盖率是指测试用例覆盖了多少设计的功能点,以及覆盖的程度如何。高的测试覆盖率通常意味着更高的验证质量。测试覆盖率可以进一步细分为代码覆盖率、功能覆盖率、条件覆盖率等。缺陷密度是指在一定的设计规模或代码量中,发现的缺陷的数量。低的缺陷密度通常意味着更高的设计质量。验证效率是指在一定的时间和资源下,能够完成的验证工作量。高的验证效率通常意味着更高的验证生产力。验证成本是指进行验证所需要的总体资源,包括人力、设备、时间等。低的验证成本通常意味着更高的验证经济性。

功能正确性是验证的绝对指标,但在实践中,很多时候无法确定测试方案是否完备,所有测试空间是否全部测试到位,因此需要一个可量化的指标来指导验证是否足够充分,是否可以结束验证。该指标通常采用“测试覆盖率”。测试覆盖率通常有代码覆盖率(行,函数,分支)、功能覆盖率。

代码行覆盖率: 即在测试过程中,DUT的设计代码中有多少行被执行;

函数覆盖率: 即在测试过程中,DUT的设计代码中有多少函数被执行;

分支覆盖率: 即在测试过程中,DUT的设计代码中有多少分支被执行(if else);

功能覆盖率: 即在测试过程中,有多少预定义功能被触发。

高的代码覆盖率可以提高验证的质量和可靠性,但并不能保证验证的完全正确性,因为它不能覆盖所有的输入和状态组合。因此,除了追求高的代码覆盖率,还需要结合其他测试方法和指标,如功能测试、性能测试、缺陷密度等。

芯片验证管理


芯片验证管理是一个涵盖了芯片验证过程中所有活动的管理过程,包括之前提到的验证策略的制定、验证环境的搭建、测试用例的编写和执行、结果的收集和分析、以及问题和缺陷的跟踪和修复等。芯片验证管理的目标是确保芯片设计满足所有的功能和性能要求,以及规格和标准。

在芯片验证管理中,首先需要制定一个详细的验证策略,包括验证的目标、范围、方法、时间表等。然后,需要搭建一个适合的验证环境,包括硬件设备、软件工具、测试数据等。接下来,需要编写一系列的测试用例,覆盖所有的功能和性能点,然后执行这些测试用例,收集和分析结果,找出问题和缺陷。最后,需要跟踪和修复这些问题和缺陷,直到所有的测试用例都能通过。

芯片验证管理是一个复杂的过程,需要多种技能和知识,包括芯片设计、测试方法、项目管理等。它需要与芯片设计、生产、销售等其他活动紧密协作,以确保芯片的质量和性能。芯片验证管理的效果直接影响到芯片的成功和公司的竞争力。因此,芯片验证管理是芯片开发过程中的一个重要环节。

芯片验证管理过程可以基于“项目管理平台”和“bug管理平台”进行,基于平台的管理效率通常情况下明显高于基于人工的管理模式。

芯片验证现状


当前,芯片验证通常是在芯片设计公司内部完成的,这一过程不仅技术上复杂,而且具有巨大的成本。从验收与设计的紧密关系来看,芯片验证不可避免地涉及芯片设计的源代码。然而,芯片设计公司通常将芯片设计源代码视为商业机密,这使得必须由公司内部人员来执行芯片验证,难以将验证工作外包。

验证工作量占比

芯片验证的重要性在于确保设计的芯片在各种条件下能够可靠运行。验证工作不仅仅是为了满足技术规格,还需要应对不断增长的复杂性和新兴技术的要求。随着半导体行业的发展,芯片验证的工作量不断增加,尤其是对于复杂的芯片而言,验证工作已经超过了设计工作,占比超过70%。这使得在工程师人员配比上,验证工程师人数通常是设计工程师人数的2倍或以上(例如zeku的三千人规模团队中,大约有一千人的设计工程师,两千人的验证工程师。其他大型芯片设计公司的验证人员占比类似或更高)。

由于验证工作的特殊性,需要对芯片设计源代码进行访问,这在很大程度上限制了芯片验证的外包可能性。芯片设计源代码被视为公司的核心商业机密,涉及到技术细节和创新,因此在安全和法律层面上不太可能与外部方共享。这也导致了公司内部人员必须承担验证工作的重任,增加了公司内部的工作负担和成本。

在当前情况下,芯片验证工程师的需求持续增加。他们需要具备深厚的技术背景,熟悉各种验证工具和方法,并且对新兴技术有敏锐的洞察力。由于验证工作的复杂性,验证团队通常需要庞大的规模,这与设计团队规模形成鲜明对比。

为了应对这一挑战,行业可能需要不断探索创新的验证方法和工具,以提高验证效率,降低成本。

小结:复杂芯片验证成本昂贵,表现在如下几个方面

验证工作量大: 对于复杂芯片,验证工作在整个芯片设计工作中,占比超过 70%。

人力成本高: 验证工程师人数是设计工程师人数的2倍,对于复杂业务,工程师数量在千人以上。

内部验证: 芯片设计公司为了保证商业秘密(芯片设计代码)不被泄露,只能选择招聘大量验证工程师,在公司内部进行验证工作。

芯片验证众包


相比与硬件,软件领域为了减少软件测试成本,测试外包(分包)已经成为常态,该领域的分包业务非常成熟,市场规模已经是千亿人民币级别,并朝万亿级别规模进发。从工作内容上看,软件测试和硬件验证,有非常大的共同特征(系统的目的不同的对象),如果以软件的方式对硬件验证进行分包是否可行?

软件外包市场

把芯片验证工作进行外包(分包)面临诸多挑战,例如:

从业人员基数少: 相比软件领域,硬件开发者数量少了几个数量级。例如在github的统计上(https://madnight.github.io/githut/#/pull_requests/2023/2),传统软件编程语言占(Python、Java、C++,Go)比接近 50%, 而硬件描述语言,verilog占比仅 0.076%,这能从侧面反应出各自领域的开发者数量。

验证工具商业化: 企业中使用的验证工具(仿真器、形式化、数据分析)几乎都是商业工具,这类工具对于普通人来说几乎不可见,自学难度高。

开放学习资料少: 芯片验证涉及到访问芯片设计的源代码,而这些源代码通常被视为公司的商业机密和专有技术。芯片设计公司可能不愿意公开详细的验证过程和技术,限制了学习材料的可用性。

可行性分析

虽然芯片验证领域一直以来相对封闭,但从技术角度而言,采用分包的方式进行验证是一种可行的选择。这主要得益于以下几个因素:

首先,随着开源芯片项目的逐渐增多,验证过程中所涉及的源代码已经变得更加开放和透明。这些开源项目在设计和验证过程中没有商业机密的顾虑,为学习和研究提供了更多的可能性。即使某些项目涉及商业机密,也可以通过采用加密等方式来隐藏设计代码,从而在一定程度上解决了商业机密的问题,使验证更容易实现。

其次,芯片验证领域已经涌现出大量的基础验证工具,如verilator和systemc等。这些工具为验证工程师提供了强大的支持,帮助他们更高效地进行验证工作。通过这些工具,验证过程的复杂性和难度得到了一定程度的缓解,为采用分包的验证方法提供了更为可行的技术基础。

在开源软件领域,已经有一些成功的案例可供参考。例如,Linux内核的验证过程采用了分包的方式,不同的开发者和团队分别负责不同的模块验证,最终形成一个整体完备的系统。类似地,机器学习领域的ImageNet项目也采用了分包标注的策略,通过众包的方式完成大规模的图像标注任务。这些案例为芯片验证领域提供了成功的经验,证明了分包验证在提高效率、降低成本方面的潜力。

因此,尽管芯片验证领域相对于其他技术领域而言仍显得封闭,但技术的进步和开源项目的增多为采用分包验证提供了新的可能性。通过借鉴其他领域的成功经验和利用现有的验证工具,我们有望在芯片验证中推动更加开放、高效的验证方法的应用,进一步促进行业的发展。这种技术的开放性和灵活性将为验证工程师提供更多的选择,推动芯片验证领域迎来更为创新和多样化的发展。

技术路线

为了克服挑战,让更多的人参与到芯片验证,本项目从如下几个技术方向进行持续尝试

提供多语言验证工具: 传统芯片验证是基于System Verilog编程语言进行,但是该语言用户基数少,为了让其他软件开发/测试的技术人员参与到芯片验证,本项目提供多语言验证转换工具Picker,它可以让验证者使用自己熟悉的编程语言(例如C++/Python/Java/Go)基于开源验证工具参与验证工作。

提供验证学习材料: 芯片验证学习材料少,主要原因由于商业公司几乎不可能公开其内部资料,为此本项目会持续更新学习材料,让验证人员可在线,免费学习所需要的技能。

提供真实芯片验证案例: 为了让学习材料更具使用性,本项目以“香山昆明湖(工业级高性能risc-v处理器)IP核”作为基础,从中摘取模块持续更新验证案例。

组织芯片设计分包验证: 学以致用是每个人学习的期望目标,为此本项目定期组织芯片设计的验证分包,让所有人(无论你是大学生、验证专家、软件开发测试者、还是中学生)都可以参与到真实芯片的设计工作中去。

本项目的目标是达到如下愿景,“打开传统验证模式的黑盒,让所有感兴趣的人可以随时随地的,用自己擅长的编程语言参与芯片验证”。

愿景

3.2 - 数字电路

关于数字电路的基本概念

本页将介绍数字电路的基础知识。数字电路是利用数字信号的电子电路。近年来,绝大多数的计算机都是基于数字电路实现的。

什么是数字电路


数字电路是一种利用两种不连续的电位来表示信息的电子电路。在数字电路中,通常使用两个电源电压,分别表示高电平(H)和低电平(L),分别代表数字1和0。这样的表示方式通过离散的电信号,以二进制形式传递和处理信息。

大多数数字电路的实现基于场效应管,其中最常用的是 MOSFET(Metal-Oxide-Semiconductor Field-Effect Transistor,金属氧化物半导体场效应管)。MOSFET 是一种半导体器件,可以在电场的控制下调控电流流动,从而实现数字信号的处理。

在数字电路中,MOSFET 被组合成各种逻辑电路,如与门、或门、非门等。这些逻辑门通过不同的组合方式,构建了数字电路中的各种功能和操作。以下是一些数字电路的基本特征:

(1) 电位表示信息: 数字电路使用两种电位,即高电平和低电平,来表示数字信息。通常,高电平代表数字1,低电平代表数字0。

(2) MOSFET 实现: MOSFET 是数字电路中最常用的元件之一。通过控制 MOSFET 的导通和截止状态,可以实现数字信号的处理和逻辑运算。

(3) 逻辑门的组合: 逻辑门是数字电路的基本构建块,由 MOSFET 组成。通过组合不同的逻辑门,可以构建复杂的数字电路,实现各种逻辑功能。

(4) 二进制表达: 数字电路中的信息通常使用二进制系统进行表示。每个数字都可以由一串二进制位组成,这些位可以在数字电路中被处理和操作。

(5) 电平转换和信号处理: 数字电路通过电平的变化和逻辑操作,实现信号的转换和处理。这种离散的处理方式使得数字电路非常适用于计算和信息处理任务。

为什么要学习数字电路


学习数字电路是芯片验证过程中的基础和必要前提,主要体现在以下多个方面:

(1) 理解设计原理: 数字电路是芯片设计的基础,了解数字电路的基本原理和设计方法是理解芯片结构和功能的关键。芯片验证的目的是确保设计的数字电路在实际硬件中按照规格正常工作,而理解数字电路原理是理解设计的关键。

(2) 设计规范: 芯片验证通常涉及验证设计是否符合特定的规范和功能要求。学习数字电路可以帮助理解这些规范,从而更好地构建测试用例和验证流程,确保验证的全面性和准确性。

(3) 时序和时钟: 时序问题是数字电路设计和验证中的常见挑战。学习数字电路可以帮助理解时序和时钟的概念,以确保验证过程中能够正确处理时序问题,避免电路中的时序迟滞和冲突。

(4) 逻辑分析: 芯片验证通常涉及对逻辑的分析,确保电路的逻辑正确性。学习数字电路可以培养对逻辑的深刻理解,从而更好地进行逻辑分析和故障排查。

(5) 测试用例编写: 在芯片验证中,需要编写各种测试用例来确保设计的正确性。对数字电路的理解可以帮助设计更全面、有针对性的测试用例,涵盖电路的各个方面。

(6) 信号完整性: 学习数字电路有助于理解信号在电路中的传播和完整性问题。在芯片验证中,确保信号在不同条件下的正常传递是至关重要的,特别是在高速设计中。

整体而言,学习数字电路为芯片验证提供了基础知识和工具,使验证工程师能够更好地理解设计,编写有效的测试用例,分析验证结果,并解决可能出现的问题。数字电路的理论和实践经验对于芯片验证工程师来说都是不可或缺的。

数字电路基础知识

可以通过以下在线资源进行数字电路学习:

硬件描述语言Chisel


传统描述语言

硬件描述语言(Hardware Description Language,简称 HDL)是一种用于描述数字电路、系统和硬件的语言。它允许工程师通过编写文本文件来描述硬件的结构、功能和行为,从而实现对硬件设计的抽象和建模。

HDL 通常被用于设计和仿真数字电路,如处理器、存储器、控制器等。它提供了一种形式化的方法来描述硬件电路的行为和结构,使得设计工程师可以更方便地进行硬件设计、验证和仿真。

常见的硬件描述语言包括:

  • Verilog:Verilog 是最常用的 HDL 之一,它是一种基于事件驱动的硬件描述语言,广泛应用于数字电路设计、验证和仿真。
  • VHDL:VHDL 是另一种常用的 HDL,它是一种面向对象的硬件描述语言,提供了更丰富的抽象和模块化的设计方法。
  • SystemVerilog:SystemVerilog 是 Verilog 的扩展,它引入了一些高级特性,如对象导向编程、随机化测试等,使得 Verilog 更适用于复杂系统的设计和验证。

Chisel

Chisel 是一种现代化高级的硬件描述语言,与传统的 Verilog 和 VHDL 不同,它是基于 Scala 编程语言的硬件构建语言。Chisel 提供了一种更加现代化和灵活的方法来描述硬件,通过利用 Scala 的特性,可以轻松地实现参数化、抽象化和复用,同时保持硬件级别的效率和性能。

Chisel 的特点包括:

  • 现代化的语法:Chisel 的语法更加接近软件编程语言,如 Scala,使得硬件描述更加直观和简洁。
  • 参数化和抽象化:Chisel 支持参数化和抽象化,可以轻松地创建可配置和可重用的硬件模块。
  • 类型安全:Chisel 是基于 Scala 的,因此具有类型安全的特性,可以在编译时检测到许多错误。
  • 生成性能优化的硬件:Chisel 代码可以被转换成 Verilog,然后由标准的 EDA 工具链进行综合、布局布线和仿真,生成性能优化的硬件。
  • 强大的仿真支持:Chisel 提供了与 ScalaTest 和 Firrtl 集成的仿真支持,使得对硬件进行仿真和验证更加方便和灵活。

Chisel版的全加法器实例

电路设计如下图所示:

全加器电路

完整的Chisel代码如下:

package examples

import chisel3._

class FullAdder extends Module {
  // Define IO ports
  val io = IO(new Bundle {
    val a = Input(UInt(1.W))    // Input port 'a' of width 1 bit
    val b = Input(UInt(1.W))    // Input port 'b' of width 1 bit
    val cin = Input(UInt(1.W))  // Input port 'cin' (carry-in) of width 1 bit
    val sum = Output(UInt(1.W)) // Output port 'sum' of width 1 bit
    val cout = Output(UInt(1.W))// Output port 'cout' (carry-out) of width 1 bit
  })

  // Calculate sum bit (sum of a, b, and cin)
  val s1 = io.a ^ io.b               // XOR operation between 'a' and 'b'
  io.sum := s1 ^ io.cin              // XOR operation between 's1' and 'cin', result assigned to 'sum'

  // Calculate carry-out bit
  val s3 = io.a & io.b               // AND operation between 'a' and 'b', result assigned to 's3'
  val s2 = s1 & io.cin               // AND operation between 's1' and 'cin', result assigned to 's2'
  io.cout := s2 | s3                 // OR operation between 's2' and 's3', result assigned to 'cout'
}

Chisel 学习材料可以参考官方文档:https://www.chisel-lang.org/docs

3.3 - 创建DUT

以果壳cache为例,介绍如何创建基于chisel的DUT

以果壳cache为例,介绍如何创建基于Chisel的DUT

在本文档中,DUT(Design Under Test)是指在芯片验证过程中,被验证的对象电路或系统。DUT是验证的主体,在基于picker工具创建DUT时,需要考虑被测对象的功能、性能要求和验证目标,例如是需要更快的执行速度,还是需要更详细的测试信息。通常情况下DUT,即RTL编写的源码,与外围环境一起构成验证环境(test_env),然后基于该验证环境编写测试用例。在本项目中,DUT是需要测试的Python模块,需要通过RTL进行转换。传统的RTL语言包括Verilog、System Verilog、VHDL等,然而作为新兴的RTL设计语言,Chisel(https://www.chisel-lang.org/)也以其面向对象的特征和便捷性,逐渐在RTL设计中扮演越来越重要的角色。本章以果壳处理器-NutShell中的cache源代码到Python模块的转换为例进行介绍如何创建DUT。

Chisel与果壳

准确来说,Chisel是基于Scala语言的高级硬件构造(HCL)语言。传统HDL是描述电路,而HCL则是生成电路,更加抽象和高级。同时Chisel中提供的Stage包则可以将HCL设计转化成Verilog、System Verilog等传统的HDL语言设计。配合上Mill、Sbt等Scala工具则可以实现自动化的开发。

果壳是使用 Chisel 语言模块化设计的、基于 RISC-V RV64 开放指令集的顺序单发射处理器实现。果壳更详细的介绍请参考链接:https://oscpu.github.io/NutShell-doc/

果壳 cache

果壳cache(Nutshell Cache)是果壳处理器中使用的缓存模块。其采用三级流水设计,当第三级流水检出当前请求为MMIO或者发生重填(refill)时,会阻塞流水线。同时,果壳cache采用可定制的模块化设计,通过改变参数可以生成存储空间大小不同的一级cache(L1 Cache)或者二级cache(L2 Cache)。此外,果壳cache留有一致性(coherence)接口,可以处理一致性相关的请求。

nt_cache

Chisel 转 Verilog

Chisel中的stage库可以帮助由Chisel代码生成Verilog、System Verilog等传统的HDL代码。以下将简单介绍如何由基于Chisel的cache实现转换成对应的Verilog电路描述。

初始化果壳环境

首先从源仓库下载整个果壳源代码,并进行初始化:

mkdir cache-ut
cd cache-ut
git clone https://github.com/OSCPU/NutShell.git
cd NutShell && git checkout 97a025d
make init

创建scala编译配置

在cache-ut目录下创建build.sc,其中内容如下:

import $file.NutShell.build
import mill._, scalalib._
import coursier.maven.MavenRepository
import mill.scalalib.TestModule._

// 指定Nutshell的依赖
object difftest extends NutShell.build.CommonNS {
  override def millSourcePath = os.pwd / "NutShell" / "difftest"
}

// Nutshell 配置
object NtShell extends NutShell.build.CommonNS with NutShell.build.HasChiselTests {
  override def millSourcePath = os.pwd / "NutShell"
  override def moduleDeps = super.moduleDeps ++ Seq(
        difftest,
  )
}

// UT环境配置
object ut extends NutShell.build.CommonNS with ScalaTest{
    override def millSourcePath = os.pwd
    override def moduleDeps = super.moduleDeps ++ Seq(
        NtShell
    )
}

实例化 cache

创建好配置信息后,按照scala规范,创建src/main/scala源代码目录。之后,就可以在源码目录中创建nut_cache.scala,利用如下代码实例化Cache并转换成Verilog代码:

package ut_nutshell

import chisel3._
import chisel3.util._
import nutcore._
import top._
import chisel3.stage._

object CacheMain extends App {
  (new ChiselStage).execute(args, Seq(
      ChiselGeneratorAnnotation(() => new Cache()(CacheConfig(ro = false, name = "tcache", userBits = 16)))
    ))
}

生成RTL

完成上述所有文件的创建后(build.sc,src/main/scala/nut_cache.scala),在cache-ut目录下执行如下命令:

mkdir build
mill --no-server -d ut.runMain ut_nutshell.CacheMain --target-dir build --output-file Cache

注:mill环境的配置请参考 https://mill-build.com/mill/Intro_to_Mill.html

上述命令成功执行完成后,会在build目录下生成verilog文件:Cache.v。之后就可以通过picker工具进行Cache.v到 Python模块的转换。除去chisel外,其他HCL语言几乎都能生成对应的 RTL代码,因此上述基本流程也适用于其他HCL。

DUT编译

一般情况下,如果需要DUT生成波形、覆盖率等会导致DUT的执行速度变慢,因此在通过picker工具生成python模块时会根据多种配置进行生成:(1)关闭所有debug信息;(2)开启波形;(3)开启代码行覆盖率。其中第一种配置的目标是快速构建环境,进行回归测试等;第二种配置用于分析具体错误,时序等;第三种用于提升覆盖率。

3.4 - DUT验证

介绍验证的一般流程

本节介绍基于Picker验证DUT的一般流程

开放验证平台的目标是功能性验证,其一般有以下步骤:

1. 确定验证对象和目标

通常来说,同时交付给验证工程师的还有DUT的设计文档。此时您需要阅读文档或者源代码,了解验证对象的基本功能、主体结构以及预期功能。

2. 构建基本验证环境

充分了解设计之后,您需要构建验证的基本环境。例如,除了由Picker生成的DUT外,您可能还需要搭建用于比对的参考模型,也可能需要为后续功能点的评测搭建信号的监听平台。

3. 功能点与测试点分解

在正式开始验证之前,您还需要提取功能点,并将其进一步分解成测试点。提取和分解方法可以参考:CSDN:芯片验证系列——Testpoints分解

4. 构造测试用例

有了测试点之后,您需要构造测试用例来覆盖相应的测试点。一个用例可能覆盖多个测试点。

5. 收集测试结果

运行完所有的测试用例之后,您需要汇总所有的测试结果。一般来说包括代码行覆盖率以及功能覆盖率。前者可以通过Picker工具提供的覆盖率功能获得,后者则需要您通过监听DUT的行为判断某功能是否被用例覆盖到。

6. 评估测试结果

最后您需要评估得到的结果,如是否存在错误的设计、某功能是否无法被触发、设计文档表述是否与DUT行为一致、设计文档是否表述清晰等。


接下来我们以果壳Cache的MMIO读写为例,介绍一般验证流程:

1 确定验证对象和目标
果壳Cache的MMIO读写功能。MMIO是一类特殊的IO映射,其支持通过访问内存地址的方式访问IO设备寄存器。由于IO设备的寄存器状态是随时可能改变的,因此不适合将其缓存在cache中。当收到MMIO请求时,果壳cache不会在普通的cache行中查询命中/缺失情况,而是会直接访问MMIO的内存区域来读取或者写入数据。

2 构建基本验证环境
我们可以将验证环境大致分为五个部分:
env

1. Testcase Driver:负责由用例产生相应的信号驱动
2. Monitor:监听信号,判断功能是否被覆盖以及功能是否正确
3. Ref Cache:一个简单的参考模型
4. Memory/MMIO Ram:外围设备的模拟,用于模拟相应cache的请求
5. Nutshell Cache Dut:由Picker生成的DUT

此外,您可能还需要对DUT的接口做进一步封装以实现更方便的读写请求操作,具体可以参考Nutshll cachewrapper

3 功能点与测试点分解
果壳cache可以响应MMIO请求,进一步分解可以得到一下测试点:

测试点1:MMIO请求会被转发到MMIO端口上
测试点2:cache响应MMIO请求时,不会发出突发传输(Burst Transfer)的请求
测试点3:cache响应MMIO请求时,会阻塞流水线

4 构造测试用例: 测试用例的构造是简单的,已知通过创建DUT得到的Nutshell cache的MMIO地址范围是0x30000000~0x7fffffff,则我们只需访问这段内存区间,应当就能获得MMIO的预期结果。需要注意的是,为了触发阻塞流水线的测试点,您可能需要连续地发起请求。
以下是一个简单的测试用例:

# import CacheWrapper here

def mmio_test(cache: CacheWrapper):
	mmio_lb	= 0x30000000
	mmio_rb	= 0x30001000
	
	print("\n[MMIO Test]: Start MMIO Serial Test")
	for addr in range(mmio_lb, mmio_rb, 16):
		addr &= ~(0xf)
		addr1 = addr
		addr2 = addr + 4
		addr3 = addr + 8

		cache.trigger_read_req(addr1)
		cache.trigger_read_req(addr2)
		cache.trigger_read_req(addr3)

		cache.recv()
		cache.recv()
		cache.recv()
		
	print("[MMIO Test]: Finish MMIO Serial Test")

5 收集测试结果

'''
    In tb_cache.py
'''

# import packages here

class TestCache():
    def setup_class(self):
        color.print_blue("\nCache Test Start")

        self.dut = DUTCache("libDPICache.so")
        self.dut.init_clock("clock")

        # Init here
        # ...

        self.testlist = ["mmio_serial"]
    
    def teardown_class(self):
        self.dut.finalize()
        color.print_blue("\nCache Test End")

    def __reset(self):
        # Reset cache and devices
            
    # MMIO Test
    def test_mmio(self):
        if ("mmio_serial" in self.testlist):
            # Run test
            from ..test.test_mmio import mmio_test
            mmio_test(self.cache, self.ref_cache)
        else:
            print("\nmmio test is not included")

    def run(self):
        self.setup_class()
        
        # test
        self.test_mmio()

        self.teardown_class()
    pass

if __name__ == "__main__":
	tb = TestCache()
	tb.run()

运行:

    python3 tb_cache.py

以上仅为大致的运行流程,具体可以参考:Nutshell Cache Verify

6 评估运行结果
运行结束之后可以得到以下数据:
行覆盖率:
line_cov

功能覆盖率:
func_cov

可以看到预设的MMIO功能均被覆盖且被正确触发。

3.5 - 验证报告

概述验证报告的结构与内容。

在我们完成DUT验证后,编写验证报告是至关重要的一环。本节将从整体角度概述验证报告的结构以及报告所需覆盖的内容。

验证报告是对整个验证过程的回顾,是验证合理与否的重要支持文件。一般情况下,验证报告需要包含以下内容:

  1. 文档基本信息(作者、日志、版本等)
  2. 验证对象(验证目标)
  3. 功能点介绍
  4. 验证方案
  5. 测试点分解
  6. 测试用例
  7. 测试环境
  8. 结果分析
  9. 缺陷分析
  10. 测试结论

以下内容对列表进行进一步解释,具体示例可以参考nutshell_cache_report_demo.pdf


1. 基本信息

应当包括作者、日志、版本、日期等。

2. 验证对象(验证目标)

需要对您的验证对象做必要的介绍,可以包括其结构、基本功能、接口信息等。

3. 功能点介绍

通过阅读设计文档或者源码,您需要总结DUT的目标功能,并将其细化为各功能点。

4. 验证方案

应当包括您计划的验证流程以及验证框架。同时,您也应当接受您的框架各部分是如何协同工作的。

5. 测试点分解

针对功能点提出的测试方法。具体可以包括在怎样的信号输入下应当观测到怎样的信号输出。

6. 测试用例

测试点的具体实现。一个测试用例可以包括多个测试点。

7. 测试环境

包括硬件信息、软件版本信息等。

8. 结果分析

结果分析一般指覆盖率分析。通常来说应当考虑两类覆盖率:
1. 行覆盖率: 在测试用例中有多少RTL行代码被执行。一般来说我们要求行覆盖率在98%以上。
2. 功能覆盖率:根据相应的信号判断您提取的功能点是否被覆盖且被正确触发。一般我们要求测试用例覆盖每个功能点。

9. 缺陷分析

对DUT存在的缺陷进行分析。可以包括设计文档的规范性和详细性、DUT功能的正确性(是否存在bug)、DUT功能是否能被触发等方面。

10. 验证结论

验证结论是在完成芯片验证过程后得出的最终结论,是对以上内容的总结。

4 - 验证框架

Toffee 是一套基于 Python 的硬件验证框架,帮助用户更加方便、规范地使用 Python 建立起硬件验证环境

Toffee 是使用 Python 语言编写的一套硬件验证框架,它依赖于多语言转换工具 picker,该工具能够将硬件设计的 Verilog 代码转换为 Python Package,使得用户可以使用 Python 来驱动并验证硬件设计。

其吸收了部分 UVM 验证方法学,以保证验证环境的规范性和可复用性,并重新设计了整套验证环境的搭建方式,使其更符合软件领域开发者的使用习惯,从而使软件开发者可以轻易地上手硬件验证工作。

4.1 - 快速开始

安装

toffee

Toffee 是一款基于 Python 的硬件验证框架,旨在帮助用户更加便捷、规范地使用 Python 构建硬件验证环境。它依托于多语言转换工具 picker,该工具能够将硬件设计的 Verilog 代码转换为 Python Package,使得用户可以使用 Python 来驱动并验证硬件设计。

Toffee 需要的依赖有:

  • Python 3.6.8+
  • Picker 0.9.0+

当安装好上述依赖后,可通过pip安装toffee:

pip install pytoffee

或通过以下命令安装最新版本的toffee:

pip install pytoffee@git+https://github.com/XS-MLVP/toffee@master

或通过以下方式进行本地安装:

git clone https://github.com/XS-MLVP/toffee.git
cd toffee
pip install .

toffee-test

Toffee-test 是一个用于为 Toffee 框架提供测试支持的 Pytest 插件,他为 toffee 框架提供了将测试用例函数标识为 toffee 的测试用例对象,使其可以被 toffee 框架识别并执行;测试用例资源的管理功能;测试报告生成功能,以便于用户编写测试用例。

通过 pip 安装 toffee-test

pip install toffee-test

或安装开发版本

pip install toffee-test@git+https://github.com/XS-MLVP/toffee-test@master

或通过源码安装

git clone https://github.com/XS-MLVP/toffee-test.git
cd toffee-test
pip install .

搭建简单的验证环境

我们使用一个简单的加法器示例来演示 toffee 的使用方法,该示例位于 example/adder 目录下。

加法器的设计如下:

module Adder #(
    parameter WIDTH = 64
) (
    input  [WIDTH-1:0] io_a,
    input  [WIDTH-1:0] io_b,
    input              io_cin,
    output [WIDTH-1:0] io_sum,
    output             io_cout
);

assign {io_cout, io_sum}  = io_a + io_b + io_cin;

endmodule

首先使用 picker 将其转换为 Python Package,再使用 toffee 来为其建立验证环境。安装好依赖后,可以直接在 example/adder 目录下运行以下命令来完成转换:

make dut

为了验证加法器的功能,我们使用 toffee 提供的方法来建立验证环境。

首先需要为其创建加法器接口的驱动方法,这里用到了 Bundle 来描述需要驱动的某类接口,Agent 用于编写对该接口的驱动方法。如下所示:

class AdderBundle(Bundle):
    a, b, cin, sum, cout = Signals(5)


class AdderAgent(Agent):
    @driver_method()
    async def exec_add(self, a, b, cin):
        self.bundle.a.value = a
        self.bundle.b.value = b
        self.bundle.cin.value = cin
        await self.bundle.step()
        return self.bundle.sum.value, self.bundle.cout.value

我们使用了 driver_method 装饰器来标记 Agent 中用于驱动的方法 exec_add,该方法完成了对加法器的一次驱动操作,每当该方法被调用,其会将输入信号 abcin 的值分别赋给加法器的输入端口,并在下一个时钟周期后读取加法器的输出信号 sumcout 的值并返回。

Bundle 是该 Agent 需要驱动的接口的描述。在 Bundle 中提供了一系列的连接方法来连接到 DUT 的输入输出端口。这样一来,我们可以通过此 Agent 完成所有拥有相同接口的 DUT 的驱动操作。

为了验证加法器的功能,我们还需要为其创建一个参考模型,用于验证加法器的输出是否正确。在 toffee 中,我们使用 Model 来定义参考模型。如下所示:

class AdderModel(Model):
    @driver_hook(agent_name="add_agent")
    def exec_add(self, a, b, cin):
        result = a + b + cin
        sum = result & ((1 << 64) - 1)
        cout = result >> 64
        return sum, cout

在参考模型中,我们同样定义了一个 exec_add 方法,该方法与 Agent 中的 exec_add 方法含有相同的输入参数,我们用程序代码计算出了加法器的标准返回值。我们使用了 driver_hook 装饰器来标记该方法,以便该方法可以与 Agent 中的 exec_add 方法进行关联。

接下来,我们需要创建一个顶层的测试环境,将上述的驱动方法与参考模型相关联,如下所示:

class AdderEnv(Env):
    def __init__(self, adder_bundle):
        super().__init__()
        self.add_agent = AdderAgent(adder_bundle)

        self.attach(AdderModel())

此时,验证环境已经搭建完成,toffee 会自动驱动参考模型并收集结果,并将结果与加法器的输出进行比对。

之后,需要编写测试用例来验证加法器的功能,通过 toffee-test,可以使用如下方式编写测试用例。

@toffee_test.testcase
async def test_random(adder_env):
    for _ in range(1000):
        a = random.randint(0, 2**64 - 1)
        b = random.randint(0, 2**64 - 1)
        cin = random.randint(0, 1)
        await adder_env.add_agent.exec_add(a, b, cin)

@toffee_test.testcase
async def test_boundary(adder_env):
    for cin in [0, 1]:
        for a in [0, 2**64 - 1]:
            for b in [0, 2**64 - 1]:
                await adder_env.add_agent.exec_add(a, b, cin)

可以直接在 example/adder 目录下运行以下命令来运行该示例:

make run

运行结束后报告将自动在reports目录下生成。

4.2 - 编写规范的验证环境

概述

一个验证任务编写代码的主体工作可以大致分为两部分,验证环境的搭建测试用例的编写

验证环境的搭建 旨在完成对待测设计(DUT)的封装,使得验证人员在驱动 DUT 时,不必面临繁杂的接口信号,而是可以直接使用验证环境中提供的高级接口。如果需要编写参考模型,则参考模型也应是验证环境的一部分。

测试用例的编写 则是测试人员使用验证环境提供的接口,编写一个个测试用例,对 DUT 进行功能验证。

搭建验证环境是一件相当有挑战的事情,当 DUT 极度复杂,特别是在接口信号繁多的情况下,搭建验证环境的难度会更大。此时,若没有一个统一的规范,验证环境的搭建将会变得混乱不堪,一个人编写的验证环境很难被其他人维护。并且当出现新的验证任务与原有验证任务有交集时,因为原有的验证环境缺乏规范,很难将原有的验证环境复用。

本节将会介绍一个规范的验证环境所应该具备的特性,这将有助于理解 mlvp 验证环境的搭建流程。

无法复用的验证代码

以一个简单的加法器为例,该加法器拥有两个输入端口 io_aio_b,一个输出端口 io_sum。 在没有意识到验证代码可能会被用于其他验证任务的情况下,我们可能会编写出这样的驱动代码:

def exec_add(dut, a, b):
    dut.io_a.value = a
    dut.io_b.value = b
    dut.Step(1)
    return dut.io_sum.value

上述代码中,我们编写了一个 exec_add 方法,该方法本质上是对加法器加法操作的一次高层封装。拥有 exec_add 方法以后,我们无需再关心如何对加法器的接口信号进行赋值,也无需关心怎样驱动加法器并获取其输出,只需要调用 exec_add 方法即可驱动加法器完成一次加法操作。

然而,这个驱动函数却有一个很大的弊端,它直接使用了 DUT 的接口信号来与 DUT 进行交互,这也就意味着,这个驱动函数只能用于这个加法器。

与软件测试不同,在硬件验证中我们每时每刻都能碰到接口结构相同的情况。假设我们拥有另一个具有相同功能的加法器,但其接口信号名称分别是 io_a_0io_b_0io_sum_0,那么这个驱动函数对这个加法器则直接失效,无法复用。要想驱动,只能重新编写一个新的驱动函数。

一个加法器尚且如此,倘若我们拿到了一个拥有繁杂接口的 DUT,费尽心思为其编写了驱动代码。当后续发现驱动代码需要迁移至另一个相似结构的接口上时,我们将会面临巨大的工作量。例如出现接口名称改变、部分接口缺少但驱动代码中却有引用,部分接口新增等等一系列的问题。

出现这种问题的根本原因在于,我们在验证代码中直接对 DUT 的接口信号进行操作,如下图所示,这种做法是不可取的。

+-----------+   +-----------+
|           |-->|           |
| Test Code |   |    DUT    |
|           |<--|           |
+-----------+   +-----------+

将验证代码与 DUT 进行解耦

为了解决上述问题,我们需要将验证代码与 DUT 进行解耦,使得验证代码不再直接操作 DUT 的接口信号,而是通过一个中间层来与 DUT 进行交互。这个中间层是人为定义的一个接口结构,在 toffee 中,我们将这个中间层定义为 Bundle,下文也将会使用 Bundle 来代指这个中间层。

以上述加法器为例,我们可以定义一个 Bundle 结构,其中包含 a, bsum 三个信号,并让测试代码与这个 Bundle 进行直接交互。

def exec_add(bundle, a, b):
    bundle.a.value = a
    bundle.b.value = b
    bundle.Step(1)
    return bundle.sum.value

此时,在 exec_add 中并没有直接操作 DUT 的接口信号,甚至不知道 DUT 中的接口信号名称是什么,其直接与我们在 Bundle 中定义的接口信号进行交互。

那如何让 Bundle 中的信号与 DUT 的引脚进行关联呢?只需要添加一个连接操作即可,最简单的连接方法是,我们直接指定 Bundle 中的每一个信号具体与 DUT 的哪一个引脚相连,例如:

bundle.a   <-> dut.io_a
bundle.b   <-> dut.io_b
bundle.sum <-> dut.io_sum

如果 DUT 的接口信号名称发生了变化,我们只需要修改这个连接过程,例如:

bundle.a   <-> dut.io_a_0
bundle.b   <-> dut.io_b_0
bundle.sum <-> dut.io_sum_0

这样一来,无论 DUT 的接口如何变化,只要其拥有相同的结构,都可以通过原有的驱动代码来驱动,需要修改的仅仅是连接过程。此时的验证代码与 DUT 的关系如下图所示:

+-----------+  +--------+             +-----------+
|           |->|        |             |           |
| Test Code |  | Bundle |-- connect --|    DUT    |
|           |<-|        |             |           |
+-----------+  +--------+             +-----------+

在 toffee 中,我们为 Bundle 提供了简洁的定义过程以及大量的连接方法,可极大方便中间层的定义与连接,除此之外,Bundle 还提供了大量的实用功能来帮助验证人员更好的与接口信号进行交互。

将 DUT 接口进行分类驱动

我们已经知道,需要定义一个 Bundle 来完成测试代码与 DUT 之间的解耦,但是如果 DUT 的接口信号过于复杂,我们将会面临一个新的问题————可能只有这一个 DUT 能与这个 Bundle 进行连接。因为我们会定义一个含有众多信号的中间层,将整个 DUT 的引脚全部涵盖在内,这样一来,只有与整个 DUT 结构相同的 DUT 才能与这个 Bundle 进行连接,这个条件是极为苛刻的。

这样一来,中间层的设置也就失去意义了。但我们观察到,每个 DUT 的接口结构往往是有规律的,他们通常由若干个具有独立功能的接口组成。例如 这里 提到的双端口栈,它的接口则由两个结构完全相同的子接口构成。因此,相比于将整个双端口栈的接口信号全部涵盖在一个 Bundle 中,我们可以将其拆分为两个 Bundle,每个 Bundle 分别对应一个子接口。

并且,对于双端口栈来说,两个子接口的结构是完全相同的,因此我们可以使用同一个 Bundle 来描述这两个子接口,无需重复定义。既然他们拥有同样的 Bundle,那么我们针对这个 Bundle 编写的驱动代码也是完全可以复用的!这就是验证环境可复用性的魅力。

总结一下,对于所有的 DUT 来说,我们应该将其接口信号划分成若干个独立的子接口,每个子接口拥有独立的功能,然后为每个子接口定义一个 Bundle,并编写与这个 Bundle 相关的驱动代码。

此时,我们的验证代码与 DUT 的关系如下图所示:

+-----------+  +--------+             +-----------+
|           |->|        |             |           |
| Test Code |  | Bundle |-- connect --|           |
|           |<-|        |             |           |
+-----------+  +--------+             |           |
                                      |           |
     ...           ...                |    DUT    |
                                      |           |
+-----------+  +--------+             |           |
|           |->|        |             |           |
| Test Code |  | Bundle |-- connect --|           |
|           |<-|        |             |           |
+-----------+  +--------+             +-----------+

同时,我们搭建验证环境的思路也变得清晰起来,只需要为分别每个独立的子接口编写高层封装的操作即可。

驱动独立接口的结构

我们为每个 Bundle 都编写了高层封装的操作,这些代码之间相互独立,拥有极高的可复用性。如果我们把不同 Bundle 高层封装之间的交互逻辑都划分出去,放到测试用例中来完成,那么多个 Test Code + Bundle 的组合将会完成对整个 DUT 的驱动环境的搭建。

我们不妨对单个 Test Code + Bundle 的组合起一个名字,在 toffee 中,该结构被称之为 AgentAgent 独立于 DUT,负责完成对一类接口的所有交互操作。

此时,我们的验证代码与 DUT 的关系如下图所示:

+---------+    +-----------+
|         |    |           |
|  Agent  |----|           |
|         |    |           |
+---------+    |           |
               |           |
    ...        |    DUT    |
               |           |
+---------+    |           |
|         |    |           |
|  Agent  |----|           |
|         |    |           |
+---------+    +-----------+

因此编写驱动环境的过程,就是编写一个个 Agent 的过程。但至此,我们还没有讨论过编写 Agent 的具体规范,如果每个人编写的 Agent 都各不相同,那么验证环境依然会变得较为混乱。

编写规范的 “Agent”

为了探寻如何编写一个规范的 Agent,我们首先要明白 Agent 主要完成怎样的功能。如上文所述,Agent 中实现了对一类接口的所有交互操作,并提供高层封装。

我们首先来探讨,验证代码究竟会与接口产生怎样的交互,假设验证代码具备读取输入端口的能力,我们可以按照验证代码是否主动发起交互,与接口的方向性来划分为以下几类交互。

  1. 验证代码主动发起
    • 验证代码主动读取输入/输出端口的值
    • 验证代码主动给输入端口赋值
  2. 验证代码被动接收
    • 验证代码被动接收输出/输出端口的值

这两类操作涵盖了验证代码侧与接口之间的的所有操作,因此 Agent 也必须具备这两类操作的能力。

验证代码主动发起的交互

我们首先考虑验证代码主动发起的两类交互,对这两类交互完成高层封装,就要求 Agent 必须具备两种能力:

  1. 驱动发起者能够将上层语义信息转换为对接口信号的赋值操作
  2. 能够将接口信号转换为上层语义信息并返回给发起者

能够完成这两类交互的形式有很多。但由于 toffee 是一个基于软件测试语言的验证框架,且我们希望验证代码的编写形式尽量简洁,toffee 中规范了使用 函数 为载体来完成这两类交互。

因为函数是编程语言中最基本的抽象单元,输入参数可直接作为上层语义信息并传递给函数体,函数体中通过赋值和读取操作完成上层语义信息与接口信号的转换,最后通过返回值将接口信号转换为上层语义信息并返回给发起者。

toffee 此类用于验证代码主动发起交互的函数称之为驱动方法,在 toffee 中,我们使用 driver_method 装饰器来标记此类函数。

验证代码被动接收的交互

接下来我们考虑验证代码被动接收的交互,并对此类交互完成高层封装。这类交互的呈现形式为,验证代码并不去主动发起对接口的输入输出,而是当满足特定的条件时,接口会将输出信号传递给验证代码。

例如,验证代码想要在 DUT 完成一次操作后,被动获取 DUT 的输出信号并转换为上层语义信息。再如,验证代码想在每一周期都被动获取 DUT 的输出信号并转换为上层语义信息。

driver_method 类似,toffee 中同样规范了使用 函数 为载体来完成这类交互,只不过这个函数没有输入参数,并且不受验证代码的主动控制。当特定条件满足时,该函数会被调用,完成对接口信号的读取操作,并转换为上层语义信息。该信息会被保存,等待验证代码的使用。

类似的,toffee 将此类用于验证代码被动接收交互的函数称之为监测方法,在 toffee 中,我们使用 monitor_method 装饰器来标记此类函数。

一个规范的 “Agent” 结构

综上所述,我们使用 函数 作为载体来完成验证代码与接口的所有交互,并将其分为两类:驱动方法监测方法。这两类方法分别完成验证代码主动发起的交互和被动接收的交互。

因此,编写 Agent,其实就是编写一系列的驱动方法和检测方法。一个 Agent 编写好之后,也只需要提供其内部驱动方法和检测方法的列表,便能描述整个 Agent 的功能。

一个 Agent 的结构可以使用下图来描述:

+--------------------+
| Agent              |
|                    |
|   @driver_method   |
|   @driver_method   |
|   ...              |
|                    |
|   @monitor_method  |
|   @monitor_method  |
|   ...              |
|                    |
+--------------------+

验证 DUT 的功能正确性

目前为止,我们完成了对 DUT 高层操作的封装,并使用函数完成了验证代码与 DUT 之间的交互。此时,为了验证 DUT 的功能是否正确,我们会编写测试用例,通过我们封装好的驱动方法来驱动起 DUT 完成特定的执行过程。与此同时,监测方法 在自动地被调用并监测收集 DUT 的相关信息。

然而如何去验证 DUT 的功能是否正确呢?

在测试用例中对 DUT 进行驱动后,能够得到 DUT 输出的信息包含两种,一种是通过驱动方法主动获取的信息,另一种是通过检测方法收集到的信息。因此,验证 DUT 的功能是否正确,实际上就是验证这两种信息是否符合预期。

那如何判断这两种信息是否符合预期呢?

一种情况是,我们本来就知道 DUT 的输出应该是什么,或是满足什么样的条件。这时我们在测试用例中拿到这两种信息后,直接对这两种信息(或是其中一种,取决于验证用例的需求)进行检查即可。

另一种情况是,我们并不知道 DUT 的输出应该是什么。此时我们只能编写一个与 DUT 功能相同的 参考模型(RM, Reference Model),当主动发送给 DUT 任何信息时,同时将这些信息主动发送给参考模型。

为了对验证两类信息是否符合预期。当主动获取 DUT 的输出信息是时,同时主动去获取参考模型中的输出信息,并将两者进行比对;当监测方法监测到 DUT 的输出信息时,同时参考模型也应主动提供输出信息,并将两者进行比对。

这便是验证 DUT 功能正确性的两类方法:直接比对参考模型比对

如何添加参考模型

对于直接比对的验证方法,我们直接在测试用例中编写比对逻辑即可。而如果我们使用参考模型的比对方式,测试用例可能会面临一些比较繁琐的步骤:驱动接口的同时,需要将信息同时发送给模型;收集接口信息的同时,需要同时收集模型的信息;对于被动监测的接口信息,还要额外编写逻辑来完成与参考模型的比对。这样一来,测试用例的代码会混乱,与参考模型的交互逻辑会混杂在测试用例中,不利于代码的维护。

我们注意到,对于驱动函数的每一次调用,代表着对 DUT 的每一次操作,这些操作都需要转发给参考模型。而参考模型的编写无需考虑 DUT 接口是怎样驱动的,它只需要分析高层语义信息,并且完成自身状态更新即可,因此参考模型中只需要获取上层发来的高层语义信息(即驱动函数的输入参数)。

所以在参考模型中只需要实现当驱动函数被调用时,如何做出反应即可。对于将调用信息传递给参考模型的操作完全可以交由框架来完成。与此同时,每一次操作的返回值、监测方法的检测值比较,也可以通过框架来自动完成。

这样一来,测试用例中只需要编写驱动 DUT 的逻辑,参考模型的同步与比对工作将会被框架自动完成。

为了实现参考模型的同步,toffee 中定义了一套参考模型的匹配规范,只需要按照此规范编写参考模型调用接口,便可实现参考模型的自动转发与比较。同时为了方便参考模型与整个验证环境相关联,toffee 中提供了 Env 的概念来打包整个验证环境,写好参考模型后,只需要将其与 Env 相关联,便可实现参考模型的自动同步。

总结

这样一来,我们的验证环境变成了如下的结构:

+--------------------------------+
| Env                            |
|                  +-----------+ |  +-----------+
|   +---------+    |           | |  |           |
|   |  Agent  |----|           | |->| Reference |
|   +---------+    |    DUT    | |  |   Model   |
|   +---------+    |           | |<-|           |
|   |  Agent  |----|           | |  |           |
|   +---------+    |           | |  +-----------+
|       ...        +-----------+ |
+--------------------------------+

此时,整个验证环境的搭建变得清晰而规范,需要复用时,只需挑选合适的 Agent,连接至 DUT,并打包到 Env 中。需要编写参考模型时,只需要根据 Env 中调用接口规范,实现参考模型的逻辑即可。

测试用例的编写与验证环境是分开的,当测试环境搭建完毕后,测试环境的接口就是每个 Agent 中的调用接口。测试用例可以清晰地使用这些接口来完成驱动逻辑的编写。参考模型的同步及对比工作也将由框架自动完成。

这是 toffee 中验证环境的搭建思想,toffee 中提供了大量的功能,来帮助你建立起这样一个规范的验证环境。同时,它提供了测试用例的管理方法,使得测试用例更易于编写和管理。

4.3 - 搭建验证环境

toffee和toffee-test 提供了搭建验证环境全流程所需要的方法和工具,本章中将详细介绍如何使用 toffee和toffee-test 搭建一个完整的验证环境。

在阅读前请确保您已经阅读了 如何编写规范的验证环境,并了解了 toffee 规范验证环境的基本结构。

对于一次全新的验证工作来说,按照环境搭建步骤的开始顺序,搭建验证环境可以分为以下几个步骤:

  1. 按照逻辑功能划分 DUT 接口,并定义 Bundle
  2. 为每个 Bundle 编写 Agent,完成对 Bundle 的高层封装
  3. 将多个 Agent 封装成 Env,完成对整个 DUT 的高层封装
  4. 按照 Env 的接口规范编写参考模型,并将其与 Env 进行绑定

本章将会分别介绍每个步骤中如何使用 toffee和toffee-test 中的工具来完成环境搭建需求。

4.3.1 - 如何使用异步环境

启动事件循环

在如前介绍的验证环境中,设计了一套规范的验证环境。但是如果尝试用朴素单线程程序编写,会发现会遇到较为复杂的实现问题。

例如,我们创建了两个驱动方法分别驱动两个接口,在每一个驱动方法内部,都需要等待 DUT 经过若干个时钟周期,并且这两个驱动方法需要同时运行。这时候,如果使用朴素的单线程程序,会发现很难同时让两个驱动方法同时运行,即便我们使用多线程强势使他们同时运行,也会发现缺乏一种机制,使他们能够等待 DUT 经过若干时钟周期。这是因为在 Picker 提供的接口中,我们只能去推动 DUT 向前执行一个周期,而无法去等待 DUT 执行一个周期。

更不用说我们还会遇到有众多环境组件需要同时运行的情况了,因此我们首先需要一个能够运行异步程序的环境。toffee 使用了 Python 的协程来完成对异步程序的管理,其在单线程之上建立了一个事件循环,用于管理多个同时运行的协程,协程之间可以相互等待并通过事件循环来进行切换。

在启动事件循环之前,我们首先需要了解两个关键字 asyncawait 来了解 Python 对与协程的管理。

当我们在函数前加上 async 关键字时,这个函数就变成了一个协程函数,例如

async def my_coro():
    ...

当我们在协程函数内部使用 await 关键字时,我们就可以执行一个协程函数,并等待其执行完成并返回结果,例如

async def my_coro():
    return "my_coro"

async def my_coro2():
    result = await my_coro()
    print(result)

如果不想等待一个协程函数完成,只想将这一函数加入到事件循环中放入后台运行,可以使用 toffee 提供的 create_task 方法,例如

import toffee

async def my_coro():
    return "my_coro"

async def my_coro2():
    toffee.create_task(my_coro())

那么如何启动事件循环,并使事件循环开始运行 my_coro2 呢?在 toffee 中,我们使用 toffee.run 来启动事件循环,并运行异步程序。

import toffee

toffee.run(my_coro2())

toffee 中的环境组件都需要在事件循环中运行,因此当启动 toffee 验证环境时,必须通过 toffee.run 先启动事件循环,然后在事件循环中去创建 toffee 验证环境。

因此,在验证环境创建时,应该以类似如下的方式:

import toffee

async def start_test():
    # 创建验证环境
    env = MyEnv()

    ...

toffee.run(start_test())

如何管理 DUT 时钟

正如开头提出的问题,如果我们需要两个驱动方法同时运行,并且在每个驱动方法需要等待 DUT 经过若干个时钟周期。异步环境给予了我们等待某个事件的能力,但 Picker 只提供了推动 DUT 向前执行一个周期的能力,没有有提供一个事件让我们来等待。

toffee 中提供了对这类功能的支持,它通过创建一个后台时钟,来实现对 DUT 进行一个个周期的向前推动,每推动一个周期,后台时钟就会向其他协程发出时钟信号,使得其他协程能够继续执行。因此,DUT 的实际执行周期推动是由后台时钟来完成的,其他协程中只需要等待后台时钟发布的时钟信号即可。

在 toffee 中,通过start_clock来创建后台时钟:

import toffee

async def start_test():
    dut = MyDUT()
    toffee.start_clock(dut)

toffee.run(start_test())

只需要在事件循环中调用 start_clock 即可创建后台时钟,它需要一个 DUT 对象作为参数,用于推动 DUT 的执行,以及将时钟信号绑定到 DUT 以及其各个引脚。

在其他协程中,我们可以通过 ClockCycles 来等待时钟信号到来,ClockCycles 的参数可以是 DUT,也可以是 DUT 的每一个引脚。例如:

import toffee
from toffee.triggers import *

async my_coro(dut):
    await ClockCycles(dut, 10)
    print("10 cycles passed")

async def start_test():
    dut = MyDUT()
    toffee.start_clock(dut)

    await my_coro(dut)

toffee.run(start_test())

my_coro 中,通过 ClockCycles 来等待 DUT 经过 10 个时钟周期,当 10 个时钟周期经过后,my_coro 就会继续执行,并打印 “10 cycles passed”。

toffee 中提供了多种等待时钟信号的方法,例如:

  • ClockCycles 等待 DUT 经过若干个时钟周期
  • Value 等待 DUT 的某个引脚的值等于某个值
  • AllValid 等待 DUT 的所有引脚的值同时有效
  • Condition 等待某个条件满足
  • Change 等待 DUT 的某个引脚的值发生变化
  • RisingEdge 等待 DUT 的某个引脚的上升沿
  • FallingEdge 等待 DUT 的某个引脚的下降沿

更多等待时钟信号的方法,参见 API 文档。

4.3.2 - 如何使用 Bundle

Bundle 在 toffee 验证环境中,用于构建 Agent 与 DUT 之间交互的中间层,以保证 Agent 与 DUT 之间的解耦。同时 Bundle 也起到了对 DUT 接口层次结构划分的作用,使得对 DUT 接口的访问变得更加清晰、方便。

一个简单的 Bundle 的定义

为了定义一个 Bundle,需要自定义一个新类,并继承 toffee 中的 Bundle 类。下面是一个简单的 Bundle 的定义示例:

from toffee import Bundle, Signals

class AdderBundle(Bundle):
    a, b, sum, cin, cout = Signals(5)

该 Bundle 定义了一个简单的加法器接口,在 AdderBundle 类中,我们定义了五个信号 a, b, sum, cin, cout,这五个信号分别代表了加法器的输入端口 a, b,输出端口 sum,以及进位输入端口 cin 和进位输出端口 cout

定义完成后,我们可以通过 AdderBundle 类的实例来访问这些信号,例如:

adder_bundle = AdderBundle()

adder_bundle.a.value = 1
adder_bundle.b.value = 2
adder_bundle.cin.value = 0
print(adder_bundle.sum.value)
print(adder_bundle.cout.value)

将 DUT 绑定到 Bundle

在上述代码中,我们虽然创建了一个 bundle 实例,并对他进行了驱动,但是我们并没有将这个 bundle 与任何 DUT 绑定,也就意味着对这个 bundle 的操作,无法真正影响到 DUT。

使用 bind 方法,可以将一个 DUT 绑定到 bundle 上。例如我们有一个简单的加法器 DUT,其接口名称与 Bundle 中定义的名称相同。

adder = DUTAdder()

adder_bundle = AdderBundle()
adder_bundle.bind(adder)

bind 函数会自动检索 DUT 中所有的接口,并将名称相同的接口进行绑定。绑定完成后,对 bundle 的操作,就会直接影响到 DUT。

但是,如果 DUT 的接口名称与 Bundle 中定义的名称不同,直接使用 bind 则无法正确绑定。在 Bundle 中,我们提供多种绑定方法,以适应不同的绑定需求。

通过字典进行绑定

bind 函数中,我们可以通过传入一个字典,来指定 DUT 中的接口名称与 Bundle 中的接口名称之间的映射关系。

假设 Bundle 中的接口名称与 DUT 中的接口名称拥有如下对应关系:

a    -> a_in
b    -> b_in
sum  -> sum_out
cin  -> cin_in
cout -> cout_out

在实例化 bundle 时,我们可以通过 from_dict 方法创建,并传入一个字典,告知 Bundle 以字典的方式进行绑定。

adder = DUTAdder()
adder_bundle = AdderBundle.from_dict({
    'a': 'a_in',
    'b': 'b_in',
    'sum': 'sum_out',
    'cin': 'cin_in',
    'cout': 'cout_out'
})
adder_bundle.bind(adder)

此时,adder_bundle 可正确绑定至 adder

通过前缀进行绑定

假设 DUT 中的接口名称与 Bundle 中的接口名称拥有如下对应关系:

a    -> io_a
b    -> io_b
sum  -> io_sum
cin  -> io_cin
cout -> io_cout

可以发现,实际 DUT 的接口名称相比于 Bundle 中的接口名称,都多了一个 io_ 的前缀。在这种情况下,我们可以通过 from_prefix 方法创建 Bundle,并传入前缀名称,告知 Bundle 以前缀的方式进行绑定。

adder = DUTAdder()
adder_bundle = AdderBundle.from_prefix('io_')
adder_bundle.bind(adder)

通过正则表达式进行绑定

在某些情况下,DUT 中的接口名称与 Bundle 中的接口名称之间的对应关系并不是简单的前缀或者字典关系,而是更为复杂的规则。例如,DUT 中的接口名称与 Bundle 中的接口名称之间的对应关系为:

a    -> io_a_in
b    -> io_b_in
sum  -> io_sum_out
cin  -> io_cin_in
cout -> io_cout_out

在这种情况下,我们可以通过传入正则表达式,来告知 Bundle 以正则表达式的方式进行绑定。

adder = DUTAdder()
adder_bundle = AdderBundle.from_regex(r'io_(.*)_.*')
adder_bundle.bind(adder)

使用正则表达式时,Bundle 会尝试将 DUT 中的接口名称与正则表达式进行匹配,匹配成功的接口,将会读取正则表达式中的所有捕获组,将其连接为一个字符串。再使用这个字符串与 Bundle 中的接口名称进行匹配。

例如对于上面代码中的正则表达式,io_a_in 会与正则表达式成功匹配,唯一的捕获组捕获到的内容为 aa 这个名称与 Bundle 中的接口名称 a 匹配,因此 io_a_in 会被正确绑定至 a

创建子 Bundle

很多时候,我们会需要一个 Bundle 包含一个或多个其他 Bundle 的情况,这时我们可以将其他已经定义好的 Bundle 作为当前 Bundle 的子 Bundle。

from toffee import Bundle, Signal, Signals

class AdderBundle(Bundle):
    a, b, sum, cin, cout = Signals(5)

class MultiplierBundle(Bundle):
    a, b, product = Signals(3)

class ArithmeticBundle(Bundle):
    selector = Signal()

    adder = AdderBundle.from_prefix('add_')
    multiplier = MultiplierBundle.from_prefix('mul_')

在上面的代码中,我们定义了一个 ArithmeticBundle,它包含了自己的信号 selector。除此之外它还包含了一个 AdderBundle 和一个 MultiplierBundle,这两个子 Bundle 分别被命名为 addermultiplier

当我们需要访问 ArithmeticBundle 中的子 Bundle 时,可以通过 . 运算符来访问:

arithmetic_bundle = ArithmeticBundle()

arithmetic_bundle.selector.value = 1
arithmetic_bundle.adder.a.value = 1
arithmetic_bundle.adder.b.value = 2
arithmetic_bundle.multiplier.a.value = 3
arithmetic_bundle.multiplier.b.value = 4

同时,当我们以这种定义方式进行定义后,在最顶层的 Bundle 进行绑定时,会同时将子 Bundle 也绑定到 DUT 上,在定义子 Bundle 时,依然可以使用前文提到的多种绑定方式。

需要注意的是,子 Bundle 的创建方法去匹配的信号名称,是经过上一次 Bundle 的创建方法进行处理过后的名称。例如在上面的代码中,我们将顶层 Bundle 的匹配方式设置为 from_prefix('io_'),那么在 AdderBundle 中去匹配的信号,是去除了 io_ 前缀后的名称。

同时,字典匹配方法会将信号名称转换为字典映射后的名称传递给子 Bundle 进行匹配,正则表达式匹配方法会将正则表达式捕获到的名称传递给子 Bundle 进行匹配。

Bundle 中的实用操作

信号访问与赋值

访问信号值

在 Bundle 中,我们不仅可以通过 . 运算符来访问 Bundle 中的信号,也可以通过 [] 运算符来访问 Bundle 中的信号。

adder_bundle = AdderBundle()
adder_bundle['a'].value = 1

访问未连接信号

def bind(self, dut, unconnected_signal_access=True)

bind 时,我们可以通过传入 unconnected_signal_access 参数来控制是否允许访问未连接的信号。默认为 True,即允许访问未连接的信号,此时当写入该信号时,该信号不会发生变化,当读取该信号时,会返回 None。 当 unconnected_signal_accessFalse 时,访问未连接的信号会抛出异常。

同时赋值所有信号

可以通过 set_all 方法同时将所有输入信号更改为某个值。

adder_bundle.set_all(0)

随机赋值所有信号

可以通过 randomize_all 方法随机赋值所有信号。“value_range” 参数用于指定随机值的范围,“exclude_signals” 参数用于指定不需要随机赋值的信号,“random_func” 参数用于指定随机函数。

adder_bundle.randomize_all()

信号赋值模式更改

信号赋值模式是 picker 中的概念,用于控制信号的赋值方式,请查阅 picker 文档以了解更多信息。

Bundle 中支持通过 set_write_mode 来改变整个 Bundle 的赋值模式。

同时,Bundle 提供了设置的快捷方法:set_write_mode_as_imme, set_write_mode_as_riseset_write_mode_as_fall,分别用于设置 Bundle 的赋值模式为立即赋值、上升沿赋值与下降沿赋值。

消息支持

默认消息类型赋值

toffee 支持一个默认的消息类型,可以通过 assign 方法将一个字典赋值给 Bundle 中的信号。

adder_bundle.assign({
    'a': 1,
    'b': 2,
    'cin': 0
})

Bundle 将会自动将字典中的值赋值给对应的信号,当需要将未指定的信号赋值成某个默认值时,可以通过 * 来指定默认值:

adder_bundle.assign({
    '*': 0,
    'a': 1,
})

子 Bundle 的默认消息赋值支持

如果希望通过默认消息类型同时赋值子 Bundle 中的信号,可以通过两种方式实现。当 assign 中的 multilevel 参数为 True 时,Bundle 支持多级字典赋值。

arithmetic_bundle.assign({
    'selector': 1,
    'adder': {
        '*': 0,
        'cin': 0
    },
    'multiplier': {
        'a': 3,
        'b': 4
    }
}, multilevel=True)

multilevelFalse 时,Bundle 支持通过 . 来指定子 Bundle 的赋值。

arithmetic_bundle.assign({
    '*': 0,
    'selector': 1,
    'adder.cin': 0,
    'multiplier.a': 3,
    'multiplier.b': 4
}, multilevel=False)

默认消息类型读取

在 Bundle 中可以使用,as_dict 方法将 Bundle 当前的信号值转换为字典。其同样支持两种格式,当 multilevelTrue 时,返回多级字典;当 multilevelFalse 时,返回扁平化的字典。

> arithmetic_bundle.as_dict(multilevel=True)
{
    'selector': 1,
    'adder': {
        'a': 0,
        'b': 0,
        'sum': 0,
        'cin': 0,
        'cout': 0
    },
    'multiplier': {
        'a': 0,
        'b': 0,
        'product': 0
    }
}
> arithmetic_bundle.as_dict(multilevel=False)
{
    'selector': 1,
    'adder.a': 0,
    'adder.b': 0,
    'adder.sum': 0,
    'adder.cin': 0,
    'adder.cout': 0,
    'multiplier.a': 0,
    'multiplier.b': 0,
    'multiplier.product': 0
}

自定义消息类型

在我们自定义的消息结构中,可以执行规则将其赋值给 Bundle 中的信号。

一种方法是,在自定义消息结构中,实现 as_dict 函数,将自定义消息结构转换为字典,然后通过 assign 方法赋值给 Bundle。

另一种方法是,在自定义消息结构中,实现 __bundle_assign__ 函数,其接收一个 Bundle 实例,将自定义消息结构赋值给 Bundle。实现后,可以通过 assign 方法赋值给 Bundle,Bundle 将会自动调用 __bundle_assign__ 函数进行赋值。

class MyMessage:
    def __init__(self):
        self.a = 0
        self.b = 0
        self.cin = 0

    def __bundle_assign__(self, bundle):
        bundle.a.value = self.a
        bundle.b.value = self.b
        bundle.cin.value = self.cin

my_message = MyMessage()
adder_bundle.assign(my_message)

当需要将 Bundle 中的信号值转换为自定义消息结构时,简易在自定义消息结构中实现 from_bundle 的类方法,接收一个 Bundle 实例,返回一个自定义消息结构。在创建自定义消息结构时,可以通过 from_bundle 方法将 Bundle 中的信号值转换为自定义消息结构。

class MyMessage:
    def __init__(self):
        self.a = 0
        self.b = 0
        self.cin = 0

    @classmethod
    def from_bundle(cls, bundle):
        message = cls()
        message.a = bundle.a.value
        message.b = bundle.b.value
        message.cin = bundle.cin.value
        return message

my_message = MyMessage.from_bundle(adder_bundle)

时序封装

Bundle 类除了对 DUT 的引脚进行封装外,还提供了基于数组的时序封装,可以适用于部分简单时序场景。Bundle 类提供了process_requests(data_list)函数,他接受一个数组输入,第i个时钟周期,会将data_list[i]对应的数据赋值给引脚。data_list中的数据可以是dict类型,或者callable(cycle, bundle_ins)类型的可调用对象。对于dict类型,特殊key有:

__funcs__: func(cycle, self)  # 可调用对象,可以为函数数组[f1,f2,..]
__condition_func__:  func(cycle, slef, cargs) # 条件函数,当改函数返回为true时,进行赋值,否则继续推进时钟
__condition_args__:  cargs # 条件函数需要的参数
__return_bundles__:  bundle # 需要本次dict赋值时返回的bundle数据,可以是list[bundle]

如果输入的dict中有__return_bundles__,则函数会返回该输入对应的 bundle 值,例如{"data": x, "cycle": y}。以 Adder 为例,期望第三次加后返回结果:

# Adder虽然为存组合逻辑,但此处当时序逻辑使用
class AdderBundle(Bundle):
    a, b, sum, cin, cout = Signals(5)             # 指定引脚

    def __init__(self, dut):
        super().__init__()
        # init clock
        # dut.InitClock("clock")
        self.bind(dut)                            # 绑定到dut

    def add_list(data_list =[(1,2),(3,4),(5,6),(7,8)]):
        # make input dit
        data = []
        for i, (a, b) in enumerate(data_list):
            x = {"a":a, "b":b, "*":0}             # 构建budle赋值的dict
            if i >= 2:
                x["__return_bundles__"] = self    # 设置需要返回的bundle
                data.append(X)
        return self.process_requests(data)        # 推动时钟,赋值,返回结果

当调用add_list()后,返回的结果为:

[
  {"data": {"a":5, "b":6, "cin": 0, "sum":11, "cout": 0}, "cycle":3},
  {"data": {"a":7, "b":8, "cin": 0, "sum":15, "cout": 0}, "cycle":4}
]

异步支持

在 Bundle 中,为了方便的接收时钟信息,提供了 step 函数。当 Bundle 连接至 DUT 的任意一个信号时,step 函数会自动同步至 DUT 的时钟信号。

可以通过 step 函数来完成时钟周期的等待。

async def adder_process(adder_bundle):
    adder_bundle.a.value = 1
    adder_bundle.b.value = 2
    adder_bundle.cin.value = 0
    await adder_bundle.step()
    print(adder_bundle.sum.value)
    print(adder_bundle.cout.value)

信号连接

信号连接规则

当定义好 Bundle 实例后,可以调用 all_signals_rule 方法,获取所有信号的连接规则,以帮助用户检查信号的连接规则是否符合预期。

adder_bundle.all_signals_rule()

信号可连接性检查

detect_connectivity 方法可以检查一个特定的信号名称是否可以连接到该 Bundle 中的某个信号。

adder_bundle.detect_connectivity('io_a')

detect_specific_connectivity 方法可以检查一个特定的信号名称是否可以连接到该 Bundle 中的某个特定的信号。

adder_bundle.detect_specific_connectivity('io_a', 'a')

如果需要检测子 Bundle 的信号连接性,可以通过 . 运算符来指定。

DUT 信号连接检查

未连接信号检查

detect_unconnected_signals 方法可以检查 DUT 中未连接到任何 Bundle 的信号。

Bundle.detect_unconnected_signals(adder)

重复连接检查

detect_multiple_connections 方法可以检查 DUT 中同时连接到多个 Bundle 的信号。

Bundle.detect_multiple_connections(adder)

其他实用操作

设置 Bundle 名称

可以通过 set_name 方法设置 Bundle 的名称。

adder_bundle.set_name('adder')

设置名称之后,将会得到更加直观的提示信息。

获取 Bundle 中所有信号

all_signals 信号会返回一个 generator,其中包含了包括子 Bundle 信号在内的所有信号。

for signal in adder_bundle.all_signals():
    print(signal)

Bundle 的自动生成脚本

在很多情况下,DUT 的接口可能过于复杂,手动去编写 Bundle 的定义会变得非常繁琐。然而,Bundle 作为中间层,提供一个确切的信号名称定义是必要的。为了解决这个问题,toffee 提供了一个自动生成 Bundle 的脚本来从 DUT 的接口定义中生成 Bundle 的定义。

可以在 toffee 仓库目录下的 scripts 文件夹中找到 bundle_code_gen.py 脚本。该脚本可以通过解析 DUT 实例,以及指定的绑定规则自动生成 Bundle 的定义。

其中提供了三个函数

def gen_bundle_code_from_dict(bundle_name: str, dut, dict: dict, max_width: int = 120)
def gen_bundle_code_from_prefix(bundle_name: str, dut, prefix: str = "", max_width: int = 120):
def gen_bundle_code_from_regex(bundle_name: str, dut, regex: str, max_width: int = 120):

分别用于通过字典、前缀、正则表达式的方式生成 Bundle 的定义。

使用时,指定 Bundle 的名称,DUT 实例,以及对应的生成规则便可生成 Bundle 的定义,还可以通过 max_width 参数来指定生成的代码的最大宽度。

from bundle_code_gen import *

gen_bundle_code_from_dict('AdderBundle', dut, {
    'a': 'io_a',
    'b': 'io_b',
    'sum': 'io_sum',
    'cin': 'io_cin',
    'cout': 'io_cout'
})
gen_bundle_code_from_prefix('AdderBundle', dut, 'io_')
gen_bundle_code_from_regex('AdderBundle', dut, r'io_(.*)')

生成好的代码可以直接或经过简单的修改后,复制到代码中使用。也可以作为子 Bundle 的定义,应用到其他 Bundle 中。

4.3.3 - 如何编写 Agent

Agent 在 toffee 验证环境中实现了对一类 Bundle 中信号的高层封装,使得上层驱动代码可以在不关心具体信号赋值的情况下,完成对 Bundle 中信号的驱动及监测。

一个 Agent驱动方法(driver_method)监测方法(monitor_method) 组成,其中驱动方法用于主动驱动 Bundle 中的信号,而监测方法用于被动监测 Bundle 中的信号。

初始化 Agent

为了定义一个 Agent,需要自定义一个新类,并继承 toffee 中的 Agent 类。下面是一个简单的 Agent 的定义示例:

from toffee.agent import *

class AdderAgent(Agent):
    def __init__(self, bundle):
        super().__init__(bundle.step)
        self.bundle = bundle

AdderAgent 类初始化时,需要外界传入该 Agent 需要驱动的 Bundle,并且需要向父类 Agent 中传入一个时钟同步函数,以便 Agent 使用这一函数来决定何时调用监测方法。一般来说,可以将其设置为 bundle.step,即 Bundle 中的时钟同步函数,Bundle 中的 step 与 DUT 中的时钟同步。

创建驱动方法

Agent 中,驱动方法是一个异步函数,用于主动驱动 Bundle 中的信号。驱动函数需要将函数的传入参数进行解析,并根据解析结果对 Bundle 中的信号进行赋值,赋值的过程可以跨越多个时钟周期。如果需要获取 Bundle 的信号值,那么在函数中编写相应的逻辑,并将其转换为需要的数据,通过函数返回值返回。

每一个驱动方法都应是一个异步函数,并且使用 @driver_method 装饰器进行修饰,以便 Agent 能够识别该函数为驱动方法。

下面是一个简单的驱动方法的定义示例:

from toffee.agent import *

class AdderAgent(Agent):
    def __init__(self, bundle):
        super().__init__(bundle.step)
        self.bundle = bundle

    @driver_method()
    async def exec_add(self, a, b, cin):
        self.bundle.a.value = a
        self.bundle.b.value = b
        self.bundle.cin.value = cin
        await self.bundle.step()
        return self.bundle.sum.value, self.bundle.cout.value

drive 函数中,我们将传入的 a, b, cin 三个参数分别赋值给 Bundle 中的 a, b, cin 信号,并等待一个时钟周期。在时钟周期结束后,我们返回 Bundle 中的 sum, cout 信号值。

在驱动函数的编写过程中,你可以使用 如何使用异步环境 中介绍的所有等待时钟信号的同步方法,例如 ClockCycles, Value 等。

创建完毕后,你可以像调用普通函数一样在驱动代码中调用该驱动方法,例如:

adder_bundle = AdderBundle()
adder_agent = AdderAgent(adder_bundle)
sum, cout = await adder_agent.exec_add(1, 2, 0)
print(sum, cout)

被标识为 @driver_method 的函数在调用时拥有诸多特性,这一部分将在编写测试用例中详细介绍。同时,该类函数还会完成参考模型的匹配与自动调用以返回值对比,这一部分将在编写参考模型中详细介绍。

创建监测方法

监测方法同样需要是一个异步函数,并且使用 @monitor_method 装饰器进行修饰,以便 Agent 能够识别该函数为监测方法。

一个简单的监测方法的定义示例如下:

from toffee.agent import *

class AdderAgent(Agent):
    def __init__(self, bundle):
        super().__init__(bundle.step)
        self.bundle = bundle

    @monitor_method()
    async def monitor_sum(self):
        if self.bundle.sum.value > 0:
            return self.bundle.as_dict()

monitor_sum 函数中,我们以 Bundle 中的 sum 信号作为监测对象,当 sum 信号的值大于 0 时,收集 Bundle 生成的默认消息类型,收集到的返回值将会被存储到内部的消息队列中。

添加 monitor_method 装饰器后,monitor_sum 函数将会被 Agent 自动调用,它会使用 Agent 初始化时提供的时钟同步函数来决定何时调用监测方法。默认情况下,Agent 会在每个时钟周期都调用一次监测方法,如果监测方法有返回值,那么返回值将会被存储到内部的消息队列中。若监测方法的一次调用会经过多个时钟周期,Agent 会等待上一次监测方法调用结束后再次调用监测方法。

如果编写了类似下面的监测方法:

@monitor_method()
async def monitor_sum(self):
    return self.bundle.as_dict()

该监测方法将会在每个周期都往消息队列中添加一个消息。

获取监测消息

由于该监测方法被标记为了 @monitor_method,因此该方法将会被 Agent 自动调用,在测试用例中如果按照以下方式直接调用该函数,并不能执行该函数的预期行为。

adder_bundle = AdderBundle()
adder_agent = AdderAgent(adder_bundle)
result = await adder_agent.monitor_sum()

相反的,按照上述方式调用监测方法,它将会弹出消息队列中收集到的最早的消息,并返回该消息。如果消息队列为空,该次调用将会等待消息队列中有消息后再返回。

如果想获取消息队列中的消息数量,可以使用如下方式获取:

message_count = adder_agent.monitor_size("monitor_sum")

通过创建监测方法,你可以方便地添加一个后台监测任务,监测 Bundle 中的信号值,并在满足条件时收集消息。将函数标记为监测方法后,框架还会为这一方法提供与参考模型的匹配与自动收集对比,这一部分将在编写参考模型中详细介绍。

通过在 Agent 中编写多个驱动方法和监测方法,便完成了整个 Agent 的编写。

4.3.4 - 如何搭建 Env

Env 在 toffee 验证环境中用于打包整个验证环境,Env 中直接实例化了验证环境中需要用的所有 agent,并负责将这些 Agent 需要的 bundle 传递给它们。

创建好 Env 后,参考模型的编写规范也随之确定,按照此规范编写的参考模型可直接附加到 Env 上,由 Env 来完成参考模型的自动同步。

创建 Env

为了定义一个 Env,需要自定义一个新类,并继承 toffee 中的 Env 类。下面是一个简单的 Env 的定义示例:

from toffee.env import *

class DualPortStackEnv(Env):
    def __init__(self, port1_bundle, port2_bundle):
        super().__init__()

        self.port1_agent = StackAgent(port1_bundle)
        self.port2_agent = StackAgent(port2_bundle)

在这个例子中,我们定义了一个 DualPortStackEnv 类,该类中实例化了两个相同的 StackAgent,分别用于驱动两个不同的 Bundle

可以选择在 Env 之外连接 Bundle,也可以在 Env 内部连接 Bundle,只要能保证向 Agent 中传入正确的 Bundle 即可。

此时,如果不需要编写额外的参考模型,那么整个验证环境的搭建就完成了,可以直接编写测试用例并且在测试用例中使用 Env 提供的接口,例如:

port1_bundle = StackPortBundle()
port2_bundle = StackPortBundle()
env = DualPortStackEnv(port1_bundle, port2_bundle)

await env.port1_agent.push(1)
await env.port2_agent.push(1)
print(await env.port1_agent.pop())
print(await env.port2_agent.pop())

附加参考模型

定义好 Env 后,整个验证环境的接口也就随之确定,例如:

DualPortStackEnv
  - port1_agent
    - @driver_method push
    - @driver_method pop
    - @monitor_method some_monitor
  - port2_agent
    - @driver_method push
    - @driver_method pop
    - @monitor_method some_monitor

按照此规范编写的参考模型都可以直接附加到 Env 上,由 Env 来完成参考模型的自动同步,方式如下:

env = DualPortStackEnv(port1_bundle, port2_bundle)
env.attach(StackRefModel())

一个 Env 可以附加多个参考模型,这些参考模型都将会被 Env 自动同步。

参考模型的具体编写方式将在编写参考模型一节中详细介绍。

4.3.5 - 如何编写参考模型

参考模型 用于模拟待验证设计的行为,以便在验证过程中对设计进行验证。在 toffee 验证环境中,参考模型需要遵循 Env 的接口规范,以便能够附加到 Env 上,由 Env 来完成参考模型的自动同步。

参考模型的两种实现方式

toffee 提供了两种参考模型的实现方式,这两种方式都可以被附加到 Env 上,并由 Env 来完成参考模型的自动同步。在不同的场景下,可以选择更适合的方式来实现参考模型。

这两种方式分别是 函数调用模式独立执行流模式,下面将分别介绍这两种方式的具体概念。

函数调用模式

函数调用模式即是将参考模型的对外接口定义为一系列的函数,通过调用这些函数来驱动参考模型的行为。此时,我们通过输入参数向参考模型发送数据,并通过返回值获取参考模型的输出数据,参考模型通过函数体的逻辑来更新内部状态。

下面是一个简单的函数调用模式的参考模型的定义示例:

例如,这是一个简单的加法器参考模型:

class AdderRefModel():
    def add(self, a, b):
        return a + b

在这个参考模型中,不需要任何内部状态,通过一个对外函数接口即可实现参考模型所有功能。

需要注意的是,使用函数调用模式编写的参考模型,只能通过外部主动调用的方式来执行,无法被动输出内部数据。因此,其无法与 Agent 中的监测方法进行匹配。在 Agent 中编写监测方法,在函数调用模式编写参考模型时是没有意义的。

独立执行流模式

独立执行流模式即是将参考模型的行为定义为一个独立的执行流,它不再受外部主动调用函数控制,而拥有了主动获取输入数据和主动输出数据的能力。当外部给参考模型发送数据时,参考模型不会立即响应,而是将这一数据保存起来,等待其执行逻辑主动获取该数据。

我们用一段代码来说明这种模式,该示例中用到了 toffee 中提供的相关概念来实现,但目前无需关心这些概念的使用细节。

class AdderRefModel(Model):
    def __init__(self):
        super().__init__()

        self.add_port = DriverPort()
        self.sum_port = MonitorPort()

    async def main():
        while True:
            operands = await self.add_port()
            sum = operands["a"] + operands["b"]
            await self.sum_port(sum)

在这里,我们在参考模型构造函数中定义了两类接口,一类为驱动接口(DriverPort),即代码中的add_port,用于接收外部输入数据;另一类为监测接口(MonitorPort),即代码中的sum_port,用于向外部输出数据。

定义了这两个接口后,上层代码在给参考模型发送数据时,并不会触发参考模型中的某个函数,而是会将数据发送到 add_port 这个驱动接口中。同时,上层代码也无法主动获取到参考模型的输出数据了。参考模型的输出数据会通过 sum_port 这个监测接口,由参考模型主动输出。

那么参考模型如何去使用这两个接口呢?在参考模型中,有一个 main 函数,这是参考模型执行的入口,当参考模型创建时, main 函数会被自动调用,并在后台持续运行。在上面代码中 main 函数里,参考模型通过不断重复这一过程:等待 add_port 中的数据、计算结果、将结果输出到 sum_port 中 来实现参考模型的行为。

参考模型会主动向 add_port 请求数据,如果 add_port 中没有数据,参考模型会等待数据的到来。当数据到来后,参考模型将会进行计算,将计算结果主动的输出到 sum_port 中。它的执行过程是一个独立的执行流,不受外部的主动调用控制。当参考模型变得复杂时,其将会含有众多的驱动接口和监测接口,通过独立执行流的方式,可以更好的去处理结构之间的相互关系,尤其是接口之间存在调用顺序的情况。

如何编写函数调用模式的参考模型

驱动函数匹配

假如 Env 中定义的接口如下:

StackEnv
  - port_agent
    - @driver_method push
    - @driver_method pop

那么如果我们想要编写与之对应的参考模型,自然地,我们需要定义这四个驱动函数被调用时参考模型的行为。也就是说为每一个驱动函数编写一个对应的函数,这些函数将会在驱动函数被调用时被框架自动调用。

如何让参考模型中定义的函数能够与某个驱动函数匹配呢?首先应该使用 @driver_hook 装饰器来表示这个函数是一个驱动函数的匹配函数。接着,为了建立对应关系,我们需要在装饰器中指定其对应的 Agent 和驱动函数的名称。最后,只需要保证函数的参数与驱动函数的参数一致,两个函数便能够建立对应关系。

class StackRefModel(Model):
    @driver_hook(agent_name="port_agent", driver_name="push")
    def push(self, data):
        pass

    @driver_hook(agent_name="port_agent", driver_name="pop")
    def pop(self):
        pass

此时,驱动函数与参考模型的对应关系已经建立,当 Env 中的某个驱动函数被调用时,参考模型中对应的函数将会被自动调用,并自动对比两者的返回值是否一致。

toffee 还提供了以下几种匹配方式,以便更好地匹配驱动函数:

指定驱动函数路径

可以通过 “.” 来指定驱动函数的路径,例如:

class StackRefModel(Model):
    @driver_hook("port_agent.push")
    def push(self, data):
        pass

    @driver_hook("port_agent.pop")
    def pop(self):
        pass

使用函数名称匹配驱动函数名称

如果参考模型中的函数名称与驱动函数名称相同,可以省略 driver_name 参数,例如:

class StackRefModel(Model):
    @driver_hook(agent_name="port_agent")
    def push(self, data):
        pass

    @driver_hook(agent_name="port_agent")
    def pop(self):
        pass

使用函数名称同时匹配 Agent 名称与驱动函数名称

可以在函数名中通过双下划线 “__” 来同时匹配 Agent 名称与驱动函数名称,例如:

class StackRefModel(Model):
    @driver_hook()
    def port_agent__push(self, data):
        pass

    @driver_hook()
    def port_agent__pop(self):
        pass

Agent 匹配

除了对 Agent 中每一个驱动函数都编写一个 driver_hook 之外,还可以通过 @agent_hook 装饰器来一次性匹配 Agent 中的所有驱动函数。

class StackRefModel(Model):
    @agent_hook("port_agent")
    def port_agent(self, driver_name, args):
        pass

在这个例子中,port_agent 函数将会匹配 port_agent Agent 中的所有驱动函数,当 Agent 中的任意一个驱动函数被调用时,port_agent 函数将会被自动调用。除了 self 之外,port_agent 函数还需接受且只接受两个参数,第一个参数为驱动函数的名称,第二个参数为驱动函数的参数。

当某个驱动函数被调用时,driver_name 参数将会传入驱动函数的名称,args 参数将会传入该该驱动函数被调用时的参数,参数将会以字典的形式传入。port_agent 函数可以根据 driver_name 和 args 来决定如何处理这个驱动函数的调用,并将结果返回。此时框架将会使用此函数的返回值与驱动函数的返回值进行对比。

与驱动函数类似,@agent_hook 装饰器也支持当函数名与 Agent 名称相同时省略 agent_name 参数。

class StackRefModel(Model):
    @agent_hook()
    def port_agent(self, driver_name, args):
        pass

agent_hook 与 driver_hook 同时存在

agent_hook 被定义后,理论上无需再定义任何 driver_hook 与 Agent 中的驱动函数进行匹配。但是,如果需要对某个驱动函数进行特殊处理,可以再定义一个 driver_hook 与该驱动函数进行匹配。

agent_hookdriver_hook 同时存在时,框架会优先调用 agent_hook 函数,再调用 driver_hook 函数,并将 driver_hook 函数的返回值用于结果的对比。

当 Env 中所有的驱动函数都能找到对应的 driver_hookagent_hook 时,参考模型便能成功与 Env 建立匹配关系,此时可以直接通过 Env 中的 attach 方法将参考模型附加到 Env 上。

如何编写独立执行流模式的参考模型

独立执行流模式的参考模型是通过 port 接口的形式来完成数据的输入输出,他可以主动向 port 请求数据,也可以主动向 port 输出数据。在 toffee 中,我们提供了两种接口来实现这一功能,分别是 DriverPortMonitorPort

类似地,我们需要定义一系列的 DriverPort 使其与 Env 中的驱动函数匹配,同时定义一系列的 MonitorPort 使其与 Env 中的监测函数匹配。

当 Env 中的驱动函数被调用时,调用数据将会被发送到 DriverPort 中,参考模型将会主动获取这些数据,并进行计算。计算结果将会被输出到 MonitorPort 中,当 Env 中的监测函数被调用时,比较器会自动从 MonitorPort 中获取数据,并与 Env 中的监测函数的返回值进行比较。

驱动方法接口匹配

为了接收到 Env 中所有的驱动函数的调用,参考模型可以选择为每一个驱动函数编写对应的 DriverPort。可以通过 DriverPort 的参数 agent_namedriver_name 来匹配 Env 中的驱动函数。

class StackRefModel(Model):
    def __init__(self):
        super().__init__()

        self.push_port = DriverPort(agent_name="port_agent", driver_name="push")
        self.pop_port = DriverPort(agent_name="port_agent", driver_name="pop")

driver_hook 类似,也可以使用下面的方式来匹配 Env 中的驱动函数:

# 使用 "." 来指定驱动函数的路径
self.push_port = DriverPort("port_agent.push")

# 如果参考模型中的变量名称与驱动函数名称相同,可以省略 driver_name 参数
self.push = DriverPort(agent_name="port_agent")

# 使用变量名称同时匹配 Agent 名称与驱动函数名称,并使用 `__` 分隔
self.port_agent__push = DriverPort()

Agent 接口匹配

也可以选择定义 AgentPort 同时匹配一个 Agent 中的所有驱动函数。但与 agent_hook 不同的是,定义了 AgentPort 后,便不能为该 Agent 中的任何驱动函数再定义 DriverPort。所有的驱动函数调用将会被发送到 AgentPort 中。

class StackRefModel(Model):
    def __init__(self):
        super().__init__()

        self.port_agent = AgentPort(agent_name="port_agent")

类似的,当变量名称与 Agent 名称相同时,可以省略 agent_name 参数:

self.port_agent = AgentPort()

监测方法接口匹配

为了与 Env 中的监测函数匹配,参考模型需要为每一个监测函数编写对应的 MonitorPort,定义方法与 DriverPort 一致。

self.monitor_port = MonitorPort(agent_name="port_agent", monitor_name="monitor")

# 使用 "." 来指定监测函数的路径
self.monitor_port = MonitorPort("port_agent.monitor")

# 如果参考模型中的变量名称与监测函数名称相同,可以省略 monitor_name 参数
self.monitor = MonitorPort(agent_name="port_agent")

# 使用变量名称同时匹配 Agent 名称与监测函数名称,并使用 `__` 分隔
self.port_agent__monitor = MonitorPort()

MonitorPort 中送入的数据,将会自动与 Env 中的监测函数的返回值进行比较,来完成参考模型的比对工作。

当参考模型中定义的 DriverPort, AgentPortMonitorPort 能够与 Env 中所有接口匹配时,参考模型便能成功与 Env 建立匹配关系,此时可以直接通过 Env 中的 attach 方法将参考模型附加到 Env 上。

4.4 - 编写测试用例

编写测试用例需要使用验证环境中定义好的接口来实现,但在用例中,往往会遇到同时驱动多个接口的情况,并且对于参考模拟的同步往往也有不同的需求,这一部分将详细介绍如何更好地使用验证环境中的接口来编写测试用例。

当验证环境搭建完成后,编写测试用例用于验证设计的功能是否符合预期。对于硬件验证中的验证,两个重要的导向是:功能覆盖率行覆盖率,功能覆盖率意味着测试用例是否覆盖了设计的所有功能,行覆盖率意味着测试用例是否触发了设计的所有代码行。在 toffee-test 中,不仅提供了对这两种覆盖率的支持,还会再每次运行过后,自动计算出这两种覆盖率的结果,并生成一个验证报告。toffee-test 使用 pytest 来管理测试用例,使其拥有强大的测试用例管理能力。

在本节中,会在以下几个方面来讲述如何编写测试用例,以使用 toffee 和 toffee-test 提供的强大功能:

  1. 如何使用测试环境接口进行驱动
  2. 如何使用 pytest 管理测试用例
  3. 如何添加功能测试点

4.4.1 - 如何使用测试环境接口进行驱动

如何同时调用多个驱动函数

当验证环境搭建完成后,可以通过验证环境提供的接口来编写测试用例。然而,通过普通的串行代码,往往无法完成两个驱动函数的同时调用。在多个接口需要同时驱动的情况下,这种情况变得尤为重要,toffee 为这种场景提供了简便的调用方式。

同时调用多个不同类别的驱动函数

例如目前的 Env 结构如下:

DualPortStackEnv
  - port1_agent
    - @driver_method push
    - @driver_method pop
  - port2_agent
    - @driver_method push
    - @driver_method pop

我们期望在测试用例中同时调用 port1_agentport2_agentpush 函数,以便同时驱动两个接口。

在 toffee 中,可以通过 Executor 来完成。

from toffee import Executor

def test_push(env):
    async with Executor() as exec:
        exec(env.port1_agent.push(1))
        exec(env.port2_agent.push(2))

    print("result", exec.get_results())

我们使用 async with 来创建一个 Executor 对象,并建立一个执行块,通过直接调用 exec 可以添加需要执行的驱动函数。当 Executor 对象退出作用域时,会将所有添加的驱动函数同时执行。Executor 会自动等待所有驱动函数执行完毕。

如果需要获取驱动函数的返回值,可以通过 get_results 方法来获取,get_results 会以字典的形式返回所有驱动函数的返回值,其中键为驱动函数的名称,值为一个列表,列表中存放了对应驱动函数的返回值。

同一驱动函数被多次调用

如果在在执行块中多次调用同一驱动函数,Executor 会自动将这些调用串行执行。

from toffee import Executor

def test_push(env):
    async with Executor() as exec:
        for i in range(5):
            exec(env.port1_agent.push(1))
        exec(env.port2_agent.push(2))

    print("result", exec.get_results())

例如上述代码中,port1_agent.push 会被调用 5 次,port2_agent.push 会被调用 1 次。由于 port1_agent.push 是同一驱动函数,Executor 会自动将这 10 次调用串行执行,其返回值会被依次存放在返回值列表中。通过,port2_agent.push 将会与 port1_agent.push 并行执行。

上述过程中,我们创建了这样一个调度过程:

------------------  current time --------------------
  +---------------------+   +---------------------+
  | group "agent1.push" |   | group "agent2.push" |
  | +-----------------+ |   | +-----------------+ |
  | |   agent1.push   | |   | |   agent2.push   | |
  | +-----------------+ |   | +-----------------+ |
  | +-----------------+ |   +---------------------+
  | |   agent1.push   | |
  | +-----------------+ |
  | +-----------------+ |
  | |   agent1.push   | |
  | +-----------------+ |
  | +-----------------+ |
  | |   agent1.push   | |
  | +-----------------+ |
  | +-----------------+ |
  | |   agent1.push   | |
  | +-----------------+ |
  +---------------------+
------------------- Executor exit -------------------

Executor 根据两个驱动函数的函数名自动创建了两个调度组,并按照调用顺序将驱动函数添加到对应的调度组中。在调度组内部,驱动函数会按照添加的顺序依次执行。在调度组之间,驱动函数会并行执行。

调度组的默认名称为以 . 分隔的驱动函数路径名。

通过 sche_group 参数,你可以在执行函数时手动指定驱动函数调用时所属的调度组,例如

from toffee import Executor

def test_push(env):
    async with Executor() as exec:
        for i in range(5):
            exec(env.port1_agent.push(1), sche_group="group1")
        exec(env.port2_agent.push(2), sche_group="group1")

    print("result", exec.get_results())

这样一来,port1_agent.pushport2_agent.push 将会被按顺序添加到同一个调度组 group1 中,表现出串行执行的特性。同时 get_results 返回的字典中,group1 会作为键,其值为一个列表,列表中存放了 group1 中所有驱动函数的返回值。

将自定义函数加入 Executor

如果我们在一个自定义函数中调用了驱动函数或其他驱动函数,并希望自定义函数也可以通过 Executor 来调度,可以通过与添加驱动函数相同的方式来添加自定义函数。

from toffee import Executor

async def multi_push_port1(env, times):
    for i in range(times):
        await env.port1_agent.push(1)

async def test_push(env):
    async with Executor() as exec:
        for i in range(2):
            exec(multi_push_port1(env, 5))
        exec(env.port2_agent.push(2))

    print("result", exec.get_results())

此时,multi_push_port1 会被添加到 Executor 中,并创建以 multi_push_port1 为名称的调度组,并向其中添加两次调用。其会与 port2_agent.push 调度组并行执行。

我们也可以在自定义函数中使用 Executor,或调用其他自定义函数。这样一来,我们可以通过 Executor 完成任意复杂的调度。以下提供了若干个案例:

案例一

环境接口如下:

Env
- agent1
    - @driver_method send
- agent2
    - @driver_method send

两个 Agent 中的 send 函数各需要被并行调用 5 次,并且调用时需要发送上一次的返回结果,第一次发送时发送 0,两个函数调用相互独立。

from toffee import Executor

async def send(agent):
    result = 0
    for i in range(5):
        result = await agent.send(result)

async def test_send(env):
    async with Executor() as exec:
        exec(send(env.agent1), sche_group="agent1")
        exec(send(env.agent2), sche_group="agent2")

    print("result", exec.get_results())

案例二

环境接口如下:

env
- agent1
    - @driver_method long_task
- agent2
    - @driver_method task1
    - @driver_method task2

task1 和 task2 需要并行执行,并且一次调用结束后需要同步,task1 和 task2 都需要调用 5 次,long_task 需要与 task1 和 task2 并行执行。

from toffee import Executor

async def exec_once(env):
    async with Executor() as exec:
        exec(env.agent2.task1())
        exec(env.agent2.task2())

async def test_case(env):
    async with Executor() as exec:
        for i in range(5):
            exec(exec_once(env))
        exec(env.agent1.long_task())

    print("result", exec.get_results())

设置 Executor 的退出条件

Executor 会等待所有添加的驱动函数执行完毕后退出,但有时我们并不需要等待所有驱动函数执行完毕,可以通过在创建 Executor 时使用 exit 参数来设置退出条件。

exit 参数可以被设置为 all, anynone 三种值,分别表示所有调度组执行完毕后退出、任意一个调度组执行完毕后退出、不等待直接退出。

from toffee import Executor

async def send_forever(agent):
    result = 0
    while True:
        result = await agent.send(result)

async def test_send(env):
    async with Executor(exit="any") as exec:
        exec(send_forever(env.agent1))
        exec(env.agent2.send(1))

    print("result", exec.get_results())

例如上述代码中 send_forever 函数是一个无限循环的函数,将 exit 设置为 any 后,Executor 会在 env.agent2.send 函数执行完毕后退出,而不会等待 send_forever 函数执行完毕。

如果后续需要等待所有任务执行完毕,可以通过等待 exec.wait_all 来实现。

如何控制参考模型调度

在 toffee 中,参考模型的调度是由 toffee 自动完成的,但在某些情况下需要手动控制参考模型的调度顺序,例如在参考模型中需要调用多个函数,且这些函数之间存在调用顺序的情况。或者是控制参考模型与驱动函数之间的调用顺序。

参考模型的调度顺序

在使用 Executor 执行时,可以使用参数 sche_order 来控制参考模型是在驱动函数之前、之后或同时执行。当为 model_first 时,参考模型会在驱动函数之前执行;当为 dut_first 时,驱动函数会在参考模型之前执行;当为 parallel 时,参考模型会与驱动函数同时执行。默认情况下为并行执行。

def test_push(env):
    async with Executor() as exec:
        exec(env.port1_agent.push(1), sche_order="dut_first")
        exec(env.port2_agent.push(2), sche_order="dut_first")

    print("result", exec.get_results())

上述代码中,参考模型将会在对应的驱动函数结束之后才会被调用。

参考模型函数之间的调用顺序

当使用函数调用模式编写参考模型时,参考模型中的函数之间可能存在调用顺序相关的一来,例如一个函数在调用之前必须需要另一个函数先被调用。

这一过程若不使用 Executor 使函数并行执行,很容易得到控制,串行执行的代码中函数的调用顺序即为其执行顺序。

但如果使用 Executor 并行执行函数,两个参考模型之间的调用顺序就无法保证。toffee 为此场景提供了 priority 参数,用于指定参考模型函数的调用顺序,数值越小其优先级较高。

from toffee import Executor

def test_push(env):
    async with Executor() as exec:
        exec(env.port1_agent.push(1), priority=1)
        exec(env.port2_agent.push(2), priority=0)

    print("result", exec.get_results())

例如上述代码中,port2_agent.pushport1_agent.push 两个函数会并行执行,其参考模型的调用也将在同一时钟周期内完成。由于我们指定了port2_agent.push 的优先级为 0,port1_agent.push 的优先级为 1,因此在该周期的执行过程中,port2_agent.push 会优先被调用。

注意,优先级只在同一时钟周期内有效,若两个函数调用跨越了时钟周期,那么时钟周期靠前的函数依然会被优先调用。

4.4.2 - 如何使用 Pytest 管理测试用例

编写测试用例

在 toffee 中,测试用例是通过 pytest 来管理的。pytest 是一个功能强大的 Python 测试框架,如果你不熟悉 pytest,可以查看 pytest 官方文档

编写第一个测试用例

首先,我们需要创建一个测试用例文件,例如 test_adder.py,该文件需要以 test_ 开头,或以 _test.py 结尾,以便 pytest 能够识别。接着可以在其中编写我们的第一个测试用例。

# test_adder.py

async def my_test():
    env = AdderEnv()
    env.add_agent.exec_add(1, 2, 0)

def test_adder():
    toffee.run(my_test())

pytest 并不能直接运行协程测试用例,因此我们需要在测试用例中调用 toffee.run 来运行异步测试用例。

用例编写完成后,我们可以在终端中运行 pytest。

pytest

pytest 会查找当前目录下所有以 test_ 开头或以 _test.py 结尾的文件,并运行其中以 test_ 开头的函数,每一个函数被视作一个测试用例。

运行协程测试用例

为了使 pytest 能够直接运行协程测试用例,toffee 提供了 toffee_async 标记来标记异步测试用例。

# test_adder.py

@pytest.mark.toffee_async
async def test_adder():
    env = AdderEnv(DUTAdder())
    await env.add_agent.exec_add(1, 2, 0)

如图所示,我们只需要在测试用例函数上添加 @pytest.mark.toffee_async 标记,pytest 就能够直接运行协程测试用例。

生成测试报告

在运行 pytest 时,toffee 会自动收集测试用例的执行结果,自动统计覆盖率信息,并生成一个验证报告,想要生成该报告,需要在调用 pytest 时添加 --toffee-report 参数。

pytest --toffee-report

默认情况下,toffee 将会为每次运行生成一个默认报告名称,并将报告放至 reports 目录下。可以通过 --report-dir 参数来指定报告的存放目录,通过 --report-name 参数来指定报告的名称。

但此时,由于 toffee 无法得知覆盖率文件名称,因此在报告中无法显示覆盖率信息,如果想要在报告中显示覆盖率信息,需要在每个测试用例中传入功能覆盖组及行覆盖率文件的名称。

@pytest.mark.toffee_async
async def test_adder(request):
    adder = DUTAdder(
        waveform_filename="adder.fst",
        coverage_filename="adder.dat"
    )
    g = CovGroup("Adder")

    env = AdderEnv(adder)
    await env.add_agent.exec_add(1, 2, 0)

    adder.Finish()
    set_func_coverage(request, cov_groups)
    set_line_coverage(request, "adder.dat")

上述代码中,在创建 DUT 时,我们传入了波形文件和覆盖率文件的名称,使得 DUT 在运行时可以生成指定名称的覆盖率文件。接着我们定义了一个覆盖组,来收集 DUT 的功能覆盖率信息,具体如何使用将在下个文档中介绍。

接着,调用了 DUT 的 Finish 方法,用于结束波形文件的记录。最终我们通过 set_func_coverageset_line_coverage 函数来设置功能覆盖组及行覆盖率文件信息。

此时再次运行 pytest 时,toffee 将会自动收集覆盖率信息,并在报告中显示。

使用 toffee-test 管理资源

然而,上述过程过于繁琐,并且为了保证每个测试用例之间文件名称不产生冲突,我们需要在每个测试用例中传入不一样的文件名称。并且在测试用例出现异常时,测试用例并不会运行完毕,导致覆盖率文件无法生成。

因此,toffee-test 提供了 toffee_request Fixture 来管理资源,简化了测试用例的编写。

# test_adder.py

@pytest.mark.toffee_async
async def test_adder(my_request):
    dut = my_request
    env = AdderEnv(dut)
    await env.add_agent.exec_add(1, 2, 0)

@pytest.fixture()
def my_request(toffee_request: ToffeeRequest):
    toffee_request.add_cov_groups(CovGroup("Adder"))
    return toffee_request.create_dut(DUTAdder)

Fixture 是 pytest 中的概念,例如上述代码中定义了一个名为 my_request 的 Fixture。如果在其他测试用例的输出参数中含有 my_request 参数,pytest 将会自动调用 my_request Fixture,并将其返回值传入测试用例。

上述代码中自定义了一个 Fixture my_request,并在测试用例中进行使用,这也就意味着资源的管理工作都将会在 Fixture 中完成,测试用例只需要关注测试逻辑即可。my_request 必须使用 toffee-test 提供的 toffee_request Fixture 作为参数,以便进行资源管理,toffee_request 提供了一系列的方法来管理资源。

通过 add_cov_groups 添加覆盖组,toffee-test 会自动将其生成至报告中。 通过 create_dut 创建 DUT 实例,toffee-test 会自动管理 DUT 的波形文件和覆盖率文件的生成,并确保文件名称不产生冲突。

my_request 中,可以自定义返回值传入测试用例中。如果想要任意测试用例都可以访问到该 Fixture,可以将 Fixture 定义在 conftest.py 文件中。

至此,我们实现了测试用例资源管理和逻辑编写的分离,无需在每个测试用例中手动管理资源的创建与释放。

4.4.3 - 功能检查点(功能覆盖率)

什么是功能检查点

在 toffee 中,功能检查点(Cover Point) 是指对设计的某个功能进行验证的最小单元,判断该功能是否满足设计目标。测试组(Cover Croup) 是一类检查点的集合。

定义一个检查点,需要指定检查点的名称及检查点的触发条件(触发条件可以有多个,最终的检查结果为所有条件取“逻辑与”,触发条件称为Cover Bin)。例如,可以定义了一个检查点,“当加法器运算结果不为 0 时,结果运算正确”,此时,检查点的触发条件可以为 “加法器的 sum 信号不为零”。

当检查点的所有触发条件都满足时,检查点被触发,此时,验证报告将会记录下该检查点的触发。并会提升验证的功能覆盖率。当所有检查点都被触发时,验证的功能覆盖率达到 100%。

如何编写检查点

编写检查点前,首先需要创建一个测试组,并指定测试组的名称

import toffee.funcov as fc

g = fc.CovGroup("Group-A")

接着,需要往这个测试组中添加检查点。一般情况下,一个功能点对应一个或多个检查点,用来检查是否满足该功能。例如我们需要检查Addercout是否有0出现,我们可以通过如下方式添加:

g.add_watch_point(adder.io_cout,
                  {"io_cout is 0": fc.Eq(0)},
                  name="cover_point_1")

在上述检查点中,需要观察的数据为io_cout引脚,检查条件(Cover Bin)的名称为io_cout is 0,检查点名称为cover_point_1。函数add_watch_point的参数说明如下:

def add_watch_point(target,
                    bins: dict,
                    name: str = "", once=None):
        """
        @param target: 检查目标,可以是一个引脚,也可以是一个DUT对象
        @param bins: 检查条件,dict格式,key为条件名称,value为具体检查方法或者检查方法的数组。
        @param name: 检查点名称
        @param once,如果once=True,表明只检查一次,一旦该检查点满足要求后就不再进行重复条件判断。

通常情况下,targetDUT引脚,bins中的检查函数来检查targetvalue是否满足预定义条件。funcov模块内存了部分检查函数,例如Eq(x), Gt(x), Lt(x), Ge(x), Le(x), Ne(x), In(list), NotIn(list), isInRange([low,high])等。当内置检查函数不满足要求时,也可以自定义,例如需要跨时钟周期进行检查等。自定义检查函数的输入参数为target,返回值为bool。例如:

g.add_watch_point(adder.io_cout,
                  {
                    "io_cout is 0": lambda x: x.value == 0,
                    "io_cout is 1": lambda x: x.value == 1,
                    "io_cout is x": [fc.Eq(0), fc.In([0,1]), lambda x:x.value < 4],
                  },
                  name="cover_point_1")

当添加完所有的检查点后,需要在DUTStep回调函数中调用CovGroupsample()方法进行判断。在检查过程中,或者测试运行完后,可以通过CovGroupas_dict()方法查看检查情况。

dut.StepRis(lambda x: g.sample())

...

print(g.as_dict())

如何在测报告中展示

在测试case每次运行结束时,可以通过set_func_coverage(request, cov_groups)告诉框架对所有的功能覆盖情况进行合并收集。相同名字的CoverGroup会被自动合并。下面是一个简单的例子:

import pytest
import toffee.funcov as fc
from toffee_test.reporter import set_func_coverage

g = fc.CovGroup("Group X")

def init_function_coverage(g):
    # add your points here
    pass

@pytest.fixture()
def dut_input(request):
    # before test
    init_function_coverage(g)
    dut = DUT()
    dut.InitClock("clock")
    dut.StepRis(lambda x: g.sample())
    yield dut
    # after test
    dut.Finish()
    set_func_coverage(request, g)
    g.clear()

def test_case1(dut_input):
    assert True

def test_case2(dut_input):
    assert True

# ...

在上述例子中,每个case都会通过dut_input函数来创建输入参数。该函数用yield返回dut,在运行case前初始化dut,并且设置在dutstep回调中执行g.sample()。运行完case后,调用set_func_coverage收集覆盖率,然后清空收集的信息。所有测试运行完成后,可在生成的测试报告中查看具体的覆盖情况。

4.5 - 开始新的验证任务

使用 toffee,你已经可以搭建出一个完整的验证环境,并且方便地去编写测试用例了。然而在实际的业务中,往往无法理解如何开始上手,并最终完成验证任务。实际编写代码后,会遇到无法正确划分 Bundle,无法正确理解 Agent 的高级语义封装,搭建完环境之后不知道做什么等问题。

在这一节中,将会介绍如何从头开始完成一个新的验证任务,以及如何更好地使用 toffee 来完成验证任务。

1. 了解待验证设计

拿到一个新的设计后,往往面对的是几十或数百个输入输出信号,如果直接看这些信号,很可能一头雾水,感觉无从下手。在这时,你必须坚信,输入输出信号都是设计人员来定义的,只要能够理解设计的功能,就能够理解这些信号的含义。

如果设计人员提供了设计文档,那么你可以阅读设计文档,了解设计的功能,并一步步地将功能与输入输出信号对应起来,并且要清楚地理解输入输出信号的时序,以及如何使用这些信号来驱动设计。一般来说,你还需要阅读设计的源代码,来找寻更细节的接口时序问题。

当大致了解了 DUT 的功能,并明白如何驱动起 DUT 接口之后,你就可以开始搭建验证环境了。

2. 划分 Bundle

搭建环境的第一件事,就是根据接口的逻辑功能,将其划分为若干个接口集合,我们可以每一个接口集合视作一个 Bundle。划分为的每个 Bundle 都应是独立的,由一个独立的 Agent 来驱动。

但是,往往实际中的接口是这样的:

|---------------------- DUT Bundle -------------------------------|

|------- Bundle 1 ------| |------ Bundle 2 ------| |-- Bundle 3 --|

|-- B1.1 --| |-- B1.2 --| |-- B2.1 --|

那么问题就出现了,例如究竟是应该为 B1.1, B1.2 各自创建一个 Agent,还是应该直接为 Bundle 1 建立一个 Agent 呢?

这还是取决于接口的逻辑功能,如果需要定义一个独立的请求,这个请求需要对 B1.1 和 B1.2 同时进行操作,那么就应该为 Bundle 1 创建一个 Agent,而不是为 B1.1 和 B1.2 分别创建 Agent。

即便如此,为 B1.1 和 B1.2 定义 1.2 也是可行的,这增添了 Agent 的划分粒度,但牺牲了操作的连续性,上层代码和参考模型的编写都会变得复杂。因此选择合适的划分粒度是需要对具体业务的权衡。最终的划分,所有的 Agent 加起来应该能覆盖 DUT Bundle 的所有接口。

实践中,为了方便 DUT 的连接,可以定义一个 DUT Bundle,一次性将所有的接口都连接到这个 Bundle 上,由 Env 将其中的子 Bundle 分发给各个 Agent。

3. 编写 Agent

当 Bundle 划分完成后,就可以开始编写 Agent 来驱动这些 Bundle 了,你需要为每个 Bundle 编写一个 Agent。

首先,可以从驱动方法开始写起,驱动方法实际上是对 Bundle 的一种高级语义封装,因此,高级语义信息应该携带了足以驱动 Bundle 的所有信息。如果 Bundle 中存在一个信号需要数字,但参数中并没有提供与这一信号相关的信息,那么这种高级语义封装就是不完整的。应尽量避免在驱动方法中对某个信号值进行假定,如果对这一信号在 Agent 中进行假定,DUT 的输出将会受到这一假定的影响,可能导致参考模型与 DUT 的行为不一致。

同时,这一高层封装也决定了参考模型的功能层级,参考模型会直接与高层语义信息进行交互,并不会涉及到底层信号。

如果参考模型需要用函数调用模式编写,那么应该将 DUT 的输出通过函数返回值来返回。如果参考模型需要用独立执行流模式编写,那么应该编写监测方法,将 DUT 的所有输出转换成高层语义信息,通过监测方法输出。

4. 封装成 Env

当所有的 Agent 编写完成后,或者挑选之前已有的 Agent,就可以将这些 Agent 封装成 Env 了。

Env 封装了整个验证环境,并确定了参考模型的编写规范。

5. 编写参考模型

参考模型的编写没有必要在 Env 编写完成之后再开始,可以与 Agent 的编写同时进行,并实时编写一些驱动代码,来检验编写的正确性。当然如果 Agent 的编写特别规范,编写完整 Env 后再编写参考模型也是可行的。

参考模型最重要的是选择合适的编写模式,函数调用模式和独立执行流模式都是可行的,但在不同的场景下,选择不同的模式会更加方便。

6. 确定功能点及测试点

编写好 Env 以及参考模型后,并不能直接开始编写测试用例,因为此时并没有测试用例的编写方向,盲目的编写测试用例,没有办法让待测设计验证完全。

首先需要列出功能点及测试点列表。功能点是待测设计支持的所有功能,例如对于一个算术逻辑单元(ALU)来说,功能点的形式可能是“支持加法”,“支持乘法”等。每个功能点需要对应一个或多个测试点,测试点通过将功能划分为不同的测试场景,来验证功能点是否正确。例如对于“支持加法”这个功能点,可能有“当输入都为正数时,加法正确”等测试点。

7. 编写测试用例

当功能点及测试点列表确定后,就可以开始编写测试用例了,一个测试用例需要能够覆盖一个或多个测试点,以验证功能点是否正确。所有的测试用例应该能够覆盖所有的测试点(功能覆盖率 100%),以及覆盖所有的设计行(行覆盖率 100%),这样一来就能保证验证的完备性。

如何保证验证的正确性呢?如果采用参考模型比对的方式,当比对失败时,toffee 会自动抛出异常,使得测试用例失败。如果采用直接比对的方式,应该在测试用例中使用 assert 来编写比对代码,当比对失败时,测试用例也会失败。最终,当所有的测试用例都通过时,意味着功能已验证为正确。

编写过程中,你需要使用 Env 中提供的接口来驱动 DUT,如果出现了需要多个驱动方法交互的情况,可以使用 Executor 来封装更高层的函数。也就是说驱动方法级的交互,是在测试用例的编写中完成的。

8. 编写验证报告

当行覆盖率和功能覆盖率都达到了 100% 之后,意味着验证已经完成。最终需要编写一个验证报告,来总结验证任务的结果。如果验证出了待测设计的问题,也应在验证报告中详细描述问题的原因。如果行覆盖率或者功能覆盖率没有达到 100%,也应在验证报告中说明原因,报告的格式应该遵循公司内部统一的规范。

4.6 - API 文档

4.6.1 - Bundle API

5 - 高级案例

基于开放验证平台完成验证的复杂案例。

5.1 - 完整果壳 Cache 验证

利用Python语言对果壳Cache进行验证,

验证报告

https://github.com/XS-MLVP/Example-NutShellCache/blob/master/nutshell_cache_report_demo.pdf

验证环境&用例代码

https://github.com/XS-MLVP/Example-NutShellCache

5.2 - TileLink 协议

基于C++驱动使用 TillLink 协议的 L2 Cache

6 - 多语言支持

开放验证平台支持多种语言

6.1 - 验证接口

DUT文件与编程语言都支持的验证接口

生成库文件

picker可以通过参数--lang指定转换的对应语言(参数已支持cpp、python、java、scala、golang),由于不同编程语言对应的“库”不同,因此生成的库文件有所区别,例如java生成的是jar包,python生成的为文件夹。picker导出对应编程语言的库,需要xcomm的支持,可以通过picker --check查看支持情况:

$picker --check
[OK ] Version: 0.9.0---dirty
[OK ] Exec path: /home/yaozhicheng/mambaforge/lib/python3.11/site-packages/picker/bin/picker
[OK ] Template path: /home/yaozhicheng/mambaforge/lib/python3.11/site-packages/picker/share/picker/template
[OK ] Support    Cpp (find: '/home/yaozhicheng/mambaforge/lib/python3.11/site-packages/picker/share/picker/include' success)
[Err] Support   Java (find: 'java/xspcomm-java.jar' fail)
[Err] Support  Scala (find: 'scala/xspcomm-scala.jar' fail)
[OK ] Support Python (find: '/home/yaozhicheng/mambaforge/lib/python3.11/site-packages/picker/share/picker/python' success)
[Err] Support Golang (find: 'golang' fail)

输出显示success表示支持,fail表示不支持。

C++

对于C++语言,picker生成的为so动态链接库,和对应的头文件。例如:

UT_Adder/
├── UT_Adder.cpp       # DUT 文件
├── UT_Adder.hpp       # DUT 头文件
├── UT_Adder_dpi.hpp   # DPI 头文件
├── dut_base.hpp       # DUT base 头文件
├── libDPIAdder.a      # DPI 静态库
└── libUTAdder.so      # DUT 动态库

在使用时,设置好LD路径,然后再测试代码中#include UT_Adder.hpp

Python

Python语言生成的为目录(Python module以目录的方式表示)

UT_Adder/
├── _UT_Adder.so
├── __init__.py
├── libUTAdder.so
└── libUT_Adder.py

设置PYTHONPATH后,可以在test中import UT_Adder

Java/scala

对于Java和scala基于JVM的编程语言,picker生成的为对应的jar包。

UT_Adder/
├── UT_Adder-scala.jar
└── UT_Adder-java.jar

go

go语言生成的为目录(类似python)。

UT_Adder/
└── golang
    └── src
        └── UT_Adder
            ├── UT_Adder.go
            ├── UT_Adder.so
            ├── UT_Adder_Wrapper.go
            ├── go.mod
            └── libUTAdder.so

设置GOPATH后,可直接进行import

验证接口

DUT验证接口可以参考连接:https://github.com/XS-MLVP/picker/blob/master/doc/API.zh.md

xspcomm库接口请参考连接:https://github.com/XS-MLVP/xcomm/blob/master/docs/APIs.cn.md

6.2 - 验证案例

多语言案例介绍

本页将展示使用多种语言验证的各种案例。

6.2.1 - 加法器

使用C++、Java、Python和Golang验证加法器的案例

以Adder为例,各语言的验证代码和注释如下:

#include "UT_Adder.hpp"

int64_t random_int64()
{
    static std::random_device rd;
    static std::mt19937_64 generator(rd());
    static std::uniform_int_distribution distribution(INT64_MIN,
                                                               INT64_MAX);
    return distribution(generator);
}

int main()
{
    UTAdder *dut = new UTAdder();
    dut->Step(1);
    printf("Initialized UTAdder\n");

    struct input_t {
        uint64_t a;
        uint64_t b;
        uint64_t cin;
    };

    struct output_t {
        uint64_t sum;
        uint64_t cout;
    };

    for (int c = 0; c < 114514; c++) {
        input_t i;
        output_t o_dut, o_ref;

        i.a   = random_int64();
        i.b   = random_int64();
        i.cin = random_int64() & 1;

        auto dut_cal = [&]() {
            dut->a   = i.a;
            dut->b   = i.b;
            dut->cin = i.cin;
            dut->Step(1);
            o_dut.sum  = (uint64_t)dut->sum;
            o_dut.cout = (uint64_t)dut->cout;
        };

        auto ref_cal = [&]() {
            uint64_t sum = i.a + i.b;
            bool carry   = sum < i.a;

            sum += i.cin;
            carry = carry || sum < i.cin;

            o_ref.sum  = sum;
            o_ref.cout = carry ;
        };

        dut_cal();
        ref_cal();
        printf("[cycle %lu] a=0x%lx, b=0x%lx, cin=0x%lx\n", dut->xclock.clk, i.a,
               i.b, i.cin);
        printf("DUT: sum=0x%lx, cout=0x%lx\n", o_dut.sum, o_dut.cout);
        printf("REF: sum=0x%lx, cout=0x%lx\n", o_ref.sum, o_ref.cout);
        Assert(o_dut.sum == o_ref.sum, "sum mismatch");
    }

    dut->Finish();
    printf("Test Passed, destory UTAdder\n");
    return 0;
}

6.2.2 - CoupledL2

用C++、Java和Python简单验证香山L2 Cache的案例

CoupledL2是一个非阻塞的L2 Cache

下面的代码会对CoupledL2进行简单的验证,并使用数组作为参考模型,验证过程如下:

  1. 生成随机的地址addr、执行AcquireBlock,请求读取addr的数据。
  2. 执行GrantData,接收DUT响应的数据。
  3. 把接收到的数据和参考模型的内容进行比较,验证行为是否一致。
  4. 执行GrantAck,响应DUT
  5. 执行ReleaseData,向DUT请求在addr写入随机数据data
  6. 同步参考模型,把addr的数据更新为data
  7. 执行ReleaseAck,接收DUT的写入响应。

上述步骤会重复4000次。

验证代码:

#include "UT_CoupledL2.hpp"
using TLDataArray = std::array;

enum class OpcodeA : uint32_t {
  PutFullData = 0x0,
  PutPartialData = 0x1,
  ArithmeticData = 0x2,
  LogicalData = 0x3,
  Get = 0x4,
  Hint = 0x5,
  AcquireBlock = 0x6,
  AcquirePerm = 0x7,
};

enum class OpcodeB : uint32_t { ProbeBlock = 0x6, ProbePerm = 0x7 };

enum class OpcodeC : uint32_t { ProbeAck = 0x4, ProbeAckData = 0x5, Release = 0x6, ReleaseData = 0x7 };

enum class OpcodeD : uint32_t { AccessAck, AccessAckData, HintAck, Grant = 0x4, GrantData = 0x5, ReleaseAck = 0x6 };

enum class OpcodeE : uint32_t { GrantAck = 0x4 };

constexpr std::initializer_list ARGS = {"+verilator+rand+reset+0"};
auto dut = UTCoupledL2(ARGS);
auto &clk = dut.xclock;

void sendA(OpcodeA opcode, uint32_t size, uint32_t address) {
  const auto &valid = dut.master_port_0_0_a_valid;
  const auto &ready = dut.master_port_0_0_a_ready;
  while (ready.value == 0x0) clk.Step();
  valid.value = 1;
  dut.master_port_0_0_a_bits_opcode.value = opcode;
  dut.master_port_0_0_a_bits_size.value = size;
  dut.master_port_0_0_a_bits_address.value = address;
  clk.Step();
  valid.value = 0;
}

void getB() {
  assert(false);
  const auto &valid = dut.master_port_0_0_b_valid;
  const auto &ready = dut.master_port_0_0_b_ready;
  ready.value = 1;
  while (valid.value == 0)
    clk.Step();
  dut.master_port_0_0_b_bits_opcode = 0x0;
  dut.master_port_0_0_b_bits_param = 0x0;
  dut.master_port_0_0_b_bits_size = 0x0;
  dut.master_port_0_0_b_bits_source = 0x0;
  dut.master_port_0_0_b_bits_address = 0x0;
  dut.master_port_0_0_b_bits_mask = 0x0;
  dut.master_port_0_0_b_bits_data = 0x0;
  dut.master_port_0_0_b_bits_corrupt = 0x0;
  clk.Step();
  ready.value = 0;
}

void sendC(OpcodeC opcode, uint32_t size, uint32_t address, uint64_t data) {
  const auto &valid = dut.master_port_0_0_c_valid;
  const auto &ready = dut.master_port_0_0_c_ready;

  while (ready.value == 0) clk.Step();
  valid.value = 1;
  dut.master_port_0_0_c_bits_opcode.value = opcode;
  dut.master_port_0_0_c_bits_size.value = size;
  dut.master_port_0_0_c_bits_address.value = address;
  dut.master_port_0_0_c_bits_data.value = data;
  clk.Step();
  valid.value = 0;
}

void getD() {
  const auto &valid = dut.master_port_0_0_d_valid;
  const auto &ready = dut.master_port_0_0_d_ready;
  ready.value = 1;
  clk.Step();
  while (valid.value == 0) clk.Step();
  ready.value = 0;
}

void sendE(uint32_t sink) {
  const auto &valid = dut.master_port_0_0_e_valid;
  const auto &ready = dut.master_port_0_0_e_ready;
  while (ready.value == 0) clk.Step();
  valid.value = 1;
  dut.master_port_0_0_e_bits_sink.value = sink;
  clk.Step();
  valid.value = 0;
}

void AcquireBlock(uint32_t address) { sendA(OpcodeA::AcquireBlock, 0x6, address); }

void GrantData(TLDataArray &r_data) {
  const auto &opcode = dut.master_port_0_0_d_bits_opcode;
  const auto &data = dut.master_port_0_0_d_bits_data;

  for (int i = 0; i < 2; i++) {
    do { getD(); } while (opcode.value != OpcodeD::GrantData);
    r_data[i] = data.value;
  }
}

void GrantAck(uint32_t sink) { sendE(sink); }

void ReleaseData(uint32_t address, const TLDataArray &data) {
  for (int i = 0; i < 2; i++)
    sendC(OpcodeC::ReleaseData, 0x6, address, data[i]);
}

void ReleaseAck() {
  const auto &opcode = dut.master_port_0_0_d_bits_opcode;
  do { getD(); } while (opcode.value != OpcodeD::ReleaseAck);
}

int main() {
  TLDataArray ref_data[16] = {};
  /* Random generator */
  std::random_device rd;
  std::mt19937_64 gen_rand(rd());
  std::uniform_int_distribution distrib(0, 0xf - 1);

  /* DUT init */
  dut.InitClock("clock");
  dut.reset.SetWriteMode(xspcomm::WriteMode::Imme);
  dut.reset.value = 1;
  clk.Step();
  dut.reset.value = 0;
  for (int i = 0; i < 100; i++) clk.Step();

  /* Test loop */
  for (int test_loop = 0; test_loop < 4000; test_loop++) {
    uint32_t d_sink;
    TLDataArray data{}, r_data{};
    /* Generate random */
    const auto address = distrib(gen_rand) << 6;
    for (auto &i : data)
      i = gen_rand();

    printf("[CoupledL2 Test\t%d]: At address(0x%03x), ", test_loop + 1, address);
    /* Read */
    AcquireBlock(address);
    GrantData(r_data);

    // Print read result
    printf("Read: ");
    for (const auto &x : r_data)
      printf("%08lx", x);

    d_sink = dut.master_port_0_0_d_bits_sink.value;
    assert ((r_data == ref_data[address >> 6]) && "Read Failed");
    GrantAck(d_sink);

    /* Write */
    ReleaseData(address, data);
    ref_data[address >> 6] = data;
    ReleaseAck();

    // Print write data
    printf(", Write: ");
    for (const auto &x : data)
      printf("%08lx", x);
    printf(".\n");
  }

  return 0;
}