ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

自定义uiFDMA IP核:基于AXI协议简化DMA寄存器配置与突发传输

自定义uiFDMA IP核:基于AXI协议简化DMA寄存器配置与突发传输

简介:一套面向现场可编程门阵列与片上系统开发者的自定义全双工存储器访问IP核封装文件。该IP核由米联客封装,用于处理可编程逻辑与处理器系统之间的高速大容量数据传输,尤其适合需要频繁读取处理器端双倍速率内存的网络协议报文搬运与实时数据采集应用。压缩包共6个文件,整体仅11KB,其中3个可扩展标记语言文件用于IP核组件定义与接口配置,2个硬件描述语言文件用于核心逻辑实现,另有1个脚本文件用于工程打包与导入,模块划分清晰,二次开发成本低。目前已有2214人学习下载。借助该工程可以直观理解全双工内存访问控制器的配置方式、突发传输流程、中断与异常处理机制,也能将封装好的IP核导入常用FPGA设计环境直接复用,从而缩短开发周期并提高网络数据吞吐能力。该资源非常适合作为自定义总线类IP的设计参考模板。

1. 自定义uiFDMA IP核:不是重复造轮子,是给AXI DMA做减法

很多人在米联客开发板上第一次看到 uiFDMA 这个名字时,第一反应是:Xilinx 官方明明有 AXI DMA 和 AXI VDMA,为什么还要再封装一个?等你真正在 Vivado 里把 VDMA 的寄存器配完,或者因为 AXI Stream 的握手时序对不上而调了两天,就会明白:uiFDMA 不是又一个 IP,它是把官方 DMA 最难用的那部分——寄存器配置、帧同步、突发长度计算、中断管理——全部收进一个黑匣子,对外只暴露几个简单的 AXI4-Lite 寄存器和一组干净的 AXI4-Stream 接口。这篇笔记想讲的不是怎么去下载一个现成的 uiFDMA 然后用起来,而是当你需要在自己的项目里自定义 uiFDMA 时,封装思路是什么、RTL 怎么搭、参数怎么定、哪些地方最容易翻车。适合手里有米联客板子、或者正在做图像采集/高速数据搬移,被官方 IP 的复杂度折磨过的工程师。

2. uiFDMA 的封装思路:读通道、写通道和寄存器映射为什么这样设计

2.1 官方 AXI DMA/VDMA 的痛点:你被寄存器耗尽耐心的那个下午

要理解 uiFDMA 的封装逻辑,先得回忆一下直接用 Xilinx 官方 VDMA 时遇到的那些"体力活"。VDMA 的 AXI4-Lite 寄存器有几十个,从 S2MM_CTRL(0x00)、S2MM_DMACTRL(0x30)到 S2MM_FRMBUF_CNT(0x40)、S2MM_START_ADDR(0x80 起每帧地址都要摆一组),更别说 PARK_PTR、IRQ_MASK、IRQ_STAT 这些中断相关的寄存器了。每次上电你要按固定顺序写一轮:先复位、再配帧缓冲地址、再配帧大小和行/列参数、最后把 DMACTRL 里的 Run 位置 1。顺序写错一个寄存器,DMA 可能直接不干活,而且官方 IP 还不会给你任何提示,只能用 ILA 去抓 AXI 总线一点一点查。一旦图像分辨率变了,帧缓冲地址、行像素数、突发长度要全部重算,纯手工配这些字段非常容易配出"地址写对但长度写错"这种玄学问题。

另一个痛点是 VDMA 的 AXI4-Stream 接口带 sof/eof 帧标志,但它的握手时序在不同版本上表现不完全一样,更新一次 Vivado 版本之后,原来跑得好好的设计可能突然丢帧。你很难分辨是 IP 配置问题、时序问题还是版本兼容问题。uiFDMA 的思路就是把这些脏活封装到 IP 内部:用户侧不直接面对几十个寄存器,而是面对一个简单的控制接口,配置项收敛到 5 到 6 个关键寄存器。它保留 AXI4 主接口去访问 DDR,把 AXI4-Stream 或者 AXI4-FIFO 作为用户侧数据接口,帧同步、突发请求、中断清标志这些逻辑全部内置。

2.2 uiFDMA 的三个核心组成:写通道、读通道和中断控制

从结构上看,自定义 uiFDMA 无外乎三个部分:写通道(用户数据进 DDR)、读通道(DDR 数据出给用户)、中断控制。写通道对应的 AXI 方向是 S2MM(Stream to Memory-Mapped),读通道对应 MM2S(Memory-Mapped to Stream)。有的封装还会在写通道前面加一个 CDC 异步 FIFO,因为用户时钟和 AXI 时钟往往不是一个频率,跨时钟域处理如果交给使用者自己做,又要引入一堆 FIFO IP,不如在 IP 内部统一处理。

