编写规范的验证环境

一个验证任务编写代码的主体工作可以大致分为两部分,验证环境的搭建测试用例的编写

验证环境的搭建 旨在完成对待测设计(DUT)的封装,使得验证人员在驱动 DUT 时,不必面临繁杂的接口信号,而是可以直接使用验证环境中提供的高级接口。如果需要编写参考模型,则参考模型也应是验证环境的一部分。

测试用例的编写 则是测试人员使用验证环境提供的接口,编写一个个测试用例,并让覆盖率尽可能高,对 DUT 进行功能验证。

搭建验证环境是一件相当有挑战的事情,当 DUT 极度复杂,特别是在接口信号繁多的情况下,搭建验证环境的难度会更大。此时,若没有一个统一的规范,验证环境的搭建将会变得混乱不堪,一个人编写的验证环境很难被其他人维护。并且当出现新的验证任务与原有验证任务有交集时,因为原有的验证环境缺乏规范,很难将原有的验证环境复用。

本节将会介绍一个规范的验证环境所应该具备的特性,这将有助于理解 toffee 验证环境的搭建流程。

无法复用的验证代码

以一个简单的加法器为例,该加法器拥有两个输入端口 io_aio_b,一个输出端口 io_sum。 在没有意识到验证代码可能会被用于其他验证任务的情况下,我们可能会编写出这样的驱动代码:

def exec_add(dut, a, b):
    dut.io_a.value = a
    dut.io_b.value = b
    dut.Step(1)
    return dut.io_sum.value

上述代码中,我们编写了一个 exec_add 方法,该方法本质上是对加法器加法操作的一次高层封装。拥有 exec_add 方法以后,我们无需再关心如何对加法器的接口信号进行赋值,也无需关心怎样驱动加法器并获取其输出,只需要调用 exec_add 方法即可驱动加法器完成一次加法操作。

然而,这个驱动函数却有一个很大的弊端,它直接使用了 DUT 的接口信号来与 DUT 进行交互,这也就意味着,这个驱动函数只能用于这个加法器。

与软件测试不同,在硬件验证中我们每时每刻都能碰到接口结构相同的情况。假设我们拥有另一个具有相同功能的加法器,但其接口信号名称分别是 io_a_0io_b_0io_sum_0,那么这个驱动函数对这个加法器则直接失效,无法复用。要想驱动,只能重新编写一个新的驱动函数。

一个加法器尚且如此,倘若我们拿到了一个拥有繁杂接口的 DUT,费尽心思为其编写了驱动代码。当后续发现驱动代码需要迁移至另一个相似结构的接口上时,我们将会面临巨大的工作量。例如出现接口名称改变、部分接口缺少但驱动代码中却有引用,部分接口新增等一系列的问题。

出现这种问题的根本原因在于,我们在验证代码中直接对 DUT 的接口信号进行操作,如下图所示,这种做法是不可取的。

+-----------+   +-----------+
|           |-->|           |
| Test Code |   |    DUT    |
|           |<--|           |
+-----------+   +-----------+

将验证代码与 DUT 进行解耦

为了解决上述问题,我们需要将验证代码与 DUT 进行解耦,使得验证代码不再直接操作 DUT 的接口信号,而是通过一个中间层来与 DUT 进行交互。这个中间层是人为定义的一个接口结构,在 toffee 中,我们将这个中间层定义为 Bundle,下文也将会使用 Bundle 来代指这个中间层。

以上述加法器为例,我们可以定义一个 Bundle 结构,其中包含 a, bsum 三个信号,并让测试代码与这个 Bundle 进行直接交互。

def exec_add(bundle, a, b):
    bundle.a.value = a
    bundle.b.value = b
    bundle.Step(1)
    return bundle.sum.value

此时,在 exec_add 中并没有直接操作 DUT 的接口信号,甚至不知道 DUT 中的接口信号名称是什么,其直接与我们在 Bundle 中定义的接口信号进行交互。

那如何让 Bundle 中的信号与 DUT 的引脚进行关联呢?只需要添加一个连接操作即可,最简单的连接方法是,我们直接指定 Bundle 中的每一个信号具体与 DUT 的哪一个引脚相连,例如:

