本教程从零开始,面向初学者,带你理解 DMA、AXI 总线、数字验证 的基本概念,并一步步搭建基于 Cocotb、cocotbext-axi VIP 和 Verilator 的仿真环境,实现一个简易的 AXI-Lite DMA IP,编写完整的 Python Testbench 进行功能验证与覆盖率收集。
目录
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 握手:当 valid 和 ready 同时为高时,数据传输发生。
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)完成后,不能直接把代码烧到芯片里。因为:
- 流片成本极高:一次芯片制造(流片)可能需要数百万美元,且耗时数月。
- bug 修复困难:芯片制造完成后发现 bug,只能重新设计、重新流片。
- 逻辑错误难以肉眼发现:复杂的状态机、时序交互问题,单纯看代码很难发现。
因此,我们需要在流片前,通过仿真来穷尽各种场景,确保设计功能正确。
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 的
rresp或bresp,则拉高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 分支]
状态流转详解:
- IDLE:空闲状态。收到
start且length > 0时,保存配置参数,进入READ_ADDR。 - READ_ADDR:发送读地址(
araddr = current_src,arvalid = 1)。当 Slave 返回arready = 1时,握手成功,进入READ_DATA。 - READ_DATA:等待 Slave 返回数据(
rvalid = 1)。收到数据后检查rresp:- 若为 OKAY(00):保存数据到
data_buffer,进入WRITE_ADDR。 - 若为错误:置位
error和done,回到IDLE。
- 若为 OKAY(00):保存数据到
- WRITE_ADDR:发送写地址(
awaddr = current_dst,awvalid = 1)。握手成功后进入WRITE_DATA。 - WRITE_DATA:发送写数据(
wdata = data_buffer,wvalid = 1)。握手成功后进入WRITE_RESP。 - WRITE_RESP:等待 Slave 返回写响应(
bvalid = 1)。检查bresp:- 若为 OKAY:更新地址和剩余字节数。若
bytes_left <= 4则传输完成(done = 1),否则回到READ_ADDR继续下一轮。 - 若为错误:置位
error和done,回到IDLE。
- 若为 OKAY:更新地址和剩余字节数。若
6.5 代码设计要点
- 复位处理:所有控制信号(
valid、ready)都清零,防止复位后出现虚假的总线请求。 - 握手逻辑:
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. 运行仿真
确保你的终端位于项目目录下,且已激活安装了 cocotb 和 cocotbext-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
这会执行以下两步:
- 先调用
make coverage_report,生成源码标注文件。 - 再调用
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 没有 Force 或 Release
原因:Cocotb 2.x 已弃用部分旧 API。
解决:本教程已使用兼容 Cocotb 2.x 的写法(如 dut.state.value = 7 直接 force),无需额外导入。
10.4 波形文件太大
原因:--trace 会生成 VCD 波形。
解决:若不需要波形,可从 EXTRA_ARGS 中移除 --trace;若只需覆盖率,保留 --coverage 即可。
11. 扩展建议
- 增加随机测试:使用
random或cocotb-coverage库生成随机地址和长度,进一步提升覆盖率。 - 测试延迟场景:在手动 Slave 协程中加入随机
Timer延迟,验证 DMA 对 AXI ready 信号等待的鲁棒性。 - 接入 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 进行编译仿真并收集行覆盖率。
所有代码均已在本教程中完整给出,复制到对应文件即可直接运行。
结语
第三百八十二篇博文写完,开心!!!!
今天,也是充满希望的一天。