对UVM的初步理解就是类似于java的spring框架,是编程的方法论并为编程人员制定规则,让编程更加规范
类库:verificationacademy.com/verification-methodology-reference/uvm/docs_1.2/html/
1. UVM概念
- UVM就一种验证方法学而言,它的思想却并不是必须要与某一种语言绑定的。因此,UVM的验证方法学通过吸取eRM、AVM、OVM、UVM等之前不同方法学的优点,集众家之所长。
- 所有的验证方法学服务目的都在于提供一些可以重用的类来减轻在项目之间水平复用和垂直复用的工作量,而同时对于验证新人又能提供一套可靠的框架,摆脱搭建房子构思图纸的苦恼。
- UVM面向所有数字设计,涵盖了从模块级到芯片级,ASIC到FPGA,以及控制逻辑、数据通路到处理器验证的全部场景。
- UVM中的Universal(通用)的含义代表的是该方法学可以适用于大多数的验证项目,而它自身提供的基础类库(basic class library)和基本验证结构可以让具有不同软件编程经验的验证人员能够快速构建起一个结构可靠的验证框架。
- UVM的框架构建类和测试类能够减轻环境构建的负担,进而将更多的精力集中在如何制定验证计划和创建测试场景。
UVM框架,包括UVM的类库和核心机制、核心的UVM组件和层次构建方式、常见的UVM组件间的通信方式、UVM测试场景的构成、UVM的寄存器模型应用。
UVM类库地图
类库网址:https://verificationacademy.com/verification-methodology-reference/uvm/docs_1.2/html/
10大类型:
核心基类
克隆,打印等方法都已经进行了封装
工厂(factory)类
事务(transaction)和序列(sequence)类
trans的有序组合——序列
结构创建(struct creation)类
环境组件(environment component)类
driver,monitor,generator,checker等
通信管道(channel)类
mailbox
信息报告(message report)类
为什么不用$display()?
寄存器模型(register model)类
线程同步(thread synchronizetion)类
event,旗语等
事务接口(transaction interface)类
UVM组件
sequencer:承担了generator发送激励的功能,产生激励的部分在架构图中已经看不到了,属于动态部分sequence
UVM环境-phase机制
硬件:仿真开始前,已经完成了实例化和连接
软件:运行时动态加载对象,并生成连接
多层嵌套的组件,需要在上层次中创建对象和建立连接(monitor和checker)
UVM验证环境是按照UVM_PHASE有序执行的,UVM_PHASE按照是否消耗时间分为function_phase和task_phase.function_phase如build_phase、connect_phase,这些phase是不消耗时间的;task_phase如main_phase、reset_phase这些phase是消耗时间的.
1.function_phase介绍
在function_phase中build_phase实现实例化工作,主要是对uvm_component及其派生类的实例化;connect_phase用来完成诸如port的链接这些动作;
end_of_elaborattion_phase、start_of_simulation_phase不常用,不过也可以根据平台的需要做一些预处理的动作;
report_phase主要在结束时对DUT和RM状态做一下检查;涉及到读取寄存器状态,需要在task_phase中检查.
2.function_phase顺序
build_phase是自顶而下的执行顺序,即从uvm_test_top开始执行,进而到uvm_env、uvm_rm的build_phase;其他function_phase正好相反,是自下而上的执行顺序.
工厂机制
从设计模式出发就很好理解了,通过工厂类中的生成方法制造需要类型的对象!!
- UVM中创建的任何一个类都需要工厂注册
- 注册的两大类型:UVM_component和UVM_object(10大类型里面除了port类外,全都由这两个类制造)
- component:常见组件:不动产,构建验证环境
- object:非固定资产,如trans类,帮助实现验证场景,可以动态产生
范式:
- UVM里任何一个组件类都需要注册
- 构造方法,new函数的参数固定
- 利用new()方法创建对象:可以在构造方法中执行,也可以放在build_phase中
- 工厂机制创建对象:只能在build_phase中执行,因为倘若进行类型覆盖,类型转换就是在new的时候发生,如果直接在new中进行type_id的创建,那覆盖方法将毫无意义!!
1 | 例1 uvm_object类型: |
1 | //工厂和new创建对象的区别 |
问:factory是独有的,有且只有一个,这保证了所有的component的注册都在一个“机构”中。那么,在上面代码以及读者构写的代码中,并没有主动出现factory例化代码,这个factory何时被例化,而隐藏在哪里呢?
component和object虽然在创建时都需要传递create(name, parent),但是最终创建出来的component是会表示在UVM层次结构中的UVM的核心机制都浓缩在了一个类uvm_coreservice_t,该类内置了UVM世界核心机制的组件和方法,它们主要包括了:
- 唯一的uvm_factory,该组件用来注册、覆盖和例化。
- 全局的report_server,该组件用来做消息统筹和报告。
- 全局的tr_database,该组件用来记录transaction记录。
- get_root()方法用来返回当前UVM环境的结构顶层对象。
因此,如果在仿真器中寻找这个隐藏的核心服务组件对象,可以先搜寻类uvm_coreservice_t,再在其内部搜寻局部静态变量uvm_coreservice_t::inst。找到之后,进一步展开,就可以发现其其内部的几个主要组件实例factory、report_server、tr_database。无论是对于uvm_coreservice_t::inst,还是uvm_default_coreservice_t的成员变量factory、report_server等,都遵循一个原则,即如果被“需要”,那么会例化只且例化一次。
在上面的简单例码中,由于两个宏define uvm_component_utils(T)和
define uvm_object_utils(T)需要uvm_coreservie_t::inst,所以在仿真开始后,可以从仿真器的对象浏览窗口中看到下面的对象关系图。该图从Synopsys VCS ‘object’窗口中截取,而今后用来表示对象关系、UVM结构等层次关系的图时,我们也将从VCS工具中截取示例说明。需要额外补充一点的是,之前的SV核心篇章的实例仿真均是在MentorGraphics QuestaSim中进行的,而到了UVM核心篇章的实例仿真均在Synopsys VCS仿真器中进行
在下面这个对象图中,有4列,从左至右分别是层次、类型、对象ID和对象创建时间。对于对象ID和对象创建时间,我们可以从下图的对象中看到,已经被创建的对象均是在0时刻创建的,而且都是它们对应类型的第一个实例(用@1表示,也应该是唯一一个)。
了解了UVM核心机制组件的建立,我们来继续分解上面的宏define uvm_component_utils(T)和
define uvm_object_utils(T)。
这里,以uvm_component_utils(T)为例,其注册的机制发生在该宏的进一步拆解中:
1 |
|
component和object虽然在创建时都需要传递create(name, parent),但是最终创建出来的component是会表示在UVM层次结构中的,而object则不会显示在层次当中。
uvm_component::new(name, parent)
保留两个参数,且缺一不可就是为了通过类似“钩子”的做法,一层层由底层勾住上一层,这样就能够将整个UVM结构串接起来了。(如agent和driver、monitor)
uvm_object::new(name)则没有parent参数,因此也不会显示在UVM层次中,也只能作为configuration或者transaction等用来做传递的配置结构体或者抽象数据传输的数据结构体,成为uvm_component的成员变量。
注意:虽然在实际的uvm_default_factory中,它用来注册所有uvm_component和uvm_object的词典只有一个,但是用来覆盖层次中类型的方式却有不止一种。在上面的图中我们将这些可能用来覆盖的类,抽象到一个词典中为了方便简化注册、创建和覆盖的关系。
在注册过程中,我们通过uvm_component_registry或者uvm_object_registry(均继承与uvm_object_wrapper)来分别注册uvm_component和uvm_object。上面的例子以uvm_component_registry来说明,对于这样一种专门用来注册的类而言,它们自身的方法是专门为配合factory的注册、创建和覆盖而生的,这些方法分别是
- create()
- create_component()
- get()
- get_type_name()
- set_inst_override()
- set_type_override()
我们如果要通过factory来创建对象时,可以使用的方法有:
- create_component_by_name()
- create_component_by_type()
- create_object_by_name()
- create_object_by_type()
覆盖方法
从上面的UVM类型的注册到对象的创建,读者知道了利用factory中注册号的类型,可以通过类型T(类型“箱子”)或者类型名Tname(字符串)来进行。与之类似的,factory也提供了覆盖(override)的特性,用户也可以通过类型覆盖或者实例名覆盖两种方式进行
覆盖后,创建原始类型的请求就可以用来创建新的类型!!
- 无需修改原始代码,保证源代码的封装性
- 新类型必须与原始类型相兼容
使用覆盖的条件:
原有类型和新类型都需要注册
使用create()来创建对象时
覆盖一定要发生在实例化之前,否则将毫无意义~
工厂检查是否原有类型被覆盖,如果是,创建新类型,否则创建原有类型
● set_inst_override(uvm_object_wrapper override_type, string inst_path):实例覆盖,部分UVM对象被覆盖
● set_type_override(uvm_object_wrapper override_type,bit r):类型覆盖,UVM结构层面的替换
以类型覆盖的方法为例:
1 | module factory_override; |
执行结果:
1 | comp1:: c1 is created |
注意:多级组件对同一类型进行覆盖,高层次的组件的优先级更高!
2. 核心基类UVM-object
域的自动化 (Field Automation)
UVM通过一些域的自动化,使得用户在注册UVM类的同时也可以声明今后会参与到对象拷贝、克隆、打印等等操作的成员变量。仍然是上面的例子,我们来看看通过UVM与域的自动化相关的宏,如何简化了对象的拷贝。
比如在SV中使用的trans的clone方法,checker的compare方法等,都可以通过这种方式进行替换,同时也要增加对UVM的配置
1 | module object_copy; |
输出结果:
1 | ------------------------------- |
核心方法
从uvm_object提供的方法和相关的宏操作来看,它的核心方法主要提供与数据操作的相关服务:
Copy:深拷贝
对于实例中的句柄成员变量,在拷贝时会额外创建新的对象!!!这就是深拷贝
代码中省略了box的new方法中对ball的实例创建,在执行copy方法时,b2对b1进行深拷贝,意味着对ball实例也进行了copy,且可以看到b2中的ball实例和b1的ball不是同一个实例
ball类型的内部同样进行了域自动化,这意味着ball的copy同样遵循域自动化的规则!!!那为啥两个b的diameter和color不一样呢?
因为color的域声明为NOCOPY,所以不对其进行赋值;给copy配置了一个回调函数do_copy,即在执行copy后还会执行do_copy,此时篡改了diameter的值
1
2
3些回调函数do_xxx是定义为空的,所以譬如用户执行了copy()函数,那么它会在执行末尾再执行do_copy()函数。所以,do_copy()就是copy()的回调函数。通过
在copy()的执行尾端,来勾住(hook)下一个callback函数do_copy()。如果用户自定义了这些回调函数,那么就可以执行扩展后的方法。
那么,这种普通的回调函数定义就足够了,为什么还要专门定义一个uvm_callback类呢?可以说,通过这个新添加的类,使得回调都有了顺序和继承性。关于顺序和继承性的实现,又是通过两个相关的类uvm_callback_iter和uvm_callbacks #(T, CB)来实现的。接下来,我们依然给出一个实例来说明在uvm_callback的帮助下,回调函数可以玩出什么新的花样?
输出结果:
UVM_INFO @ 0: reporter [RNTST] Running test test1…
UVM_INFO @ 0: uvm_test_top.env.c1 [RUN] proceeding data 100
UVM_INFO @ 0: reporter [CB] cb1 executed with data 200
UVM_INFO @ 0: reporter [CB] cb2 executed with data 300
如果读者已经理解了回调函数本身的“钩子”属性,那么从上面这个例子可以看到,uvm_callback类使得钩子属性变得更加容易控制和继承。在这里例子中,有下面一些需要读者注意的地方:
- callback可以通过继承的方式来满足用户更多的定制,例如上面的c2继承于cb1。
- 为了保证调用callback的组件类型T与callback类型CB保持匹配,建议用户在T中声明T与CB的匹配,该声明可以通过宏`uvm_register_cb(T, CB)来实现。用户养成了注册的习惯之后,如果以后一旦调用的T与CB不匹配,那么在检查完匹配注册表之后系统会打印warning信息,提示用户调用的潜在问题。
- uvm_callback实现了回调函数执行的层次性,因此在实现方面,不再是在T的方法中直接呼叫某一个回调方法,而是通过宏
uvm_do_callbacks(T, CB, METHOD)来实现。该宏最直观的作用在于会循环执行已经与该对象结对的uvm_callback中的方法。此外,还有一个宏
uvm_do_callbacks_exit_on(T, CB, METHOD, VAL)可以进一步控制执行回调函数的层次,简单来讲,回调函数会一直执行,直到返回值与给入的VAL值相同就立刻返回,这一点使得回调方法执行顺序上面有了更多的选择。 - 有了`uvm_do_callbacks宏还不够,需要注意的是,在执行回调方法时,依赖的是已经例化的uvm_callback对象。所以,最后一步,需要例化uvm_callback对象,上面的例子中分别例化了cb1和cb2。最后,通过“结对子”的方式,通过uvm_callbacks #(T, CB)的静态方法add()来添加成对的uvm_object对象和uvm_callback对象。
从这个例子可以看到,uvm_callback的灵活性不但在于可以利用继承性的优点,实现用户的自定义内容,还在于回调函数不再固定依赖于某一些固定的程序,而是通过对象(uvm_object)和对象(uvm_callback)的绑定实现了精细化的回调函数指定。从最后的执行顺序来看,也证明了通过uvm_callbacks #(T, CB)::add()方法的简便性。
Clone
==create()+ copy(),创建实例,调用copy方法,最后将该实例返回!!!
clone和copy的区别:
1
2
3
4trans a;
trans b;
a.copy(b);
a = b.clone();Compare
将每一个自动化的域进行比较,一旦失败立即返回(0),后续变量不再比较
uvm_default_comparer
(uvm_comparer
类型)是默认的UVM全局比较器,默认最大错误输出信息是1,即当比较错误信息时,立刻返回show_max可以设置最大比较结果
1
uvm_default_comparer.show_max = 10;
Print
Pack/Unpack
全局对象
在uvm_object使用到的方法compare()、print()和pack(),如果没有数据操作配置对象作为参数时,即会使用在uvm_pkg中例化的全局成员。在这里,我们可以从下面的图中看到,在uvm_pkg中例化了不少对象,而在本节中我们会使用到的全局配置包括有:
- uvm_default_comparer
- uvm_default_printer
- uvm_default_packer
比较
如果不想使用默认的比较配置,用户想自己对比较配置进行设定,可以考虑自行创建一个uvm_comparer对象,或者修改全局的uvm_comparer对象。下面的这段例码,采取了第一种方法:
1 | module object_compare2; |
1 | UVM_INFO @ 0: reporter [MISCMP] Miscompare for box2.volume: lhs = 'h5a : rhs = 'h50 |
在这段例码中,额外创建了一个比较配置cmpr。这个对象是uvm_comparer类,而此类并不继承与任何其他的UVM类,只是单纯的一个用于存放比较配置信息的类。在设定了最大的比较错误次数之后,将b1与b2进行比较后的结果信息给得更加全面,这一次则将全部的比较错误信息都输出了。
打印
打印方法是核心基类提供的另外一种便于开发和调试的功能。通过field automation,使得声明之后的各个成员域会在调用uvm_object::print()函数时自动打印出来。下面是一段例码:
1 | module object_print; |
1 | default table printer format |
从上面这段例码中,读者可以发现,只要被在field automation中声明过的域,在稍后的print()函数打印,都将打印出它们的类型、大小和数值。如果用户不对打印的格式做出修改,那么在打印时,UVM会按照uvm_default_printer规定的格式来打印。在上面“比较”一节中,读者已经知道uvm_pkg中在仿真一开始的时候就会例化不少全局的对象,这其中就包括了uvm_default_printer和其它几个用于打印的对象,它们分别是:
- uvm_default_tree_printer:可以将对象按照数状结构打印。
- uvm_default_line_printer : 可以将对象打印到一行上面。
- uvm_default_table_printer : 可以将对象按照表格的方式打印。
- uvm_default_printer : UVM环境默认的打印设置,该句柄默认指向了uvm_default_table_printer。
所以通过给全局打印机uvm_default_printer赋予不同的打印机句柄,就可以在调用任何uvm_object的print()方法时,得到不同的打印格式。如果用户需要自定义一些打印的属性,用户可以自己创建一个打印机,进而通过修改其属性uvm_printer::knobs中的成员,来输出自己的打印格式。每一台打印机中,都有自己的打印属性,用户可以通过查看UVM类的参考手册,查找关于详细的打印属性类uvm_printer_knobs。
除了简单的print()函数,用户还可以通过uvm_object::sprint()将对象的信息作为字符串返回,或者自定义do_print()函数来定制一些额外的打印输出。
打包和解包
最后,来看看另外一个核心功能,打包和解包。类似于之前的拷贝和打印,uvm_object也分别提供了通过field automation自动实现的打包和解包方法:
1 | function int pack (ref bit bitstream[], input uvm_packer packer=null); |
以及用户可以自定义的回调函数:
1 | virtual function void do_pack (uvm_packer packer); |
打包和解包实例:
1 | module object_pack_unpack; |
打印输出:
1 | ------------------------------- |
上面的例码中b1将自己内部已经声明过的域,首先进行打包,打包好的数据存入到一个比特数组中packed_bits,这个数组中存放着所有经过field aumation的域值,接下来b2又从packed_bits中解包,将数据存入到自己的各个域中。这么看起来,这个例子是完成一次对象数值的拷贝。如果是这样,那么为什么不适用uvm_object::copy()函数,而是费这么大周折,来将b1内的域值先打包,然后再通过b2解包完成数值的完整传递呢?
实际上,打包和解包的方式并不是主要为软件对象之间的数值拷贝服务的,而是真正地在为从软件对象到硬件接口的赋值服务的。在硬件接口中,所有的接口都是按照一定的比特宽度来放置的,并不像软件对象内的各个域那样声明。因此,要完成一次从软件对象到硬件接口的赋值,一种方法就是利用uvm_object::pack()来实现。同样地,如果要完成对硬件信号的采样,也可以将排列好的硬件信号数值保存,继而通过uvm_object::unpack()来完成到软件对象的数值拷贝。
3. phase机制
解决new构建的问题:层次化环境下,实例化的先后关系;各个组件在实例化后的连接
在顶层需要对底层进行配置时,SV无法在底层未实例化之前完成配置
phase的目的:更清晰的将仿真环境层次化
如果暂时抛开phase的机制剖析,对于UVM组件的开发者而言,他们主要关心各个phase之间执行的先后顺序。在完成各个phase虚方法的实现之后,UVM环境会按照phase的顺序分别调用这些方法。
关于执行的顺序,可以从下面这段简单的例码中得到佐证:
1 | module common_phase_order; |
执行结果
1 | UVM_INFO @ 0: reporter [RNTST] Running test test1... |
从这个例子可以看出,上面的九个phase,对于一个测试环境的声明周期而言,是有固定的执行先后顺序的;同时,对于处于同一个phase的组件之间,执行也会按照层次的顺序或者自顶向下、或者自底向上来执行。这个简单的环境中,顶层测试组件test1中,例化了一个t1组件,而t1组件内又进一步例化了c1和c2组件。从执行的打印结果来看,需要注意的地方有:
- 对于build phase,执行顺序按照自顶向下,这符合验证结构建设的逻辑。因为只有先创建高层的组件,才会创建空间来容纳低层的组件。
- 只有uvm_component及其继承与uvm_component的子类,才会按照phase机制将上面九个phase先后执行完毕。
上面介绍的九个phase中,常用的phase包括build、connect、run和report,它们分别完成了组件的建立、连接、运行和报告。这些phase在uvm_component中通过_phase的后缀完成了虚方法的定义,比如build_phase()中可以定义一些例化组件和配置的任务。在这九个phase中,只有run_phase方法是一个可以耗时的任务,这意味着该方法中可以完成一些等待、激励、采样的任务。对于其它phase对应的方法,都是函数,必须即时返回(0耗时)。
在run_phase中,用户如果要完成测试,则通常需要经历下面的激励序列:
- 上电
- 复位
- 寄存器配置
- 主要测试内容
- 等待DUT完成测试
一种简单的方式是,用户在run_phase中完成上面所有的激励;另外一种方式,如果可以将上面的几种典型的序列分到不同的区间,让对应的激励按区搁置的话,也能让测试更有层次。因此,run_phase又可以分为下面的12个phase:
- pre_reset_phase
- reset_phase
- post_reset_phase
- pre_configure_phase
- configure_phase
- post_configure_phase
- pre_main_phase
- main_phase
- post_main_phase
- pre_shutdown_phase
- shutdown_phase
- post_shutdown_phase
上面的12个phase的执行顺序也是前后排列的。实际上,run_phase任务和上面细分的12个phase是并行进行的。在start_of_simulation_phase任务执行以后,run_phase和reset_phase开始执行,而在shutdown_phase执行完之后,需要等待run_phase执行完以后,才能进入extract_phase。关于执行的关系,可以从下面这张图中得出
这里需要提醒用户的是,虽然run_phase与细分的12个phase是并行执行的,而12个phase也是按照先后顺序执行的。为了避免不必要的干扰,用户可以选择run_phase,或者12个phase中的若干来完成激励,但是请不要将它们混合起来使用,因为这样容易导致执行关系的不明确。
建立仿真环境
- 首先在加载硬件模型,调用仿真器之前,需要完成编译和建模阶段。
- 接下来,在开始仿真之前,会分别执行硬件的always/initial语句,以及UVM的调用测试方法run_test和几个phase,分别是build、connect、end_of_elaboration和start_of_simulation。
- 在开始仿真后,将会执行run_phase或者对应的12个细化phase。
- 在仿真结束后,将会执行剩余的phase,分别是extract、check、report和final。
如果只是从UVM的应用角度来看,要在仿真开始时建立验证环境,那么用户可以考虑选择下面几种方式:
- 可以通过全局函数(由uvm_pkg提供)run_test()来选择性地指定要运行哪一个uvm_test。这里的test类均继承于uvm_test。这样的话,指定的test类将被例化并指定为顶层的组件。一般而言,run_test()函数可以在合适的module中initial进程块中调用。
- 如果没有任何参数传递给run_test(),那么用户可以在仿真时通过传递参数+UVM_TESTNAME=
,来指定仿真时调用的uvm_test。当然,即便run_test()函数在调用时已经有test传递进去,在仿真时的+UVM_TESTNAME= 也可以从顶层覆盖底层的指定。这种方式使得在仿真开始时,不需要通过再次修改run_test()调用的test名字和重复编译,而可以灵活选定test
无论上面哪一种方式,都必须有顶层调用全局函数run_test(),用户可以考虑不传递test名字作为参数,而在仿真时通过传递参数+UVM_TESTNAME=
1 | task run_test (string test_name=""); |
这里需要先来了解UVM的顶层类uvm_root。该类也继承与uvm_component,说明它必然是UVM环境结构中的一员,而他可以作为顶层结构类,它提供了一些像run_test这种方法,来充当了UVM世界中的核心角色。在uvm_pkg中,有且只有一个顶层类uvm_root例化的对象,即uvm_top。这就同“道生一,一生二,二生三,三生万物”的古语一般。在UVM的世界中,“道”就是uvm_pkg,“一”就是uvm_top,而后来的“万物”就是uvm_top下例化的uvm_test及其更多的子组件。
uvm_top充当的主要核心任务包括:
- 作为隐形的UVM世界顶层,任何其它的组件都在它之下,通过创建组件时指定parent来构成层次。如果parent设定为null,那么它将作为uvm_top的子组件。
- phase控制。控制所有组件的phase顺序。
- 索引功能。通过层次名称来索引组件实例。
- 报告配置。通过uvm_top来全局配置报告的繁简度(verbosity)。
- 全局报告设备。由于uvm_top全局可以访问,因此UVM的报告配置在组件内部和组件外部(例如module和seequence)都可以访问。
通过uvm_top调用方法run_test(test_name),uvm_top做了如下的初始化:
- 得到正确的test_name。
- 初始化objection机制。
- 创建uvm_test_top实例。
- 调用phase控制方法,安排所有组件的phase方法执行顺序。
- 等待所有phase执行结束,关闭phase控制进程。
- 报告总结和结束仿真。
uvm仿真结束
从之前的例子和上面的图中,UVM的环境建立和各个phase的先后调用的入口,都是从run_test()进入的。默认情况下,如果run_test()方法执行完毕,那么系统函数$finish则会被调用,来终止仿真。此外还有什么方法可以结束仿真呢?——objection挂起机制
从这一版本的结束机制可以看到:
- 默认情况下,如果没有objection反停止标记挂起的,所有的run_phase任务在执行时,会直接放入到fork-join_none进程当中。因此run_phase()中的任务会在后台执行,但不会阻止run_phase()结束,进入下一个phase。因此,objection机制是控制仿真退出run phase的一种办法。
uvm_objection类提供了一种供所有component和sequence共享的计数器counter。如果有组件来挂起objection,那么它还应该记得落下objection。参与到objection机制中的参与组件,可以独立的各自挂起objection,来防止run phase退出,但是只有这些组件都落下objection后,uvm_objection共享的counter才会变为0,这意味run phase推出的条件满足,因此可以退出run phase。
对于uvm_objection类,用来反停止的控制方法包括:
raise_objection ( uvm_object bj = null, string description = “” , int count = 1) 挂起objection
drop_objection ( uvm_object bj = null, string description = “” , int count = 1) 落下objection
- set_drain_time ( uvm_object bj = null, time drain) 设置退出时间
对这几种典型方法,在实际应用中的建议包括:
- 对于component()而言,用户可以在run_phase()中使用phase.raise_objection()/phase.drop_objection()来控制run phase退出。
- 用户最好提供对于description字符串参数用来描述,这有利于后期的调试。
- 应该使用默认count值。
- 对于uvm_top或者uvm_test_top应该尽可能少使用set_drain_time()。
1 | class test1 extends uvm_test; |
输出结果:
1 | UVM_INFO @ 0: reporter [RNTST] Running test test1... |
从输出结果来看,uvm_pkg::uvm_test_done实例会在test1的run_phase()执行完毕之后,才会退出run phase。这得益于test1::run_phase()中在仿真一开始就挂起了objection。在执行完毕之后,才落下了objection。这时,uvm_pkg::uvm_test_done认为run phase已经可以退出,进而转向了下一个extract phase。直到退出所有的phase之后,UVM进入了报告总结阶段
反之,如果在整个test及其子组件和sequence中,都没有通过objection()机制来控制run phase退出,那么所有组件的run phase都会通过fork-join_none线程提交之后,立即转入到extract phase。所以objection机制的应用是必不可少的。那么,在什么时间点应该挂起objection呢?再来看下面这段例码:
1 | module objection_application; |
输出结果:
1 | UVM_INFO @ 0: reporter [RNTST] Running test test1... |
这段代码中,看起来挂起objection()已经晚了,因为run phase还是立即退出了。这是因为在挂起objection之前还需要运行1ps,而处于fork-join_none后的run_phase任务在0时刻被调用后,run phase退出机制在0时刻发现没有挂起的objection,因此终止所有的run_phase()任务,继而转入了extract phase。所以,如果要在component中挂起objection,建议在一进入run_phase()后就挂起,保证objection counter及时被增加;
另外,需要习惯在sequence中挂起objection,由于sequence不是uvm_component类,而是uvm_object类,因此它只有body()方法,而没有run_phase()方法。所以,在sequence中使用objection机制,可以在body()中的首尾部分分别调用uvm_test_done.raise_objection()和uvm_test_done.drop_objection()。对于习惯在pre_body()中调用uvm_test_done.raise_objection(),在post_body()中调用uvm_test_done.drop_objection(),这么做在多数情况下可以起到objection的防退出机制,但是一些情况下,sequence的pre_body()和post_body()并不会调用,所以objection机制也没有起到作用。因此,我们建议在sequence body()任务中raise/drop objection。
4.config机制
buildphase中,除了组件的实例化,配置也是必不可少的
UVM提供了uvm_config_db的配置类以及几种很方便的变量设置方法来实现在仿真时的环境控制。常见的uvm_config_db的使用方式包括有:
- 传递virtual interface到环境中。
- 设置单一变量值,例如int、string、enum等。
- 传递配置对象(config object)到环境。
config机制的路径传递基于字符串层次:
和工厂机制的关系;
工厂的实例方法:由parent构成的层次关系,基于name构成的基于字符串的层次关系
注意点:
parent win,即多层对底层的同一变量做不同的配置时,上层的优先级更高
interface传递
首先来看看interface的传递,通过这种方便的传递方式很好地解决了连接硬件世界和软件世界。而在之前关于SV的核心篇章中,读者们可以看到,虽然SV可以通过层次化interface的索引来完成传递,但是这种方式不利于软件环境的封装和复用。下面这种方式,使得接口的传递和获取彻底分离开来,而在后台对virtual interface的传递立下功劳的便是uvm_config_db。
1 | interface intf1; |
输出结果
1 | UVM_INFO @ 0: reporter [RNTST] Running test test1... |
最关键的两个方法:存和取
对
uvm_config_db#(virtual intf1)::set(uvm_root::get(), "uvm_test_top.c1", "vif", intf);
参数的理解:所有参数实际上构建的是一个hash表!前三个参数实际上对应的是path,最后一个参数对应的是传入的interface
首先run_test(“test1”)构建了一个root - test - c1 的层次结构。uvm_root::get()获取的是最顶层的句柄root,第二个参数说明是test_top下的c1,第三个参数实际上可以理解为一个key,此时对应的一个path就是:root.test.c1.vif
对在comp1组件类中声明的
uvm_config_db#(virtual intf1)::get(this, "", "vif", vif)
这里的get方法和set刚好对应:- this:当前实例的路径(root.test.c1)
- vif:对应接口名的key:vif,此时相当于把set中存储的intf拿了出来,赋给了vif
- set:顶层把信息(如interface)set进来
- get:底层把信息获取到
- set和get的发生场景:组件中
从上面这个例子可以看到,接口的传递从硬件世界到UVM环境中的传递可以通过uvm_config_db来实现。在实现过程中需要注意几点:
- 接口的传递应该发生在run_test()之前。这保证了在进入build phase之前,virtual interface已经传递进入uvm_config_db中。
- 用户应当把interface与virtual interface的声明区分开来。在传递过程中的类型应当为virtual interface,即实际接口的句柄。
object传递
在实际的test配置中,需要配置的参数不单单数量多,而且还分属于不同的组件。那么,如果对这么多层次中的变量做出类似上面的单一变量设置,一方面需要更多的代码,这就容易出错,不易于阅读,另外一方面也不易于复用,毕竟底层组件的变量有添加或者减少,通过uvm_config_db::set是无法得知是否设置成功的。因此,如果将每个组件中的变量加以整合,首先放置到一个uvm_object中用于传递,那么将会更有利于整体进行配置。
1 | import uvm_pkg::*; |
输出结果
1 | UVM_INFO @ 0: reporter [RNTST] Running test test1... |
注意:
和上面的interface传递类似,不需要在initial块中就对interface进行set,只需要在调用object的组件类(comp1)创建之前进行set就可以了!!如代码的47-50行
set和get的通配符,即对象类型必须完全一致,子类和父类的关系也不行
- 如果set的是一个子类对象,用一个父类句柄进行get,必须通过$cast(a,b)进行类型转换
、
5. 消息(message)管理
一个好的验证系统应该具有消息管理特性,它们是:通过一种标准化的方式打印信息、过滤重要级别信息、打印通道。UVM中提供了一系列的类和方法来生成和过滤消息,包括消息方法、消息处理、消息机制。
5.1 消息方法
在UVM环境中或者环境外,只有引入uvm_pkg
,都可以通过下面的方法来按照消息的严重级别和冗余度打印消息。
1 | function void uvm_report_info(string id, string message, int verbosity=UVM_MEDIUM, string filename="", int line=0); |
filename:当前执行的文件名;line:该条消息来自于哪一行
这两个变量不需要传入,系统自动添加
非常关键的是一个冗余度的级别,可以对信息进行过滤,NONE的等级最高,无法被过滤。约往下就越低,越容易被过滤掉
5.2 消息处理
与每一条消息对应的是如何处理这些消息。通过情况下,消息处理的方式是同消息的严重级别对应的,也可以修改对各个严重级别的消息处理方式。
不同的严重级别消息,可以使用默认的消息处理方式
uvm_report_handler和uvm_report_server
消息处理是由uvm_report_handler类来完成的,而每一个uvm_report_object类中都有一个uvm_report_handler实例。
uvm_report_object消息处理方法或者uvm_component消息处理方法,都是针对于这些uvm_report_handler做出的配置。
除了每一个uvm_report_object中都内置一个uvm_report_handler实例之外,所有的uvm_report_handler实例也都依赖于uvm_pkg中。uvm_report_server的唯一实例,但是该实例并没有作为全局变量,需要自行调用uvm_report_server::get_server()方法来获取。uvm_report_server是一个全局的消息处理设备,用来处理从所有uvm_report_handler中产生的消息,这个唯一的uvm_report_server之所以没有直接暴露在uvm_pkg中,一个原因在于对消息的处理方式。
回调函数
在处理信息时除默认的处理方式外还想做其他处理,可以使用回调函数
1 | function bit report_hook(string id, string message, int verbosity, string filename, int line) |
实例:
- 对UVM_ERROR的消息处理方式进行了修改,此时其可以触发回调函数,report_hook必须设置,如果返回值唯一,则可以进行其细分一级的hook回调函数,如果定义report_hook的返回值为0,则report_hook执行完即截止
- 设置了过滤等级
1 | class test1 extends uvm_test; |
执行结果:
5.3 消息的创建
如果要做自定义的消息处理方式,可以通过uvm_report_object
类提供的方法进行配置,uvm_report_object
类是间于uvm_object
类与uvm_component
类之间的中间类,它的主要功能是完成消息打印和管理。
处理消息的方法和宏:
6. UVM结构回顾
类库地图中可以看到,用来创建组件的uvm_factory和用来构建环境的uvm_root都是找不到的,
6.1 uvm_top
- uvm_top是uvm_root类的唯一实例,UVM世界的“一”,它由UVM创建和管理,它所在的域是uvm_pkg。
- uvm_top是所有test组件的顶层,所有验证环境中的组件在创建时都需要指明它的父一级,如果某些组件在创建时指定父级的参数为null,那么它将直接隶属于uvm_top。
- uvm_top提供一系列的方法来控制仿真,例如phase机制、objection防止仿真退出机制等。
6.2 uvm_test
所有的test类必须继承uvm_test,否则uvm_top不识别!!!(run_test()就废了)
test
的目标包括:
- 提供不同的配置,包括环境结构配置、测试模式配置等,然后再创建验证环境;
- 例化测试序列,并且挂载到目标
sequencer
,使其命令driver
发送激励。
6.3 构建环境的主要组件
uvm_component
- 继承于uvm_report_object(进一步继承于object),提供消息方法。
- 所有的验证环境组件都继承于uvm_component。
- 管理验证的层次。
uvm_env
- 继承于uvm_component。
- 没有额外的功能。
- 用来为验证环境结构提供一个容器。
uvm_test
- 继承于uvm_component。
- 没有额外的功能。
- 用来提供对uvm_env的额外配置以及挂载励。
6.4 uvm_component
该类是一个虚类,所有环境组件都继承于该类,所有继承于该类的子类,都称之为组件或者环境组件。由于环境中所有的组件都继承于uvm_component,因此也就可以使得UVM提供统一的方式来管理层次结构和组件方法。
该类提供以下接口或者API:
- 结构,例如get_full_name(),get_parent(),get_num_children()
- 阶段(phase)机制,例如build_phase(),connect_phase(),run_phase()
- 配置(configuration)机制,例如print_config(),print_override_info()
- 报告(report)机制,例如report_hook(),set_report_verbosity_level_hier()
- 事务记录(transaction recording),例如record()
- 工厂(factory)机制,例如set_inst_override(),set_type_override()
对于组件的构建函数,固定形式为:
1 | function new(string name, uvm_component parent); |
- string name用来声明当前例化组件的名称,用来自动和它所在的父级层次组合为组件的整个层次名称,可以get_full_name()方法获取。
- uvm_component parent用来指示所例化的父级句柄,通常用this指代,即例化在当前的父级组件中。
- uvm_object并不参与组件的层次构建,因此只有一个形参string name。
7. MCDF顶层验证方案
MCDF的主要功能是将输入端的三个通道数据,通过数据整形和过滤,最终输出。
可以将MCDF的设计结构分别四个模块:
- 上行数据的通道从端(channel slave)
- 仲裁器(arbiter)
- 整形器(formatter)
- 控制寄存器(control registers)
7.1 reg_env
对于寄存器模块的验证环境reg_env
,它的组织包括:
reg_master_agent
,提供寄存器接口驱动信号。reg_slave_agent
,提供寄存器接口反馈信号。scoreboard
,分别从reg_master_agent
内的monitor
和reg_slave_agent
内的monitor
获取监测数据,并且进行数据对比。
7.2 chnl_env
数据通道从端的验证环境chnl_env
的组件包括:
- chnl_master_agent,提供上行的激励数据。
- chnl_slave_agent,提供用来模拟arbiter仲裁信号,并且接受流出数据。
- reg_cfg_agent,用来提供模拟寄存器的配置信号,并且接收内置FIFO的余量信号。
- scoreboard,分别从chnl_master_agent、chnl_slave_agent、reg_cfg_agent的monitor接收检测数据,并且对channel的流入流出数据进行比对。
7.3 arb_env
仲裁器的验证环境arb_env的组件包括:
- 模拟channel输出接口的arbiter_master_agent的三个实例,用来对arbiter提供并行数据输入,同时对arbiter反馈的仲裁信号做出响应。
- arbiter_slave_agent,用来接收arbiter的输出数据,模拟formatter的行为,对arbiter的输出信号做出响应。
- reg_cfg_agent,提供用来模拟寄存器的配置信号,对三个channel数据源分别做出不同的优先级配置。
- scoreboard,从三个arbiter_master_agent、arbiter_slave_agent、reg_cfg_agent中的monitor获取监测数据,对arbiter的仲裁机制做出预测,并且将输入输出数据按照预测的优先级做出比对
7.4 fmt_env
整形器的验证环境fmt_env的组件包括:
- fmt_master_agent,用来模拟arbiter的输出数据。
- fmt_slave_agent,用来模拟MCDF的下行数据接收端。
- reg_cfg_agent,用来模拟寄存器的配置信号,用来指定输出数据包的长度。
- scoreboard,从fmt_master_agent、fmt_slave_agent、reg_cfg_agent的monitor获取检测数据,通过数据包长度来预测输出的数据包,与formatter输出的数据包进行比对。
7.5 环境集成方案一
MCDF顶层验证环境复用了这些模块验证环境的组件,reg_master_agent
、chnl_master_agent
、fmt_slave_agent
,通过这三个激励组件可以有效生成新的激励序列,而将各个agent的sequencer句柄合并在一起时,virtual sequencer
的作用就体现出来了,可以通过这个中心化的序列分发管道,将各个agent的sequencer也集中管理。MCDF的scoreboard提供了一个完整的数据通路覆盖方案,即从各个agent的monitor的数据检测端口将数据收集起来,同时建立MCDF的参考模型,预测输出数据包,最终进行数据比对。
代码实现:
1 | class mcdf_env1 extends uvm_env; |
7.6 环境集成方案二
在方案一中最大的额外投入在于需要新建一个scoreboard用来检查MCDF的整体功能,而方案二的目的在于复用底层模块环境的scoreboard,减少顶层环境的额外成本,顶层环境的组件都直接复用了各个模块验证环境,顶层环境在集成模块验证环境时,需要将各个子模块中的agent配置为不同模式(active或者passive),以此适应顶层场景,所以不需要实现新的scoreboard,而是可以复用原有模块验证环境的scoreboard。
代码实现:
1 | class mcdf_env1 extends uvm_env; |
7.7 总结
1. 方案1和方案2的区别
方案一和方案二相同的地方在于,顶层都需要新建virtual sequencer和sequence,用来生成顶层的测试序列。而virtual sequencer也不是从零创建的,它本身也是利用原有模块环境的序列库,进行了有机的组合,最后协调生成了新的测试序列。从方案二可以看出,mcdf_env的子组件不再是uvm_agent类,而是各个模块的验证环境uvm_env类。通过直接复用这些子环境,也间接复用了它们内部的scoreboard,在build阶段,需要将各个子环境中不需要再产生激励的agent,配置为passive模式,而默认情况下这些agent均为active模式。这种复用方式使得我们无需再新建一个MCDF scoreboard,只需要确保MCDF的各个子模块都有scoreboard会检查功能,这样从整体上便可以覆盖完整的数据通路。
2. UVM的环境复用相较于SV的优势
- 各个模块的验证环境是独立封装的,对外不需要保留数据端口,因此便于环境的进一步集成复用。
- 由于UVM自身的phase机制,在顶层协调各个子环境时,无需考虑由于子环境之间的例化顺序而导致的对象句柄引用悬空的问题。
- 由于子环境的测试序列是相对独立的,这使得顶层在复用子环境测试序列而构成
virtual sequence
时,不需要其它额外的迁移成本。 - UVM提供的
config_db
配置方式,使得整体环境的结构和运行模式都可以从树状的config
对象中获取,这也使得顶层环境可以在不同uvm_test
进行集中管理配置。
8. 环境构建的四要素
在发送测试序列之前,首先需要创建一个结构化的环境,将环境建立的核心要素拆解开来,可以分为四个部分:
- 单元组件的自闭性
- 回归创建
- 通信端口连接
- 顶层配置
8.1 自闭性
自闭性指的是单元组件(例如uvm_agent或者uvm_env)自身可以成为独立行为、不依赖于其它并行的组件。举例来说,driver同sequencer之间,虽然driver需要获取sequencer的transaction item,但是它本身可以独立例化,而它们之间的通信也是基于TLM端对端的连接实现的。这种单元组件的自闭性为之后的组件复用提供了良好的基础,各个子环境也可以独立集成于顶层环境,互相也不需要额外的通信连接。
8.2 回归创建
通过回归创建这种方式,上一级的组件在例化自身(执行new()函数)之后,会执行各个phase阶段,通过build_phase可以进一步创建子组件,而这些子组件也通过一样的过程去创建下一级组件。
回归创建之所以可以实现,这要依赖于自顶向下执行顺序的build_phase。通过build_phase这种结构化执行顺序可以保证父组件必先于子组件创建,而创建过程还包括:
- 在定义成员变量时赋予默认值,或者在new()函数赋予初始值。
- 结构配置变量用来决定组件的条件生成,例如uvm_agent依靠is_active变量来判断是否需要例化uvm_sequencer和uvm_driver。
- 模式配置变量用来决定各个子组件的工作模式。
- 子组件按照自顶向下、从前到后的顺序依次生成。
8.3 通信端口连接
在完成了整个环境创建以后,各个组件会通过通信端口的连接进行数据通信,常见的端口通信用途包括:
- driver的端口连接到sequencer,并且对sequencer采取blocking pull的形式获取transaction item。
- monitor的端口连接到scoreboard内部的analysis fifo,将监测的数据写入其中。
8.4 顶层配置
由于单元组件的自闭性,UVM结构不建议通过引用子环境句柄,继而索引更深层次的变量进行顶层配置,因此会增加顶层环境同子环境的粘性,无法做到更好的分离。
所以更好的方式是通过配置化对象,作为绑定于顶层环境的部分传递到子环境,而子环境的各个组件又可以从结构化配置对象中获取自身的配置参数,从而在build_phase、connect_phase以及run_phase中来决定它们的结构和运行模式。
顶层配置对象可以在子环境没有例化时就将其配置到将来会创建的子环境当中,无需考虑顶层配置对象会先于子环境生成,这也为UVM验证结构提供了安全的配置方式:
无论在哪一层使用配置,应该尽量将所有配置都置于子组件创建之前,保证配置已经完成。
配置的作用域应该只关注当前层次及以下,而不涉及更高的层次。
配置的对象结构应该尽量独立,最好同环境结构一样形成一个树状结构。这样独立的配置对象会对应独立的子环境,如果将独立的配置合并为一个树状顶层配置结
构,那么顶层配置对象更便于使用和维护。
由于config_db的配置特性使得高层的配置会覆盖底层的配置,这也使得在uvm_test层次做出的配置可以控制整体的结构和模式。
顶层配置框图
见config机制:变量,句柄,接口都可以在test层次上进行配置
8.5 环境元素分类
将uvm_test层作为比uvm_env更高的层次绘制出来,这是因为uvm_test层会有一些配置的部分传递给子环境。包括构成环境的组件uvm_component在内,环境元素可以分为以下部分:
- 成员变量:一般变量、结构变量、模式变量。
- 子组件:固定组件、条件组件、引用组件。
- 子对象:自生对象、克隆对象、引用对象。
成员变量
- 一般变量用于对象内部的操作,或者为外部访问提供状态值。
- 结构变量则用来决定内部子组件是否需要创建和连接,例如顶层的is_active变量即用作该目的。
- 模式变量用来控制组件的行为,例如driver变量经过模式配置,可以在run_phase做出不同的激励行为。
- 对于结构变量和模式变量,一般由int或者enum类型定义,可以在uvm_test层通过uvm_config_db的配置方法直接设置,也可以通过结构化的配置对象来进行系统配置。对于复杂的验证环境,配置对象的方式会容易操作和维护。
子组件(固定,条件,引用)
环境必须创建的组件称之为固定组件,例如agent中的monitor无论对于active模式或者passive模式,都需要创建,又或者顶层环境中的scoreboard,也需要创建来比较数据。
条件组件则是通过结构变量的配置来决定是否需要创建,例如sequencer和driver只允许在active模式下创建。
引用组件是内部声明一个类型句柄,同时通过自向下的句柄传递,使得该句柄可以指向外部的一个对象。例如在uvm_test一层,首先例化了一个寄存器模型
rgm(固定组件),其后将该模型的句柄通过配置传递到reg_env层中的rgm句柄(引用组件)。利用引用组件的方式,使得环境各个层次在需要的情况下,都可以共享一个组件。
子对象
在某一层中首先会创建一个对象,该对象可以称之为自生对象。
对象传递过程中,该对象经过克隆从而生成一个成员数值相同的对象,称之为克隆对象。
如果对象经过了端口传递,到达另一个组件,而该组件对其未经过克隆而直接进行操作的话,称之为引用对象的操作。例如在virtual sequence会生成送往
reg_master_agent和reg_slave_agent的transaction item,分别是mst_t和slv_t,这些连续发送的mst_t和slv_t通过uvm_sequencer,最终到达uvm_driver。
uvm_driver拿到这些transaction对象之后,如果首先进行克隆,而后利用克隆数据对象进行激励是一种方式;uvm_driver也可以不克隆数据对象而直接对这些对象(引用对象)进行操作。
9. TLM通信
问:为什么用tlm通信,mailbox怎么就不香了?
答:因为mailbox牵扯到一个跨层次找句柄的过程,比如在顶层env中将scoreboard的mailbox实例对象赋值给agent中的mailbox句柄,相当于分享同一块内存,两个组件之间产生了耦合;
tlm通信实际上就是给每个组件配置了一个端口,并对其进行实例化,如monitor作为initiator端,其实例的port端口就是插头;scoreboard作为target端,其实例化的imp端就是插座,顶层只需要实现插头和插座的连接,此时就实现了组件间的隔离!!!
9.1 通信概论
- TLM是一个基于事务(transaction)的通信方式,通常在高抽象级的语言中被引用作为模块之间的通讯方式,例如SystemC或者UVM。TLM成功地将模块内的计算和模块之间的通信从时间跨度方面剥离开了。
- 在实现的过程中,TLM通信需要两个通信的对象,这两个对象分别称之为initiator object和target object。区分它们的方法在于,谁首先发起通信的要求,谁就属于initiator,而谁作为发起通信的响应方,谁就属于target。在初学过程中,读者们还应该注意,通信发起方并不代表了transaction的流向起点,即不一定数据是从initiator流向target,也可能是从target流向了initiator。因此,按照transaction的流向,我们又可以将两个对象分为producer和consumer。区分它们的方法是,数据从哪里产生,它就属于producer,而数据流向了哪里,它就属于consumer。
从下面的这张图可以看出,initiator与target的关系同producer与consumer的关系,不是固定的。而有了两个参与通信的对象之后,用户需要将TLM的通信方法在target一端中实现,以便于initiator将来作为发起方可以调用target内的通信方法,实现数据传输。在target实现了必要的通信方法之后,最后一步需要将两个对象进行连接。这需要首先在两个对象内创建端口,继而在更高的层次中将这两个对象进行连接。
所以,抽象来看TLM通信的步骤可以分解为:
- 分辨出initiator和target,producer和consumer。
- 在target中实现TLM通信方法。
- 在两个对象中创建TLM端口。
- 在更高的层次中将两个对象的端口进行连接。(initiator作为连接的发起端:调动connect()方法)
例如,monitor和checker:monitor是initator;generator和driver:driver是initator(通过get请求数据)
从数据流向的方向来看,传输的方向可以分为单向(unidirection)和双向(bidirection)。
- 单向传输:由initiator发起request transaction。
- 双向传输:由initiator发起request transaction,传送至target;而target在消化了request transaction后,也会发起response transaction,继而返回给initiator。
端口的按照类型可以划分为三种:
- port:经常作为initiator的发起端,也凭借port,initiator才可以访问target中实现的TLM通信方法。
- export:作为initiator和target中间层次的端口。
- imp:只能是作为target接收request的末端,它无法作为中间层次的端口,所以imp的连接无法再次延伸。
如果将传输方向和端口类型加以组合,就形成了TLM端口的继承树,TLM端口一共可以分为六类:
uvm_UNDIR_port #(trans_t)
uvm_UNDIR_export #(trans_t)
uvm_UNDIR_imp #(trans_t,imp_parent_t)
问:为啥imp就要多传入一个parent呢?
答:port和imp之间存在隔间,真正在顶层建立连接后,port通过connect找到imp,imp又通过其所在类的类名找到相应的方法给port调用
uvm_BIDIR_port #(req_trans_t,rsp_trans_t)
uvm_BIDIR_export #(req_trans_t,rsp_trans_t)
uvm_BIDIR_imp #(req_trans_t,imp_parent_t)
端口使用
- 就单向端口而言,声明port和export作为request发起方,需要指定transaction类型参数,而声明imp作为request接收方,不但需要指定transaction类型,也需要指定它所在的component类型。
- 就声明双向端口而言,指定参数需要考虑双向传输的因素,将传输类型transaction拆分为request transaction类型和response transaction类型。
从对应连接关系得出TLM端口连接的一般做法:
- 在initiator端例化port,在中间层次例化export,在target端例化imp。
- 多个port可以连接到同一个export或者imp,但是单个port或者export无法连接多个imp。
- port应为request起点,imp应为request终点,而中间可以穿越多个层次。基于单元组件的自闭性考虑,在穿越的中间层次声明export,继而通过层次连接实现数据通路。
- port可以连接port、export或者imp,export可以连接export或者imp,imp只能作为数据传送的终点,无法扩展连接。
代码实例:
1 | class request extends uvm_transaction; |
从代码可以得出建立TLM通信的常规步骤:
- 定义TLM传输中的数据类型,上面分别定义了request类和response类。
- 分别在各个层次的component中声明和创建TLM端口对象。
- 通过connect()函数完成端口之间的连接。
- 在imp端口类中要实现需要提供给initiator的可调用方法,例如在comp2中由于有一个
uvm_noblocking_put_imp #(request, comp2) nbp_imp
,因此需要实现两个方法try_put()
和can_put()
,而comp4中有一个uvm_blocking_get_imp #(request, comp4) bg_imp
,则需要实现对应的方法get()
。 - 需要注意的是,必须在imp端口类中实现对应方法,否则端口即使连接也无法实现数据传输。
9.2 单向通信
单向通信指的是从initiator到target之间的数据流向是单一方向的,或者说initiator和target只能扮演producer和consumer中的一个角色。在UVM中,对应数据流向的TLM单向端口有很多的类型:
- uvm_blocking_put_PORT
- uvm_nonblocking_put_PORT
- uvm_put_PORT
- uvm_blocking_get_PORT
- uvm_nonblocking_get_PORT
- uvm_get_PORT
- uvm_blocking_peek_PORT
- uvm_nonblocking_peek_PORT
- uvm_peek_PORT
- uvm_blocking_get_peek_PORT
- uvm_nonblocking_get_peek_PORT
- uvm_get_peek_PORT
这里的PORT代表了三种端口名:port、export和imp。这么一计算的话,那么对于单一方向的传输端口一共有36种。看起来这么多的端口类型似乎对读者的记忆不太友好,实际上记忆这么多的端口名是有技巧的。按照每一个端口名的命名规则,它们也指出了通信的两个要素:
- 是否是阻塞的方式(即可以等待延时):blocking or nonblocking
- 何种通信方法:get or put or peek
数据blocking阻塞传输的方法分别包含:
- put:initiator通过该方法可以自己生成数据T t,同时将该数据传送至target。
- get:initiator通过该方法可以从target获取数据T t,而target中的该数据则应消耗。
- peek:initiator通过该方法可以从target获取数据T t,而target中的该数据还应该保留。
此时target就相当于一个buffer,可以对数据进行存储~
特别注意:任务实现必须要task
与上述三种task对应的nonblocking非阻塞的方法分别是:
- try_put
- can_put
- try_get
- can_get
- try_peek
- can_peek
这六个函数与其对应的任务的区别在于,它们必须立即返回,如果try_xxx函数可以发送或者获取数据,那么函数还应该返回1,如果执行失败则应该返回0。或者通过can_xxx函数先试探target是否可以接收数据,通过返回值,再通过try_xxx函数发送,提高数据发送的成功率。
实例代码:
注意代码中相应方法的创建!!!
1 | class itrans extends uvm_transaction; |
class comp1
的run_phase任务:bp_port.put(itr)
实际上并不是在comp1中定义put方法,而是在imp端或者说target端定义的put方法,这里就实现了通信的隔离!!这时实现put方法的可以是任一一个类~只有当bp_port上没有建立连接时才会报错!!!
class comp2中的put方法:实际上就是将数据放入队列;get方法:就是将数据从队列中取出,此时initiator端可以直接调用这里定义的方法
9.3 双向通信
与单向通信相同的是,双向通信的两端也分为initiator和target,但是数据流向在端对端之间是双向的。双向通信中的两端同时扮演着producer和consumer的角色,而initiator作为request发起方,在发起request之后,还会等待response返回。
UVM双向端口分为以下类型:
- uvm_blocking_transport_PORT
- uvm_nonblocking_transport_PORT
- uvm_transport_PORT
- uvm_blocking_master_PORT
- uvm_nonblocking_master_PORT
- uvm_master_PORT
- uvm_blocking_slave_PORT
- uvm_nonblocking_slave_PORT
- uvm_slave_PORT
PORT代表了port、export,不能代表imp
双向端口按照通信握手方式可以分为:
- transaction双向通信方式
- master和slave双向通信方式
transport端口通过transport()
方法,可以在同一方法调用过程中完成REQ和RSP的发出和返回(未收到RSP就不结束)。
master和slave的通信方式必须分别通过put、get和peek的调用,使用两个方法才可以完成一次握手通信。master端口和slave端口的区别在于,当initiator作为master时,它会发起REQ送至target,而后再从target端获取RSP,当initiator使用slave端口时,它会先从target端获取REQ,而后将RSP送至target端。
transport
代码实例:
可以看到transport的方法实现非常简单,直接把REQ赋值给RSP(也可以进行修改后再赋值),齐活~
1 | class comp1 extends uvm_component; |
9.4 多向通信
- 多向通信这种方式服务的仍然是两个组件之间的通信,而不是多个组件之间的通信,毕竟多个组件的通信r仍然可以由基础的两个组件的通信方式来构建。
- 多向通信指的是,如果initiator与target之间的相同TLM端口数目超过一个时的处理解决办法。
相同TLM端口数目超过一个,会产生什么问题呢?
comp1有两个uvm_blocking_put_port,而comp2有两个uvm_blocking_put_imp端口。对于端口例化可以给不同名字,连接也可以通过不同名字来索引,但问题在于
comp2中需要实现两个task put(itrans t),又因为不同端口之间要求在imp端口一侧实现专属方法,这就造成了方法命名冲突,即无法在comp2中定义两个同名的put任务。
解决方法:
UVM通过端口宏声明方式来解决这一问题,它解决问题的核心在于让不同端口对应不同名的任务。UVM为解决多向通信问题的宏按照端口名的命名方式分为:
实例代码:
最关键的就是头两句的宏定义
- initiator端不用管,该怎么定义怎么定义,调用方法也只需要
put()
就行了 - target需要改动
- imp的端口声明,将宏定义括号中的扩展加进去:
uvm_blocking_put_imp_p1 #(itrans, comp2) bt_imp_p1;
- 方法声明类似:
task put_p1(itrans t);
- imp的端口声明,将宏定义括号中的扩展加进去:
1 |
|
9.5 通信管道-插盘
以上通信有一个共同的地方即都是端对端的方式,同时在target一端需要实现传输方法,例如put()或者get()。这种方式在实际使用过程中也不免会给用户带来一些烦恼:
- 如何可以不自己实现这些传输方法,同时可以享受到TLM的好处
- 对于monitor、coverage collector等组件在传输数据时,会存在一端到多端的传输,如何解决这一问题
UVM_TLM_FIFO
- 在一般TLM传输过程中,无论是initiator给target发起一个transaction,还是initiator从target获取一个transaction,transaction最终都会流向consumer中。consumer在没有分析transaction时,先将该对象存储到本地FIFO中。
- 需要分别在两个组件中例化端口,同时在target中实现相应的传输方法。多数情况下,需要实现的传输方法都是相似的,方法的主要内容即是为了实现一个数据缓存功能。
- TLM_FIFO:uvm_tlm_fifo类是一个新组件,它继承于uvm_component类,而且已经预先内置了多个端口以及实现了多个对应方法。
uvm_tlm_fifo的功能类似于mailbox,不同的地方在于uvm_tlm_fifo提供了各种端口可以使用,在initiator端例化put_port或者get_peek_port,来匹配uvm_tlm_fifo的端口类型。如果例化了其它类型的端口,uvm_tlm_fifo还提供put、get以及peek对应的端口
1 | uvm_put_imp #(T,this_type) blocking_put_export |
写作export,其本质还是imp,即target
Analysis Port
除了端对端的传输,在一些情况下还有多个组件会对同一个数据进行运算处理。如果这个数据从同一个源的TLM端口发出到达不同的组件,这就要求该种端口可以满足从一端到多端的需求。如果数据源端发生变化需要通知跟它关联的多个组件时,可以利用软件设计模式的观察者模式(广播模式)来实现。
观察者模式的核心在于:
- 这是从一个initiator端到多个target端的方式。
- analysis port采取的是“push”模式,即从initiator端调用多个target端的write()函数来实现数据传输。
- 调用write函数时,实际上是对所有target中的write函数进行遍历调用
- 因为函数时立即返回的,无论连接多少个target,都可以立即返回;且就算没有连接,调用write()函数也不会报错!!!
注意:这里target端的方法使用的是write函数
实例代码:
1 | initiator.ap.connect(target1.aimp); |
TLM Analysis FIFO(继承与tlm_fifo)
- 由于analysis端口提出实现了一端到多端的TLM数据传输,而一个新的数据存储组件类uvm_tlm_analysis_fifo提供了可以搭配uvm_analysis_port端口、uvm_analysis_imp端口和write()函数。
- uvm_tlm_analysis_fifo类继承于uvm_tlm_fifo,这表明它本身具有面向单一TLM端口的数据缓存特性,而同时该类又有一个uvm_analysis_imp端口analysis_export并且实现了write()函数:
uvm_analysis_imp #(T, uvm_tlm_analysis_fifo #(T)) analysis_export
;
基于initiator到多个target的连接方式,如果实现一端到多端的数据传输,可以插入多个uvm_tlm_analysis_fifo,连接方式如下:
- 将initiator的analysis port连接到tlm_analysis_fifo的analysis_export端口,这样数据可以从initiator发起,写入到各个tlm_analysis_fifo的缓存中。
- 将多个target的get_port连接到tlm_analysis_fifo的get_export端口,注意保持端口类型的匹配,这样从target一侧只需要调用get()方法就可以得到先前存储在tlm_analysis_fifo中的数据。
总结:
- 在一个analysis_port和多个analysis_imp之间加上,多个tml_analysis_fifo
- 把write方法和buffer都写到
tml_analysis_fifo
里面,每个tml_analysis_fifo
预留两个imp端口
Request & Response通信管道
双向通信端口transport,即通过在target端实现transport()方法可以在一次传输中既发送request又可以接收response。UVM提供了两种简便的通信管道,它们作为数据缓存区域,既有TLM端口从外侧接收request和response,同时也有TLM端口供外侧获取request和response。
这两种TLM通信管道分别是uvm_tlm_req_rsp_channel
和uvm_tlm_transport_channel
uvm_tlm_req_rsp_channel
它提供的端口首先是单一方向的
1
2
3
4
5
6
7
8uvm_put_export #(REQ) put_request_export;
uvm_put_export #(RSP) put_response_export;
uvm_get_peek_export #(RSP) get_peek_response_export;
uvm_get_peek_export #(REQ) get_peek_request_export;
uvm_analysis_port #(REQ) request_ap;
uvm_analysis_port #(RSP) response_ap;
uvm_master_imp #(REQ, RSP, this_type, uvm_tlm_fifo #(REQ), uvm_tlm_fifo #(RSP)) master_export;
uvm_slave_imp #(REQ, RSP, this_type, uvm_tlm_fifo #(REQ), uvm_tlm_fifo #(RSP)) slave_export;可以在使用成对的端口进行数据的存储和访问。需要注意的是,
uvm_tlm_req_rsp_channel
内部例化了两个mailbox分别用来存储request和response1
2protect uvm_tlm_fifo #(REQ) m_request_fifo;
protect uvm_tlm_fifo #(RSP) m_response_fifo;initiator端可以连接channel的put_request_export,target连接channel的get_peek_request_export
target连接channel的put_response_export,initiator连接channel的get_peek_response_export端口。
通过这种对应的方式,使得initiator与target可以利用uvm_tlm_req_rsp_channel进行request与response的数据交换
1
2
3
4initiator.put_port.connect(req_rsp_channel.put_request_export);
target.get_peek_port.connect(req_rsp_channel.get_peek_request_export);
target.put_port.connect(req_rsp_channel.put_response_export);
initiator.get_peek_port.connect(req_rsp_channel.get_peek_response_export);- 也可以利用另一种连接方式
1
2initiator.master_port.connect(req_rsp_channel.master_export);
target.slave_port.connect(req_rsp_channel.slave_export);虽然看起来连接变少了,但put和get方法的调用还是和上面一样
uvm_tlm_transport_channel
在uvm_tlm_req_rsp_channel的基础上,UVM又添加了具备transport端口的管道组件uvm_tlm_transport_channel类,它继承于uvm_tlm_req_rsp_channel,并且例化了transport端口
uvm_transport_imp #(REQ, RSP, this_type) transport_export
新添加的这个TLM FIFO组件类型是针对于一些无法流水化处理的request和response传输,例如initiator一端要求每次发送完request,必须等到response接收到以后才可以发送下一个request,这时transport()方法就可以满足这一需求。
将initiator端到
req_rsp_channel
的连接修改为1
initiator.transport_port.connect(transport_channel.transport_export)
10. Sequence 和 Item
sequence
指的是uvm_sequence
类,而item
指的是uvm_sequence_item
类。- 对于激励生成和场景控制,是由
sequence
来编织的,而对于激励所需要的具体数据和控制要求,则是从item
的成员数据得到的。
10.1 Item
item是基于uvm_object类,这表明了它具备UVM核心基类所必要的数据操作方法,例如copy()、clone()、compare()、record()。item根据数据成员的类型,将划分为:
- 控制类。例如总线协议上的读写类型、数据长度、传送模式等。
- 负载类。一般指的是数据总线上的数据包。
- 配置类。用来控制driver的驱动行为,例如命令driver的发送间隔或者有无错误插入。
- 调试类。用来标记一些额外信息方便调试,例如该对象的实例序号、创建时间、被driver解析的时间始末等。
1 | class bus_trans extends uvm_sequence_item; |
输出结果:
item使用时的特点:
- 如果数据域属于需要用来做驱动,那么应考虑定义为rand类型,同时按照驱动协议给出合适的constraint。
- 由于item本身的数据属性,为了充分利用UVM域声明的特性,将必要的数据成员都通过‘uvm_field_XXX宏来声明,方便使用基本数据方法自动实现。
- t1没有被随机化而t2被随机化了,这种差别在item通往sequencer之前是很明显的。UVM要求item的创建和随机化都应该发生在sequence的body()任务中,而不是在sequencer或者driver中。
- 按照item对象的生命周期来区分,它的生命应该开始于sequence的body()方法,而后经历了随机化并穿越sequencer最终到达driver,直到被driver消化之后,它的生命一般来讲才会结束。
10.2 Sequence
Item和Sequence的关系:
一个sequence可以包含一些有序组织起来的item实例,考虑到item在创建后需要被随机化,sequence在声明时也需要预留一些可供外部随机化的变量,这些随机变量一部分是用来通过层次传递约束来最终控制item对象的随机变量,一部分是用来对item对象之间加以组织和时序控制的。
为了区分几种常见的sequence
定义方式,将其分类为:
- 扁平类。这一类往往只用来组织更细小的粒度,即item实例构成的组织。
- 层次类。这一类是由更高层的sequence用来组织底层的sequence,进而让这些sequence按照顺序方法,或者按照并行方式,挂载到同一个sequencer上。
- 虚拟类。这一类则是最终控制整个测试场景的方式,鉴于整个环境中往往存在不同种类的sequencer和其对应的sequence,因此需要一个虚拟的sequence来协调顶层的测试场景。之所以称这个方式为虚拟类,是因为该序列本身并不会固定挂载于某一种sequencer类型上,而是将其内部不同类型sequence最终挂载到不同的目标sequencer上。
扁平类
一个flat sequence往往是由细小的sequence item群落构成,在此之上sequence还有更多的信息来完备它需要实现的激励场景。
一般对于flat sequence而言,它包含的信息:
sequence item以及相关的constraint用来关联生成的item之间的关系,从而完善出一个flat sequence的时序形态。
除了限制sequence item的内容,各个item之间的时序信息也需要由flat sequence给定,例如何时生成下一个item并且发送至driver。
对于需要driver握手的情况(例如读操作),或者等待monitor事件从而做出反应(例如slave的memory response数据响应操作),都需要sequence在收到另外一侧组件
的状态后,再决定下一步操作,即响应具体事件从而创建对应的item并且发送出去。
flat sequence
示例1
1 | class flat_seq extends uvm_sequence; |
flat sequence
示例2
1 | class bus_trans extends uvm_sequence_item; |
这个示例将一段完整发生在数据传输中的、更长的数据都“收编”在一个bug_trans类中,提高这个item粒度的抽象层次,使得item更成熟、更适合切割。这样flat sequence更倾向于控制,不用去关注数据内容,而只关注这个数据包的长度、地址等信息即可,扩充随机数据的责任一般由item负责
层次类
hierarchical sequence区别于flat sequence的地方在于,它可以使用其他sequence,还有item,这么做是为了创建更丰富的激励场景。
通过层次嵌套关系,可以让hierarchical sequence使用其它hierarchical sequence、flat sequence和sequence item,这也就意味着,如果底层的sequence item和
flat sequence的粒度得当,那么就可以充分复用这些flat sequence和sequence来构成形式更多样的hierarchical sequence。
1 | class hier_seq extends uvm_sequence; |
11. Sequencer和driver
driver同sequencer之间的TLM通信采取了get模式,即由driver发起请求,从sequencer一端获得item,再由sequencer将其传递至driver。作为driver,永远停不下来,只要它可以从sequencer获取item,它就一直工作。sequencer和item只应该在合适的时间点产生需要的数据,而至于怎么处理数据,则会由driver来实现。
sequencer是sequence和driver之间的一道关卡,里面只有RSP的fifo,没有REQ的fifo。REQ类型的item到sequencer一侧时就被卡住,driver发送request信号时就开闸放行~
为了便于item传输,UVM专门定义了匹配的TLM端口供sequencer和driver使用:
1 | uvm_seq_item_pull_port #(type REQ=int, type RSP=REQ) |
由于driver
是请求发起端,所以driver
一侧例化了下面两种端口:
一般只用第一种就够了
1 | uvm_seq_item_pull_port #(REQ, RSP) seq_item_port; |
而sequencer
一侧则为请求的响应端,在sequencer
一侧例化了对应的两种端口:
1 | uvm_seq_item_pull_imp #(REQ, RSP, this_type) seq_item_export |
11.1 端口和方法
通常情况下,可以通过匹配的一对TLM端口完成item的完整传送,即driver::seq_item_port和sequencer::seq_item_export。这一对端口在连接时同其它端口连接方式一样,即通过driver::seq_item_port.connect(sequencer::seq_item_export)完成。这一类端口功能主要用来实现driver与sequencer的request获取和response返回。
这一种类型的TLM端口支持如下方法:
本身端口类就是从tlm端口继承而来,所以get,peek,put方法都在
1 | //采取blocking的方式等待从sequence获取下一个item |
关于REQ
和RSP
类型的一致性,由于uvm_sequencer
与uvm_driver
实际上都是参数化的类:
1 | uvm_sequencer #(type REQ=uvm_sequence_item, RSP=REQ) |
通常情况下RSP类型与REQ类型保持一致,这么做的好处是为了便于统一处理,方便item对象的拷贝、修改等操作。driver消化完当前的request后,可以通过
item_done(input RSP rsp_arg=null)方法来告知sequence此次传输已经结束,参数中的RSP可以选择填入,返回相应的状态值。driver也可以通过put_response()或者
put()方法来单独发送response。此外发送response还可以通过成对的uvm_driver::rsp_port和uvm_driver::rsp_export端口来完成,方法为
uvm_driver::rsp_port::write(RSP)。
11.2 item传输实例
1 | class bus_trans extends uvm_sequence_item; |
解析:
在定义sequencer时,默认了REQ类型为uvm_sequence_item类型,这与定义driver时采取默认REQ类型保持一致。
flat_seq作为动态创建的数据生成载体,它的主任务flat_seq::body()做了下面几件事情:
通过方法create_item()创建request item对象。
调用start_item()准备发送item(立刻返回)。
在完成发送item之前对item进行随机处理。
调用finish_item()完成item发送(阻塞,等待sequencer放行)。
有必要的情况下可以从driver获取response item。(
注意:这里的get_response(tmp),是要和driver匹配的,driver返回RSP,这里就接收,driver不返回,这里就不能写,否则就被阻塞
)
把get到的uvm_sequence_item返回类型进行类型转换
在定义driver时,它的主任务driver::run_phase()也应通常做出如下处理:
- 通过seq_item_port.get_next_item(REQ)从sequencer获取有效的request item。
- 从request item中获取数据,进而产生数据激励。
- 对request item进行克隆生成新的对象response item。
- 修改response item中的数据成员,最终通过seq_item_port.item_done(RSP)将response item对象返回给sequence。
注意点:
对于uvm_sequence::get_response(RSP)和uvm_driver::item_done(RSP)这种成对得到操作,是可选的而不是必须的,即可以选择uvm_driver不返回response item,同时sequence也无需获取response item。
在高层环境中,应该在connect_phase中完成driver到sequencer的TLM端口连接,比如在env::connect_phase()中通过drv.seq_item_port.connect(sqr.seq_item_export)完成了driver与sequencer的连接。
在完成了flat_seq、sequencer、driver和env的定义后,到了test1层,除了需要考虑挂起objection防止提前退出,便可以利用uvm_sequence类的方法uvm_sequence::start(SEQUENCER)来实现sequence到sequencer的挂载。
11.3 通信时序
- 无论是sequence还是driver,它们通话的对象都是sequencer。当多个sequence试图挂载到同一个sequencer上时,涉及sequencer的仲裁功能。
- 对于sequence而言,无论是flat sequence还是hierarchical sequence,进一步切分的话,流向sequencer的都是sequence item,所以就每个item的”成长周期“来看,它起始于create_item(),继而通过start_item()尝试从sequencer获取可以通过的权限。
- driver一侧将一直处于”吃不饱“的状态,如果没有了item可以使用,将调用get_next_item()来尝试从sequencer一侧获取item。
- 在sequencer将通过权限交给某一个底层的sequence前,目标sequence中的item应该完成随机化,继而在获取sequencer的通过权限后,执行finish_item()。
接下来sequence中的item将穿过sequencer到达driver一侧,这个重要节点标志着sequencer第一次充当通信桥梁的角色已经完成。 - driver在得到新的item后,会提取有效的数据信息,将其驱动到与DUT连接的接口上面。在完成驱动后,driver通过item_done()告知sequence已经完成数据传送,而sequence在获取该消息后,则表示driver与sequence双方完成了这一次item的握手传输。在这次传递中,driver可以选择将RSP作为状态返回值传递给sequence,而sequence也可以选择调用get_response(RSP)等待从driver一侧获取返回的数据对象。
12. Sequence 和 Sequencer
sequencer就是组件,和driver,monitor一起被封装到同一个agent,在agent中和driver建立连接
sequence就是激励源,利用trans的内部约束和sequence中定义的外部约束,create_item(bus_trans::get_type(), m_sequencer, “req”)
item和sequence都是挂载到sequencer上的,item在sequence的body方法中创建时,挂载到m_sequencer,如上
如果底层的sequence在顶层的sequence创建,同样可以调用宏挂载到m_sequencer(cseq.start(m_sequencer, this);
),也就是和顶层挂载到同一个sequencer上,此时顶层seq在test类中挂载到sequencer组件时,顶层seq内的所有底层seq同样实现挂载
可以在顶层seq对底层的seq进行配置,如
随机化创建:
uvm_do_with(seq, {freq == 150;})
设置sequencer的仲裁器(
m_sequencer.set_arbitration(UVM_SEQ_ARB_STRICT_FIFO);
)- 设置底层seq的优先级:
`uvm_do_pri_with(seq1, 500, {base == 10;})
sequence和item发送实例
sequence 只需要在test中装载到sequencer中,sequence类中就可以直接使用sequencer句柄“m_sequencer”,包括sequence类中实例化的低层sequence,也可以装载到该句柄
1 | class bus_trans extends uvm_sequence_item; |
12.1 发送sequence/item方法解析
在这段代码中,主要使用了两种方法:
第一个方法:将sequence
挂载到sequencer
上
1 | uvm_sequence::start(uvm_sequencer_base_sequence, uvm_sequence_base_parent_sequence=null, int this_priority=-1, bit call_pre_post=1) |
示例中child_seq
被嵌套到top_seq
中,继而在挂载时需要指定parent_sequence
,而在test
一层调用top_seq
时,由于它是root sequence
,则不需要再指定parent sequence
。
第二种发送方法:item挂载到
sequencer上
1 | uvm_sequence::start_item(uvm_sequence_item item, int set_priority=-1, uvm_sequencer_base_sequence=null); |
对于一个item的完整传送,sequence要在sequencer一侧获得通过权限,才可以顺利将item发送至driver。拆解这些步骤如下:
创建item。
通过start_item()方法等待获得sequencer的授权许可,其后执行parent sequence的方法pre_do()。
对item进行随机化处理。
通过finish_item()方法在对item进行了随机化处理之后,执行parent sequence的mid_do(),以及调用uvm_sequencer::send_request()和
uvm_sequencer::wait_for_item_done()来将item发送至sequencer再完成与driver之间的握手。最后执行了parent_sequence的post_do()。
这些完整的细节有两个部分需要注意:
- 第一,
sequence
和item
自身的优先级,可以决定什么时刻可以获取sequencer
的授权。 - 第二,
parent sequence
的虚方法pre_do()
、mid_do()
、post_do()
会发生在发送item
的过程中间。
对比start()方法和start_item()/finish_item(),首先要分清它们面向的挂载对象是不同的。在执行start()过程中,默认情况下会执行sequence的pre_body()和post_body(),但是如果start()的参数call_pre_post=0,那么就不会这样执行。
start()
方法的源代码如下:
1 | sub_seq.pre_start() (task) |
start_item()/finish_item()
源代码如下:
需要注意:
sequence也可以挂在到sequencer上,必须传入的是sequencer的句柄,这样在调用start_item()时,才能执行sequencer的wait_for_grant方法,send_request方法
等待仲裁的方法在只有一个类型的item传入的时候是立刻返回的,只有当多个sequence挂载的时候,才会需要仲裁;优先级高的先拿到sequencer句柄
通过sequencer的都是item,sequence只是挂载
12.2 发送序列的相关宏
- 只有sequence可以调用这些宏,test类中还是要循规蹈矩的用new创建sequence对象进行挂载
- 通过这些sequence/item宏,可以使用’uvm_do、’uvm_do_with来发送无论是sequence还是item。这种不区分对象是sequence还是item方式带来了不少便捷。不同的宏,可能会包含创建对象的过程,也可能不会创建对象。例如’uvm_do、’uvm_do_with会创建对象,而’uvm_send则不会创建对象,也不会将对象做随机处理,因此要了解它们各自包含的执行内容和顺序。
示例:
1 | class child_seq extends uvm_sequence; |
child_seq:create()+rand_send_with() == uvm_do_with();
top_seq:可以看到代码被明显简化了:
底层squence(cseq)的实例化创建和装载只需要一句
item的创建和装载,随机化也就只需要一句
1 | cseq = child_seq::type_id::create("cseq"); |
无论sequence处于什么层次,都应该让sequence在test结束前执行完毕,还应该保留出一部分时间供DUT将所有发送的激励处理完毕,进入空闲状态才可以结束测试。
尽量避免使用fork_join_any或者fork_join_none来控制sequence的发送顺序。
因此如果想终止在后台运行的sequence线程而简单使用disable方式,就可能在不恰当的时间点上锁住sequencer。一旦sequencer被锁住而又无法释放,接下来也就无法
发送其它sequence,尽量在发送完item完成握手之后再终止sequence。
如果要使用fork_join方式,应该确保有方法可以让sequence线程在满足一些条件后停止发送item,否则只要有一个sequence线程无法停止,则整个fork_join无法退出。
12.3 Sequencer的仲裁
uvm_sequencer类自建了仲裁机制用来保证多个sequence在同时挂载到sequencer时,可以按照仲裁规则允许特定sequence中的item优先通过。在实际使用中,可以通过uvm_sequencer::set_arbitration(UVM_SEQ_ARB_TYPE val)函数来设置仲裁模式,这里的仲裁模式UVM_SEQ_ARB_TYPE 有下面几种值可以选择:
- UVM_SEQ_ARB_FIFO:默认模式。来自于sequences的发送请求,按照FIFO先进先出的方式被依次授权,和优先级没有关系。
- UVM_SEQ_ARB_WEIGHTED:不同sequence的发送请求,将按照它们的优先级权重随机授权。
- UVM_SEQ_ARB_RANDOM:不同的请求会被随机授权,而无视它们抵达顺序和优先级。
- UVM_SEQ_ARB_STRICT_FIFO:不同的请求,会按照它们的优先级以及抵达顺序来依次授权,与优先级和抵达时间都有关系。
- UVM_SEQ_ARB_STRICT_RANDOM:不同的请求,会按照它们的最高优先级随机授权,与抵达时间无关。
- UVM_SEQ_ARB_USER:可以自定义仲裁方法user_priority_arbitration()来裁定哪个sequence的请求被优先授权。
1 | class top_seq extends uvm_sequence; |
seq1、seq2、seq3在同一时刻发起传送请求,通过'uvm_do_prio_with
的宏,在发送sequence时可以传递优先级参数。由于将seq1与seq2设置为同样的高优先级,而seq3设置为较低的优先级,这样在随后的UVM_SEQ_ARB_STRICT_FIFO仲裁模式下,可以从输出结果看到,按照优先级高低和传送请求时间顺序,先将seq1和seq2中的item发送完毕,随后将seq3发送完。除了sequence遵循仲裁机制,在一些特殊情况下,有一些sequence需要有更高权限取得sequencer的授权来访问driver。例如在需要响应中断的情况下,用于处理中断的sequence应该有更高的权限来获得sequencer的授权。
12.4 Sequencer的锁定机制
uvm_sequencer提供了两种锁定机制,分别通过lock()和grab()方法实现,这两种的方法区别在于:
- lock()与unlock()这一对方法可以为sequence提供排外的访问权限,但前提条件是,该sequence首先需要按照sequencer的仲裁机制获得授权。而一旦sequence获得授权,则无需担心权限被收回,只有该sequence主动解锁它的sequencer,才可以释放这一锁定的权限,lock()是一种阻塞任务,只有获得了权限才会返回。
- grab()与ungrab()也可以为sequence提供排外的访问权限,而且它只需要在sequencer下一次授权周期时就可以无条件地获得权限。与lock方法相比,grab方法无视同一时刻内发起传送请求的其它sequence,而唯一可以阻止它的只有已经预先获得授权的其它lock或者grab的sequence。
- 如果sequence使用了lock()或者grab()方法,必须在sequence结束前调用unlock()或者ungrab()方法来释放权限,否则sequencer会进入死锁状态而无法继续为其余sequence授权。
示例:
1 | class bus_trans extends uvm_sequence_item; |
结果:
对于sequence locks,在10ns时它跟其它几个sequence一同向sequencer发起请求,按照仲裁模式,sequencer先后授权给seq1、seq2、seq3,最后才授权给locks。而locks在获得授权之后,就可以一直享有权限而无需担心权限被sequencer收回,locks结束前,需要通过unlock()方法返还权限。
对于sequence grabs,尽管在20ns时就发起了请求权限(实际上seq1、seq2、seq3也在同一时刻发起了权限请求),而由于权限已经被locks占用,所以它也无权收回权限。因此只有当locks在40ns结束时,grabs才可以在sequencer没有被锁定的状态下获得权限,而grabs在此条件下获取权限是无视同一时刻发起请求的其它sequence的。同样的在grabs结束前,也应当通过ungrab()方法释放权限,防止sequencer的死锁行为。
13. Sequence的层次化
概述
就水平复用而言,在MCDF各个子模块的验证环境中,它指的是如何利用已有资源,完成高效的激励场景创建。而就垂直复用来看,它指的是在MCDF子系统验证中,可以完成结构复用和激励场景复用两个方面。无论是水平复用还是垂直复用,激励场景的复用很大程度上取决于如何设计sequence,使得底层的sequence实现合理的粒度,帮助完成水平复用,进一步依托于底层激励场景,最终可以实现底层到高层的垂直复用。
13.1 Hierarchical Sequence介绍
在验证MCDF的寄存器模块时,将SV验证环境进化到了UVM环境之后,关于测试寄存器模块的场景可以将其拆分为:
- 设置时钟和复位
- 测试通道1的控制寄存器和只读寄存器
- 测试通道2的控制寄存器和只读寄存器
- 测试通道3的控制寄存器和只读寄存器
上面的测试场景拆解下的sequence
需要挂载的都是reg_master_agent
中的sequencer
。
1 | typedef enum {CLKON, CLKOFF, RESET, WRREG, RDREG} cmd_t; |
item类bus_trans包含了几个简单的域cmd、addr、data。在clk_rst_seq和reg_test_seq这两个底层的sequence在例化和传送item时,就通过随机化bus_trans中的域来实现不同的命令和数据内容。通过不同的数据内容的item,最终可以实现不同的测试目的。
在top_seq中,它就通过对clk_rst_seq和reg_test_seq这两个element sequence进行组合和随机化赋值,最终实现了一个完整的测试场景,即先打开时钟和完成复位,其后对寄存器模块中的寄存器完成读写测试。
所以如果将clk_rst_seq和reg_test_seq作为底层sequence,或者称之为element sequence,top_seq作为一个更高层的协调sequence,它本身也会容纳,并对它们进行协调和随机限制,通过将这些element sequence进行有机的调度,最终完成一个期望的测试场景。那么这样的top_seq就可以成为Hierarchical Sequence,它内部 可以包含多个sequence和item,而通过层层嵌套,最终完成测试序列的合理切分。验证时,有了粒度合适的element sequence,就会更容易在这些设计好的”轮子“上面,实现验证的加速过程。而水平复用,就非常依赖于hierarchical sequence的实现。
13.2 virtual sequence介绍
virtual sequencer:类似顶层路由,起到连接底层sequencer的作用,包含各个组件的sequencer句柄,通过左下角的这个virtual sequencer就可以找到各个组件的sequencer 。本身不传输item,不同和driver连接。所以需要在顶层的connect_phase对virtual sequencer和底层sequencer建立连接。
virtual sequence也是协调各个sequence,但hierarchical sequence面对的对象是同一个sequencer,即hierarchical sequence本身也会挂载到sequencer上面,而对于virtual sequence而言,它内部不同的sequence可以允许面向不同的sequencer种类。即承载了不同sequencer目标的sequence群落。
virtual sequence一般挂载到virtual sequencer上!!!
在MCDF子系统验证环境集成过程中,完成了前期的结构垂直复用,就需要考虑如何各个模块的element sequence和hierarchical sequence。对于更上层的环境,顶层的测试序列要协调的不再只是面向一个sequencer的sequence群,而是要面向多个sequencer的sequence群。面向单一的sequencer,可以通过uvm_sequence::start()来挂载root sequence,而在内部的child sequence则可以通过宏’uvm_do来实现。如果将各个模块环境的element sequence和hierarchical sequence都作为可以复用的sequence资源,那么就需要一个可以容纳各个sequence的容器来承载它们,同时也需要一个合适的routing sequencer来组织不同结构中的sequencer,这样的sequence和sequencer分别称之为virtual sequence和virtual sequencer。
virtual sequence可以承载不同目标sequencer的sequence群落,而组织协调这些sequence的方式则类似于高层次的hierarchical sequence。virtual sequence一般只会挂载到virtual sequencer上面。virtual sequencer与普通的sequencer相比有着很大的不同,它们起到了桥接其它sequencer的作用,即virtual sequencer是一个链接所有底层sequencer句柄的地方,它是一个中心化的路由器。同时virtual sequencer本身并不会传送item数据对象,因此virtual sequencer不需要与任何的driver进行TLM连接。所以UVM需要在顶层的connect阶段,做好virtual sequencer中各个sequencer句柄与底层sequencer实体对象的一一对接,避免句柄悬空。
代码:
virtual sequence中底层sequencer的挂载:和Hierarchical Sequence区别很明显
1 |
m_sequencer:uvm_sequencer的实例句柄
p_sequencer:自己声明的uvm_sequencer子类mcdf_virtual_sequencer的句柄
问:为啥之前的sequencer也继承的uvm_sequencer,都直接用了m_sequencer,这里就要用宏转成p_sequencer?
virtual_sequencer中定义了底层的sequencer,父类句柄m_sequencer访问不到,这里的宏默认做了一个类型转换,p_sequencer就可以访问到底层sqr
1 | class mcdf_normal_seq extends uvm_sequence; |
14. 寄存器模概览
14.1 寄存器模型概览
- 寄存器是模块之间互相交谈的窗口,一方面可以通过读出寄存器的状态,获取硬件当前的状况,另外一方面也可以通过配置寄存器,使得寄存器工作在一定的模式下。在验证的过程中,寄存器的验证也排在了验证清单的前列,因为只有首先保证寄存器的功能正确,才会使得硬件与硬件之间的交谈是“语义一致”的。如果寄存器配置结果与寄存器配置内容不同,那么硬件无法工作在想要的模式下,同时寄存器也可能无法正确反映硬件的状态。
- 硬件中的各个功能模块可以由处理器来配置功能以及访问状态,而与处理器的对话即是通过寄存器的读写来实现的。
- 寄存器的硬件实现是通过触发器,而每一个比特位的触发器都对应着寄存器的功能描述。一个寄存器一般由32个比特位构成,将单个寄存器拆分之后,又可以分为多个域(field),不同的域往往代表着某一项独立的功能。单个的域可能有多个比特位构成,也可能由单一比特位构成(如reg的en,prio,length,),这取决于该域的功能模式可配置的数量。而不同的域,对于外部的读写而言,又大致可以分为WO(只写),RO(只读)和RW(读写),除了这些常见的操作属性以外,还有一些特殊行为(quirky)的寄存器,例如读后擦除模式(clean-on-read,RC),只写一次模式(write-one-to-set,W1S)。
对于MCDF的寄存器模块描述,将0x00功能寄存器和0x10状态寄存器位用图来表示。
通常来讲,一个寄存器有32位宽,寄存器按照地址索引的关系是按字对齐的,上图中的寄存器有多个域,每个域的属性也可以不相同,reserved域表示的是该域所包含的比特位暂时保留以作日后功能的扩展使用,而对保留域的读写不起任何作用,即无法写入而且读出值也是它的复位值。上面的这些寄存器按照地址排列,即可构成寄存器列表,称之为寄存器块,实际上,寄存器块除了包含寄存器,也可以包含存储器,因为它们的属性都近乎读写功能,以及表示为同外界通信的接口。
如果将这些寄存器有机的组合在一起,MCDF的寄存器功能模块即可由这样一个register block来表示:
一个寄存器可以由多个域构成,而单个域可以包含多个比特位,一个功能模块中的多个寄存器可以组团构成一个寄存器模型。上面的图中除了包含了DUT的寄存器模块(由硬件实现),还有属于验证环境的寄存器模型。这两个模块包含的寄存器信息是高度一致的,属于验证环境的寄存器模型也可以抽象出一个层次化的寄存器列表,该列表所包含的地址、域、属性等信息都与硬件一侧的寄存器内容一致。对于功能验证而言,可以将总线访问寄存器的方式抽象为寄存器模型访问的方式,这种方式使得寄存器后期的地址修改(例如基地址更改)或者域的添加都不会对已有的激励构成影响,从而提高已有测试序列的复用性。
14.2 中心化管理方式
通过软件建立寄存器模型的方法要保证与硬件寄存器的内容属性保持一致,这离不开一份中心化管理的寄存器描述文件。寄存器描述文档使用了结构化的文档描述方式,这也是为什么可以通过XML或者Excel(CSV)等数据结构化的方式来实现寄存器的功能描述。
通过数据结构化的存储方式,可以在硬件和软件开发过程中以不同方式来使用寄存器描述文档:
- 系统工程师会撰写并维护寄存器描述文档,而后归置到中心化存储路径供其他工程师开发使用。
- 硬件工程师会利用寄存器描述文件生成寄存器硬件模块(包含各个寄存器的硬件实现和总线访问模块)。
- 验证工程师会利用寄存器描述文件来生成UVM寄存器模型,以供验证过程中的激励使用、寄存器测试和功能覆盖率收集。
- 软件工程师会利用该文件生成用于软件开发的寄存器配置的头文件,从而提高软件开发的可维护性。
14.3 寄存器模型构建
1. 常用类
在构建UVM寄存器模型的过程中,需要用到如下与模型构建相关的类和它们的功能:
uvm_reg_field:类似mcdf的refmod中对于域的枚举,每一个枚举内容都对于1个寄存器的对应bit位
uvm_reg:类似这里的struct,包含多个枚举变量的信息
1
2
3
4
5
6
7typedef struct packed {
bit[2:0] len;
bit[1:0] prio;
bit en;
bit[7:0] avail;
} mcdf_reg_t;
typedef enum {RW_LEN, RW_PRIO, RW_EN, RD_AVAIL} mcdf_field_t;
示例代码:
ctrl_reg:读写寄存器
- object类型,且ref_field声明了rand
- 声明的uvm_reg_field不需要注册!!!
- build()方法:uvm_reg预定义,类似build_phase,对各个field进行创建,和配置
stat_reg:只读寄存器
mcdf_rgm(uvm_reg_block):包含所有的reg和reg_map
uvm_reg_map:在map中传入基地址,再添加寄存器reg和相应的偏移地址
为什么上层的uvm_rgm的build方法中,要调用下层的reg的build方法,而不是像component在build_phase中创建时就会自动加载下层的build_phase方法?
因为component有顶层uvm_root的支持,允许自动调用
为什么uvm_reg_block中的reg句柄也声明了rand?
最终还是为了对reg中的rand变量进行随机化
1 | class ctrl_reg extends uvm_reg; |
注意点:
uvm_reg的new()方法调用父类的new()方法:super.new(name, 32, UVM_NO_COVERAGE);
第一个参数是寄存器的类名,第二个参数为寄存器的位宽,这个数字一般与系统总线的宽度一致,第三个参数为该寄存器是否要加入覆盖率的支持,可选的值如下:
uvm_reg_field的configure()方法的参数:
1 | function void configure( |
uvm_reg_bock的build()方法中对uvm_reg的configure():
1 | function void configure ( |
上述例子中只传了第一个参数~,hdl路径下面有专门的方法定义
uvm_map的create_map()方法的参数:
1 | virtual function uvm_reg_map create_map( |
用add_reg()方法添加寄存器,该方法几个参数的含义如下:
1 | virtual function void add_reg ( |
关于寄存器建模的基本要点和顺序:
在定义单个寄存器时,需要将寄存器的各个域整理出来,在创建之后还应当通过uvm_reg_field::configure()函数来进一步配置各自属性(包括reserve域)。
在定义uvm_reg_block时,需要注意reg_block与uvm_mem、uvm_reg以及uvm_reg_map的包含关系。首先uvm_reg和uvm_mem分别对应着硬件中独立的寄存器或者存储,而一个uvm_reg_block可以用来模拟一个功能模块的寄存器模型,其中可以容纳多个uvm_reg和uvm_mem实例,其次map的作用一方面用来表示寄存器和存储对应的偏移地址,同时由于一个reg_block可以包含多个map,各个map可以分别对应不同总线或者不同地址段。在reg_block中创建了各个uvm_reg之后,需要调用uvm_reg::configure()去配置各个uvm_reg实例的属性。
因为uvm_reg_map也会在uvm_reg_block中例化,在例化之后需要通过uvm_reg_map::add_reg()函数来添加各个uvm_reg对应的偏移地址和访问属性等。只有规定了这些属性,才可以在稍后的前门访问(frontdoor)中给出正确的地址。
uvm_reg_block可以对更大的系统做寄存器建模,这意味着uvm_reg_block之间也可以存在层次关系,上层uvm_reg_block的uvm_reg_map可以添加子一级
uvm_reg_block的uvm_reg_map,用来构建更全局的“版图”,继而通过uvm_reg_block与uvm_reg_map之间的层次关系来构建更系统的寄存器模型。
15. 寄存器模型集成
从上节给的寄存器模型流程图中我们可以看到,接下来需要考虑选择与DUT寄存器接口一致的总线UVC,该UVC会提供硬件级别的访问方式。
要完成一次硬件级别的总线传输,往往需要考虑给出地址、数据队列、访问方式等,而寄存器模型可以使得硬件级别的抽象级可以上升到寄存器级别。
将抽象级上升到寄存器级别的好处:
- 以往写的由具体地址来指定的寄存器,将由寄存器名称来替代,同时寄存器模型封装的一些函数使得可以对域做直接操作,这一升级使得转变后的测试序列更易读。
- 伴随着项目变化,无论寄存器基地址如何变化,以寄存器级别实现的配置序列都要比以硬件级别的序列可维护性更好。
我的理解:
硬件级别就是对interface的直接操作;寄存器模型的使用让这一行为变的间接,测试序列更易读,可维护性更好!
15.1 总线UVC的实现
就是现有的实验中的通过interface对reg的操作
- MCDF访问寄存器的总线接口时序较为简单。控制寄存器接口首先需要在每一个时钟解析
cmd
。 - 当
cmd
为写指令时,即需要把数据cmd_data_in
写入到cmd_addr
对应的寄存器中。 - 当
cmd
为读指令时,即需要从cmd_addr
对应的寄存器中读出数据,在下一个周期,cmd_addr
对应的寄存器数据被输送至cmd_data_out
接口。
1 | class mcdf_bus_trans extends uvm_sequence_item; |
示例中囊括了mcdf_bus_agent
的所有组件,包括sequence item
、sequencer
、driver
、monitor
和agent
:
mcdf_bus_trans
包括了可随机化的数据成员cmd
、addr
、wdata
和不可随机化的rdata
。rdata
之所以没有声明为rand
类型,是因为它应从总线读出或者观察,不应随机化。mcdf_bus_monitor
会观测总线,其后通过analysis port
写出到目标analysis
组件。mcdf_bus_driver
主要实现了总线驱动和复位功能,通过模块化的方法reset_listener()
、driver_bus()
、drive_write()
、drive_read()
、drive_idle()
可以解析三种命令模式IDLE
、WRITE
、READ
,并且在READ
模式下,将读回的数据通过item_done(rsp)
写回到sequencer
和sequence
一侧。‘
本质上就是对于接口的驱动(driver,将数据放到接口)和检测(monitor,从接口采集数据)过程
15.2 adapter
实现从rgm的reg中获取到的uvm_reg_item转化为个之前通过外部sequence挂载到SQR上的bus_seq_item
这里的predictor是干嘛的?
BUS agent对dut的操作,此时硬件的寄存器的信息应该被软件模型预测到,只有这样refmod才能进行打包操作
mcdf中的refmod,里面的模拟寄存器通过do_update_reg()方法,通过reg_monitor检测到的数据对3个reg进行更新;
predictor的功能类似,同样是获取到monitor检测到的bus_transaction,和图中的bus_seq_item是一致的。一旦获取到adapter句柄,就可以转为uvm_reg_bus_on
对软件模型中的寄存器信息进行修改
除此之外还有一种预测方式
不使用predictor,只需要BUS_agent左半边的通路,也就是在输出bus_seq_item时就默认会对dut的寄存器进行配置,依此作为预测!!
但是这种方法有缺陷,比如状态寄存器的值就无法预测
对应的方法见15.2中adapter的集成的实例代码中:rgm.map.set_auto_predict();
Adapter(总线适配器)的实现
在具备了MCDF总线UVC之后,需要实现adapter
。每一个总线对应的adapter
所完成的桥接功能即是在uvm_reg_bus_op
和总线transaction
之间的转换,在开发某一个总线adapter
类型时,需要实现下面几点:
uvm_reg_bus_op
与总线transaction
中各自的数据映射。- 实现
reg2bus()
和bus2reg()
两个函数(预定义,必须实现),这两个函数实现了两种transaction
的数据映射。 - 如果总线支持
byte
访问,可以使能supports_byte_enable
;如果总线UVC要返回response
数据,则应当使能provides_response
。mcdf_bus_driver
在读数时会将读回的数据填入到RSP
并返回至sequencer
,因此需要在adapter
中使能provides_responses
。由此使得bus2reg()
函数调用时得到的数据是总线返回时的transaction
,如果总线UVC不支持返回RSP
(没有调用put_response(RSP)
或者item_done(RSP)
),那么不应该置此位,否则adapter
将会使得验证环境挂起。
代码实例:
1 | class reg2mcdf_adapter extends uvm_reg_adapter; |
uvm_reg_bus_op
类的成员变量:
该类在构建函数中使能了provides_response
,这是因为mcdf_bus_driver
在发起总线访问之后会将RSP
一并返回至sequencer
。reg2bus()
完成的桥接场景是,如果在寄存器级别做了操作,那么寄存器级别操作的信息uvm_reg_bus_op
会被记录,同时调用uvm_reg_adapter::reg2bus()
函数。在完成了将uvm_reg_bus_op
的信息映射到mcdf_bus_trans
之后,函数将mcdf_bus_trans
实例返回。而在返回mcdf_bus_trans
之后,该实例将通过mcdf_bus_sequencer
传入到mcdf_bus_driver
。这里的transaction
传输是后台隐式调用的。
寄存器无论读写,都应当知道总线操作后的状态返回,对于读操作时,也需要知道总线返回的读数据,因此uvm_reg_adapter::bus2reg()
即是从mcdf_bus_driver()
将数据写回至mcdf_bus_sequence
,而一直保持监听的reg2mcdf_adapter
一旦从sequencer
获取了RSP(mcdf_bus_trans)
之后,就将自动调用bus2reg()
函数。bus2reg()
函数的功能与reg2bus()
相反,完成了从mcdf_bus_trans
到uvm_reg_bus_op
的内容映射。在完成映射之后,更新的uvm_reg_bus_op
数据最终返回至寄存器操作场景层。对于寄存器操作,无论读操作还是写操作,都需要经历调用reg2bus()
,继而发起总线事务,而在完成事务发回反馈之后,又需要调用bus2reg()
,将总线的数据返回至寄存器操作层面。
15.2 Adapter的集成
在具备了寄存器模型mcdf_rgm
、总线UVC mcdf_bus_agent
和桥接reg2mcdf_adapter
之后,就需要考虑如何将adapter
集成到验证环境中去:
- 对于
mcdf_rgm
的集成,倾向于顶层传递的方式,即最终从test层传入寄存器模型句柄。这种方式有利于验证环境mcdf_bus_env
的闭合性,在后期不同test
如果要对rgm
做不同的配置,都可以在顶层例化,而后通过uvm_config_db
来传递。 - 寄存器模型在创建之后,还需要显式调用
build()
函数。而uvm_reg_block
是uvm_object
类型,因此其预定义的build()
函数并不会自动执行,还需要单独调用。 - 在顶层环境的
connect
阶段中,还需要将寄存器模型的map
组件与bus sequencer
和adapter
连接。这样才能将map
(寄存器信息)、sequencer
(总线侧激励驱动)和adapter
(寄存器级别和硬件总线级别的桥接)关联在一起。也只有通过这一步,adapter
的桥接功能才可以工作。
1 | class mcdf_bus_env extends uvm_env; |
15.3 访问方式
利用寄存器模型,可以更方便地对寄存器做操作,分为两种访问寄存器的方式,即前门访问和后门访问。
- 前门访问,指的是在寄存器模型上做的读写操作,最终会通过总线UVC来实现总线上的物理时序访问,因此是真实的物理操作。
- 后门访问,指的是利用UVM DPI(uvm_hdl_read()、uvm_hdl_deposit()),将寄存器的操作直接作用到DUT内的寄存器变量,而不通过物理总线访问。
前门访问
原理:
- 搞一个sequence继承于
uvm_reg_sequence
; - 这里的sequence不用考虑挂载到sequencer,adapter的集成可以看到寄存器模型rgm的map和adapter,以及agent的sequencer都是连起来的!!!sequence只需要拿到寄存器模型,就可以通过寄存器模型对sequencer进行读写操作!!!
前门访问的示例中的sequence
继承于uvm_reg_sequence
。uvm_reg_sequence
除了具备一般uvm_sequence
的预定义方法外,还具有跟寄存器操作相关的方法。
- 第一种即
uvm_reg::read()/write()
。传递时需要注意将参数path
指定为UVM_FRONTDOOR
。uvm_reg::read()/write()
方法可传入的参数较多,除了status
和value
两个参数需要传入,其它参数如果不指定,可采用默认值。 - 第二种即
uvm_reg_sequence::read_reg()/write_reg()
。在使用时,也需要将path
指定为UVM_FRONTDOOR
。
后门访问
- 进行后门访问时,用户首先确保寄存器模型在建立时,是否将各个寄存器映射到了DUT一侧的HDL路径。
例码中通过uvm_reg_block::add_hdl_path(),将寄存器模型关联到了DUT一端,而通过uvm_reg::add_hdl_path_slice完成了将寄存器模型各个寄存器成员与HDL一侧的地址映射。例如在稍后对寄存器SLV0_RW_REG进行后门访问时,UVM DPI函数会通过寄存器HDL路径“reg_backdoor_access.dut.regs[0]”映射到正确的寄存器位置,继而对其进行读值或者修改。另外,寄存器模型build()函数最后一句,以lock_model()结尾,该函数的功能是结束地址映射关系,并且保证模型不会被其它用户修改。
在寄存器模型完成了HDL路径映射后,我们才可以利用uvm_reg或者uvm_reg_sequence自带的方法进行后门访问,下面仍然给出一段后门访问的例码。类似于前门访问,后门访问也有几类方法提供:
- uvm_reg::read()/write(),在调用该方法时需要注明UVM_BACKDOOR的访问方式。
- uvm_reg_sequence::read_reg()/write_reg(),在使用时也需要注明UVM_BACKDOOR的访问方式。
- 另外,uvm_reg::peek()/poke()两个方法,也分别对应了读取寄存器(peek)和修改寄存器(poke)两种操作,而用户无需指定访问方式尾UVM_BACKDOOR,因为这两个方法本来就只针对于后门访问。
前门和后门访问的区别
从上面的差别可以看出,后门访问较前门访问更便捷一些更快一些,但如果单纯依赖后门访问也不能称之为“正道”。实际上,利用寄存器模型的前门和后门访问两种混合方式,对寄存器验证的完备性更有帮助。下面给出一些实际应用的场景:
- 通过前门访问的方式,先验证寄存器访问的物理通路工作正常,并且有专门的寄存器测试的前门访问用例,来遍历所有的寄存器。在前门访问被验证充分的前提下,可以在后续测试中使用后门访问来节省访问多个寄存器的时间。
寄存器随机设置的精髓不在于随机可设置的域值,而是为了考虑日常不可预期的场景,先通过后门访问随机化整个寄存器列表(在一定的随机限制下),随后再通过前门访问来配置寄存器。这么做的好处在于,不再只是通过设置复位之后的寄存器这种更有确定性的场景,而是通过让测试序列一开始的寄存器值都随机化来模拟无法预期的硬件配置前场景,而在稍后设置了必要的寄存器之后,再来看是否会有意想不到的边界情况发生。
- 有的时候,即便通过先写再读的方式来测试一个寄存器,也可能存在地址不匹配的情况。譬如寄存器A地址本应该0x10,寄存器B地址本应该为0x20;而在硬件实现用,寄存器A对应的地址位0x20,寄存器B对应的地址位0x10。像这种错误,即便通过先写再读的方式也无法有效测试出来,那么不妨在通过前门配置寄存器A之后,再通过后门访问来判断HDL地址映射的寄存器A变量值是否改变,最后通过前门访问来读取寄存器A的值。上述的方式是在之前前门测试的基础之上又加入了中途的后门访问和数值比较,可以解决地址映射到内部错误寄存器的问题。
- 对于一些状态寄存器,在一些时候外界的激励条件修改会依赖这些状态寄存器,并且在时序上的要求也可能很严格。例如,上面MCDF的寄存器中有一组状态寄存器表示各个channel中FIFO的余量,而channel中FIFO的余量对于激励驱动的行为也很重要。无论是前门访问还是后门访问,都可能无法第一时间反映FIFO在当前时刻的余量。因此对于需要要求更严格的测试场景,除了需要前门和后门来访问寄存器,也需要映射一些重要的信号来反映第一时间的信息。
16. 寄存器模型的常规方法
16.1 mirror、desired和actual value
- 在应用寄存器模型的时候,除了利用它的寄存器信息,也会利用它来跟踪寄存器的值。寄存器模型中的每一个寄存器,都应该有两个值,一个是镜像值(mirrored value),一个是期望值(desired value)。
- uvm中存放这些value值的最小单元其实不是uvm_reg,而是uvm_reg_field
- 期望值是先利用寄存器模型修改软件对象值,而后利用该值更新硬件值;镜像值是表示当前硬件的已知状态值。镜像值往往由模型预测给出,即在前门访问时通过观察总线或者在后门访问时通过自动预测等方式来给出镜像值。
- 镜像值有可能与硬件实际值(actual value)不一致。例如状态寄存器的镜像值就无法与硬件实际值保持同步更新(必须进行前门访问或者后门访问,否则软件模型中的寄存器不可能更新),另外如果其他访问寄存器的通路修改了寄存器,那么可能由于那一路总线没有被监测,因此寄存器的镜像值也无法得到及时更新。
uvm_reg_field的属性
在深入了解寄存器访问方法之前,让我们看看如何存储寄存器值。 如寄存器抽象中所示,uvm_reg_field是表示寄存器的位的最低寄存器抽象层。 uvm_reg_field使用多个属性来存储各种寄存器字段值:
- m_reset [“HARD”]存储硬重置值(hard reset)。 请注意,m_reset是一种带有一种重置键的关联数组。
- m_mirrored存储我们在待测试设计(DUT)中所认为应该存储的值。
- m_desired存储我们想要设置给DUT的值。
- value将要采样的值存储在功能覆盖率中,或者当该字段被随机化时将value约束。
请注意,在这些属性中,只有值属性是公共的。 其他属性是本地的,因此我们无法直接从类外访问它们。 稍后我们将向您介绍如何使用寄存器访问方法访问这些本地属性。
randomize()方法是一个SystemVerilog方法。它随机化一个寄存器字段对象的值属性。随机化后,post_randomize()方法将value属性的值复制到m_desired属性。请注意,如果value属性的rand_mode为OFF,则pre_randomize()方法会将m_desired的值复制到value属性。
对寄存器模型的design值进行随机化:
1 | void'(rgm.slv_en.en.randomize() with {value inside {['b0:'b1111]};}); |
总结一下:想要通过寄存器模型修改期望值,此时期望值和镜像值(这里的镜像值对应寄存器的实际值)不同,此时就可以调用update()方法,对dut的寄存器实际值进行更新,激励发送的时候又会被monitor监测经由predictor显式预测修改镜像值,此时期望值和镜像值再次一样。如果再修改期望值,调用update()方法,此时就又进入了下一个循环
16.2 prediction的分类
UVM提供了两种用来跟踪寄存器值的方式,将其分为自动预测(auto prediction)和显式预测(explicit)。如果想使用自动预测的方式,还需要调用函数uvm_reg_map::set_auto_predict()。两种预测方式的显著差别在于,显式预测对寄存器数值预测更为准确。
mirror_value只有通过prediction才能修改
在15.2 中也添加了对预测的理解
自动预测
如果没有在环境中集成独立的predictor,而是利用寄存器的操作来自动记录每一次寄存器的读写数值,并在后台自动调用predict()方法的话,这种方式称之为自动预测。这种方式简单有效,但是需要注意的是,如果出现了其它一些sequence直接在总线层面上对寄存器进行操作(跳过寄存器级别的 write()/read()操作,或者通过其它总线来访问寄存器等这些额外的情况,都无法自动得到寄存器的镜像值和预期值。)
显式预测
在通过总线对dut的reg进行注入的时候,monitor检测总线数据,并交由已定义的predictor,经过adapter的处理,返回软件模型,更新其中的期望值和镜像值。这一过程也是自动完成的!!
- 更为可靠的一种方式是在物理总线上通过监视器来捕捉总线事务,并将捕捉到的事务传递给外部例化的predictor,该predictor由UVM参数化类uvm_reg_predictor例化并集成在顶层环境中。
- 在集成的过程中需要将adapter与map的句柄也一并传递给predictor,同时将monitor采集的事务通过analysis port接入到predictor一侧。这种集成关系可以使得,monitor一旦捕捉到有效事务,会发送给predictor,再由其利用 adapter的桥接方法,实现事务信息转换,并将转化后的寄存器模型有关信息更新到map中。
- 默认情况下,系统将采用显式预测的方式,这就需要集成到环境中的总线UVC monitor需要具备捕捉事务的功能和对应的analysis port,以便于同predictor连接
显示预测的实例:
1 | class mcdf_bus_env extends uvm_env; |
注意:
predictor同样是参数化的类,要传入参数
mcdf2reg_predictor = uvm_reg_predictor #(mcdf_bus_trans)::type_id::create("mcdf2reg_predictor", this);
predictor通过连接获得寄存器模型map的实例句柄,同样通过连接过去adapter的实例句柄(如上图所示的两条曲线),此时就可以将预测结果写入rgm
1
2mcdf2reg_predictor.map = rgm.map;
mcdf2reg_predictor.adapter = reg2mcdf;组件的monitor和predictor建立analysis连接
1
agent.monitor.ap.connect(mcdf2reg_predictor.bus_in);
16.3 uvm_reg的访问方法
1. reg相关类提供的方法
uvm_reg_block
、uvm_reg
、uvm_reg_field
三个类提供的用于访问寄存器的方法:
mirror:在uvm_reg_block一级调用mirror()方法,则reg_block下的所有寄存器都被赋予镜像值
update:先修改软件的值,在调用update时发现软件和硬件值不同,则对硬件值进行修改
2. uvm_reg_sequence提供的方法
都是针对寄存器对象的,而不是寄存器块或者寄存器域。
- 对于前门访问的read()和write(),在总线事务完成时,镜像值和期望值才会更新为与总线上相同的值,这种预测方式是显式预测。
- 对于peek()和poke(),以及后门访问模式下的read()和write(),由于不通过总线,默认采取自动预测的方式,因此在零时刻方法调用返回后,镜像值和期望值也相应修改。
- 关于reset()和get_reset()的用法,例如硬件在复位触发时,会将内部寄存器值复位,而寄存器模型在捕捉到复位事件时,为了保持同硬件行为一致,也应当对其复位,这里的复位的对象是寄存器模型,而不是硬件。
1 | @(negedge p_sequencer.vif.rstn); |
- 在复位之后,也可以通过读取寄存器模型的复位值(与寄存器描述文件一致),与前门访问获取的寄存器复位值进行比较,以此来判断硬件各个寄存器的复位值是否按照寄存器描述去实现。这里的
get_reset()
方法指的也是寄存器模型的复位值,而不是硬件。
1 | //register model reset value get and check |
- 对于读写寄存器来说,通过write()方法把软件模型的值写入dut之后,可以调用mirror方法获得硬件实际值对镜像值进行修改!!!
- mirror()方法与read()方法类似,也可以选择前门访问或者后门访问,不同的是,mirror()不会返回读回的数值,但是会将对应的镜像值修改。在修改镜像值之前,可以选择是否将读回的值与模型中的原镜像值进行比较。例如,对于配置寄存器,可以采用这样的方法来检查上一次的配置是否生效,又或者对于状态寄存器,可以选择只更新镜像值不做比较,这是因为状态寄存器随时可能被硬件内部逻辑修改。
1 | //get register value and check |
set()方法:修改期望值
update()方法:将修改更新到硬件,让左右两边的寄存器一致
set()方法的对象是寄存器模型自身,通过set()可以修改期望值,而在寄存器配置时不妨先对其模型随机化,再配置个别寄存器或者域,当寄存器的期望值与镜像值不同时,可以通过update()方法来将不同的寄存器通过前门访问或者后门访问的方式做全部修改。这种set()和update()的方式较write()和poke()的寄存器方式更为灵活的是,它可以实现随机化寄存器配置值(先随机化寄存器模型,后将随机化的值结合某些域的指定值写入到寄存器),继而模拟更多不可预知的寄存器应用场景,另外update()强大的批量操作寄存器功能使得修改寄存器更方便。
1 | //randomize register model ,set register/field value and update to hardware actual value |
16.4 mem与reg的联系和差别
UVM寄存器模型也可以用来对存储建模。uvm_mem类可以用来模拟RW(读写)、RO(只读)和WO(只写)类型的存储,并且可以配置存储模型的数据宽度和地址范围。uvm_mem不同于uvm_reg的地方在于,考虑到物理存储因映射到uvm_mem会带来更大的资源消耗,因此uvm_mem并不支持预测和影子存储(shadow storage)功能,即没有镜像值和期望值。
uvm_mem可以提供的功能就是利用自带的方法去访问硬件存储,相比于直接利用硬件总线UVC进行访问,这么做的好处在于:
- 类似于寄存器模型访问寄存器,利用存储模型访问硬件存储便于维护和复用。
- 在访问过程中,可以利用模型的地址范围来测试硬件的地址范围是否全部覆盖。
- 由于uvm_mem也同时提供前门访问和后门访问,这使得存储测试可以考虑先通过后门访问预先加载存储内容,而后通过前门访问读取存储内容,继而做数据比对,这样做不但节省时间,同时也在测试方式上保持了前后一致性。同时这种方式相比于传统测试方式(利用系统函数或者仿真器实现存储加载),要在UVM框架中更为统一。
- 与uvm_reg相比,uvm_mem不但拥有常规的访问方法read()、write()、peek()、poke(),也提供了burst_read()和burst_write()。之所以额外提供这两种方法,不但是为了可以更高速通过总线BURST方式连续存储,也是为了贴合实际访问存储中的场景。
要实现BURST访问形式,需要考虑以下因素:
- 目前挂载的总线UVC是否支持BURST形式访问,例如APB不能支持BURST访问模式。
- 与read()、write()方法相比,burst_read()和burst_write()的参数列表中的一项uvm_reg_data_t value[]采用的是数组形式,不再是单一变量,即表示用户可以传递多个数据。而在后台,这些数据首先需要装载到uvm_reg_item对象中,装载时value数组可以直接写入,另外两个成员需要分别指定为element_kind = UVM_MEM,kind = UVM_BURST_READ。
- 在adapter实现中,也需要考虑到存储模型BURST访问的情形,实现四种访问类型的转换,即UVM_READ、UVM_WRITE、UVM_BURST_READ和UVM_BURST_WRITE。
- 对于UVM_READ、UVM_WRITE的桥接,已经在寄存器模型访问中实现,而UVM_BURST_READ和UVM_BURST_WRITE的转换,往往需要考虑写入的数据长度,例如长度是否是4、8、16或者其它。
- 此外还需要考虑不同总线的其它控制参数,例如AHB支持WRAP模式,AXI支持out-of-order模式等,如果想要将更多的总线控制封装在adapter的桥接功能里,需要将更多的配置作为扩展配置,在调用访问方法时作为扩展信息类,传入到形式参数uvm_object extension。
- 对于更为复杂的BURST形式,如果需要实现更多的协议配置要求,那么最好直接在总线UVC层面去驱动,这样做的灵活性更大,且更能充分全面测试存储接口的协议层完备性。
16.5 内建(built-in) sequences
- 项目一开始的阶段,设计内部的逻辑还不稳定,可以展开验证的部分无外乎是系统控制信号(时钟、复位、电源)和寄存器的验证。
- 在项目早期,寄存器模型的验证可以为后期各个功能点验证基础。比如通过内建的寄存器或者存储序列可以实现完整的寄存器复位值检查,又比如检查读写寄存器的读写功能是否正常等。
uvm_reg_shared_access_seq
对于两个连接到mcdf的不同的处理器,其访问reg的基地址不同,这里rgm中也有两个map与之相对应,这就对应uvm_reg_shared_access_seq的使用场景
存储模型内建序列
寄存器健康检测(利用内建sequence)
- 声明并例化:uvm_reg_hw_reset_seq reg_rst_seq = new();检测寄存器模型复位值是否与硬件复位值一致
- 给这个内建sequence添加寄存器模型:
1 | class mcdf_example_seq extends uvm_reg_sequence; |
对于一些寄存器,如果想将其排除在某些内建序列测试范围之外,可以额外添加上面列表中提到的“禁止域名”。由于uvm_reg_block和uvm_reg都是uvm_object类而不是uvm_component类,所以可以使用uvm_resource_db来配置“禁止域名”。
mcdf_rgm::build()方法,这相当于寄存器模型在自己的建立阶段设定了一些属性。当然,uvm_resource_db的配置也可以在更高层指定,只不过考虑到uvm_resource_db不具备层次化的覆盖属性,最好只在一个地方进行“禁止域名”的配置。
1 | class mcdf_rgm extends uvm_reg_block; |
17. 寄存器模型的应用场景
- 通过寄存器模型的常规方法,可以用来检查寄存器,以及协助检查硬件设计逻辑进和比对数据。
- 在软件实现硬件驱动和固件层时,也会实现类似寄存器模型镜像值的方法,即在寄存器配置的底层函数中,同时也声明一些全局的影子寄存器。这些影子寄存器的功能就是暂存当时写入寄存器的值,而在后期使用时,如果这些寄存器是非易失的,那么就可以省略读取寄存器的步骤,转而使用影子寄存器的值。这么做的好处在于响应更迅速,而不再通过若干个时钟周期的总线发起请求和等待响应,但另外一方面这么做的前提同测试寄存器模型的目的是一样的,即寄存器的写入值可以准确地反映到硬件中的寄存器。
- 利用寄存器模型的另一个场景是,在对硬件数据通路做数据比对时,需要及时地知道当时的硬件配置状况,而利用寄存器模型的镜像值可以实现实时读取,而不需要从前门访问。后门访问也可以在零时刻内完成,只是这么做会省略检查寄存器的步骤,即假设寄存器模型的镜像值同硬件中的寄存器真实值保持一致,而这一假设存在验证风险。所以只有这么做,才能为后期软件开发时使用影子寄存器扫清可能的硬件缺陷。
寄存器模型不但可以用来检查硬件寄存器,也可以用来配合scoreboard实时检查DUT的功能。(之前的refmod中的寄存器组)
用来检查寄存器时,有以下几种可行的方式:
- 从前门写,并且从前门读。这种方式最为常见,但是无法检查地址是否正确映射,而前门与后门混合操作的方式可以保证地址的映射检查。
- 从前门写,再从后门读。
- 从后门写,再从前门读。
- 对于一些状态寄存器(硬件自身信号会驱动更新其实际值),先用peek()来获取(并且会调用predict()方法来更新镜像值),再调用mirror()方法来从前门访问并且与之前更新的镜像值比较。
上面的这些方法,在寄存器模型的内建序列中都已经实现。与内建序列相比,自建序列可以更灵活,更贴近需求,而内建序列使用简单,是全自动化的方式。
在配合scoreboard实施检查DUT的功能时,需要注意:
- 无论是将寄存器模型通过config_db进行层次化配置,还是间接通过封装在配置对象(configuration object)中的寄存器模型句柄,都需要scoreboard可以索引到寄存器模型。
- 在读取寄存器或者寄存器域的值时,需要加以区分。uvm_reg类中没有类似value的成语来表征其对应硬件寄存器的值。
- uvm_reg并不是寄存器模型的最小切分单元,uvm_reg_field才是。uvm_reg可以理解为uvm_reg_field的容器,一个uvm_reg可以包含多个顺序排列的
- uvm_reg_field。在取值时,可以使用uvm_reg_field的成员value直接访问,最好使用uvm_reg类和uvm_reg_field类都具备的接口函数get_mirrored_value()。
功能覆盖率
在测试寄存器以及设计的某些功能配置模式时,也需要统计测试过的配置情况。
就MCDF寄存器模型来看,除了测试寄存器本身,还需要考虑在不同的配置下,设计的数据处理、仲裁等功能是否正确,所以需要放置功能覆盖率在寄存器模型中。
由于寄存器描述文件的结构化,可以通过扩充寄存器模型生成器的功能,使得生成的寄存器模型也可以自动包含各个寄存器域的功能覆盖率。
UVM的寄存器模型已经内置了一些方法用来使能对应的covergroup,同时在调用write()或者read()方法时,会自动调用covergroup::sample()来完成功能覆盖率收集。
覆盖率自动收集模式
- 如果寄存器模型生成器可以一并生成covergroup和对应方法,我们就可以考虑是否实例化这些covergroup,以及何时收集这些数据
- 从示例中摘出的ctrl_reg寄存器扩充定义部分看,value_cg是用来收集寄存器中所有的域(包含reserved只读区域)
- 由于covergroup在此模式下可以自动生成,并且在使能的情况下,可以在每次read()、write()方法后调用
- 从实例化时的内存消耗、以及每次采集时的内存消耗,从上百个寄存器内置的covergroup联动的情况触发,是否实例化,是否使能采样数据都需要考虑
- 在验证前期,可以不实例化covergroup,保证更好的资源利用;在验证后期需要采集功能覆盖率时,再考虑实例化、使能采样
1 | class ctrl_reg extends uvm_reg; |
sample()可以理解为read()、write()方法的回调函数,需要填充该方法,使得可以保证自动采样数据。
sample_values()是提供外部调用的方法,在一些特定事件触发时,例如中断、复位等场景,可以在外部通过监听具体事件来调用该方法。在sample_values()方法中,可以通过调用get_coverage()方法来判断是否允许进行覆盖率采样。
覆盖率外部事件触发收集
更贴合实际的、可作为覆盖率验收标准的covergroup定义还当采取自定义的形式,一方面来限定感兴趣的域和值,一方面来指定感兴趣的采样事件,即使用合适的事件来触发采样,通过这种方式,最后可以完成寄存器功能覆盖率的验证完备性标准。
1 | class mcdf_coverage extends uvm_subscriber #(mcdf_bus_trans); |