这里有一个常见设计选择:用户侧接口到底用 AXI4-Stream 还是 AXI4-FIFO?AXI4-Stream 的好处是标准化,任何支持 AXIS 的外设(比如 VDMA、AXI-Stream Data FIFO、各种视频处理 IP)都能直接对接;缺点是你自己要处理 tvalid/tready/tlast 的握手,特别是 tlast 不拉高就会死等。AXI4-FIFO 接口的好处是简单,读起来像操作一个 FIFO:写使能、写数据、满信号、读使能、读数据、空信号,时序上不用管 AXIS 那套 valid-ready 反压逻辑,对不熟悉 AXI 总线的 FPGA 工程师更友好。米联客封装的 uiFDMA 对不同型号板子给出的接口风格略有差异,但设计理念一致:把复杂时序关在 IP 里面,对外暴露最简单可靠的握手。

2.3 关键寄存器映射:最少需要哪几个控制位

自定义 uiFDMA 时寄存器映射不需要照搬官方 VDMA 那几十个,收敛到下面这张表就基本够用。我一般会预留扩展位,避免后续加功能时地址空间不够用。

偏移地址寄存器名位宽功能描述关键位说明
0x00控制寄存器32启动/停止 DMA、软复位、方向选择bit0 DMA 使能;bit1 软复位;bit2 写通道使能;bit3 读通道使能
0x04状态寄存器32通道忙碌状态、帧完成标志、错误标志bit0 写通道忙;bit1 读通道忙;bit2 帧完成;bit3 FIFO 上溢/下溢;只读
0x08写通道基地址32用户数据写入 DDR 的起始地址必须按 4KB 或 64KB 对齐,取决于 AXI IP 的地址对齐配置
0x0C写通道传输长度32单次突发传输的字节数建议为 AXI 数据位宽(字节数)的整数倍,如 64 位总线则按 8 字节对齐
0x10读通道基地址32DDR 数据读出给用户的起始地址对齐要求同写通道
0x14读通道传输长度32单次突发传输的字节数同上,必须与 FIFO 深度匹配
0x18中断使能寄存器32控制哪些中断源能产生中断输出bit0 写完成中断;bit1 读完成中断;bit2 错误中断
0x1C中断状态寄存器32当前中断状态,写 1 清 0读返回中断状态;写对应位清标志

这套映射表的关键在于控制寄存器里把读和写通道分开使能。实际项目中经常有只写不读或者只读不写的场景,比如 ADC 采集只往 DDR 写、DAC 回放只从 DDR 读,分开使能避免用户不得不写一个空读通道去凑数。状态寄存器里的错误标志位也很重要,FIFO 上溢/下溢、AXI 总线错误都应该锁存,并且可以通过状态寄存器随时查询。如果你想让这个 IP 挂进 Vivado 的 Block Design 里被 PS 端访问,这套寄存器映射完全兼容 AXI4-Lite 从接口的标准读写法,不需要额外封装。

2.4 为什么突突发长度和地址对齐是 uiFDMA 性能的命门

自定义 uiFDMA 时最容易被轻视的两个参数是 AXI 突发长度和地址对齐。AXI4 协议规定突发长度最多 256,但具体数值受总线位宽限制:如果你把 AXI 数据总线配成 64 位(8 字节),256 个突发对应 2048 字节;如果配成 128 位(16 字节),256 个突发对应 4096 字节。AXI 突发地址必须按突发字节数对齐,比如 64 位总线、突发长度 64,那么单次突发传输 512 字节,起始地址必须 512 字节对齐。你从软件上层传来一个 DDR 地址,如果不做对齐检查直接塞给 AXI 地址总线,大概率会触发 AXI 的地址错误甚至 DECERR。

更微妙的是 AXI 协议要求突发不能跨越 4KB 边界。哪怕你的地址是对齐的,如果最后一个突发从 0x1FF00 开始,长度 512 字节,正好跨过 0x20000 这个 4KB 边界,就必须把它拆成两个突发。这些逻辑在状态机设计时必须显式处理,不能假设上层传来的地址和长度都完美。米联客的 uiFDMA 在内部处理了 4KB 边界分割,这是它有价值的原因之一;但当你自定义自己的 uiFDMA 时,千万不要砍掉这个逻辑,否则跑大数据量传输时会间歇性出错,而且这种错用 ILA 抓波形很难抓到,属于典型的不定时翻车。

3. 在 Vivado 里自定义 uiFDMA:从创建 IP 到写通道路 RTL

3.1 创建封装 IP 的正确姿势:Create and Package IP 的两种路径

在 Vivado 里自定义 uiFDMA,第一步不是写 RTL,而是决定用哪种封装方式。Vivado 的 Tools -> Create and Package IP 提供两种模板:一种是创建一个带 AXI4-Lite 从接口和 AXI4 主接口的完整模板,Vivado 自动帮你生成 AXI 读写状态机的骨架;另一种是从当前工程创建,把已经写好的 RTL 模块封装成 IP。对自定义 uiFDMA 来说,我推荐第一种——让 Vivado 生成模板,然后在此基础上改,因为模板里的 AXI4-Lite 从接口处理(地址译码、读数据回传、写寄存器时序)已经是验证过的,你只需要往里面填自己的逻辑。

