后门访问与前门访问
*UVM中前门访问的实现
前门访问:通过寄存器配置总线(如APB协议、OCP协议、I2C协议等)对DUT进行操作。任何总线协议中前门访问操作只有两种:读操作和写操作。前门访问操作是比较正统的用法。对实际焊接在电路板上正常工作的芯片来说,此时若要访问其中某些寄存器,前门访问操作是唯一的方法。
前面提到对于参考模型来说,最大的问题是如何在其中启动一个sequence,列举了全局变量和config_db的两种方式。除这两种方式之外,如果能在参考模型中得到sequencer的指针,也可以在此sequencer上启动sequence。这通常比较容易实现,只要在其中设置一个p_sqr的变量,并在env中将sequencer的指针赋值给此变量即可。
接下来的关键是分别写一个读写的sequence:
class reg_access_sequence extends uvm_sequence#(bus_transaction);
string tID = get_type_name();
bit[15:0] addr;
bit[15:0] rdata;
bit[15:0] wdata;
bit is_wr;
…
virtual task body();
bus_transaction tr;
tr = new("tr");
tr.addr = this.addr;
tr.wr_data = this.wdata;
tr.bus_op = (is_wr ? BUS_WR : BUS_RD);
`uvm_info(tID, $sformatf("begin to access register: is_wr = %0d, addr = %0h",
is_wr, addr), UVM_MEDIUM)
`uvm_send(tr)
`uvm_info(tID, "successfull access register", UVM_MEDIUM)
this.rdata = tr.rd_data;
endtask
endclass
之后,在参考模型中使用如下方式来进行读操作:
task my_model::main_phase(uvm_phase phase);
…
reg_access_sequence reg_seq;
super.main_phase(phase);
reg_seq = new("reg_seq");
reg_seq.addr = 16'h9;
reg_seq.is_wr = 0;
reg_seq.start(p_sqr);
while(1) begin
…
if(reg_seq.rdata)
invert_tr(new_tr);
ap.write(new_tr);
end
endtask
sequence是自动执行的,但在其执行完毕后(body及post_body调用完成),为此sequence分配的内存依然是有效的,所以可以使用reg_seq继续引用此sequence。上述读操作正是用到了这一点。
对UVM,其在寄存器模型中使用的方式也与此类似。上述操作方式的关键是在参考模型中有一个sequencer的指针,在寄存器模型中也有这样的指针,就是在前面base_test的connect_phase为default map设置的sequencer指针。
UVM是一种通用的验证方法学,所以要能够处理各种transaction类型。这些要处理的transaction都非常相似,综合它们的特征后,UVM内建了一种transaction:uvm_reg_item。通过adapter的bus2reg及reg2bus,可以实现uvm_reg_item与目标transaction的转换。以读操作为例,其完整的流程为:
- 参考模型调用寄存器模型的读任务
- 寄存器模型产生sequence,并产生uvm_reg_item:rw
- 产生driver能够接受的transaction:bus_req=adapter.reg2bus(rw)
- 把bus_req交给bus_sequencer
- driver得到bus_req后驱动它得到读取的值,并将读取值放入bus_req中,调用item_done
- 寄存器模型调用adapter.bus2reg(bus_req, rw)将bus_req中的读取值传递给rw
- 将rw中的读数据返回参考模型
如果driver一直发送应答而sequence不收集应答,那么将会导致sequencer的应答队列溢出。UVM考虑到这种情况,在adapter中设置了provide_responses选项:
virtual class uvm_reg_adapter extends uvm_object;
…
bit provides_responses;
…
endclass
设置此选项后,寄存器模型在调用bus2reg将目标transaction转换成uvm_reg_item时,传入的参数是rsp而不是req。使用应答机制的操作流程为:
- 参考模型调用寄存器模型的读任务
- 寄存器模型产生sequence,并产生uvm_reg_item:rw
- 产生driver能够接受的transaction:bus_req=adapter.reg2bus(rw)
- 将bus_req交给bus_sequencer
- driver得到bus_req后驱动它得到读取的值,并将读取值放入rsp中,调用item_done
- 寄存器模型调用adapter.bus2reg(rsp, rw)将rsp中的读取值传递给rw
- 将rw中的读数据返回参考模型
后门访问操作的定义
为了讲述后门访问操作,从本节开始将引入一个新的DUT,这个DUT中加入了寄存器counter。它的功能就是统计rx_dv为高电平的时钟数。
在通信系统中有大量计数器用于统计各种包裹的数量,如长包、中包、短包等。这些计数器的共同特点是它们是只读的,DUT的总线接口无法通过前门访问操作对其进行写操作。这些寄存器的位宽一般都比较宽,如32位或64位等,超过了设计中对加法器宽度的上限限制。计数器在计数过程中需要使用加法器,对于加法器来说在同等工艺下位宽越宽则其时序越差,因此设计时一般会规定加法器的最大位宽。上述DUT的加法器位宽被限制在16位。实现32位counter的加法操作,需要使用两个叠加的16位加法器。
为counter分配16‘h5和16’h6的地址,采用大端格式将高位数据存放在低地址。此计数器是可读的,另外可以对其进行写1清0操作。如果对其写入其他数值则不会起作用。
后门访问是与前门访问相对的操作,广义上,所有不通过DUT的总线而对DUT内部的寄存器或者存储器进行存取的操作都是后门访问操作。如在top_tb中可以使用如下方式对counter赋初值:
initial begin
@(posedge rst_n);
my_dut.counter = 32'hFFFD;
end
所有后门访问操作都是不消耗仿真时间(即$time打印的时间)而只消耗运行时间,这是后门访问操作的最大优势。它存在的意义在于:
- 后门访问操作能够更好地完成前门访问操作所做的事情。后门访问不消耗仿真时间,它消耗的运行时间要远小于前门访问操作的运行时间。大型芯片的验证中,在其正常工作前需配置众多的寄存器,配置时间可能要达到一个或几个小时,如果使用后门访问操作,则时间可能缩短为原来的1/100。
- 后门访问操作能够完成前门访问操作不能完成的事情。如在网络通信系统中计数器通常都是只读的(有一些会附加清零功能),无法对其指定一个非零的初值。大部分计数器是多个加法器的叠加,需测试它们的进位操作。本节DUT的counter使用两个叠加的16位加法器,需测试当计数到32’hFFFF时能否顺利进位为32’h1_0000,可通过延长仿真时间使其计数到32’hFFFF,这在本节DUT中是可以的,因为计数器每个时钟都加1。但在实际中可能要几万个或更多的时钟才会加1,需要大量运行时间。如果是更大位数的计数器,情况则会更坏。这种情况下后门访问操作能够完成前门访问操作完成的事情,给只读的寄存器一个初值。
当然,后门访问操作也有其劣势。如所有前门访问操作都可以在波形文件中找到总线信号变化的波形及所有操作记录。但后门访问无法在波形文件中找到操作痕迹。其操作记录只能仰仗验证平台编写者在进行后门访问操作时输出的打印信息,这样增加了调试的难度。
*使用interface进行后门访问操作
上节提到过在top_tb中使用绝对路径对寄存器进行后门访问操作,这需要更改top_tb.sv文件,但这个文件一般是固定的,不会因测试用例不同而变化,所以这种方式的可操作性不强。在driver等组件中也可使用这种绝对路径的方式进行后门访问操作,但强烈建议不要在driver等验证平台的组件中使用绝对路径。这种方式的可移植性不强。
如果想在driver或monitor中使用后门访问,一种方法是使用接口。可以新建一个后门interface:
interface backdoor_if(input clk, input rst_n);
function void poke_counter(input bit[31:0] value);
top_tb.my_dut.counter = value;
endfunction
function void peek_counter(output bit[31:0] value);
value = top_tb.my_dut.counter;
endfunction
endinterface
poke_counter为后门写,peek_counter为后门读。在测试用例中(或者drvier、scoreboard), 若要对寄存器赋初值可以直接调用此函数:
task my_case0::configure_phase(uvm_phase phase);
phase.raise_objection(this);
@(posedge vif.rst_n);
vif.poke_counter(32'hFFFD);
phase.drop_objection(this);
endtask
如果有n个寄存器,那么需要写n个poke函数;同时如果有读取要求的话,还要写n个peek函数, 这限制了其使用,且此文件完全没有任何移植性。
这种方式在实际中是有用的,它适用于不想使用寄存器模型提供的后门访问或者根本不想建立寄存器模型,同时又必须要对DUT中的一个寄存器或一块存储器(memory)进行后门访问操作的情况。
UVM中后门访问操作的实现:DPI+VPI
前面提供两种广义的后门访问方式,它们的共同点都是在SV中实现的。但在实际的验证平台中,还有在C/C++代码中对DUT中的寄存器进行读写的需求。Verilog提供VPI接口,可以将DUT的层次结构开放给外部的C/C++代码。
常用的VPI接口有如下两个:
vpi_get_value(obj, p_value);
vpi_put_value(obj, p_value, p_time, flags);
vpi_get_value用于从RTL中得到一个寄存器的值。
vpi_put_value用于将RTL中的寄存器设置为某个值。
但如果单纯使用VPI进行后门访问操作,在SV与C/C++之间传递参数时将非常麻烦。VPI是Verilog提供的接口,为了调用C/C++中的函数,提供更好的用户体验,SV提供了一种更好的接口:DPI。如果使用DPI,以读操作为例,在C/C++中定义如下函数: int uvm_hdl_read(char *path, p_vpi_vecval value); 在这个函数中通过最终调用vpi_get_value得到寄存器的值。
在SV中首先需要使用如下方式将在C/C++中定义的函数导入:import “DPI-C” context function int uvm_hdl_read(string path, output uvm_hdl_data_t value);
以后就可以在SV中像普通函数一样调用uvm_hdl_read函数了。这种方式比单纯地使用VPI的方式简练许多。它可以直接将参数传递给C/C++中的相应函数,省去了单纯使用VPI时繁杂的注册系统函数的步骤。
整个过程如下图所示:
这种DPI+VPI的方式,要操作的寄存器路径被抽像成一个字符串,而不再是一个绝对路径:uvm_hdl_read(“top_tb.my_dut.counter”, value);
与前面使用interface进行后门访问操作的代码相比,可以发现这种方式的优势:路径被抽像成字符串,从而以参数的形式传递并可以存储,为建立寄存器模型提供了可能。单纯的Verilog路径如top_tb.my_dut.counter,它不能被传递的,也无法存储。
UVM中使用DPI+VPI的方式进行后门访问操作,它大体的流程是:
- 在建立寄存器模型时将路径参数设置好。
- 进行后门访问的写操作时,寄存器模型调用uvm_hdl_deposit函数:import “DPI-C” context function int uvm_hdl_deposit(string path, uvm_hdl_data_t value); 在C/C++侧,此函数内部会调用vpi_put_value函数来对DUT中的寄存器进行写操作。
- 进行后门访问的读操作时,调用uvm_hdl_read函数,在C/C++侧,此函数内部会调用vpi_get_value函数对DUT中的寄存器进行读操作,并将读取值返回。
*UVM中后门访问操作接口
掌握UVM后门访问操作的原理后,就可以使用寄存器模型的后门访问功能。使用这个功能需要做如下准备:
在reg_block中调用uvm_reg的configure函数时,设置好第三个路径参数:
class reg_model extends uvm_reg_block;
rand reg_invert invert;
rand reg_counter_high counter_high;
rand reg_counter_l ow counter_low;
virtual function void build();
…
invert.configure(this, null, "invert");
…
counter_high.configure(this, null, "counter[31:16]");
…
counter_low.configure(this, null, "counter[15:0]");
…
endfunction
…
endclass
由于counter是32bit,占据两个地址,因此在寄存器模型中它是作为两个寄存器存在的。
当上述工作完成后,在将寄存器模型集成到验证平台时,需要设置好根路径hdl_root:
function void base_test::build_phase(uvm_phase phase);
…
rm = reg_model::type_id::create("rm", this);
rm.configure(null, "");
rm.build();
rm.lock_model();
rm.reset();
rm.set_hdl_path_root("top_tb.my_dut");
…
endfunction
UVM提供两类后门访问的函数:一是UVM_BACKDOOR形式的read和write,二是peek和poke。 这两类函数的区别是:第一类会在进行操作时模仿DUT的行为,第二类则完全不管DUT的行为。如对一个只读的寄存器进行写操作,那么第一类由于要模拟DUT的只读行为,所以是写不进去的,但是使用第二类可以写进去。
poke函数用于第二类写操作,其原型为:
task uvm_reg::poke(output uvm_status_e status,
input uvm_reg_data_t value,
input string kind = "",
input uvm_sequence_base parent = null,
input uvm_object extension = null,
input string fname = "",
input int lineno = 0);
peek函数用于第二类的读操作,其原型为:
task uvm_reg::peek(output uvm_status_e status,
output uvm_reg_data_t value,
input string kind = "",
input uvm_sequence_base parent = null,
input uvm_object extension = null,
input string fname = "",
input int lineno = 0);
两个函数常用的参数都是前两个。各自第一个参数表示操作是否成功,第二个参数表示读写的数据。
在sequence中可使用如下方式调用这两个任务:
class case0_cfg_vseq extends uvm_sequence;
…
virtual task body();
…
p_sequencer.p_rm.counter_low.poke(status, 16'hFFFD);
…
p_sequencer.p_rm.counter_low.peek(status, value);
counter[15:0] = value[15:0];
p_sequencer.p_rm.counter_high.peek(status, value);
counter[31:16] = value[15:0];
`uvm_info("case0_cfg_vseq", $sformatf("after poke,
counter's value(B ACKDOOR) is %0h", counter), UVM_LOW)
…
endtask
endclass