00382 利用 Cocotb + cocotbext-axi 验证 AXI DMA 的完整教程


本教程从零开始,面向初学者,带你理解 DMAAXI 总线数字验证 的基本概念,并一步步搭建基于 Cocotbcocotbext-axi VIP 和 Verilator 的仿真环境,实现一个简易的 AXI-Lite DMA IP,编写完整的 Python Testbench 进行功能验证与覆盖率收集。


目录

  1. 什么是 DMA?
  2. 什么是 AXI?
  3. 什么是数字验证?
  4. 项目概述
  5. 环境 Setup
  6. RTL 源码详解
  7. Makefile
  8. Cocotb Testbench 详解
  9. 运行仿真
  10. 常见问题排查
  11. 扩展建议
  12. 总结

1. 什么是 DMA?

DMA(Direct Memory Access,直接存储器访问) 是一种让外设不经过 CPU 直接读写内存的技术。

1.1 为什么需要 DMA?

想象一下,你要把硬盘上的 1GB 文件拷贝到内存中:

  • 没有 DMA:CPU 必须一个字节一个字节地从硬盘读,再写入内存。这期间 CPU 被完全占用,无法做其他事情。
  • 有了 DMA:CPU 只需要告诉 DMA 控制器”源地址、目标地址、长度”,DMA 就会自动完成拷贝。CPU 可以去执行其他任务,等 DMA 完成后通过中断通知 CPU。
flowchart LR
    subgraph 无DMA["❌ 无 DMA"]
        CPU1["CPU"] -->|逐字节搬运| MEM1["内存"]
        CPU1 -->|占用100%| DISK1["硬盘"]
    end

    subgraph 有DMA["✅ 有 DMA"]
        CPU2["CPU"] -->|配置参数| DMA["DMA 控制器"]
        DMA -->|自动搬运| MEM2["内存"]
        DMA -->|自动搬运| DISK2["硬盘"]
        CPU2 -->|空闲做其他事| OTHER["其他任务"]
    end

1.2 DMA 的基本工作流程

sequenceDiagram
    participant CPU as CPU
    participant DMA as DMA 控制器
    participant SRC as 源设备/内存
    participant DST as 目标设备/内存

    CPU->>DMA: 1. 配置 src_addr
    CPU->>DMA: 2. 配置 dst_addr
    CPU->>DMA: 3. 配置 length
    CPU->>DMA: 4. 启动 start
    Note over CPU: CPU 去干别的了

    loop 每次搬运 4 字节
        DMA->>SRC: 5. 发送读请求
        SRC-->>DMA: 6. 返回数据
        DMA->>DST: 7. 发送写请求
        DST-->>DMA: 8. 返回写完成
    end

    DMA-->>CPU: 9. done = 1 (完成)

1.3 本教程的 DMA 特点

本教程实现的是一个简化版 AXI-Lite DMA

  • 只支持 Memory-to-Memory(内存到内存)传输
  • 通过 AXI-Lite 总线协议访问内存
  • 每次传输固定 4 字节(32位数据宽度)
  • 支持总线错误检测(非 OKAY 响应时报告 error)

2. 什么是 AXI?

AXI(Advanced eXtensible Interface) 是 ARM 公司设计的一种高性能片上总线协议,广泛应用于 SoC(System on Chip)设计中。

2.1 AXI 家族成员

协议 特点 适用场景
AXI4-Full 支持突发传输(Burst)、乱序完成 高性能数据传输(如 DDR 控制器)
AXI4-Lite 简单、每次传输 1 个数据 寄存器配置、低速外设
AXI4-Stream 无地址,只有数据流 音视频流、流水线数据处理

本教程使用 AXI4-Lite,因为它足够简单,适合初学者理解。

2.2 AXI-Lite 写通道信号

AXI-Lite 写操作使用三个独立通道

通道 信号 方向(对 Master) 说明
写地址 awaddr Output 要写入的内存地址
写地址 awvalid Output Master 地址有效
写地址 awready Input Slave 准备好接收地址
写数据 wdata Output 要写入的数据
写数据 wstrb Output 字节使能(本例固定 0xF)
写数据 wvalid Output Master 数据有效
写数据 wready Input Slave 准备好接收数据
写响应 bresp Input 响应状态(00=OKAY)
写响应 bvalid Input Slave 响应有效
写响应 bready Output Master 准备好接收响应

2.3 AXI-Lite 读通道信号

通道 信号 方向(对 Master) 说明
读地址 araddr Output 要读取的内存地址
读地址 arvalid Output Master 地址有效
读地址 arready Input Slave 准备好接收地址
读数据 rdata Input 返回的数据
读数据 rresp Input 响应状态(00=OKAY)
读数据 rvalid Input Slave 数据有效
读数据 rready Output Master 准备好接收数据

2.4 AXI-Lite 写时序(关键握手)

AXI 的核心机制是 VALID/READY 握手:当 validready 同时为高时,数据传输发生。

sequenceDiagram
    participant M as DMA (Master)
    participant S as AXI-Lite Slave (内存)

    Note over M,S: 写地址通道握手
    M->>S: awaddr = 0x100, awvalid = 1
    S-->>M: awready = 1
    Note over M,S: 时钟上升沿:地址传输完成

    Note over M,S: 写数据通道握手
    M->>S: wdata = 0x11223344, wvalid = 1
    S-->>M: wready = 1
    Note over M,S: 时钟上升沿:数据传输完成

    Note over M,S: 写响应通道握手
    S-->>M: bresp = 00 (OKAY), bvalid = 1
    M->>S: bready = 1
    Note over M,S: 时钟上升沿:响应传输完成

2.5 AXI-Lite 读时序