使用第一种方式时,在 Vivado 里选 Peripherals -> AXI4 Peripheral,然后配置接口数量:一个 S_AXI(32 位 AXI4-Lite 从接口)、一个 M_AXI(AXI4 主接口,用于读写 DDR)、一个 M_AXIS 或者用户自定义接口(用于数据输出给用户逻辑)。Vivado 生成的模板里自带 AXI 读写状态机和 S_AXI 寄存器读写逻辑,省掉了最枯燥的部分。关键的改造点在于:模板默认的 M_AXI 突发逻辑是单笔传输的,你要把它改造成可连续突发、可拆分 4KB 边界的形式,并且要把用户数据流(Stream/FIFO)和 AXI 写数据通道对接起来。

3.2 写通道 RTL:把用户数据搬进 DDR 的最小状态机

下面这段代码是自定义 uiFDMA 写通道的骨架。它实现了最核心的功能:从用户侧读数据,当满足突发条件时发起一次 AXI 写传输。代码做了简化,去掉了跨时钟域 FIFO 和 4KB 边界拆分,只保留主干逻辑。

module uifdma_wr_channel #( parameter AXI_DATA_WIDTH = 64, parameter AXI_ADDR_WIDTH = 32, parameter BURST_LEN = 16 // beat 数 )( input wire axi_clk, input wire axi_rst_n, // AXI 写地址通道 output wire [AXI_ADDR_WIDTH-1:0] m_axi_awaddr, output wire [7:0] m_axi_awlen, output wire [2:0] m_axi_awsize, output wire m_axi_awvalid, input wire m_axi_awready, // AXI 写数据通道 output wire [AXI_DATA_WIDTH-1:0] m_axi_wdata, output wire [AXI_DATA_WIDTH/8-1:0] m_axi_wstrb, output wire m_axi_wlast, output wire m_axi_wvalid, input wire m_axi_wready, // AXI 写响应通道 input wire [1:0] m_axi_bresp, input wire m_axi_bvalid, output wire m_axi_bready, // 用户侧 FIFO 读接口 input wire [AXI_DATA_WIDTH-1:0] user_fifo_dout, output wire user_fifo_rd_en, input wire user_fifo_empty, // 寄存器配置 input wire [AXI_ADDR_WIDTH-1:0] cfg_base_addr, input wire [31:0] cfg_trans_len, // 字节数 input wire cfg_start, output reg busy ); localparam IDLE = 3'd0, WAIT_FIFO = 3'd1, ISSUE_ADDR = 3'd2; localparam SEND_DATA = 3'd3, WAIT_RESP = 3'd4; reg [2:0] state; reg [31:0] byte_cnt; reg [7:0] burst_cnt; reg [AXI_ADDR_WIDTH-1:0] cur_addr; reg [7:0] total_bursts; wire [7:0] bursts_total = cfg_trans_len[31:3] / BURST_LEN; // 64 位总线,8 字节每 beat;burst 总字节数 = BURST_LEN * 8 assign m_axi_awsize = $clog2(AXI_DATA_WIDTH/8); // 3 for 64-bit assign m_axi_awlen = BURST_LEN - 1; always @(posedge axi_clk or negedge axi_rst_n) begin if (!axi_rst_n) begin state <= IDLE; busy <= 1'b0; m_axi_awvalid <= 1'b0; m_axi_wvalid <= 1'b0; m_axi_bready <= 1'b0; user_fifo_rd_en <= 1'b0; cur_addr <= 32'd0; byte_cnt <= 32'd0; burst_cnt <= 8'd0; end else begin case (state) IDLE: begin if (cfg_start) begin busy <= 1'b1; cur_addr <= cfg_base_addr; byte_cnt <= 32'd0; state <= WAIT_FIFO; end end WAIT_FIFO: begin // 等待 FIFO 数据积累到足够一个 burst // 实际项目用 FIFO 的 prog_full 或者计数判断 if (!user_fifo_empty) begin m_axi_awvalid <= 1'b1; m_axi_awaddr <= cur_addr; state <= ISSUE_ADDR; end end ISSUE_ADDR: begin if (m_axi_awready) begin m_axi_awvalid <= 1'b0; burst_cnt <= 8'd0; state <= SEND_DATA; end end SEND_DATA: begin // 每个周期从 FIFO 读一个数据,同时驱动 wvalid if (!user_fifo_empty) begin user_fifo_rd_en <= 1'b1; m_axi_wvalid <= 1'b1; m_axi_wdata <= user_fifo_dout; m_axi_wstrb <= {AXI_DATA_WIDTH/8{1'b1}}; if (burst_cnt == BURST_LEN - 1) begin m_axi_wlast <= 1'b1; // 最后一个 beat,同时拉 wlast end else if (m_axi_wready && m_axi_wvalid) begin burst_cnt <= burst_cnt + 1'b1; m_axi_wlast <= 1'b0; end // wlast 判断需要提前一拍,这里简化展示了思路 end else begin user_fifo_rd_en <= 1'b0; m_axi_wvalid <= 1'b0; end if (m_axi_wready && m_axi_wvalid && m_axi_wlast) begin m_axi_wvalid <= 1'b0; m_axi_wlast <= 1'b0; user_fifo_rd_en <= 1'b0; state <= WAIT_RESP; end end WAIT_RESP: begin m_axi_bready <= 1'b1; if (m_axi_bvalid) begin m_axi_bready <= 1'b0; byte_cnt <= byte_cnt + (BURST_LEN * AXI_DATA_WIDTH/8); cur_addr <= cur_addr + (BURST_LEN * AXI_DATA_WIDTH/8); if (byte_cnt >= cfg_trans_len - (BURST_LEN * AXI_DATA_WIDTH/8)) begin busy <= 1'b0; state <= IDLE; end else begin state <= WAIT_FIFO; end end end endcase end end endmodule

