
在ZYNQ开发中让PL侧逻辑主动通过AXI4接口向内存写入一批数据几乎是每个入门者都要跨过的关卡。很多人做完LED流水灯和按键中断之后信心满满地想处理大批量数据结果一碰到AXI总线就懵了一堆VALID/READY信号、突发传输、地址对齐、缓存一致性……本文就是专门为这类同学准备的。我会从系统架构讲起拆解AXI4写事务的完整过程给出一个可以直接落地的精简Master IP代码再配合PS侧验证程序最后聊聊实际调试中一定会遇到的坑。1. 为什么“PL往DDR写数据”是ZYNQ基础里绕不开的一道坎1.1 ZYNQ是PSPL两个世界DDR天然长在PS侧ZYNQ这颗芯片很特别它把ARM处理器PS端和FPGA逻辑PL端封在一起但内存控制器DDR Controller挂在PS侧PL并没有直接访问DDR的物理引脚。也就是说PL想要读写内存必须“借道”PS内部的互联矩阵通过AXI接口进入内存控制器。初学者容易有一个错觉既然PL和PS在同一个芯片里那DDR是不是像BRAM一样可以随便接并不是。PL只能通过几个专门的高速接口——也就是常说的HP口——去访问DDR地址空间。所以“通过AXI4接口向内存中写入数据”这个标题本质上是讲清楚一件事PL侧如何按照AXI4协议发起一笔写事务让数据穿过HP接口最终落在DDR的某个地址上。1.2 为什么要让PL主动写内存而不是交给CPU你可能觉得既然PS有ARM核写DDR直接由CPU执行一段C代码不就行了确实可以但很多场景下PL主动写内存更有优势。举个例子你做了一个千兆以太网UDP接收模块数据以几十兆字节每秒的速度涌入如果每包数据都要进CPU、由CPU搬运到内存CPU开销会非常大。更合理的做法是让PL里自带的逻辑把数据按帧组装好用AXI4突发写入DDRCPU只在整包数据到达后收到一个中断然后取走处理。再比如CMOS摄像头采集连续的视频流必须实时写入帧缓存这种高吞吐、持续性的数据流正是AXI4主设备接口的用武之地。理解了这一点你就会发现“PL主动写DDR”是很多高性能应用的基础能力值得花时间彻底搞懂。1.3 本篇实验最终做到什么效果这篇实验我设计得很朴素在PL端写一个精简的AXI4 Master模块产生一组递增数据比如从0、1、2……一直到255通过AXI4接口写入DDR地址0x10000000然后PS端在嵌入式软件里把同一块地址读出来和期望值比对。预期结果是在串口上看到一组连续且完全正确的序列。之所以选递增数据是因为它有两个好处第一数据是自己生成的不需要外部输入设备容易复现第二出问题时定位特别直观比如某一拍错位、高半字丢失、字节序反了从打印出来的数值一眼就能看明白。这个实验虽小但AXI4写事务的握手、突发、响应、地址计算全部覆盖了后续换成真实数据源只是改数据生成逻辑而已。2. 硬件侧总体设计与AXI接口选型2.1 AXI4、AXI4-Lite、AXI4-Stream别选错很多教程一上来就让你用AXI DMA但事实上动手前先分清楚AXI的三种变体可以帮助你理解后续所有设计。我习惯用一个表格来对比变体事务特点典型用途适合本实验吗AXI4支持突发传输一次可传多个数据高速数据搬运到DDR最适合AXI4-Lite不支持突发一次只读写一个寄存器/地址配置寄存器、控制外设不适合写批量数据AXI4-Stream没有地址只有连续流DMA、视频流、FIFO传输适合配合DMA使用如果只是给PS核里的某个控制器配置几个寄存器AXI4-Lite就够了。但往DDR里写大批量数据必须用AXI4因为它支持突发burst一次地址握手后可以连续传输多个数据效率远高于单个读写。AXI4-Stream虽然快但它本身不带地址不能直接对应内存位置通常要经过AXI DMA转换成AXI4事务才能落到DDR。2.2 打开HP口并确认DDR地址映射在搭建硬件工程时最重要的两件事就是为PS核打开UART和S_AXI_HP0口。在开发工具的IP配置界面里找到PL到PS的高速接口列表启用HP0通常还会把它设置成32位数据宽度。这个宽度和后续AXI Master IP的数据宽度必须一致否则连接时会失败或需要额外转换逻辑。地址映射方面DDR的物理基地址通常是0x00000000但不同板卡、不同工程可能会被占掉一部分所以最稳妥的做法是查阅当前工程生成的内存映射表。我习惯把实验数据放在0x10000000这个位置对大多数DDR配置都不冲突而且地址低12位全为零方便做突发对齐。2.3 一条最简的AXI4 Master数据通路本实验的数据通路非常简洁PL侧自定义Master IP产生递增数据通过AXI4接口连接到PS核的S_AXI_HP0HP0内部再通过互联矩阵把事务送达DDR控制器。整个过程没有DMA、没有中断完全靠Master模块里的状态机驱动。需要说明的是我这里选择手写一个轻量级Master IP而不是直接拖AXI DMA原因有两个。第一手写能让你真正看清AXI4写事务的发生过程而不是把寄存器配完就完事第二很多真实项目中PL侧的数据源并不天然是Stream格式比如多个传感器要按帧打包写入DDR手写状态机反而更灵活。当然AXI DMA也是一种高效方案我会在文末扩展部分提到它的适用场景。3. AXI4写事务协议拆解AW、W、B三通道的握手时序3.1 一次突发写到底涉及哪些信号AXI4写事务由三个通道组成地址通道AW、数据通道W、响应通道B。信号清单并不复杂核心如下通道信号方向含义AWawvalid / awreadyMaster - Slave / Slave - Master地址握手AWawaddrMaster - Slave起始地址AWawlenMaster - Slave突发长度值为8则表示9个节拍AWawsizeMaster - Slave每个节拍字节数32位时为0b010AWawburstMaster - Slave突发类型INCR为0b01Wwvalid / wreadyMaster - Slave / Slave - Master数据握手WwdataMaster - Slave写数据WwlastMaster - Slave最后一个节拍必须置高Bbvalid / breadySlave - Master / Master - Slave写响应握手BbrespSlave - Master响应状态OKAY为0b00AXI4的核心规则是握手机制如果主设备发出valid但从设备没有准备好那么valid必须保持稳定直到双方握手成功。这个规则保证了事务不会丢失但也要求状态机设计不能乱拉低valid。3.2 一个完整写事务的推进过程假设现在要向地址0x10000000连续写8个32位数据。具体过程可以拆成四步第一Master拉高awvalid把awaddr设为0x10000000awlen设为7awsize设为2awburst设为INCR。当Slave拉高awready后地址通道握手完成awvalid可以拉低。第二Master开始逐拍发送wdata。每拍数据都要配合wvalid置高并且当Slave的wready拉高时才算该拍真正被接收。比如第一拍传数据0第二拍传数据1……到了最后一拍wlast必须拉高表示这是本突发最后一笔。第三Slave接收完所有数据后会在响应通道上拉高bvalid返回一个bresp。此时Master必须给出bready1表示响应已被接收。第四Master根据bvalid握手结果判断这次突发是否成功然后回到空闲状态等待下一次触发。要注意的是AXI4允许地址通道和数据通道重叠进行也就是地址还没握手完就已经可以发数据了。但初学者最好先按顺序做即地址握手完成后再发数据这样状态机清晰也不容易违反协议。后续若要追求极致吞吐再考虑重叠。3.3 为什么状态机设计里最怕“死等”很多第一次写AXI Master的人会写出类似“先把awvalid拉高然后一直等awready”的逻辑。代码本身没错但要注意如果Slave一直不拉高ready你的状态机就必须一直停在当前状态不能擅自跳走。一个常见的错误是在一个时钟周期里同时发起多个握手导致后续状态冲突。实际设计中我建议把每个握手拆成“发请求 - 等待 ready - 跳转”三个子状态。这样逻辑最清晰综合后资源开销也不大。哪怕后面要挂到DDR控制器这种慢速Slave上也不会有问题。4. 从零写一个精简AXI4 Master IP4.1 建议用IP打包向导生成模板再自己改写手工写一个完整的AXI4 Master IP并不难但接口信号很多容易拼写错误。如果用的是市面上常见的FPGA开发工具都可以利用“Create and Package IP”向导选择AXI4 Master模板。生成出来的模板自带一套能跑通的读、写状态机虽然代码有点长但可以帮你省去接口声明和打包的麻烦。我的建议是第一次做实验时先生成模板把模板里与写通道相关的逻辑看懂再替换成自己的数据生成逻辑。第二次再做类似项目时就可以直接沿用自己精简后的Master模块。本节的代码就是基于这种方法去掉读逻辑、只保留写通道的精简版本。4.2 精简Master模块的Verilog核心代码下面只列出写状态机的核心部分完整工程代码量太大不适合全部贴在博客里。为了突出AXI写事务的主干我把读通道和额外信号都删掉了。// 参数定义 localparam DATA_WIDTH 32; localparam BURST_LEN 8; // 每突发写8个节拍 localparam BASE_ADDR 32h10000000; localparam S_IDLE 3d0; localparam S_ADDR 3d1; localparam S_DATA 3d2; localparam S_RESP 3d3; localparam S_DONE 3d4; reg [2:0] state; reg [31:0] waddr_reg; reg [31:0] wdata_cnt; reg [3:0] beat_cnt; reg awvalid, wvalid, bready; reg awlast_reg, wlast_reg; always (posedge axi_aclk or negedge axi_aresetn) begin if (~axi_aresetn) begin state S_IDLE; awvalid 1b0; wvalid 1b0; bready 1b0; wlast_reg 1b0; waddr_reg 0; wdata_cnt 0; beat_cnt 0; end else begin case (state) S_IDLE: begin if (start_en) begin waddr_reg BASE_ADDR; wdata_cnt 32d0; beat_cnt 4d0; state S_ADDR; end end S_ADDR: begin awvalid 1b1; axi_awaddr waddr_reg; axi_awlen BURST_LEN - 1; axi_awsize 3d2; // 32位数据宽度 axi_awburst 2b01; // INCR axi_awprot 3d0; if (axi_awready awvalid) begin awvalid 1b0; state S_DATA; end end S_DATA: begin wvalid 1b1; axi_wdata wdata_cnt; if (beat_cnt BURST_LEN - 1) begin wlast_reg 1b1; end if (axi_wready wvalid) begin wdata_cnt wdata_cnt 1b1; if (beat_cnt BURST_LEN - 1) begin wvalid 1b0; wlast_reg 1b0; beat_cnt 4d0; state S_RESP; end else begin beat_cnt beat_cnt 1b1; end end end S_RESP: begin bready 1b1; if (axi_bvalid bready) begin bready 1b0; state S_DONE; end end S_DONE: begin state S_IDLE; end default: state S_IDLE; endcase end end assign axi_awvalid awvalid; assign axi_wvalid wvalid; assign axi_wlast wlast_reg; assign axi_bready bready; assign axi_awaddr waddr_reg; assign axi_wstrb 4hF;这段代码有几个地方需要特别解释。awlen和awburst虽然在S_ADDR状态里被赋值但注意这些信号必须在握手成功的时刻保持稳定。awvalid拉高后如果Slave迟迟不回awreadyawaddr等信号就要一直不变所以我把它们放在了S_ADDR状态内持续驱动。这是初学者最容易忽略的细节。wlast的生成要格外小心。它必须在最后一个节拍被Slave接收时拉高而且一旦接收完成就要立刻拉低。这里我用的方式是在beat_cnt等于BURST_LEN-1的那一拍让wlast_reg置1等到awreadywvalid双高确认这一拍接收完成后再把它清零。4.3 为什么突发长度必须减1AXI4协议规定awlen字段表示的是“节拍数减1”而不是节拍数本身。也就是说你希望一次突发传8个数据那么awlen要写7。这个逆向表示让很多新手困扰我最初也在这里栽过跟头。由于awlen是地址通道信号它要在地址握手阶段被Slave采样。一旦握手成功地址通道的任务就结束了awlen的值即使再变化也无关紧要。所以代码里我不需要持续保持awlen字段只要在S_ADDR状态内把值赋好就行。4.4 综合与实现的注意事项这个精简Master在综合时要注意两点。第一复位信号建议使用异步复位并且与AXI总线的aresetn保持一致避免复位不同步导致总线握手卡死。第二如果你把这段代码封装成IP接口命名要严格对应AXI协议否则连接时开发工具会自动加前缀。我自己实测中直接把这段代码作为顶层模块然后手工把它的axi_*接口连到S_AXI_HP0上也完全可以工作。如果你更习惯图形化先用IP向导封装好再用Block Design连线流程也不复杂。关键是把代码里那些valid、ready信号理解透。5. PS端验证与缓存一致性最容易被坑的地方5.1 SDK里读取物理地址的正确姿势PL通过AXI4写完DDR地址后PS端用C代码读取看起来只是定义一个指针然后打印。但在ZYNQ裸机环境里这并不简单。最安全的方式是使用Xil_MapPhysAddr。我在实验中常用的做法如下#include xil_io.h #include xil_cache.h #include xil_printf.h #define TEST_BASE 0x10000000 #define TEST_LEN 256 unsigned int *addr; int main(void) { int i; int errors 0; // 将物理地址映射为可访问的虚拟地址同时关闭该段地址的缓存 addr (unsigned int *)Xil_MapPhysAddr(TEST_BASE, TEST_LEN * 4); if (addr NULL) { xil_printf(Map failed!\n); return -1; } // 对DMA写入的区域做一次cache失效操作确保读到的是PL写进去的数据 Xil_DCacheInvalidateRange((u32)addr, TEST_LEN * 4); for (i 0; i TEST_LEN; i) { if (addr[i] ! (unsigned int)i) { xil_printf(ERROR: addr[%d] %d, expect %d\n, i, addr[i], i); errors; } } if (errors 0) { xil_printf(All data check OK!\n); } return 0; }这段代码里Xil_MapPhysAddr做两件事把物理地址转换成MMU映射后的虚拟地址并且默认配置为不可缓存Xil_DCacheInvalidateRange则显式地把D-Cache中对应行失效强制从DDR重新读取。两者缺一不可。5.2 为什么读到的数据会是全零或者乱码在PL写DDR的实验里最经典的“成功但读不到”的故障就是缓存污染。假设你在PL侧已经成功写入数据但PS的MMU把这段地址映射为可缓存读操作会先访问Cache。如果Cache里保存的陈旧数据是0CPU读到的就是0如果Cache里有之前写过的残留读到的就可能是乱码。我建议在BSP里就把映射配置好或者在每次读取前都习惯性调用Xil_DCacheInvalidateRange。不要试图在PL写和PS读之间只做一条Xil_printf来判断因为打印本身可能会触发Cache行为导致结果看起来时好时坏。另一类全零原因更基础PL里的Master模块没有真正开始运行。有些初学者把start_en信号接到了外部开关上但忘了把开关拨到有效电平。所以排查故障时第一件事是去SDK或者仿真里确认Master的状态机到底有没有进入S_ADDR。5.3 验证程序的预期结果如果一切正常串口会打印出All data check OK!如果某个地址出错会打印类似ERROR: addr[3] 5, expect 3看到这个格式定位就非常方便。addr[3]应该是3却读到5多半是wdata和waddr的时序错位或者数据通道多写了一拍。addr[255]那批数据如果错乱则可以重点检查突发长度是否超出范围以及wlast的位置是否正确。6. 实测中的几个问题与调优建议6.1 现象一写入完成但PS读到的数据还是旧值这个现象我遇到得最多。原因通常是两类一是Cache一致性二是在DDR地址错误。Cache问题的解决方式刚才已经说过。地址错误的话可以从PL侧单独抓出axi_awaddr和axi_wdata用在线逻辑分析仪查看实际总线事务。这里有个小技巧实验时把PL写的起始地址故意设成容易被识别的值比如0x12345678然后将PS端打印出读到的首个地址。如果首地址完全对不上说明地址计算或连接有问题如果首地址对但后续数据乱说明突发长度或wlast有问题。6.2 现象二数据整体后移或中间多了几个0数据后移多半是因为某一拍wdata没有在wready为高的时刻更新。AXI总线的wdata必须在wreadywvalid双高时才算被接收如果你的数据累加逻辑写成了“每时钟加一”而不是“每握手成功才加一”就可能出现某个数据被重复或者跳过。还有一种常见的错乱是数据高16位丢失这通常是因为awsize设置成了2b01也就是半字16位但wdata总线上却放的是完整32位数据。Slave会按awsize来解析有效字节导致结果对不上。每次在S_ADDR状态时都检查一下awsize是不是与你设计的位宽一致。6.3 现象三突发长度超过AXI限制AXI4规定INCR突发最多支持256个节拍如果你的awlen不小心设成了256实际值会被Slave解释为1节拍。超出范围的突发长度通常不会直接报错而是表现为数据量不对且不容易察觉。所以写代码时我会把BURST_LEN参数做成常量并且始终使用BURST_LEN - 1赋给awlen。如果数据总量很大就拆成多次突发每次突发结束后再回到S_IDLE或发起下一次地址请求。6.4 性能估算与后续扩展方向对于这个实验数据量很小性能不是重点。但你可以用同样的结构估算带宽假设时钟100MHz、数据宽度32位、突发长度16每次突发传输16拍但地址握手和响应需要额外开销实际效率大概在70%~85%之间。公式很简单有效数据字节数除以地址握手周期数数据周期数响应周期数再乘以时钟频率。更进一步如果你的数据源本身是连续的数据流建议把它转成AXI4-Stream格式然后用AXI DMA来做搬运。DMA的好处是可以在无需CPU参与的情况下自动完成多次突发并且支持Scatter-Gather。手写Master更适合固定模式、固定地址的小批量写入两者可以互为备份。另外如果PS端跑的是Linux而不是裸机读取这块DDR同样要注意Cache和MMU问题。你可以为物理地址注册一个连续DMA缓冲区或者使用/dev/mem设备映射并设置强序属性。不过对刚接触ZYNQ的人来说先把裸机这一套跑通再进入Linux环境会踏实很多。最后再分享一个实际调试中的体会。手写AXI4 Master写DDR最难的其实不是代码量而是“看不见”。你没法像调试BRAM那样在波形里轻松看到内存内容。所以我的习惯是第一阶段用仿真工具做纯RTL验证把AW、W、B三个通道的握手工整地打印出来第二阶段上板时在PL侧设置一个计数完成的LED再在PS侧用串口打印读取结果。两个阶段都通过后项目才算真正稳了。如果你也卡在这个环节不妨从最简单的8个数据、单一突发开始调通再逐步扩展长度。祝顺利。