bundle.a   <-> dut.io_a
bundle.b   <-> dut.io_b
bundle.sum <-> dut.io_sum

如果 DUT 的接口信号名称发生了变化,我们只需要修改这个连接过程,例如:

bundle.a   <-> dut.io_a_0
bundle.b   <-> dut.io_b_0
bundle.sum <-> dut.io_sum_0

这样一来,无论 DUT 的接口如何变化,只要其拥有相同的结构,都可以通过原有的驱动代码来驱动,需要修改的仅仅是连接过程。此时的验证代码与 DUT 的关系如下图所示:

+-----------+  +--------+             +-----------+
|           |->|        |             |           |
| Test Code |  | Bundle |-- connect --|    DUT    |
|           |<-|        |             |           |
+-----------+  +--------+             +-----------+

在 toffee 中,我们为 Bundle 提供了简洁的定义过程以及大量的连接方法,可极大方便中间层的定义与连接,除此之外,Bundle 还提供了大量的实用功能来帮助验证人员更好的与接口信号进行交互。

将 DUT 接口进行分类驱动

我们已经知道,需要定义一个 Bundle 来完成测试代码与 DUT 之间的解耦,但是如果 DUT 的接口信号过于复杂,我们将会面临一个新的问题————可能只有这一个 DUT 能与这个 Bundle 进行连接。因为我们会定义一个含有众多信号的中间层,将整个 DUT 的引脚全部涵盖在内,这样一来,只有与整个 DUT 结构相同的 DUT 才能与这个 Bundle 进行连接,这个条件是极为苛刻的。

这样一来,中间层的设置也就失去意义了。但我们观察到,每个 DUT 的接口结构往往是有规律的,他们通常由若干个具有独立功能的接口组成。例如 这里 提到的双端口栈,它的接口则由两个结构完全相同的子接口构成。因此,相比于将整个双端口栈的接口信号全部涵盖在一个 Bundle 中,我们可以将其拆分为两个 Bundle,每个 Bundle 分别对应一个子接口。

并且,对于双端口栈来说,两个子接口的结构是完全相同的,因此我们可以使用同一个 Bundle 来描述这两个子接口,无需重复定义。既然他们拥有同样的 Bundle,那么我们针对这个 Bundle 编写的驱动代码也是完全可以复用的!这就是验证环境可复用性的魅力。

总结一下,对于所有的 DUT 来说,我们应该将其接口信号划分成若干个独立的子接口,每个子接口拥有独立的功能,然后为每个子接口定义一个 Bundle,并编写与这个 Bundle 相关的驱动代码。

此时,我们的验证代码与 DUT 的关系如下图所示:

+-----------+  +--------+             +-----------+
|           |->|        |             |           |
| Test Code |  | Bundle |-- connect --|           |
|           |<-|        |             |           |
+-----------+  +--------+             |           |
                                      |           |
     ...           ...                |    DUT    |
                                      |           |
+-----------+  +--------+             |           |
|           |->|        |             |           |
| Test Code |  | Bundle |-- connect --|           |
|           |<-|        |             |           |
+-----------+  +--------+             +-----------+

同时,我们搭建验证环境的思路也变得清晰起来,只需要为分别每个独立的子接口编写高层封装的操作即可。

驱动独立接口的结构

我们为每个 Bundle 都编写了高层封装的操作,这些代码之间相互独立,拥有极高的可复用性。如果我们把不同 Bundle 高层封装之间的交互逻辑都划分出去,放到测试用例中来完成,那么多个 Test Code + Bundle 的组合将会完成对整个 DUT 的驱动环境的搭建。

我们不妨对单个 Test Code + Bundle 的组合起一个名字,在 toffee 中,该结构被称之为 AgentAgent 独立于 DUT,负责完成对一类接口的所有交互操作。

此时,我们的验证代码与 DUT 的关系如下图所示:

+---------+    +-----------+
|         |    |           |
|  Agent  |----|           |
|         |    |           |
+---------+    |           |
               |           |
    ...        |    DUT    |
               |           |
