自研AXI4 Slave核:打通XDMA到用户逻辑的最后一公里
简介面向FPGA开发者与PCIe研究人员本资源为基于XDMA核和AXI4协议实现PCIe数据读写二的自编AXI4Slave核工程完成AXI4到AXI-stream的接口转换涵盖数据宽度匹配、事务管理、流控制、错误处理与FIFO缓冲等设计要点。压缩包共15个文件以tcl脚本、Verilog源码、C驱动及头文件为主含SystemVerilog与XML定义覆盖综合、仿真、驱动适配等环节约25KB。目前已有5191人学习/浏览。工程内包含模块化Verilog实现、测试平台与设计说明便于集成到PCIe系统中。1. 自编AXI4 Slave核XDMA链路里最后一段需要自己写的接口上一篇文章把XDMA核拖进Vivado配好了PCIe链路驱动起来后Host也能枚举到设备、看到BAR空间。但真到初始化代码开始读写BAR时傻眼的事就来了要么读回来全是0要么请求直接超时。原因其实不玄学——XDMA只负责把Host侧的PCIe访问转成AXI4总线事务而事务落到哪块用户逻辑、应答怎么给它一概不管。如果你没挂一个实现好的AXI4 Slave核那整条数据通路就是断的。这个项目做的就是这件事自己用Verilog写一个AXI4 Slave核挂在XDMA的user侧AXI4 Master端口下把PCIe过来的读写请求真正落到寄存器或RAM里再把响应按AXI4握手规则送回。适合正在做PCIe采集卡、DMA搬运、板卡控制寄存器开发的FPGA工程师。读完这篇文章你能把整个AXI4 Slave的写通道、读通道、地址译码和握手时序完整落地也能绕开我上板调试时踩过的几个坑。2. 整体架构XDMA、AXI4与自研Slave核的对接关系2.1 XDMA的AXI4 Master侧长什么样XDMA IP核在配置为DMA/Bridge模式时会暴露一组AXI4主接口方向是Host到Card也就是H2C。这组接口的地址空间直接映射到PCIe BAR上Host端软件对BAR地址发起一次读写XDMA内部就会在user侧发出对应的AXI4读或写事务。换句话说XDMA已经帮你把PCIe事务层和传输层大部分机制消化掉了留给你的只有AXI4这一层。这组接口的时钟和复位是用户在IP配置时选的常见的有100MHz、125MHz、250MHz几个档位。信号名通常是m_axi_aclk和m_axi_aresetn低有效复位。它的数据位宽可以是32、64、128或者256bit具体看IP配置。接口上跑的就是标准AXI4协议写通道有AW、W、B三组信号读通道有AR、R两组信号每一组都遵循VALID/READY握手。这里有一个容易混淆的点XDMA的AXI4 Master是面向用户逻辑的“请求方”它发出的地址是绝对物理地址还是相对偏移取决于BAR配置和IP里的地址译码选项。我习惯在IP里把地址译码关掉让XDMA直接转发Host侧的BAR偏移地址这样用户逻辑只需要对一段连续地址空间做译码调试时大脑不用来回换算。2.2 Slave核顶层结构五个通道怎么分工自研AXI4 Slave核的顶层结构可以拆成四个独立模块写地址接收和锁存、写数据接收和锁存、读地址接收和锁存、读写响应生成。这里不把它们做成分层的module也行直接在顶层.v里用几段always块分开写结构反而清晰方便剪裁。严格的AXI4协议要求五个通道是并行且独立的。AW和W可以同时到达甚至可以在时间上错开B响应必须在写数据被接收之后AR和R通道则和写通道完全无关。因此Slave核的核心设计思路不是用一个总状态机去统管所有通道而是给每个通道单独做握手标记再用组合逻辑判断“寄存器写使能何时产生”。通道分工大致是这样AW通道接收Host写请求地址把地址锁存到内部寄存器W通道接收Host写数据连同wstrb字节选通一起锁存B通道当地址和数据都到齐后向Host返回写响应AR通道接收Host读请求地址锁存后再去查寄存器或RAMR通道把读到的数据送回并保持握手直到Host取走。这个分工的好处是读写可以全双工并行写通道在等待数据时读通道可以同时服务另一笔读请求。对XDMA这种会发起批量DMA搬运的场景来说读写并行很关键否则DMA的读描述符和写数据会被同一个状态机卡成串行吞吐率下降一大截。2.3 地址映射与寄存器预算在设计寄存器映射前先确定BAR空间的利用方式。XDMA自己会占用BAR空间的一部分做控制寄存器、中断和DMA描述符通常从0x0开始。用户逻辑最好从0x100000这种偏移往后挂避免和XDMA内部寄存器空间冲突。Host驱动里给用户空间单独映射一段偏移量在驱动代码里写清楚就可以。这个Slave核按32位地址、32位数据设计寄存器offset用awaddr_q[7:2]来索引低2位是字节地址实际用高6位做偏移。寄存器预算可以按项目裁剪我在这里安排了4个32位寄存器Offset名称方向用途0x00CTRL读写控制寄存器比如启动停止DMA、软件复位0x04STATUS只读状态寄存器例如FIFO空满、链路是否稳定0x08VERSION只读固件版本号驱动初始化时校验0x30BUF_REG读写数据缓冲区起始地址配置写地址译码时把地址对齐到寄存器宽度32位宽度下最低两位不参与译码。读通道同理。整个Slave核的地址空间预留了0x40到0x100的空白方便后续扩展成RAM映射区或者加FIFO接口。3. 核心代码拆解写通道、读通道与字节使能逻辑3.1 端口定义与寄存器地址表这个Slave核的端口沿用了XDMA AXI4 Master那边的信号命名习惯代码里直接按标准AXI4 Slave的命名写方便对图。顶层模块参数可以做成可配置的这样后续改成64位或128位数据宽度时不用改逻辑主体。module axi4_slave #( parameter DATA_WIDTH 32, parameter ADDR_WIDTH 32 )( input wire aclk, input wire aresetn, // 写地址通道 input wire [ADDR_WIDTH-1:0] s_axi_awaddr, input wire [2:0] s_axi_awprot, input wire s_axi_awvalid, output reg s_axi_awready, // 写数据通道 input wire [DATA_WIDTH-1:0] s_axi_wdata, input wire [DATA_WIDTH/8-1:0] s_axi_wstrb, input wire s_axi_wvalid, output reg s_axi_wready, // 写响应通道 output reg [1:0] s_axi_bresp, output reg s_axi_bvalid, input wire s_axi_bready, // 读地址通道 input wire [ADDR_WIDTH-1:0] s_axi_araddr, input wire [2:0] s_axi_arprot, input wire s_axi_arvalid, output reg s_axi_arready, // 读数据通道 output reg [DATA_WIDTH-1:0] s_axi_rdata, output reg [1:0] s_axi_rresp, output reg s_axi_rvalid, input wire s_axi_rready );参数说明DATA_WIDTH默认32需要宽度变化时直接把XDMA侧配成对应位宽然后修改这个参数即可。ADDR_WIDTH默认32实际上XDMA最多支持到64位地址总线如果用了大地址空间可以改到64。AWPROT和ARPROT在绝大多数场景下不需要关心直接忽略不影响功能。3.2 写通道实现AW/W并行握手与wstrb处理写通道的核心是分别对AW和W做握手锁存等待两者都齐活后再产生寄存器写使能。这个结构的优势在于AXI4允许AW和W乱序到达Slave必须兼容这种并行性不能用单一的“先收地址再收数据”的顺序状态机。// 写通道AW与W独立握手两者都完成后锁存写入 reg [ADDR_WIDTH-1:0] awaddr_q; reg [DATA_WIDTH-1:0] wdata_q; reg [DATA_WIDTH/8-1:0] wstrb_q; reg aw_hit; reg w_hit; wire aw_done s_axi_awvalid s_axi_awready; wire w_done s_axi_wvalid s_axi_wready; wire both_done aw_hit w_hit; assign s_axi_awready !aw_hit; assign s_axi_wready !w_hit; // 锁存写地址 always (posedge aclk) begin if (!aresetn) awaddr_q {ADDR_WIDTH{1b0}}; else if (aw_done) awaddr_q s_axi_awaddr; end // 锁存写数据与字节选通 always (posedge aclk) begin if (!aresetn) begin wdata_q {DATA_WIDTH{1b0}}; wstrb_q {(DATA_WIDTH/8){1b0}}; end else if (w_done) begin wdata_q s_axi_wdata; wstrb_q s_axi_wstrb; end end // 记录握手完成状态 always (posedge aclk) begin if (!aresetn) begin aw_hit 1b0; w_hit 1b0; end else begin if (aw_done) aw_hit 1b1; if (w_done) w_hit 1b1; if (both_done) begin aw_hit 1b0; w_hit 1b0; end end end // B响应生成 always (posedge aclk) begin if (!aresetn) begin s_axi_bvalid 1b0; end else if (both_done) begin s_axi_bvalid 1b1; end else if (s_axi_bvalid s_axi_bready) begin s_axi_bvalid 1b0; end end assign s_axi_bresp 2b00; // 固定OKAY逻辑说明awready和wready直接由两个hit标志取反得到所以只要当前没有未处理完的写请求Slave就允许主机随时打入新的地址或数据。当aw_done和w_done各自到来后awaddr_q和wdata_q在时钟沿锁存同时对应的hit标志置位。等到两个hit都为1both_done组合成立此时地址、数据、字节选通全部有效可以在下一拍产生实际写入动作。参数细节both_done成立的时机很关键——如果AW和W在同拍握手成功地址和数据在上一拍锁存hit也同步置起both_done在这拍才为真。这不会造成数据冒险因为awaddr_q和wdata_q都已经是稳定值。使用aw_hit/w_hit而不是直接拿awvalid当ready条件是为了防止主机连续发起多个写事务时Slave还没处理完上一个就又被塞入新地址。现在把字节选通逻辑加上去。wstrb是AXI4里容易忽略但实际必须处理的信号如果忽略它驱动里只写一个字节会把整个32位寄存器刷掉别人一读发现其他字节也被清了。// 字节选通合并写 function [31:0] byte_merge; input [31:0] old_data; input [31:0] new_data; input [3:0] strb; begin byte_merge old_data; if (strb[0]) byte_merge[7:0] new_data[7:0]; if (strb[1]) byte_merge[15:8] new_data[15:8]; if (strb[2]) byte_merge[23:16] new_data[23:16]; if (strb[3]) byte_merge[31:24] new_data[31:24]; end endfunction // 寄存器写译码 always (posedge aclk) begin if (!aresetn) begin reg_ctrl 32h0; end else if (both_done) begin case (awaddr_q[7:2]) 6d0: reg_ctrl byte_merge(reg_ctrl, wdata_q, wstrb_q[3:0]); 6d1: ; // STATUS只读 6d2: ; 6d3: reg_ctrl byte_merge(reg_ctrl, wdata_q, wstrb_q[3:0]); default: ; endcase end end逻辑说明byte_merge把旧寄存器值和新写数据按wstrb逐字节合并wstrb对应位为1才更新该字节。如果直接写reg_ctrl wdata_q遇到Host只写一个字节的IO操作就会出问题。这里的case用awaddr_q[7:2]做索引是因为AXI地址是字节地址32位寄存器对齐后偏移按4递增低2位恒为0所以直接用[7:2]位就能选到寄存器序号。3.3 读通道实现ar_pending标志与两拍数据通路读通道相对简单但要注意读响应rvalid不能直接等于arvalid否则主机还没准备好收数据时读数据就跑了。我采用ar_pending标志来管理读事务生命周期。// 读通道AR握手后锁存地址生成读数据响应 reg [ADDR_WIDTH-1:0] araddr_q; reg ar_pending; reg rvalid_q; reg [DATA_WIDTH-1:0] rdata_q; wire ar_done s_axi_arvalid s_axi_arready; assign s_axi_arready !ar_pending; always (posedge aclk) begin if (!aresetn) araddr_q {ADDR_WIDTH{1b0}}; else if (ar_done) araddr_q s_axi_araddr; end always (posedge aclk) begin if (!aresetn) ar_pending 1b0; else begin if (ar_done) ar_pending 1b1; else if (s_axi_rvalid s_axi_rready) ar_pending 1b0; end end // 读地址译码 reg [DATA_WIDTH-1:0] rdata_comb; always (*) begin case (araddr_q[7:2]) 6d0: rdata_comb reg_ctrl; 6d1: rdata_comb reg_status; 6d2: rdata_comb reg_version; 6d3: rdata_comb reg_ctrl; default: rdata_comb {DATA_WIDTH{1b0}}; endcase end // 读数据输出 always (posedge aclk) begin if (!aresetn) begin rvalid_q 1b0; rdata_q {DATA_WIDTH{1b0}}; end else begin if (ar_pending !rvalid_q) begin rvalid_q 1b1; rdata_q rdata_comb; end else if (s_axi_rvalid s_axi_rready) begin rvalid_q 1b0; end end end assign s_axi_rdata rdata_q; assign s_axi_rresp 2b00;逻辑说明ar_pending在ar_done握手成功后下一拍置位此时arready拉低阻止新读请求进来。随后在ar_pending有效且rvalid_q尚未拉高的时钟沿把组合译码结果锁存到rdata_q并拉高rvalid。等Host通过rready取走数据rvalid清零ar_pending也同步清零读通道恢复空闲。这里有个值得注意的细节ar_pending置位后并不是立刻输出rdata而是隔了一个时钟拍才锁存。这是因为组合译码依赖araddr_q而araddr_q在ar_done拍末才更新所以必须避开同一拍的竞争。读延迟固定在两拍ar_done到rvalid有效对XDMA来说完全可接受驱动里也不需要对时序做特殊处理。3.4 顶层集成端口互联与响应拉齐Slave核写完后顶层要做的事就是把XDMA的m_axi_*信号和这个Slave的s_axi_*信号一对一接起来然后用一个异步FIFO把跨时钟域隔离掉。XDMA的user时钟和用户逻辑主时钟经常不是同一个频率直接连会导致亚稳态数据偶发错乱。// 顶层集成示例 axi4_slave #( .DATA_WIDTH(32), .ADDR_WIDTH(32) ) u_slave ( .aclk (m_axi_aclk), .aresetn (m_axi_aresetn), .s_axi_awaddr (m_axi_awaddr), .s_axi_awprot (m_axi_awprot), .s_axi_awvalid(m_axi_awvalid), .s_axi_awready(m_axi_awready), .s_axi_wdata (m_axi_wdata), .s_axi_wstrb (m_axi_wstrb), .s_axi_wvalid (m_axi_wvalid), .s_axi_wready (m_axi_wready), .s_axi_bresp (m_axi_bresp), .s_axi_bvalid (m_axi_bvalid), .s_axi_bready (m_axi_bready), .s_axi_araddr (m_axi_araddr), .s_axi_arprot (m_axi_arprot), .s_axi_arvalid(m_axi_arvalid), .s_axi_arready(m_axi_arready), .s_axi_rdata (m_axi_rdata), .s_axi_rresp (m_axi_rresp), .s_axi_rvalid (m_axi_rvalid), .s_axi_rready (m_axi_rready) );集成时最容易忘的是arready、awready这类输出不能悬空。如果Slave的ready信号一直为0XDMA侧会认为总线上挂着个永远不响应的设备驱动访问BAR就卡死。代码里这些ready信号全部由内部标志生成不存在悬空情况但如果你自己改写了部分通道一定要在仿真里检查每个通道的握手是否都能顺利完成。4. 上板避坑记录握手时序、全零数据与跨时钟域问题4.1 现象XDMA访问超时总线没有响应驱动初始化时对BAR空间做一次读操作函数返回超时Vivado ILA里看到awvalid和awready从来没同时拉高过。原因排查下来是awready信号在一个always块里被赋值成了寄存器类型但没有给初始值。上电后awready是0awvalid再高也不会握手。我最初是把awready做成了时序逻辑条件写得过于严格导致它只有在“某种状态”下才拉高而那个状态永远进不去。解决的思路是像awready这类slave侧接收信号的生成优先用组合逻辑加内部标志不要用状态机去驱动。只要内部没有未处理完的事务ready信号就始终拉高让XHOST来主导节奏。4.2 现象寄存器能读但总是0寄存器映射表明确写了地址ILA上看araddr_q也对rvalid也拉高了但rdata_q全是0。一开始还以为是XDMA地址译码的问题后来回头看代码才发现rdata_comb的case里用了awaddr_q[7:2]而不是araddr_q[7:2]。这种复制粘贴导致的bug在工程里很常见写地址通道和读地址通道各有一个地址锁存但读通道的case选错了信号源。定位方法是在ILA里同时观察awaddr_q和araddr_q比对rdata锁存时用的到底是哪个地址。从那以后我都是把读写通道的case各自独立写不互相引用也不在同一个always里混用。另外STATUS寄存器是只读的我虽然没写它但case分支里经常忘了加default导致未命中地址返回不定态。HDL综合时不定态会被当0处理所以看起来就是读回0。修复方法是给读通道的default分支补上明确的0输出不要让它悬空。4.3 现象连续DMA传输偶发数据错位Host发起大块DMA读数据大多数时候正确偶尔出现整段偏移4字节或者两个数据块内容互换。这个问题不在AXI4协议逻辑上而在跨时钟域。XDMA的m_axi_aclk跑250MHz用户逻辑侧用100MHz两边用两级触发器同步FIFO的指针结果FIFO的几乎满标志同步不及时导致写入侧判断错误。后来我把数据通路改成用XPM异步FIFO直接跨时钟空满标志由各自时钟域独立生成不再手动同步指针。现在常见做法是AXI4侧数据先落到异步FIFO用户逻辑侧用慢时钟读出去。调试这类问题不要盯着逻辑功能看先问自己时钟域的边界到底划在哪。4.4 现象直接把AXI_Stream设备挂到AXI4 Slave端口上翻车看到XDMA同时提供AXI4和AXI4-Stream接口想省事把一路AXI_Stream采集数据直接接到AXI4 Slave的写通道上结果仿真通过、上板必挂。原因在于AXI_Stream没有独立的写地址通道也就没有AW握手的概念它的TLAST是打拍对齐的。而AXI4 Slave在收到AW地址前收到了W数据地址锁存为空写地址直接落到0x0然后把控制寄存器覆盖了。正确做法是保持AXI4 Slave接收和响应逻辑在内部把W数据转存到FIFO再从FIFO按流式时序搬给下游的AXI_Stream模块。或者在XDMA侧直接选AXI4-Stream接口做数据搬运控制寄存器走AXI-Lite数据通道走Stream两个接口分开各管各的。说到底AXI4和AXI_Stream是两种不同深度握手的协议靠打补丁兼容不如从一开始就规划好接口边界。5. 二次开发把固定寄存器组改造成可配置的AXI4 Slave这个核目前只做了32位寄存器空间如果你要拿它做实际项目第一件事通常是把它扩成RAM映射区。改动量不大在原有地址译码基础上增加一段地址范围判断当地址落在RAM空间时写使能转向RAM端口读数据选择RAM的输出。wire is_ram_write both_done (awaddr_q[7:2] 6d8); wire is_ram_read ar_pending (araddr_q[7:2] 6d8); // 如果是RAM区走RAM写否则走寄存器写 always (posedge aclk) begin if (is_ram_write) ram_mem[awaddr_q[7:2] - 6d8] wdata_q; endRAM区的深度、位宽、读写延迟需要和你的数据通路匹配。这里有一个技巧RAM读数据不要直接接到rdata_comb上而是先打一拍锁存到RAM读数据寄存器再和寄存器区数据做MUX这样能避免组合路径过长时序收敛更容易。如果想加AXI_Stream接口推荐在Slave核外面做一个桥接模块内部用异步FIFO隔离时钟域。主机侧DMA写下来的数据先进FIFO流式侧按自己的节奏读走流式侧产生的数据也先写FIFOAXI4读通道去FIFO取数。这个结构我后来复用到好几个项目里唯一的建议是FIFO深度按DMA最大突发长度加上至少两倍余量来配否则高带宽场景下会频繁置起几乎满标志。关于这个核的通用化还有一处值得改现在awready和arready在hit/pending置位后拉低意味着同一时刻只能服务一个写事务和一个读事务。XDMA在一次DMA传输中会连续发起多个AXI4事务如果每个事务都需要等B响应或R响应完成才能接下一个效率会打折。可以改成用两块内存交替存储地址和数据让AW和W通道保持连续接收B响应延迟到事务边界统一回。这个改造的收益在DMA大块搬运时很明显吞吐率能提升不少。我后来做采集卡控制模块时把这段代码拷过来改了三天接上了RAM区、中断寄存器、AXI_Stream桥才算把XDMA从IP核到用户逻辑的链路整个跑顺。从那以后我每次写AXI4 Slave都会先在仿真里把AW、W、B、AR、R五个通道的握手波形全部打出来看一眼确认没有依赖上一拍状态的隐藏时序才敢上板。这个习惯帮我省了不少ILA扒拉波形的苦工。希望这篇能帮你少走几步弯路。本文还有配套的精品资源点击获取