这段代码的逻辑主干分四步:IDLE 等启动信号,WAIT_FIFO 等数据,SEND_DATA 按 burst 往 AXI 写数据通道灌数据,WAIT_RESP 等写响应后更新地址和计数器。有几个参数是自定义 uiFDMA 时必须明确的:AXI_DATA_WIDTH 决定每个 beat 多少字节,awsize 必须等于 log2(数据位宽/8),awlen 是突发长度减 1。特别注意 SEND_DATA 状态里 wlast 的生成逻辑,它必须提前一拍判断当前是否最后一个 beat,因为 AXI 协议要求 wlast 与最后一个数据同拍拉高。这里为了展示主干逻辑做了简化,实际工程建议用一个专门的前瞻寄存器去生成 wlast,避免因为 FIFO 读出一拍延迟导致最后一个数据丢失。

这段代码里对 FIFO 空信号的判断是"只要有数据就开始一个 burst",在实际工程中容易产生碎片化突发,效率不高。更稳的做法是用 Xilinx AXI-Stream Data FIFO 的 prog_full 信号来判断:等 FIFO 里攒够一个 burst 的数据量再启动 AXI 突发,这样 DDR 访问效率会高很多,尤其是挂多个 master 争抢带宽的时候。你可以把 prog_full 阈值设为 BURST_LEN,然后在 WAIT_FIFO 里等待 prog_full 拉高。

3.3 读通道 RTL:从 DDR 把数据推给用户侧 FIFO

读通道负责发起 AXI 读突发、接收读数据,并把数据写入用户侧 FIFO。这里给出与写通道对称的骨架代码。

module uifdma_rd_channel #( parameter AXI_DATA_WIDTH = 64, parameter AXI_ADDR_WIDTH = 32, parameter BURST_LEN = 16 )( input wire axi_clk, input wire axi_rst_n, // AXI 读地址通道 output wire [AXI_ADDR_WIDTH-1:0] m_axi_araddr, output wire [7:0] m_axi_arlen, output wire [2:0] m_axi_arsize, output wire m_axi_arvalid, input wire m_axi_arready, // AXI 读数据通道 input wire [AXI_DATA_WIDTH-1:0] m_axi_rdata, input wire [1:0] m_axi_rresp, input wire m_axi_rlast, input wire m_axi_rvalid, output wire m_axi_rready, // 用户侧 FIFO 写接口 output wire [AXI_DATA_WIDTH-1:0] user_fifo_din, output wire user_fifo_wr_en, input wire user_fifo_full, // 寄存器配置 input wire [AXI_ADDR_WIDTH-1:0] cfg_base_addr, input wire [31:0] cfg_trans_len, input wire cfg_start, output reg busy ); localparam IDLE = 3'd0, ISSUE_ADDR = 3'd1, READ_DATA = 3'd2; reg [2:0] state; reg [31:0] byte_cnt; reg [7:0] burst_cnt; reg [AXI_ADDR_WIDTH-1:0] cur_addr; assign m_axi_arsize = $clog2(AXI_DATA_WIDTH/8); assign m_axi_arlen = BURST_LEN - 1; always @(posedge axi_clk or negedge axi_rst_n) begin if (!axi_rst_n) begin state <= IDLE; busy <= 1'b0; m_axi_arvalid <= 1'b0; m_axi_rready <= 1'b0; user_fifo_wr_en <= 1'b0; cur_addr <= 32'd0; byte_cnt <= 32'd0; burst_cnt <= 8'd0; end else begin case (state) IDLE: begin if (cfg_start) begin busy <= 1'b1; cur_addr <= cfg_base_addr; byte_cnt <= 32'd0; state <= ISSUE_ADDR; end end ISSUE_ADDR: begin m_axi_arvalid <= 1'b1; m_axi_araddr <= cur_addr; if (m_axi_arready) begin m_axi_arvalid <= 1'b0; burst_cnt <= 8'd0; state <= READ_DATA; end end READ_DATA: begin // 接收 AXI 读数据并写入用户 FIFO if (m_axi_rvalid) begin if (!user_fifo_full) begin user_fifo_din <= m_axi_rdata; user_fifo_wr_en <= 1'b1; m_axi_rready <= 1'b1; if (m_axi_rlast) begin // 一个突发结束 byte_cnt <= byte_cnt + (BURST_LEN * AXI_DATA_WIDTH/8); cur_addr <= cur_addr + (BURST_LEN * AXI_DATA_WIDTH/8); if (byte_cnt >= cfg_trans_len - (BURST_LEN * AXI_DATA_WIDTH/8)) begin busy <= 1'b0; user_fifo_wr_en <= 1'b0; m_axi_rready <= 1'b0; state <= IDLE; end else begin user_fifo_wr_en <= 1'b0; state <= ISSUE_ADDR; end end end else begin // FIFO 满,暂停接收 —— 反压逻辑 m_axi_rready <= 1'b0; user_fifo_wr_en <= 1'b0; end end else begin user_fifo_wr_en <= 1'b0; end end endcase end end endmodule

