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

返回本页常规视图.

基础工具

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

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

1 - 工具介绍

验证工具的基本使用。

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

Picker 简介

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

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

基于 Picker 进行验证的优点:

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

Picker 目前支持的 RTL 仿真器:

  1. Verilator
  2. Synopsys VCS

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

Picker的工作原理

Python 模块生成

生成模块的过程

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

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

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

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

Python 模块使用

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

使用工具生成Python的DUT类

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

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

驱动DUT的一般流程

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

对应伪代码如下:


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

# 1 创建
dut = DUT()

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

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

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

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

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

# 7 销毁
dut.Finish()

其他数据类型

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

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

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

XData 类

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

主要方法

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

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

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

XPort 类

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

初始化与添加引脚

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

主要方法

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

XClock 类

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

初始化与添加引脚

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

主要方法

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

2 - 波形生成

生成电路波形

使用方法

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

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

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

Python 示例

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

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

from UT_Adder import *

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

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

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

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

查看结果

GTKWave

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

GTKWave

Verdi

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

Verdi

3 - 多文件输入

处理多个Verilog源文件

多文件输入输出

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

文件列表

Cache.sv

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

    CacheStage s2(
        ...
    );

    CacheStage s3(
        ...
    );

    CacheMeta cachemeta(
        ...
    );
endmodule

CacheStage.sv

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

CacheMeta.sv

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

应用方式

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

命令行指定

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

通过文件列表文件指定

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

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

其中src.txt的内容为:

CacheStage.sv
CacheMeta.sv

注意事项

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

4 - 覆盖率统计

覆盖率工具

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

代码行覆盖率

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

Verilator

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

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

使用时的流程如下:

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

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

verilator_coverage

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

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

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

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

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

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

genhtml

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

加法器为例。

adder.jpg

使用示例

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

命令内容如下:

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

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

VCS

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

5 - 多时钟

多时钟示例

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

XClock 中的 FreqDivWith 接口

XClock 函数提供如下分频接口

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

XClock 的一般驱动流程

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

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

方法:

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

举例:

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

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

下面是举例:

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

多时钟案例

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

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

通过picker导出:

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

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


from MultiClock import *
from xspcomm import XClock

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

if __name__ == "__main__":
    test_multi_clock()

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

multi_clock

可以看到:

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

6 - 多实例

多实例示例

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

动态多实例

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

例子:

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

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

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

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

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

from Adder import *

import random

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

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

if __name__ == "__main__":
    main()

注:目前仅支持 verilator模拟器

静态多实例

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

单个模块需要多实例

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

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

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

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

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

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

from Adder import *

import random

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

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

if __name__ == "__main__":
    main()

多个模块需要多实例

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

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

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

    assign random_number = lfsr;
endmodule

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

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

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

封装后dut的引脚定义为:

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

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

对应的测试代码举例为:

from RandomAdder import *

import random

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

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

if __name__ == "__main__":
    main()

7 - 内部信号

内部信号示例

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

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

动机

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


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

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

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

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

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

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

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

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

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

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

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

DPI 直接导出

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

编写信号文件

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

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

UpperCounter:
  - "wire upper"

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

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

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

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

选项支持

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

--internal=[internal_signal_file]

完整命令如下:

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

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

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

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

访问信号

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


print(dut.UpperCounter_upper.value)

优点

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

局限

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

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

VPI 动态获取

TBD

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

8 - 集成测试框架

可用软件测试框架

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

8.1 - PyTest

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

软件测试

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

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

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

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

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

Pytest安装

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

Pytest使用

命名规则

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

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

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

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

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

Pytest 参数

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

pytest -help

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

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

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

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

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

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

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

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

pytest test_se.py -s

Pytest 选择测试用例执行

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

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

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

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

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

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

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

使用Pytest编写验证

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

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

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

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

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

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

Results (4.33s):

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

8.2 - Hypothesis

可用来生成激励

Hypothesis

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

基本概念

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

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

安装

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

pip install hypothesis

import hypothesis

基本用法

属性和策略

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

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

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

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

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

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

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

期望

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

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

假设和断言

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

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

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

编写测试

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

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

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

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

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

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

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

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

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

Results (0.42s):
       1 passed

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