+---------+    |           |
|         |    |           |
|  Agent  |----|           |
|         |    |           |
+---------+    +-----------+

因此编写驱动环境的过程,就是编写一个个 Agent 的过程。但至此,我们还没有讨论过编写 Agent 的具体规范,如果每个人编写的 Agent 都各不相同,那么验证环境依然会变得较为混乱。

编写规范的 “Agent”

为了探寻如何编写一个规范的 Agent,我们首先要明白 Agent 主要完成怎样的功能。如上文所述,Agent 中实现了对一类接口的所有交互操作,并提供高层封装。

我们首先来探讨,验证代码究竟会与接口产生怎样的交互,假设验证代码具备读取输入端口的能力,我们可以按照验证代码是否主动发起交互,与接口的方向性来划分为以下几类交互。

  1. 验证代码主动发起

    • 验证代码主动读取输入/输出端口的值

    • 验证代码主动给输入端口赋值

  2. 验证代码被动接收

    • 验证代码被动接收输出/输出端口的值

这两类操作涵盖了验证代码侧与接口之间的的所有操作,因此 Agent 也必须具备这两类操作的能力。

验证代码主动发起的交互

我们首先考虑验证代码主动发起的两类交互,对这两类交互完成高层封装,就要求 Agent 必须具备两种能力:

  1. 驱动发起者能够将上层语义信息转换为对接口信号的赋值操作

  2. 能够将接口信号转换为上层语义信息并返回给发起者

能够完成这两类交互的形式有很多。但由于 toffee 是一个基于软件测试语言的验证框架,且我们希望验证代码的编写形式尽量简洁,toffee 中规范了使用 函数 为载体来完成这两类交互。

因为函数是编程语言中最基本的抽象单元,输入参数可直接作为上层语义信息并传递给函数体,函数体中通过赋值和读取操作完成上层语义信息与接口信号的转换,最后通过返回值将接口信号转换为上层语义信息并返回给发起者。

toffee 此类用于验证代码主动发起交互的函数称之为驱动方法,在 toffee 中,我们使用 driver_method 装饰器来标记此类函数。

验证代码被动接收的交互

接下来我们考虑验证代码被动接收的交互,并对此类交互完成高层封装。这类交互的呈现形式为,验证代码并不去主动发起对接口的输入输出,而是当满足特定的条件时,接口会将输出信号传递给验证代码。

例如,验证代码想要在 DUT 完成一次操作后,被动获取 DUT 的输出信号并转换为上层语义信息。再如,验证代码想在每一周期都被动获取 DUT 的输出信号并转换为上层语义信息。

driver_method 类似,toffee 中同样规范了使用 函数 为载体来完成这类交互,只不过这个函数没有输入参数,并且不受验证代码的主动控制。当特定条件满足时,该函数会被调用,完成对接口信号的读取操作,并转换为上层语义信息。该信息会被保存,等待验证代码的使用。

类似的,toffee 将此类用于验证代码被动接收交互的函数称之为监测方法,在 toffee 中,我们使用 monitor_method 装饰器来标记此类函数。

一个规范的 “Agent” 结构

综上所述,我们使用 函数 作为载体来完成验证代码与接口的所有交互,并将其分为两类:驱动方法监测方法。这两类方法分别完成验证代码主动发起的交互和被动接收的交互。

因此,编写 Agent,其实就是编写一系列的驱动方法和检测方法。一个 Agent 编写好之后,也只需要提供其内部驱动方法和检测方法的列表,便能描述整个 Agent 的功能。

一个 Agent 的结构可以使用下图来描述:

+--------------------+
| Agent              |
|                    |
|   @driver_method   |
|   @driver_method   |
|   ...              |
|                    |
|   @monitor_method  |
|   @monitor_method  |
|   ...              |
|                    |
+--------------------+

验证 DUT 的功能正确性

目前为止,我们完成了对 DUT 高层操作的封装,并使用函数完成了验证代码与 DUT 之间的交互。此时,为了验证 DUT 的功能是否正确,我们会编写测试用例,通过我们封装好的驱动方法来驱动起 DUT 完成特定的执行过程。与此同时,监测方法 在自动地被调用并监测收集 DUT 的相关信息。