读通道比写通道多一个天然的反压控制点:当用户 FIFO 满时,可以通过拉低 rready 来暂停接收 AXI 读数据。这符合 AXI 协议——rready 和 rvalid 同时为高时数据才被接收。实际使用中要注意,如果用户 FIFO 经常被占满,DDR 读带宽会掉下来,所以读通道的 FIFO 深度要参考下游消费速率来定。另一个容易忽略的点是 rlast 处理:一个突发结束后要立即发起下一个突发,中间不要插入额外等待周期,否则 DDR 读带宽会明显下降。如果你用 ILA 抓波形发现读通道两个突发之间有超过 10 个周期的空闲,大概率是状态机设计里多了一拍空转,需要优化。

3.4 打包和同一封装内的接口检查:这一步决定了你后半夜回不回家

RTL 写完后,在 Create and Package IP 向导里选择封装当前工程,Vivado 会自动分析模块的接口,并把所有输入输出 ports 映射成 IP 的接口。这里有一个非常影响体验的细节:如果你把 user_fifo_din、user_fifo_wr_en、user_fifo_full 这类信号做成普通端口,封装出来的 IP 就不带 AXI-Stream 接口,集成到 Block Design 时别人还得手动一个个连,非常容易连错线。更符合 uiFDMA 使用习惯的做法是,在封装前就把用户侧接口显式定义成 AXI4-Stream 接口,利用 Vivado 的 Interface 分组功能——把 tdata/tvalid/tready/tlast 单独拎出来组成一组名为 S_AXIS 的接口,这样在 Block Design 里拖出来就直接是一个标准 AXIS 接口,能自动连接下游的 AXIS FIFO 或者视频处理 IP。

封装时还要注意 IP 版本和 AXI 协议的匹配。Vivado 的 IP Packager 默认生成 IP 是 AXI4 协议规范,如果你的总线位宽是 128 位,而 m_axi_awsize 参数没跟上,地址对齐就全乱了。另外,记得把时钟接口命名成 s_axi_aclk 和 s_axi_aresetn,这是 Vivado 识别 AXI 外设接口的标准命名,命名不对会导致 IP 在 Block Design 里不能被自动连线。做完这些,点 Package IP,一定打开 IP 的 Example Design 仿真一下,确认接口分组和地址空间都正常再发布。很多人在打包时跳过了仿真验证,结果导入别处用的时候发现端口名字对不上或者位宽不对,非常浪费工时。

4. 用自定义 uiFDMA 打通 DDR 读写:最小系统搭建与寄存器配置

4.1 Block Design 接线:最小系统只需要五个组件

一个能跑起来的自定义 uiFDMA 最小系统在 Block Design 里只需要五类组件:Zynq PS 或者 MicroBlaze(提供 AXI4-Lite 配置通道)、DDR 控制器(MIG 或 Zynq 内部的 DDR 控制器)、自定义 uiFDMA IP、AXI Interconnect、以及用户侧的数据源/目的地。如果你用的是纯 FPGA 芯片没有 PS,可以用 MicroBlaze 配合 AXI4-Lite 接口来配置寄存器,也可以做一个简单的 AXI4-Lite 主机状态机来写寄存器。对于 Zynq 系列,最常见的做法是把 uiFDMA 的 S_AXI 挂到 PS 的 AXI_GP 口上,M_AXI 经过 AXI Interconnect 挂在 S_AXI_HP 口上访问 DDR。这里有一个带宽考量:HP 口的带宽远大于 GP 口,所以数据通路一定要走 HP 口,配置通路可以走 GP 口,分担压力。

用户侧接口如何接取决于你的数据场景。图像采集场景,通常把 uiFDMA 的写通道接在摄像头采集 IP 后面,数据从摄像头进 FIFO,再由 uiFDMA 搬进 DDR;显示回放场景,则把 uiFDMA 的读通道接在显示控制器的 AXI-Stream 接口上。如果用户侧不是流式接口,而是带地址的,那就需要再加一个地址转换逻辑,在 uiFDMA 封装层面往往不做这层适配,而是要求用户把数据整理成流——这是 uiFDMA 封装模型的边界,要提前想清楚。

