ZYNQ PS侧AXI DMA驱动开发中的缓存一致性与传输顺序优化
1. 缓存一致性:DMA数据传输的“隐形杀手”
大家好,我是老王,在嵌入式系统里摸爬滚打了十几年,尤其喜欢折腾ZYNQ这类PS+PL的异构平台。今天想和大家深入聊聊一个在ZYNQ PS侧开发AXI DMA驱动时,几乎人人都会踩、但又至关重要的坑:缓存一致性问题。这玩意儿不处理好,你的数据传输轻则出错,重则系统卡死,而且现象还特别诡异,可能今天跑得好好的,明天就出错了,让人头大。
简单来说,在ZYNQ的PS端(也就是那个ARM处理器)里,为了提升性能,CPU访问内存时会经过一个叫数据缓存(Data Cache) 的东西。你可以把它想象成CPU身边的一个“小仓库”。CPU要读数据时,会先看看“小仓库”里有没有,有就直接拿,速度快得很;要写数据时,也常常是先写到“小仓库”里,之后再找个合适的时机批量搬回主内存(DDR)。这个机制对CPU性能提升巨大,但对DMA来说,就是个“灾难”。
为什么呢?因为DMA(直接内存访问)这家伙,是个“耿直boy”,它干活不经过CPU,也完全不知道“小仓库”的存在。DMA读写数据,是直接对着主内存(DDR)的地址操作的。这就出问题了:当你让DMA把数据从内存发送出去时,如果CPU最新修改的数据还躺在“小仓库”里没同步到DDR,那DMA发出去的就是DDR里的老数据,也就是“脏数据”。反过来,当DMA从外设接收数据写到DDR后,如果CPU不去“小仓库”里把对应的旧数据标记为失效,而是直接去读“小仓库”,那读到的就是过期数据。
我当年就吃过这个亏。做一个图像传感器数据采集的项目,PS用DMA从PL侧的FIFO收数据。代码写好了,一跑,收到的图像全是错乱的雪花点,或者干脆是上一帧的残留图像。排查了半天硬件时序、DMA配置都没问题,最后才恍然大悟,是Cache在作祟。PS端使能了Data Cache,但我在启动DMA接收前,忘记告诉Cache:“嘿,这片内存区域我要直接操作了,你里面的旧数据不管用了,赶紧失效掉!”
所以,在ZYNQ SDK里编程,只要你的系统使能了Data Cache(默认通常是开启的),那么在启动DMA传输前后,必须手动进行缓存同步操作。这就像在两个独立工作的部门(CPU和DMA)之间设立一个可靠的“通信官”。
1.1 核心操作:Flush与Invalidate
解决缓存一致性,靠的是两个核心函数:Xil_DCacheFlushRange 和 Xil_DCacheInvalidateRange。别看名字唬人,理解起来很简单。
Xil_DCacheFlushRange (刷出):这个动作是针对**发送缓冲区(TxBuffer)**的。当你已经把要发送的数据填入了TxBuffer,在启动DMA发送之前,你必须调用这个函数。它的作用是:“小仓库管理员,请立刻把你负责的、从TxBuffer地址开始、长度为Length的这片区域里,所有被CPU修改过但还没存回DDR的数据(脏数据),强制写回DDR!” 这样,DMA再去DDR里读数据时,拿到的就是最新、最正确的版本了。
Xil_DCacheInvalidateRange (失效):这个动作是针对**接收缓冲区(RxBuffer)**的。在启动DMA接收之前,你需要调用这个函数。它的作用是:“小仓库管理员,请你把从RxBuffer地址开始、长度为Length的这片区域里,缓存的所有数据标记为‘无效’或‘过期’。” 因为接下来DMA会直接把新数据写到DDR的这片区域,你缓存里的旧数据已经没意义了。标记为无效后,CPU下次需要读这块数据时,就会乖乖地去DDR里取最新的、由DMA刚写进来的数据,而不是读缓存里的陈年旧货。
这里有个非常关键的顺序问题,也是新手容易搞混的:Flush(刷出)在发送前做,Invalidate(失效)在接收前做。顺序反了或者漏了,数据必然出错。我习惯在代码里把它们紧挨着DMA启动函数放置,形成肌肉记忆。
// 准备发送的数据已经存放在TxBuffer中
// 1. 刷出发送缓存,确保DMA读到的是最新数据
Xil_DCacheFlushRange((UINTPTR)TxBuffer, TX_LENGTH);
// 2. 失效接收缓存,为DMA写入新数据做准备
Xil_DCacheInvalidateRange((UINTPTR)RxBuffer, RX_LENGTH);
// 3. 启动DMA传输(注意顺序,后面会讲)
XAxiDma_SimpleTransfer(&AxiDma, (UINTPTR)RxBuffer, RX_LENGTH, XAXIDMA_DEVICE_TO_DMA);
XAxiDma_SimpleTransfer(&AxiDma, (UINTPTR)TxBuffer, TX_LENGTH, XAXIDMA_DMA_TO_DEVICE);
1.2 内存地址的对齐与分配
除了调用同步函数,缓冲区本身的位置也有讲究。千万不要把DMA缓冲区定义在函数内部的局部变量(栈空间)上。栈空间地址不确定,且生命周期短,函数结束可能就释放了,而DMA传输是异步的,还在访问那块内存,结果就是内存访问错误,系统崩溃。
正确的做法是,将缓冲区定义为全局数组,或者使用动态分配(如malloc),并确保其位于DDR内存中。更稳妥的方法是使用Xilinx提供的Xil_Out32等函数或确保地址通过DDR控制器可访问。另外,AXI总线通常有对齐要求,虽然最新的IP和驱动可能处理了非对齐访问,但为了最佳性能和避免潜在问题,强烈建议将缓冲区地址按4字节(32位)或更高(如64位)对齐。malloc通常返回对齐的内存,但如果你自己指定地址,需要注意。
一个实用的技巧是使用编译器属性来强制对齐:
// 定义一个1KB的缓冲区,并强制64字节对齐(对Cache行友好)
u8 TxBuffer[1024] __attribute__ ((aligned(64)));
u8 RxBuffer[1024] __attribute__ ((aligned(64)));
对齐到Cache行大小(通常为32或64字节)可以减少不必要的缓存操作,提升Flush和Invalidate的效率。
2. 传输顺序优化:先接后发,还是先发后接?
解决了缓存一致性问题,数据传输流程就顺畅了一半。但另一半,则取决于一个看似简单却至关重要的决策:先启动DMA接收,还是先启动DMA发送? 原始文章里提到了“先启动接收,再启动发送”,这是一个非常重要的经验法则,但它并非铁律。理解其背后的原理,才能在各种场景下灵活应用。
2.1 为什么“先接收后发送”是通用法则?
让我们拆解一下DMA的两种传输方向:
XAXIDMA_DMA_TO_DEVICE(TX/发送):数据从PS侧的内存(DDR)搬移到PL侧的外设(如FIFO、自定义IP)。XAXIDMA_DEVICE_TO_DMA(RX/接收):数据从PL侧的外设搬移到PS侧的内存。
想象一个典型的“环回测试”场景:PS发送数据给PL,PL不做处理,直接原路返回给PS。如果你的代码顺序是:
- 启动TX发送。
- 数据立刻从内存流向PL。
- PL逻辑(比如一个简单的直通逻辑)收到数据后,可能立即就想通过RX通道送回来。
- 但此时,RX通道还没启动!DMA的接收端没有准备好内存地址来存放数据。
- 结果:PL送回来的数据找不到归宿,被丢弃,传输失败。
所以,“先启动接收”的本质,是先让数据的“目的地”(RxBuffer)准备就绪,再开放数据的“源头”(TxBuffer)。这就像你先在目的地放好一个空桶,再打开水龙头,水(数据)才能顺利流到桶里。如果先开水龙头,水可能就流到地上(丢失)了。
2.2 不同场景下的顺序策略
然而,实际项目千变万化,不能一概而论。我们需要根据PL侧逻辑的行为模式来决定顺序。
场景一:被动回送型(懒加载)
这是官方示例和很多简单环回测试采用的方式。PL侧的逻辑设计是:只有当我确实从AXI Stream接口收到了数据(检测到TVALID和TREADY握手成功),我才开始启动回送流程。在这种设计下,先启动RX是完全安全且推荐的。因为即使RX先启动了,DMA拉高了TREADY,但PL侧的TVALID为低(没数据),传输并不会发生,DMA的接收通道会安静地等待。直到TX启动,数据流入PL,PL才产生回送数据的TVALID,此时握手成功,数据开始接收。整个过程严丝合缝。
场景二:主动发送型(自动发射)
这种场景下,PL侧可能连接着一个ADC采样模块,或者一个一旦有数据就迫不及待要发送的FIFO。例如,一个深度优先的FIFO,其m_axis_tvalid信号直接由!fifo_empty驱动。只要FIFO里有数据,它就立刻拉高TVALID试图发送。对于这种情况,就必须先启动TX(甚至只涉及RX)。因为系统一上电或复位后,PL可能已经在往FIFO里灌数据了。如果你不先启动DMA接收准备好“接水桶”,FIFO里的数据一产生就会因为无人接收而丢失。正确的顺序是:先启动RX,让DMA准备好接收,然后再允许或触发PL开始产生数据。
场景三:单向传输型
- 纯接收:比如PS持续采集PL传感器数据。只需要关注RX,顺序问题简化为“在数据产生前启动好RX”。
- 纯发送:比如PS向PL发送配置参数。只需要关注TX,但发送前别忘了
Flush缓存。
为了更清晰,我们可以用一个表格来总结:
| PL侧逻辑行为模式 | 推荐启动顺序 | 关键原因与风险 |
|---|---|---|
| 被动回送/懒加载 (收到数据才触发发送) | 先 RX, 后 TX | RX先启动后空等,安全。TX触发数据流后,RX即刻接收,无丢失。 |
| 主动发送/自动发射 (FIFO有数据即发) | 先 RX, 后触发TX (或仅启动RX) | 必须确保数据产生前,接收方已就绪。否则先产生的数据会丢失。 |
| 双向流水线 (收发持续进行) | 先启动RX通道 | 建立稳定的接收链路是双向通信的基础,发送的数据流应答需要有地方存放。 |
| 纯接收场景 (如ADC采集) | 仅启动RX (需提前启动) | 顺序不涉及TX,核心是确保DMA接收在数据流开始前已准备。 |
| 纯发送场景 (如发送命令) | 仅启动TX (发送前Flush缓存) | 顺序不涉及RX,核心是保证缓存数据已同步至内存。 |
2.3 如何判断PL逻辑的类型?
如果你不确定自己的PL逻辑是“被动”还是“主动”,最好的办法是抓波形。使用Vivado的ILA(集成逻辑分析仪)抓取AXI-Stream接口的关键信号:
s_axis_tvalid/s_axis_tready:观察数据何时送入PL。m_axis_tvalid/m_axis_tready:观察PL何时试图送出数据。
如果发现一上电或复位后,m_axis_tvalid就周期性拉高(即使PS还没启动DMA),那你的逻辑就是“主动发送型”,必须优先保证RX启动。如果m_axis_tvalid一直为低,直到m_axis_tready拉高并有过一次tvalid & tready握手后才有动作,那可能就是“被动型”。
3. 深入AXI-Stream握手与背压机制
理解了顺序策略,我们还需要深入到协议层,明白为什么“先启动RX空等”是可行的,这关乎AXI4-Stream协议的握手机制。
3.1 TREADY与TVALID的舞蹈
AXI4-Stream数据传输的核心是TVALID(数据有效)和TREADY(接收就绪)这一对握手信号。只有当TVALID和TREADY在同一个时钟周期同时为高时,数据才会被成功传输。这是一个非常重要的特性。
当你调用XAxiDma_SimpleTransfer(..., XAXIDMA_DEVICE_TO_DMA)启动接收时,DMA所做的就是配置好内部状态机和目标内存地址,然后将AXI-Stream接口的TREADY信号拉高,意思是“我准备好了,有数据就可以给我”。此时,如果PL侧的FIFO是空的(TVALID为低),那么握手条件不满足,没有数据传输发生,DMA只是静静地等待。这不会导致任何错误。
这就是“先启动RX”安全的根本原因。DMA的接收行为是被动的、基于握手的,它不会去主动“抢”数据。只有当数据提供方(PL)主动说“我有数据了”(TVALID=1),并且DMA也正好“准备好了”(TREADY=1),传输才发生。
3.2 背压与FIFO溢出防护
这就引出了另一个关键问题:如果PL侧数据产生太快,DMA来不及接收怎么办? 这就是背压(Backpressure)问题。在“主动发送型”场景中尤其需要关注。
AXIS Data FIFO IP的行为是这样的:
- 当它内部非空且下游
TREADY为高时,它会拉高M_AXIS_TVALID并输出数据。 - 当它内部快满时,它会拉低
S_AXIS_TREADY,告诉上游数据源:“我快满了,别再送了!”
这个S_AXIS_TREADY就是背压信号。你的PL侧自定义逻辑在向FIFO写数据时,必须严格遵循这个握手。也就是说,你的逻辑应该像这样设计:
always @(posedge clk) begin
if (my_data_valid && s_axis_tready) begin // 确保有数据且FIFO ready时才发送
s_axis_tvalid <= 1'b1;
s_axis_tdata <= my_data;
end else begin
s_axis_tvalid <= 1'b0;
end
end
如果你的逻辑无视S_AXIS_TREADY,在FIFO已满时还强行拉高TVALID发送数据,这些数据就会被FIFO丢弃,造成永久性丢失。因此,一个健壮的PL设计,其数据流必须能够响应来自下游的背压信号。
4. 实战:构建健壮的DMA驱动框架
理论说了一大堆,最后我们来落地,看看一个考虑周全的ZYNQ PS侧AXI DMA驱动框架应该包含哪些部分。我会以一个双向环回测试为例,但融入缓存一致性和顺序优化的思想。
4.1 初始化与配置
首先,是标准的DMA初始化和缓存同步准备。这里我特别强调错误检查,很多初级驱动忽略了这一点。
#include "xaxidma.h"
#include "xil_cache.h"
#define DMA_DEV_ID XPAR_AXIDMA_0_DEVICE_ID
#define MEM_BASE_ADDR XPAR_PS7_DDR_0_S_AXI_BASEADDR // DDR基地址
#define TX_BUFFER_SIZE 1024
#define RX_BUFFER_SIZE 1024
// 使用对齐属性定义缓冲区
static u8 tx_buffer[TX_BUFFER_SIZE] __attribute__ ((aligned(64)));
static u8 rx_buffer[RX_BUFFER_SIZE] __attribute__ ((aligned(64)));
XAxiDma dma_inst;
int dma_init() {
XAxiDma_Config *cfg_ptr;
int status;
// 1. 查找配置
cfg_ptr = XAxiDma_LookupConfig(DMA_DEV_ID);
if (!cfg_ptr) {
xil_printf("ERROR: DMA config lookup failed.\r\n");
return XST_FAILURE;
}
// 2. 初始化DMA实例
status = XAxiDma_CfgInitialize(&dma_inst, cfg_ptr);
if (status != XST_SUCCESS) {
xil_printf("ERROR: DMA initialization failed: %d\r\n", status);
return XST_FAILURE;
}
// 3. 检查是否为Simple模式(本例假设是)
if (XAxiDma_HasSg(&dma_inst)) {
xil_printf("INFO: DMA is in SG mode. Simple transfer functions may not work.\r\n");
// 需要初始化BD环等,这里简化处理
}
// 4. 复位DMA,确保干净的状态(可选但推荐)
XAxiDma_Reset(&dma_inst);
while (!XAxiDma_ResetIsDone(&dma_inst)); // 等待复位完成
// 5. 初始化缓冲区数据(示例:填充发送缓冲区)
for (int i = 0; i < TX_BUFFER_SIZE; i++) {
tx_buffer[i] = i & 0xFF;
}
return XST_SUCCESS;
}
4.2 执行一次安全的传输
接下来是执行单次传输的函数。这里综合了缓存操作、顺序控制和完成等待。
int dma_transfer_test() {
int status;
xil_printf("--- Starting DMA Transfer Test ---\r\n");
// ===== 关键步骤1: 缓存同步 =====
// 确保发送数据已从Cache写回DDR
Xil_DCacheFlushRange((UINTPTR)tx_buffer, TX_BUFFER_SIZE);
// 确保接收缓存区在Cache中失效,CPU会从DDR读新数据
Xil_DCacheInvalidateRange((UINTPTR)rx_buffer, RX_BUFFER_SIZE);
// ===== 关键步骤2: 启动传输(顺序根据场景调整) =====
// 本例假设PL为被动回送型,采用先RX后TX的通用安全顺序
xil_printf("Starting DMA Receive...\r\n");
status = XAxiDma_SimpleTransfer(&dma_inst, (UINTPTR)rx_buffer,
RX_BUFFER_SIZE, XAXIDMA_DEVICE_TO_DMA);
if (status != XST_SUCCESS) {
xil_printf("ERROR: Failed to start RX transfer: %d\r\n", status);
return XST_FAILURE;
}
// 短暂延时(可选),确保RX通道完全就绪。对于高速系统,通常不需要。
// usleep(10);
xil_printf("Starting DMA Transmit...\r\n");
status = XAxiDma_SimpleTransfer(&dma_inst, (UINTPTR)tx_buffer,
TX_BUFFER_SIZE, XAXIDMA_DMA_TO_DEVICE);
if (status != XST_SUCCESS) {
xil_printf("ERROR: Failed to start TX transfer: %d\r\n", status);
return XST_FAILURE;
}
// ===== 关键步骤3: 等待传输完成 =====
xil_printf("Waiting for transfers to complete...\r\n");
// 轮询方式等待发送完成
while (XAxiDma_Busy(&dma_inst, XAXIDMA_DMA_TO_DEVICE)) {
// 可以在这里加入超时机制,防止死等
}
// 轮询方式等待接收完成
while (XAxiDma_Busy(&dma_inst, XAXIDMA_DEVICE_TO_DMA)) {
// 超时机制
}
xil_printf("DMA transfers completed.\r\n");
// ===== 关键步骤4: 传输后缓存处理 =====
// 接收完成后,由于之前Invalidate过,CPU可直接读取rx_buffer。
// 但更严谨的做法是,在读取接收数据前,再次失效缓存,确保读到DMA刚写入的数据。
// 实际上,由于我们之前Invalidate了整个范围,且传输期间CPU未读取该区域,所以可以省略。
// 如果传输过程中CPU可能访问过该内存,则需要再次Invalidate。
// Xil_DCacheInvalidateRange((UINTPTR)rx_buffer, RX_BUFFER_SIZE);
// ===== 验证数据 =====
xil_printf("Verifying received data...\r\n");
for (int i = 0; i < TX_BUFFER_SIZE; i++) {
if (tx_buffer[i] != rx_buffer[i]) {
xil_printf("ERROR: Data mismatch at index %d! Tx=0x%02x, Rx=0x%02x\r\n",
i, tx_buffer[i], rx_buffer[i]);
return XST_FAILURE;
}
}
xil_printf("SUCCESS: Data verification passed!\r\n");
return XST_SUCCESS;
}
4.3 系统启动时序与长期运行考量
最后,我们谈谈系统层面的考虑。在真实的项目中,DMA驱动不是跑一次就完事的。
上电初始化时序:从硬件上电到你的main()函数开始执行,中间经历了FPGA配置、Bootloader引导、操作系统加载等过程,可能需要几百毫秒到几秒。在这段时间里,如果你的PL逻辑已经开始疯狂产生数据,而PS侧的DMA驱动还未初始化,就会导致FIFO溢出、数据丢失。 解决方案有两种:一是在PL设计中加入使能信号,由PS在DMA初始化完成后通过GPIO或寄存器配置来启动PL数据流;二是确保PL逻辑的初始状态是空闲的,并且其数据源受控。
长期运行与中断:对于简单的测试,轮询等待XAxiDma_Busy是可以的。但对于需要持续传输、高吞吐率或低CPU占用的应用,必须使用中断模式。你需要配置PS的通用中断控制器(GIC),为DMA的发送完成和接收完成中断分别设置服务函数。在中断服务程序(ISR)中,进行缓存同步、缓冲区切换、通知主线程等操作。中断模式能极大解放CPU,但程序结构会更复杂,要小心处理竞态条件和缓冲区管理。
错误恢复:一个工业级的驱动还需要考虑DMA传输错误(如总线错误、长度不匹配)的检测与恢复。XAxiDma_SimpleTransfer会返回状态,但传输过程中的错误可能需要检查DMA IP的内部状态寄存器。一旦检测到错误,合理的流程是:复位DMA通道、重新初始化缓冲区、重新启动传输。
更多推荐


所有评论(0)