然而如何去验证 DUT 的功能是否正确呢?

在测试用例中对 DUT 进行驱动后,能够得到 DUT 输出的信息包含两种,一种是通过驱动方法主动获取的信息,另一种是通过检测方法收集到的信息。因此,验证 DUT 的功能是否正确,实际上就是验证这两种信息是否符合预期。

那如何判断这两种信息是否符合预期呢?

一种情况是,我们本来就知道 DUT 的输出应该是什么,或是满足什么样的条件。这时我们在测试用例中拿到这两种信息后,直接对这两种信息(或是其中一种,取决于验证用例的需求)进行检查即可。

另一种情况是,我们并不知道 DUT 的输出应该是什么。此时我们只能编写一个与 DUT 功能相同的 参考模型(RM, Reference Model),当主动发送给 DUT 任何信息时,同时将这些信息主动发送给参考模型。

为了对验证两类信息是否符合预期。当主动获取 DUT 的输出信息是时,同时主动去获取参考模型中的输出信息,并将两者进行比对;当监测方法监测到 DUT 的输出信息时,同时参考模型也应主动提供输出信息,并将两者进行比对。

这便是验证 DUT 功能正确性的两类方法:直接比对参考模型比对

如何添加参考模型

对于直接比对的验证方法,我们直接在测试用例中编写比对逻辑即可。而如果我们使用参考模型的比对方式,测试用例可能会面临一些比较繁琐的步骤:驱动接口的同时,需要将信息同时发送给模型;收集接口信息的同时,需要同时收集模型的信息;对于被动监测的接口信息,还要额外编写逻辑来完成与参考模型的比对。这样一来,测试用例的代码会混乱,与参考模型的交互逻辑会混杂在测试用例中,不利于代码的维护。

我们注意到,对于驱动函数的每一次调用,代表着对 DUT 的每一次操作,这些操作都需要转发给参考模型。而参考模型的编写无需考虑 DUT 接口是怎样驱动的,它只需要分析高层语义信息,并且完成自身状态更新即可,因此参考模型中只需要获取上层发来的高层语义信息(即驱动函数的输入参数)。

所以在参考模型中只需要实现当驱动函数被调用时,如何做出反应即可。对于将调用信息传递给参考模型的操作完全可以交由框架来完成。与此同时,每一次操作的返回值、监测方法的检测值比较,也可以通过框架来自动完成。

这样一来,测试用例中只需要编写驱动 DUT 的逻辑,参考模型的同步与比对工作将会被框架自动完成。

为了实现参考模型的同步,toffee 中定义了一套参考模型的匹配规范,只需要按照此规范编写参考模型调用接口,便可实现参考模型的自动转发与比较。同时为了方便参考模型与整个验证环境相关联,toffee 中提供了 Env 的概念来打包整个验证环境,写好参考模型后,只需要将其与 Env 相关联,便可实现参考模型的自动同步。

总结

这样一来,我们的验证环境变成了如下的结构:

+--------------------------------+
| Env                            |
|                  +-----------+ |  +-----------+
|   +---------+    |           | |  |           |
|   |  Agent  |----|           | |->| Reference |
|   +---------+    |    DUT    | |  |   Model   |
|   +---------+    |           | |<-|           |
|   |  Agent  |----|           | |  |           |
|   +---------+    |           | |  +-----------+
|       ...        +-----------+ |
+--------------------------------+

此时,整个验证环境的搭建变得清晰而规范,需要复用时,只需挑选合适的 Agent,连接至 DUT,并打包到 Env 中。需要编写参考模型时,只需要根据 Env 中调用接口规范,实现参考模型的逻辑即可。

测试用例的编写与验证环境是分开的,当测试环境搭建完毕后,测试环境的接口就是每个 Agent 中的调用接口。测试用例可以清晰地使用这些接口来完成驱动逻辑的编写。参考模型的同步及对比工作也将由框架自动完成。

这是 toffee 中验证环境的搭建思想,toffee 中提供了大量的功能,来帮助你建立起这样一个规范的验证环境。同时,它提供了测试用例的管理方法,使得测试用例更易于编写和管理。