ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

XDMA PCIe DMA 实战:H2C/C2H、描述符与寄存器排错

XDMA PCIe DMA 实战:H2C/C2H、描述符与寄存器排错

简介:这份PDF是面向FPGA与PCIe开发者的Xilinx XDMA IP核中文学习笔记,围绕DMA/Bridge Subsystem for PCI Express v4.1展开,适合正在做XDMA配置、驱动联调或阅读PG195时查漏补缺的工程人员。内容按概述、描述符、可配置功能、中断机制、H2C/C2H传输流程、端口定义、寄存器空间、时钟复位、设计指南、Example设计与Test Bench、Linux驱动应用等章节整理,并涉及BARs配置、MSI/MSI-X、AXI-MM与AXI-ST模式、SGDMA寄存器等关键知识点。压缩包仅含1个PDF文件,大小约174KB,轻量便于随查随用。已有4215人学习,可作为理解官方英文文档、梳理XDMA数据通路与调试思路的中文参考。

1. 从一份中文笔记切入:XDMA 究竟替你做了哪些事

板卡插进主机,lspci 能认到设备,BAR 也枚举正常,DMA 一启动却停在半路——做 Xilinx XDMA(pci-e DMA/Bridge Subsystem)的人多半撞过这一幕。v4.1 的英文手册体量大,寄存器按 Target/Channel/Offset 三段编码散在各章,端口定义又按方向成对列,查一个 0x4080 得翻三处。这份中文读书笔记把概述、可配置功能、中断、DMA 操作、端口定义、寄存器空间、时钟复位、设计指南、Example 与 Test Bench、Linux 驱动应用开发逐节整理,保留了描述符格式、地址对齐、中断时序这些真正卡人的细节。做采集卡、加速卡、需要 H2C/C2H 高速搬运的 FPGA 与驱动工程师,以及刚接手 PCIe 项目、想先建立全局图的人,都适合从它入手。

2. 三条通路怎么分工:H2C、C2H 与 Bypass 的选型边界

XDMA 在工程里最容易出问题的地方,往往就在于一开始没想清楚哪条数据该走哪条通路。有人把寄存器读写挂到 DMA 上,结果每次配置都要等一个描述符;也有人把大块 DDR 搬运塞进 Bypass,带宽上不去还占满了主机写通道。先把三条通路的分工拆开,后面所有寄存器和描述符操作才有落点。

2.1 H2C 与 C2H:两个方向的数据流骨架

H2C 是 Host to Card,主机内存到 FPGA。数据从主机内存出发,经 PCIe RX、PCIe IP、RQ/RC(Requester Request / Requester Completion),进入 H2C channel,最后从 AXI Write 接口写进用户逻辑。C2H 反过来,用户逻辑从 AXI Read 接口出数据,进 C2H channel,经 RQ/RC、PCIe IP 的 TX 送到主机指定的目标地址。两条通路在 XDMA 内部各自独立计数、独立中断,互不阻塞。

XDMA 内部最多可以配置 8 路独立的物理 DMA 引擎,4 路 H2C 加 4 路 C2H。这些引擎有两条落地方式:映射到各自的 AXI4-ST 接口,或者共享一个 AXI4-MM 接口。AXI-MM 是单一 Master 接口,所有通道在同一个出口上仲裁;AXI-ST 则是每通道一组独立的 tdata/tvalid/tready/tlast,可以直接怼到 RTL 模块上。

选型上我一般这么切:用户逻辑侧是 DDR 控制器、片上 BRAM 这类带地址的存储体,走 AXI-MM,一块连续地址一次搬完,仲裁开销只发生一次;用户逻辑是流水线出来的采样数据、视频像素流、网络包,走 AXI-ST,通道之间天然并行,也不用在 RTL 里再写一套地址生成。两边都要,就把一部分通道配成 MM、一部分配成 ST,IP 允许这么混搭。

