本学习资源介绍验证相关的基本概念、技术,以及如何使用本项目提供的开源工具进行芯片验证
学习本材料前,假定您已经拥有linux、python等相关基础知识。
相关学习材料:
若计划参与本平台上发布的“开源开放验证项目”,建议提前完本材料的学习。
本学习资源介绍验证相关的基本概念、技术,以及如何使用本项目提供的开源工具进行芯片验证
学习本材料前,假定您已经拥有linux、python等相关基础知识。
相关学习材料:
若计划参与本平台上发布的“开源开放验证项目”,建议提前完本材料的学习。
在开始前本页会 简单的介绍什么是验证,以及示例里面用到的概念,如 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 上稳定运行。
请注意,请确保
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
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
参数。file
: 必需。待解析的UVM transaction文件--example, -e
: 可选。根据UVM的transaction生成示例项目。--force, -c
: 可选。若已存在picker根据当前transaction解析出的文件,通过该命令可强制删除该文件,并重新生成--rename, -r
: 可选。配置生成文件以及生成的agent的名称,默认为transaction名。编译完成后,在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/
在本案例中,我们驱动一个 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 源码。
进入 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目录作为目标构建目录。
该命令的含义是:
在使用该命令时,还有部分命令行参数没有使用,这些命令将在后续的章节中介绍。
输出的目录结构如下,请注意这部分均为中间文件,不能直接使用:
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
定义的自动编译过程流如下:
- 通过
cmake/*.cmake
定义的仿真器调用脚本,编译Adder_top.sv
及相关文件为libDPIAdder.so
动态库。- 通过
CMakelists.txt
定义的编译脚本,将libDPIAdder.so
通过dut_base.cpp
封装为libUTAdder.so
动态库。并将1、2步产物拷贝到UT_Adder
目录下。- 通过
dut_base.hpp
及dut.hpp
等头文件,利用SWIG
工具生成封装层,并最终在UT_Adder
这一目录中构建一个 Python Module。- 如果有
-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
在本案例中,我们驱动一个随机数生成器,其源码如下:
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 的更新规则为:
在测试过程中,我们将创建一个名为 RandomGenerator 的文件夹,其中包含一个 RandomGenerator.v 文件。该文件内容即为上述的 RTL 源码。
进入 RandomGenerator 文件夹,执行如下命令:
picker export --autobuild=false RandomGenerator.v -w RandomGenerator.fst --sname RandomGenerator --tdir picker_out_rmg/ --lang python -e --sim verilator
该命令的含义是:
输出的目录类似加法器验证-生成中间文件,这里不再赘述。
进入 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
双端口栈是一种数据结构,支持两个端口同时进行操作。与传统单端口栈相比,双端口栈允许同时进行数据的读写操作,在例如多线程并发读写等场景下,双端口栈能够提供更好的性能。本例中,我们提供了一个简易的双端口栈实现,其源码如下:
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_valid
输入数据有效信号in_ready
输入数据准备好信号in_data
输入数据in_cmd
输入命令 (0:PUSH, 1:POP)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输出即可。
但双端口栈却不同,它的两个端口是两个独立的执行逻辑,在驱动中,这两个端口可能处于完全不同的状态,例如端口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
操作。
在 PUSH
或 POP
请求生效时,会调用同一个 StackModel
中的 commit_push
或 commit_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
:等待响应端口返回数据
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
...
在输出中,你可以看到每次 PUSH
和 POP
操作的数据,以及每次 POP
操作的结果。如果输出中没有错误信息,则表示测试通过。
通过使用回调函数,我们能够完成对 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_req
和 receive_resp
函数的实现逻辑类似,只需要将对应的输入输出信号设置为对应的值,然后等待对应的信号变为有效即可,可以完全根据端口的执行顺序来编写。
类似地,我们使用 StackModel
类来模拟栈的行为,在 commit_push
和 commit_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
...
在输出中,你可以看到每次 PUSH
和 POP
操作的数据,以及每次 POP
操作的结果。如果输出中没有错误信息,则表示测试通过。
通过协程函数,我们可以很好地实现并行操作,同时避免了回调函数所带来的问题。每个独立的执行流都能被完整保留,实现为一个协程,大大方便了代码的编写。
然而,在更为复杂的场景下你会发现,实现了众多协程,会使得协程之间的同步和时序管理变得复杂。尤其是你需要在两个不与DUT直接交互的协程之间进行同步时,这种现象会尤为明显。
在这时候,你就需要一套协程编写的规范以及验证代码的设计模式,来帮助你更好地编写基于协程的验证代码。因此,我们提供了 toffee 库,它提供了一套基于协程的验证代码设计模式,你可以通过使用 toffee 来更好地编写验证代码,你可以在这里去进一步了解 toffee。
在开始前本页会 简单的介绍什么是验证,以及示例里面用到的概念,如 DUT (Design Under Test) 和 RM (Reference Model) 。
为满足开放验证的环境要求,我们开发了 Picker 工具,用于将 RTL 设计转换为多语言接口,并在此基础上进行验证,我们将会使用 Picker 工具生成的环境作为基础的验证环境。接下来我们将介绍 Picker 工具,及其基础的使用方法。
picker 是一个芯片验证辅助工具,具有两个主要功能:
基于 Picker 进行验证的优点:
Picker 目前支持的 RTL 仿真器:
Picker的工作原理
Picker
的主要功能就是将Verilog
代码转换为C++或者Python代码,以Chisel开发的处理器为例:先通过Chisel自带的工具将其转换为Verilog代码,再通Picker提供高级编程语言接口。
Picker 导出 Python Module 的方式是基于 C++ 的。
Swig 是一个用于将 C/C++ 导出为其他高级语言的工具。该工具会解析 C++ 头文件,并生成对应的中间代码。 如果希望详细了解生成过程,请参阅 Swig 官方文档。 如果希望知道 Picker 如何生成 C++ Class,请参阅 C++。
--language python
或 --lang python
用于指定生成Python基础库。--example, -e
用于生成包含示例项目的可执行文件。--verbose, -v
用于保留生成项目时的中间文件。以案例一中的简单加法器为例:
picker_out_adder
├── Adder # Picker 工具生成的项目
│ ├── _UT_Adder.so
│ ├── __init__.py
│ ├── libUTAdder.so
│ ├── libUT_Adder.py
│ └── signals.json
└── example.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() # 销毁电路
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的值
对应伪代码如下:
# 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类型的xclock和XPort类型的xport。
class DUTAdder(object):
xport: XPort # 成员变量 xport,用于管理DUT中的所有引脚
xclock: XClock # 成员变量 xclock,用于管理时钟
# DUT 引脚
a: XData
b: XData
cin: XData
cout: 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=x
和 x=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() 等价
初始化与添加引脚
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
初始化与添加引脚
# 初始化
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个时钟,用于最外层
在使用 Picker 工具封装 DUT 时,使用选项-w [wave_file]
指定需要保存的波形文件。
针对不同的后端仿真器,支持不同的波形文件类型,具体如下:
需要注意的是,如果你选择自行生成 libDPI_____.so
文件,那么波形文件格式不受上述约束的限制。因为波形文件是在仿真器构建 libDPI.so
时决定的,如果你自行生成,那么波形文件格式也需要自行用对应仿真器的配置指定。
正常情况下,dut需要被显式地声明完成任务,以通知进行模拟器的后处理工作(写入波形、覆盖率等文件)。
在Python中,需要在完成所有测试后,调用dut的.finalize()
方法以通知模拟器任务已完成,进而将文件flush到磁盘。
以加法器为例,以下为测试程序:
from UT_Adder import *
if __name__ == "__main__":
dut = DUTAdder()
for i in range(10):
dut.a.value = i * 2
dut.b.value = int(i / 4)
dut.Step(1)
print(dut.sum.value, dut.cout.value)
dut.finalize() # flush the wave file to disk
运行结束后即可生成指定文件名的波形文件。
使用 GTKWave 打开 fst
或 vcd
波形文件,即可查看波形图。
使用 Verdi 打开 fsdb
或 vcd
波形文件,即可查看波形图。
在许多情况中,某文件下的某个模块会例化其他文件下的模块,在这种情况下您可以使用Picker工具的-f
选项处理多个verilog源文件。例如,假设您有Cache.sv
, CacheStage.sv
以及CacheMeta.sv
三个源文件:
// In
module Cache(
...
);
CacheStage s1(
...
);
CacheStage s2(
...
);
CacheStage s3(
...
);
CacheMeta cachemeta(
...
);
endmodule
// In CacheStage.sv
module CacheStage(
...
);
...
endmodule
// 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
Cache.sv
。Picker 工具支持生成代码行覆盖率报告,toffee-test(https://github.com/XS-MLVP/toffee-test) 项目支持生成功能覆盖率报告。
目前 Picker 工具支持基于 Verilator 仿真器生成的代码行覆盖率报告。
Verilator 仿真器提供了覆盖率支持功能。
该功能的实现方式是:
verilator_coverage
工具处理或合并覆盖率数据库,最终针对多个 DUT 生成一个 coverage.info
文件。lcov
工具的 genhtml
命令基于coverage.info
和 RTL 代码源文件,生成完整的代码覆盖率报告。使用时的流程如下:
-c
选项)。dut.finalize()
之后会生成覆盖率数据库文件 V{DUT_NAME}.dat
。verilator_coverage
的 write-info 功能将其转换成 .info
文件。lcov
的 genhtml
功能,使用.info
文件和文件中指定的rtl 源文件,生成 html 报告。注意: 文件中指定的rtl 源文件是指在生成的
DUT
时使用的源文件路径,需要保证这些路径在当前环境下是有效的。简单来说,需要编译时用到的所有.sv/.v
文件都需要在当前环境下存在,并且目录不变。
verilator_coverage
工具用于处理 DUT
运行后生成的 .dat
的覆盖率数据。该工具可以处理并合并多个 .dat
文件,同时具有两类功能:
基于 .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
可以将 .dat
文件,结合源代码文件,将覆盖率数据以标注的形式与源代码结合在一起,并写入到指定目录。
-write <merged-datafile> -read <datafiles>
:将若干个.dat(datafiles
)文件合并为一个.dat 文件-write-info <merged-info> -read <datafiles>
:将若干个.dat(datafiles
)文件合并为一个.info 文件由 locv
包提供的 genhtml
可以由上述的.info 文件导出可读性更好的 html 报告。命令格式为:genhtml [OPTIONS] <infofiles>
。
建议使用-o <outputdir>
选项将结果输出到指定目录。
以加法器为例。
如果您使用 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 对应的文档正在完善当中。
部分电路有多个时钟,XClock 类提供了分频功能,可以通过它实现对多时钟电路的驱动。
XClock 函数提供如下分频接口
void XClock::FreqDivWith(int div, // 分频数,,即绑定的XClock的频率为原时钟频率的div分之1
XClock &clk, // 绑定的XClock
int shift=0) // 对波形进行 shift 个半周期的移位
# 假设已经创建了DUT,并将其命名为dut
# 创建XClock
xclock = XClock(dut.dut.simStep)
# clk是dut的时钟引脚
xclock.Add(dut.clk)
# Add方法具有别名:AddPin
因为在我们的工具中,对于端口的读写是通过 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()
上述代码输出的波形如下:
可以看到:
在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()
内部信号指的是不在模块的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即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动态获取方法。
TBD
优点:动态获取,能读能写 缺点:速度慢,请谨慎使用
在芯片验证的传统实践中,UVM等框架被广泛采用。尽管它们提供了一整套验证方法,但通常只适用于特定的硬件描述语言和仿真环境。本工具突破了这些限制,能够将仿真代码转换成C++或Python,使得我们可以利用软件验证工具来进行更全面的测试。
因为Python具有强大的生态系统,所以本项目主要以Python作为示例,简单介绍Pytest和Hypothesis两个经典软件测试框架。Pytest以其简洁的语法和丰富的功能,轻松应对各种测试需求。而Hypothesis则通过生成测试用例,揭示出意料之外的边缘情况,提高了测试的全面性和深度。
我们的项目从一开始就设计为与多种现代软件测试框架兼容。我们鼓励您探索这些工具的潜力,并将其应用于您的测试流程中。通过亲身实践,您将更深刻地理解这些工具如何提升代码的质量和可靠性。让我们一起努力,提高芯片开发的质量。
在正式开始pytest 之间我们先了解一下软件的测试,软件测试一般分为如下四个方面
- 单元测试:称模块测试,针对软件设计中的最小单位——程序模块,进行正确性检查的测试工作
- 集成测试:称组装测试,通常在单元测试的基础上,将所有程序模块进行有序的、递增测试,重点测试不同模块的接口部分
- 系统测试:将整个软件系统看成一个整体进行测试,包括对功能、性能以及软件所运行的软硬件环境进行测试
- 验收测试:指按照项目任务书或合同、供需双方约定的验收依据文档进行的对整个系统的测试与评审,决定是否接收或拒收系统
pytest最初是作为一个单元测试框架而设计的,但它也提供了许多功能,使其能够进行更广泛的测试,包括集成测试,系统测试,他是一个非常成熟的全功能的python 测试框架。 它通过收集测试函数和模块,并提供丰富的断言库来简化测试的编写和运行,是一个非常成熟且功能强大的 Python 测试框架,具有以下几个特点:
pytest-selenium
:集成 Selenium。pytest-html
:生成HTML测试报告。pytest-rerunfailures
:在失败的情况下重复执行测试用例。pytest-xdist
:支持多 CPU 分发。本文将基于测试需求简单介绍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 时我们的模块名通常是以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支持很多参数,可以通过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 .
# 执行跟当前文件夹同级的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 还支持控制测试用例执行的多种方式,例如过滤执行、多进程运行、重试运行等。
# 导入测试模块和所需的库
from UT_Adder import *
import pytest
import ctypes
import random
# 使用 pytest fixture 来初始化和清理资源
@pytest.fixture
def adder():
# 创建 DUTAdder 实例,加载动态链接库
dut = DUTAdder()
# 执行一次时钟步进,准备 DUT
dut.Step(1)
# yield 语句之后的代码会在测试结束后执行,用于清理资源
yield dut
# 清理DUT资源,并生成测试覆盖率报告和波形
dut.Finish()
class TestFullAdder:
# 将 full_adder 定义为静态方法,因为它不依赖于类实例
@staticmethod
def full_adder(a, b, cin):
cin = cin & 0b1
Sum = ctypes.c_uint64(a).value
Sum += ctypes.c_uint64(b).value + cin
Cout = (Sum >> 64) & 0b1
Sum &= 0xffffffffffffffff
return Sum, Cout
# 使用 pytest.mark.usefixtures 装饰器指定使用的 fixture
@pytest.mark.usefixtures("adder")
# 定义测试方法,adder 参数由 pytest 通过 fixture 注入
def test_adder(self, adder):
# 进行多次随机测试
for _ in range(114514):
# 随机生成 64 位的 a 和 b,以及 1 位的进位 cin
a = random.getrandbits(64)
b = random.getrandbits(64)
cin = random.getrandbits(1)
# 设置 DUT 的输入
adder.a.value = a
adder.b.value = b
adder.cin.value = cin
# 执行一次时钟步进
adder.Step(1)
# 使用静态方法计算预期结果
sum, cout = self.full_adder(a, b, cin)
# 断言 DUT 的输出与预期结果相同
assert sum == adder.sum.value
assert cout == adder.cout.value
if __name__ == "__main__":
pytest.main(['-v', 'test_adder.py::TestFullAdder'])
collected 1 item
test_adder.py ✓ 100% ██████████
Results (4.33s):
测试成功表明,在经过114514次循环之后,我们的设备暂时没有发现bug。然而,使用多次循环的随机数生成测试用例会消耗大量资源,并且这些随机生成的测试用例可能无法有效覆盖所有边界条件。在下一部分,我们将介绍一种更有效的测试用例生成方法。
在上一节中,我们通过手动编写测试用例,并为每个用例指定输入和预期输出。这种方式存在一些问题,例如测试用例覆盖不全面、边界条件 容易被忽略等。它是一个用于属性基于断言的软件测试的 Python 库。Hypothesis 的主要目标是使测试更简单、更快速、更可靠。它使用了一种称为属性基于断言的测试方法,即你可以为你的代码编写一些假(hypotheses),然后 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 == addition(1)
其中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
可以看到在很短的时间里我们已经完成了测试
介绍芯片验证,以果壳 Cache 为例,介绍基本的验证流程、报告撰写。
本页简单介绍什么是芯片验证,以及示例里面用到的概念,如 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核”作为基础,从中摘取模块持续更新验证案例。
组织芯片设计分包验证: 学以致用是每个人学习的期望目标,为此本项目定期组织芯片设计的验证分包,让所有人(无论你是大学生、验证专家、软件开发测试者、还是中学生)都可以参与到真实芯片的设计工作中去。
本项目的目标是达到如下愿景,“打开传统验证模式的黑盒,让所有感兴趣的人可以随时随地的,用自己擅长的编程语言参与芯片验证”。
本页将介绍数字电路的基础知识。数字电路是利用数字信号的电子电路。近年来,绝大多数的计算机都是基于数字电路实现的。
数字电路是一种利用两种不连续的电位来表示信息的电子电路。在数字电路中,通常使用两个电源电压,分别表示高电平(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) 信号完整性: 学习数字电路有助于理解信号在电路中的传播和完整性问题。在芯片验证中,确保信号在不同条件下的正常传递是至关重要的,特别是在高速设计中。
整体而言,学习数字电路为芯片验证提供了基础知识和工具,使验证工程师能够更好地理解设计,编写有效的测试用例,分析验证结果,并解决可能出现的问题。数字电路的理论和实践经验对于芯片验证工程师来说都是不可或缺的。
可以通过以下在线资源进行数字电路学习:
硬件描述语言(Hardware Description Language,简称 HDL)是一种用于描述数字电路、系统和硬件的语言。它允许工程师通过编写文本文件来描述硬件的结构、功能和行为,从而实现对硬件设计的抽象和建模。
HDL 通常被用于设计和仿真数字电路,如处理器、存储器、控制器等。它提供了一种形式化的方法来描述硬件电路的行为和结构,使得设计工程师可以更方便地进行硬件设计、验证和仿真。
常见的硬件描述语言包括:
Chisel 是一种现代化高级的硬件描述语言,与传统的 Verilog 和 VHDL 不同,它是基于 Scala 编程语言的硬件构建语言。Chisel 提供了一种更加现代化和灵活的方法来描述硬件,通过利用 Scala 的特性,可以轻松地实现参数化、抽象化和复用,同时保持硬件级别的效率和性能。
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
以果壳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是基于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(Nutshell Cache)是果壳处理器中使用的缓存模块。其采用三级流水设计,当第三级流水检出当前请求为MMIO或者发生重填(refill)时,会阻塞流水线。同时,果壳cache采用可定制的模块化设计,通过改变参数可以生成存储空间大小不同的一级cache(L1 Cache)或者二级cache(L2 Cache)。此外,果壳cache留有一致性(coherence)接口,可以处理一致性相关的请求。
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
在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
)
}
创建好配置信息后,按照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)))
))
}
完成上述所有文件的创建后(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的执行速度变慢,因此在通过picker工具生成python模块时会根据多种配置进行生成:(1)关闭所有debug信息;(2)开启波形;(3)开启代码行覆盖率。其中第一种配置的目标是快速构建环境,进行回归测试等;第二种配置用于分析具体错误,时序等;第三种用于提升覆盖率。
本节介绍基于Picker验证DUT的一般流程
开放验证平台的目标是功能性验证,其一般有以下步骤:
通常来说,同时交付给验证工程师的还有DUT的设计文档。此时您需要阅读文档或者源代码,了解验证对象的基本功能、主体结构以及预期功能。
充分了解设计之后,您需要构建验证的基本环境。例如,除了由Picker生成的DUT外,您可能还需要搭建用于比对的参考模型,也可能需要为后续功能点的评测搭建信号的监听平台。
在正式开始验证之前,您还需要提取功能点,并将其进一步分解成测试点。提取和分解方法可以参考:CSDN:芯片验证系列——Testpoints分解
有了测试点之后,您需要构造测试用例来覆盖相应的测试点。一个用例可能覆盖多个测试点。
运行完所有的测试用例之后,您需要汇总所有的测试结果。一般来说包括代码行覆盖率以及功能覆盖率。前者可以通过Picker工具提供的覆盖率功能获得,后者则需要您通过监听DUT的行为判断某功能是否被用例覆盖到。
最后您需要评估得到的结果,如是否存在错误的设计、某功能是否无法被触发、设计文档表述是否与DUT行为一致、设计文档是否表述清晰等。
接下来我们以果壳Cache的MMIO读写为例,介绍一般验证流程:
1 确定验证对象和目标:
果壳Cache的MMIO读写功能。MMIO是一类特殊的IO映射,其支持通过访问内存地址的方式访问IO设备寄存器。由于IO设备的寄存器状态是随时可能改变的,因此不适合将其缓存在cache中。当收到MMIO请求时,果壳cache不会在普通的cache行中查询命中/缺失情况,而是会直接访问MMIO的内存区域来读取或者写入数据。
2 构建基本验证环境:
我们可以将验证环境大致分为五个部分:
1. Testcase Driver:负责由用例产生相应的信号驱动
2. Monitor:监听信号,判断功能是否被覆盖以及功能是否正确
3. Ref Cache:一个简单的参考模型
4. Memory/MMIO Ram:外围设备的模拟,用于模拟相应cache的请求
5. Nutshell Cache Dut:由Picker生成的DUT
此外,您可能还需要对DUT的接口做进一步封装以实现更方便的读写请求操作,具体可以参考Nutshll cachewrapper。
3 功能点与测试点分解:
果壳cache可以响应MMIO请求,进一步分解可以得到一下测试点:
测试点1:MMIO请求会被转发到MMIO端口上
测试点2:cache响应MMIO请求时,不会发出突发传输(Burst Transfer)的请求
测试点3:cache响应MMIO请求时,会阻塞流水线
4 构造测试用例:
测试用例的构造是简单的,已知通过创建DUT得到的Nutshell cache的MMIO地址范围是0x30000000
~0x7fffffff
,则我们只需访问这段内存区间,应当就能获得MMIO的预期结果。需要注意的是,为了触发阻塞流水线的测试点,您可能需要连续地发起请求。
以下是一个简单的测试用例:
# import CacheWrapper here
def mmio_test(cache: CacheWrapper):
mmio_lb = 0x30000000
mmio_rb = 0x30001000
print("\n[MMIO Test]: Start MMIO Serial Test")
for addr in range(mmio_lb, mmio_rb, 16):
addr &= ~(0xf)
addr1 = addr
addr2 = addr + 4
addr3 = addr + 8
cache.trigger_read_req(addr1)
cache.trigger_read_req(addr2)
cache.trigger_read_req(addr3)
cache.recv()
cache.recv()
cache.recv()
print("[MMIO Test]: Finish MMIO Serial Test")
5 收集测试结果:
'''
In tb_cache.py
'''
# import packages here
class TestCache():
def setup_class(self):
color.print_blue("\nCache Test Start")
self.dut = DUTCache("libDPICache.so")
self.dut.init_clock("clock")
# Init here
# ...
self.testlist = ["mmio_serial"]
def teardown_class(self):
self.dut.finalize()
color.print_blue("\nCache Test End")
def __reset(self):
# Reset cache and devices
# MMIO Test
def test_mmio(self):
if ("mmio_serial" in self.testlist):
# Run test
from ..test.test_mmio import mmio_test
mmio_test(self.cache, self.ref_cache)
else:
print("\nmmio test is not included")
def run(self):
self.setup_class()
# test
self.test_mmio()
self.teardown_class()
pass
if __name__ == "__main__":
tb = TestCache()
tb.run()
运行:
python3 tb_cache.py
以上仅为大致的运行流程,具体可以参考:Nutshell Cache Verify。
6 评估运行结果
运行结束之后可以得到以下数据:
行覆盖率:
功能覆盖率:
可以看到预设的MMIO功能均被覆盖且被正确触发。
在我们完成DUT验证后,编写验证报告是至关重要的一环。本节将从整体角度概述验证报告的结构以及报告所需覆盖的内容。
验证报告是对整个验证过程的回顾,是验证合理与否的重要支持文件。一般情况下,验证报告需要包含以下内容:
- 文档基本信息(作者、日志、版本等)
- 验证对象(验证目标)
- 功能点介绍
- 验证方案
- 测试点分解
- 测试用例
- 测试环境
- 结果分析
- 缺陷分析
- 测试结论
以下内容对列表进行进一步解释,具体示例可以参考nutshell_cache_report_demo.pdf
应当包括作者、日志、版本、日期等。
需要对您的验证对象做必要的介绍,可以包括其结构、基本功能、接口信息等。
通过阅读设计文档或者源码,您需要总结DUT的目标功能,并将其细化为各功能点。
应当包括您计划的验证流程以及验证框架。同时,您也应当接受您的框架各部分是如何协同工作的。
针对功能点提出的测试方法。具体可以包括在怎样的信号输入下应当观测到怎样的信号输出。
测试点的具体实现。一个测试用例可以包括多个测试点。
包括硬件信息、软件版本信息等。
结果分析一般指覆盖率分析。通常来说应当考虑两类覆盖率:
1. 行覆盖率: 在测试用例中有多少RTL行代码被执行。一般来说我们要求行覆盖率在98%以上。
2. 功能覆盖率:根据相应的信号判断您提取的功能点是否被覆盖且被正确触发。一般我们要求测试用例覆盖每个功能点。
对DUT存在的缺陷进行分析。可以包括设计文档的规范性和详细性、DUT功能的正确性(是否存在bug)、DUT功能是否能被触发等方面。
验证结论是在完成芯片验证过程后得出的最终结论,是对以上内容的总结。
Toffee 是使用 Python 语言编写的一套硬件验证框架,它依赖于多语言转换工具 picker,该工具能够将硬件设计的 Verilog 代码转换为 Python Package,使得用户可以使用 Python 来驱动并验证硬件设计。
其吸收了部分 UVM 验证方法学,以保证验证环境的规范性和可复用性,并重新设计了整套验证环境的搭建方式,使其更符合软件领域开发者的使用习惯,从而使软件开发者可以轻易地上手硬件验证工作。
Toffee 是一款基于 Python 的硬件验证框架,旨在帮助用户更加便捷、规范地使用 Python 构建硬件验证环境。它依托于多语言转换工具 picker,该工具能够将硬件设计的 Verilog 代码转换为 Python Package,使得用户可以使用 Python 来驱动并验证硬件设计。
Toffee 需要的依赖有:
当安装好上述依赖后,可通过pip安装toffee:
pip install pytoffee
或通过以下命令安装最新版本的toffee:
pip install pytoffee@git+https://github.com/XS-MLVP/toffee@master
或通过以下方式进行本地安装:
git clone https://github.com/XS-MLVP/toffee.git
cd toffee
pip install .
Toffee-test 是一个用于为 Toffee 框架提供测试支持的 Pytest 插件,他为 toffee 框架提供了将测试用例函数标识为 toffee 的测试用例对象,使其可以被 toffee 框架识别并执行;测试用例资源的管理功能;测试报告生成功能,以便于用户编写测试用例。
通过 pip 安装 toffee-test
pip install toffee-test
或安装开发版本
pip install toffee-test@git+https://github.com/XS-MLVP/toffee-test@master
或通过源码安装
git clone https://github.com/XS-MLVP/toffee-test.git
cd toffee-test
pip install .
我们使用一个简单的加法器示例来演示 toffee 的使用方法,该示例位于 example/adder
目录下。
加法器的设计如下:
module Adder #(
parameter WIDTH = 64
) (
input [WIDTH-1:0] io_a,
input [WIDTH-1:0] io_b,
input io_cin,
output [WIDTH-1:0] io_sum,
output io_cout
);
assign {io_cout, io_sum} = io_a + io_b + io_cin;
endmodule
首先使用 picker 将其转换为 Python Package,再使用 toffee 来为其建立验证环境。安装好依赖后,可以直接在 example/adder
目录下运行以下命令来完成转换:
make dut
为了验证加法器的功能,我们使用 toffee 提供的方法来建立验证环境。
首先需要为其创建加法器接口的驱动方法,这里用到了 Bundle
来描述需要驱动的某类接口,Agent
用于编写对该接口的驱动方法。如下所示:
class AdderBundle(Bundle):
a, b, cin, sum, cout = Signals(5)
class AdderAgent(Agent):
@driver_method()
async def exec_add(self, a, b, cin):
self.bundle.a.value = a
self.bundle.b.value = b
self.bundle.cin.value = cin
await self.bundle.step()
return self.bundle.sum.value, self.bundle.cout.value
我们使用了 driver_method
装饰器来标记 Agent
中用于驱动的方法 exec_add
,该方法完成了对加法器的一次驱动操作,每当该方法被调用,其会将输入信号 a
、b
、cin
的值分别赋给加法器的输入端口,并在下一个时钟周期后读取加法器的输出信号 sum
和 cout
的值并返回。
Bundle
是该 Agent
需要驱动的接口的描述。在 Bundle
中提供了一系列的连接方法来连接到 DUT 的输入输出端口。这样一来,我们可以通过此 Agent
完成所有拥有相同接口的 DUT 的驱动操作。
为了验证加法器的功能,我们还需要为其创建一个参考模型,用于验证加法器的输出是否正确。在 toffee 中,我们使用 Model
来定义参考模型。如下所示:
class AdderModel(Model):
@driver_hook(agent_name="add_agent")
def exec_add(self, a, b, cin):
result = a + b + cin
sum = result & ((1 << 64) - 1)
cout = result >> 64
return sum, cout
在参考模型中,我们同样定义了一个 exec_add
方法,该方法与 Agent
中的 exec_add
方法含有相同的输入参数,我们用程序代码计算出了加法器的标准返回值。我们使用了 driver_hook
装饰器来标记该方法,以便该方法可以与 Agent
中的 exec_add
方法进行关联。
接下来,我们需要创建一个顶层的测试环境,将上述的驱动方法与参考模型相关联,如下所示:
class AdderEnv(Env):
def __init__(self, adder_bundle):
super().__init__()
self.add_agent = AdderAgent(adder_bundle)
self.attach(AdderModel())
此时,验证环境已经搭建完成,toffee 会自动驱动参考模型并收集结果,并将结果与加法器的输出进行比对。
之后,需要编写测试用例来验证加法器的功能,通过 toffee-test,可以使用如下方式编写测试用例。
@toffee_test.testcase
async def test_random(adder_env):
for _ in range(1000):
a = random.randint(0, 2**64 - 1)
b = random.randint(0, 2**64 - 1)
cin = random.randint(0, 1)
await adder_env.add_agent.exec_add(a, b, cin)
@toffee_test.testcase
async def test_boundary(adder_env):
for cin in [0, 1]:
for a in [0, 2**64 - 1]:
for b in [0, 2**64 - 1]:
await adder_env.add_agent.exec_add(a, b, cin)
可以直接在 example/adder
目录下运行以下命令来运行该示例:
make run
运行结束后报告将自动在reports
目录下生成。
一个验证任务编写代码的主体工作可以大致分为两部分,验证环境的搭建 和 测试用例的编写。
验证环境的搭建 旨在完成对待测设计(DUT)的封装,使得验证人员在驱动 DUT 时,不必面临繁杂的接口信号,而是可以直接使用验证环境中提供的高级接口。如果需要编写参考模型,则参考模型也应是验证环境的一部分。
测试用例的编写 则是测试人员使用验证环境提供的接口,编写一个个测试用例,对 DUT 进行功能验证。
搭建验证环境是一件相当有挑战的事情,当 DUT 极度复杂,特别是在接口信号繁多的情况下,搭建验证环境的难度会更大。此时,若没有一个统一的规范,验证环境的搭建将会变得混乱不堪,一个人编写的验证环境很难被其他人维护。并且当出现新的验证任务与原有验证任务有交集时,因为原有的验证环境缺乏规范,很难将原有的验证环境复用。
本节将会介绍一个规范的验证环境所应该具备的特性,这将有助于理解 mlvp 验证环境的搭建流程。
以一个简单的加法器为例,该加法器拥有两个输入端口 io_a
和 io_b
,一个输出端口 io_sum
。 在没有意识到验证代码可能会被用于其他验证任务的情况下,我们可能会编写出这样的驱动代码:
def exec_add(dut, a, b):
dut.io_a.value = a
dut.io_b.value = b
dut.Step(1)
return dut.io_sum.value
上述代码中,我们编写了一个 exec_add
方法,该方法本质上是对加法器加法操作的一次高层封装。拥有 exec_add
方法以后,我们无需再关心如何对加法器的接口信号进行赋值,也无需关心怎样驱动加法器并获取其输出,只需要调用 exec_add
方法即可驱动加法器完成一次加法操作。
然而,这个驱动函数却有一个很大的弊端,它直接使用了 DUT 的接口信号来与 DUT 进行交互,这也就意味着,这个驱动函数只能用于这个加法器。
与软件测试不同,在硬件验证中我们每时每刻都能碰到接口结构相同的情况。假设我们拥有另一个具有相同功能的加法器,但其接口信号名称分别是 io_a_0
、io_b_0
和 io_sum_0
,那么这个驱动函数对这个加法器则直接失效,无法复用。要想驱动,只能重新编写一个新的驱动函数。
一个加法器尚且如此,倘若我们拿到了一个拥有繁杂接口的 DUT,费尽心思为其编写了驱动代码。当后续发现驱动代码需要迁移至另一个相似结构的接口上时,我们将会面临巨大的工作量。例如出现接口名称改变、部分接口缺少但驱动代码中却有引用,部分接口新增等等一系列的问题。
出现这种问题的根本原因在于,我们在验证代码中直接对 DUT 的接口信号进行操作,如下图所示,这种做法是不可取的。
+-----------+ +-----------+
| |-->| |
| Test Code | | DUT |
| |<--| |
+-----------+ +-----------+
为了解决上述问题,我们需要将验证代码与 DUT 进行解耦,使得验证代码不再直接操作 DUT 的接口信号,而是通过一个中间层来与 DUT 进行交互。这个中间层是人为定义的一个接口结构,在 toffee 中,我们将这个中间层定义为 Bundle
,下文也将会使用 Bundle
来代指这个中间层。
以上述加法器为例,我们可以定义一个 Bundle 结构,其中包含 a
, b
和 sum
三个信号,并让测试代码与这个 Bundle 进行直接交互。
def exec_add(bundle, a, b):
bundle.a.value = a
bundle.b.value = b
bundle.Step(1)
return bundle.sum.value
此时,在 exec_add
中并没有直接操作 DUT 的接口信号,甚至不知道 DUT 中的接口信号名称是什么,其直接与我们在 Bundle 中定义的接口信号进行交互。
那如何让 Bundle 中的信号与 DUT 的引脚进行关联呢?只需要添加一个连接操作即可,最简单的连接方法是,我们直接指定 Bundle 中的每一个信号具体与 DUT 的哪一个引脚相连,例如:
bundle.a <-> dut.io_a
bundle.b <-> dut.io_b
bundle.sum <-> dut.io_sum
如果 DUT 的接口信号名称发生了变化,我们只需要修改这个连接过程,例如:
bundle.a <-> dut.io_a_0
bundle.b <-> dut.io_b_0
bundle.sum <-> dut.io_sum_0
这样一来,无论 DUT 的接口如何变化,只要其拥有相同的结构,都可以通过原有的驱动代码来驱动,需要修改的仅仅是连接过程。此时的验证代码与 DUT 的关系如下图所示:
+-----------+ +--------+ +-----------+
| |->| | | |
| Test Code | | Bundle |-- connect --| DUT |
| |<-| | | |
+-----------+ +--------+ +-----------+
在 toffee 中,我们为 Bundle
提供了简洁的定义过程以及大量的连接方法,可极大方便中间层的定义与连接,除此之外,Bundle
还提供了大量的实用功能来帮助验证人员更好的与接口信号进行交互。
我们已经知道,需要定义一个 Bundle
来完成测试代码与 DUT 之间的解耦,但是如果 DUT 的接口信号过于复杂,我们将会面临一个新的问题————可能只有这一个 DUT 能与这个 Bundle
进行连接。因为我们会定义一个含有众多信号的中间层,将整个 DUT 的引脚全部涵盖在内,这样一来,只有与整个 DUT 结构相同的 DUT 才能与这个 Bundle
进行连接,这个条件是极为苛刻的。
这样一来,中间层的设置也就失去意义了。但我们观察到,每个 DUT 的接口结构往往是有规律的,他们通常由若干个具有独立功能的接口组成。例如 这里 提到的双端口栈,它的接口则由两个结构完全相同的子接口构成。因此,相比于将整个双端口栈的接口信号全部涵盖在一个 Bundle
中,我们可以将其拆分为两个 Bundle
,每个 Bundle
分别对应一个子接口。
并且,对于双端口栈来说,两个子接口的结构是完全相同的,因此我们可以使用同一个 Bundle
来描述这两个子接口,无需重复定义。既然他们拥有同样的 Bundle,那么我们针对这个 Bundle 编写的驱动代码也是完全可以复用的!这就是验证环境可复用性的魅力。
总结一下,对于所有的 DUT 来说,我们应该将其接口信号划分成若干个独立的子接口,每个子接口拥有独立的功能,然后为每个子接口定义一个 Bundle
,并编写与这个 Bundle
相关的驱动代码。
此时,我们的验证代码与 DUT 的关系如下图所示:
+-----------+ +--------+ +-----------+
| |->| | | |
| Test Code | | Bundle |-- connect --| |
| |<-| | | |
+-----------+ +--------+ | |
| |
... ... | DUT |
| |
+-----------+ +--------+ | |
| |->| | | |
| Test Code | | Bundle |-- connect --| |
| |<-| | | |
+-----------+ +--------+ +-----------+
同时,我们搭建验证环境的思路也变得清晰起来,只需要为分别每个独立的子接口编写高层封装的操作即可。
我们为每个 Bundle 都编写了高层封装的操作,这些代码之间相互独立,拥有极高的可复用性。如果我们把不同 Bundle 高层封装之间的交互逻辑都划分出去,放到测试用例中来完成,那么多个 Test Code + Bundle
的组合将会完成对整个 DUT 的驱动环境的搭建。
我们不妨对单个 Test Code + Bundle
的组合起一个名字,在 toffee 中,该结构被称之为 Agent
。Agent
独立于 DUT,负责完成对一类接口的所有交互操作。
此时,我们的验证代码与 DUT 的关系如下图所示:
+---------+ +-----------+
| | | |
| Agent |----| |
| | | |
+---------+ | |
| |
... | DUT |
| |
+---------+ | |
| | | |
| Agent |----| |
| | | |
+---------+ +-----------+
因此编写驱动环境的过程,就是编写一个个 Agent
的过程。但至此,我们还没有讨论过编写 Agent
的具体规范,如果每个人编写的 Agent
都各不相同,那么验证环境依然会变得较为混乱。
为了探寻如何编写一个规范的 Agent
,我们首先要明白 Agent
主要完成怎样的功能。如上文所述,Agent
中实现了对一类接口的所有交互操作,并提供高层封装。
我们首先来探讨,验证代码究竟会与接口产生怎样的交互,假设验证代码具备读取输入端口的能力,我们可以按照验证代码是否主动发起交互,与接口的方向性来划分为以下几类交互。
这两类操作涵盖了验证代码侧与接口之间的的所有操作,因此 Agent
也必须具备这两类操作的能力。
我们首先考虑验证代码主动发起的两类交互,对这两类交互完成高层封装,就要求 Agent
必须具备两种能力:
能够完成这两类交互的形式有很多。但由于 toffee 是一个基于软件测试语言的验证框架,且我们希望验证代码的编写形式尽量简洁,toffee 中规范了使用 函数 为载体来完成这两类交互。
因为函数是编程语言中最基本的抽象单元,输入参数可直接作为上层语义信息并传递给函数体,函数体中通过赋值和读取操作完成上层语义信息与接口信号的转换,最后通过返回值将接口信号转换为上层语义信息并返回给发起者。
toffee 此类用于验证代码主动发起交互的函数称之为驱动方法,在 toffee 中,我们使用 driver_method
装饰器来标记此类函数。
接下来我们考虑验证代码被动接收的交互,并对此类交互完成高层封装。这类交互的呈现形式为,验证代码并不去主动发起对接口的输入输出,而是当满足特定的条件时,接口会将输出信号传递给验证代码。
例如,验证代码想要在 DUT 完成一次操作后,被动获取 DUT 的输出信号并转换为上层语义信息。再如,验证代码想在每一周期都被动获取 DUT 的输出信号并转换为上层语义信息。
与 driver_method
类似,toffee 中同样规范了使用 函数 为载体来完成这类交互,只不过这个函数没有输入参数,并且不受验证代码的主动控制。当特定条件满足时,该函数会被调用,完成对接口信号的读取操作,并转换为上层语义信息。该信息会被保存,等待验证代码的使用。
类似的,toffee 将此类用于验证代码被动接收交互的函数称之为监测方法,在 toffee 中,我们使用 monitor_method
装饰器来标记此类函数。
综上所述,我们使用 函数 作为载体来完成验证代码与接口的所有交互,并将其分为两类:驱动方法 和 监测方法。这两类方法分别完成验证代码主动发起的交互和被动接收的交互。
因此,编写 Agent
,其实就是编写一系列的驱动方法和检测方法。一个 Agent 编写好之后,也只需要提供其内部驱动方法和检测方法的列表,便能描述整个 Agent
的功能。
一个 Agent
的结构可以使用下图来描述:
+--------------------+
| Agent |
| |
| @driver_method |
| @driver_method |
| ... |
| |
| @monitor_method |
| @monitor_method |
| ... |
| |
+--------------------+
目前为止,我们完成了对 DUT 高层操作的封装,并使用函数完成了验证代码与 DUT 之间的交互。此时,为了验证 DUT 的功能是否正确,我们会编写测试用例,通过我们封装好的驱动方法来驱动起 DUT 完成特定的执行过程。与此同时,监测方法 在自动地被调用并监测收集 DUT 的相关信息。
然而如何去验证 DUT 的功能是否正确呢?
在测试用例中对 DUT 进行驱动后,能够得到 DUT 输出的信息包含两种,一种是通过驱动方法主动获取的信息,另一种是通过检测方法收集到的信息。因此,验证 DUT 的功能是否正确,实际上就是验证这两种信息是否符合预期。
那如何判断这两种信息是否符合预期呢?
一种情况是,我们本来就知道 DUT 的输出应该是什么,或是满足什么样的条件。这时我们在测试用例中拿到这两种信息后,直接对这两种信息(或是其中一种,取决于验证用例的需求)进行检查即可。
另一种情况是,我们并不知道 DUT 的输出应该是什么。此时我们只能编写一个与 DUT 功能相同的 参考模型(RM, Reference Model),当主动发送给 DUT 任何信息时,同时将这些信息主动发送给参考模型。
为了对验证两类信息是否符合预期。当主动获取 DUT 的输出信息是时,同时主动去获取参考模型中的输出信息,并将两者进行比对;当监测方法监测到 DUT 的输出信息时,同时参考模型也应主动提供输出信息,并将两者进行比对。
这便是验证 DUT 功能正确性的两类方法:直接比对 和 参考模型比对。
对于直接比对的验证方法,我们直接在测试用例中编写比对逻辑即可。而如果我们使用参考模型的比对方式,测试用例可能会面临一些比较繁琐的步骤:驱动接口的同时,需要将信息同时发送给模型;收集接口信息的同时,需要同时收集模型的信息;对于被动监测的接口信息,还要额外编写逻辑来完成与参考模型的比对。这样一来,测试用例的代码会混乱,与参考模型的交互逻辑会混杂在测试用例中,不利于代码的维护。
我们注意到,对于驱动函数的每一次调用,代表着对 DUT 的每一次操作,这些操作都需要转发给参考模型。而参考模型的编写无需考虑 DUT 接口是怎样驱动的,它只需要分析高层语义信息,并且完成自身状态更新即可,因此参考模型中只需要获取上层发来的高层语义信息(即驱动函数的输入参数)。
所以在参考模型中只需要实现当驱动函数被调用时,如何做出反应即可。对于将调用信息传递给参考模型的操作完全可以交由框架来完成。与此同时,每一次操作的返回值、监测方法的检测值比较,也可以通过框架来自动完成。
这样一来,测试用例中只需要编写驱动 DUT 的逻辑,参考模型的同步与比对工作将会被框架自动完成。
为了实现参考模型的同步,toffee 中定义了一套参考模型的匹配规范,只需要按照此规范编写参考模型调用接口,便可实现参考模型的自动转发与比较。同时为了方便参考模型与整个验证环境相关联,toffee 中提供了 Env
的概念来打包整个验证环境,写好参考模型后,只需要将其与 Env
相关联,便可实现参考模型的自动同步。
这样一来,我们的验证环境变成了如下的结构:
+--------------------------------+
| Env |
| +-----------+ | +-----------+
| +---------+ | | | | |
| | Agent |----| | |->| Reference |
| +---------+ | DUT | | | Model |
| +---------+ | | |<-| |
| | Agent |----| | | | |
| +---------+ | | | +-----------+
| ... +-----------+ |
+--------------------------------+
此时,整个验证环境的搭建变得清晰而规范,需要复用时,只需挑选合适的 Agent
,连接至 DUT,并打包到 Env
中。需要编写参考模型时,只需要根据 Env 中调用接口规范,实现参考模型的逻辑即可。
测试用例的编写与验证环境是分开的,当测试环境搭建完毕后,测试环境的接口就是每个 Agent 中的调用接口。测试用例可以清晰地使用这些接口来完成驱动逻辑的编写。参考模型的同步及对比工作也将由框架自动完成。
这是 toffee 中验证环境的搭建思想,toffee 中提供了大量的功能,来帮助你建立起这样一个规范的验证环境。同时,它提供了测试用例的管理方法,使得测试用例更易于编写和管理。
toffee和toffee-test 提供了搭建验证环境全流程所需要的方法和工具,本章中将详细介绍如何使用 toffee和toffee-test 搭建一个完整的验证环境。
在阅读前请确保您已经阅读了 如何编写规范的验证环境,并了解了 toffee 规范验证环境的基本结构。
对于一次全新的验证工作来说,按照环境搭建步骤的开始顺序,搭建验证环境可以分为以下几个步骤:
本章将会分别介绍每个步骤中如何使用 toffee和toffee-test 中的工具来完成环境搭建需求。
在如前介绍的验证环境中,设计了一套规范的验证环境。但是如果尝试用朴素单线程程序编写,会发现会遇到较为复杂的实现问题。
例如,我们创建了两个驱动方法分别驱动两个接口,在每一个驱动方法内部,都需要等待 DUT 经过若干个时钟周期,并且这两个驱动方法需要同时运行。这时候,如果使用朴素的单线程程序,会发现很难同时让两个驱动方法同时运行,即便我们使用多线程强势使他们同时运行,也会发现缺乏一种机制,使他们能够等待 DUT 经过若干时钟周期。这是因为在 Picker 提供的接口中,我们只能去推动 DUT 向前执行一个周期,而无法去等待 DUT 执行一个周期。
更不用说我们还会遇到有众多环境组件需要同时运行的情况了,因此我们首先需要一个能够运行异步程序的环境。toffee 使用了 Python 的协程来完成对异步程序的管理,其在单线程之上建立了一个事件循环,用于管理多个同时运行的协程,协程之间可以相互等待并通过事件循环来进行切换。
在启动事件循环之前,我们首先需要了解两个关键字 async
和 await
来了解 Python 对与协程的管理。
当我们在函数前加上 async
关键字时,这个函数就变成了一个协程函数,例如
async def my_coro():
...
当我们在协程函数内部使用 await
关键字时,我们就可以执行一个协程函数,并等待其执行完成并返回结果,例如
async def my_coro():
return "my_coro"
async def my_coro2():
result = await my_coro()
print(result)
如果不想等待一个协程函数完成,只想将这一函数加入到事件循环中放入后台运行,可以使用 toffee 提供的 create_task
方法,例如
import toffee
async def my_coro():
return "my_coro"
async def my_coro2():
toffee.create_task(my_coro())
那么如何启动事件循环,并使事件循环开始运行 my_coro2
呢?在 toffee 中,我们使用 toffee.run
来启动事件循环,并运行异步程序。
import toffee
toffee.run(my_coro2())
toffee 中的环境组件都需要在事件循环中运行,因此当启动 toffee 验证环境时,必须通过 toffee.run
先启动事件循环,然后在事件循环中去创建 toffee 验证环境。
因此,在验证环境创建时,应该以类似如下的方式:
import toffee
async def start_test():
# 创建验证环境
env = MyEnv()
...
toffee.run(start_test())
正如开头提出的问题,如果我们需要两个驱动方法同时运行,并且在每个驱动方法需要等待 DUT 经过若干个时钟周期。异步环境给予了我们等待某个事件的能力,但 Picker 只提供了推动 DUT 向前执行一个周期的能力,没有有提供一个事件让我们来等待。
toffee 中提供了对这类功能的支持,它通过创建一个后台时钟,来实现对 DUT 进行一个个周期的向前推动,每推动一个周期,后台时钟就会向其他协程发出时钟信号,使得其他协程能够继续执行。因此,DUT 的实际执行周期推动是由后台时钟来完成的,其他协程中只需要等待后台时钟发布的时钟信号即可。
在 toffee 中,通过start_clock
来创建后台时钟:
import toffee
async def start_test():
dut = MyDUT()
toffee.start_clock(dut)
toffee.run(start_test())
只需要在事件循环中调用 start_clock
即可创建后台时钟,它需要一个 DUT 对象作为参数,用于推动 DUT 的执行,以及将时钟信号绑定到 DUT 以及其各个引脚。
在其他协程中,我们可以通过 ClockCycles
来等待时钟信号到来,ClockCycles
的参数可以是 DUT,也可以是 DUT 的每一个引脚。例如:
import toffee
from toffee.triggers import *
async my_coro(dut):
await ClockCycles(dut, 10)
print("10 cycles passed")
async def start_test():
dut = MyDUT()
toffee.start_clock(dut)
await my_coro(dut)
toffee.run(start_test())
在 my_coro
中,通过 ClockCycles
来等待 DUT 经过 10 个时钟周期,当 10 个时钟周期经过后,my_coro
就会继续执行,并打印 “10 cycles passed”。
toffee 中提供了多种等待时钟信号的方法,例如:
ClockCycles
等待 DUT 经过若干个时钟周期Value
等待 DUT 的某个引脚的值等于某个值AllValid
等待 DUT 的所有引脚的值同时有效Condition
等待某个条件满足Change
等待 DUT 的某个引脚的值发生变化RisingEdge
等待 DUT 的某个引脚的上升沿FallingEdge
等待 DUT 的某个引脚的下降沿更多等待时钟信号的方法,参见 API 文档。
Bundle
在 toffee 验证环境中,用于构建 Agent
与 DUT 之间交互的中间层,以保证 Agent
与 DUT 之间的解耦。同时 Bundle
也起到了对 DUT 接口层次结构划分的作用,使得对 DUT 接口的访问变得更加清晰、方便。
为了定义一个 Bundle
,需要自定义一个新类,并继承 toffee 中的 Bundle
类。下面是一个简单的 Bundle
的定义示例:
from toffee import Bundle, Signals
class AdderBundle(Bundle):
a, b, sum, cin, cout = Signals(5)
该 Bundle 定义了一个简单的加法器接口,在 AdderBundle
类中,我们定义了五个信号 a
, b
, sum
, cin
, cout
,这五个信号分别代表了加法器的输入端口 a
, b
,输出端口 sum
,以及进位输入端口 cin
和进位输出端口 cout
。
定义完成后,我们可以通过 AdderBundle
类的实例来访问这些信号,例如:
adder_bundle = AdderBundle()
adder_bundle.a.value = 1
adder_bundle.b.value = 2
adder_bundle.cin.value = 0
print(adder_bundle.sum.value)
print(adder_bundle.cout.value)
在上述代码中,我们虽然创建了一个 bundle 实例,并对他进行了驱动,但是我们并没有将这个 bundle 与任何 DUT 绑定,也就意味着对这个 bundle 的操作,无法真正影响到 DUT。
使用 bind
方法,可以将一个 DUT 绑定到 bundle 上。例如我们有一个简单的加法器 DUT,其接口名称与 Bundle 中定义的名称相同。
adder = DUTAdder()
adder_bundle = AdderBundle()
adder_bundle.bind(adder)
bind
函数会自动检索 DUT 中所有的接口,并将名称相同的接口进行绑定。绑定完成后,对 bundle 的操作,就会直接影响到 DUT。
但是,如果 DUT 的接口名称与 Bundle 中定义的名称不同,直接使用 bind
则无法正确绑定。在 Bundle 中,我们提供多种绑定方法,以适应不同的绑定需求。
在 bind
函数中,我们可以通过传入一个字典,来指定 DUT 中的接口名称与 Bundle 中的接口名称之间的映射关系。
假设 Bundle 中的接口名称与 DUT 中的接口名称拥有如下对应关系:
a -> a_in
b -> b_in
sum -> sum_out
cin -> cin_in
cout -> cout_out
在实例化 bundle
时,我们可以通过 from_dict
方法创建,并传入一个字典,告知 Bundle
以字典的方式进行绑定。
adder = DUTAdder()
adder_bundle = AdderBundle.from_dict({
'a': 'a_in',
'b': 'b_in',
'sum': 'sum_out',
'cin': 'cin_in',
'cout': 'cout_out'
})
adder_bundle.bind(adder)
此时,adder_bundle
可正确绑定至 adder
。
假设 DUT 中的接口名称与 Bundle 中的接口名称拥有如下对应关系:
a -> io_a
b -> io_b
sum -> io_sum
cin -> io_cin
cout -> io_cout
可以发现,实际 DUT 的接口名称相比于 Bundle 中的接口名称,都多了一个 io_
的前缀。在这种情况下,我们可以通过 from_prefix
方法创建 Bundle
,并传入前缀名称,告知 Bundle
以前缀的方式进行绑定。
adder = DUTAdder()
adder_bundle = AdderBundle.from_prefix('io_')
adder_bundle.bind(adder)
在某些情况下,DUT 中的接口名称与 Bundle 中的接口名称之间的对应关系并不是简单的前缀或者字典关系,而是更为复杂的规则。例如,DUT 中的接口名称与 Bundle 中的接口名称之间的对应关系为:
a -> io_a_in
b -> io_b_in
sum -> io_sum_out
cin -> io_cin_in
cout -> io_cout_out
在这种情况下,我们可以通过传入正则表达式,来告知 Bundle
以正则表达式的方式进行绑定。
adder = DUTAdder()
adder_bundle = AdderBundle.from_regex(r'io_(.*)_.*')
adder_bundle.bind(adder)
使用正则表达式时,Bundle 会尝试将 DUT 中的接口名称与正则表达式进行匹配,匹配成功的接口,将会读取正则表达式中的所有捕获组,将其连接为一个字符串。再使用这个字符串与 Bundle 中的接口名称进行匹配。
例如对于上面代码中的正则表达式,io_a_in
会与正则表达式成功匹配,唯一的捕获组捕获到的内容为 a
。a
这个名称与 Bundle 中的接口名称 a
匹配,因此 io_a_in
会被正确绑定至 a
。
很多时候,我们会需要一个 Bundle 包含一个或多个其他 Bundle 的情况,这时我们可以将其他已经定义好的 Bundle 作为当前 Bundle 的子 Bundle。
from toffee import Bundle, Signal, Signals
class AdderBundle(Bundle):
a, b, sum, cin, cout = Signals(5)
class MultiplierBundle(Bundle):
a, b, product = Signals(3)
class ArithmeticBundle(Bundle):
selector = Signal()
adder = AdderBundle.from_prefix('add_')
multiplier = MultiplierBundle.from_prefix('mul_')
在上面的代码中,我们定义了一个 ArithmeticBundle
,它包含了自己的信号 selector
。除此之外它还包含了一个 AdderBundle
和一个 MultiplierBundle
,这两个子 Bundle 分别被命名为 adder
和 multiplier
。
当我们需要访问 ArithmeticBundle
中的子 Bundle 时,可以通过 .
运算符来访问:
arithmetic_bundle = ArithmeticBundle()
arithmetic_bundle.selector.value = 1
arithmetic_bundle.adder.a.value = 1
arithmetic_bundle.adder.b.value = 2
arithmetic_bundle.multiplier.a.value = 3
arithmetic_bundle.multiplier.b.value = 4
同时,当我们以这种定义方式进行定义后,在最顶层的 Bundle 进行绑定时,会同时将子 Bundle 也绑定到 DUT 上,在定义子 Bundle 时,依然可以使用前文提到的多种绑定方式。
需要注意的是,子 Bundle 的创建方法去匹配的信号名称,是经过上一次 Bundle 的创建方法进行处理过后的名称。例如在上面的代码中,我们将顶层 Bundle 的匹配方式设置为 from_prefix('io_')
,那么在 AdderBundle
中去匹配的信号,是去除了 io_
前缀后的名称。
同时,字典匹配方法会将信号名称转换为字典映射后的名称传递给子 Bundle 进行匹配,正则表达式匹配方法会将正则表达式捕获到的名称传递给子 Bundle 进行匹配。
访问信号值
在 Bundle 中,我们不仅可以通过 .
运算符来访问 Bundle 中的信号,也可以通过 []
运算符来访问 Bundle 中的信号。
adder_bundle = AdderBundle()
adder_bundle['a'].value = 1
访问未连接信号
def bind(self, dut, unconnected_signal_access=True)
在 bind
时,我们可以通过传入 unconnected_signal_access
参数来控制是否允许访问未连接的信号。默认为 True
,即允许访问未连接的信号,此时当写入该信号时,该信号不会发生变化,当读取该信号时,会返回 None
。 当 unconnected_signal_access
为 False
时,访问未连接的信号会抛出异常。
同时赋值所有信号
可以通过 set_all
方法同时将所有输入信号更改为某个值。
adder_bundle.set_all(0)
随机赋值所有信号
可以通过 randomize_all
方法随机赋值所有信号。“value_range” 参数用于指定随机值的范围,“exclude_signals” 参数用于指定不需要随机赋值的信号,“random_func” 参数用于指定随机函数。
adder_bundle.randomize_all()
信号赋值模式更改
信号赋值模式是 picker
中的概念,用于控制信号的赋值方式,请查阅 picker
文档以了解更多信息。
Bundle 中支持通过 set_write_mode
来改变整个 Bundle 的赋值模式。
同时,Bundle 提供了设置的快捷方法:set_write_mode_as_imme
, set_write_mode_as_rise
与 set_write_mode_as_fall
,分别用于设置 Bundle 的赋值模式为立即赋值、上升沿赋值与下降沿赋值。
默认消息类型赋值
toffee 支持一个默认的消息类型,可以通过 assign
方法将一个字典赋值给 Bundle 中的信号。
adder_bundle.assign({
'a': 1,
'b': 2,
'cin': 0
})
Bundle 将会自动将字典中的值赋值给对应的信号,当需要将未指定的信号赋值成某个默认值时,可以通过 *
来指定默认值:
adder_bundle.assign({
'*': 0,
'a': 1,
})
子 Bundle 的默认消息赋值支持
如果希望通过默认消息类型同时赋值子 Bundle 中的信号,可以通过两种方式实现。当 assign
中的 multilevel
参数为 True
时,Bundle 支持多级字典赋值。
arithmetic_bundle.assign({
'selector': 1,
'adder': {
'*': 0,
'cin': 0
},
'multiplier': {
'a': 3,
'b': 4
}
}, multilevel=True)
当 multilevel
为 False
时,Bundle 支持通过 .
来指定子 Bundle 的赋值。
arithmetic_bundle.assign({
'*': 0,
'selector': 1,
'adder.cin': 0,
'multiplier.a': 3,
'multiplier.b': 4
}, multilevel=False)
默认消息类型读取
在 Bundle 中可以使用,as_dict
方法将 Bundle 当前的信号值转换为字典。其同样支持两种格式,当 multilevel
为 True
时,返回多级字典;当 multilevel
为 False
时,返回扁平化的字典。
> arithmetic_bundle.as_dict(multilevel=True)
{
'selector': 1,
'adder': {
'a': 0,
'b': 0,
'sum': 0,
'cin': 0,
'cout': 0
},
'multiplier': {
'a': 0,
'b': 0,
'product': 0
}
}
> arithmetic_bundle.as_dict(multilevel=False)
{
'selector': 1,
'adder.a': 0,
'adder.b': 0,
'adder.sum': 0,
'adder.cin': 0,
'adder.cout': 0,
'multiplier.a': 0,
'multiplier.b': 0,
'multiplier.product': 0
}
自定义消息类型
在我们自定义的消息结构中,可以执行规则将其赋值给 Bundle 中的信号。
一种方法是,在自定义消息结构中,实现 as_dict
函数,将自定义消息结构转换为字典,然后通过 assign
方法赋值给 Bundle。
另一种方法是,在自定义消息结构中,实现 __bundle_assign__
函数,其接收一个 Bundle 实例,将自定义消息结构赋值给 Bundle。实现后,可以通过 assign
方法赋值给 Bundle,Bundle 将会自动调用 __bundle_assign__
函数进行赋值。
class MyMessage:
def __init__(self):
self.a = 0
self.b = 0
self.cin = 0
def __bundle_assign__(self, bundle):
bundle.a.value = self.a
bundle.b.value = self.b
bundle.cin.value = self.cin
my_message = MyMessage()
adder_bundle.assign(my_message)
当需要将 Bundle 中的信号值转换为自定义消息结构时,简易在自定义消息结构中实现 from_bundle
的类方法,接收一个 Bundle 实例,返回一个自定义消息结构。在创建自定义消息结构时,可以通过 from_bundle
方法将 Bundle 中的信号值转换为自定义消息结构。
class MyMessage:
def __init__(self):
self.a = 0
self.b = 0
self.cin = 0
@classmethod
def from_bundle(cls, bundle):
message = cls()
message.a = bundle.a.value
message.b = bundle.b.value
message.cin = bundle.cin.value
return message
my_message = MyMessage.from_bundle(adder_bundle)
Bundle 类除了对 DUT 的引脚进行封装外,还提供了基于数组的时序封装,可以适用于部分简单时序场景。Bundle 类提供了process_requests(data_list)
函数,他接受一个数组输入,第i
个时钟周期,会将data_list[i]
对应的数据赋值给引脚。data_list
中的数据可以是dict
类型,或者callable(cycle, bundle_ins)
类型的可调用对象。对于dict
类型,特殊key
有:
__funcs__: func(cycle, self) # 可调用对象,可以为函数数组[f1,f2,..]
__condition_func__: func(cycle, slef, cargs) # 条件函数,当改函数返回为true时,进行赋值,否则继续推进时钟
__condition_args__: cargs # 条件函数需要的参数
__return_bundles__: bundle # 需要本次dict赋值时返回的bundle数据,可以是list[bundle]
如果输入的dict
中有__return_bundles__
,则函数会返回该输入对应的 bundle 值,例如{"data": x, "cycle": y}
。以 Adder 为例,期望第三次加后返回结果:
# Adder虽然为存组合逻辑,但此处当时序逻辑使用
class AdderBundle(Bundle):
a, b, sum, cin, cout = Signals(5) # 指定引脚
def __init__(self, dut):
super().__init__()
# init clock
# dut.InitClock("clock")
self.bind(dut) # 绑定到dut
def add_list(data_list =[(1,2),(3,4),(5,6),(7,8)]):
# make input dit
data = []
for i, (a, b) in enumerate(data_list):
x = {"a":a, "b":b, "*":0} # 构建budle赋值的dict
if i >= 2:
x["__return_bundles__"] = self # 设置需要返回的bundle
data.append(X)
return self.process_requests(data) # 推动时钟,赋值,返回结果
当调用add_list()
后,返回的结果为:
[
{"data": {"a":5, "b":6, "cin": 0, "sum":11, "cout": 0}, "cycle":3},
{"data": {"a":7, "b":8, "cin": 0, "sum":15, "cout": 0}, "cycle":4}
]
在 Bundle 中,为了方便的接收时钟信息,提供了 step
函数。当 Bundle 连接至 DUT 的任意一个信号时,step 函数会自动同步至 DUT 的时钟信号。
可以通过 step
函数来完成时钟周期的等待。
async def adder_process(adder_bundle):
adder_bundle.a.value = 1
adder_bundle.b.value = 2
adder_bundle.cin.value = 0
await adder_bundle.step()
print(adder_bundle.sum.value)
print(adder_bundle.cout.value)
信号连接规则
当定义好 Bundle 实例后,可以调用 all_signals_rule
方法,获取所有信号的连接规则,以帮助用户检查信号的连接规则是否符合预期。
adder_bundle.all_signals_rule()
信号可连接性检查
detect_connectivity
方法可以检查一个特定的信号名称是否可以连接到该 Bundle 中的某个信号。
adder_bundle.detect_connectivity('io_a')
detect_specific_connectivity
方法可以检查一个特定的信号名称是否可以连接到该 Bundle 中的某个特定的信号。
adder_bundle.detect_specific_connectivity('io_a', 'a')
如果需要检测子 Bundle 的信号连接性,可以通过 .
运算符来指定。
未连接信号检查
detect_unconnected_signals
方法可以检查 DUT 中未连接到任何 Bundle 的信号。
Bundle.detect_unconnected_signals(adder)
重复连接检查
detect_multiple_connections
方法可以检查 DUT 中同时连接到多个 Bundle 的信号。
Bundle.detect_multiple_connections(adder)
设置 Bundle 名称
可以通过 set_name
方法设置 Bundle 的名称。
adder_bundle.set_name('adder')
设置名称之后,将会得到更加直观的提示信息。
获取 Bundle 中所有信号
all_signals
信号会返回一个 generator,其中包含了包括子 Bundle 信号在内的所有信号。
for signal in adder_bundle.all_signals():
print(signal)
在很多情况下,DUT 的接口可能过于复杂,手动去编写 Bundle 的定义会变得非常繁琐。然而,Bundle 作为中间层,提供一个确切的信号名称定义是必要的。为了解决这个问题,toffee 提供了一个自动生成 Bundle 的脚本来从 DUT 的接口定义中生成 Bundle 的定义。
可以在 toffee 仓库目录下的 scripts
文件夹中找到 bundle_code_gen.py
脚本。该脚本可以通过解析 DUT 实例,以及指定的绑定规则自动生成 Bundle 的定义。
其中提供了三个函数
def gen_bundle_code_from_dict(bundle_name: str, dut, dict: dict, max_width: int = 120)
def gen_bundle_code_from_prefix(bundle_name: str, dut, prefix: str = "", max_width: int = 120):
def gen_bundle_code_from_regex(bundle_name: str, dut, regex: str, max_width: int = 120):
分别用于通过字典、前缀、正则表达式的方式生成 Bundle 的定义。
使用时,指定 Bundle 的名称,DUT 实例,以及对应的生成规则便可生成 Bundle 的定义,还可以通过 max_width
参数来指定生成的代码的最大宽度。
from bundle_code_gen import *
gen_bundle_code_from_dict('AdderBundle', dut, {
'a': 'io_a',
'b': 'io_b',
'sum': 'io_sum',
'cin': 'io_cin',
'cout': 'io_cout'
})
gen_bundle_code_from_prefix('AdderBundle', dut, 'io_')
gen_bundle_code_from_regex('AdderBundle', dut, r'io_(.*)')
生成好的代码可以直接或经过简单的修改后,复制到代码中使用。也可以作为子 Bundle 的定义,应用到其他 Bundle 中。
Agent
在 toffee 验证环境中实现了对一类 Bundle
中信号的高层封装,使得上层驱动代码可以在不关心具体信号赋值的情况下,完成对 Bundle 中信号的驱动及监测。
一个 Agent
由 驱动方法(driver_method) 和 监测方法(monitor_method) 组成,其中驱动方法用于主动驱动 Bundle
中的信号,而监测方法用于被动监测 Bundle
中的信号。
为了定义一个 Agent
,需要自定义一个新类,并继承 toffee 中的 Agent
类。下面是一个简单的 Agent
的定义示例:
from toffee.agent import *
class AdderAgent(Agent):
def __init__(self, bundle):
super().__init__(bundle.step)
self.bundle = bundle
在 AdderAgent
类初始化时,需要外界传入该 Agent 需要驱动的 Bundle,并且需要向父类 Agent
中传入一个时钟同步函数,以便 Agent
使用这一函数来决定何时调用监测方法。一般来说,可以将其设置为 bundle.step
,即 Bundle
中的时钟同步函数,Bundle
中的 step 与 DUT 中的时钟同步。
在 Agent
中,驱动方法是一个异步函数,用于主动驱动 Bundle
中的信号。驱动函数需要将函数的传入参数进行解析,并根据解析结果对 Bundle
中的信号进行赋值,赋值的过程可以跨越多个时钟周期。如果需要获取 Bundle 的信号值,那么在函数中编写相应的逻辑,并将其转换为需要的数据,通过函数返回值返回。
每一个驱动方法都应是一个异步函数,并且使用 @driver_method
装饰器进行修饰,以便 Agent
能够识别该函数为驱动方法。
下面是一个简单的驱动方法的定义示例:
from toffee.agent import *
class AdderAgent(Agent):
def __init__(self, bundle):
super().__init__(bundle.step)
self.bundle = bundle
@driver_method()
async def exec_add(self, a, b, cin):
self.bundle.a.value = a
self.bundle.b.value = b
self.bundle.cin.value = cin
await self.bundle.step()
return self.bundle.sum.value, self.bundle.cout.value
在 drive
函数中,我们将传入的 a
, b
, cin
三个参数分别赋值给 Bundle
中的 a
, b
, cin
信号,并等待一个时钟周期。在时钟周期结束后,我们返回 Bundle
中的 sum
, cout
信号值。
在驱动函数的编写过程中,你可以使用 如何使用异步环境 中介绍的所有等待时钟信号的同步方法,例如 ClockCycles
, Value
等。
创建完毕后,你可以像调用普通函数一样在驱动代码中调用该驱动方法,例如:
adder_bundle = AdderBundle()
adder_agent = AdderAgent(adder_bundle)
sum, cout = await adder_agent.exec_add(1, 2, 0)
print(sum, cout)
被标识为 @driver_method
的函数在调用时拥有诸多特性,这一部分将在编写测试用例中详细介绍。同时,该类函数还会完成参考模型的匹配与自动调用以返回值对比,这一部分将在编写参考模型中详细介绍。
监测方法同样需要是一个异步函数,并且使用 @monitor_method
装饰器进行修饰,以便 Agent
能够识别该函数为监测方法。
一个简单的监测方法的定义示例如下:
from toffee.agent import *
class AdderAgent(Agent):
def __init__(self, bundle):
super().__init__(bundle.step)
self.bundle = bundle
@monitor_method()
async def monitor_sum(self):
if self.bundle.sum.value > 0:
return self.bundle.as_dict()
在 monitor_sum
函数中,我们以 Bundle 中的 sum 信号作为监测对象,当 sum 信号的值大于 0 时,收集 Bundle 生成的默认消息类型,收集到的返回值将会被存储到内部的消息队列中。
添加 monitor_method
装饰器后,monitor_sum
函数将会被 Agent
自动调用,它会使用 Agent
初始化时提供的时钟同步函数来决定何时调用监测方法。默认情况下,Agent
会在每个时钟周期都调用一次监测方法,如果监测方法有返回值,那么返回值将会被存储到内部的消息队列中。若监测方法的一次调用会经过多个时钟周期,Agent
会等待上一次监测方法调用结束后再次调用监测方法。
如果编写了类似下面的监测方法:
@monitor_method()
async def monitor_sum(self):
return self.bundle.as_dict()
该监测方法将会在每个周期都往消息队列中添加一个消息。
获取监测消息
由于该监测方法被标记为了 @monitor_method
,因此该方法将会被 Agent
自动调用,在测试用例中如果按照以下方式直接调用该函数,并不能执行该函数的预期行为。
adder_bundle = AdderBundle()
adder_agent = AdderAgent(adder_bundle)
result = await adder_agent.monitor_sum()
相反的,按照上述方式调用监测方法,它将会弹出消息队列中收集到的最早的消息,并返回该消息。如果消息队列为空,该次调用将会等待消息队列中有消息后再返回。
如果想获取消息队列中的消息数量,可以使用如下方式获取:
message_count = adder_agent.monitor_size("monitor_sum")
通过创建监测方法,你可以方便地添加一个后台监测任务,监测 Bundle
中的信号值,并在满足条件时收集消息。将函数标记为监测方法后,框架还会为这一方法提供与参考模型的匹配与自动收集对比,这一部分将在编写参考模型中详细介绍。
通过在 Agent 中编写多个驱动方法和监测方法,便完成了整个 Agent
的编写。
Env
在 toffee 验证环境中用于打包整个验证环境,Env 中直接实例化了验证环境中需要用的所有 agent,并负责将这些 Agent 需要的 bundle 传递给它们。
创建好 Env 后,参考模型的编写规范也随之确定,按照此规范编写的参考模型可直接附加到 Env 上,由 Env 来完成参考模型的自动同步。
为了定义一个 Env
,需要自定义一个新类,并继承 toffee 中的 Env
类。下面是一个简单的 Env
的定义示例:
from toffee.env import *
class DualPortStackEnv(Env):
def __init__(self, port1_bundle, port2_bundle):
super().__init__()
self.port1_agent = StackAgent(port1_bundle)
self.port2_agent = StackAgent(port2_bundle)
在这个例子中,我们定义了一个 DualPortStackEnv
类,该类中实例化了两个相同的 StackAgent
,分别用于驱动两个不同的 Bundle
。
可以选择在 Env 之外连接 Bundle,也可以在 Env 内部连接 Bundle,只要能保证向 Agent 中传入正确的 Bundle 即可。
此时,如果不需要编写额外的参考模型,那么整个验证环境的搭建就完成了,可以直接编写测试用例并且在测试用例中使用 Env 提供的接口,例如:
port1_bundle = StackPortBundle()
port2_bundle = StackPortBundle()
env = DualPortStackEnv(port1_bundle, port2_bundle)
await env.port1_agent.push(1)
await env.port2_agent.push(1)
print(await env.port1_agent.pop())
print(await env.port2_agent.pop())
定义好 Env 后,整个验证环境的接口也就随之确定,例如:
DualPortStackEnv
- port1_agent
- @driver_method push
- @driver_method pop
- @monitor_method some_monitor
- port2_agent
- @driver_method push
- @driver_method pop
- @monitor_method some_monitor
按照此规范编写的参考模型都可以直接附加到 Env 上,由 Env 来完成参考模型的自动同步,方式如下:
env = DualPortStackEnv(port1_bundle, port2_bundle)
env.attach(StackRefModel())
一个 Env 可以附加多个参考模型,这些参考模型都将会被 Env 自动同步。
参考模型的具体编写方式将在编写参考模型一节中详细介绍。
参考模型
用于模拟待验证设计的行为,以便在验证过程中对设计进行验证。在 toffee 验证环境中,参考模型需要遵循 Env
的接口规范,以便能够附加到 Env
上,由 Env
来完成参考模型的自动同步。
toffee 提供了两种参考模型的实现方式,这两种方式都可以被附加到 Env
上,并由 Env
来完成参考模型的自动同步。在不同的场景下,可以选择更适合的方式来实现参考模型。
这两种方式分别是 函数调用模式 与 独立执行流模式,下面将分别介绍这两种方式的具体概念。
函数调用模式即是将参考模型的对外接口定义为一系列的函数,通过调用这些函数来驱动参考模型的行为。此时,我们通过输入参数向参考模型发送数据,并通过返回值获取参考模型的输出数据,参考模型通过函数体的逻辑来更新内部状态。
下面是一个简单的函数调用模式的参考模型的定义示例:
例如,这是一个简单的加法器参考模型:
class AdderRefModel():
def add(self, a, b):
return a + b
在这个参考模型中,不需要任何内部状态,通过一个对外函数接口即可实现参考模型所有功能。
需要注意的是,使用函数调用模式编写的参考模型,只能通过外部主动调用的方式来执行,无法被动输出内部数据。因此,其无法与 Agent 中的监测方法进行匹配。在 Agent 中编写监测方法,在函数调用模式编写参考模型时是没有意义的。
独立执行流模式即是将参考模型的行为定义为一个独立的执行流,它不再受外部主动调用函数控制,而拥有了主动获取输入数据和主动输出数据的能力。当外部给参考模型发送数据时,参考模型不会立即响应,而是将这一数据保存起来,等待其执行逻辑主动获取该数据。
我们用一段代码来说明这种模式,该示例中用到了 toffee 中提供的相关概念来实现,但目前无需关心这些概念的使用细节。
class AdderRefModel(Model):
def __init__(self):
super().__init__()
self.add_port = DriverPort()
self.sum_port = MonitorPort()
async def main():
while True:
operands = await self.add_port()
sum = operands["a"] + operands["b"]
await self.sum_port(sum)
在这里,我们在参考模型构造函数中定义了两类接口,一类为驱动接口(DriverPort),即代码中的add_port
,用于接收外部输入数据;另一类为监测接口(MonitorPort),即代码中的sum_port
,用于向外部输出数据。
定义了这两个接口后,上层代码在给参考模型发送数据时,并不会触发参考模型中的某个函数,而是会将数据发送到 add_port
这个驱动接口中。同时,上层代码也无法主动获取到参考模型的输出数据了。参考模型的输出数据会通过 sum_port
这个监测接口,由参考模型主动输出。
那么参考模型如何去使用这两个接口呢?在参考模型中,有一个 main 函数,这是参考模型执行的入口,当参考模型创建时, main 函数会被自动调用,并在后台持续运行。在上面代码中 main 函数里,参考模型通过不断重复这一过程:等待 add_port
中的数据、计算结果、将结果输出到 sum_port
中 来实现参考模型的行为。
参考模型会主动向 add_port
请求数据,如果 add_port
中没有数据,参考模型会等待数据的到来。当数据到来后,参考模型将会进行计算,将计算结果主动的输出到 sum_port
中。它的执行过程是一个独立的执行流,不受外部的主动调用控制。当参考模型变得复杂时,其将会含有众多的驱动接口和监测接口,通过独立执行流的方式,可以更好的去处理结构之间的相互关系,尤其是接口之间存在调用顺序的情况。
假如 Env 中定义的接口如下:
StackEnv
- port_agent
- @driver_method push
- @driver_method pop
那么如果我们想要编写与之对应的参考模型,自然地,我们需要定义这四个驱动函数被调用时参考模型的行为。也就是说为每一个驱动函数编写一个对应的函数,这些函数将会在驱动函数被调用时被框架自动调用。
如何让参考模型中定义的函数能够与某个驱动函数匹配呢?首先应该使用 @driver_hook
装饰器来表示这个函数是一个驱动函数的匹配函数。接着,为了建立对应关系,我们需要在装饰器中指定其对应的 Agent 和驱动函数的名称。最后,只需要保证函数的参数与驱动函数的参数一致,两个函数便能够建立对应关系。
class StackRefModel(Model):
@driver_hook(agent_name="port_agent", driver_name="push")
def push(self, data):
pass
@driver_hook(agent_name="port_agent", driver_name="pop")
def pop(self):
pass
此时,驱动函数与参考模型的对应关系已经建立,当 Env 中的某个驱动函数被调用时,参考模型中对应的函数将会被自动调用,并自动对比两者的返回值是否一致。
toffee 还提供了以下几种匹配方式,以便更好地匹配驱动函数:
指定驱动函数路径
可以通过 “.” 来指定驱动函数的路径,例如:
class StackRefModel(Model):
@driver_hook("port_agent.push")
def push(self, data):
pass
@driver_hook("port_agent.pop")
def pop(self):
pass
使用函数名称匹配驱动函数名称
如果参考模型中的函数名称与驱动函数名称相同,可以省略 driver_name
参数,例如:
class StackRefModel(Model):
@driver_hook(agent_name="port_agent")
def push(self, data):
pass
@driver_hook(agent_name="port_agent")
def pop(self):
pass
使用函数名称同时匹配 Agent 名称与驱动函数名称
可以在函数名中通过双下划线 “__” 来同时匹配 Agent 名称与驱动函数名称,例如:
class StackRefModel(Model):
@driver_hook()
def port_agent__push(self, data):
pass
@driver_hook()
def port_agent__pop(self):
pass
除了对 Agent 中每一个驱动函数都编写一个 driver_hook
之外,还可以通过 @agent_hook
装饰器来一次性匹配 Agent 中的所有驱动函数。
class StackRefModel(Model):
@agent_hook("port_agent")
def port_agent(self, driver_name, args):
pass
在这个例子中,port_agent
函数将会匹配 port_agent
Agent 中的所有驱动函数,当 Agent 中的任意一个驱动函数被调用时,port_agent
函数将会被自动调用。除了 self 之外,port_agent
函数还需接受且只接受两个参数,第一个参数为驱动函数的名称,第二个参数为驱动函数的参数。
当某个驱动函数被调用时,driver_name 参数将会传入驱动函数的名称,args 参数将会传入该该驱动函数被调用时的参数,参数将会以字典的形式传入。port_agent 函数可以根据 driver_name 和 args 来决定如何处理这个驱动函数的调用,并将结果返回。此时框架将会使用此函数的返回值与驱动函数的返回值进行对比。
与驱动函数类似,@agent_hook
装饰器也支持当函数名与 Agent 名称相同时省略 agent_name
参数。
class StackRefModel(Model):
@agent_hook()
def port_agent(self, driver_name, args):
pass
agent_hook 与 driver_hook 同时存在
当 agent_hook
被定义后,理论上无需再定义任何 driver_hook
与 Agent 中的驱动函数进行匹配。但是,如果需要对某个驱动函数进行特殊处理,可以再定义一个 driver_hook
与该驱动函数进行匹配。
当 agent_hook
与 driver_hook
同时存在时,框架会优先调用 agent_hook
函数,再调用 driver_hook
函数,并将 driver_hook
函数的返回值用于结果的对比。
当 Env 中所有的驱动函数都能找到对应的 driver_hook
或 agent_hook
时,参考模型便能成功与 Env 建立匹配关系,此时可以直接通过 Env 中的 attach
方法将参考模型附加到 Env 上。
独立执行流模式的参考模型是通过 port
接口的形式来完成数据的输入输出,他可以主动向 port
请求数据,也可以主动向 port
输出数据。在 toffee 中,我们提供了两种接口来实现这一功能,分别是 DriverPort
和 MonitorPort
。
类似地,我们需要定义一系列的 DriverPort
使其与 Env 中的驱动函数匹配,同时定义一系列的 MonitorPort
使其与 Env 中的监测函数匹配。
当 Env 中的驱动函数被调用时,调用数据将会被发送到 DriverPort
中,参考模型将会主动获取这些数据,并进行计算。计算结果将会被输出到 MonitorPort
中,当 Env 中的监测函数被调用时,比较器会自动从 MonitorPort
中获取数据,并与 Env 中的监测函数的返回值进行比较。
为了接收到 Env 中所有的驱动函数的调用,参考模型可以选择为每一个驱动函数编写对应的 DriverPort
。可以通过 DriverPort
的参数 agent_name
与 driver_name
来匹配 Env 中的驱动函数。
class StackRefModel(Model):
def __init__(self):
super().__init__()
self.push_port = DriverPort(agent_name="port_agent", driver_name="push")
self.pop_port = DriverPort(agent_name="port_agent", driver_name="pop")
与 driver_hook
类似,也可以使用下面的方式来匹配 Env 中的驱动函数:
# 使用 "." 来指定驱动函数的路径
self.push_port = DriverPort("port_agent.push")
# 如果参考模型中的变量名称与驱动函数名称相同,可以省略 driver_name 参数
self.push = DriverPort(agent_name="port_agent")
# 使用变量名称同时匹配 Agent 名称与驱动函数名称,并使用 `__` 分隔
self.port_agent__push = DriverPort()
也可以选择定义 AgentPort
同时匹配一个 Agent 中的所有驱动函数。但与 agent_hook
不同的是,定义了 AgentPort
后,便不能为该 Agent 中的任何驱动函数再定义 DriverPort
。所有的驱动函数调用将会被发送到 AgentPort
中。
class StackRefModel(Model):
def __init__(self):
super().__init__()
self.port_agent = AgentPort(agent_name="port_agent")
类似的,当变量名称与 Agent 名称相同时,可以省略 agent_name
参数:
self.port_agent = AgentPort()
为了与 Env 中的监测函数匹配,参考模型需要为每一个监测函数编写对应的 MonitorPort
,定义方法与 DriverPort
一致。
self.monitor_port = MonitorPort(agent_name="port_agent", monitor_name="monitor")
# 使用 "." 来指定监测函数的路径
self.monitor_port = MonitorPort("port_agent.monitor")
# 如果参考模型中的变量名称与监测函数名称相同,可以省略 monitor_name 参数
self.monitor = MonitorPort(agent_name="port_agent")
# 使用变量名称同时匹配 Agent 名称与监测函数名称,并使用 `__` 分隔
self.port_agent__monitor = MonitorPort()
MonitorPort 中送入的数据,将会自动与 Env 中的监测函数的返回值进行比较,来完成参考模型的比对工作。
当参考模型中定义的 DriverPort
, AgentPort
和 MonitorPort
能够与 Env
中所有接口匹配时,参考模型便能成功与 Env
建立匹配关系,此时可以直接通过 Env
中的 attach
方法将参考模型附加到 Env
上。
编写测试用例需要使用验证环境中定义好的接口来实现,但在用例中,往往会遇到同时驱动多个接口的情况,并且对于参考模拟的同步往往也有不同的需求,这一部分将详细介绍如何更好地使用验证环境中的接口来编写测试用例。
当验证环境搭建完成后,编写测试用例用于验证设计的功能是否符合预期。对于硬件验证中的验证,两个重要的导向是:功能覆盖率和行覆盖率,功能覆盖率意味着测试用例是否覆盖了设计的所有功能,行覆盖率意味着测试用例是否触发了设计的所有代码行。在 toffee-test 中,不仅提供了对这两种覆盖率的支持,还会再每次运行过后,自动计算出这两种覆盖率的结果,并生成一个验证报告。toffee-test 使用 pytest 来管理测试用例,使其拥有强大的测试用例管理能力。
在本节中,会在以下几个方面来讲述如何编写测试用例,以使用 toffee 和 toffee-test 提供的强大功能:
当验证环境搭建完成后,可以通过验证环境提供的接口来编写测试用例。然而,通过普通的串行代码,往往无法完成两个驱动函数的同时调用。在多个接口需要同时驱动的情况下,这种情况变得尤为重要,toffee 为这种场景提供了简便的调用方式。
例如目前的 Env 结构如下:
DualPortStackEnv
- port1_agent
- @driver_method push
- @driver_method pop
- port2_agent
- @driver_method push
- @driver_method pop
我们期望在测试用例中同时调用 port1_agent
和 port2_agent
的 push
函数,以便同时驱动两个接口。
在 toffee 中,可以通过 Executor
来完成。
from toffee import Executor
def test_push(env):
async with Executor() as exec:
exec(env.port1_agent.push(1))
exec(env.port2_agent.push(2))
print("result", exec.get_results())
我们使用 async with
来创建一个 Executor
对象,并建立一个执行块,通过直接调用 exec
可以添加需要执行的驱动函数。当 Executor
对象退出作用域时,会将所有添加的驱动函数同时执行。Executor
会自动等待所有驱动函数执行完毕。
如果需要获取驱动函数的返回值,可以通过 get_results
方法来获取,get_results
会以字典的形式返回所有驱动函数的返回值,其中键为驱动函数的名称,值为一个列表,列表中存放了对应驱动函数的返回值。
如果在在执行块中多次调用同一驱动函数,Executor
会自动将这些调用串行执行。
from toffee import Executor
def test_push(env):
async with Executor() as exec:
for i in range(5):
exec(env.port1_agent.push(1))
exec(env.port2_agent.push(2))
print("result", exec.get_results())
例如上述代码中,port1_agent.push
会被调用 5 次,port2_agent.push
会被调用 1 次。由于 port1_agent.push
是同一驱动函数,Executor
会自动将这 10 次调用串行执行,其返回值会被依次存放在返回值列表中。通过,port2_agent.push
将会与 port1_agent.push
并行执行。
上述过程中,我们创建了这样一个调度过程:
------------------ current time --------------------
+---------------------+ +---------------------+
| group "agent1.push" | | group "agent2.push" |
| +-----------------+ | | +-----------------+ |
| | agent1.push | | | | agent2.push | |
| +-----------------+ | | +-----------------+ |
| +-----------------+ | +---------------------+
| | agent1.push | |
| +-----------------+ |
| +-----------------+ |
| | agent1.push | |
| +-----------------+ |
| +-----------------+ |
| | agent1.push | |
| +-----------------+ |
| +-----------------+ |
| | agent1.push | |
| +-----------------+ |
+---------------------+
------------------- Executor exit -------------------
Executor 根据两个驱动函数的函数名自动创建了两个调度组,并按照调用顺序将驱动函数添加到对应的调度组中。在调度组内部,驱动函数会按照添加的顺序依次执行。在调度组之间,驱动函数会并行执行。
调度组的默认名称为以 .
分隔的驱动函数路径名。
通过 sche_group
参数,你可以在执行函数时手动指定驱动函数调用时所属的调度组,例如
from toffee import Executor
def test_push(env):
async with Executor() as exec:
for i in range(5):
exec(env.port1_agent.push(1), sche_group="group1")
exec(env.port2_agent.push(2), sche_group="group1")
print("result", exec.get_results())
这样一来,port1_agent.push
和 port2_agent.push
将会被按顺序添加到同一个调度组 group1
中,表现出串行执行的特性。同时 get_results
返回的字典中,group1
会作为键,其值为一个列表,列表中存放了 group1
中所有驱动函数的返回值。
如果我们在一个自定义函数中调用了驱动函数或其他驱动函数,并希望自定义函数也可以通过 Executor
来调度,可以通过与添加驱动函数相同的方式来添加自定义函数。
from toffee import Executor
async def multi_push_port1(env, times):
for i in range(times):
await env.port1_agent.push(1)
async def test_push(env):
async with Executor() as exec:
for i in range(2):
exec(multi_push_port1(env, 5))
exec(env.port2_agent.push(2))
print("result", exec.get_results())
此时,multi_push_port1
会被添加到 Executor
中,并创建以 multi_push_port1
为名称的调度组,并向其中添加两次调用。其会与 port2_agent.push
调度组并行执行。
我们也可以在自定义函数中使用 Executor
,或调用其他自定义函数。这样一来,我们可以通过 Executor
完成任意复杂的调度。以下提供了若干个案例:
案例一
环境接口如下:
Env
- agent1
- @driver_method send
- agent2
- @driver_method send
两个 Agent 中的 send
函数各需要被并行调用 5 次,并且调用时需要发送上一次的返回结果,第一次发送时发送 0,两个函数调用相互独立。
from toffee import Executor
async def send(agent):
result = 0
for i in range(5):
result = await agent.send(result)
async def test_send(env):
async with Executor() as exec:
exec(send(env.agent1), sche_group="agent1")
exec(send(env.agent2), sche_group="agent2")
print("result", exec.get_results())
案例二
环境接口如下:
env
- agent1
- @driver_method long_task
- agent2
- @driver_method task1
- @driver_method task2
task1 和 task2 需要并行执行,并且一次调用结束后需要同步,task1 和 task2 都需要调用 5 次,long_task 需要与 task1 和 task2 并行执行。
from toffee import Executor
async def exec_once(env):
async with Executor() as exec:
exec(env.agent2.task1())
exec(env.agent2.task2())
async def test_case(env):
async with Executor() as exec:
for i in range(5):
exec(exec_once(env))
exec(env.agent1.long_task())
print("result", exec.get_results())
Executor 会等待所有添加的驱动函数执行完毕后退出,但有时我们并不需要等待所有驱动函数执行完毕,可以通过在创建 Executor 时使用 exit
参数来设置退出条件。
exit
参数可以被设置为 all
, any
或 none
三种值,分别表示所有调度组执行完毕后退出、任意一个调度组执行完毕后退出、不等待直接退出。
from toffee import Executor
async def send_forever(agent):
result = 0
while True:
result = await agent.send(result)
async def test_send(env):
async with Executor(exit="any") as exec:
exec(send_forever(env.agent1))
exec(env.agent2.send(1))
print("result", exec.get_results())
例如上述代码中 send_forever
函数是一个无限循环的函数,将 exit
设置为 any
后,Executor 会在 env.agent2.send
函数执行完毕后退出,而不会等待 send_forever
函数执行完毕。
如果后续需要等待所有任务执行完毕,可以通过等待 exec.wait_all
来实现。
在 toffee 中,参考模型的调度是由 toffee 自动完成的,但在某些情况下需要手动控制参考模型的调度顺序,例如在参考模型中需要调用多个函数,且这些函数之间存在调用顺序的情况。或者是控制参考模型与驱动函数之间的调用顺序。
在使用 Executor 执行时,可以使用参数 sche_order
来控制参考模型是在驱动函数之前、之后或同时执行。当为 model_first
时,参考模型会在驱动函数之前执行;当为 dut_first
时,驱动函数会在参考模型之前执行;当为 parallel
时,参考模型会与驱动函数同时执行。默认情况下为并行执行。
def test_push(env):
async with Executor() as exec:
exec(env.port1_agent.push(1), sche_order="dut_first")
exec(env.port2_agent.push(2), sche_order="dut_first")
print("result", exec.get_results())
上述代码中,参考模型将会在对应的驱动函数结束之后才会被调用。
当使用函数调用模式编写参考模型时,参考模型中的函数之间可能存在调用顺序相关的一来,例如一个函数在调用之前必须需要另一个函数先被调用。
这一过程若不使用 Executor 使函数并行执行,很容易得到控制,串行执行的代码中函数的调用顺序即为其执行顺序。
但如果使用 Executor 并行执行函数,两个参考模型之间的调用顺序就无法保证。toffee 为此场景提供了 priority
参数,用于指定参考模型函数的调用顺序,数值越小其优先级较高。
from toffee import Executor
def test_push(env):
async with Executor() as exec:
exec(env.port1_agent.push(1), priority=1)
exec(env.port2_agent.push(2), priority=0)
print("result", exec.get_results())
例如上述代码中,port2_agent.push
和 port1_agent.push
两个函数会并行执行,其参考模型的调用也将在同一时钟周期内完成。由于我们指定了port2_agent.push
的优先级为 0,port1_agent.push
的优先级为 1,因此在该周期的执行过程中,port2_agent.push
会优先被调用。
注意,优先级只在同一时钟周期内有效,若两个函数调用跨越了时钟周期,那么时钟周期靠前的函数依然会被优先调用。
在 toffee 中,测试用例是通过 pytest 来管理的。pytest 是一个功能强大的 Python 测试框架,如果你不熟悉 pytest,可以查看 pytest 官方文档。
首先,我们需要创建一个测试用例文件,例如 test_adder.py
,该文件需要以 test_
开头,或以 _test.py
结尾,以便 pytest 能够识别。接着可以在其中编写我们的第一个测试用例。
# test_adder.py
async def my_test():
env = AdderEnv()
env.add_agent.exec_add(1, 2, 0)
def test_adder():
toffee.run(my_test())
pytest 并不能直接运行协程测试用例,因此我们需要在测试用例中调用 toffee.run
来运行异步测试用例。
用例编写完成后,我们可以在终端中运行 pytest。
pytest
pytest 会查找当前目录下所有以 test_
开头或以 _test.py
结尾的文件,并运行其中以 test_
开头的函数,每一个函数被视作一个测试用例。
为了使 pytest 能够直接运行协程测试用例,toffee 提供了 toffee_async
标记来标记异步测试用例。
# test_adder.py
@pytest.mark.toffee_async
async def test_adder():
env = AdderEnv(DUTAdder())
await env.add_agent.exec_add(1, 2, 0)
如图所示,我们只需要在测试用例函数上添加 @pytest.mark.toffee_async
标记,pytest 就能够直接运行协程测试用例。
在运行 pytest 时,toffee 会自动收集测试用例的执行结果,自动统计覆盖率信息,并生成一个验证报告,想要生成该报告,需要在调用 pytest 时添加 --toffee-report
参数。
pytest --toffee-report
默认情况下,toffee 将会为每次运行生成一个默认报告名称,并将报告放至 reports
目录下。可以通过 --report-dir
参数来指定报告的存放目录,通过 --report-name
参数来指定报告的名称。
但此时,由于 toffee 无法得知覆盖率文件名称,因此在报告中无法显示覆盖率信息,如果想要在报告中显示覆盖率信息,需要在每个测试用例中传入功能覆盖组及行覆盖率文件的名称。
@pytest.mark.toffee_async
async def test_adder(request):
adder = DUTAdder(
waveform_filename="adder.fst",
coverage_filename="adder.dat"
)
g = CovGroup("Adder")
env = AdderEnv(adder)
await env.add_agent.exec_add(1, 2, 0)
adder.Finish()
set_func_coverage(request, cov_groups)
set_line_coverage(request, "adder.dat")
上述代码中,在创建 DUT 时,我们传入了波形文件和覆盖率文件的名称,使得 DUT 在运行时可以生成指定名称的覆盖率文件。接着我们定义了一个覆盖组,来收集 DUT 的功能覆盖率信息,具体如何使用将在下个文档中介绍。
接着,调用了 DUT 的 Finish
方法,用于结束波形文件的记录。最终我们通过 set_func_coverage
和 set_line_coverage
函数来设置功能覆盖组及行覆盖率文件信息。
此时再次运行 pytest 时,toffee 将会自动收集覆盖率信息,并在报告中显示。
然而,上述过程过于繁琐,并且为了保证每个测试用例之间文件名称不产生冲突,我们需要在每个测试用例中传入不一样的文件名称。并且在测试用例出现异常时,测试用例并不会运行完毕,导致覆盖率文件无法生成。
因此,toffee-test 提供了 toffee_request
Fixture 来管理资源,简化了测试用例的编写。
# test_adder.py
@pytest.mark.toffee_async
async def test_adder(my_request):
dut = my_request
env = AdderEnv(dut)
await env.add_agent.exec_add(1, 2, 0)
@pytest.fixture()
def my_request(toffee_request: ToffeeRequest):
toffee_request.add_cov_groups(CovGroup("Adder"))
return toffee_request.create_dut(DUTAdder)
Fixture 是 pytest 中的概念,例如上述代码中定义了一个名为 my_request
的 Fixture。如果在其他测试用例的输出参数中含有 my_request
参数,pytest 将会自动调用 my_request
Fixture,并将其返回值传入测试用例。
上述代码中自定义了一个 Fixture my_request
,并在测试用例中进行使用,这也就意味着资源的管理工作都将会在 Fixture 中完成,测试用例只需要关注测试逻辑即可。my_request
必须使用 toffee-test 提供的 toffee_request
Fixture 作为参数,以便进行资源管理,toffee_request
提供了一系列的方法来管理资源。
通过 add_cov_groups
添加覆盖组,toffee-test 会自动将其生成至报告中。
通过 create_dut
创建 DUT 实例,toffee-test 会自动管理 DUT 的波形文件和覆盖率文件的生成,并确保文件名称不产生冲突。
在 my_request
中,可以自定义返回值传入测试用例中。如果想要任意测试用例都可以访问到该 Fixture,可以将 Fixture 定义在 conftest.py
文件中。
至此,我们实现了测试用例资源管理和逻辑编写的分离,无需在每个测试用例中手动管理资源的创建与释放。
在 toffee 中,功能检查点(Cover Point) 是指对设计的某个功能进行验证的最小单元,判断该功能是否满足设计目标。测试组(Cover Croup) 是一类检查点的集合。
定义一个检查点,需要指定检查点的名称及检查点的触发条件(触发条件可以有多个,最终的检查结果为所有条件取“逻辑与”,触发条件称为Cover Bin
)。例如,可以定义了一个检查点,“当加法器运算结果不为 0 时,结果运算正确”,此时,检查点的触发条件可以为 “加法器的 sum 信号不为零”。
当检查点的所有触发条件都满足时,检查点被触发,此时,验证报告将会记录下该检查点的触发。并会提升验证的功能覆盖率。当所有检查点都被触发时,验证的功能覆盖率达到 100%。
编写检查点前,首先需要创建一个测试组,并指定测试组的名称
import toffee.funcov as fc
g = fc.CovGroup("Group-A")
接着,需要往这个测试组中添加检查点。一般情况下,一个功能点对应一个或多个检查点,用来检查是否满足该功能。例如我们需要检查Adder
的cout
是否有0
出现,我们可以通过如下方式添加:
g.add_watch_point(adder.io_cout,
{"io_cout is 0": fc.Eq(0)},
name="cover_point_1")
在上述检查点中,需要观察的数据为io_cout
引脚,检查条件(Cover Bin
)的名称为io_cout is 0
,检查点名称为cover_point_1
。函数add_watch_point
的参数说明如下:
def add_watch_point(target,
bins: dict,
name: str = "", once=None):
"""
@param target: 检查目标,可以是一个引脚,也可以是一个DUT对象
@param bins: 检查条件,dict格式,key为条件名称,value为具体检查方法或者检查方法的数组。
@param name: 检查点名称
@param once,如果once=True,表明只检查一次,一旦该检查点满足要求后就不再进行重复条件判断。
通常情况下,target
为DUT
引脚,bins
中的检查函数来检查target
的value
是否满足预定义条件。funcov
模块内存了部分检查函数,例如Eq(x), Gt(x), Lt(x), Ge(x), Le(x), Ne(x), In(list), NotIn(list), isInRange([low,high])
等。当内置检查函数不满足要求时,也可以自定义,例如需要跨时钟周期进行检查等。自定义检查函数的输入参数为target
,返回值为bool
。例如:
g.add_watch_point(adder.io_cout,
{
"io_cout is 0": lambda x: x.value == 0,
"io_cout is 1": lambda x: x.value == 1,
"io_cout is x": [fc.Eq(0), fc.In([0,1]), lambda x:x.value < 4],
},
name="cover_point_1")
当添加完所有的检查点后,需要在DUT
的Step
回调函数中调用CovGroup
的sample()
方法进行判断。在检查过程中,或者测试运行完后,可以通过CovGroup
的as_dict()
方法查看检查情况。
dut.StepRis(lambda x: g.sample())
...
print(g.as_dict())
在测试case
每次运行结束时,可以通过set_func_coverage(request, cov_groups)
告诉框架对所有的功能覆盖情况进行合并收集。相同名字的CoverGroup
会被自动合并。下面是一个简单的例子:
import pytest
import toffee.funcov as fc
from toffee_test.reporter import set_func_coverage
g = fc.CovGroup("Group X")
def init_function_coverage(g):
# add your points here
pass
@pytest.fixture()
def dut_input(request):
# before test
init_function_coverage(g)
dut = DUT()
dut.InitClock("clock")
dut.StepRis(lambda x: g.sample())
yield dut
# after test
dut.Finish()
set_func_coverage(request, g)
g.clear()
def test_case1(dut_input):
assert True
def test_case2(dut_input):
assert True
# ...
在上述例子中,每个case
都会通过dut_input
函数来创建输入参数。该函数用yield
返回dut
,在运行case
前初始化dut
,并且设置在dut
的step
回调中执行g.sample()
。运行完case
后,调用set_func_coverage
收集覆盖率,然后清空收集的信息。所有测试运行完成后,可在生成的测试报告中查看具体的覆盖情况。
使用 toffee,你已经可以搭建出一个完整的验证环境,并且方便地去编写测试用例了。然而在实际的业务中,往往无法理解如何开始上手,并最终完成验证任务。实际编写代码后,会遇到无法正确划分 Bundle,无法正确理解 Agent 的高级语义封装,搭建完环境之后不知道做什么等问题。
在这一节中,将会介绍如何从头开始完成一个新的验证任务,以及如何更好地使用 toffee 来完成验证任务。
拿到一个新的设计后,往往面对的是几十或数百个输入输出信号,如果直接看这些信号,很可能一头雾水,感觉无从下手。在这时,你必须坚信,输入输出信号都是设计人员来定义的,只要能够理解设计的功能,就能够理解这些信号的含义。
如果设计人员提供了设计文档,那么你可以阅读设计文档,了解设计的功能,并一步步地将功能与输入输出信号对应起来,并且要清楚地理解输入输出信号的时序,以及如何使用这些信号来驱动设计。一般来说,你还需要阅读设计的源代码,来找寻更细节的接口时序问题。
当大致了解了 DUT 的功能,并明白如何驱动起 DUT 接口之后,你就可以开始搭建验证环境了。
搭建环境的第一件事,就是根据接口的逻辑功能,将其划分为若干个接口集合,我们可以每一个接口集合视作一个 Bundle。划分为的每个 Bundle 都应是独立的,由一个独立的 Agent 来驱动。
但是,往往实际中的接口是这样的:
|---------------------- DUT Bundle -------------------------------|
|------- Bundle 1 ------| |------ Bundle 2 ------| |-- Bundle 3 --|
|-- B1.1 --| |-- B1.2 --| |-- B2.1 --|
那么问题就出现了,例如究竟是应该为 B1.1, B1.2 各自创建一个 Agent,还是应该直接为 Bundle 1 建立一个 Agent 呢?
这还是取决于接口的逻辑功能,如果需要定义一个独立的请求,这个请求需要对 B1.1 和 B1.2 同时进行操作,那么就应该为 Bundle 1 创建一个 Agent,而不是为 B1.1 和 B1.2 分别创建 Agent。
即便如此,为 B1.1 和 B1.2 定义 1.2 也是可行的,这增添了 Agent 的划分粒度,但牺牲了操作的连续性,上层代码和参考模型的编写都会变得复杂。因此选择合适的划分粒度是需要对具体业务的权衡。最终的划分,所有的 Agent 加起来应该能覆盖 DUT Bundle 的所有接口。
实践中,为了方便 DUT 的连接,可以定义一个 DUT
Bundle,一次性将所有的接口都连接到这个 Bundle 上,由 Env 将其中的子 Bundle 分发给各个 Agent。
当 Bundle 划分完成后,就可以开始编写 Agent 来驱动这些 Bundle 了,你需要为每个 Bundle 编写一个 Agent。
首先,可以从驱动方法开始写起,驱动方法实际上是对 Bundle 的一种高级语义封装,因此,高级语义信息应该携带了足以驱动 Bundle 的所有信息。如果 Bundle 中存在一个信号需要数字,但参数中并没有提供与这一信号相关的信息,那么这种高级语义封装就是不完整的。应尽量避免在驱动方法中对某个信号值进行假定,如果对这一信号在 Agent 中进行假定,DUT 的输出将会受到这一假定的影响,可能导致参考模型与 DUT 的行为不一致。
同时,这一高层封装也决定了参考模型的功能层级,参考模型会直接与高层语义信息进行交互,并不会涉及到底层信号。
如果参考模型需要用函数调用模式编写,那么应该将 DUT 的输出通过函数返回值来返回。如果参考模型需要用独立执行流模式编写,那么应该编写监测方法,将 DUT 的所有输出转换成高层语义信息,通过监测方法输出。
当所有的 Agent 编写完成后,或者挑选之前已有的 Agent,就可以将这些 Agent 封装成 Env 了。
Env 封装了整个验证环境,并确定了参考模型的编写规范。
参考模型的编写没有必要在 Env 编写完成之后再开始,可以与 Agent 的编写同时进行,并实时编写一些驱动代码,来检验编写的正确性。当然如果 Agent 的编写特别规范,编写完整 Env 后再编写参考模型也是可行的。
参考模型最重要的是选择合适的编写模式,函数调用模式和独立执行流模式都是可行的,但在不同的场景下,选择不同的模式会更加方便。
编写好 Env 以及参考模型后,并不能直接开始编写测试用例,因为此时并没有测试用例的编写方向,盲目的编写测试用例,没有办法让待测设计验证完全。
首先需要列出功能点及测试点列表。功能点是待测设计支持的所有功能,例如对于一个算术逻辑单元(ALU)来说,功能点的形式可能是“支持加法”,“支持乘法”等。每个功能点需要对应一个或多个测试点,测试点通过将功能划分为不同的测试场景,来验证功能点是否正确。例如对于“支持加法”这个功能点,可能有“当输入都为正数时,加法正确”等测试点。
当功能点及测试点列表确定后,就可以开始编写测试用例了,一个测试用例需要能够覆盖一个或多个测试点,以验证功能点是否正确。所有的测试用例应该能够覆盖所有的测试点(功能覆盖率 100%),以及覆盖所有的设计行(行覆盖率 100%),这样一来就能保证验证的完备性。
如何保证验证的正确性呢?如果采用参考模型比对的方式,当比对失败时,toffee 会自动抛出异常,使得测试用例失败。如果采用直接比对的方式,应该在测试用例中使用 assert
来编写比对代码,当比对失败时,测试用例也会失败。最终,当所有的测试用例都通过时,意味着功能已验证为正确。
编写过程中,你需要使用 Env
中提供的接口来驱动 DUT,如果出现了需要多个驱动方法交互的情况,可以使用 Executor
来封装更高层的函数。也就是说驱动方法级的交互,是在测试用例的编写中完成的。
当行覆盖率和功能覆盖率都达到了 100% 之后,意味着验证已经完成。最终需要编写一个验证报告,来总结验证任务的结果。如果验证出了待测设计的问题,也应在验证报告中详细描述问题的原因。如果行覆盖率或者功能覆盖率没有达到 100%,也应在验证报告中说明原因,报告的格式应该遵循公司内部统一的规范。
https://github.com/XS-MLVP/Example-NutShellCache/blob/master/nutshell_cache_report_demo.pdf
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++语言,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 module以目录的方式表示)
UT_Adder/
├── _UT_Adder.so
├── __init__.py
├── libUTAdder.so
└── libUT_Adder.py
设置PYTHONPATH后,可以在test中import UT_Adder
对于Java和scala基于JVM的编程语言,picker生成的为对应的jar包。
UT_Adder/
├── UT_Adder-scala.jar
└── UT_Adder-java.jar
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
本页将展示使用多种语言验证的各种案例。
以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;
}
CoupledL2
是一个非阻塞的L2 Cache。
下面的代码会对CoupledL2
进行简单的验证,并使用数组作为参考模型,验证过程如下:
addr
、执行AcquireBlock
,请求读取addr
的数据。GrantData
,接收DUT
响应的数据。GrantAck
,响应DUT
。ReleaseData
,向DUT
请求在addr
写入随机数据data
。addr
的数据更新为data
。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;
}