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

返回本页常规视图.

学习资源

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

学习本材料前,假定您已经拥有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的.Finish()方法以通知模拟器任务已完成,进而将文件flush到磁盘。

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

from 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.Finish() # 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.Finish() 之后会生成覆盖率数据库文件 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 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.Finish()
        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

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

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

Toffee Documentation 中查看 Toffee 的详细使用说明。

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;
}