接口形态通道数用户侧连接方式典型场景
AXI4-MM多通道共享一个 Master带地址的存储体、DDR大块连续搬运、批量回传
AXI4-ST每通道独立直连 RTL 流式模块采集流、像素流、包处理
AXI4-Lite Master1 路,32bit 固定用户逻辑配置/状态寄存器参数下发、状态回读
AXI-MM Bypass1 路,位宽同 DMA高带宽用户 memorypeer-to-peer、低延迟小包

还有两条非 DMA 的旁路要单独记。Cfg Master 就是 AXI4-Lite Master,固定 32bit,主机通过它读写用户逻辑里的控制和状态寄存器,速率要求不高但延迟要小。另一条是 AXI MM Master CQ Bypass,位宽和 DMA channel 一致,主机写请求打到“PCIe to DMA bypass BAR”之后,数据直接从 M_AXI_BYPASS 出去,不经过 DMA 引擎、不生成描述符。做板间 peer-to-peer 或者几十字节的频繁交互时,这条路比走 DMA 省掉一整圈描述符开销。

2.2 描述符:一次 DMA 的全部指令都塞在这 32 字节里

XDMA 靠描述符链表描述一次搬运:源地址、目标地址、长度、下一块在哪、要不要中断。链表由主机驱动在内存里建好,驱动只写几个寄存器把首地址和数量告诉 DMA,剩下的由 DMA 自己取。描述符格式是固定 8 个 32bit 字,共 32 字节:

偏移字段说明
0x00Magic[15:0] / Rsv[1:0] / Nxt_adj[5:0] / control[7:0]Magic 固定 0xad4b,用于校验描述符有效
0x04Len[27:0]本次搬运字节数
0x08Src_adr[31:0]源地址低 32 位
0x0cSrc_adr[63:32]源地址高 32 位
0x10Dst_adr[31:0]目标地址低 32 位
0x14Dst_adr[63:32]目标地址高 32 位
0x18Nxt_adr[31:0]下一描述符地址低 32 位
0x1cNxt_adr[63:32]下一描述符地址高 32 位

control 字节里有三位要重点看:bit0 是 Stop,取到这一块就停止继续取描述符;bit1 是完成中断使能,这一块搬完要不要发中断;bit4 是 EOP(End of Packet),只在 AXI-ST 模式下有意义,决定 tlast 什么时候拉高。Nxt_adj 是下一描述符地址的邻近调整量,配合连续分配的描述符块使用,能少写一次完整地址。

DMA 内部有一块深度为 Bit_width × 512 的 FIFO 专门缓存描述符。以 AXI 位宽 256bit 为例,就是 256bit × 512 = 16KB,够放 512 条 32 字节的描述符。这个深度决定了一次预取能吞下多少块,描述符链表拉得很长时,FIFO 满了 DMA 会自己停下等,不会丢。

用 C 描述这块内存时,结构体定义和对齐得抠一下:

/* 32 字节描述符,必须按 32 字节对齐,避免跨 cache line 拆开 */ struct xdma_desc { uint32_t ctrl; /* [15:0]=0xAD4B [13:8]=Nxt_adj [7:0]=control */ uint32_t len; /* [27:0]=字节数,高位保留 */ uint32_t src_lo; /* 源地址低 32 位 */ uint32_t src_hi; /* 源地址高 32 位 */ uint32_t dst_lo; /* 目标地址低 32 位 */ uint32_t dst_hi; /* 目标地址高 32 位 */ uint32_t nxt_lo; /* 下一描述符低 32 位 */ uint32_t nxt_hi; /* 下一描述符高 32 位 */ } __attribute__((aligned(32))); static inline uint32_t xdma_desc_ctrl(uint8_t nxt_adj, uint8_t control) { return (0xAD4Bu) | ((uint32_t)(nxt_adj & 0x3F) << 8) | (control & 0xFF); }

