UVM 高阶用法

第 1 章 · 共 6 章

认识 AXI4-Lite 与 axil_regfile

UVM 高阶用法系列的第一章:认识新的 DUT——AXI4-Lite 协议的五个通道与 VALID/READY 握手规则、一个带中断输出的简化寄存器外设,以及一个完整的手动驱动 testbench,证明这一切都能跑通。

systemverilog-basicsuvm 两个系列加起来一共 26 章,全部用的是同一个 mux2——单一组合逻辑输出,只有一个接口驱动、一个接口检查。这种简单是故意的:让这两个系列能专注在语法和 UVM 机制本身,不被复杂的 DUT 分散注意力。但 uvm 系列第 10 章结尾恰恰点名了 mux2 撑不起来的东西——协调不止一个 agent,以及一个真正能拿来搭寄存器模型的寄存器映射。这个系列需要一个新的 DUT,而且是刻意换的。

一页看懂 AXI4-Lite

AXI4-Lite 是一个真实、广泛使用的协议(属于 ARM 的 AMBA AXI 规范的一部分),专门给简单的、寄存器风格的外设用——小到真的能在一页里学完,这一点跟它的"完整版"AXI4 不一样。下面的内容都是直接取自 spec 原文(AMBA AXI and ACE Protocol Specification,ARM IHI 0022D,第 B1 章),不是凭记忆写的。

五个相互独立的通道,每个都有自己的 VALID/READY 握手:

通道方向内容
写地址(AW)master → slave写操作的地址 + 保护位
写数据(W)master → slave写操作的数据 + 字节使能
写响应(B)slave → master这次写成功了吗?
读地址(AR)master → slave读操作的地址 + 保护位
读数据(R)slave → master读到的数据 + 成功了吗?

握手规则,spec 原文的精确表述:当 VALID 和 READY 在同一个时钟边沿都为高时,传输就发生了。拉高 VALID 的一方不能等 READY 先拉高——它必须愿意按自己的节奏保持信息稳定并拉高 VALID。拉高 READY 的一方则可以等到看见 VALID 才拉高 READY(也可以提前拉高,在 VALID 出现之前)。正是这种不对称,让这个协议不会死锁——如果双方都可以互相等对方,那谁都不会先动。

没有 burst。 和完整版 AXI4 不同,AXI4-Lite 的每次事务都恰好一拍——没有 AWLEN/ARLEN(burst 长度),没有 AWBURST/ARBURST(burst 类型)。这正是 AXI4-Lite"轻量"的主要原因:不需要搭建也不需要验证一整套 burst 状态机。

AW 和 W 相互独立。 spec 的写依赖规则说 slave "可以在拉高 AWREADY 之前等待 AWVALID 或 WVALID,或两者都等",同样"可以在拉高 WREADY 之前等待 AWVALID 或 WVALID,或两者都等"——也就是说 master 可以任意顺序发送地址和数据,甚至同时发,slave 必须能接住先到的那一个,同时等另一个。slave 必须等 AW 和 W 两者都完成,才能拉高 BVALID。这是一段比 mux2 曾经要求过的任何时序都更难的真实握手时序。

响应码:每次事务都会带回一个 2 位的响应码——OKAY(0)、SLVERR(2,地址是真的但这次访问失败,比如写一个只读寄存器)、或 DECERR(3,压根没有这个地址)。AXI4-Lite 去掉了 AXI4 的第四种响应 EXOKAY,因为它不支持独占访问(exclusive access)。

认识 axil_regfile

一个小型 AXI4-Lite 外设,32 位数据宽度,字地址对齐,映射了 4 个寄存器,外加一个任何 AXI 通道都不会传的输出信号:irq

地址名字读写行为
0x00CTRLRWbit0 ENABLE;bit1 IRQ_CLR(写 1 清零 COUNT 并拉低 irq——这一位从不被存储,只是一个一次性脉冲)
0x04STATUSRObit0 镜像当前的 irq 输出
0x08DATARW写入时(若 ENABLE=1)让内部计数器 +1;读取返回上次写入的值
0x0CCOUNTRO内部计数器;达到一个固定阈值时拉高 irq

其余地址一律返回 DECERR——这是一个诚实、真正做了地址译码的实现,不是占位符。写 STATUSCOUNT(映射了但只读)返回 SLVERR——地址存在,只是这次访问不被支持。

