UVM实战笔记(七)

这篇具有很好参考价值的文章主要介绍了UVM实战笔记(七)。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

第七章. UVM中的寄存器模型

7.1 寄存器模型简介

7.1.1 带寄存器配置总线的DUT

本章节使用的DUT带寄存器配置,代码如下:

module dut(clk,
           rst_n,
           bus_cmd_valid,
           bus_op,
           bus_addr,
           bus_wr_data,
           bus_rd_data,
           rxd,
           rx_dv,
           txd,
           tx_en
           )
           
    input         clk;                //system clock
    input         rst_n;              //system reset
    input         bus_cmd_valid;      //bus command valid
    input         bus_op;             //bus operate type, 1:bus-write, 0:bus_read
    input  [15:0] bus_addr;           //bus operate address
    input  [15:0] bus_wr_data;        //bus-write data
    output [15:0] bus_rd_data;        //bus-read data
    input  [7:0]  rxd;                //input data
    input         rx_dv;              //input data valid
    output [7:0]  txd;                //output data of DUT
    output        tx_en;              //output data enable
    
    reg    [7:0]  txd;                //output data register
    reg           tx_en;              //output data enable register
    reg           invert;             //dut-control register, addr 16'h9, 1bit; 1:revert input
                                      //                                         0: not change input
    
    //invert input control
    always @(posedge clk)
    begin
        if(!rst_n)
        begin
            txd     <= 8'b0;
            tx_end  <= 1'b0;
        end
        else if(invert)
        begin
            txd     <= ~rxd;
            tx_en   <= rx_dv;
        end
        else
        begin
            txd     <= rxd;
            tx_en   <= rx_dv;
        end
    end
    
    //bus-write invert
    always @(posedge clk)
    begin
        if(!rst_n)    invert <=1'b0;
        else if(bus_cmd_valid && bus_op)
        begin
            case(bus_addr)
                16'h9:
                begin
                    invert <= bus_wr_data[0];
                end
                default:
                begin
                end
            endcase
        end 
    end
    
    //bus-read inver
    reg [15:0] bus_rd_data;
    always @(posedge clk)
    begin
        if(!rst_n)    bus_rd_data <= 16'b0;
        else if(bus_cmd_valid && !bus_op)
        begin
            case(bus_addr)
                16'h9:
                begin
                    bus_rd_data <= {15'b0, invert};
                end
                default:
                begin
                    bus_rd_data <= 16'b0;
                end
            endcase
        end
    end
endmodule

该DUT只有一个1bit的寄存器invert,为其分配地址16’h9。如果其值为1,那么DUT在输出时会将输入的数据取反;如果为0,则将输入数据直接发送出去。inver可以通过总线bus_*进行配置。bus_op为1表示写操作,为0表示读操作。bus_addr表示将地址,bus_rd_data表示读取的数据,bus_wr_data表示写入的数据。bus_cmd_valid表示总线数据有效性,只持续一个时钟,DUT在其为1期见采样总线数据。如果是读,在下一个时钟周期给出读数据,如果是写,在下一个时钟周期把数据写入。当此总线对16’h9(invert寄存器)的地址进行读写操作时,会得到结果,对其他地址进行操作不会有任何结果。该总线不支持burst(猝发)操作,不支持延时相应等。
针对此总线,有如下的transaction定义:

typedef enum{BUS_RD, BUS_WR} bus_op_e;

class bus_transaction extends uvm_sequence_item;
    rand bit[15:0] rd_data;
    rand bit[15:0] wr_data;
    rand bit[15:0] addr;
    
    rand bus_op_e  bus_op;
    ...
    
endclass

有如下driver定义:

task bus_driver::run_phase(uvm_phase phase);
    while(1)
    begin
        seq_item_port.get_next_item(seq);
        driver_one_pkt(req);
        seq_item_port.item_done();
    end
endtask

task bus_driver::drive_one_pkt(bus_transaction tr);
    `uvm_info("bus_driver", "begin to drive one pkt", UVM_LOW)
    repeat(1) @(posedge vif.clk);
    
    vif.bus_cmd_valid <= 1'b1;
    vif.bus_op        <= ((tr.bus_op == BUS_RD) 0:1);
    vif.bus_addr       = tr.addr;
    vif.bus_wr_data   <= ((tr.bus_op == BUS_RD) 0:tr.wr_data);
    
    @(posedge vif.clk);
    vif.bus_cmd_valid <=1'b0;
    vif.bus_op        <=1'b0;
    vif.bus_addr      <= 16'b0;
    vif.bus_wr_data   <= 16'b0;
    
    @(posedge vif.clk);
    if(tr.bus_op == BUS_RD)
    begin
        tr.rd_data == vif.bus_rd_data;
    end
    `uvm_info("bus_driver", "end drive one pkt", UVM_LOW)
endtask

在sequence中使用如下方式读(另类response???????):

virtual task body();
    `uvm_do_with(m_trans, {m_trans.addr   == 16'h9;
                           m_trans.bus_op == BUS_RD;
                         })
    `uvm_info("case0_bus_seq", $sformatf("inver's initial value is %0h", m_trans.rd_data), UVM_LOW)
    ...
endtask

在sequence中使用如下方式写:

virtual task body();
        `uvm_do_with(m_trans, {m_trans.addr     ==16'h9;
                               m_trans.bus_op   == BUS_WR;
                               m_trans.wr_data  ==16'h1;
                             })
endtask

验证平台示意图如下:
uvm实例,UVM实战,fpga开发,学习,硬件工程

7.1.2 需要寄存器模型才能做的事情

寄存器模型是为了方便参考模型访问寄存器内数值。在没有寄存器模型之前,只能启动sequence通过前门(FRONTDOOR)访问的方式来读取寄存器,局限较大,难以控制。在有了寄存器模型之后,scb可以直接与寄存器模型打交道,无论是发送读的指令还是获取读操作的返回值,都可以由寄存器模型完成。有了寄存器模型后,可以在任何耗费时间的phase中使用寄存器模型以前门访问或者后门访问的方式获取寄存器的值。同时还可以在某些不耗费时间的phase中使用后门访问的方式读取寄存器的值。
uvm实例,UVM实战,fpga开发,学习,硬件工程

前门访问和后门访问是两种寄存器的访问方式。

  • 前门访问:通过模拟cpu在总线上发出读指令,进行读写操作。在这个过程中,仿真时间是一直往前走的。
  • 后门访问:不通过总线进行读写操作,而是直接通过层次化引用来改变寄存器的值。
    寄存器模型的本质就是重新定义了验证平台与DUT的寄存器接口,使验证人员更好地组织及配置寄存器,简化流程,减少工作量。
7.1.3 寄存器模型中的基本概念
  • uvm_reg_field:寄存器模型中的最小单位。

  • uvm_reg:比uvm_reg_field高一个级别。一个寄存器中至少包含一个uvm_reg_field。
    uvm实例,UVM实战,fpga开发,学习,硬件工程

  • uvm_reg_block:比较大的单位,在其中可以加入很多的uvm_reg,也可以加入其他的uvm_reg_block。一个寄存器模型中至少包含一个uvm_reg_block。

  • uvm_reg_map:,每个寄存器在加入寄存器模型时都有其地址,uvm_reg_map就是存储这些地址,并将其转换成可以访问的物理地址(因为加入寄存器模型中的寄存器地址一般都是偏移地址,而不是绝对地址)。当寄存器模型使用前门访问方式来实现读或写操作时,uvm_reg_map就会将其地址转换成绝对地址,启动一个读或写的sequence,并将读或写的结构返回。在每个reg_block内部,至少有一个uvm_reg_map。

7.2 简答的寄存器模型

7.2.1 只有一个寄存器的寄存器模型

以下代码为7.1.1中DUT的寄存器invert建立寄存器模型:

class reg_invert extends uvm_reg;

    rand uvm_reg_field reg_data;

    virtual function void build();
        reg_data = uvm_reg_field::type_id::create("reg_data");
        //parameter: parent, size, lsb_pos, access, volatile, reset value, has_reset, is_rand, individually
        reg_data.configure(this, 1, 0, "RW", 1, 0, 1, 1, 0);
    endfunction
    
    `uvm_object_utils(reg_invert)

    function new(input string name="reg_invert");
        //parameter: name, size, has_coverage
        super.new(name, 16, UVM_NO_COVERAGE);
    endfunction
endclass

在new函数中,要将invert寄存器的宽度作为参数传递给super.new函数。这里的宽度并不是指这个寄存器的有效宽度,而是指这个寄存器中总共的位数。如对于一个16位寄存器,其中只使用了8位,那么new中参数应该传16。这个数字一般跟系统总线宽度保持一致。new的第三个参数表示是否支持覆盖率支持。
每一个派生自uvm_reg的类都有一个build,这个build与uvm_component的build_phase并不一样,他不会自动执行,而需要手动调用,与build_phase相似的是所有的uvm_reg_field都在这里实例化。在实例化后,要调用data.configure函数来配置这个字段。configure函数一共有9个参数,含义分别是:

  • parent:此域的父类,也即此域位于哪个寄存器中。
  • size:此域的宽度。
  • lsb_pos:此域的最低位在整个寄存器中的位置,从0开始计数。
  • access:此字段的存取方式。
  • volatile:是否易失,这个参数一般不会使用。
  • reset value:此域上电复位后的默认值。
  • has_reset:此域是否有复位,一般的寄存器或者寄存器的域都有上电复位值。
  • is_rand:这个域是否可以随机化。即这主要用于对寄存器进行随机写测试。这个参数当且仅当第四个参数为RW,WRC,WRS,WO,W1,WO1时才有效。
  • individually:表示这个域是否可以单独存取。

第四个字段access寄存器的存取方式,UVM共支持25中存取方式:

  • RO:读写此域无影响。
  • RW:会尽量写入,读取时对此域无影响。
  • RC:写入时无影响,读取时会清零。
  • RS:写入时无影响,读取时会设置所有的位。
  • WRC:尽量写入,读取时会清零。
  • WRS:尽量写入,读取时会设置所有的位。
  • WC:写入时会清零,读取时无影响。
  • WS:写入时设置所有的位,读取时无影响。
  • WSRC:写入时会设置所有的位,读取时会清零。
  • WCRS:写入时会清零,读取时会设置所有的位。
  • W1C:写1清零,写0时无影响,读取时无影响。
  • W1S:写1设置所有的位,写0时无影响,读取时无影响。
  • W1T:写1时会翻转,写0时无影响,读取时无影响。
  • W0C:写0清零,写1时无影响,读取无影响。
  • W0S:写0设置所有的位,写1时无影响,读取时无影响。
  • W0T:写0时会翻转,写1时无影响,读取无影响。
  • W1SRC:写1设置所有的位,写0时无影响,读清零。
  • W1CRS:写1清零,写0时无影响,读设置所有位。
  • W0SRC:写0设置所有的位,写1无影响,读清零。
  • W0CRS:写0清零,写1时无影响,读设置素有位。
  • WO:尽可能写入,读取时会出错。
  • WOC:写入时清零,读取时出错。
  • WOS:写入时设置所有位,读取时会出错。
  • W1:复位(reset)后,第一次会尽量写入,其他写入无影响,读取时无影响。
  • WO1:复位后,第一次会尽量写入,其他写入无影响,读取时会出错。

定义好此寄存器后,需要在一个由reg_block派生的类中将其实例化:

class reg_model extends uvm_reg_block;
    rand reg_invert invert;

    virtual function void build();
        default_map = create_map("default_map", 0, 2, UVM_BIG_ENDIAN, 0);

        invert = reg_invert::type_id::create("invert", , get_full_name());
        invert.configure(this, null, "");
        invert.build();
        default_map.add_reg(invert, 'h9, "RW");
    endfunction

    `uvm_object_utils(reg_model)

    function new(input string name="reg_model");
        super.new(name, UVM_NO_COVERAGE);
    endfunction

endclass

同uvm_reg派生的类一样,每一个由uvm_reg_block派生的类也要定义一个build函数,一般在此函数中实现所有寄存器的实例化。
一个uvm_reg_block中一定要对应一个uvm_reg_map,系统已经有一个声明好的default_map,只需要在build中将其实例化。default_map实例化不是调用new函数,而是调用uvm_block的creat_map函数。create函数的参数含义为:第一个参数是名字,第二个参数为基地址,第三个参数是系统总线的位宽(单位为byte),第四个参数是大小端,最后一个参数表示是否能够按照byte寻址。
随后实例化invert并调用invert.configure函数。此函数的主要功能是指定寄存器进行后门访问操作时的路径。第一个参数是此寄存器所在的uvm_reg_block的指针,第二个参数是reg_file的指针(后文讨论),第三个参数是此寄存器的后门访问路径(后文讨论)。当调用完configure时,需手动调用invert的build函数,将invert中的域实例化。
最后一步是将此寄存器加入到default_map中。uvm_reg_map的作用是存储所有寄存器的地址,因此必须将实例化的寄存器加入default_map中,否则无法进行前门访问。add_reg中第一个参数是要加入的寄存器,第二个参数是寄存器的地址,第三个参数是此寄存器的存取方式。

7.2.2 将寄存器模型集成到验证平台中uvm实例,UVM实战,fpga开发,学习,硬件工程

寄存器模型的前门访问操作分为读和写,流程图如上所示。寄存器模型读/写都会通过sequence产生一个uvm_reg_bus_op的变量,此变量中存储这操作类型和操作的地址。如果是写操作,还会有要写入的数据。此变量要经过一个转换器(adapter)转换后交给bus_sequencer,随后交给bus_driver,由bus_driver实现最终的前门访问读写操作。
因此必须定义一个转换器uvm_reg_adapter:

class my_adapter extends uvm_reg_adapter;
    string tID = get_type_name();
    
    `uvm_object_utils(my_adapter)
    
    function new(string name="my_adapter");
        super.new(name);
    endfunction
    
    function uvm_sequence_item reg2bus(const ref uvm_reg_bus_op rw);
        bus_transaction tr;
        tr = new("tr");
        tr.addr = rw.addr;
        tr.bus_op = (rw.kind == UVM_READ) BUS_RD : BUS_WR;
        if(tr.bus_op == BUS_WR)
            * tr.wr_data = rw.data;
        return tr;
    endfunction
    
    function void bus2reg(uvm_sequence_item bus_item, ref uvm_reg_bus_op rw);
        bus_transaction tr;
        if(!$cast(tr, bus_item))
        begin
            `uvm_fatal(tID, "Provided bus_item is not of the correct type. Expecting bus_trans action")
            return;
        end
        rw.kind = (tr.bus_op == BUS_RD)     UVM_READ : UVM_WRITE;
        rw.addr = tr.addr;
        rw.byte_en = 'h3;
        rw.data = (tr.bus_op == BUS_RD)     tr.rd_data : tr.wr_data;
        rw.status = UVM_IS_OK;
    endfunction
endclass

一个转换器要定义好两个函数:

  • reg2bus:将寄存器模型通过sequence发出的uvm_reg_bus_op型的变量转换成bus_sequencer能够接受的形式
  • bus2reg:当监测到总线上有操作时,将收集来的transaction转换成寄存器模型能够接受的形式,以便寄存器模型能够更新相应的寄存器的值

在定义好adapter类后,就可以在base_test中加入寄存器模型:

class base_test extends uvm_test;
    my_env        env;
    my_vsqr       v_sqr;
    reg_model     rm;
    my_adapter    reg_sqr_adapter;
    
    ...
endclass

function void base_test::build_phase(uvm_phase phase);
    super.build_phase(phase);
    env     = my_env::type_id::create("env", this);
    v_sqr   = my_vsqr::type_id::create("env", this);
    rm      = reg_model::type_id::create("rm", this);
    rm.configure(null, "");
    rm.build();
    rm.lock_model();
    rm.reset();
    reg_sqr_adapter = new("reg_sqr_adapter");
    env.p_rm = this.rm;
endfunction

function void base_test::connect_phase(uvm_phase phase);
    super.connect_phase(phase);
    v_sqr.p_my_sqr    = env.i_agt.sqr;
    v_sqr.p_bus_sqr   = env.bus_agt.sqr;
    v_sqr.p_rm        this.rm;
    rm.default_map.set_sequencer(env.bus_agt.sqr, reg_sqr_adapter);
    rm.default_map.set_auto_predict(1);
endfunction

要将一个寄存器模型集成到base_test中,至少需要在base_test中定义两个成员变量,一个是reg_model,另外一个就是reg_sqr_adapter。将所有用到的类在build_phase中实例化。
在实例化后reg_model还要做四件事:

  • 调用configure函数,第一个参数是父类block,第二个参数是后门访问路径。
  • 调用build函数,将包含的所有寄存器实例化;
  • 调用lock_model函数,调用此函数后,reg_model中就不能再加入新的寄存器了;
  • 调用reset函数,如果不调用此函数,那么reg_model中所有的寄存器的值都是0,调用此函数后,所有寄存器的值都将变成设置的复位值。
    寄存器模型的前门访问操作最终都将由uvm_reg_map完成,因此在connect_phase中,需要将转换器和bus_sequencer通过set_sequencer函数告知reg_model的default_map,并将default_map设置为自动预测状态
7.2.3 在验证平台中使用寄存器模型

当一个寄存器模型被建立好以后,可以在sequence和其他component中使用。如在参考模型中使用,需要在参考模型中有一个寄存器模型的指针:

class my_model extends uvm_component;
    reg_model p_rm;                     //在base_test中已被赋值,指向寄存器模型的实例
endclass

对于寄存器,寄存器模型提供了两个基本的任务:read/write。

task my_model::main_phase(uvm_phase phase);
    my_transaction tr;
    my_transaction new_tr;
    uvm_status_e status;
    uvm_reg_data_t value;
    super.main_phase(phase);
    p_rm.inver.read(status, value, UVM_FRONTDOOR);
    
    while(1)
    begin
        port.get(tr);
        new_tr = new("new_tr");
        new_tr.copy(tr);
        if(value)        invert_tr(new_tr);
        ap.write(new_tr);
    end
endtask

//寄存器模型的read函数原型
extern virtual task read(output    uvm_status_e        status,
                         output    uvm_reg_data_t      value,
                         input     uvm_path_e          path = UVM_DEFAULT_PATH,
                         input     uvm_reg_map         map  = null,
                         input     uvm_sequence_base   parent = null,
                         input     int                 prior = -1,
                         input     uvm_project         extension = null,
                         input     string              fname = "",
                         input     int                 lineno = 0);

常用的参数是前三个,第一个参数用来表明读操作是否成功。第二个参数是读取的数值。第三个是读取的方式,可选UVM_FRONTDOOR和UVM_BACKDOOR。
由于参考模型一般不会写寄存器,对于write任务,在virtual sequence进行写操作说明。在sequence中使用寄存器模型,通常通过p_sequencer的形式进行引用。首先在sequencer中声明一个寄存器模型的指针。

class case0_cfg_vseq extends uvm_sequence;
    virtual task body();
        uvm_status_e status;
        uvm_reg_data_t value;
        p_sequencer.p_rm.invert.write(status, 1, UVM_FRONTDOOR);
    endtask
endclass

//write任务的原型为:
extern virtual task write(output     uvm_status_e        status;
                          input      uvm_reg_data_t      value,
                          input      uvm_path_e          path = "UVM_DEFAULT_PATH",
                          input      uvm_reg_map         map = null,
                          input      uvm_sequence_base   parent = null,
                          input      int                 prior = -1,
                          input      uvm_object          extension = null,
                          input      string              fname = "",
                          input      int                 lineno = 0);

常用的参数有前三个,第一个表示写操作是否成功,第二个是要写入的数据,第三个写操作方式,可选UVM_FRONTDOOR和UVM_BACKDOOR。寄存器模型对sequence的transaction类型没有任何要求,因此,可以在一个发送my_transaction的sequence中使用寄存器模型对寄存器进行读写操作。

7.3 后门访问与前门访问

7.3.1 UVM中前门访问的实现

所谓前门访问操作就是通过寄存器配置总线(如APB,OCP,I2C协议等)来对DUT进行操作。无论在任何总线协议中,前门访问操作只有两种,读和写。对一块实际焊接在电路板上正常工作的芯片来说,此时若要访问其中的某些寄存器,前门访问操作是唯一的方法。
在参考模型中使用寄存器模型,最大的问题是如何在其中启动一个sequence,可以使用全局变量和config_db两种方式。除了这种方式,如果能够在参考模型中得到一个sequencer的指针(在参考模型中设置一个p_sqr的变量,并在env中将sequencer的指针赋值给此变量即可),也可以在此sequencer上启动一个sequence。然后就是写一个读写的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_LOW)
        `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(reg_seq.rdata)
对于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中的读数据返回参考模型。
    在sequencer的应答章节中提到过,如果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。

7.3.2 后门访问操作的定义

为了讲述后门访问操作,引入一个新的DUT,这个DUT中加入了寄存器counter,他的功能就是统计rx_dv为高电平的时钟数

//带计数器的DUT
//adder: add augend and addend to result
module cadder(
              input    [15:0]    augend,
              input    [15:0]    addend,
              output   [15:0]    result);
         
        assign result = {1'b0, augend} + {1'b0, addend};     
endmodule

module dut(
           clk,
           rst_n,
           bus_cmd_valid,
           bus_op,
           bus_addr,
           bus_wr_data,
           bus_rd_data,
           rxd,
           rx_dv,
           txd,
           tx_en);
           
        input    clk;
        input    rst_n;
        input    bus_cmd_valid;
        input    bus_op;
        input    [15:0]    bus_addr;
        input    [15:0]    bus_wr_data;
        output   [15:0]    bus_rd_data;
        input    [7:0]     rxd;
        input              rx_dv;
        output   [7:0]     txd;
        output             tx_en;
        
        reg      [7:0]     txd;
        reg      tx_en;
        reg      invert;
        
        always@(posedge clk)
        begin
            if(!rst_n)
            begin
                txd <= 8'b0;
                tx_en <= 1'b0;
            end
            else if(invert)
            begin
                txd <= ~rxd;
                tx_en <= rx_dv;
            end
            else
            begin
                txd <= rxd;
                tx_en <= rx_dv;
            end
        end
        
    reg     [31:0]    counter;
    wire    [16:0]    counter_low_result;
    wire    [16:0]    counter_high_result;
    
    cadder    low_adder(
                        .augend(counter[15:0],
                        .addend(16'h1),
                        .result(counter_low_result));
                        
    cadder    high_adder(
                         .augend(counter[31:16]),
                         .addend(16'h1),
                         .result(counter_high_result));
                         
    always@(posedge clk)
    begin
        if(!rsn_n)        counter[15:0] <= 16'h0;
        //if rx_dv is 1, counter[15:0] add 1
        else if(rx_dv)    counter[15:0] <= counter_low_result[15:0];
    end
    
    always@(posedge clk)
    begin
        if(!rst_n)                         counter[31:16] <= 16'h0;
        //if conunter_low_result[16] is 1(data full), add 1 to counter_high_result
        else if(counter_low_result[16])    counter[31:16] <= counter_high_result[15:0];
    end
    
    always@(posedge clk)
    begin
        if(!rst_n)            invert <= 1'b0;
        else if(bus_cmd_valid && bus_op)
        begin
            case(bus_addr)
                16'h5:
                begin
                    if(bus_wr_data[0] == 1'b1)    counter <= 32'h0;
                end
                16'h6:
                begin
                    if(bus_wr_data[0] == 1'b1)    counter <= 32'h0;
                end
                16'h9:
                begin
                    invert <= bus_wr_data[0];
                end
                default:
                begin
                end
            endcase
       end
    end
    
    reg    [15:0]    bus_rd_data;
    
always@(posedge clk)
    begin
        if(!rst_n)    bus_rd_data <= 16'b0;
        else if(bus_cmd_valid && !bus_op)
        begin
            case(bus_addr)
                16'h5:    bus_rd_data <= counter[31:16];
                16'h6:    bus_rd_data <= counter[15:0];
                16'h9:    bus_rd_data <= {15'b0, invert};
                default:  bus_rd_data <= 16'b0;
            endcase
        end
    end
endmodule

在通信系统中,有大量计数器用于统计各种包的数量,如超长包,长包,中包,短包,超短包等。这些计数器共同的特点是只读的,DUT的总线接口无法通过前门访问操作对其进行写操作。除了是只读外,这些寄存器的位宽一般都比较宽,如32bit,48bit和64bit等,它们的位宽超过了设计中对加法器宽度的上限限制。计数器在计数过程中需要使用加法器,对于加法器来说,同等工艺下,位宽越宽时序越差,因此在设计中一般会规定加法器的最大位宽。上述的DUT中,加法器的位宽被限定为16bit,要实现32位的counter的加法操作,需要使用两个叠加的16bit加法器。
为counter分配16’h5和16’h6的地址,采用大端格式将高位数据存放在低地址。此计数器是可读的,可以对其进行写1请0操作。如果对其写入其他数值,则不会起作用
后门访问:相对于前门操作,从广义上来讲,所有不通过DUT的总线而对DUT内部的寄存器或者存储器进行存取的操作都是后后门访问操作。所有的后门操作都是不消耗仿真时间而只消耗运行时间,这是后门访问操作的最大优势。
那么为什么要是用后门访问操作呢?后门访问操作的意义在于:

  • 后门访问操作能更好地完成前门访问操作所做的事情。后门访问不消耗仿真时间,与前门访问操作相比,它消耗的运行时间要远小于前门访问操作的运行时间。在一个大型的芯片的验证中,在其正常工作前需要配置众多的寄存器,配置时间可能要高达一个或几个小时,而如果使用后门访问惭怍,则时间可能缩短为原来的1/100。
  • 后门访问操作能完成前门访问操作不能完成的事情。如在网络通信系统中,计数器通常都是只读的,无法对其指定一个非零的初值。而大部分计数器都是多个加法器的叠加,需要测试他的进位操作。如本节的DUT加法器操作,如果要测其进位,要通过延长仿真时间使其达到进位的场景,需要大量的运行时间。而通过后门访问,可以给只读寄存器一个初值
  • 与前门操作相比,所有的后门访问操作无法在波形文件中找到操作痕迹,这增加了调试的难度。
7.3.3 使用interface进行后门访问操作

在top_tb中使用绝对路径对寄存器进行后门访问操作,这就需要更改top_tb.sv文件,一般top_tb.sv是不会改变的,不会因为测试用例的不同而改变。在driver等组件中也可以使用绝对路径的方式进行后门访问操作,但不建议在driver等验证平台的组件中使用绝对路径,这种操作方式的可移植性不强。
一种可行的方式是,如果在driver或monitor中要使用后门访问,使用接口进行后门访问。

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为后门读。 在测试用例中,如果要对寄存器赋初值可以直接调用此函数:

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)进行后门访问操作的情况

7.3.4 UVM中后门访问操作的实现:DPI+VPI

前面两个章节介绍了两种广义的后门访问方式,他们的共同特点是 都是在SV中实现的。但是实际的验证平台中,还有C/C++代码对DUT中的寄存器进行读写的需求。V提供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++之间传递参数时非常麻烦,为了提升用户体验,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时繁杂的注册系统函数的步骤。
uvm实例,UVM实战,fpga开发,学习,硬件工程

在这种DPI+VPI的方式中,要操作的寄存器的路径被抽象成了一个字符串,而不再是一个绝对路径:

uvm_hdl_read("top_tb.my_dut.counter", value);

路径被抽象成了一个字符串,从而可以以参数的形式传递,并可以存储,这为建立寄存器模型提供了可能。一个单纯的V路径,如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中的寄存器进行读操作,并将读取值返回。
7.3.5 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_low  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);
    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和peek函数的原型为:

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);
                    
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_sequence.p_rm.counter_high.peek(status, value);
        counter[31:16] = value]15:0];
    endtask
endclass

7.4 复杂的寄存器模型

7.4.1 层次化的寄存器模型

上述的寄存器模型是最简单的寄存器模型,只在uvm_reg_block中加入了一个寄存器,在现实的应用中,一般会将uvm_reg_block中再加入一个uvm_reg_block,然后在base_test中实例化后者。从逻辑关系上看,呈现出的是两级的寄存器模型。
uvm实例,UVM实战,fpga开发,学习,硬件工程

通常只会在低一级的寄存器(reg_model)中加入uvm_reg_block,而第二级的寄存器只添加uvm_reg,这样从整体上呈现出一个比较清晰的结构。
如一个DUT分成了三个子模块就,用于控制全局的globe模块,用于缓存数据的buf模块,用于接收发以太网帧的mac模块。global模块的寄存器地址为0x00000x0FFF,buf部分的寄存器地址为0x10000x1FFF,mac部分的寄存器地址为0x2000~0x2FFF,那么可以按照如下方式定义寄存器模型:

class global_blk extends uvm_reg_block;
    ...
endclass

class buf_blk extends uvm_reg_block;
    ...
endclass

class mac_blk extends uvm_reg_block;
    ...
endclass

class reg_model extends uvm_reg_block;
    
    rand global_blk  gb_ins;
    rand buf_blk     bb_ins;
    rand mac_blk     mb_ins;
    
    virtual function void build();
        default_map = create_map("default_map", 0, 2, UVM_BIG_ENDIAN, 0);
        gb_ins      = global_blk::type_id::create("gb_ins");
        gb_ins.configure(this, "");
        gb_ins.build();
        gb_ins.lock_model();
        default_map.add_submap(gb_ins.default_map, 16'h0);
        
        bb_ins = buf_blk::type_id::create("bb_ins");
        bb_ins.configure(this, "");
        bb_ins.build();
        bb_ins.lock_model();
        default_map.add_submap(bb_ins.default_map, 16'h1000);
        
        ...
    endfunction
endclass

要讲一个子reg_block加入父reg_block中,第一步是先实例化子reg_block。第二步是调用子reg_block的configure函数。如果要是使用后门访问,则要在这个函数中说明子reg_block的路径,这个路径不是绝对路径,而是相对于父reg_block来说的路径。第三步是调用reg_block的build函数。第四步是调用子reg_block的lock_model函数。第五步是将子reg_block的default_map以子map的形式加入父reg_block的default_map中。寄存器的前门访问的读写操作最终都要通过default_map来完成。一般将具有同一基地址的寄存器作为整体加入一个uvm_reg_block中,而不同的基地址对应不同的uvm_reg_block。每个uvm_reg_block一般都有与其对应的物理地址空间。

7.4.2 reg_file的作用

uvm_reg_file的引入主要是用于区分不同的hdl路径。
假设有两个寄存器regA和regB,他们的hdl路径分别为top_tb.mac_reg.fileA.regA和top_tb.mac_reg.fileB.regB,设top_tb.mac_reg下面的所有寄存器的基地址为0x2000,这样在最顶层的reg_block中加入mac模块时,其hdl路径要写成:

mb_ins.configure(this, "mac_reg");

相应的,在mac_blk的build中,通过如下方式将regA和regB的路径告知寄存器模型:

regA.configure(this,null, "fileA.regA");
regB.configure(this, null, "fileB.regB");

当个寄存器中的数目较多时,fileA.*会几十次地出现在这几十个寄存器的configure函数里。假如fileA的名字需要变成filea_inst,那么就需要把所以寄存器的fileA替换成file_inst,这个过程繁琐且易出错。
为了适应这种情况,UVM的寄存器模型引入了uvm_reg_file的概念。uvm_reg_file是一个纯虚类(抽象类),不能直接使用,二必须使用其派生类:

class regfile extends uvm_reg_file;
    function new(string name = "regfile");
        super.new(name);
    endfunction
    
    `uvm_object_utils(regfile)
endclass

class mac_blk extends uvm_reg_block;
    rand regfile file_a;
    rand regfile file_b;
    rand reg_regA regA;
    rand reg_regB regB;
    rand reg_vlan vlan;
    
    virtual function void build();
        default_map = create_map("default_map", 0, 2, UVM_BIG_ENDIAN, 0);
        file_a = regfile::type_id::create("file_a", , get_full_name());
        file_a.configure(this, null, "fileA");
        file_b = regfile::type_id::create("file_b", , get_full_name());
        file_b.configure(this, null, "fileB");
        regA.configure(this, file_a, "regA");
        regB.configure(this, file_b, "regB");
    endfunction
endclass

如上,先从uvm_reg_file派生一个类,然后在my_blk中实例化此类,之后调用其configure函数,此函数的第一个参数是其所在的reg_block的指针,第二个参数是假设此reg_file是另外一个reg_file的父文件,那么这里就应该写其父reg_file的指针。由于这里只有一级reg_file,所以填null。第三个参数则是此reg_file的hdl路径。当把reg_file定义好后,在调用寄存器的configure参数时,就可以将其第二个参数设置为reg_file的指针。加入了reg_file的概念后,当fileA变成了filea_inst时,只需要将file_a的configure参数值改变即可,其他则不需要做任何改变。

7.4.3 多个域的寄存器

上述的寄存器都是只有一个域,如果一个寄存器存在多个域时,那么在建立寄存器模型时需要做调整。设某个寄存器有三个域,其中最低两位为fieldA,接着三位为fieldB,接着四位为fieldC,其余位未使用。这个寄存器从逻辑上来看是一个寄存器,但是从物理上来看,即他的DUT实现中是三个寄存器,因此这个寄存器实际上对应着三个不同hdl路径:filedA,fieldB,fieldC。

class three_field_reg extends uvm_reg;
    rand uvm_reg_field fieldA;
    rand uvm_reg_field fieldB;
    rand uvm_reg_field fieldC;
    
    virtual function void build();
        fieldA = uvm_reg_field::type_id::create("fieldA");
        fieldB = uvm_reg_field::type_id::create("fieldB");
        fieldC = uvm_reg_field::type_id::create("fieldC");
    endfunction
endclass

class mac_blk extends uvm_reg_block;
    rand three_field_reg tf_reg;
    virtual function void build();
        tf_reg = three_field_reg::type_id::create("tf_reg", , get_full_name());
        tf_reg.configure(this, null, "");
        tf_reg.build();
        tf_reg.filedA.configure(tf_reg, 2, 0, "RW", 1, 0, 1, 1, 1);
        tf_reg.add_hdl_path_slice("fieldA", 0, 2);
        tf_reg.filedB.configure(tf_reg, 3, 2, "RW", 1, 0, 1, 1, 1);
        tf_reg.add_hdl_path_slice("fieldA", 2, 3);
        tf_reg.filedA.configure(tf_reg, 4, 5, "RW", 1, 0, 1, 1, 1);
        tf_reg.add_hdl_path_slice("fieldA", 5, 4);
    endfunction 
endclass

调用完fieldA的configure函数后,需要将fieldA的hdl路径加入tf_reg中,此时用到的函数是add_hdl_path_slice。这个函数的第一个参数是要加入的路径,第二个参数是此路径对应的域在此寄存器的起始位数,如fieldA是从0开始的,而fieldB是从2开始的,第三个参数则是此路景观对应的域的位宽。

7.4.4 多个地址的寄存器

实际的DUT中,有些寄存器会同时占据多个地址。如conunter是32bit,而系统的数据位宽是16bit,所以就占据了两个地址。
UVM提供了一种方式,可以使一个寄存器占据多个地址:

class reg_counter extends uvm_reg;
    rand uvm_reg_field reg_data;
    virtual function void build();
    
    reg_data = uvm_reg_field::type_id::create("reg_data");
    // parameter: parent, size, lsb_pos, access, volatile, reset value, has_reset, is_rand, individually
    reg_data.configure(this, 32, 0, "W1C", 1, 0, 1, 1, 0);
    endfunction

    `uvm_object_utils(reg_counter)
    function new(input string name="reg_counter");
    //parameter: name, size, has_coverage
    super.new(name, 32, UVM_NO_COVERAGE);
    endfunction
    endclass

class reg_model extends uvm_reg_block;
    rand reg_invert invert;
    rand reg_counter counter;
    virtual function void build();
    …
    counter= reg_counter::type_id::create("counter", , get_full_name());
    counter.configure(this, null, "counter");
    counter.build();
    default_map.add_reg(counter, 'h5, "RW");
    endfunction
endclass

此方法先定义了一个reg_counter,并在构造函数中指明寄存器的大小为32位,此寄存器只有一个域,此域的宽度也为32位,之后在reg_model将其实例化。在调用default_map的add_reg函数时,要指定寄存器的地址,这里只需要指明一个最小的地址即可,因为在前面的实例化default_map时,已经指明它使用UMV_LITTLE_ENDIAN形式,同时总线的宽度为2byte,UVM会自动根据这些信息计算出此寄存器占据两个地址。当使用前门访问的形式读写此寄存器时,寄存器模型会进行两次读写操作,即发出两个transaction,这两个transaction对应的读写操作的地址从0x05一直递增到0x06
将counter作为一个整体时,可以一次性地访问它:

class case0_cfg_vseq extends uvm_sequence;
    …
    virtual task body();
        …
        p_sequencer.p_rm.counter.read(status, value, UVM_FRONTDOOR);
        `uvm_info("case0_cfg_vseq", $sformatf("counter's initial value(FRONT DOOR) is %0h", value), UVM_40 p_sequencer.p_rm.counter.poke(status, 32'h1FFFD);
        p_sequencer.p_rm.counter.read(status, value, UVM_FRONTDOOR);
        `uvm_info("case0_cfg_vseq", $sformatf("after poke, counter's value(F RONTDOOR) is %0h", value), 43 p_sequencer.p_rm.counter.peek(status, value);
        `uvm_info("case0_cfg_vseq", $sformatf("after poke, counter's value(B ACKDOOR) is %0h", value), …
    endtask
endclass
7.4.5 加入存储器

除了寄存器外,DUT还存在大量的存储器,这些存储器有些被分配了地址空间,有些没有。验证人员有时需要在仿真过程中得到存放这些存储器中数据的值,从而与期望的值比较并给出结果。
如,一个DUT的功能是接收一种数据,它经过一些相当复杂的处理(操作A)后将数据存储在存储器中,这块存储器是DUT内部的存储器,并没有为其分配地址。当存储器中的数量达到一定量时,将它们读出,并再另外做一些复杂处理(如封装成另外一种形式的帧,操作B)后发送出去。在验证平台中如果只是将DUT输出接口的数据与期望值相比较,当数据不匹配情况出现时,则无法确定问题是出在操作A还是操作B中,如下图,此时如果在输出接口之前再增加一级比较,就可以快速地定位问题所在了。
uvm实例,UVM实战,fpga开发,学习,硬件工程

以下代码在一个16位系统中加入一块1024x16的存储器。

class my_memory extends uvm_mem;
    function new(string name="my_memory");
        super.new(name, 1024, 16);
    endfunction
    `uvm_object_utils(my_memory)
endclass

class reg_model extends uvm_reg_block;
    …
    rand my_memory mm;

    virtual function void build();
        …
        mm = my_memory::type_id::create("mm", , get_full_name());
        mm.configure(this, "stat_blk.ram1024x16_inst.array");
        default_map.add_mem(mm, 'h100);
    endfunction
…
endclass

首先由uvm_mem派生一个类my_memory,在其new函数中调用super.new函数。这个函数有三个参数,第一个是名字,第二个是存储器的深度,第三个是宽度。在reg_model的build_phase函数中将存储器实例化,调用其configure函数,第一个参数是所在reg_block的指针,第二个参数是此块存储器的hdl路径。最后调用default_map.add_mem函数,将此块存储器加入default_map中,从而可以对其进行前门访问操作。如果没有对此块存储器分配地址空间,那么这里可以不加入default_map中。这种情况下,只能使用后门访问的方式对其进行访问。
对存储器进行读写,可以通过调用read,write,peek,poke实现。相对于uvm_reg来说,这四个任务/函数在调用时需要额外加入一个offset参数,说明读取此存储器的哪个地址。

task uvm_mem::read(output uvm_status_e status,
                   input uvm_reg_addr_t offset,
                   output uvm_reg_data_t value,
                   input uvm_path_e path = UVM_DEFAULT_PATH,
…);

task uvm_mem::write(output uvm_status_e status,
                    input uvm_reg_addr_t offset,
                    input uvm_reg_data_t value,
                    input uvm_path_e path = UVM_DEFAULT_PATH,
…);

task uvm_mem::peek(output uvm_status_e status,
                    input uvm_reg_addr_t offset,
                    output uvm_reg_data_t value,
…);

task uvm_mem::poke(output uvm_status_e status,
                    input uvm_reg_addr_t offset,
                    input uvm_reg_data_t value,
…);

上面存储器的宽度与总线的宽度相同。如果存储器的宽度大于总线位宽时,情况会略有不同。如下代码是在一个16位系统中加入512x32的存储器:

class my_memory extends uvm_mem;
    function new(string name="my_memory");
        super.new(name, 512, 32);
    endfunction
`uvm_object_utils(my_memory)
endclass

在派生my_memory时,就要在其new函数汇总指明宽度为32bit,在my_block中加入此memory的方法与前面的相同。很明显,这里加入的存储器的一个单元占据两个物理地址,共占1024个地址。那么当使用read,write,peek,poke时,输入的参数offset代表实际的存储单元的偏移。当指定一个offset,使用前门访问操作读写时,由于一个offset对应的是两个物理地址,所以寄存器模型会在总线上进行两次读写操作

7.5 寄存器模型对DUT的模拟

7.5.1 期望值与镜像值

由于DUT中寄存器的值可能是实时变更的,寄存器模型并不能实时地知道这种变更,因此,寄存器模型中的寄存器的值有时与DUT中相关寄存器的值并不一致。对于任意一个寄存器,寄存器模型中都会有一个专门的变量用于最大可能地与DUT保持同步,这个变量在寄存器模型中称为DUT的镜像值(mirrored value)。
除了DUT的镜像值外,寄存器模型中海油期望值(desired value)。如目前DAUAT中invert的值为;'h0,寄存器模型中的镜像值也为’h0,但是希望向此寄存器中写入一个’h1,此时一种方法是直接调用前面介绍的write任务,将’h1写入,期望值和镜像值都更新为’h1。另外一种方法是通过set函数将期望值设置为’h1(此时镜像值依然是0),之后调用update任务,update任务会检查期望值和镜像值是否一致,如果不一致,那么将会把期望值写入DUT中,并且更新镜像值。

class case0_cfg_vseq extends uvm_sequence;
    …
    virtual task body();
        …
        p_sequencer.p_rm.invert.set(16'h1);
        value = p_sequencer.p_rm.invert.get();
        `uvm_info("case0_cfg_vseq", $sformatf("invert's desired value is %0h ", value), UVM_LOW)
        value = p_sequencer.p_rm.invert.get_mirrored_value();
        `uvm_info("case0_cfg_vseq", $sformatf("invert's mirrored value is %0h ", value), UVM_LOW)
        p_sequencer.p_rm.invert.update(status, UVM_FRONTDOOR);
        value = p_sequencer.p_rm.invert.get();
        `uvm_info("case0_cfg_vseq", $sformatf("invert's desired value is %0h ", value), UVM_LOW)
        value = p_sequencer.p_rm.invert.get_mirrored_value();
        `uvm_info("case0_cfg_vseq", $sformatf("invert's mirrored value is %0h ", value), UVM_LOW)
        p_sequencer.p_rm.invert.peek(status, value);
        `uvm_info("case0_cfg_vseq", $sformatf("invert's actual value is %0h", value), UVM_LOW)
        if(starting_phase != null)
            starting_phase.drop_objection(this);
    endtask

通过get函数可以得到寄存器的期望值,通过get_mirrored_value可以得到镜像值
对于存储器来说,并不存在期望值和镜像值。寄存器模型不对存储器进行任何模拟

7.5.2 常用操作及其对期望值和镜像值的影响

read&write:无论通过前门还是后门访问的方式从DUT中读取或写入寄存器的值,在操作完成后,寄存器模型都会根据读写的结果更新期望值和镜像值。
peek&poke:操作完成后,寄存器模型会根据操作的记过更新期望值和镜像值。
get&set:set操作会更新期望值,但是镜像值不会变。get操作会返回寄存器模型的期望值。
update操作:检查寄存器模型期望值与镜像值是否一致,如不一致,那么将期望值写入DUT中,并更新镜像值使其与期望值一致。
randomize:期望值将会变成随机数值,镜像值不会改变。一般randomize不会单独使用而是和update一起。

7.6 寄存器模型中一些内建的sequence

7.6.1 检查后门访问中hdl路径的sequence

UVM提供了一系列的sequence,可以用于检查寄存器模型及DUT中的寄存器。其中uvm_reg_mem_hdl_paths_seq用于检查hdl路径的正确性。sequence原型为:

class uvm_reg_mem_hdl_paths_seq extends uvm_reg_sequence #(uvm_sequence #(uvm_reg_item))

此sequence的运行依赖于在基类uvm_sequence中定义的一个变量:

uvm_reg_block model

在启动此sequence时必须给model赋值,在任意的sequence中,可以启动此sequence。

class case0_cfg_vseq extends uvm_sequence;
    virtual task body();
        uvm_reg_mem_hdl_paths_seq ckseq;
        ckseq = new("ckseq");
        ckseq.model = p_sequencer.p_rm;
        ckseq.start(null);
    endtask
endclass

在调用这个sequence的start任务时,传入的sequencer参数为null。因为它正常工作不依赖于这个sequencer,而依赖于model变量。这个sequence会试图读取hdl所指向的寄存器,如果无法读取,则给出错误提示。
该sequence除了寄存器外,还检查存储器。如果某个寄存器/存储器在加入寄存器模型时没有指定其hdl路径,那么此sequence在检查时会跳过这个寄存器/存储器。

7.6.2 检查默认值的sequence

uvm_reg_hw_reset_seq用于检查上电复位后寄存器模型与DUT中寄存器的默认值是否相同,他的原型为:

class uvm_reg_hw_reset_seq extends uvm_reg_sequence #(uvm_sequence #(uvm_reg_item));

对于DUT来说,在复位完成后,其值就是默认值。但是对于寄存器模型来说,如果只是将它集成在验证平台上,而不做任何处理,那么它所有寄存器的值为0,此时需要调用reset函数来使寄存器的值变为默认值(复位值);

function void base_test::build_phase(uvm_phase phase);
    rm = reg_model::type_id::create("rm", this);
    rm.reset();
endfunction

这个sequence在其检查前会调用model的reset函数,所以即使在集成到验证平台时没有调用reset函数,这个sequence也能正常工作。除了复位(reset)外,这个sequence所做的事情就是使用前门访问的方式读取所有寄存器的值,并将其与寄存器模型中的值比较。这个sequence在启动时也需要指定其model变量。
如果想调过某个寄存器的检查,可以在启动sequence前使用resource_db设置不检查此寄存器。resource_db和config_db机制的底层实现是一样的,uvm_config_db类就是从uvm_resource_db类派生而来的。

function void my_case0::build_phase(uvm_phase phase);
    uvm_resource_db#(bit)::set({"REG::",rm.invert.get_full_name(),".*"}, "NO_REG_TESTS", 1, this);
endfunction

或者使用:

function void my_case0::build_phase(uvm_phase phase);
    uvm_resource_db#(bit)::set({"REG::",rm.invert.get_full_name(),".*"},"NO_REG_HW_RESET_TEST", 1, this);
endfunction
7.6.3 检查读写功能的sequence

UVM提供了两个sequence分别用于检查寄存器和存储器的读写功能。原型为:

class uvm_reg_access_seq extends uvm_reg_sequence #(uvm_sequence #(uvm_reg_item))

使用此sequence也需要指定其model变量。这个sequence会使用前门访问的方式向所有寄出器写数据,然后使用后门访问的方式读回,并比较结构。最后把这个过程反过来,使用后门访问的方式写入数据,在用前门访问读回。此sequence要正常工作必须为所有的寄存器设置好hdl路径。
如果要调过某个寄存器的读写检查,则可以在启动sequence前使用如下的两种方式之一进行设置:

function void my_case0::build_phase(uvm_phase phase);
    //set for reg access sequence
    uvm_resource_db#(bit)::set({"REG::",rm.invert.get_full_name(),".*"},"NO_REG_TESTS", 1, this);
    uvm_resource_db#(bit)::set({"REG::",rm.invert.get_full_name(),".*"},"NO_REG_ACCESS_TEST", 1, this);
endfunction

uvm_mem_access_seq用于检查存储器的读写。原型为:

class uvm_mem_access_seq extends uvm_reg_sequence #(uvm_sequence #(uvm_reg_it em)

启动此sequence同样需要指定其model变量。这个sequence会使用前门访问的方式对所有存储器写数据,然后使用后门访问的方式读回,并比较结果。最后把这个过程反过来,后门写,前门读。这个sequence要正常工作必须为所有的存储器设置好HDL路径。
如果要调过某个存储器的检查,则可以使用如下三种方式之一进行设置:

function void my_case0::build_phase(uvm_phase phase);
    //set for mem access sequence
    uvm_resource_db#(bit)::set({"REG::",rm.get_full_name(),".*"},"NO_REG_TESTS", 1, this);
    uvm_resource_db#(bit)::set({"REG::",rm.get_full_name(),".*"},"NO_MEM_TESTS", 1, this);
    uvm_resource_db#(bit)::set({"REG::",rm.invert.get_full_name(),".*"},"NO_MEM_ACCESS_TEST", 1, this);
endfunction

7.7 寄存器模型的高级用法

7.7.1 使用reg_predictor

如下图,读操作返回时,driver将取到的值返回,寄存器模型会更新寄存器的镜像值和期望值。这个功能被称为寄存器模型的auto predict功能。在建立寄存器模型时需要使用如下语句打开此功能:

rm.default_map.set_auto_predict(1);

除了左图使用driver的返回值更新寄存器模型外,还存在另外一种形式,如右图。在这种形式中,是由monitor将从总线上收集到的transaction交给寄存器模型,后者更新相应寄存器的值。
要是用这种方式更新数据,需要实例化一个reg_predictor,并为这个reg_predictor实例化一个adapter:
uvm实例,UVM实战,fpga开发,学习,硬件工程

class base_test extends uvm_test;
    reg_model rm;
    my_adapter reg_sqr_adapter;
    my_adapter mon_reg_adapter;
    uvm_reg_predictor#(bus_transaction) reg_predictor;
endclass
    
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();
    reg_sqr_adapter = new("reg_sqr_adapter");
    mon_reg_adapter = new("mon_reg_adapter");
    reg_predictor = new("reg_predictor", this);
    env.p_rm = this.rm;
endfunction

function void base_test::connect_phase(uvm_phase phase);
    rm.default_map.set_sequencer(env.bus_agt.sqr, reg_sqr_adapter);
    rm.default_map.set_auto_predict(1);
    reg_predictor.map = rm.default_map;
    reg_predictor.adapter = mon_reg_adapter;
    env.bus_agt.ap.connect(reg_predictor.bus_in);
endfunction

在connect_phase中,需要将reg_predictor和bus_agt的ap口连接在一起,并设置reg_predictor的adapter和map。只有设置了map后,才能将predictor和寄存器模型关联在一起。
当总线上只有一个主设备是,上图左图和右图是完全等价的。如果有多个主设备,则左图会漏掉某些transaction。
事实上存在两条更新寄存器模型的路径,一个是上图右图中虚线所示的自动预测路径,而是经由predictor的途径,如果要彻底关掉虚线的更新路径,则需要:

rm.drfault_map.set_auto_predict(0);
7.7.2 使用UVM_PREDICT_DIRECT功能与mirror操作

UVM提供mirror操作,用于读取DUT中寄存器的值并将它们更新到寄存器模型中。原型为:

task uvm_reg::mirror(output uvm_status_e status,
                     input uvm_check_e check = UVM_NO_CHECK,
                     input uvm_path_e path = UVM_DEFAULT_PATH,
                     ...);

他有多个参数,但是常用的只有前三个。其中第二个参数指的是如果发现DUT中寄存器的值与寄存器模型中的镜像值不一致,呢么在更新寄存器模型之前是否给出错误提示。其可选值为UVM_CHECK和UVM_NO_CHECK。
此选项有两种应用场景,一是在仿真中不断地调用它,使得整个寄存器模型的值与DUT中寄存器的值保持一致,此时check是关闭的。二是在仿真即将结束时,检查DUT中寄存器的值与寄存器模型中寄存器的镜像值是否一致,这种情况下,check选项是打开的。
mirror操作会更新期望值和镜像值。同update操作类似,mirror操作既可以在uvm_reg级别被调用,也可以在uvm_reg_block级别被调用。当调用一个uvm_reg_block的mirror时,其是指是调用加入其中的所有寄存器的mirror。
如何人为的更新镜像值,但是同时又不要对DUT进行操作,UVM提供predict操作来实现此功能。

function bit uvm_reg::predict (uvm_reg_data_t value,
                               uvm_reg_byte_en_t be = -1,
                               uvm_predict_e kind = UVM_PREDICT_DIRECT,
                               uvm_path_e path = UVM_FRONTDOOR,
                               ...);

其中第一个参数表示要预测的值,第二个参数是byte_en,默认-1的意思是全部有效。第三个参数是预测的类型,第四个参数是后门访问或者是前门访问。第三个参数预测类型有如下几种可以选择:

typedef enum {  UVM_PREDICT_DIRECT,
                UVM_PREDICT_READ,
                UVM_PREDICT_WRITE } uvm_predict_e;

read/peek和write/poke操作在对DUT完成读写后,也会调用此函数,知识他们给出的参数是UVM_PREDICT_READ和UVM_PREDICT_WRITE。要实现在参考模型中更新寄存器而又不影响DUT的值,需要使用UVM_PREDICT_DIRECT,即默认值。

task my_model::main_phase(uvm_phase phase);
…
    p_rm.invert.read(status, value, UVM_FRONTDOOR);
    while(1)
    begin
        port.get(tr);
        ...
        if(value)
            invert_tr(new_tr);
        counter = p_rm.counter.get();
        length = new_tr.pload.size() + 18;
        counter = counter + length;
        p_rm.counter.predict(counter);
        ap.write(new_tr);
    end
endtask

在my_model中,每得到一个新的transaction,就先从寄存器模型中得到的counter的期望值(此时与镜像值一致),之后将新的transaction的长度加到counter中,最后使用predict函数将新的counter值更新到寄存器模型中。predict操作会更新镜像值和期望值。
在测试用例中,仿真完成后可以检查DUT中counter的值是否与寄存器模型中的counter值一致:

class case0_vseq extends uvm_sequence;
…
    virtual task body();
        …
        dseq = case0_sequence::type_id::create("dseq");
        dseq.start(p_sequencer.p_my_sqr);
        #100000;
        p_sequencer.p_rm.counter.mirror(status, UVM_CHECK, UVM_FRONTDOOR);
        …
     endtask
endclass
7.7.3 寄存器模型的随机化与update

前文中向uvm_reg中加入uvm_reg_field时,是将加入的uvm_reg_field定义为rand类型:

class reg_invert extends uvm_reg;
    rand uvm_reg_field reg_data;
    …    
endclass

在将uvm_reg加入uvm_reg_block中时,同样定义为rand类型:

class reg_model extends uvm_reg_block;
    rand reg_invert invert;
endclass

可以在uvm_reg_block级别调用randomize函数,也可以在uvm_reg级别,甚至可以在uvm_reg_field级别调用:

assert(rm.randomize());
assert(rm.invert.randomize());
assert(rm.invert.reg_data.randomize());

但是,要使某个field能够随机化,只是将其定义为rand类型是不够的,在每个reg_field加入uvm_reg时,要调用其configure函数:

// parameter: parent, size, lsb_pos, access, volatile, reset value, has_reset, is_rand, individually accessible
reg_data.configure(this, 1, 0, "RW", 1, 0, 1, 1, 0);

这个函数的第八个参数即决定此field是否会在randomize时被随机化。但是即使此参数为1,也不一定能够保证此field被随机化。当一个field的类型中没有写操作时,此参数设置是无效的。换而言之,此参数只在此field类型为RW,WRC,WRS,WO,W1,WO1时才有效。
因此,要避免一个field被随机化,可以在以下三种方式任选其一:
当在uvm_reg中定义此field时,不要设置为rand类型。
在调用此field的configure函数时,第八个参数设置为0。
设置此field的类型为RO,RC,RS,WC,WS,W1C,W1S,W1T,W0C,W0S,W0T,W1CRS,W0SRC,W0CRS,WSRC,WCRS,WOC,WOS中的一种。
可以定义field的constraint:

class reg_invert extends uvm_reg;
    rand uvm_reg_field reg_data;
    constraint cons{reg_data.value == 0;}
endclass

在施加约束时,要深入reg_field的value变量。randomize会更新寄存器模型中的预期值,函数原型为:

function void uvm_reg_field::post_randomize();
    m_desired = value;
endfunction: post_randomize

可以在randomize完成后调用update任务,将随机化后的参数更新后DUT中,这特别适用于在仿真开始时随机化并配置参数。

7.7.4 扩展位宽

在uvm_reg类的build_phase中调用super.new时,第二个参数是16,这个数字一般表示系统总线的宽度,可以是32,64,128等。但是在寄存器模型中,这个数字的默认值是64,它是通过一个宏来控制的:

`ifndef UVM_REG_DATA_WIDTH
    `define UVM_REG_DATA_WIDTH 64
`endif

如果想要扩展系统总线的位宽,可以通过重新定义这个宏来扩展。与数据位宽相似的是地址位宽也有默认最大值限制,其默认值也是64:

`ifndef UVM_REG_ADDR_WIDTH
    `define UVM_REG_ADDR_WIDTH 64
`endif

默认情况下,字选择信号的位宽等于数据位宽除以8,它通过如下宏来控制:

`ifndef UVM_REG_BYTENABLE_WIDTH
    `define UVM_REG_BYTENABLE_WIDTH ((`UVM_REG_DATA_WIDTH-1)/8+1)
`endif

如果想要使用一个其他值,也可以重新定义这个宏。

7.8 寄存器模型的其他常用函数

7.8.1 get_root_blocks

在本章以前的例子中,如果某处要使用寄存器模型,则必须将寄存器模型的指针传递过去,如在virtual sequence中使用,需要传递给virtual sequencer:

function void base_test::connect_phase(uvm_phase phase);
    v_sqr.p_rm = this.rm;
endfunction

除了这种指针传递的形式外,UVM还提供其他函数,使得可以在不使用指针传递的情况下得到寄存器模型的指针:

function void uvm_reg_block::get_root_blocks(ref uvm_reg_block blks[$]);

get_root_blocks函数得到验证平台上所有的根块(root block)。根块指最顶层的reg_block。
一个使用get_root_block函数的使用示例如下:

class case0_cfg_vseq extends uvm_sequence;
…
    virtual task body();
        uvm_status_e status;
        uvm_reg_data_t value;
        bit[31:0] counter;
        uvm_reg_block blks[$];
        reg_model p_rm;
        …
        uvm_reg_block::get_root_blocks(blks);
        if(blks.size() == 0)
            `uvm_fatal("case0_cfg_vseq", "can't find root blocks")
        else begin
            if(!$cast(p_rm, blks[0]))
                `uvm_fatal("case0_cfg_vseq", "can't cast to reg_model")
        end
        p_rm.invert.read(status, value, UVM_FRONTDOOR);
        …
    endtask
endclass

在使用get_root_blocks函数得到reg_block的指针后,要是用cast将其转化为目标reg_block形式,以后就可以直接使用p_rm来进行寄存器操作,而不必使用p_sequencer.p_rm。

7.8.2 get_reg_by_offset函数

在建立了寄存器模型后,可以直接通过层次引用的方式访问检测器:

rm.invert.read(...);

如果依然要是用地址来访问检测器模型,那么此时可以使用get_reg_by_offset函数通过寄存器的地址得到一个uvm_reg指针,在调用此uvm_reg的read或者write就可以进行读写操作:

virtual task read_reg(input bit[15:0] addr, output bit[15:0] value);
    uvm_status_e status;
    uvm_reg target;
    uvm_reg_data_t data;
    uvm_reg_addr_t addrs[];
    target = p_sequencer.p_rm.default_map.get_reg_by_offset(addr);
    if(target == null)
        `uvm_error("case0_cfg_vseq", $sformatf("can't find reg in register model with address: 'h%36 target.read(status, data, UVM_FRONTDOOR);
    void'(target.get_addresses(null,addrs));
    if(addrs.size() == 1)
        value = data[15:0];
    else
    begin
        int index;
        for(int i = 0; i < addrs.size(); i++)
        begin
            if(addrs[i] == addr)
            begin
                data = data >> (16*(addrs.size() - i));
                value = data[15:0];
                break;
            end
        end
    end
endtask

通过调用最顶层的reg_block的get_reg_by_offset,既可以得到任一寄存器的指针。从最顶层的reg_block的get_reg_by_offset也可以得到子reg_block的寄存器。如buf_blk的地址偏移是’h1000,其中有偏移为’h3的寄存器(即此寄存器的实例物理地址是’h1003),那么可以直接由p_rm.get_reg_by_offset('h1003)得到此寄存器,而不必使用p_rm.buf_blk.get_reg_by_offset('h3)。
如果没有多地址寄存器,上述代码会运行addr.size()==1分支。当存在多个地址的情况,通过get_addresses函数可以得到这个函数的所有地址,其返回值是一个动态数组addrs。其中无论是大端还是小端,addrs[0]是LSB对应的地址。对于大端,addrs[0]中存放的是’h6,如果是小端,addrs[0]对应的是’h1005。

参考资料:
UVM实战(卷Ⅰ)张强 编著 机械工业出版社文章来源地址https://www.toymoban.com/news/detail-768784.html

到了这里,关于UVM实战笔记(七)的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 基于UVM+VCS基本平台,运行《UVM实战》中的第一个UVM代码并输出结果

    (1)下载puvm.tar.gz和uvm-1.1d.tar.gz压缩包 ,使用分别使用tar zxvf puvm.tar.gz和 tar zxvf uvm-1.1d.tar.gz解压到当前目录下 (2)找到puvm-src-ch2-dut-dut.sv文件,将该文件拷贝到puvm-src-ch2-section2.2-2.2.1目录下 (3)打开puvm-src-ch2-section2.2-2.2.1-Makefile.vcs文件并按照下面的方法进行编辑,编辑完后

    2024年02月09日
    浏览(42)
  • UVM实战_5_UVM中的寄存器模型

    提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档 本章节主要介绍UVM实战第7章寄存器模型的内容 通常来说,DUT中会有一组 控制端口 ,通过控制端口,可以配置DUT中的寄存器,DUT可以根据寄存器的值来改变行为,这组控制端口就是寄存器配置总线。 暂

    2024年01月23日
    浏览(55)
  • UVM实战--加法器

    这里以UVM实战(张强)第二章为基础修改原有的DUT,将DUT修改为加法器,从而修改代码以使得更加深入的了解各个组件的类型和使用。 和第二章的平台的主要区别点 (1)有两个transaction,一个为transaction_i,一个为transaction_o,由于DUT的输入输出值并不相同,输入为a,b,cin,输

    2024年02月06日
    浏览(42)
  • UVM学习——搭建简单的UVM平台

      本专栏的博客均与 UVM 的学习相关,学习参考:  【1】UVM Tutorial  【2】张强著,UVM实战 (卷 Ⅰ)  【3】Download UVM (Standard Universal Verification Methodology) 本专栏的学习基本依照 资料【2】的主线,以【1】【3】资料作为参考。特别是【3】是官方的UVM手册,具有很高的可参考性

    2024年02月16日
    浏览(35)
  • 【从零开始学习 UVM】6.4、UVM 激励产生 —— uvm_do 宏详解

    请注意, start 方法的 call_pre_post 字段设置为0, 这意味着在使用这些序列宏时,序列的pre_body和post_body方法将永远不会被调用 。否则,执行流程与通过start方法执行序列时类似。 使用序列宏的优点是可以使用内联约束,但是您失去了控制执行sequence中 pre_body 和 post_body 方法调

    2023年04月08日
    浏览(71)
  • 【从零开始学习 UVM】9.2、UVM Config DB —— UVM config database 详解【重要】

    UVM有一个内部数据库表,可以将值存储在给定名称下,并且稍后可以由其他TestBench组件检索。 uvm_config_db 类提供了一个方便的接口,位于 uvm_resource_db 之上,以简化用于uvm_component实例的基本接口。 请注意,所有函数都是静态的,并且必须使用 :: 作用域运算符调用 。 这样的配

    2023年04月09日
    浏览(76)
  • 【从零开始学习 UVM】8.2、Reporting Infrastructure —— uvm_printer 详解

    在一个随机验证环境中,数据对象不断地由不同的组件生成和操作, 如果能够显示对象的内容,则调试会变得更加容易 。 传统上,这是通过将值打印到日志文件或屏幕上的 $display 语句和自定义打印函数来完成的。

    2023年04月09日
    浏览(36)
  • linux VCS+verdi运行UVM实战(第二章)中的例子

    目录 前言 介绍 建立工程 运行代码 查看波形 总结 前言 用VCS+verdi运行了下UVM实战中的例子(第二章)。 在某宝上花了几十块,买了个虚拟机(已经安装好VCS+verdi)。直接用UVM实战中,现成的uvm代码跑了下。 UVM实战源码下载地址:UVM实战源码下载 书中DUT的功能:通过rxd接收

    2023年04月08日
    浏览(43)
  • 【UVM】-- UVM测试平台搭建与调试

    《UVM》实战中这个DUT的功能比较简单: 在clk的上升沿,且rst不为低电平的时候,将输入的信号直接发送出去,并且输出输入的使能信号; 在clk上升沿,如果rst为低电平,复位输出信号和使能信号 clk 时钟输入信号 rst_n 复位输入信号  rxd 8bit输入数据信号 rx_dv 输入使能信号

    2024年02月15日
    浏览(34)
  • UVM重点归纳(一)

    1.1 利用工厂机制的一般实现步骤: 1.继承 范式: class comp_type/obj_type extends uvm_component/uvm_object; 实例: class comp1/obj1 extends uvm_component/uvm_object; 2.注册 范式: ​​​​​​​ `uvm_component/object_utils(comp_type/obj_type); 实例: `uvm_component/object_untils(comp1/obj1); 3.new函数声明 范式: func

    2024年02月14日
    浏览(35)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包