ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用Cocotb和cocotbext-axi快速搭建AXI总线验证环境

用Cocotb和cocotbext-axi快速搭建AXI总线验证环境 做数字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. 为什么选Cocotbcocotbext-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 SystemVerilogCocotb 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下安装Icarusapt install iverilog。macOS下用Homebrewbrew 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结果。如果你还想生成波形文件加上WAVES1make sim WAVES1这样目录下会生成dump.vcd用GTKWave打开即可查看波形。3. 先花十分钟回顾AXI协议要点3.1 五个通道与握手机制AXI4总线分为五个通道写地址AW、写数据W、写响应B、读地址AR、读数据R。每个通道都靠VALID和READY两个信号完成握手。数据只有在VALID1且READY1的时钟上升沿才被确认传输。很多第一次写slave的人容易漏掉这一点在握手之前数据可以任意变化握手之后发送方必须保持数据稳定直到本次传输结束。cocotbext-axi厉害的地方在于它已经把握手逻辑封装好了。你发起await master.write()时库内部会自动完成AW通道和W通道的握手再等待B通道返回发起await master.read()时会完成AR通道握手然后接收R通道数据。你需要关心的不是每个VALID/READY怎么拉而是突发长度、数据宽度这些高层参数。3.2 LEN、SIZE与BURST的含义AXI协议里三个信号决定了突发传输的形状AXLEN表示突发长度实际传输的beat数等于AXLEN1。要注意协议里这个“减一”的设计很多人第一次写的时候容易多传一拍。AXSIZE表示每个beat的字节数取值是对数形式0表示1字节1表示2字节2表示4字节3表示8字节。例如SIZE2对应4字节传输。AXBURST表示突发类型FIXED每拍地址不变适合访问FIFOINCR每拍地址递增最常用的顺序访问WRAP用于Cacheline传输边界处会回卷。举个例子一个LEN3, SIZE2, BURSTINCR的读事务描述的是连续的4拍、每拍4字节、总共16字节的读操作起始地址到结束地址依次为ADDR, ADDR4, ADDR8, ADDR12。3.3 ID、响应与字节通道AXI的ID信号用于支持乱序返回。多个outstanding事务并行发出后返回的读数据或写响应会携带相同的ID从而让主端区分哪个事务完成了。对于刚起步的验证环境ID保持为固定值即可但DUT端最好还是原样把ID传回来否则后续做多ID乱序测试时会发现ID被“吞”了。字节通道WSTRB是写数据通道里的掩码信号位宽等于总线字节数。每一bit对应一个字节置1才表示该字节真正写入。这个信号是测试地址非对齐访问时最容易出错的地方cocotbext-axi内部会根据地址对齐自动生成正确的WSTRB你不用手动算但理解它有助于排查DUT端的问题。4. 一个适合练手的AXI Slave DUT4.1 DUT功能定义要搭建验证环境总得先有一个被测对象。我写了一个简单但五脏俱全的AXI4 slave DUT256字节的寄存器堆/SRAM支持INCR和FIXED两种突发类型位宽32bitID宽度4bit。为了简化一次只处理一笔事务不支持写读并发也不支持outstanding乱序。这对练习足够了后面我会在避坑章节说明怎么扩展。地址空间规划如下地址范围功能0x00 - 0x03版本寄存器固定值0x202401010x04 - 0x07控制寄存器可读可写0x08 - 0x0B状态寄存器写入后镜像读回0x10 - 0xFF256字节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 2b00; localparam WR_W 2b01; localparam WR_B 2b10; localparam RD_R 2b11; 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 1b0; s_axi_bvalid 1b0; s_axi_bid 0; s_axi_bresp 2b00; s_axi_rid 0; s_axi_rresp 2b00; s_axi_rvalid 1b0; s_axi_rlast 1b0; s_axi_rdata 32h0; 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 1b1; 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 1b1; 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 1b0; s_axi_bid awid_q; s_axi_bresp 2b00; s_axi_bvalid 1b1; state WR_B; end else begin wbeat_cnt wbeat_cnt 1; waddr waddr (32h4); end end end WR_B: begin if (s_axi_bvalid s_axi_bready) begin s_axi_bvalid 1b0; state IDLE; end end RD_R: begin if (s_axi_rvalid s_axi_rready) begin if (rbeat_cnt arlen_q) begin s_axi_rvalid 1b0; s_axi_rlast 1b0; state IDLE; end else begin rbeat_cnt rbeat_cnt 1; araddr_q araddr_q 32h4; 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 32h0; 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 32h20240101[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_levelTrue)from_prefix会自动扫描DUT端口里以S_AXI_开头的信号按awaddr、wdata、rresp这些协议名装配成一个总线对象。只要你的DUT端口命名规范这一步基本不会出错。5.2 时钟与复位生成cocotb提供了Clock类直接挂到DUT的时钟端口上。复位信号要自己控制最简单的是在测试开头拉高几个周期等内部状态机初始化完成再释放cocotb.start_soon(Clock(dut.clk, 10, unitsns).start()) dut.rst.value 1 await ClockCycles(dut.clk, 5) dut.rst.value 0 await ClockCycles(dut.clk, 2)这里把时钟周期设成10ns也就是100MHz对验证环境来说是个合理的默认值。reset_active_levelTrue表示高有效复位与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, unitsns).start()) await reset_dut(dut) bus AxiBus.from_prefix(dut, S_AXI) master AxiMaster(bus, dut.clk, dut.rst, reset_active_levelTrue) await master.write(0x04, b\x11\x22\x33\x44) data await master.read(0x04, 4) assert bytes(data) b\x11\x22\x33\x44, fread 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, unitsns).start()) await reset_dut(dut) bus AxiBus.from_prefix(dut, S_AXI) master AxiMaster(bus, dut.clk, dut.rst, reset_active_levelTrue) test_data bytes(range(64)) await master.write(0x10, test_data) readback await master.read(0x10, len(test_data)) assert bytes(readback) test_data, fburst 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, unitsns).start()) await reset_dut(dut) bus AxiBus.from_prefix(dut, S_AXI) master AxiMaster(bus, dut.clk, dut.rst, reset_active_levelTrue) 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, frandom 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会按总线位宽和地址对齐自动拆分beatDUT的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 WAVES1 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, unitsns)这类基于时间的等待建议改成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协议的理解会扎实很多。
返回列表