4.2 寄存器配置顺序:先长度后使能,最后再启动

寄存器配置顺序在自定义 uiFDMA 里是个容易被忽视但又极其关键的环节。推荐配置顺序是这样,写通道为例。

// 伪代码:向 uiFDMA 写通道发起一次传输 // 1. 先写传输长度(0x0C),再写基地址(0x08),最后使能启动 void uifdma_write_transfer(uint32_t base_addr, uint32_t byte_len) { // 软复位,清掉上一次可能残留的状态 reg_write(UIFDMA_BASE + 0x00, 0x02); // 控制寄存器 bit1 = 软复位 reg_write(UIFDMA_BASE + 0x00, 0x00); // 释放复位 // 配置传输参数 reg_write(UIFDMA_BASE + 0x0C, byte_len); reg_write(UIFDMA_BASE + 0x08, base_addr); // 启动写通道 reg_write(UIFDMA_BASE + 0x00, 0x01 | 0x04); // bit0 使能 + bit2 写通道使能 }

这里有两个细节。第一,先配长度再配地址,因为在 IP 内部,地址和长度寄存器写入时是独立的,如果先写地址后写长度,内部逻辑虽然不会立即启动,但有些粗心的封装会把地址写入当作启动触发信号,导致长度还没写就开始传输。所以统一先写长度、后写地址、最后写使能位,顺序最安全。第二,软复位那两步不是可选的,它负责把上一帧的 busy 状态和 FIFO 残余数据清干净。如果你不做软复位就重新启动,FIFO 里上一次残留的数据会被发到 DDR,造成数据错位,而且这种错位在波形上很难看——地址和长度都对,就是数据内容不对。

读通道的配置顺序类似,把写通道基地址换成读通道基地址(0x10)、长度换成 0x14 即可。如果 udma 同时做读写,要注意两个通道的优先级设置取决于 AXI Interconnect 的配置,如果你希望写通道优先(比如 ADC 采集不能丢数据),在 Interconnect 里把写通道对应的 slave 端口 QoS 调高,或者在 uiFDMA 封装内部做一个简单的轮询里写通道权重更大。

4.3 帧参数怎么设:行/列/像素字节对齐这三个值最容易算错

很多做图像传输的人在配置 uiFDMA 长度时容易把行、列、像素字节三者关系弄混。假设一幅 1920x1080 的 RGB888 图像,一个像素 3 字节。单帧数据字节数 = 1920 * 1080 * 3 = 6,220,800 字节。如果你希望按行突发传输(每行一个 burst 边界),则一次传输长度应该设置为 1920 * 3 = 5760 字节。但注意:AXI 突发传输要求地址对齐,5760 字节不能被 8 整除(5760 / 8 = 720,其实可以),但有些分辨率不满足对齐。比如一个 640x480 的 RGB888,一行 1920 字节,这可以按 8 对齐;但如果是 800x600,一行 2400 字节,同样可被 8 整除。真正会出问题的是像素宽度非对齐的场景,比如 24 位深度的 YUV 422 打包格式,如果每行字节数不能被 AXI 总线位宽的字节数整除,就需要在行尾做填充(padding),否则 DMA 会跨行读取。

自定义 uiFDMA 的长度寄存器应该设置为"本次搬运的总字节数",而不是"帧数"或者"像素数"。有的封装会额外提供一个 DMA_STRIDE 寄存器专门存在行偏移,用于处理图像行对齐。如果你的场景是连续多帧搬运,推荐的做法是在软件端把多帧拼接成一个大 buffer,一次 DMA 搬运多帧,减少启动开销和中断次数。真机上你还会发现,DDR 访问效率跟 burst 长度强相关,帧总字节数越大,平均突发效率越高。做图像采集时常见的坑是你把长度寄存器配成了像素数而不是字节数,导致长度偏小 3 倍,DMA 搬完一半图像就停下来了,而且帧完成中断照常拉高,干扰你判断。

4.4 仿真和在线验证:不用 ILA 也能快速判断通道是否工作

自定义 uiFDMA 在跑上板之前,至少要在 Vivado 仿真里验证两种情况:单次突发正确性和连续突发时的 FSM 状态跳转。建议写一个简单的 testbench,用任务函数模拟 AXI 从设备的行为,向被测试的 uiFDMA 写通道写入固定递增数据(比如 0x00、0x01、0x02...),然后在 AXI 总线侧用 monitor 检查写入 DDR 的数据是否符合预期。注意在 testbench 里给 AXI 接口加 RANDOM DELAY(用 $urandom 控制 awready/wready 的随机拉低),这样能暴露出状态机在反压情况下的设计缺陷。

上板验证时,如果你不想动用 ILA 去抓内部信号,有一个更快捷的办法:利用状态寄存器的 busy 位做判断。软件端启动传输后,轮询状态寄存器(0x04),如果 busy 位长时间不拉低,要么是 DMA 没有配置正确,要么是 AXI 总线出了错误。如果 busy 正常拉低但是数据不对,优先怀疑地址对齐或者长度寄存器配错。只有到了这一步查不出来,才需要动用 ILA 去抓 M_AXI 的波形看地址通道和数据通道的时序关系。很多人一上来就 ILA 抓总线波形,浪费时间。正确的排查顺序是寄存器状态 -> 软件端回读 DDR 数据 -> ILA 抓波形,逐层深入,每次只排除一种可能性。