有一个细节值得明确说一下,因为 DUT 的实现依赖它:CTRL 的写不是读-改-写。 不管写进来的 32 位是什么,都会同时成为 ENABLE 和(瞬时的)IRQ_CLR 的新值——没有任何隐藏逻辑会在一次只想脉冲 IRQ_CLR 的写操作中保留住 ENABLE。要既清中断又保持计数使能,软件必须一次性把两个 bit 都写上(0x3,不是 0x2)。真实的内存映射寄存器就是这么工作的;这正是 mux2axil_regfile 之间"现在真得看文档了"的差距,而且是故意留的。

计数器的阈值是一个 SystemVerilog parameter,不是第五个寄存器——很多真实外设的阈值都是构建时就定死的,这样设计也让寄存器数量保持在少数几个。

DUT 源码

下面的 interface、DUT 和 testbench 打算放进同一个文件里——文件最开头的 `timescale 是编译器指令(SV 第 3 章),会作用于它之后编译的每一个 module,所以哪怕后面接着三个模块,也只需要声明这一次:

`timescale 1ns/1ps
 
module axil_regfile #(
  parameter int IRQ_THRESHOLD = 4
) (
  input  logic        s_axi_aclk,
  input  logic        s_axi_aresetn,
 
  input  logic [7:0]  s_axi_awaddr,
  input  logic [2:0]  s_axi_awprot,
  input  logic        s_axi_awvalid,
  output logic        s_axi_awready,
 
  input  logic [31:0] s_axi_wdata,
  input  logic [3:0]  s_axi_wstrb,
  input  logic        s_axi_wvalid,
  output logic        s_axi_wready,
 
  output logic [1:0]  s_axi_bresp,
  output logic        s_axi_bvalid,
  input  logic        s_axi_bready,
 
  input  logic [7:0]  s_axi_araddr,
  input  logic [2:0]  s_axi_arprot,
  input  logic        s_axi_arvalid,
  output logic        s_axi_arready,
 
  output logic [31:0] s_axi_rdata,
  output logic [1:0]  s_axi_rresp,
  output logic        s_axi_rvalid,
  input  logic        s_axi_rready,
 
  output logic         irq
);
 
  localparam logic [7:0] ADDR_CTRL   = 8'h00;
  localparam logic [7:0] ADDR_STATUS = 8'h04;
  localparam logic [7:0] ADDR_DATA   = 8'h08;
  localparam logic [7:0] ADDR_COUNT  = 8'h0C;
 
  localparam logic [1:0] RESP_OKAY   = 2'b00;
  localparam logic [1:0] RESP_SLVERR = 2'b10;
  localparam logic [1:0] RESP_DECERR = 2'b11;
 
  logic        ctrl_enable;
  logic [31:0] data_reg;
  logic [31:0] count_reg;
 
  assign irq = (count_reg >= IRQ_THRESHOLD);
 
  // ---- Write channel: AW and W are latched independently, since the
  // spec permits either to arrive first, then combined into one register
  // write once both have arrived.
  logic        aw_have, w_have;
  logic [7:0]  aw_addr_q;
  logic [31:0] w_data_q;
 
  logic aw_fire, w_fire, write_fire;
  assign aw_fire = s_axi_awvalid && s_axi_awready;
  assign w_fire  = s_axi_wvalid  && s_axi_wready;
  assign write_fire = (aw_have || aw_fire) && (w_have || w_fire) && !s_axi_bvalid;
 
  // Single-outstanding-transaction slave: don't accept a new AW/W until
  // the current one's response has been consumed (s_axi_bvalid stays high
  // until BREADY). The spec explicitly permits this ("a slave can
  // restrict [outstanding transactions] by the appropriate use of the
  // handshake signals"). Deliberately depends only on registered state
  // (aw_have/w_have/bvalid), never on write_fire itself -- gating on
  // write_fire here would make awready depend combinationally on awvalid
  // through write_fire/aw_fire, exactly the kind of input-to-output
  // combinational path the spec (A3.2) says an AXI interface must not have.
  assign s_axi_awready = !aw_have && !s_axi_bvalid;
  assign s_axi_wready  = !w_have  && !s_axi_bvalid;
 
  logic [7:0]  aw_addr_eff;
  logic [31:0] w_data_eff;
  assign aw_addr_eff = aw_have ? aw_addr_q : s_axi_awaddr;
  assign w_data_eff  = w_have  ? w_data_q  : s_axi_wdata;
 
  always_ff @(posedge s_axi_aclk or negedge s_axi_aresetn) begin
    if (!s_axi_aresetn) begin
      aw_have      <= 1'b0;
      w_have       <= 1'b0;
      s_axi_bvalid <= 1'b0;
      s_axi_bresp  <= RESP_OKAY;
      ctrl_enable  <= 1'b0;
      data_reg     <= '0;
      count_reg    <= '0;
    end else begin
      if (aw_fire && !write_fire) begin
        aw_addr_q <= s_axi_awaddr;
        aw_have   <= 1'b1;
      end
      if (w_fire && !write_fire) begin
        w_data_q <= s_axi_wdata;
        w_have   <= 1'b1;
      end
 
      if (write_fire) begin
        aw_have      <= 1'b0;
        w_have       <= 1'b0;
        s_axi_bvalid <= 1'b1;
        unique case (aw_addr_eff)
          ADDR_CTRL: begin
            ctrl_enable <= w_data_eff[0];
            if (w_data_eff[1]) count_reg <= '0;
            s_axi_bresp <= RESP_OKAY;
          end
          ADDR_DATA: begin
            data_reg <= w_data_eff;
            if (ctrl_enable) count_reg <= count_reg + 1;
            s_axi_bresp <= RESP_OKAY;
          end
          ADDR_STATUS, ADDR_COUNT: s_axi_bresp <= RESP_SLVERR;
          default:                 s_axi_bresp <= RESP_DECERR;
        endcase
      end else if (s_axi_bvalid && s_axi_bready) begin
        s_axi_bvalid <= 1'b0;
      end
    end
  end
 
  // ---- Read channel: a single address channel, so no AW/W-style
  // independence problem -- one cycle of latency from AR to RVALID.
  assign s_axi_arready = !s_axi_rvalid;
 
  always_ff @(posedge s_axi_aclk or negedge s_axi_aresetn) begin
    if (!s_axi_aresetn) begin
      s_axi_rvalid <= 1'b0;
      s_axi_rresp  <= RESP_OKAY;
      s_axi_rdata  <= '0;
    end else begin
      if (s_axi_arvalid && s_axi_arready) begin
        s_axi_rvalid <= 1'b1;
        unique case (s_axi_araddr)
          ADDR_CTRL:   begin s_axi_rdata <= {31'b0, ctrl_enable}; s_axi_rresp <= RESP_OKAY; end
          ADDR_STATUS: begin s_axi_rdata <= {31'b0, irq};         s_axi_rresp <= RESP_OKAY; end
          ADDR_DATA:   begin s_axi_rdata <= data_reg;             s_axi_rresp <= RESP_OKAY; end
          ADDR_COUNT:  begin s_axi_rdata <= count_reg;            s_axi_rresp <= RESP_OKAY; end
          default:     begin s_axi_rdata <= '0;                   s_axi_rresp <= RESP_DECERR; end
        endcase
      end else if (s_axi_rvalid && s_axi_rready) begin
        s_axi_rvalid <= 1'b0;
      end
    end
  end
 
endmodule

有两个地方值得直接指出来:write_fire 只有在 (aw_have || aw_fire) (w_have || w_fire) 同时为真时才会拉高——这正是 spec 里 AW/W 独立性规则的具体实现。而且每一个响应码(RESP_OKAY/RESP_SLVERR/RESP_DECERR)都是通过真正的地址比较决定的,不是写死的——DECERR 真的意味着"这个地址上什么寄存器都没有"。

这个设计真实踩过的一个坑,值得了解一下:早先版本的 s_axi_awready/s_axi_wready 还额外加了 !write_fire 这个条件,意思是"当前这次写一旦触发,就不接受新的 AW/W"。听起来很合理——但 write_fire 依赖 aw_fire,而 aw_fire 又依赖 s_axi_awready 本身。真正跑一遍仿真(而不只是读代码)立刻就暴露了问题:这是一个真正的组合环——awready → write_fire → awready——根本没有稳定解,正是 spec(A3.2)里明确写的"输入和输出信号之间不能有组合路径"这条规则要防止的情况。上面这版只依赖寄存器状态(aw_have/w_have/s_axi_bvalid),结果发现这已经足够防止重叠了。这个教训能推广到这一个信号之外:一个"感觉上"应该对"刚刚是不是发生了一次传输"做出反应的握手 ready 信号,天然就是容易不小心闭合出组合环的地方——因为"刚刚是不是发生了传输"这件事,往往正是从这同一个 ready 信号算出来的。

axi4lite_if:延续 clocking block 的写法

SV 第 13 章搭过一个 clocking block(simple_bus_if)来无竞险地驱动一个时序接口——default input #1step output #1,通过 cb.<信号> <= ... 驱动、通过 cb.<信号> 采样。AXI4-Lite 只是把同一套写法套在更多信号上:

interface axi4lite_if (input logic aclk);
  logic        aresetn;
 
  logic [7:0]  awaddr;
  logic [2:0]  awprot;
  logic        awvalid;
  logic        awready;
 
  logic [31:0] wdata;
  logic [3:0]  wstrb;
  logic        wvalid;
  logic        wready;
 
  logic [1:0]  bresp;
  logic        bvalid;
  logic        bready;
 
  logic [7:0]  araddr;
  logic [2:0]  arprot;
  logic        arvalid;
  logic        arready;
 
  logic [31:0] rdata;
  logic [1:0]  rresp;
  logic        rvalid;
  logic        rready;
 
  clocking cb @(posedge aclk);
    default input #1step output #1;
    output awaddr, awprot, awvalid, wdata, wstrb, wvalid, bready,
           araddr, arprot, arvalid, rready;
    input  awready, wready, bresp, bvalid, arready, rdata, rresp, rvalid;
  endclocking
 
  modport dut_mp (
    input  aclk, aresetn, awaddr, awprot, awvalid, wdata, wstrb, wvalid,
           bready, araddr, arprot, arvalid, rready,
    output awready, wready, bresp, bvalid, arready, rdata, rresp, rvalid
  );
 
  modport tb_mp (clocking cb);
endinterface

aclkaresetn 都放在 clocking block 外面,原因和第 13 章的 clk 一样:它们不是需要通过带偏移的采样去读写的信号,而是由 testbench 直接生成的。dut_mp 在实例化时接到 DUT 上,和 simple_bus_ifdut_mp做法一样;tb_mp 是一个最小视图——只包含 clocking block——以后 driver 类会通过 virtual axi4lite_if.tb_mp 句柄拿到它,和 uvm 第 3、5 章里 mux2_if 用过的交接方式完全一样。

一个完整、可运行的例子

这一章还完全没有用到 UVM 类库——纯 SystemVerilog,所以不需要支持 UVM 的仿真器。但用到了 axi4lite_if 的 clocking block,这就排除了 Icarus Verilog——实测确认过,Icarus 压根没实现 clocking block,和 SV 第 13 章已经提到的限制是同一个。在 EDA Playground 上换一个商业级仿真器:Aldec Riviera-PRO 在那上面免费可用,不需要你自己的授权,而且完整支持 SystemVerilog 的全部特性。把上面的 interface 和 DUT 跟下面这个 testbench 放进同一个文件里运行:

module tb;
  logic aclk;
  logic irq;
 
  axi4lite_if axi_if (.aclk(aclk));
 
  axil_regfile #(.IRQ_THRESHOLD(4)) dut (
    .s_axi_aclk    (axi_if.aclk),
    .s_axi_aresetn (axi_if.aresetn),
    .s_axi_awaddr  (axi_if.awaddr),
    .s_axi_awprot  (axi_if.awprot),
    .s_axi_awvalid (axi_if.awvalid),
    .s_axi_awready (axi_if.awready),
    .s_axi_wdata   (axi_if.wdata),
    .s_axi_wstrb   (axi_if.wstrb),
    .s_axi_wvalid  (axi_if.wvalid),
    .s_axi_wready  (axi_if.wready),
    .s_axi_bresp   (axi_if.bresp),
    .s_axi_bvalid  (axi_if.bvalid),
    .s_axi_bready  (axi_if.bready),
    .s_axi_araddr  (axi_if.araddr),
    .s_axi_arprot  (axi_if.arprot),
    .s_axi_arvalid (axi_if.arvalid),
    .s_axi_arready (axi_if.arready),
    .s_axi_rdata   (axi_if.rdata),
    .s_axi_rresp   (axi_if.rresp),
    .s_axi_rvalid  (axi_if.rvalid),
    .s_axi_rready  (axi_if.rready),
    .irq           (irq)
  );
 
  initial aclk = 1'b0;
  always #5 aclk = ~aclk;
 
  initial begin
    axi_if.aresetn = 1'b0;
    axi_if.awvalid = 1'b0;
    axi_if.wvalid  = 1'b0;
    axi_if.bready  = 1'b1;
    axi_if.arvalid = 1'b0;
    axi_if.rready  = 1'b1;
    repeat (3) @(posedge aclk);
    axi_if.aresetn = 1'b1;
  end
 
  task automatic axi_write(input logic [7:0] addr, input logic [31:0] data);
    axi_if.cb.awaddr  <= addr;
    axi_if.cb.awvalid <= 1'b1;
    axi_if.cb.wdata   <= data;
    axi_if.cb.wstrb   <= 4'hF;
    axi_if.cb.wvalid  <= 1'b1;
    @(axi_if.cb);
    while (!(axi_if.cb.awready && axi_if.cb.wready)) @(axi_if.cb);
    axi_if.cb.awvalid <= 1'b0;
    axi_if.cb.wvalid  <= 1'b0;
    while (!axi_if.cb.bvalid) @(axi_if.cb);
    $display("t=%0t WRITE addr=0x%0h data=0x%0h bresp=%0d", $time, addr, data, axi_if.cb.bresp);
  endtask
 
  task automatic axi_read(input logic [7:0] addr);
    axi_if.cb.araddr  <= addr;
    axi_if.cb.arvalid <= 1'b1;
    @(axi_if.cb);
    while (!axi_if.cb.arready) @(axi_if.cb);
    axi_if.cb.arvalid <= 1'b0;
    while (!axi_if.cb.rvalid) @(axi_if.cb);
    $display("t=%0t READ  addr=0x%0h data=0x%0h rresp=%0d", $time, addr, axi_if.cb.rdata, axi_if.cb.rresp);
  endtask
 
  initial begin
    $dumpfile("waves.vcd");
    $dumpvars(0, tb);
 
    wait (axi_if.aresetn === 1'b1);
    @(axi_if.cb);
 
    axi_write(8'h00, 32'h1);   // CTRL: ENABLE=1
    axi_write(8'h08, 32'hAA);  // DATA write #1 -> COUNT=1
    axi_write(8'h08, 32'hBB);  // DATA write #2 -> COUNT=2
    axi_write(8'h08, 32'hCC);  // DATA write #3 -> COUNT=3
    axi_write(8'h08, 32'hDD);  // DATA write #4 -> COUNT=4 == IRQ_THRESHOLD
    axi_read(8'h0C);           // COUNT
    axi_read(8'h04);           // STATUS -- expect bit0=1
    $display("t=%0t irq=%0b", $time, irq);
 
    axi_write(8'h00, 32'h3);   // CTRL: ENABLE=1, IRQ_CLR=1 (write both bits, not just IRQ_CLR)
    axi_read(8'h04);           // STATUS -- expect bit0=0 again
    $display("t=%0t irq=%0b", $time, irq);
 
    axi_read(8'h10);           // unmapped -> DECERR
    $finish;
  end
endmodule

运行这段代码会打印 8 行:四次写,一对 COUNT/STATUS 读——显示第四次写之后 irq 已经拉高,接下来是清中断的那次写,第二次 STATUS 读显示 irq 已经拉低,最后是对一个未映射地址的 DECERR。波形会精确展示寄存器映射所承诺的行为:前三次 DATA 写期间 irq 一直是低,第四次写之后拉高,而清中断那次写的 BVALID 一出现,irq 立刻拉低。

小结

  • AXI4-Lite 有五个相互独立、各自 VALID/READY 握手的通道,没有 burst,只有三种响应码(OKAY/SLVERR/DECERR)——小到能装进脑子里,跟完整版 AXI4 不一样。
  • 握手的核心规则:VALID 不能等 READY,但 READY 可以等 VALID——这正是协议不会死锁的原因。
  • AW 和 W 相互独立——slave 必须能接住先到的那一个,只有两者都到齐才能响应。
  • axil_regfile 是一个 4 寄存器的外设(CTRL/STATUS/DATA/COUNT),带一个 irq 输出——这是 mux2 从来没有过的第二个接口,也正是这个系列接下来要讲的一切的出发点。
  • 寄存器写是整字写,不是读-改-写——要既清中断又保持计数使能,得一次性把两个 bit 都写上。
  • axi4lite_if 把 SV 第 13 章的 clocking block 写法延伸到了一个更宽的接口上——写法本身没变,只是信号变多了。

下面哪句话准确描述了 AXI4-Lite 的 VALID/READY 握手规则?

一个 master 向 axil_regfile 的 STATUS 寄存器(一个已映射、只读的地址)发起写操作,应该收到什么响应,为什么?

axil_regfile 的 DATA 寄存器映射在哪个字偏移地址上?(十六进制,例如 0x08)