第 1 章 · 共 6 章
认识 AXI4-Lite 与 axil_regfile
UVM 高阶用法系列的第一章:认识新的 DUT——AXI4-Lite 协议的五个通道与 VALID/READY 握手规则、一个带中断输出的简化寄存器外设,以及一个完整的手动驱动 testbench,证明这一切都能跑通。
systemverilog-basics 和 uvm 两个系列加起来一共 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。
| 地址 | 名字 | 读写 | 行为 |
|---|---|---|---|
0x00 | CTRL | RW | bit0 ENABLE;bit1 IRQ_CLR(写 1 清零 COUNT 并拉低 irq——这一位从不被存储,只是一个一次性脉冲) |
0x04 | STATUS | RO | bit0 镜像当前的 irq 输出 |
0x08 | DATA | RW | 写入时(若 ENABLE=1)让内部计数器 +1;读取返回上次写入的值 |
0x0C | COUNT | RO | 内部计数器;达到一个固定阈值时拉高 irq |
其余地址一律返回 DECERR——这是一个诚实、真正做了地址译码的实现,不是占位符。写 STATUS 或 COUNT(映射了但只读)返回 SLVERR——地址存在,只是这次访问不被支持。
有一个细节值得明确说一下,因为 DUT 的实现依赖它:CTRL 的写不是读-改-写。 不管写进来的 32 位是什么,都会同时成为 ENABLE 和(瞬时的)IRQ_CLR 的新值——没有任何隐藏逻辑会在一次只想脉冲 IRQ_CLR 的写操作中保留住 ENABLE。要既清中断又保持计数使能,软件必须一次性把两个 bit 都写上(0x3,不是 0x2)。真实的内存映射寄存器就是这么工作的;这正是 mux2 和 axil_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);
endinterfaceaclk 和 aresetn 都放在 clocking block 外面,原因和第 13 章的 clk 一样:它们不是需要通过带偏移的采样去读写的信号,而是由 testbench 直接生成的。dut_mp 在实例化时接到 DUT 上,和 simple_bus_if 的 dut_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)