5. 自定义 uiFDMA 避坑手册:常见报错与排查方向

5.1 读通道超时:ARM 端读回超时,busy 位始终为高

现象:软件端发起读 DMA 传输后,轮询状态寄存器发现 busy 位一直为 1,几秒钟后如果是通过 AXI4-Lite 回读数据,软件报超时错误。用 ILA 抓 M_AXI 读地址通道,发现 arvalid 拉高后 arready 一直没有回。

原因:这个问题在自定义 uiFDMA 里最常见的来源是地址配置错误。读起始地址超出了 DDR 实际映射范围,或者 AXI Interconnect 里没有把地址范围配给对应的 slave,导致经过 Interconnect 译码时找不到对应的 slave,arready 永远不会拉高。另一种可能是 ddR 控制器没有被正确初始化——MIG 没 training 成功,整个 DDR 地址空间都不可用,这个时候任何访问 DDR 的 AXI 事务都会挂死。

解决:先核对基地址是否在 DDR 地址范围内。Zynq 上如果 DDR 起始地址是 0x00100000,而你配成了 0x00000000,得确认这块是否被其他外设占用。其次是查 AXI Interconnect 的地址映射表,确保 uiFDMA 的 M_AXI 接口地址范围能在 Interconnect 里命中 DDR 控制器。最后用最简单的方法确认 DDR 是否活着:让 PS 端直接往某一块 DDR 地址写数据再读回,能读通说明 MIG 正常,再回来查 DMA 配置。

5.2 写数据少一拍或多一拍:DDR 里前几个字是乱的

现象:从 DDR 读回 DMA 写进去的数据,发现前 4 个字节是上一次传输的残留数据,或者最后一个字节丢失。整个数据 pattern 呈现出"错位"而不是"错值"。

原因:用户侧 FIFO 在软复位时没有清空,第一次传输前 FIFO 里还留着上一次的残余数据,DMA 一启动就把这批老数据先发出去了。另外一个常见原因是 wvalid 和 wdata 的时序不匹配,RTL 里在发送数据时把 wdata 打了一拍,但 wvalid 没有跟着打拍,导致第一个有效数据实际出现在第二个周期,而 AXI 总线在第 1 个周期采样到了无效数据。

解决:在软复位流程里加一步,强制把用户 FIFO 的复位信号拉高至少 4 个周期,等 FIFO 的 empty 信号拉高后再启动下一帧传输。对于 wdata 和 wvalid 错拍的问题,建议在 testbench 里加一个数据完整性校验模块,对比写入数据和源数据的 pattern。如果发现错拍,修正方法是把 wdata 和 wvalid 用同一个寄存器打拍,保证它们同拍变化。这个问题属于典型的时序细节翻车,FPGA 工程师俗称"时序玄学",其实都是设计不规范导致的。

5.3 帧完成中断丢了:状态寄存器里能看到标志,但中断没触发

现象:软件查询状态寄存器时帧完成标志位是 1,但中断控制器那边没有收到中断信号。检查中断使能寄存器,发现使能位也配置正确。

原因:自定义 uiFDMA 的中断输出设计有两个常见缺陷。一个是中断输出采用电平模式,如果软件处理中断时是先读状态再清标志,而 IP 内部在标志没清掉之前一直拉高中断线,这本身没问题;但有的封装里中断脉冲宽度只有 1 个时钟周期,软件中断控制器没采到就丢了。另一个是中断状态寄存器里"写 1 清 0"的逻辑没实现好,软件清标志后状态没变化,导致中断线一直拉着,后续中断再也无法触发。

解决:建议把中断输出设计成"可配置电平/脉冲模式",通过寄存器 bit 控制。默认用脉冲模式,宽度至少 16 个时钟周期,保证中断控制器能采到。如果已经流片或者不好改 RTL,软件侧可以改成轮询模式绕开硬件中断。脉冲型中断丢失还有一个隐蔽原因:中断信号在跨时钟域时没有同步,如果你把中断输出直接跨到 PS 端时钟域,没有用两级同步器,极少概率会漏采。这个要升级 RTL 才能根治,同时这也是血泪经验——跨时钟域中断必须同步。

5.4 AXI4-Lite 读回寄存器全是 0,地址根本不对

现象:ARM 端读 uiFDMA 的任何寄存器都是 0,写寄存器后回读也是 0。仿真里读写逻辑都正确,上板后失效。