这段代码里三个点值得说明。第一,0xAD4B必须落在最低 16 位,DMA 取到描述符后第一件事就是比对这 16 位,对不上整条链直接作废,现象是“DMA 启动了但一个字节都没动”。第二,nxt_adj左移 8 位,它占用 bit13:8,和 control 的 bit7:0 拼在同一个字里,写错位置的表现是描述符取到第二块就跳到错误地址。第三,aligned(32)不是洁癖,描述符被 cache line 劈成两半时,主机侧 DMA 读到的可能是半新半旧的组合,尤其是驱动刚改完链表还没回写内存的时候。

Descriptor Bypass 是另一条路:使能之后描述符不再由主机驱动提供,而是从 c2h_dsc_byp / h2c_dsc_byp 这组总线直接给源地址、目标地址和长度。适合 FPGA 侧自己知道该搬什么的场景,比如固定长度的周期性采集,省掉主机来回写寄存器的往返。

2.3 位宽、地址对齐与 Length Granularity

对齐这条规则在 XDMA 里是硬约束,不是建议。位宽决定地址低几位必须为 0,也决定长度按多少字节取整:

AXI 位宽源/目标地址约束Length 约束
64bitaddr[2:0] == 3'h0len[2:0] == 3'h0
128bitaddr[3:0] == 4'h0len[3:0] == 4'h0
256bitaddr[4:0] == 5'h0len[4:0] == 5'h0
512bitaddr[5:0] == 6'h0len[5:0] == 6'h0

笔记里 128bit 那一行把 Length 写成了Length[2:0] == 4'h0,位宽对不上,按 16 字节对齐推应该是Length[3:0] == 4'h0,读的时候自己纠正一下。这条不是吹毛求疵:位宽 256bit 时长度按 32 字节取整,如果应用层传下来一个 1000 字节的 buffer,最后 8 字节要么被截掉,要么 DMA 多搬一段踩到隔壁内存,两种现象都很难从日志里看出来。

Length Granularity 只对 AXI-ST 流数据生效,AXI-MM 模式看的是字节数本身。写驱动时我习惯在入口处直接做一次断言,长度不满足粒度就返回错误,别让它带着问题进 DMA。

奇偶校验是可选项。使能 Check Parity 和 Propagate Parity 之后,校验位宽是 DATA_WIDTH/8。AXI-ST 模式下走*_tuser端口,AXI-MM 模式下走*_ruser和*_wuser。IP 不替你算校验,只是把校验信息透传到用户 AXI 接口,生成和检查都得在用户逻辑里自己做。打算开这个功能,就在 RTL 侧留好对应的 user 位宽,别等综合报位宽不匹配再返工。

3. 寄存器地图怎么读:BAR 编号、三段编码与启动序列

寄存器这一块是整份笔记里密度最高的部分,也是最容易照着抄还抄错的地方——因为地址不是连续的,而是 Target、Channel、Offset 三段拼出来的。搞不清这套编码,看到 0x4080 和 0x5080 只会觉得是两个随机数。

3.1 BAR 的默认布局与一个高频错误

XDMA 的 BAR 分配跟 BAR 位宽(32bit / 64bit)和是否使能 Bypass 强相关,默认组合有这么几种:

BAR 位宽配置BAR0BAR1 / BAR2BAR2 / BAR4
32bit默认PCIe to AXI4-Lite MasterDMA—
32bit使能 BypassDMAPCIe to DMA Bypass—
64bit默认PCIe to AXI4-Lite Master—BAR2 DMA,BAR4 Bypass
64bit使能 BypassDMA—BAR2 DMA(Bypass 合并)

最容易踩的是 64bit BAR 占两个 BAR 号这件事。IP 里 BAR2 配成 64bit,实际在 PCIe 配置空间里 BAR2 和 BAR3 是一体的,BAR4 往后顺延。驱动里如果写死pci_resource_start(pdev, 2)去拿 Bypass 的基址,拿到的其实还是 DMA 那块,现象是往 Bypass 地址写数据,波形上 M_AXI_BYPASS 一点动静没有,反而 DMA 通道开始报异常。上板先用lspci -vv把每个 Region 的起始地址和大小打出来,跟 IP 配置界面逐个对齐,五分钟能省掉半天。