sequenceDiagram
    participant M as DMA (Master)
    participant S as AXI-Lite Slave (内存)

    Note over M,S: 读地址通道握手
    M->>S: araddr = 0x100, arvalid = 1
    S-->>M: arready = 1
    Note over M,S: 时钟上升沿:地址传输完成

    Note over M,S: 读数据通道握手
    S-->>M: rdata = 0xAABBCCDD, rresp = 00, rvalid = 1
    M->>S: rready = 1
    Note over M,S: 时钟上升沿:数据传输完成

2.6 响应编码

rresp/bresp 值 含义 说明
00 OKAY 正常完成
01 EXOKAY 独占访问成功(Lite 不支持)
10 SLVERR Slave 错误
11 DECERR 解码错误(地址不存在)

3. 什么是数字验证?

3.1 为什么需要验证?

数字电路设计(RTL)完成后,不能直接把代码烧到芯片里。因为:

  1. 流片成本极高:一次芯片制造(流片)可能需要数百万美元,且耗时数月。
  2. bug 修复困难:芯片制造完成后发现 bug,只能重新设计、重新流片。
  3. 逻辑错误难以肉眼发现:复杂的状态机、时序交互问题,单纯看代码很难发现。

因此,我们需要在流片前,通过仿真来穷尽各种场景,确保设计功能正确。

3.2 验证 = 找 bug

验证工程师的核心目标是:证明设计有 bug,或者在有限时间内找不到 bug

flowchart TD
    A[RTL 设计代码] --> B[Testbench 测试平台]
    B --> C{仿真运行}
    C -->|发现输出不匹配| D[发现 BUG]
    C -->|所有测试通过| E[暂时未发现 BUG]
    D --> A
    E --> F[流片 / 上板]

3.3 传统验证 vs Cocotb 验证

方式 语言 优点 缺点
传统 SystemVerilog SystemVerilog + UVM 行业标准,功能强大 学习曲线陡峭,代码冗长
Cocotb Python 简洁易读,生态丰富,适合快速验证 对 SystemVerilog 高级特性支持有限

3.4 本教程的验证架构

flowchart TB
    subgraph TB["Testbench (Python + Cocotb)"]
        direction TB
        TEST["测试用例<br/>test_dma.py"]
        VIP1["cocotbext-axi VIP<br/>AxiLiteRam"]
        VIP2["手动 Slave 协程<br/>错误注入"]
    end

    subgraph SIM["仿真器 (Verilator)"]
        DUT["DUT: unique_simple_axi_dma"]
    end

    TEST -->|驱动信号| DUT
    DUT -->|AXI-Lite 总线| VIP1
    DUT -->|AXI-Lite 总线| VIP2
    VIP1 -->|返回数据| DUT
    VIP2 -->|返回错误| DUT

3.5 什么是覆盖率?

覆盖率(Coverage)是衡量验证完备性的核心指标。它回答了一个关键问题:

“我们的测试用例到底跑过了设计中的多少代码?”

行覆盖率(Line Coverage)

本教程使用的是最直观的覆盖率指标——行覆盖率

  • 定义:仿真过程中,RTL 源代码里每一行被执行的次数
  • 意义:如果某一行代码从未被执行(%000000),说明当前测试集没有覆盖到这个场景,对应的功能可能存在未被发现的 bug。
  • 目标:通常追求尽可能高的行覆盖率(如 >95%),但 100% 行覆盖率不等于没有 bug——它只说明代码被”跑到过”,不代表所有边界条件都被验证。

举个例子:

