做数字IC验证的人,十有八九都绕不开AXI总线。我第一次被安排去写AXI slave的验证环境时,用的还是SystemVerilog + UVM,光是把driver和monitor搭起来就花了一周,还整天被编译错误折磨。后来接触了Cocotb,再配合cocotbext-axi这个库,我才发现AXI总线验证环境可以做得这么轻。这篇博客把我踩过的坑、整理过的完整可运行demo全部分享出来,目标读者是刚接触AXI验证、想在本机快速跑通读写用例的同学。如果你已经有一两年代码经验,里面关于随机校验、日志控制和波形查看的思路也值得参考。
Cocotb本身是一个用Python编写testbench的框架,cocotbext-axi则是它的AXI总线扩展包,提供AxiMaster、AxiSlave、AxiRam等现成的总线功能模型。简单说,你不再需要手写几十个接口信号的driver和monitor,只要把总线对象挂到DUT上,就能用Python的async/await直接发起AXI读写事务。这套方案对个人项目、原型验证、教学demo都非常友好,跑通一个最小AXI验证环境的时间可以从一周压缩到半天。
1. 为什么选Cocotb+cocotbext-axi搭AXI环境
1.1 传统UVM方案的代价
UVM作为行业主流验证方法学,功能强大,但代价也摆在那里:类库庞大,sequence、agent、driver、monitor、scoreboard一圈套一圈,光是理解继承关系就要花不少时间。对于刚入门AXI协议的人来说,UVM里大量样板代码会淹没真正的协议学习重点。很多时候你只是想验证一个AXI slave读写逻辑对不对,结果一半时间在跟UVM的phase机制和factory机制搏斗。
另一个痛点是编译速度。SystemVerilog的UVM环境,用商用仿真器还好,在开源工具链下跑一次回归往往慢得让人丧失耐心。如果只是在做小规模模块验证,这种重量级方案未必划算。
1.2 Python写testbench的核心优势
Cocotb把testbench语言换成了Python,这个转变带来的生产力提升是实打实的。Python的async/await语法天然适合描述总线时序,一个AXI突发写事务,在cocotbext-axi里就是一句await master.write(0x10, data)。你不需要关心底层awvalid和wready是怎么握手、数据是怎么按wstrb写入的,这些细节被封装成稳定的API。
更重要的是Python生态可以直接被验证环境使用。比如你用random库做随机激励,用os.urandom生成随机数据,用struct或int.from_bytes处理字节序,甚至在测试里调用pytest做断言,这些在UVM里实现起来非常啰嗦,在Python里几乎是零成本。
1.3 cocotbext-axi提供的现成部件
cocotbext-axi这个库最大的价值,是把AXI总线协议的行为模型替你写好了。核心有几个类:
AxiBus:承载AXI总线信号的对象,从DUT的端口自动创建。AxiMaster:主动发起AXI事务的主端模型,用于驱动DUT的从端接口。AxiSlave:响应AXI事务的从端模型,用于处理DUT主端发起的事务。AxiRam:一个带内存行为的从端模型,适合模拟简单内存设备。AxiMonitor:总线事务监听器,可以抓取总线上发生的事务用于做覆盖率或数据比较。
对于最开始的验证环境,大部分情况下你只需要AxiMaster配合一个AXI slave类型的DUT,就能把读写通路全跑起来。下面这幅对比表能更直观地看出差距:
| 对比项 | UVM + SystemVerilog | Cocotb + cocotbext-axi |
|---|---|---|
| 环境搭建成本 | 高,类库庞大样板代码多 | 低,几十行Python就能跑通 |
| 编译与仿真速度 | 商用工具尚可,开源工具偏慢 | 轻量,迭代快 |
| 协议封装程度 | 需要自己写driver/monitor | 开箱即用的Master/Slave模型 |
| 断言与随机化 | UVM自带机制,学习成本高 | Python生态随手可用 |
| 调试效率 | 波形为主,日志需要自己搭 | Python打印、pdb、波形都方便 |
2. 动手前的准备:环境安装与最小工程结构
2.1 Python环境与Cocotb安装
推荐使用Python 3.8到3.12之间的版本,太老的版本可能装不上新版cocotb,太新的版本偶尔会遇到wheel缺失。安装本身没什么坑:
pip install cocotb cocotbext-axi建议在虚拟环境里装,免得把系统Python环境搞乱。装完可以用cocotb-config --version确认安装成功。如果你以前装过旧版cocotb,最好先升级:pip install -U cocotb cocotbext-axi。cocotbext-axi与cocotb的版本兼容性问题我后面会专门讲。
2.2 仿真器选择:Icarus还是Verilator
开源仿真器里最常见的是Icarus Verilog和Verilator。我的建议是:第一个环境老老实实用Icarus。
Icarus安装简单,对cocotb的兼容性最成熟,直接一条make sim就能跑起来。缺点是仿真速度慢、支持的语言特性有限,但对于一个练习用的AXI slave DUT完全足够。Verilator速度更快,但默认做四状态仿真比较麻烦,对X态和Z态的处理需要额外配置,而且cocotb连接Verilator时对DUT写法有更多约束,不适合新手一上来就折腾。
Ubuntu/Debian下安装Icarus:apt install iverilog。macOS下用Homebrew:brew install icarus-verilog。Windows用户可以用MSYS2或者WSL。
2.3 最小工程目录结构
我习惯把工程分成三个目录:rtl放Verilog源码,tb放cocotb测试脚本,sim放仿真生成的文件。这样一个简单但规范的结构长这样:
axi_demo/ ├── rtl/ │ └── axi_slave_dut.v ├── tb/ │ └── test_axi_slave.py └── sim/ └── MakefileMakefile放在sim目录里,方便把仿真中间文件隔离起来,不会污染工程目录。
2.4 Makefile与运行命令
一个能直接跑的Makefile长这样:
SIM ?= icarus TOPLEVEL_LANG ?= verilog VERILOG_SOURCES = $(shell pwd)/../rtl/axi_slave_dut.v TOPLEVEL = axi_slave_dut MODULE = test_axi_slave include $(shell cocotb-config --makefiles)/Makefile.sim设置好TOPLEVEL为DUT模块名,MODULE为Python测试文件去掉.py后缀的名字。运行:
make sim仿真结束后会输出每个测试用例的PASS/FAIL结果。如果你还想生成波形文件,加上WAVES=1:
make sim WAVES=1这样目录下会生成dump.vcd,用GTKWave打开即可查看波形。
3. 先花十分钟回顾AXI协议要点
3.1 五个通道与握手机制
AXI4总线分为五个通道:写地址AW、写数据W、写响应B、读地址AR、读数据R。每个通道都靠VALID和READY两个信号完成握手。数据只有在VALID=1且READY=1的时钟上升沿才被确认传输。很多第一次写slave的人容易漏掉这一点:在握手之前,数据可以任意变化;握手之后,发送方必须保持数据稳定直到本次传输结束。
cocotbext-axi厉害的地方在于,它已经把握手逻辑封装好了。你发起await master.write()时,库内部会自动完成AW通道和W通道的握手,再等待B通道返回;发起await master.read()时,会完成AR通道握手然后接收R通道数据。你需要关心的不是每个VALID/READY怎么拉,而是突发长度、数据宽度这些高层参数。
3.2 LEN、SIZE与BURST的含义
AXI协议里三个信号决定了突发传输的形状:
AXLEN表示突发长度,实际传输的beat数等于AXLEN+1。要注意协议里这个“减一”的设计,很多人第一次写的时候容易多传一拍。AXSIZE表示每个beat的字节数,取值是对数形式:0表示1字节,1表示2字节,2表示4字节,3表示8字节。例如SIZE=2对应4字节传输。AXBURST表示突发类型:FIXED每拍地址不变,适合访问FIFO;INCR每拍地址递增,最常用的顺序访问;WRAP用于Cacheline传输,边界处会回卷。
举个例子,一个LEN=3, SIZE=2, BURST=INCR的读事务,描述的是连续的4拍、每拍4字节、总共16字节的读操作,起始地址到结束地址依次为ADDR, ADDR+4, ADDR+8, ADDR+12。
3.3 ID、响应与字节通道
AXI的ID信号用于支持乱序返回。多个outstanding事务并行发出后,返回的读数据或写响应会携带相同的ID,从而让主端区分哪个事务完成了。对于刚起步的验证环境,ID保持为固定值即可,但DUT端最好还是原样把ID传回来,否则后续做多ID乱序测试时会发现ID被“吞”了。
字节通道WSTRB是写数据通道里的掩码信号,位宽等于总线字节数。每一bit对应一个字节,置1才表示该字节真正写入。这个信号是测试地址非对齐访问时最容易出错的地方,cocotbext-axi内部会根据地址对齐自动生成正确的WSTRB,你不用手动算,但理解它有助于排查DUT端的问题。
4. 一个适合练手的AXI Slave DUT
4.1 DUT功能定义
要搭建验证环境,总得先有一个被测对象。我写了一个简单但五脏俱全的AXI4 slave DUT:256字节的寄存器堆/SRAM,支持INCR和FIXED两种突发类型,位宽32bit,ID宽度4bit。为了简化,一次只处理一笔事务,不支持写读并发,也不支持outstanding乱序。这对练习足够了,后面我会在避坑章节说明怎么扩展。
地址空间规划如下:
| 地址范围 | 功能 |
|---|---|
| 0x00 - 0x03 | 版本寄存器,固定值0x20240101 |
| 0x04 - 0x07 | 控制寄存器,可读可写 |
| 0x08 - 0x0B | 状态寄存器,写入后镜像读回 |
| 0x10 - 0xFF | 256字节SRAM区域,支持突发读写 |
4.2 完整Verilog代码
把下面的代码存为rtl/axi_slave_dut.v。这个代码刻意写得很直白,方便对照协议理解。
module axi_slave_dut #( parameter ADDR_WIDTH = 32, parameter DATA_WIDTH = 32, parameter ID_WIDTH = 4 )( input wire clk, input wire rst, // AW channel input wire [ID_WIDTH-1:0] s_axi_awid, input wire [ADDR_WIDTH-1:0] s_axi_awaddr, input wire [7:0] s_axi_awlen, input wire [2:0] s_axi_awsize, input wire [1:0] s_axi_awburst, input wire s_axi_awvalid, output wire s_axi_awready, // W channel input wire [DATA_WIDTH-1:0] s_axi_wdata, input wire [DATA_WIDTH/8-1:0] s_axi_wstrb, input wire s_axi_wlast, input wire s_axi_wvalid, output reg s_axi_wready, // B channel output reg [ID_WIDTH-1:0] s_axi_bid, output reg [1:0] s_axi_bresp, output reg s_axi_bvalid, input wire s_axi_bready, // AR channel input wire [ID_WIDTH-1:0] s_axi_arid, input wire [ADDR_WIDTH-1:0] s_axi_araddr, input wire [7:0] s_axi_arlen, input wire [2:0] s_axi_arsize, input wire [1:0] s_axi_arburst, input wire s_axi_arvalid, output wire s_axi_arready, // R channel output reg [ID_WIDTH-1:0] s_axi_rid, output reg [DATA_WIDTH-1:0] s_axi_rdata, output reg [1:0] s_axi_rresp, output reg s_axi_rlast, output reg s_axi_rvalid, input wire s_axi_rready ); localparam IDLE = 2'b00; localparam WR_W = 2'b01; localparam WR_B = 2'b10; localparam RD_R = 2'b11; reg [1:0] state; reg [7:0] mem [0:255]; reg [ID_WIDTH-1:0] awid_q; reg [ADDR_WIDTH-1:0] awaddr_q; reg [7:0] awlen_q; reg [ADDR_WIDTH-1:0] waddr; reg [7:0] wbeat_cnt; reg [ID_WIDTH-1:0] arid_q; reg [ADDR_WIDTH-1:0] araddr_q; reg [7:0] arlen_q; reg [7:0] rbeat_cnt; wire [15:0] mem_addr = waddr[15:2]; // AW ready: only in IDLE state and awvalid asserted assign s_axi_awready = (state == IDLE) && s_axi_awvalid; // AR ready: only in IDLE state and arvalid asserted assign s_axi_arready = (state == IDLE) && s_axi_arvalid; always @(posedge clk) begin if (rst) begin state <= IDLE; s_axi_wready <= 1'b0; s_axi_bvalid <= 1'b0; s_axi_bid <= 0; s_axi_bresp <= 2'b00; s_axi_rid <= 0; s_axi_rresp <= 2'b00; s_axi_rvalid <= 1'b0; s_axi_rlast <= 1'b0; s_axi_rdata <= 32'h0; wbeat_cnt <= 0; rbeat_cnt <= 0; end else begin case (state) IDLE: begin if (s_axi_awvalid) begin awid_q <= s_axi_awid; awaddr_q <= s_axi_awaddr; awlen_q <= s_axi_awlen; waddr <= s_axi_awaddr; wbeat_cnt <= 0; s_axi_wready <= 1'b1; state <= WR_W; end else if (s_axi_arvalid) begin arid_q <= s_axi_arid; araddr_q <= s_axi_araddr; arlen_q <= s_axi_arlen; rbeat_cnt <= 0; s_axi_rvalid <= 1'b1; s_axi_rlast <= (s_axi_arlen == 0); state <= RD_R; end end WR_W: begin if (s_axi_wvalid && s_axi_wready) begin // byte write with wstrb for (int i = 0; i < DATA_WIDTH/8; i++) begin if (s_axi_wstrb[i]) begin mem[waddr[15:2] + i] <= s_axi_wdata[i*8 +: 8]; end end if (wbeat_cnt == awlen_q) begin s_axi_wready <= 1'b0; s_axi_bid <= awid_q; s_axi_bresp <= 2'b00; s_axi_bvalid <= 1'b1; state <= WR_B; end else begin wbeat_cnt <= wbeat_cnt + 1; waddr <= waddr + (32'h4); end end end WR_B: begin if (s_axi_bvalid && s_axi_bready) begin s_axi_bvalid <= 1'b0; state <= IDLE; end end RD_R: begin if (s_axi_rvalid && s_axi_rready) begin if (rbeat_cnt == arlen_q) begin s_axi_rvalid <= 1'b0; s_axi_rlast <= 1'b0; state <= IDLE; end else begin rbeat_cnt <= rbeat_cnt + 1; araddr_q <= araddr_q + 32'h4; s_axi_rlast <= (rbeat_cnt + 1 == arlen_q); end end end default: state <= IDLE; endcase end end // read data output, combinational read from mem always @(*) begin s_axi_rid = arid_q; s_axi_rdata = 32'h0; if (state == RD_R) begin s_axi_rid = arid_q; s_axi_rdata = {mem[araddr_q[15:2]+3], mem[araddr_q[15:2]+2], mem[araddr_q[15:2]+1], mem[araddr_q[15:2]+0]}; end end // version and control/status registers wire [7:0] mem_0x00 = 32'h20240101[7:0]; endmodule这段代码为了演示故意简化了很多:不支持未对齐地址、不支持SIZE小于2的传输、读写通道不能同时工作。但它的握手逻辑、wstrb处理、rlast产生都覆盖到了,拿来做cocotb练习足够。
4.3 为什么选这个DUT作为第一个环境
因为它是典型的“主端发起事务、从端被动响应”结构,正好对应cocotbext-axi里AxiMaster的用法。你不需要处理DUT主动发请求的场景,也不用考虑slave端怎么回事务,第一条用例的调试链路最短。
另外它把wstrb、rlast、bresp这些AXI细节都摊在明面上,一旦测试失败,翻代码时能很快定位到协议处理的哪一步出了问题。比起直接拿一个工程里几百行的复杂DUT来练,这个体量更利于建立信心。
5. 用cocotbext-axi搭建完整测试平台
5.1 总线信号绑定
cocotb里拿到DUT句柄后,需要把DUT端口映射成cocotbext-axi认识的总线对象。写法非常固定:
from cocotbext.axi import AxiBus, AxiMaster bus = AxiBus.from_prefix(dut, "S_AXI") master = AxiMaster(bus, dut.clk, dut.rst, reset_active_level=True)from_prefix会自动扫描DUT端口里以S_AXI_开头的信号,按awaddr、wdata、rresp这些协议名装配成一个总线对象。只要你的DUT端口命名规范,这一步基本不会出错。
5.2 时钟与复位生成
cocotb提供了Clock类,直接挂到DUT的时钟端口上。复位信号要自己控制,最简单的是在测试开头拉高几个周期,等内部状态机初始化完成再释放:
cocotb.start_soon(Clock(dut.clk, 10, units="ns").start()) dut.rst.value = 1 await ClockCycles(dut.clk, 5) dut.rst.value = 0 await ClockCycles(dut.clk, 2)这里把时钟周期设成10ns,也就是100MHz,对验证环境来说是个合理的默认值。reset_active_level=True表示高有效复位,与DUT设计保持一致。
5.3 完整testbench代码
把下面的代码存为tb/test_axi_slave.py。这个脚本包含三个测试用例:单次读写、突发读写、随机读写校验。
import random import cocotb from cocotb.clock import Clock from cocotb.triggers import ClockCycles from cocotbext.axi import AxiBus, AxiMaster async def reset_dut(dut): dut.rst.value = 1 await ClockCycles(dut.clk, 5) dut.rst.value = 0 await ClockCycles(dut.clk, 2) @cocotb.test() async def test_single_write_read(dut): """单次写与单次读回比较""" cocotb.start_soon(Clock(dut.clk, 10, units="ns").start()) await reset_dut(dut) bus = AxiBus.from_prefix(dut, "S_AXI") master = AxiMaster(bus, dut.clk, dut.rst, reset_active_level=True) await master.write(0x04, b'\x11\x22\x33\x44') data = await master.read(0x04, 4) assert bytes(data) == b'\x11\x22\x33\x44', f"read mismatch: {bytes(data)}" dut._log.info("single write/read test PASS") @cocotb.test() async def test_burst_write_read(dut): """突发写64字节,再突发读回比较""" cocotb.start_soon(Clock(dut.clk, 10, units="ns").start()) await reset_dut(dut) bus = AxiBus.from_prefix(dut, "S_AXI") master = AxiMaster(bus, dut.clk, dut.rst, reset_active_level=True) test_data = bytes(range(64)) await master.write(0x10, test_data) readback = await master.read(0x10, len(test_data)) assert bytes(readback) == test_data, f"burst mismatch: {bytes(readback)}" dut._log.info("burst write/read test PASS") @cocotb.test() async def test_random_write_read(dut): """随机地址随机长度的写读比较""" cocotb.start_soon(Clock(dut.clk, 10, units="ns").start()) await reset_dut(dut) bus = AxiBus.from_prefix(dut, "S_AXI") master = AxiMaster(bus, dut.clk, dut.rst, reset_active_level=True) random.seed(0x1234) for _ in range(50): addr = random.randrange(0, 0x100 - 16, 4) length = random.randrange(1, 17) if addr + length > 0x100: length = 0x100 - addr test_data = bytes(random.getrandbits(8) for _ in range(length)) await master.write(addr, test_data) readback = await master.read(addr, length) assert bytes(readback) == test_data, f"random mismatch at 0x{addr:02x}" dut._log.info("random write/read test PASS")5.4 每个用例的拆解与验证思路
第一个用例test_single_write_read验证最基础的寄存器读写。写入4字节,读回4字节,比对数据。这里注意write接口接收的是bytes类型,read返回的是bytearray,所以要用bytes(data)做比较。这个用例的意义在于确认AxiMaster能够跟DUT完成最基本的地址握手和数据传输。
第二个用例test_burst_write_read验证DUT对写响应的正确处理。bytes(range(64))构造0到63共64字节数据,一次突发写入0x10地址。cocotbext-axi会按总线位宽和地址对齐自动拆分beat,DUT的wlast和rlast逻辑会在这个用例里被完整覆盖到。如果DUT的wlast生成有bug,这个用例会挂在写入阶段;如果读地址递增有bug,会挂在读回阶段。
第三个用例test_random_write_read是回归里最常用的套路。固定随机种子保证可复现,地址限定在4字节对齐、长度限制在16字节以内,避开DUT不支持的边界情况。50轮随机写读跑下来,能覆盖到地址递增、字节掩码、突发边界等大多数组合。出现失败时,把随机种子固定住就能稳定复现。
5.5 跑通后的结果与波形查看
用make sim跑完后,终端会输出类似这样的结果:
test_single_write_read PASS test_burst_write_read PASS test_random_write_read PASS三个用例全部通过,说明这个最小AXI总线验证环境已经可以正常工作了。此时可以把波形打开看看实际信号:
make sim WAVES=1 gtkwave dump.vcd重点关注三个地方:AW和W通道握手时的awvalid/awready以及wvalid/wready组合;写响应B通道的bvalid/bready握手;读通道R上rvalid/rready和rlast的对齐关系。多观察几次波形,对AXI时序的感觉会提高得很快。
6. 常见问题与避坑心得
6.1 事务卡住:为什么write或read一直不返回
这是新手最容易碰到的问题。现象是仿真停在await master.write()处,后续没有任何输出。原因几乎都是DUT的ready信号没有正确拉高,或者拉高的时机和AxiMaster预期不一致。
排查思路很简单:开波形看五组通道的VALID/READY。如果某组通道的VALID和READY永远没有同时为高,握手就永远完成不了。常见bug是在IDLE状态下没有拉高awready或arready,或者状态机进入了错误状态把ready拉低了。
cocotbext-axi默认对等待响应没有超时机制,所以卡住时不会报错,只会一直等下去。遇到这种情况别犹豫,直接开波形看状态机跑到哪里了。
6.2 总线宽度、ID宽度等参数不匹配
AxiBus通过端口名自动识别信号,但不会校验位宽。如果DUT的S_AXI_AWADDR是32位,而cocotbext-axi里参数配的是64位地址,事务过程中就会因为地址截断出现诡异行为。最典型的例子是地址明明写的是0x40,读回的却是0x00附近的数据。
我的习惯是在初始化总线后加一个断言,检查bus.AWADDR.width和DUT的ADDR_WIDTH是否一致,不一致直接报错。另外ID宽度不一致时,返回的BID或RID可能会被截断,导致AxiMaster无法匹配事务ID,也会出现“明明握手了但read永远在等”的情况。
6.3 关于transaction打印与日志控制
很多人用商用AXI VIP时会在意transaction打印太刷屏的问题。在cocotbext-axi里,日志控制更简单,你可以在测试脚本开头加:
import logging logging.getLogger("cocotbext.axi").setLevel(logging.WARNING)这样只有warning及以上级别的日志会输出,每个读写事务的详细打印就屏蔽掉了。如果你只想关掉某一个master对象的打印,可以单独设置:
master.log.setLevel(logging.WARNING)对比商用的VIP日志开关设置,本质思路是一样的:默认log信息全开方便调试,回归时把级别调高避免刷屏。
6.4 Verilator下的差异
用Verilator跑cocotb时,最容易踩的坑是DUT必须写成单驱动逻辑,不能有initial语句里的X态赋值,也尽量不要在端口代码里依赖wire的未初始化值。Icarus下能跑的代码,换到Verilator可能因为四状态信号处理方式不同而出现仿真差异。
另外Verilator对延迟控制比较严格,如果你的测试脚本依赖await Timer(1, units="ns")这类基于时间的等待,建议改成await ClockCycles(dut.clk, n),否则仿真行为可能和Icarus不一致。第一个环境用Icarus,后续要提速再转Verilator,这是比较稳妥的路径。
6.5 一个高效扩展:用AxiRam当参考内存
等到你想验证一个AXI master类型的DUT时,cocotbext-axi里的AxiRam可以直接当参考内存用。它不需要自己写从端响应逻辑,初始化一个内存模型挂到DUT的master端口上,DUT发起的写读事务会被AxiRam自动处理。配合它自带的读写内存访问接口,可以快速对比DUT写到内存的数据是否符合预期。
我后面用它验过一个简单的AXI仲裁器,省掉了大量样板代码。类似地,如果以后要碰AXI Stream协议,cocotbext-axi还有对应的stream接口可以扩展,这套方法论的迁移成本很低。
在我实际使用的过程中,最深的体会是:cocotbext-axi虽然把总线时序封装掉了,但你在调试时必须回到协议本身去理解信号。跑通这个最小环境只是开始,真正的功力体现在遇到死锁时能快速从波形里看出是哪一拍的ready没拉起来,或者哪一笔事务因为ID不匹配被挂起了。建议大家跑完这个demo后,主动去改一改DUT代码,比如故意去掉rlast的生成逻辑,再看测试挂在哪里,这一遍下来对AXI协议的理解会扎实很多。