GLCC万众一芯单元验证(四)
前言
个人感觉学习 Toffee 的话,还是看 Toffee 的 官方文档 比较好。本文将会就我本人的理解,写一点需要重点理解的东西。
异步环境
异步是符合真实的硬件运行逻辑的,因为芯片中的电路在每一个时钟周期后,所有的引脚信号都会更新。
1 |
|
在 toffee 框架中,通过 toffee.run() 来启动一个事件循环,其中的参数为事件循环 start_test。而事件循环是由各个异步事件组成的,事件循环用于管理多个同时运行的协程,协程之间可以相互等待并通过事件循环来进行切换。start_test() 是一个异步时钟(协程任务),它和 my_coro(dut) 是两个协程。
Bundle 与 DUT 的绑定
一共有以下几种方法进行绑定,这一步很关键,一定要确保它们绑定成功,不然下层出问题,上层很难发现。
直接绑定
例如有一个简单的加法器 DUT,其接口名称与 Bundle 中定义的名称相同,就可以直接使用默认构造方法实例化 Bundle:
1 |
|
这样要求 DUT 的接口和 Bundle 中接口名称要完全一致,这不是我们想要的结果,因为 DUT 的写法如果很复杂,Bundle 中也必须定义这么复杂。
通过字典进行绑定
使用 Bundle 提供的 from_dict 方法实例化 Bundle:
1 |
|
这种优点是简单粗暴,缺点是书写麻烦。
通过前缀进行绑定
假设 Bundle 中的接口名称与 DUT 中的接口名称拥有如下对应关系:
1 |
|
使用 Bundle 提供的 from_prefix 方法实例化 Bundle:
1 |
|
通过正则表达式进行绑定
假设 Bundle 中的接口名称与 DUT 中的接口名称之间的对应关系为:
1 |
|
这是最好的方法,管什么妖魔鬼怪似的 DUT 接口名称,可以使用 from_regex 方法实例化 Bundle
1 |
|
对于上面代码中的正则表达式,io_a_in 会与正则表达式成功匹配,唯一的 捕获组 捕获到的内容为 a。a 这个名称与 Bundle 中的接口名称 a 匹配,因此 io_a_in 会被正确绑定至 a。
绑定子 Bundle
举一个例子:
1 |
|
有时候,DUT 接口很复杂,我们在划分功能点的时候,需要将整个 IO 接口划分多个 Bundle,以上面的 DUT 为例,它支持加法和乘法,就得划分为三部分:
1 |
|
在上面的代码中,我们定义了一个 ArithmeticBundle,它包含了自己的信号 selector。除此之外它还包含了一个 AdderBundle 和一个 MultiplierBundle,这两个子 Bundle 分别被命名为 adder 和 multiplier。
当我们需要访问 ArithmeticBundle 中的子 Bundle 时,可以通过 . 运算符来访问:
1 |
|
需要注意的是,子 Bundle 的创建方法去匹配的信号名称,是经过上一次 Bundle 的创建方法进行处理过后的名称。例如在上面的代码中,我们将顶层 Bundle 的匹配方式设置为 from_prefix(‘io_’),那么在 AdderBundle 中去匹配的信号,是去除了 io_ 前缀后的名称。
为什么要设置子 Bundle 呢?通过子 Bundle,可以实现系统的层次化设计。这有助于分解复杂的系统,使得每个模块都能独立工作,同时保证它们之间的接口清晰且易于管理。比如,我们通过设置不同的 Agent 来测试这个模块的不同功能,还可以在系统测试的时候,将所有的 Bundle 之上的 Agent 结合起来进行系统测试。
编写 Agent
Agent 在 toffee 验证环境中实现了对一类 Bundle 中信号的高层封装,使得上层驱动代码可以在不关心具体信号赋值的情况下,完成对 Bundle 中信号的驱动及监测。
我的想法是,所有的驱动方法全部由 Agent 提供,Bundle 只是 DUT 单纯的 IO 信号划分。换句话说,Bundle 是第一次对 DUT 的封装,Agent 是在 Bundle 之上的第二次基本功能的第二次封装。
一个 Agent 由 驱动方法(driver_method) 和 监测方法(monitor_method) 组成,其中驱动方法用于主动驱动 Bundle 中的信号,而监测方法用于被动监测 Bundle 中的信号。
我的理解是,一个 Agent 最好只涉及到一个功能,而这个功能可能涉及多个 Bundle,但是最好只涉及到一个 Bundle,这样能解耦的更彻底。
比如,DUTArithmeticModule 划分了 3 个 Bundle,但是只有两个功能,分别是乘法和加法。但是要做几个 Agent 呢?答案是三个:
- 一个加法
- 一个乘法
- 一个结合加法、乘法以及 selector
每一个 Agent 中都要写驱动方法和监测方法,这些方法可能有多个。
比如对于 AdderBundle,我们可以在它之上设计 AdderAgent:
1 |
|
这样一个 Agent 就做好了。
搭建 Env
有了 Agent,似乎还不够,因为测试环境应该是一个整体,它应该包含定义的功能 Agents 以及参考模型,然后还需要我们将 Agents 中的观测方法获取来的数据和参考模型做匹配,这样就又有了覆盖组观测点的概念。总之,Env 是最后一层,它是一个有机体。
创建 Env
为了定义一个 Env,需要自定义一个新类,并继承 toffee 中的 Env 类。下面是一个简单的 Env 的定义示例:
1 |
|
这个 Env 中定义了两个 Agent,它们由传进来的两个 bundle 实例化:
1 |
|
这样,Env 中就有了基本的 Agent。接下来就要继续添加参考模型了。
添加参考模型
参考模型也可以通过这些接口来进行编写,编写好的参考模型都可以使用 attach 操作直接附加到 Env 上,由 Env 来完成参考模型的自动同步,方式如下:
1 |
|
一个 Env 可以附加多个参考模型,这些参考模型都将会被 Env 自动同步。
如何编写参考模型
参考模型很重要,没有参考模型就无法对 Agent 乃至 DUT 进行测试。
函数调用模式
这我认为是最简单的模式了,直接编写一个和 Agent 能绑定的函数就行,比如:
1 |
|
独立执行流模式
独立执行流模式就有点复杂了,它会主动获取 Agent 中的驱动参数以及观测参数,并主动就行对比:
1 |
|
可以看到,它有两个成员 add_port、sum_port,定义了这两个接口后,上层代码在给参考模型发送数据时,并不会触发参考模型中的某个函数,而是会将数据发送到 add_port 这个驱动接口中。同时,DUT的输出数据也会被发送到 sum_port 这个监测接口中。
那么参考模型如何去使用这两个接口呢?在参考模型中,有一个 main 函数,这是参考模型执行的入口,当参考模型创建时, main 函数会被自动调用,并在后台持续运行。在上面代码中 main 函数里,参考模型通过不断重复这一过程:等待 add_port 中的数据、计算结果、获取 sum_port 中的数据、比较结果,来完成参考模型的验证工作。
参考模型会主动向 add_port 请求数据,如果 add_port 中没有数据,参考模型会等待数据的到来。当数据到来后,参考模型将会进行计算,之后参考模型再次主动等待 sum_port 中的数据到来。它的执行过程是一个独立的执行流,不受外部的主动调用控制。当参考模型变得复杂时,其将会含有众多的驱动接口和监测接口,通过独立执行流的方式,可以更好的去处理结构之间的相互关系,尤其是接口之间存在调用顺序的情况。
如何编写函数调用模式的参考模型
驱动函数匹配
假如 Env 中定义的接口如下:
1 |
|
那么如果我们想要编写与之对应的参考模型,自然地,我们需要定义这四个驱动函数被调用时参考模型的行为。也就是说为每一个驱动函数编写一个对应的函数,这些函数将会在驱动函数被调用时被框架自动调用。
如何让参考模型中定义的函数能够与某个驱动函数匹配呢?首先应该使用 @driver_hook 装饰器来表示这个函数是一个驱动函数的匹配函数。接着,为了建立对应关系,我们需要在装饰器中指定其对应的 Agent 和驱动函数的名称。最后,只需要保证函数的参数与驱动函数的参数一致,两个函数便能够建立对应关系。
class StackRefModel(Model):
@driver_hook(agent_name=”port_agent”)
def push(self, data):
pass
@driver_hook(agent_name="port_agent")
def pop(self):
pass
此时,驱动函数与参考模型的对应关系已经建立,当 Env 中的某个驱动函数被调用时,参考模型中对应的函数将会被自动调用,并自动对比两者的返回值是否一致。
监测函数匹配
Toffee 目前支持检测函数的匹配,通过 @monitor_hook 装饰器来表示这个函数是一个监测函数的匹配函数。与 @driver_hook 类似,为了建立对应关系,需要在装饰器中指定其对应的 Agent 和监测函数的名称,例如:
1 |
|
monitor_hook 含有一个固定的额外参数,例如上面代码中的 item,用于接收监测函数的返回值。当 Env 中的监测函数被调用时,参考模型中对应的 monitor_hook 函数将会被自动调用,在函数体的实现中可以判断监测函数的返回值是否符合预期。
monitor_hook 支持 driver_method 所支持的所有匹配方式。
Agent 匹配
如果在参考模型中,一个一个写,看起来会有点啰嗦,因此这里把整个 Agent 作为一个单元进行匹配,而不是 Agent 中的各个子函数。
Toffee 还提供了 agent_hook,用于一次性匹配多个驱动函数或监测函数,方式如下:
1 |
|
在这个例子中,port_agent 函数将会匹配 port_agent Agent 中的所有驱动函数与监测函数。当 Agent 中的任意一个驱动函数被调用时,port_agent 都会被自动调用,并将驱动函数的名称与参数传入。当 Agent 中的任意一个监测函数被调用时,port_agent 也会被自动调用,并将监测函数的名称与返回值传入。此外,如果 agent_hook 有返回值,框架会使用此函数的返回值与驱动函数的返回值进行对比。
与驱动函数类似,@agent_hook 装饰器也支持当函数名与 Agent 名称相同时省略 agent_name 参数。
1 |
|
如果需要同时匹配多个 Agent,可以使用 agent_hook 中的 agents 参数,例如:
1 |
|
如果需要同时匹配多个驱动函数或监测函数,可以使用 agent_hook 中的 methods 参数,并指定需要匹配的驱动函数或监测函数的路径,例如:
1 |
|
测试用例
测试用例就是调用 Agents 中的方法,来驱动 DUT,并自动调用和对比参考模型对应的方法,达到验证的作用。
当验证环境搭建完成后,编写测试用例用于验证设计的功能是否符合预期。对于硬件验证中的验证,两个重要的导向是:功能覆盖率、行覆盖率,功能覆盖率意味着测试用例是否覆盖了设计的所有功能,行覆盖率意味着测试用例是否触发了设计的所有代码行。
同时调用不同的驱动函数
为什么要这样?有的 Agent 可能有这样的需求,比如一个 AXI 接口,可能会同时发起读事务和写事务,这时候就得测试事务同时进行的场景。考虑这样一个Env:
1 |
|
我们期望在测试用例中同时调用 port1_agent 和 port2_agent 的 push 函数,以便同时驱动两个接口,就可以这样做:将两个不同的 Agent 中的方法包装成一个执行块:
1 |
|
我们使用 async with 来创建一个 Executor 对象,并建立一个执行块,通过直接调用 exec 可以添加需要执行的驱动函数。当 Executor 对象退出作用域时,会将所有添加的驱动函数同时执行。Executor 会自动等待所有驱动函数执行完毕。
同一驱动函数被多次调用
1 |
|
这个也很好理解。
功能检查点
当我们把 Env 设计好后,并写好了测试用例后,我就可以评估整个测试过程了。如何定量分析测试用例的完备与否呢(是否遗漏了某些情况)?
在 toffee 中,功能检查点(Cover Point) 是指对设计的某个功能进行验证的最小单元,判断该功能是否满足设计目标。测试组(Cover Croup) 是一类检查点的集合。
编写检查点
首先得创建一个测试组,并把测试点添加到测试组中,这里面有几个概念:
1 |
|
g 是一个测试组,cover_point_1 是 g 的一个检查点的名称,adder.io_cout 则是检查目标。针对这个检查点,可以看一些条件,如果这些条件在测试案例测试的时候,都达到了,说明这个检查目标已经被检查了。这些条件就被称为检查条件,也被称为 Cover Bin。
1 |
|
当添加完所有的检查点后,需要在 DUT 的 Step 回调函数中调用 CovGroup 的 sample() 方法进行判断。在检查过程中,或者测试运行完后,可以通过 CovGroup 的 as_dict() 方法查看检查情况。