自写AXI4 Slave核:XDMA与PCIe数据读写的FPGA实战拆解
简介面向使用Xilinx XDMA核和AXI4协议实现PCIe数据读写的FPGA工程师这套代码包聚焦自编AXI4Slave核解决AXI4到AXI-Stream接口的转换问题适合已有Verilog基础、需要将自定义DMA链路接入AXI Stream数据通道的中高级开发者。包内含15个文件主要由Verilog与SystemVerilog源码、Tcl脚本、C驱动及头文件、makefile和XML组件描述组成其中Tcl脚本可重建Vivado Block DesignC代码用于驱动验证结构精简却覆盖RTL设计到软件驱动的关键环节压缩包仅25KB。资源已有5191人学习说明该实现思路在PCIe接口开发中有参考价值。通过学习这一工程可掌握AXI4与AXI-Stream协议在数据宽度匹配、事务管理、流控制及FIFO缓冲等方面的具体写法和集成方法还能借助驱动与仿真脚本验证核的功能为后续扩展自定义PCIe数据传输设计提供可直接复用的模板。1. 一个让DMA读不到数据的晚上为什么要自写AXI4 Slave核第一次把XDMA核连到自己的用户逻辑时我遇到过一件玄学事仿真里波形完美上板后主机驱动一读寄存器返回的全是0。调了两天最后发现根因不在DMA而在XDMA旁边的AXI4从接口——主机发起的配置读写事务根本没人去响应它。XDMA负责把PCIe事务翻译成AXI4总线事务但事务到了总线上得有一个AXI4 Slave核把它接住完成寄存器的写入和读取。于是有了这篇实战拆解基于XDMA核和AXI4协议实现PCIe数据读写重点讲清楚自编AXI4 Slave核的完整实现包括状态机设计、Verilog代码、仿真验证和上板调试。适合正在做PCIe采集卡、高速数据回传、或者想在FPGA里被主机通过PCIe配置寄存器组的人照着文章能跑通一条从主机到FPGA寄存器的完整链路。2. 为什么XDMA旁边还需要一个自写的Slave核2.1 XDMA的双接口主接口负责搬数据从接口负责被配置XDMA核在PCIe和AXI4之间做翻译它对外暴露两类AXI4接口。一类是AXI4 Master接口它在收到主机发来的DMA描述符后主动发起读或写事务这是数据搬运的主干道比如把FPGA里的采集数据搬到主机内存。另一类是AXI4 Slave接口它接收主机通过PCIe BAR地址发起的读写事务用于配置和状态查询。很多项目只关注了Master接口的数据通路把Slave接口晾在一边结果主机侧读写寄存器全部超时或读回全零这就是我开头说的那个血泪场景。自编AXI4 Slave核做的就是接管这个Slave接口的活。AXI4 Slave核的核心职责有两个。一是地址解码主机访问的地址落在哪个寄存器上要把地址翻译成寄存器编号。二是握手时序AXI4协议要求每个事务走完地址、数据、响应三拍握手Slave侧必须按VALID/READY信号的正确时序响应少一拍或者时序顺序不对总线事务就卡死。实际工程里XDMA的Slave接口位宽常见为32位或64位地址位宽通常是32位AXI4协议里的AW、W、B、AR、R五个通道都要实现缺一个通道主机侧的读写就会挂起。2.2 顶层架构把Slave核拆成四个可独立验证的部分我习惯把自编AXI4 Slave核按功能拆成四块写地址/写数据接收逻辑、写响应生成逻辑、读地址接收与读数据返回逻辑、寄存器堆。这样一个核的代码量大概两百行左右按通道拆开后每个模块都能单独仿真出问题时定位速度会快很多。写通道的流程是主机拉高AWVALIDSlave拉高AWREADY完成地址握手再通过WVALID/WREADY完成数据握手最后Slave给出BVALID写响应。读通道则是主机拉ARVALIDSlave锁存地址后把对应寄存器的数据放到RDATA上同时拉高RVALID直到主机用RREADY接走数据。读通道既要支持单次读也要考虑XDMA发起INCR突发读的情况否则主机读一大段连续地址时会总线死锁。寄存器堆这部分我通常先在地址映射表里把每个寄存器的用途定好再写Verilog。表格式的架构参数整理如下方便做接口与验证规划接口/通道方向关键信号设计要点写地址AW输入AWVALID/AWREADY/AWADDR握手后锁存地址写数据W输入WVALID/WREADY/WDATA/WSTRB按WSTRB逐字节写寄存器写响应B输出BVALID/BREADY/BRESP数据接收完成后拉高等BREADY读地址AR输入ARVALID/ARREADY/ARADDR锁存地址与突发长度读数据R输出RVALID/RREADY/RDATA/RLAST按地址选通寄存器逐拍返回3. 核心模块拆解一个AXI4读事务从进入到返回的完整链路3.1 寄存器映射表先定协议再写代码写Slave核之前先定义主机能看到什么寄存器。我做的这个核里放了四个32位寄存器用地址低字节部分区分比如偏移0x00是控制寄存器0x04是状态寄存器0x08和0x0C是数据寄存器。地址按4字节对齐所以偏移地址的低2位固定为0。地址映射采用连续编址方便以后扩展成更多寄存器。映射表定清楚后主机侧驱动的读写逻辑就很好写了。控制寄存器里放了启动位和复位位状态寄存器放FIFO空满标志数据寄存器用于回读采集值。映射表不仅定了地址还定了属性哪些可读可写哪些只读。只读寄存器如果被主机写入Slave核应该忽略写操作而不是返回错误。这样设计的好处是主机侧逻辑简单驱动的读写接口和操作普通内存几乎没有区别。3.2 写通道实现AW、W、B三段式握手写通道是整个Slave核里最容易出死锁的部分常见翻车点是AWREADY拉高时机不对。下面是我实际在工程里用过的写法采用“见VALID拉READY”策略也就是只有在对应的VALID信号有效时READY才拉高一个周期避免无谓的握手占用。// 写地址通道捕获AWADDR always (posedge axi_aclk or negedge axi_aresetn) begin if (~axi_aresetn) begin awready 1b0; awaddr_reg 32d0; end else if (awvalid ~awready) begin awready 1b1; awaddr_reg awaddr; // 锁存本次写地址 end else begin awready 1b0; end end这里awready只在AWVALID有效的那个时钟周期拉高一个周期完成握手后立刻拉低不会出现READY长时间拉高导致重复接收地址的问题。awaddr_reg在握手成功的同一拍锁存地址后续写数据逻辑根据这个地址决定写入哪个寄存器。参数说明axi_aclk和axi_aresetn是总线时钟与低有效复位awaddr位宽在32位设计中直接使用32位。写数据通道的实现与写地址通道类似但关键在做字节使能。AXI4的WSTRB信号按字节选通写数据32位数据总线下WSTRB是4位哪一位为1就写对应字节。如果忽略WSTRB主机写单字节或半字时会污染相邻字节。这里按位判断WSTRB再更新寄存器// 写数据通道按WSTRB逐字节写入 always (posedge axi_aclk or negedge axi_aresetn) begin if (~axi_aresetn) begin wready 1b0; reg_ctrl 32d0; end else if (wvalid ~wready) begin wready 1b1; if (wstrb[0]) reg_ctrl[7:0] wdata[7:0]; if (wstrb[1]) reg_ctrl[15:8] wdata[15:8]; if (wstrb[2]) reg_ctrl[23:16] wdata[23:16]; if (wstrb[3]) reg_ctrl[31:24] wdata[31:24]; end else begin wready 1b0; end endwvalid和wready握手成功后数据必须在同一拍写入寄存器不能延迟否则主机侧看到的寄存器值滞后。WSTRB字节使能是一个容易被忽略的关键细节有的设计者直接把整个WDATA赋值给寄存器导致主机写一个字节时其他字节被清零。在这个核里地址通道和数据通道的握手相互独立AXI4协议允许地址先到、数据后到所以代码中两部分逻辑分开判断互不依赖。写响应通道的设计遵循“写完必答”的原则。AXI4协议要求每个写事务必须有BRESP响应哪怕地址或数据有错误也要返回。这里在WVALID握手完成后的下一拍拉高BVALIDBRESP置为OKAY等待主机BREADY。// 写响应通道每次写事务完成后给出OKAY响应 always (posedge axi_aclk or negedge axi_aresetn) begin if (~axi_aresetn) begin bvalid 1b0; bresp 2b00; end else if (wvalid wready ~bvalid) begin bvalid 1b1; // 数据已接收生成响应 bresp 2b00; // OKAY end else if (bvalid bready) begin bvalid 1b0; end end这里的关键点在于bvalid必须等bready为高才能撤销否则响应信号丢失主机侧会认为写事务未完成而一直等待。BRESP为2b00代表OKAY2b10代表SLVERR。对于寄存器写事务只要地址落在映射表范围内就回OKAY地址越界可以回SLVERR我在这个核里简化处理为统一OKAY后续二次开发时再接地址译码错误逻辑。3.3 读通道实现支持INCR突发的地址递增与数据返回读通道比写通道复杂的地方在于突发传输。XDMA发起读事务时可能带ARLEN指定一次读多个数据Slave核必须逐拍返回数据最后一拍拉RLAST信号否则主机会一直等下去。这一节我把地址接收与数据返回分成两段先锁存地址和突发长度再按beat计数逐一返回。// 读地址通道锁存地址与突发长度 always (posedge axi_aclk or negedge axi_aresetn) begin if (~axi_aresetn) begin arready 1b0; araddr_reg 32d0; arlen_reg 4d0; end else if (arvalid ~arready) begin arready 1b1; araddr_reg araddr; // 锁存首地址 arlen_reg arlen; // 锁存突发长度 end else begin arready 1b0; end endARLEN在AXI4中表示“突发长度减一”也就是一个读事务总共要返回的beat数是arlen加1。这里锁存arlen为4位最大支持16拍连续读足以覆盖XDMA配置寄存器时发起的短突发。如果主机发起的是单次读arlen等于0代码自然也能处理。返回数据时先用组合逻辑根据当前地址选择对应的寄存器再在时序逻辑里维护RVALID和RLAST。地址每个beat递增4字节匹配32位数据总线。// 读数据返回地址递增与RLAST生成 always (*) begin case (araddr_reg[7:2]) 4d0: rdata_mux reg_ctrl; 4d1: rdata_mux reg_status; 4d2: rdata_mux reg_data0; 4d3: rdata_mux reg_data1; default: rdata_mux 32hDEAD_BEEF; endcase end always (posedge axi_aclk or negedge axi_aresetn) begin if (~axi_aresetn) begin rvalid 1b0; rlast 1b0; burst_cnt 4d0; end else if (~rvalid) begin rvalid 1b1; // 有读事务时直接拉高 burst_cnt 4d0; end else if (rvalid rready) begin if (burst_cnt arlen_reg) begin rvalid 1b0; // 最后一拍完成撤销 rlast 1b1; end else begin araddr_reg araddr_reg 32d4; burst_cnt burst_cnt 1b1; end end endrlast的处理是一个容易踩坑的点。这里在最后一个beat被主机接收后拉高rlast一个周期用来标识突发传输的最后一拍。burst_cnt从0计数到arlen_reg的过程就是地址递增的过程。地址递增的是aaddr_reg这样下一次选通rdata_mux时会自然选中下一块寄存器或内存映射区域。读数据路径的组合逻辑用了araddr_reg的[7:2]位做译码因为地址低2位对于4字节对齐来说恒为0取高位的目的是选通不同的寄存器。4. 跑起来从Testbench到上板实测的完整验证路径4.1 Testbench怎么搭手动构造AXI4事务驱动Slave核单独仿真Slave核时不需要真的把XDMA例化进来只需要在testbench里模拟主机发起AXI4写读事务。这一步能提前捕获大部分握手时序问题比上板后抓逻辑分析仪调得快很多。我的testbench结构包含时钟与复位生成、AXI4任务函数、检查逻辑三部分。核心的写事务任务代码大致如下task axi_write; input [31:0] addr; input [31:0] data; begin (posedge clk); awvalid 1b1; awaddr addr; wvalid 1b1; wdata data; wstrb 4b1111; wait (awready wready); // 等待地址和数据握手同时完成 (posedge clk); awvalid 1b0; wvalid 1b0; wait (bvalid bready); // 等待写响应 (posedge clk); end endtask这段任务里我同时拉高了AWVALID和WVALID因为这是寄存器写的常见场景地址与数据同时就绪。在实际AXI4协议中写地址通道和写数据通道没有强制的先后顺序但主机侧可以自由选择先发哪个Slave核必须都能处理。任务里的wait语句保证了握手条件满足后才继续本质上是在testbench里复现了AXI4的VALID/READY同步机制。参数说明addr为目标寄存器地址data为要写入的数据wstrb全4位拉高表示写入全部四个字节。读事务任务的写法类似但要注意RVALID和RREADY的配合。因为Slave核里RVALID在地址握手完成后会自动拉高并持续到RREADY到来testbench中不能无限等应该在RVALID拉高后立即给RREADYtask axi_read; input [31:0] addr; output [31:0] data; begin (posedge clk); arvalid 1b1; araddr addr; wait (arready); (posedge clk); arvalid 1b0; wait (rvalid); rready 1b1; data rdata; (posedge clk); rready 1b0; end endtask4.2 上板验证驱动往BAR地址写Slave核接住testbench跑通后下一步是把核集成到完整的XDMA工程里上板用主机驱动实测。常见做法是PCIe枚举完成后主机驱动读取BAR地址比如BAR0的基地址加偏移量就是FPGA侧寄存器地址驱动往这个地址写控制字FPGA侧的状态寄存器的值变化就能通过主机读回验证。验证流程我一般这样走先写控制寄存器把复位位置1再清0然后读状态寄存器确认复位生效接着往数据寄存器写一组已知序列再原样读回比对最后启动一次DMA搬运把FPGA侧一块数据区搬到主机内存验证XDMA的Master通道和Slave核配合正常。如果第一步读写就异常优先检查AXI4信号有没有连到XDMA的正确管脚常见问题是把从接口的读写信号接到了主接口上。这一步如果抓到的是全零数据又找不到原因可以直接参考下一章的避坑清单。4.3 调试时的波形观察要点上板调试时如果带了调试工具优先抓五组信号AWVALID/AWREADY、WVALID/WREADY、BVALID/BREADY、ARVALID/ARREADY、RVALID/RREADY。重点看每组握手信号有没有在同一周期同时为高若一直只有一个信号为高而另一个不回应说明对应通道卡住了。比如ARVALID一直为高、ARREADY始终不拉高问题大概率在Slave核的arready逻辑可能是组合逻辑里arready被某个条件锁死了。5. 避坑指南AXI4 Slave核从仿真到上板的五个典型问题5.1 读回来的数据全是0现象主机读任何寄存器都返回0写操作看起来正常。 原因读数据通道的rdata_mux译码地址取错或者rvalid/rready时序不对数据还没稳定就被主机采走。我最初把araddr_reg的低2位也参与译码结果地址0x00和0x04都译到了同一个寄存器上。 解决地址译码只取对齐位以上的地址位32位数据总线用[7:2]64位总线用[7:3]并且确认rvalid置位时rdata_mux已经稳定输出。5.2 突发读事务卡死主机驱动超时现象单次读正常一旦驱动发起多beat读主机侧一直等不到数据。 原因Slave核没有处理INCR突发只做了单次读第二个beat的地址没有递增或者没有生成RLAST信号主机不知道读事务何时结束。 解决读通道里维护burst_cnt计数按arlen逐拍递增地址最后一拍拉rlast。注意arlen是“长度减一”循环次数要按arlen_reg1算。如果是寄存器连续读还要保证寄存器组的地址是连续编址中间不能有空洞。5.3 写事务完成后主机仍报错现象仿真中写事务正常上板后驱动返回错误码或者下一次写事务被阻塞。 原因bvalid拉高后没有等待bready就撤销了导致写响应丢失或者bvalid在多个写事务之间没有正确复位主机认为上一次事务未完成。 解决bvalid必须等到bready为高才撤销这是AXI4协议里“VALID不能等READY才有效但撤销必须等READY确认”的一个典型场景。把bvalid逻辑单独写成一个小状态机不要和写数据逻辑混在一起。5.4 burst_last信号在多拍时提前拉高现象四拍突发读第一拍就出现了rlast主机只收到一个数据。 原因rlast的生成逻辑中把“收到地址”当成了“收到最后一个数据”在burst_cnt还没等于arlen_reg时就拉高了rlast。 解决rlast必须与rvalid和rready握手关联在rvalid握手成功的同一周期判断burst_cnt是否等于arlen_reg相等时才置位rlast。我后来把rlast单独用一段always块维护彻底避免和rvalid互相干扰。5.5 复位信号异步释放导致上板偶发死锁现象上板后第一次读写偶尔失败复位后恢复正常。 原因axi_aresetn是低有效异步复位释放时刻相对于时钟沿不确定导致某些握手信号处于亚稳态。 解决在Slave核内部先做两级同步再使用复位信号或者要求总线复位释放满足时序要求。常见做法是外部复位经过同步器后再进入各个always块的复位端释放与时钟沿对齐握手信号就不会出现半个周期的不确定状态。6. 二次开发把Slave核变成可复用的参数化寄存器组到这个阶段你已经有了一个能跑通的AXI4 Slave核但距离“拿出去复用”还差一步把它参数化。我做的第一个改造是把寄存器数量定义成参数地址译码的case语句改成generate循环或for循环这样换项目时不用改核内部逻辑只改参数就能扩展出几十个寄存器。地址映射表保持连续编址译码逻辑用地址高位比较代替逐地址case代码量反而减少。第二个值得考虑的改造是增加中断通知。主机轮询状态寄存器在低吞吐场景够用但当FPGA侧需要主动通知主机“数据已就绪”时轮询的延迟不可接受。常见做法是在Slave核里加一个中断状态寄存器和中断使能寄存器写状态寄存器中的清除位来清中断通过XDMA的MSI中断上报。这个改造只涉及新增寄存器和中断产生逻辑不影响已有的读写通道。如果下一步要做数据搬运我的建议是在这个Slave核旁边接AXI4转AXI_Stream的桥把寄存器配置通路和数据通路彻底分开Slave核只管配置与状态回读数据走AXI_Stream直达XDMA的Master接口。这个方向拆开以后采集、存储和配置三类逻辑可以独立开发验证起来也清晰很多。从那以后我每写一个AXI4 Slave核都会强制走一遍“先定地址映射表再写通道握手仿真跑通后再上板”的顺序再也不直接开工写代码。没有地址表就写时序多半会在调试时翻车。希望这篇拆解能帮你少走一趟我踩过的路。本文还有配套的精品资源点击获取