AXI4-Lite Slave 这个口也常被忽略。它让用户逻辑反过来读写 XDMA 内部寄存器,比如用户逻辑想在数据攒够时自己去读一下通道状态,不用等主机来问。位宽固定 32bit,地址空间就是上面那张寄存器表。

3.2 PCIe to DMA Address Format:把地址拆成三段

主机侧看到的 DMA 寄存器地址不是线性偏移,而是一个 32bit 编码:bit31:16 保留,bit15:12 是 Target,bit11:8 是 Channel,bit7:0 是 Byte Offset。Target 的取值定义如下:

Target含义
0x0H2C Channels
0x1C2H Channels
0x2IRQ Block
0x3Config Block
0x4H2C SGDMA
0x5C2H SGDMA
0x6SGDMA Common
0x8MSI-X Vector Table 和 PBA

Channel 字段在 Channel 类 Target 下表示具体是哪一路通道,0 到 3;在 IRQ Block 这类共享 Target 下,Channel 位另有含义或者干脆填 0。书里只给 Byte Offset 部分,就是因为高位得自己按目标拼。

拼法用几行代码表达最清楚:

def xdma_addr(target: int, channel: int, offset: int) -> int: """把 (target, channel, byte offset) 拼成主机侧 32bit 寄存器地址""" return ((target & 0xF) << 12) | ((channel & 0xF) << 8) | (offset & 0xFF) # 核对几个文档里出现的地址 assert xdma_addr(0x0, 0, 0x04) == 0x0004 # H2C ch0 控制寄存器 assert xdma_addr(0x1, 0, 0x04) == 0x1004 # C2H ch0 控制寄存器 assert xdma_addr(0x2, 0, 0x44) == 0x2044 # IRQ Block 通道中断请求 assert xdma_addr(0x4, 0, 0x80) == 0x4080 # H2C SGDMA 首描述符地址低 32 位 assert xdma_addr(0x5, 0, 0x80) == 0x5080 # C2H SGDMA 首描述符地址低 32 位

函数本身没什么技术含量,价值在于把“背地址”变成“算地址”。多通道场景下这个区别很明显:4 路 H2C 同时跑,每一路的控制寄存器都是同一个 offset,只有 channel 字段不同,靠背地址迟早写串。参数上要注意 offset 只有 8 位,所以单个 Target 下的寄存器空间被限制在 256 字节以内,超出的部分一定换了 Target 或者换了编码方式,别硬加。

3.3 从驱动加载到 DMA 启动的完整序列

中断使能是启动前的第一件事,顺序错了中断来了也进不去。典型做法是三步:先写 H2C 通道的中断使能 mask(offset 0x90,即地址 0x0090),再写 C2H 的(0x1090),最后写 IRQ Block 的 Channel Interrupt Enable Mask(0x2010),把 H2C 和 C2H 全部通道一次性放开。这三步的粒度不一样:前两个是通道级,决定哪一路通道能发中断;第三个是模块级总闸,不开的话前面两处配了也传不出去。

H2C 的一次完整搬运按下面的顺序走,每一步对应一个具体动作:

  1. 应用层给出传输长度和主机侧 buffer 地址,驱动按对齐要求切块,每块生成一个描述符。
  2. 驱动把首描述符的基地址写到 0x4080(低 32 位)和 0x4084(高 32 位)。
  3. 如果是多块传输,把后续描述符数量写到 0x4088。
  4. 写 H2C 引擎控制寄存器 0x0004,置启动位。
  5. DMA 发起描述符取指请求,从主机内存把描述符读进内部 FIFO。
  6. DMA 按描述符里的源地址向主机发读请求,收回数据。
  7. 数据从 AXI-MM Master 接口写进用户逻辑,或从 AXI4-ST 接口流出去。
  8. 搬运完成,DMA 向主机发中断。
  9. 驱动在中断处理里读 0x2044 判断是哪一路通道,读 0x0044 清状态,读 0x0048 拿已完成描述符数量跟预期比对,写 0x0004 停引擎,写 0x2014 重新放开中断使能,最后把实际传输字节数返回给应用层。