原因:这种问题优先怀疑地址没有对齐到 4 字节边界。AXI4-Lite 协议要求所有寄存器访问的地址按 4 字节对齐,如果你在 Block Design 里给 uiFDMA 分配的地址基地址不以 0x0 结尾(比如分配成 0x43C0_0004),访问寄存器偏移量加上基地址后错位,读回数据自然不对。另一种可能是 M_AXI 和 S_AXI 的位宽不匹配,比如 AXI Interconnect 把 PS 端 32 位 AXI_GP 口和 uiFDMA 的 32 位 S_AXI 对接,这个没问题;但如果 PS 用 64 位 AXI 口去访问一个 32 位 S_AXI,Interconnect 会自动做位宽转换,万一配置成 Memory-mapped 到 AXI4-Stream 转换模式,行为会非常奇怪。

解决:检查 Block Design 里 uiFDMA 的地址分配是否按 4KB 对齐。一般习惯把自定义 IP 的地址段分配在 0x43C0_0000 这种固定位置,且基地址后 12 位全 0。另一个排查点是确认 S_AXI 接口上有没有把 awready 信号正确回给 Interconnect,有的封装在寄存器读写状态机里漏了 awready 的默认拉高,导致事务永远挂起,软件读到的是超时错误而不是 0。如果回读是 0 而不是超时,优先怀疑地址对齐,这个可以用 ILA 抓 S_AXI 的 awaddr 来确认。

5.5 地址非对齐导致偶发数据传输错误:VPF 报错或者 DDR 数据错位

现象:连续传输大文件或者多帧图像时,偶尔出现中间某一段数据错位,错位长度固定等于一个字节或者几个字节。不是每次复现,加长仿真时间能遇到。

原因:自定义 uiFDMA 没有做 4KB 边界拆分,或者拆分逻辑里地址计算有误。AXI 协议规定一个突发不能跨越 4KB 边界,如果基地址 0x1FF0,突发长度 512 字节,那这次突发从 0x1FF0 到 0x21F0,跨越了 0x2000 边界,是非法突发。此时不同的 AXI Interconnect 或者 DDR 控制器行为不一致,有的直接丢弃数据,有的传输错误地址的数据,表现就是随机错位。

解决:根因是 RTL 里必须实现 4KB 边界检测与拆分逻辑。检测条件很简单:当前地址的低 12 位 + 本次突发字节数 > 4096,就说明跨边界了。拆分的做法是把当前突发长度改小到"剩余可突发字节数",发完后再从 0x2000 边界地址发起下一个突发。这个逻辑必须在写通道和读通道同时实现。拆分的判断建议用判决逻辑而不是计算逻辑,否则时序很容易紧张。我见过有人在拆分逻辑里顺序写 if-else 嵌套 8 层,综合后频率掉得离谱,最后推倒重来改成查表法。所以这里给一个注意提示:4KB 边界检测用比较器(当前地址低 12 位加上突发字节数比较)异或判断,不要做一串乘除运算。

6. 把自定义 uiFDMA 调稳的最后一招:用计数器与标志位做自检

当你的 uiFDMA 已经能在仿真里跑通、上板也能搬数据,但总感觉心里没底的时候,建议给它加一个"自检模式"。这是一个非常实用的技巧:在寄存器映射里预留一个测试模式控制位,当置 1 时,IP 内部不再从用户 FIFO 读数据,而是用一个线性反馈移位寄存器(LFSR)生成固定伪随机数据,写入 DDR 指定地址;同时设置一个回读模式,把 DDR 里刚写入的数据读回来与 LFSR 生成的数据比对。比对结果存到状态寄存器的一个标志位里:一致则标志置 1,不一致则置 0 并锁存错误地址。

这个功能看起来多花了几百行 RTL,但它解决了一个核心问题:当你换了一块板子、升级了 Vivado 版本、或者改了 DDR 配置之后,如何快速判断 uiFDMA 本身是否还有问题。不用写复杂的软件检查程序,不用抓波形,软件侧只需要写测试模式控制位,然后轮询标志位,几分钟就能跑完一遍全地址空间的读写测试。我自己的习惯是每次上板先跑一遍自检,确认 IP 的 AXI 通路干净了,再去调试用户逻辑。这个自检模式也方便你在给同事或者客户做演示时快速证明 IP 的可靠性。还有一个附带好处:自检模式用的 LFSR 数据模式在 ILA 里看起来是伪随机的,比固定递增数据更容易暴露数据位粘连问题。

另外一个值得养成的习惯是,在状态寄存器的预留位里放一个"上次错误类型"字段。当自检发现数据错误或者 AXI 总线事务错误时,把错误类型锁存下来,软件端通过一次读操作就能知道到底是地址错误、数据错误还是超时。这比让用户去看 ILA 波形要高效得多,尤其是当 uiFDMA 被集成在一个大系统里、错误出现频率很低时,这个标志位是唯一的线索。自定义 IP 和官方 IP 相比,维护成本在自己手里,所以这些调试基础设施值得在一开始就做进去,不要等出了问题再去补。加完这些功能,你会发现 uiFDMA 从一个黑匣子变成了一个可观测的、可验证的、可维护的自研模块,这种掌控感是直接拿来一个 IP 核体会不到的。希望我的这些踩坑经验能帮到你。

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

RELATED READING

延伸阅读

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