简介:本资源面向FPGA与PCIe接口开发的中高级工程师,聚焦XDMA核与AXI4协议数据读写场景,重点提供自编AXI4Slave核的完整实现方案,解决AXI4协议与AXI-Stream协议之间的接口转换问题。压缩包共15个文件,约25KB,包含Verilog与SystemVerilog源码、Tcl综合脚本、C与头文件驱动代码、Makefile构建脚本及XML组件描述等,覆盖从RTL设计、综合仿真到驱动集成的完整链路。资源中给出了数据宽度匹配、事务管理与同步、流控制信号生成、错误处理及FIFO缓冲等关键设计点的工程实现,并附带测试平台与设计文档,便于读者理解核的集成方式与验证流程。目前已有5190人学习下载,适合需要打通PCIe高速数据通路、快速复用AXI4Slave核进行二次开发的FPGA开发者参考。
1. 从一次上板翻车说起:这个自编 AXI4 Slave 核到底解决什么问题
去年帮一个做高速采集的朋友调板子,XDMA 核跑通了,PCIE 链路也认到了,结果一读数据全是0xDEADBEEF这种鬼东西。查了三天,最后发现是 XDMA 的 AXI4 Master 接口对着一个不存在的 Slave 地址猛发读写请求,总线直接挂死。那一刻我才意识到,光会例化 XDMA 核根本不够,你得自己写一个能接住 AXI4 事务的 Slave 核,把 PC 端发过来的读写请求翻译成片上寄存器和 BRAM 的操作。这个项目就是干这件事的——用 Verilog 手写一个 AXI4 Slave 核,挂在 XDMA 的 M_AXI 接口上,实现 PC 通过 PCIE 对 FPGA 内部寄存器和存储器的读写。适合已经跑通 XDMA 例程、但卡在“怎么接自己的逻辑”这一步的 FPGA 开发者。下面我把这个核从协议到代码到上板调试的完整链路拆一遍,包括我踩过的那些坑。
2. AXI4 Slave 核的架构拆解:从握手信号到寄存器映射
2.1 为什么 XDMA 需要一个自编 Slave 核
XDMA 核对外暴露两组 AXI4 接口:一组是M_AXI(Master),用于主动发起对 DDR 或片上存储的读写;另一组是M_AXI_LITE(Master Lite),用于访问寄存器配置空间。但这两组接口都是 Master 角色,它们需要一个 Slave 来响应。如果你只是想把 PC 发来的数据存到 BRAM 里,或者读一个状态寄存器,就必须自己实现一个 AXI4 Slave。
常见做法是用 Xilinx 的 AXI Interconnect 加 AXI BRAM Controller 拼一套,但那样资源占用大,而且中间多了一层转换,调试时信号追起来很痛苦。自编 Slave 核的好处是:地址译码、握手逻辑、数据通路全部透明,出问题直接看波形就能定位。我一般会在 Slave 核里把地址空间划分成几个区域:0x0000_0000到0x0000_0FFF映射到控制寄存器组,0x0000_1000到0x0000_1FFF映射到一块 4KB 的 BRAM,剩下的地址返回SLVERR。这样 PC 端用mmap映射 BAR 空间后,读写不同偏移就能操作不同硬件资源。
2.2 AXI4 五个通道的握手逻辑
AXI4 全功能接口有五个独立通道:写地址(AW)、写数据(W)、写响应(B)、读地址(AR)、读数据(R)。每个通道都用VALID和READY做握手。Slave 核的核心就是正确响应这些握手,不能出现死锁。
写通道的流程是:Master 先拉高AWVALID并给出AWADDR,Slave 在准备好接收地址后拉高AWREADY;同时或随后 Master 拉高WVALID并给出WDATA和WSTRB,Slave 拉高WREADY接收数据;数据写完后 Slave 拉高BVALID并给出BRESP,Master 回BREADY完成一次写事务。
读通道类似:Master 拉高ARVALID给出ARADDR,Slave 拉高ARREADY;然后 Slave 拉高RVALID给出RDATA和RRESP,Master 回RREADY。
这里有个容易翻车的点:AWREADY和WREADY不能无条件一直拉高。如果你的 Slave 写 BRAM 需要多个周期,就必须在写使能有效期间把WREADY拉低,否则数据会丢。我一般会在 Slave 里加一个简单的状态机,用wr_busy信号控制WREADY。
2.3 地址译码与寄存器映射的实现
地址译码的逻辑很直接:用AWADDR的高几位判断访问的是哪个区域。比如AWADDR[31:12]等于20'h00000时选中寄存器组,等于20'h00001时选中 BRAM。选中后把AWADDR[11:0]作为区域内偏移,送给对应的寄存器或 BRAM 地址线。
寄存器组我一般用一组reg变量实现,每个寄存器 32 位。写操作时根据WSTRB做字节选通,读操作时用组合逻辑或时序逻辑输出RDATA。注意读数据必须和RVALID对齐,不能提前也不能滞后。常见做法是用一个寄存器在ARVALID握手后一拍拉高RVALID,同时把RDATA锁存输出。
BRAM 部分可以直接例化 Xilinx 的BRAM_SDP原语,或者用推断的方式写一个简单的双端口 RAM。写端口接 AXI 写通道,读端口接 AXI 读通道。注意 BRAM 的读延迟通常是 1 到 2 个时钟周期,所以RVALID要相应延迟,不能立刻拉高。
3. 手写 Verilog 代码:从端口定义到读写状态机
3.1 端口列表与参数定义
先看顶层端口。这个 Slave 核对外暴露标准的 AXI4 接口信号,加上时钟和复位。参数方面,我定义了C_S_AXI_DATA_WIDTH为 32 或 64,C_S_AXI_ADDR_WIDTH为 32,以及 BRAM 的深度参数。
module axi4_slave_core #( parameter C_S_AXI_DATA_WIDTH = 32, // AXI 数据位宽 parameter C_S_AXI_ADDR_WIDTH = 32, // AXI 地址位宽 parameter BRAM_DEPTH = 1024 // BRAM 深度,单位 32bit 字 )( // 全局时钟与复位 input wire aclk, input wire aresetn, // 写地址通道 input wire [C_S_AXI_ADDR_WIDTH-1:0] s_axi_awaddr, input wire [7:0] s_axi_awlen, input wire [2:0] s_axi_awsize, input wire [1:0] s_axi_awburst, input wire s_axi_awvalid, output wire s_axi_awready, // 写数据通道 input wire [C_S_AXI_DATA_WIDTH-1:0] s_axi_wdata, input wire [C_S_AXI_DATA_WIDTH/8-1:0] s_axi_wstrb, input wire s_axi_wlast, input wire s_axi_wvalid, output wire s_axi_wready, // 写响应通道 output wire [1:0] s_axi_bresp, output wire s_axi_bvalid, input wire s_axi_bready, // 读地址通道 input wire [C_S_AXI_ADDR_WIDTH-1:0] s_axi_araddr, input wire [7:0] s_axi_arlen, input wire [2:0] s_axi_arsize, input wire [1:0] s_axi_arburst, input wire s_axi_arvalid, output wire s_axi_arready, // 读数据通道 output wire [C_S_AXI_DATA_WIDTH-1:0] s_axi_rdata, output wire [1:0] s_axi_rresp, output wire s_axi_rlast, output wire s_axi_rvalid, input wire s_axi_rready );这里我把awlen、awsize、awburst都保留在端口上,虽然这个 Slave 核只支持固定长度的突发(awlen固定为 0,即单次传输),但保留这些信号方便以后扩展。awsize用来判断传输宽度,awburst一般固定为2'b01(INCR 模式)。
3.2 写通道状态机与 BRAM 写入
写通道用一个三段式状态机实现:IDLE、WRITE_ADDR、WRITE_DATA、WRITE_RESP。实际上因为 AXI4 允许地址和数据并行,我把地址和数据分开处理,用两个小状态机分别控制AWREADY和WREADY。
// 写地址握手 reg aw_ready_reg; always @(posedge aclk or negedge aresetn) begin if (!aresetn) begin aw_ready_reg <= 1'b0; end else begin if (s_axi_awvalid && !aw_ready_reg) begin aw_ready_reg <= 1'b1; // 收到地址后拉高一拍 end else begin aw_ready_reg <= 1'b0; end end end assign s_axi_awready = aw_ready_reg; // 写数据握手与 BRAM 写使能 reg w_ready_reg; reg bram_we; reg [C_S_AXI_ADDR_WIDTH-1:0] bram_waddr; always @(posedge aclk or negedge aresetn) begin if (!aresetn) begin w_ready_reg <= 1'b0; bram_we <= 1'b0; bram_waddr <= {C_S_AXI_ADDR_WIDTH{1'b0}}; end else begin if (s_axi_wvalid && !w_ready_reg) begin w_ready_reg <= 1'b1; bram_we <= 1'b1; // 数据有效时写 BRAM bram_waddr <= s_axi_awaddr; // 地址在 AW 阶段已锁存 end else begin w_ready_reg <= 1'b0; bram_we <= 1'b0; end end end assign s_axi_wready = w_ready_reg;这段代码的逻辑是:AWVALID有效时,一拍后拉高AWREADY,同时把地址锁存到bram_waddr。WVALID有效时,拉高WREADY并产生一个周期的bram_we脉冲,把WDATA写入 BRAM。注意WSTRB的处理:如果数据位宽是 32 位,WSTRB是 4 位,每一位对应一个字节。BRAM 原语通常有字节使能端口,直接把WSTRB接上去即可。
写响应通道在bram_we拉高后一拍拉高BVALID,BRESP固定为2'b00(OKAY)。如果地址译码发现访问了非法区域,BRESP设为2'b10(SLVERR)。
3.3 读通道状态机与数据返回
读通道相对简单,因为 BRAM 读延迟固定。状态机在ARVALID有效后拉高ARREADY,同时把地址送给 BRAM 读端口。BRAM 读延迟设为 2 个周期,所以RVALID在ARREADY拉高后第 3 个周期拉高。
// 读地址握手 reg ar_ready_reg; reg [C_S_AXI_ADDR_WIDTH-1:0] bram_raddr; always @(posedge aclk or negedge aresetn) begin if (!aresetn) begin ar_ready_reg <= 1'b0; bram_raddr <= {C_S_AXI_ADDR_WIDTH{1'b0}}; end else begin if (s_axi_arvalid && !ar_ready_reg) begin ar_ready_reg <= 1'b1; bram_raddr <= s_axi_araddr; // 锁存读地址 end else begin ar_ready_reg <= 1'b0; end end end assign s_axi_arready = ar_ready_reg; // 读数据返回,延迟 2 拍对齐 BRAM 读延迟 reg [2:0] r_valid_cnt; always @(posedge aclk or negedge aresetn) begin if (!aresetn) begin r_valid_cnt <= 3'd0; end else if (ar_ready_reg) begin r_valid_cnt <= 3'd3; // 启动计数,3 拍后 RVALID 有效 end else if (r_valid_cnt > 0) begin r_valid_cnt <= r_valid_cnt - 1'b1; end end assign s_axi_rvalid = (r_valid_cnt == 3'd1); assign s_axi_rlast = s_axi_rvalid; // 单次传输,RLAST 与 RVALID 同步 assign s_axi_rresp = 2'b00; // OKAY 响应这里r_valid_cnt从 3 开始递减,减到 1 时RVALID拉高,正好对应 BRAM 的 2 周期读延迟加上一拍寄存器输出。RDATA直接接 BRAM 的读数据输出。如果地址译码非法,RRESP设为2'b10,RDATA返回全零。
3.4 地址译码与 SLVERR 生成
地址译码用一个组合逻辑块实现,根据AWADDR或ARADDR的高位判断区域。我一般把地址空间分成三块:寄存器区、BRAM 区、保留区。
// 地址译码:高 20 位决定区域 localparam REGION_REG = 20'h00000; localparam REGION_BRAM = 20'h00001; wire is_reg_access = (s_axi_awaddr[31:12] == REGION_REG); wire is_bram_access = (s_axi_awaddr[31:12] == REGION_BRAM); wire is_illegal = !(is_reg_access || is_bram_access); // 写响应:非法访问返回 SLVERR assign s_axi_bresp = is_illegal ? 2'b10 : 2'b00;读通道的译码类似,用ARADDR判断。注意BRESP和RRESP必须在BVALID和RVALID有效期间保持稳定,不能中途变化。我一般用寄存器锁存译码结果,在握手完成后一拍输出。
寄存器组的实现更简单:定义一组reg [31:0] ctrl_regs [0:15],写操作时根据AWADDR[5:2]选择寄存器索引,读操作时用ARADDR[5:2]选择。WSTRB用于字节选通,读数据用组合逻辑assign rdata = ctrl_regs[araddr[5:2]]。
4. 上板调试与避坑:那些让我熬夜的 AXI 握手问题
4.1 避坑一:AWREADY 和 WREADY 同时拉高导致数据错位
现象:PC 端写 4 个 32 位数据,结果只有第一个写进去了,后面三个全是旧值。
原因:我在初始版本里把AWREADY和WREADY都无条件拉高,导致 AXI Master 认为地址和数据可以同时接收。但实际上 BRAM 写使能需要和WVALID对齐,而AWVALID可能比WVALID早好几个周期到达。结果WREADY拉高时bram_waddr还没锁存,写到了错误地址。
解决:把AWREADY和WREADY分开控制。AWREADY在AWVALID有效后一拍拉高,锁存地址;WREADY在WVALID有效后拉高,此时地址已经稳定。这样即使地址和数据到达时间差很大,也不会错位。
4.2 避坑二:RVALID 拉高太早导致读数据是 X
现象:PC 端读寄存器,第一次读总是0xXXXXXXXX,第二次读才正常。
原因:BRAM 读延迟是 2 个周期,但我一开始在ARREADY拉高的同一拍就拉高了RVALID,此时 BRAM 数据还没出来,RDATA是未定义状态。
解决:加一个计数器,ARREADY拉高后延迟 3 拍再拉高RVALID。具体延迟拍数取决于 BRAM 配置,我一般用BRAM_LATENCY参数控制,默认 2。调试时用 ILA 抓ARVALID、ARREADY、RVALID、RDATA四个信号,看RVALID是否在RDATA稳定后才拉高。
4.3 避坑三:BVALID 和 RVALID 同时有效导致总线冲突
现象:同时进行读写操作时,AXI 总线挂死,XDMA 报 timeout。
原因:AXI4 协议允许读写通道独立工作,但我的 Slave 核里写响应和读数据共用了一个状态机,导致BVALID和RVALID互相阻塞。
解决:把写响应和读数据完全分开,用两个独立的状态机。BVALID只依赖写通道状态,RVALID只依赖读通道状态。这样读写可以并行,不会互相等待。注意BRESP和RRESP也要分开锁存,不能共用寄存器。
4.4 避坑四:地址译码边界不对导致访问越界
现象:PC 端访问0x1000偏移时,读出来的数据是寄存器值而不是 BRAM 值。
原因:地址译码用了AWADDR[11:0]判断区域,但0x1000的[11:0]是0x000,和寄存器区重叠了。实际上应该用AWADDR[31:12]判断区域,[11:0]作为区域内偏移。
解决:区域判断用高位,偏移用低位。我一般把地址空间按 4KB 对齐划分,[31:12]做区域译码,[11:0]做偏移。这样每个区域最大 4KB,足够放 1024 个 32 位寄存器或 1024 个 32 位 BRAM 字。
4.5 避坑五:WSTRB 没接导致字节写失效
现象:PC 端用writeb写一个字节,结果整个 32 位寄存器都被改了。
原因:BRAM 写使能只用了bram_we,没有把WSTRB接到 BRAM 的字节使能端口。BRAM 默认全字写,所以一个字节写变成了整字写。
解决:把WSTRB接到 BRAM 原语的we端口,每一位对应一个字节。如果是自己推断的 RAM,用for循环生成字节使能逻辑:
genvar i; generate for (i = 0; i < C_S_AXI_DATA_WIDTH/8; i = i + 1) begin : byte_en always @(posedge aclk) begin if (bram_we && s_axi_wstrb[i]) begin bram_mem[bram_waddr[9:0]][i*8 +: 8] <= s_axi_wdata[i*8 +: 8]; end end end endgenerate这段代码确保只有WSTRB对应位为 1 的字节才被写入,其他字节保持原值。
5. 二次开发与验证:从单次传输到突发传输的改造思路
这个 Slave 核目前只支持单次传输(awlen固定为 0),但实际项目中经常需要突发传输来提高带宽。改造思路是:在写通道状态机里增加一个计数器,根据awlen决定传输次数;WREADY在每次数据传输时拉高一拍,bram_waddr每次递增。读通道类似,RVALID连续拉高arlen+1次,RLAST在最后一次拉高。
验证方法我一般分三步:第一步用 Verilog testbench 做仿真,重点测握手信号的时序关系,用$display打印每次读写的数据和地址;第二步用 Xilinx 的 AXI VIP 做协议检查,确保没有违反 AXI4 规范;第三步上板后用 PC 端的devmem工具读写 BAR 空间,对比写入值和读出值是否一致。
一个具体的技巧:在 Slave 核里加一个性能计数器,统计读写事务数量和总延迟。上板后通过寄存器读出来,就能知道当前带宽瓶颈在哪里。我一般会统计awvalid到bvalid的周期数,如果超过 10 个周期,说明写通道有优化空间。
从那以后我每次写 AXI Slave 核,都强制走一遍「仿真握手时序 → VIP 协议检查 → 上板 devmem 验证」这三步,再也不敢直接上板碰运气了。希望帮到你。
本文还有配套的精品资源,点击获取