C2H 是对称的,只换地址:首描述符基地址写 0x5080 / 0x5084,数量写 0x5088,启动写 0x1004;中断处理里读 0x1044 清状态、0x1048 比对完成数量、0x1004 停引擎。注意 C2H 描述符里的源地址是 FPGA 侧地址,目标地址才是主机内存;H2C 正好反过来。这两个字段写反了不会报错,只会把数据搬到莫名其妙的地方,然后驱动超时。

0x0044 那个“读即清”的行为值得单独说一句。状态寄存器不是普通只读寄存器,读一次就把当前状态清掉,驱动里如果用调试打印多读了一遍,中断处理拿到的就是空值,表现为“中断进来了但判断不出是哪路通道”。我一般在中断里只读一次,先把值存到局部变量再慢慢解析。

3.4 停止、复位与性能计数器

停 DMA 不是把控制寄存器的启动位清掉就完事。正确做法是先停引擎(写 0x0004 或 0x1004),再确认通道状态里的 busy 位掉了,最后才释放描述符内存。顺序反了的话,DMA 可能还在用那块描述符内存,主机侧把它回收再分配出去,下一次传输读到的是别人的数据。

通道状态接口 h2c_sts / c2h_sts 各 8 位,位定义一致:bit0 busy、bit1 Descriptor completed、bit2 Descriptor stop、bit3 Descriptor Done、bit4 Packet Done、bit5 IRQ event pending、bit6 Control register run。调试时把这 8 位一起打印出来,比只看中断计数有用得多——比如 bit6 是 1 但 bit0 一直是 0,说明控制寄存器写了但引擎没真正跑起来,通常是描述符地址或 Magic 有问题。

每个通道还带一组性能监控寄存器,offset 0xC0 到 0xD0:控制、周期计数高低 32 位、数据计数高低 32 位。使能之后跑一段,用数据计数除以周期计数再乘时钟频率,就是这段传输的实际带宽。这个数比在应用层掐秒表准,因为它只统计 DMA 真正在搬数据的时间,不含驱动调度和中断延迟。

时钟和复位这一块,笔记给的是设计注意事项而不是代码。要点在于 axi_aclk 是 PCIe 给 AXI 侧的驱动时钟,sys_clk 是参考时钟,两者不同源;dma_bridge_resetn 只有在 SOFT_RESET_EN 参数打开时才需要接。用户逻辑如果自己有时钟域,跨时钟域处理得放在用户侧,别指望 XDMA 内部替你做。

4. 中断与流式接口:MSI-X 向量、EOP 与写回格式

中断这部分,文档写得比较克制,但实际调试时它是耗时最多的环节。原因不复杂:中断不通,你还有轮询兜底;中断通了但时序不对,会出现“只来一次”“来了停不下来”“来了但找不到是哪路”这些更难查的现象。

4.1 Legacy 中断为什么必须等第二次 ack

Legacy 中断的行为约束写在 usr_irq_req 和 usr_irq_ack 这对信号上。用户逻辑拉高 usr_irq_req,XDMA 往 PCIe Block 发中断,主机进中断服务程序,处理后用户逻辑看到 usr_irq_ack 拉高,把 req 拉低。到这里看起来很正常,坑在下一步:req 拉低之后不能立刻重新拉高,必须等到 usr_irq_ack 第二次拉高,才能发下一个中断。