if (m_axi_rresp != 2'b00) begin
    error <= 1'b1;   // 这行只有总线返回错误时才会执行
end

如果测试用例从不注入错误响应,那么 error <= 1'b1 这行就永远是 %000000,意味着错误处理逻辑从未被验证——而这恰恰是 DMA 控制器鲁棒性的关键部分。

本教程通过 --coverage 参数让 Verilator 自动插桩,仿真结束后生成带执行次数标注的源码报告(详见 9.2 生成覆盖率报告)。


4. 项目概述

我们要实现的是一个 Memory-to-Memory 的 AXI-Lite DMA 控制器:

  • 用户配置 src_addr(源地址)、dst_addr(目标地址)和 length(传输字节数,需为 4 的倍数)。
  • 拉高 start 信号启动传输。
  • DMA 通过 AXI-Lite 总线从源地址读取数据,再写入目标地址。
  • 传输完成后拉高 done 信号。
  • 若总线返回非 OKAY 的 rrespbresp,则拉高 error 信号并停止。

验证方面,我们使用:

  • cocotbext-axi 提供的 AxiLiteRam 作为 AXI-Lite Slave VIP,模拟内存。
  • 手动编写的 AXI Slave 协程,用于注入错误响应(SLVERR / DECERR)。
  • Verilator 作为仿真器,并开启 --coverage 收集行覆盖率。

4.1 项目目录结构

axi_dma_cocotb/
├── unique_simple_axi_dma.v   # RTL 源码
├── test_dma.py               # Cocotb Testbench
├── Makefile                  # Cocotb 编译规则
└── tutorial.md               # 本教程

5. 环境 Setup

5.1 系统要求

  • Linux (Ubuntu / WSL 均可)
  • Python 3.10+
  • GCC / G++ (用于编译 Verilator 生成的 C++ 代码)
  • git

5.2 安装 Verilator

# 安装依赖
sudo apt update
sudo apt install -y git help2man perl python3 make autoconf g++ flex bison libfl2 libfl-dev zlib1g-dev

# 从源码编译安装 Verilator
git clone https://github.com/verilator/verilator
cd verilator
autoconf
./configure --prefix=/usr/local
make -j$(nproc)
sudo make install

# 验证安装
verilator --version

注意:确保安装的是 **Verilator 5.0+**,因为 Cocotb 2.x 需要较新的 VPI 支持。本教程使用 Verilator 5.038

5.3 安装 lcov(可选,用于生成 HTML 覆盖率报告)

sudo apt install -y lcov

lcov 是 Linux 下的覆盖率可视化工具,能将 Verilator 生成的 .dat 覆盖率数据转换成美观的 HTML 网页报告,方便在浏览器中查看。

5.4 安装 Python 依赖

推荐使用 Conda 或 venv 创建虚拟环境:

# 创建虚拟环境(示例使用 miniconda)
conda create -n cocotb_env python=3.12 -y
conda activate cocotb_env

# 安装核心包
pip install cocotb cocotbext-axi

# 验证安装
python -c "import cocotb; print(cocotb.__version__)"
python -c "from cocotbext.axi import AxiLiteBus, AxiLiteRam; print('cocotbext-axi OK')"

cocotbext-axi 是 Alex Forencich 开发的 AXI VIP 库,本教程使用版本 0.1.28


6. RTL 源码详解

下面是完整的 DMA Verilog 代码。它实现了基于状态机的 AXI-Lite Master 读写流程,并支持错误检测。

创建文件 unique_simple_axi_dma.v

`timescale 1ns / 1ps

module unique_simple_axi_dma (
    input  wire        clk,
    input  wire        rst_n,

    // 控制接口
    input  wire        start,
    input  wire [31:0] src_addr,
    input  wire [31:0] dst_addr,
    input  wire [31:0] length, // 字节数,需为 4 的倍数
    output reg         done,
    output reg         error,  // 传输过程中收到非 OKAY 响应

    // AXI-Lite 主机接口
    output reg  [31:0] m_axi_awaddr,
    output reg         m_axi_awvalid,
    input  wire        m_axi_awready,

    output reg  [31:0] m_axi_wdata,
    output wire [3:0]  m_axi_wstrb,
    output reg         m_axi_wvalid,
    input  wire        m_axi_wready,

    input  wire [1:0]  m_axi_bresp,
    input  wire        m_axi_bvalid,
    output reg         m_axi_bready,

    output reg  [31:0] m_axi_araddr,
    output reg         m_axi_arvalid,
    input  wire        m_axi_arready,

    input  wire [31:0] m_axi_rdata,
    input  wire [1:0]  m_axi_rresp,
    input  wire        m_axi_rvalid,
    output reg         m_axi_rready
);

    assign m_axi_wstrb = 4'b1111;

    localparam IDLE       = 3'd0;
    localparam READ_ADDR  = 3'd1;
    localparam READ_DATA  = 3'd2;
    localparam WRITE_ADDR = 3'd3;
    localparam WRITE_DATA = 3'd4;
    localparam WRITE_RESP = 3'd5;
    localparam ERROR      = 3'd6;

    reg [2:0]  state;
    reg [31:0] current_src;
    reg [31:0] current_dst;
    reg [31:0] bytes_left;
    reg [31:0] data_buffer;

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            state <= IDLE;
            done  <= 1'b0;
            error <= 1'b0;
            m_axi_awvalid <= 1'b0;
            m_axi_wvalid  <= 1'b0;
            m_axi_bready  <= 1'b0;
            m_axi_arvalid <= 1'b0;
            m_axi_rready  <= 1'b0;
        end else begin
            case (state)
                IDLE: begin
                    done  <= 1'b0;
                    error <= 1'b0;
                    if (start) begin
                        current_src <= src_addr;
                        current_dst <= dst_addr;
                        bytes_left  <= length;
                        state       <= (length > 0) ? READ_ADDR : IDLE;
                    end
                end

                READ_ADDR: begin
                    m_axi_araddr  <= current_src;
                    m_axi_arvalid <= 1'b1;
                    if (m_axi_arvalid && m_axi_arready) begin
                        m_axi_arvalid <= 1'b0;
                        m_axi_rready  <= 1'b1;
                        state         <= READ_DATA;
                    end
                end

                READ_DATA: begin
                    if (m_axi_rvalid && m_axi_rready) begin
                        if (m_axi_rresp != 2'b00) begin
                            error <= 1'b1;
                            done  <= 1'b1;
                            state <= IDLE;
                        end else begin
                            data_buffer   <= m_axi_rdata;
                            m_axi_rready  <= 1'b0;
                            state         <= WRITE_ADDR;
                        end
                    end
                end

                WRITE_ADDR: begin
                    m_axi_awaddr  <= current_dst;
                    m_axi_awvalid <= 1'b1;
                    if (m_axi_awvalid && m_axi_awready) begin
                        m_axi_awvalid <= 1'b0;
                        state         <= WRITE_DATA;
                    end
                end

                WRITE_DATA: begin
                    m_axi_wdata  <= data_buffer;
                    m_axi_wvalid <= 1'b1;
                    if (m_axi_wvalid && m_axi_wready) begin
                        m_axi_wvalid <= 1'b0;
                        m_axi_bready <= 1'b1;
                        state        <= WRITE_RESP;
                    end
                end

                WRITE_RESP: begin
                    if (m_axi_bvalid && m_axi_bready) begin
                        m_axi_bready <= 1'b0;
                        if (m_axi_bresp != 2'b00) begin
                            error <= 1'b1;
                            done  <= 1'b1;
                            state <= IDLE;
                        end else begin
                            bytes_left   <= bytes_left - 4;
                            current_src  <= current_src + 4;
                            current_dst  <= current_dst + 4;

                            if (bytes_left <= 4) begin
                                done  <= 1'b1;
                                state <= IDLE;
                            end else begin
                                state <= READ_ADDR;
                            end
                        end
                    end
                end

                default: state <= IDLE;
            endcase
        end
    end
endmodule

6.1 模块接口解析

graph TB
    subgraph DMA["unique_simple_axi_dma"]
        CTRL["控制接口"]
        AXI["AXI-Lite Master 接口"]
    end

    subgraph EXTERNAL["外部"]
        CPU["CPU / Testbench"]
        MEM["AXI-Lite Slave<br/>(内存)"]
    end

    CPU -->|start, src_addr,<br/>dst_addr, length| CTRL
    CTRL -->|done, error| CPU
    AXI -->|awaddr, awvalid...| MEM
    MEM -->|awready, bresp...| AXI
信号 方向(对本模块) 说明
clk Input 系统时钟,上升沿触发
rst_n Input 低电平有效的异步复位
start Input 单次脉冲启动 DMA 传输
src_addr Input 源内存地址(4字节对齐)
dst_addr Input 目标内存地址(4字节对齐)
length Input 要传输的字节数,必须是 4 的倍数
done Output 传输完成标志,高电平有效
error Output 传输中收到 AXI 错误响应

6.2 AXI-Lite 接口信号

所有 m_axi_* 信号构成一个完整的 AXI-Lite Master 接口。Master 主动发起读写请求,Slave(本例中是内存模型)被动响应。

  • aw* 信号:写地址通道(Address Write)
  • w* 信号:写数据通道(Write Data)
  • b* 信号:写响应通道(Write Response)
  • ar* 信号:读地址通道(Address Read)
  • r* 信号:读数据通道(Read Data)

6.3 内部寄存器

寄存器 位宽 用途
state 3 bit 状态机当前状态
current_src 32 bit 当前读地址(会随着传输递增)
current_dst 32 bit 当前写地址(会随着传输递增)
bytes_left 32 bit 剩余待传输字节数
data_buffer 32 bit 从总线读到的数据暂存

6.4 状态机详解

stateDiagram-v2
    [*] --> IDLE : 复位 rst_n=0
    IDLE --> READ_ADDR : start=1 && length>0
    IDLE --> IDLE : start=1 && length=0

    READ_ADDR --> READ_DATA : arvalid && arready

    READ_DATA --> WRITE_ADDR : rvalid && rready && rresp==OKAY
    READ_DATA --> IDLE : rvalid && rready && rresp!=OKAY<br/>[error=1, done=1]

    WRITE_ADDR --> WRITE_DATA : awvalid && awready

    WRITE_DATA --> WRITE_RESP : wvalid && wready

    WRITE_RESP --> READ_ADDR : bvalid && bready && bresp==OKAY<br/>&& bytes_left > 4
    WRITE_RESP --> IDLE : bvalid && bready && bresp==OKAY<br/>&& bytes_left <= 4<br/>[done=1]
    WRITE_RESP --> IDLE : bvalid && bready && bresp!=OKAY<br/>[error=1, done=1]

    ERROR --> IDLE : [通过 error 分支]

状态流转详解:

  1. IDLE:空闲状态。收到 startlength > 0 时,保存配置参数,进入 READ_ADDR
  2. READ_ADDR:发送读地址(araddr = current_srcarvalid = 1)。当 Slave 返回 arready = 1 时,握手成功,进入 READ_DATA
  3. READ_DATA:等待 Slave 返回数据(rvalid = 1)。收到数据后检查 rresp
    • 若为 OKAY(00):保存数据到 data_buffer,进入 WRITE_ADDR
    • 若为错误:置位 errordone,回到 IDLE
  4. WRITE_ADDR:发送写地址(awaddr = current_dstawvalid = 1)。握手成功后进入 WRITE_DATA
  5. WRITE_DATA:发送写数据(wdata = data_bufferwvalid = 1)。握手成功后进入 WRITE_RESP
  6. WRITE_RESP:等待 Slave 返回写响应(bvalid = 1)。检查 bresp
    • 若为 OKAY:更新地址和剩余字节数。若 bytes_left <= 4 则传输完成(done = 1),否则回到 READ_ADDR 继续下一轮。
    • 若为错误:置位 errordone,回到 IDLE

6.5 代码设计要点

  • 复位处理:所有控制信号(validready)都清零,防止复位后出现虚假的总线请求。
  • 握手逻辑if (valid && ready) 是 AXI 协议的标准写法,表示一次成功的传输。
  • 字节对齐:由于数据宽度是 32 位(4 字节),每次传输固定 4 字节,因此地址每次递增 4。
  • 错误处理:一旦收到非 OKAY 响应,立即停止当前传输,报告错误,不再继续。

7. Makefile

Cocotb 使用 Makefile 驱动仿真流程。下面是针对 Verilator 的完整 Makefile。

创建文件 Makefile

# 仿真器设置
SIM ?= verilator
TOPLEVEL_LANG ?= verilog

PWD=$(shell pwd)

# RTL 源代码文件
VERILOG_SOURCES += $(PWD)/unique_simple_axi_dma.v

# 顶层模块与 Testbench 名称
TOPLEVEL = unique_simple_axi_dma
MODULE = test_dma

# 开启 Verilator 覆盖率和波形生成
EXTRA_ARGS += --coverage --trace

# 引入 Cocotb 默认规则
include $(shell cocotb-config --makefiles)/Makefile.sim

# 自定义目标:生成带有源码标注的覆盖率报告
coverage_report:
	mkdir -p logs/annotated
	verilator_coverage --annotate logs/annotated coverage.dat

# 自定义目标:使用 lcov 生成 HTML 覆盖率报告
lcov_report: coverage_report
	verilator_coverage --write-info coverage.info coverage.dat
	genhtml coverage.info --output-directory logs/html

7.1 关键配置说明

变量 说明
SIM ?= verilator 指定仿真器为 Verilator
TOPLEVEL DUT 顶层模块名,必须和 Verilog 的 module 名一致
MODULE Python Testbench 文件名(不含 .py
EXTRA_ARGS 传给 Verilator 的额外参数,--coverage 开启覆盖率,--trace 生成 VCD 波形
coverage_report 自定义目标,调用 verilator_coverage 生成源码标注报告

8. Cocotb Testbench 详解

下面是完整的 Python Testbench。它包含 8 个测试用例,覆盖了正常传输、大长度传输、零长度、多次传输、复位、非法状态恢复,以及读写错误响应注入。

创建文件 test_dma.py

import cocotb
from cocotb.clock import Clock
from cocotb.triggers import RisingEdge, Timer, ClockCycles
from cocotbext.axi import AxiLiteBus, AxiLiteRam


async def reset_dma(dut):
    """复位 DMA 模块"""
    dut.rst_n.value = 0
    dut.start.value = 0
    dut.src_addr.value = 0
    dut.dst_addr.value = 0
    dut.length.value = 0
    await Timer(50, unit="ns")
    dut.rst_n.value = 1
    await RisingEdge(dut.clk)


async def wait_done(dut, timeout=1000):
    """等待 DMA 完成,带超时保护"""
    for _ in range(timeout):
        if dut.done.value == 1:
            return True
        await RisingEdge(dut.clk)
    return False


@cocotb.test()
async def test_dma_transfer(dut):
    """测试基本 AXI DMA 模块的 Memory-to-Memory 数据搬运"""
    cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
    await reset_dma(dut)

    axi_bus = AxiLiteBus.from_prefix(dut, "m_axi")
    axi_ram = AxiLiteRam(axi_bus, dut.clk, dut.rst_n, size=0x1000)

    test_data = b'\x11\x22\x33\x44\xAA\xBB\xCC\xDD'
    src_address = 0x100
    dst_address = 0x200

    axi_ram.write(src_address, test_data)
    dut._log.info(f"已向源地址 {hex(src_address)} 写入测试数据: {test_data.hex()}")

    dut.src_addr.value = src_address
    dut.dst_addr.value = dst_address
    dut.length.value = len(test_data)
    dut.start.value = 1
    await RisingEdge(dut.clk)
    dut.start.value = 0

    dut._log.info("等待 DMA 传输完成...")
    done = await wait_done(dut)
    assert done, "DMA 传输超时!"
    assert dut.error.value == 0, "正常传输不应产生 error 信号"

    read_back_data = axi_ram.read(dst_address, len(test_data))
    dut._log.info(f"从目标地址 {hex(dst_address)} 读回数据: {read_back_data.hex()}")
    assert read_back_data == test_data, f"数据不匹配! 期望值: {test_data}, 实际值: {read_back_data}"
    dut._log.info("验证通过:DMA 数据搬运完全正确!")

    await ClockCycles(dut.clk, 10)


@cocotb.test()
async def test_dma_large_transfer(dut):
    """测试大长度 DMA 传输(多轮读写循环)"""
    cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
    await reset_dma(dut)

    axi_bus = AxiLiteBus.from_prefix(dut, "m_axi")
    axi_ram = AxiLiteRam(axi_bus, dut.clk, dut.rst_n, size=0x2000)

    length = 32
    src_address = 0x100
    dst_address = 0x400
    test_data = bytes([i % 256 for i in range(length)])

    axi_ram.write(src_address, test_data)

    dut.src_addr.value = src_address
    dut.dst_addr.value = dst_address
    dut.length.value = length
    dut.start.value = 1
    await RisingEdge(dut.clk)
    dut.start.value = 0

    done = await wait_done(dut, timeout=2000)
    assert done, "大长度 DMA 传输超时!"
    assert dut.error.value == 0, "正常传输不应产生 error 信号"

    read_back_data = axi_ram.read(dst_address, length)
    assert read_back_data == test_data, f"大数据传输不匹配!"
    dut._log.info(f"大长度 ({length} 字节) DMA 传输验证通过!")

    await ClockCycles(dut.clk, 10)


@cocotb.test()
async def test_dma_zero_length(dut):
    """测试 length=0 时 DMA 不应启动传输"""
    cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
    await reset_dma(dut)

    dut.src_addr.value = 0x100
    dut.dst_addr.value = 0x200
    dut.length.value = 0
    dut.start.value = 1
    await RisingEdge(dut.clk)
    dut.start.value = 0

    await ClockCycles(dut.clk, 5)

    assert dut.done.value == 0, "length=0 时不应产生 done 信号"
    assert dut.state.value == 0, "length=0 时状态应保持在 IDLE"
    dut._log.info("length=0 测试通过:DMA 正确保持在 IDLE 状态。")

    await ClockCycles(dut.clk, 5)


@cocotb.test()
async def test_dma_multiple_transfers(dut):
    """测试连续多次 DMA 传输"""
    cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
    await reset_dma(dut)

    axi_bus = AxiLiteBus.from_prefix(dut, "m_axi")
    axi_ram = AxiLiteRam(axi_bus, dut.clk, dut.rst_n, size=0x2000)

    for i in range(3):
        src = 0x100 + i * 0x40
        dst = 0x400 + i * 0x40
        data = bytes([0xAA + i, 0xBB + i, 0xCC + i, 0xDD + i])

        axi_ram.write(src, data)

        dut.src_addr.value = src
        dut.dst_addr.value = dst
        dut.length.value = len(data)
        dut.start.value = 1
        await RisingEdge(dut.clk)
        dut.start.value = 0

        done = await wait_done(dut)
        assert done, f"第 {i+1} 次 DMA 传输超时!"
        assert dut.error.value == 0, f"第 {i+1} 次传输不应产生 error"

        read_back = axi_ram.read(dst, len(data))
        assert read_back == data, f"第 {i+1} 次传输数据不匹配!"
        dut._log.info(f"第 {i+1} 次 DMA 传输验证通过。")

    await ClockCycles(dut.clk, 10)


@cocotb.test()
async def test_dma_reset_during_idle(dut):
    """测试在 IDLE 状态下复位"""
    cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
    await reset_dma(dut)

    assert dut.state.value == 0
    assert dut.done.value == 0
    assert dut.error.value == 0
    dut._log.info("IDLE 状态下复位测试通过。")

    await ClockCycles(dut.clk, 5)


@cocotb.test()
async def test_dma_default_state_recovery(dut):
    """通过强制注入非法状态,验证 default 分支能正确回到 IDLE"""
    cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
    await reset_dma(dut)

    # 强制 state 寄存器到一个非法值 (7)
    dut.state.value = 7
    await RisingEdge(dut.clk)
    await RisingEdge(dut.clk)

    assert dut.state.value == 0, "非法状态应在 default 分支回到 IDLE"
    dut._log.info("default 分支恢复测试通过。")

    await ClockCycles(dut.clk, 5)


async def axi_lite_slave_read_error(dut, rresp_err=2):
    """手动驱动 AXI-Lite 读通道返回错误响应"""
    # 等待 arvalid
    while dut.m_axi_arvalid.value != 1:
        await RisingEdge(dut.clk)
    # 立即返回 arready
    dut.m_axi_arready.value = 1
    await RisingEdge(dut.clk)
    dut.m_axi_arready.value = 0

    # 返回 rvalid + 错误 rresp
    dut.m_axi_rdata.value = 0xDEADBEEF
    dut.m_axi_rresp.value = rresp_err
    dut.m_axi_rvalid.value = 1
    while dut.m_axi_rready.value != 1:
        await RisingEdge(dut.clk)
    await RisingEdge(dut.clk)
    dut.m_axi_rvalid.value = 0


@cocotb.test()
async def test_dma_read_error_response(dut):
    """测试读通道收到非 OKAY 响应时 DMA 报错"""
    cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
    await reset_dma(dut)

    dut.src_addr.value = 0x100
    dut.dst_addr.value = 0x200
    dut.length.value = 4
    dut.start.value = 1
    await RisingEdge(dut.clk)
    dut.start.value = 0

    await axi_lite_slave_read_error(dut, rresp_err=2)  # SLVERR

    done = await wait_done(dut)
    assert done, "错误响应下 DMA 应完成并报错"
    assert dut.error.value == 1, "收到 SLVERR 应置位 error"
    dut._log.info("读错误响应测试通过:error 正确置位。")

    await ClockCycles(dut.clk, 5)


async def axi_lite_slave_read_ok(dut):
    """手动驱动 AXI-Lite 读通道返回正常响应"""
    while dut.m_axi_arvalid.value != 1:
        await RisingEdge(dut.clk)
    dut.m_axi_arready.value = 1
    await RisingEdge(dut.clk)
    dut.m_axi_arready.value = 0

    dut.m_axi_rdata.value = 0xCAFEBABE
    dut.m_axi_rresp.value = 0
    dut.m_axi_rvalid.value = 1
    while dut.m_axi_rready.value != 1:
        await RisingEdge(dut.clk)
    await RisingEdge(dut.clk)
    dut.m_axi_rvalid.value = 0


async def axi_lite_slave_write_error(dut, bresp_err=3):
    """手动驱动 AXI-Lite 写通道返回错误响应"""
    # 等待 awvalid
    while dut.m_axi_awvalid.value != 1:
        await RisingEdge(dut.clk)
    # 返回 awready
    dut.m_axi_awready.value = 1
    await RisingEdge(dut.clk)
    dut.m_axi_awready.value = 0

    # 等待 wvalid
    while dut.m_axi_wvalid.value != 1:
        await RisingEdge(dut.clk)
    # 返回 wready
    dut.m_axi_wready.value = 1
    await RisingEdge(dut.clk)
    dut.m_axi_wready.value = 0

    # 返回 bvalid + 错误 bresp
    dut.m_axi_bresp.value = bresp_err
    dut.m_axi_bvalid.value = 1
    while dut.m_axi_bready.value != 1:
        await RisingEdge(dut.clk)
    await RisingEdge(dut.clk)
    dut.m_axi_bvalid.value = 0


@cocotb.test()
async def test_dma_write_error_response(dut):
    """测试写通道收到非 OKAY 响应时 DMA 报错"""
    cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
    await reset_dma(dut)

    dut.src_addr.value = 0x100
    dut.dst_addr.value = 0x200
    dut.length.value = 4
    dut.start.value = 1
    await RisingEdge(dut.clk)
    dut.start.value = 0

    # 同时启动读通道(正常响应)和写通道(错误响应)的 slave 协程
    cocotb.start_soon(axi_lite_slave_read_ok(dut))
    await axi_lite_slave_write_error(dut, bresp_err=3)  # DECERR

    done = await wait_done(dut)
    assert done, "错误响应下 DMA 应完成并报错"
    assert dut.error.value == 1, "收到 DECERR 应置位 error"
    dut._log.info("写错误响应测试通过:error 正确置位。")

    await ClockCycles(dut.clk, 5)

8.1 Testbench 整体架构

flowchart TB
    subgraph TB_PYTHON["Testbench (Python)"]
        direction TB

        subgraph UTILS["辅助函数"]
            RESET["reset_dma()<br/>产生复位序列"]
            WAIT["wait_done()<br/>等待 done 或超时"]
        end

        subgraph TESTS["测试用例 (@cocotb.test)"]
            T1["test_dma_transfer<br/>基本传输"]
            T2["test_dma_large_transfer<br/>大长度循环"]
            T3["test_dma_zero_length<br/>边界条件"]
            T4["test_dma_multiple_transfers<br/>连续多次"]
            T5["test_dma_reset_during_idle<br/>复位检查"]
            T6["test_dma_default_state_recovery<br/>非法状态"]
            T7["test_dma_read_error_response<br/>读错误注入"]
            T8["test_dma_write_error_response<br/>写错误注入"]
        end

        subgraph VIP["VIP / Slave 模型"]
            RAM["AxiLiteRam<br/>正常内存响应"]
            SLAVE_R["axi_lite_slave_read_error<br/>返回 SLVERR"]
            SLAVE_W["axi_lite_slave_write_error<br/>返回 DECERR"]
            SLAVE_OK["axi_lite_slave_read_ok<br/>返回正常数据"]
        end
    end

    DUT["DUT: unique_simple_axi_dma"]

    T1 -->|配置并启动| DUT
    T2 -->|配置并启动| DUT
    T3 -->|配置并启动| DUT
    T4 -->|配置并启动| DUT
    T5 -->|复位| DUT
    T6 -->|force state| DUT
    T7 -->|配置并启动| DUT
    T8 -->|配置并启动| DUT

    DUT <-->|AXI-Lite<br/>正常读写| RAM
    DUT <-->|AXI-Lite<br/>读错误| SLAVE_R
    DUT <-->|AXI-Lite<br/>写错误| SLAVE_W
    DUT <-->|AXI-Lite<br/>正常读| SLAVE_OK

8.2 辅助函数解析

reset_dma(dut)

async def reset_dma(dut):
    dut.rst_n.value = 0          # 拉低复位
    dut.start.value = 0          # 确保 start 为 0
    dut.src_addr.value = 0
    dut.dst_addr.value = 0
    dut.length.value = 0
    await Timer(50, unit="ns")  # 等待 50ns
    dut.rst_n.value = 1          # 释放复位
    await RisingEdge(dut.clk)   # 等待一个时钟上升沿,确保复位已同步释放

dut 是 “Device Under Test” 的缩写,代表你的 RTL 顶层模块实例。Cocotb 会自动将 Verilog 信号映射为 Python 对象。

wait_done(dut, timeout=1000)

async def wait_done(dut, timeout=1000):
    for _ in range(timeout):
        if dut.done.value == 1:
            return True
        await RisingEdge(dut.clk)
    return False

这是一个带超时保护的轮询函数。每个时钟上升沿检查一次 done 信号,如果超过 timeout 个周期还没完成,就返回 False,防止测试卡死。

8.3 测试用例详解

test_dma_transfer — 基本功能测试

sequenceDiagram
    participant PY as Python TB
    participant RAM as AxiLiteRam
    participant DUT as DMA

    PY->>RAM: write(0x100, b'\x11\x22...')
    PY->>DUT: src_addr=0x100, dst=0x200, len=8
    PY->>DUT: start=1
    DUT->>RAM: AXI Read addr=0x100
    RAM-->>DUT: AXI Read data=0x11223344
    DUT->>RAM: AXI Write addr=0x200, data=0x11223344
    RAM-->>DUT: AXI Write OK
    DUT->>RAM: AXI Read addr=0x104
    RAM-->>DUT: AXI Read data=0xAABBCCDD
    DUT->>RAM: AXI Write addr=0x204
    RAM-->>DUT: AXI Write OK
    DUT-->>PY: done=1
    PY->>RAM: read(0x200, 8)
    RAM-->>PY: b'\x11\x22\x33\x44\xAA\xBB\xCC\xDD'
    PY->>PY: assert 数据匹配

这是最基本的” happy path “测试:先通过 VIP 把数据写入源地址,然后启动 DMA,最后从目标地址读回数据并比对。

test_dma_large_transfer — 大长度循环测试

32 字节数据需要 32 / 4 = 8 轮读写循环。这个测试验证状态机能否正确循环。

test_dma_zero_length — 边界条件测试

length = 0 是一个典型的边界条件。好的设计应该在这种情况下什么都不做,保持在 IDLE。

为什么边界条件很重要? 因为许多 bug 都发生在”正常情况”和”异常情况”的交界处。

test_dma_multiple_transfers — 连续传输测试

验证 DMA 在完成一次传输后,能否立即接受下一次启动,而不需要重新复位。

test_dma_default_state_recovery — 容错测试

通过 dut.state.value = 7 强制把状态机注入一个非法值(本设计只有 0~6 是合法状态),验证 default 分支能否正确恢复。

sequenceDiagram
    participant TB as Testbench
    participant DUT as DMA 状态机

    TB->>DUT: force state = 7 (非法)
    Note over DUT: 时钟上升沿采样
    DUT->>DUT: case 进入 default 分支
    DUT-->>TB: state = 0 (IDLE)
    TB->>TB: assert state == 0

test_dma_read_error_response — 错误注入测试

使用手动 Slave 协程代替 AxiLiteRam,在收到读请求后返回 rresp = SLVERR (2'b10)

async def axi_lite_slave_read_error(dut, rresp_err=2):
    while dut.m_axi_arvalid.value != 1:    # 等待 DMA 发起读地址
        await RisingEdge(dut.clk)
    dut.m_axi_arready.value = 1             # 立即响应地址
    await RisingEdge(dut.clk)
    dut.m_axi_arready.value = 0

    dut.m_axi_rdata.value = 0xDEADBEEF     # 随便给个数据
    dut.m_axi_rresp.value = rresp_err      # 返回错误响应!
    dut.m_axi_rvalid.value = 1
    while dut.m_axi_rready.value != 1:      # 等待 DMA 接收数据
        await RisingEdge(dut.clk)
    await RisingEdge(dut.clk)
    dut.m_axi_rvalid.value = 0

这个协程就是一个最简单的 AXI-Lite Slave 行为模型:等待请求 -> 握手 -> 返回响应。

test_dma_write_error_response — 写错误注入测试

与读错误类似,但需要同时运行一个正常读 Slave 协程和一个错误写 Slave 协程:

cocotb.start_soon(axi_lite_slave_read_ok(dut))   # 后台协程:处理读请求
await axi_lite_slave_write_error(dut, bresp_err=3) # 前台协程:处理写请求

cocotb.start_soon() 启动一个后台协程,它会和主测试并行执行。这模拟了真实 SoC 中多个通道同时工作的场景。

8.4 Cocotb 核心概念速查

概念 代码示例 说明
定义测试 @cocotb.test() 装饰器标记这是一个测试用例
访问信号 dut.clk.value 读取信号当前值
赋值信号 dut.rst_n.value = 0 给信号赋值(下一个 delta 周期生效)
等待时间 await Timer(50, unit="ns") 等待 50 纳秒
等待时钟沿 await RisingEdge(dut.clk) 等待时钟上升沿
启动协程 cocotb.start_soon(coro()) 后台启动一个协程
启动时钟 cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start()) 自动生成时钟
断言 assert condition, "message" 条件不满足时测试失败
日志 dut._log.info("message") 打印仿真日志

9. 运行仿真

确保你的终端位于项目目录下,且已激活安装了 cocotbcocotbext-axi 的 Python 环境。

9.1 编译与仿真

make clean
make SIM=verilator

如果一切正常,你会看到如下输出:

** TESTS=8 PASS=8 FAIL=0 SKIP=0                           2610.01           0.01     179385.23  **

9.2 生成覆盖率报告

make coverage_report

这会生成 logs/annotated/unique_simple_axi_dma.v,其中每一行前面都标注了执行次数。

例如:

 000269     always @(posedge clk or negedge rst_n) begin
 000229         if (!rst_n) begin
 000040             state <= IDLE;
...
%000001                 default: state <= IDLE;
  • 左侧数字表示该行被执行的次数。
  • %000001 表示该行只执行了 1 次(对应 default 分支的非法状态恢复测试)。
  • 如果看到 %000000,说明该行从未被执行,需要补充测试用例。

9.3 生成 HTML 覆盖率报告(需 lcov)

如果你已经安装了 lcov(见 5.3 安装 lcov),可以运行:

make lcov_report

这会执行以下两步:

  1. 先调用 make coverage_report,生成源码标注文件。
  2. 再调用 verilator_coverage --write-info 将 Verilator 覆盖率数据转换为 lcov 格式的 .info 文件,然后用 genhtml 生成可在浏览器中查看的 HTML 报告。

生成完成后,打开 logs/html/index.html

# Ubuntu / WSL
xdg-open logs/html/index.html

# macOS
open logs/html/index.html

你将看到一个带有颜色标注的网页:

  • 绿色:已执行的代码行
  • 红色:未执行的代码行
  • 黄色:部分执行的分支

这种方式比纯文本的 --annotate 输出更直观,尤其适合向团队或评审展示验证完备性。


10. 常见问题排查

10.1 ModuleNotFoundError: No module named 'cocotb'

原因:Python 环境中未安装 cocotb,或 Makefile 调用的 Python 与安装环境不一致。

解决

which python
python -m pip install cocotb cocotbext-axi

确保 make 调用的 python3 就是你安装包的那个 Python。

10.2 Verilator 编译错误 undefined reference to 'VL_*'

原因:Verilator 版本过旧(如 4.x)。

解决:升级到 Verilator 5.0+,并按照第 5 节步骤重新编译安装。

10.3 cocotb.handle 没有 ForceRelease

原因:Cocotb 2.x 已弃用部分旧 API。

解决:本教程已使用兼容 Cocotb 2.x 的写法(如 dut.state.value = 7 直接 force),无需额外导入。

10.4 波形文件太大

原因--trace 会生成 VCD 波形。

解决:若不需要波形,可从 EXTRA_ARGS 中移除 --trace;若只需覆盖率,保留 --coverage 即可。


11. 扩展建议

  1. 增加随机测试:使用 randomcocotb-coverage 库生成随机地址和长度,进一步提升覆盖率。
  2. 测试延迟场景:在手动 Slave 协程中加入随机 Timer 延迟,验证 DMA 对 AXI ready 信号等待的鲁棒性。
  3. 接入 CI/CD:将 make clean && make SIM=verilator 写入 GitHub Actions 或 GitLab CI,实现自动化回归。

12. 总结

本项目完整展示了:

  • DMA 的基本概念:为什么需要 DMA、Memory-to-Memory 传输流程。
  • AXI-Lite 协议基础:VALID/READY 握手机制、读写通道分离、响应编码。
  • 数字验证的核心思想:通过 Testbench 模拟各种场景,在流片前发现 bug。
  • 如何用 Verilog 实现一个简单的 AXI-Lite Master DMA:状态机设计、错误处理、复位策略。
  • 如何用 Cocotb + cocotbext-axi 编写 Python Testbench:VIP 使用、手动 Slave 协程、错误注入、并行协程。
  • 如何用 Verilator 进行编译仿真并收集行覆盖率

所有代码均已在本教程中完整给出,复制到对应文件即可直接运行。

结语

第三百八十二篇博文写完,开心!!!!

今天,也是充满希望的一天。


文章作者: LuYF-Lemon-love
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 LuYF-Lemon-love !
  目录