本学习资源介绍验证相关的基本概念、技术,以及如何使用本项目提供的开源工具进行芯片验证
学习本材料前,假定您已经拥有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的.Finish()
方法以通知模拟器任务已完成,进而将文件flush到磁盘。
以加法器为例,以下为测试程序:
from Adder import *
if __name__ == "__main__":
dut = DUTAdder()
for i in range(10):
dut.a.value = i * 2
dut.b.value = int(i / 4)
dut.Step(1)
print(dut.sum.value, dut.cout.value)
dut.Finish() # flush the wave file to disk
运行结束后即可生成指定文件名的波形文件。
使用 GTKWave 打开 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.Finish()
之后会生成覆盖率数据库文件 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 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.Finish()
color.print_blue("\nCache Test End")
def __reset(self):
# Reset cache and devices
# MMIO Test
def test_mmio(self):
if ("mmio_serial" in self.testlist):
# Run test
from ..test.test_mmio import mmio_test
mmio_test(self.cache, self.ref_cache)
else:
print("\nmmio test is not included")
def run(self):
self.setup_class()
# test
self.test_mmio()
self.teardown_class()
pass
if __name__ == "__main__":
tb = TestCache()
tb.run()
运行:
python3 tb_cache.py
以上仅为大致的运行流程,具体可以参考:Nutshell Cache Verify。
6 评估运行结果
运行结束之后可以得到以下数据:
行覆盖率:
功能覆盖率:
可以看到预设的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 Documentation 中查看 Toffee 的详细使用说明。
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;
}