原因不难理解。第一次 ack 表示主机开始处理这个中断,第二次 ack 表示主机已经从处理程序里退出来了。中间这段时间如果再拉 req,中断会被合并或者丢掉,用户逻辑还以为自己发出去了。所以用户逻辑里光靠“清中断寄存器”不够,还得有一个状态机专门盯第二次 ack。

注意:Legacy 模式下 usr_irq_req 拉低后立刻重新拉高,是中断丢失最常见的原因,波形上表现为 req 有脉冲但主机侧计数不增加。

另外用户逻辑一般需要一个主机可读写的寄存器来告知“中断已经处理完”,因为主机进没进中断服务程序,FPGA 侧是看不见的。常见做法是主机在 ISR 末尾往用户寄存器写一个魔数,用户逻辑读到这个魔数才认为本轮结束。

4.2 MSI/MSI-X 与用户中断向量编程

MSI 和 MSI-X 的时序宽松得多:usr_irq_req 拉低之后不需要等第二次 ack,可以直接重新拉高。这也是新设计默认选 MSI-X 的原因之一,用户逻辑侧的状态机简单一大截。

MSI-X 模式下有个容易漏的配置:每一个用户中断都要在 IRQ Block User Vector Number 寄存器里编程一个不同的矢量号,主机侧才能区分是哪个源头发的中断。而所有用户中断共用且只有一个 usr_irq_ack 信号——也就是说 ack 不区分向量,收到 ack 时用户逻辑得自己知道刚才发的是哪一个。设计上我一般给每个中断源一个独立的 pending 位,ack 来了按优先级清,别指望 ack 带回来身份信息。

MSI 还有个 msi_vector_width 参数,决定 MSI 消息里数据字段的位宽。这个值要和主机侧驱动里的配置对上,不对的话中断能进来但向量号解析错,表现为中断处理函数进错分支。

4.3 AXI-ST 模式下 EOP、tlast 与 C2H 写回

AXI-ST 模式下描述符的语义和 MM 模式不一样,这是很多人第一次用 ST 时最不适应的地方。

H2C 走 ST:数据从描述符指定的主机源地址读出来,从 m_axis_h2c 接口流出去。描述符里的目标地址字段不被使用,因为流没有地址,目标是 FPGA 里的一个 FIFO。一个数据包可以跨多个描述符,靠 control 字节里的 EOP 位标记包结束,EOP 会让 m_axis_h2c_tlast 拉高一个 beat。

C2H 走 ST:数据从 s_axis_c2h 进来,写到描述符指定的主机目标地址,描述符里的源地址字段不被使用。每完成一个描述符,C2H 通道会往指定的 writeback address 写回 8 个字节:

偏移内容
0x0WB Magic[15:0] = 0x52b4,Rsv[14:0],Status(含 EOP 标志)
0x4Length[31:0],本次实际传输长度

写回 magic 是 0x52b4,和描述符的 0xad4b 不是一回事,别看串。驱动侧收到完成中断后,除了读寄存器,还要去 writeback address 校验这个 magic,magic 不对说明写回还没落地或者被踩了。

注意:C2H 流数据的长度必须始终是 64 字节的整数倍。这条是硬约束,不是对齐建议。

长度不满足 64 字节整数倍时,最轻的现象是最后一个包写回长度对不上,重一点是通道卡住不再取描述符。我在应用层入口处会强制把长度向上取整到 64,多出来的部分在协议里标记为无效填充,比让 DMA 自己处理异常省事。

4.4 Poll 模式:不要中断也能收完成

中断链路调不通时,Poll 模式是很好的兜底方案,也是高吞吐场景下降低中断开销的手段。原理很直接:DMA 每完成一个描述符,就把已完成数量写回主机内存指定地址,驱动轮询这个地址就行。

开启需要两个动作:把 ie_descriptor_completed 置起来,同时使能 Pollmode_wb_enable,然后用 Pollmode_hi_wb_addr 和 Pollmode_lo_wb_addr 两个寄存器指定回写地址。这三个寄存器在 H2C 和 C2H 各自的通道寄存器组里都有,offset 是 0x88 和 0x8C。

轮询的粒度要自己权衡。轮询间隔太密,CPU 全耗在总线上;太疏,完成延迟压不下去。我的经验是先用中断跑通功能,确认数据正确后再切 Poll 模式做压测,两个模式对比出来的带宽差就是中断开销的实际占比。如果差值很小,说明瓶颈在别处,没必要为了省中断把代码复杂化。

5. 排查清单:五类典型翻车与定位手法

下面这五条基本覆盖了我在 XDMA 上见过的大部分问题,按出现频率排序。每一条都写清楚现象、原因和解决路径,照着对一遍通常能定位到具体环节。

5.1 描述符 Magic 正确,DMA 却一个字节都不动

现象:控制寄存器写了启动位,通道状态里 bit6(Control register run)是 1,但 bit0(busy)始终是 0,主机侧内存没有任何变化,也没有中断。

原因通常有三个层次。第一层是描述符基地址写错,0x4080 和 0x4084 的高低 32 位搞反了,64 位系统上地址高 32 位通常非零,反了之后 DMA 去取一个不存在的物理地址;第二层是描述符内存没做物理地址转换,驱动传下去的是虚拟地址;第三层才是 Magic 写错位置,0xad4b 落到了高 16 位。

解决路径:先把通道状态 8 位全打出来,bit6 为 1 而 bit0 为 0 说明引擎收到启动命令但取不到有效描述符,问题在描述符本身;再看 0x0044 状态寄存器有没有异常位;最后用 JTAG 或者 ILA 抓 DMA 发起的第一笔读请求地址,跟预期描述符地址比对。物理地址这一条最隐蔽,驱动里务必用 dma_alloc_coherent 之类的接口拿总线地址,别自己 virt_to_phys。

5.2 跑到一半报 Slave Illegal Burst

现象:传输前几个描述符正常,中途通道报错,中断状态里出现非法 burst 相关位,DMA 停止取后续描述符。

原因:AXI 接口只支持 INCR 类型的 burst,地址递增。描述符链里如果某一块的下一描述符地址是往回跳的,或者用户逻辑侧对 AXI 接口返回了非 INCR 的响应,就会触发这个错误。另一种情况是描述符里的 Nxt_adr 指向了未对齐地址,DMA 按对齐规则取不到。

解决路径:检查描述符链的构造逻辑,确保每一块的 Nxt_adr 都是 32 字节对齐且单调递增(最后一块指回头部形成环路的场景除外,那种要显式用 Stop 位终止);用户逻辑侧确认 AXI Slave 只回 INCR 的 burst 类型。这条错误一旦出现,DMA 不会自己恢复,必须重新初始化通道。

5.3 中断只来一次,之后再也不来

现象:第一次 DMA 完成中断正常进入,处理函数也返回了,但第二次传输完成后主机侧毫无反应,轮询完成计数却发现数据已经搬完了。

原因:Legacy 模式下 usr_irq_req 拉低后没等第二次 usr_irq_ack 就重新拉高,中断被合并或者丢弃;或者中断处理函数里没读状态寄存器 0x0044 做清除,中断源一直挂着,控制器认为上次中断还没处理完;还有一种可能是 IRQ Block 的通道使能 mask 在中断处理里被关掉之后忘了重新打开(0x2014 那次写)。

解决路径:先看是哪种模式,Legacy 就重点查两次 ack 的时序,用 ILA 抓 usr_irq_req 和 usr_irq_ack 的完整波形;MSI-X 模式则重点查状态清除和 mask 恢复。中断处理函数的收尾顺序建议固定成:读请求寄存器判源、读状态寄存器清、读完成计数比对、停引擎、恢复中断使能,一步都别省。

5.4 C2H 回传的数据尾部缺一截

现象:主机侧收到的数据长度比预期少,少的量不大,通常是几十字节,而且每次都少,位置固定在末尾。

原因:C2H 流数据长度必须是 64 字节整数倍,非整数倍时最后一个包被截断或者干脆没发出去;另一种可能是描述符里的 Len 字段没有按位宽粒度取整,比如 256bit 位宽下长度不是 32 的倍数,DMA 按粒度截断。

解决路径:在应用层发出传输请求时就做一次长度规整,向上取整到 64 字节,同时保证描述符 Len 满足对应位宽的粒度要求;驱动侧收到写回信息后校验 WB Magic 和 Length,Length 和预期不一致就报警而不是静默返回。这条我吃过亏,数据少几个字节在上层业务里表现为偶发的解析失败,查了很久才定位到长度规整。

5.5 BAR 映射后读写全是 0xFF

现象:驱动 mmap 了 BAR 空间,读出来的寄存器全是 0xFFFFFFFF,写进去也读不回原值,但设备在 lspci 里显示正常。

原因:BAR 编号算错,64bit BAR 占两个号导致后续 BAR 顺延,代码里写死的编号对不上实际布局;或者 BAR 空间根本没使能,驱动没调 pci_enable_device 就去做映射;还有一种是被 IOMMU 拦了,映射成功但访问被翻译到无效地址。

解决路径:先用 lspci -vv 把每个 Region 的起始地址、大小、是否 64bit 打出来,跟 IP 配置界面逐项对比;确认驱动里 pci_request_regions 和 pci_enable_device 的调用顺序;全 0xFF 这个特征值本身很有信息量,它通常意味着总线返回了 Completer Abort,不是数据错误而是访问压根没落到设备上。

6. 上手验证顺序:先让 example design 跑通,再谈优化

拿到任何一份 PCIe DMA 的笔记或者代码,我的习惯是先不动自己的逻辑,把 example design 原样生成、原样仿真、原样上板跑一遍。这一步看着浪费时间,实际上是把“IP 配置、约束、时钟、驱动”这四个变量一次性锁死,后面出问题时才能确定是自己的用户逻辑还是环境。

仿真阶段重点看状态位而不是看数据。AXI-MM 模式下把 h2c_sts 和 c2h_sts 接到波形窗口上,跑一次传输,观察 bit6 先起、bit0 跟着起、完成后 bit1(Descriptor completed)和 bit3(Descriptor Done)依次置起,最后 bit0 落下。这套时序对了,说明描述符构造和寄存器配置没问题;数据对不对反而是第二步,因为数据错通常是对齐或者长度的问题,比时序问题好查。

AXI-ST 模式的仿真多两个观察点:m_axis_h2c_tlast 是否在 EOP 对应的 beat 上拉高,以及 C2H 的 writeback 地址上是否出现了 0x52b4 开头的 8 个字节。这两个信号是 ST 模式独有的,MM 模式的经验在这里不适用。

上板之后的验证顺序我一般这么排:先跑单描述符、小长度(比如 4KB)的 H2C,确认数据原样到达;再跑多描述符链,确认描述符 FIFO 预取没有问题;然后切 C2H 反向验证;最后才是加大长度、开多通道、上性能计数器。性能计数器那组寄存器(0xC0 到 0xD0)建议在功能验证阶段就打开,它记录的是纯 DMA 搬运时间和数据量,算出来的带宽不受驱动调度干扰,比应用层掐表可信。跑到标称带宽的六七成再去看瓶颈,太早优化容易在错误的方向上使劲。

有个细节值得单独提醒:example design 自动生成时,AXI-MM 和 AXI-ST 是两套不同的顶层,别拿 MM 的约束文件去跑 ST 的工程,引脚对不上会导致综合过但上板链路起不来。

从那以后我每次上板前都强制走一遍这个顺序:状态位仿真、单描述符小包、多描述符链、反向通道、性能计数器,一步不跳。跳过哪一步,后面就得花两倍时间补回来。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进