
最近这个AMBA总线系列写到第15篇了也陆续收到一些读者的私信。有意思的是问得最多的反而不是协议本身多复杂而是集中在“AXI-stream到底怎么和地址打交道”以及“sg模式是个什么玩意”这两个点上。今天就把AXI-stream和sg模式放在一起聊透顺便把我在实际项目中踩过的一些坑也一并记录下来。先交代一个背景这篇文章讨论的AXI-stream特指AMBA 4.0 AXI协议家族里面那个去掉地址通道、只保留数据通路的轻量级总线。很多人第一次接触AXI-stream会觉得“这玩意好简单不就是拉高valid然后等ready嘛”但一旦落到DMA、视频采集、网络包处理这些实际场景里尤其是碰上sg模式事情就没那么单纯了。这篇文章会从AXI-stream的握手机制讲起深入解析scatter-gather DMA的描述符设计、数据搬运原理、软件配置流程以及硬件实现时容易出问题的几个细节。适合刚接触AXI总线、准备做DMA驱动的FPGA工程师也适合正在调试AXI-stream外设、被数据异常搞到头大的嵌入式开发者。1. 先捋清楚AXI-stream到底是个什么东西1.1 AXI-stream在AMBA家族里的定位AMBA总线家族发展到今天常见的轴头就是三个AXI4full、AXI4-Lite和AXI4-Stream。很多人一开始会把它们搞混其实三者分工非常明确。AXI4面向高性能内存映射访问有独立的地址通道、读数据通道、写数据通道一次突发传输可以长达256拍。AXI4-Lite是AXI4的简化版同样有地址通道但突发长度固定为1主要用来配置寄存器功耗和面积更小。而AXI4-Stream则直接把地址通道给砍了只留下数据通道主机只需要不停地往外吐数据从机只需要负责接收。为什么要把地址通道砍掉答案是为了极致的数据搬运效率。你想在视频流、以太网帧、DDR读写这种场景下数据本来就是顺序流的硬塞地址反而浪费带宽和时序预算。AXI-stream设计哲学就是“不管数据从哪来、到哪去我只管把数据从A搬到B”。而在FPGA内部这种数据通路往往就是两个模块之间直接对接地址这个信息根本用不上。实际项目里最常见的AXI-stream接口形态是主机如DMA引擎、视频时序生成器通过TVALID信号告诉从机“我这个周期有有效数据”从机通过TREADY信号告诉主机“我可以接收”两个信号同时拉高时完成一次数据传输。TLAST表示一帧数据的最后一拍TKEEP则标记每个字节的有效性。1.2 握手机制里容易被忽略的细节AXI-stream最核心的就是握手。规则很简单只有TVALID和TREADY同时有效时数据才算真正传出去了。关键是两个信号不能用组合逻辑直接互相依赖必须有寄存器隔一拍否则整个链路会陷入死锁。实际设计时我一般会遵循一个原则TVALID一旦拉高就不能再凭空撤销除非本拍握手成功。这就是协议里说的“稳定valid”规则。举个例子假设你的数据源是一个FIFO读使能拉走后数据要等一拍才能出来。如果TVALID直接接FIFO非空信号而TREADY接下游FIFO的可写信号那么当TREADY还没准备好时你就把读使能拉高了数据被弹出FIFO但握手没成功数据就丢了。这是新手非常容易踩的坑。正确做法是先用reg缓存TVALID读使能等到握手成功那拍再去拉并且数据通路也要预取一拍保证握手成功时数据已经在总线上等着了。一句话总结AXI-stream看似很简单但握手的时序细节决定整个系统的可靠性。如果你在调试中看到数据偶发丢失、多传一拍、或者总线卡死十有八九都是握手逻辑没写对。2. sg模式到底解决的是什么问题2.1 DMA先遇到“连续地址”这道坎如果要在大内存和外部设备之间搬数据纯靠CPU一个一个字节去拷贝是不现实的。DMADirect Memory Access就是专门干这件事的它拿到源地址和目的地址然后在硬件层面把数据搬完搬完再通知CPU。核心问题在于DMA通常只能搬运一整段连续内存也就是从地址A开始、长度为len的一段连续区域。但实际使用中连续这块地址往往很难保证。打个比方你驱动一块网络DMA去把收到的以太网包存到内存里若操作系统给你分配的接收缓冲区是不连续的几个内存页——这在Linux内核里太常见了那DMA控制器就会陷入尴尬每一小段都要重新配置一次搬完一段中断CPU再配置下一段再搬。由此带来的中断风暴和软件开销在高吞吐场景下会直接把CPU打满。另外一个典型场景是视频帧。摄像头送进来一帧1920x1080的YUV数据你想把它存到某个物理上不连续但虚拟地址连续的区域或者反过来把内存中几个不连续的碎块拼成一个完整的帧送给显示器。普通DMA无能为力只能靠软件先拷贝到一块连续缓冲增加了一次无效的内存拷贝。2.2 scatter-gather用一个链表把“碎片”串起来sg模式的思路非常直观既然内存是不连续的那就用一张表把这个DMA传输需要搬运的所有物理内存块称为segments或scatter entries描述出来。控制器自己按照这张表依次搬运搬完一段再读表中下一段直到整个链表都处理完。这张表里的每一项叫“描述符”descriptor。描述符里通常包含源地址或目的地址、传输长度、控制标志比如是否是最后一个描述符、是否产生中断等以及一个指向下一个描述符的指针。描述符之间通过这个指针串起来形成一个单向链表。地址空间连续的描述符也可以预先排成一个环ring buffer控制器读到尾部自动回绕到头部。sg模式的价值在于CPU只需要把描述符链表搭好告诉DMA控制器“从链表头开始干活”然后就可以去忙别的了。DMA控制器自己会一路顺着链表搬完所有数据最后通过中断通知CPU。整个过程中CPU参与度极低内存拷贝也省掉了吞吐量明显上升。网络设备、NVMe控制器、GPU驱动等等底层DMA基本都是这个套路。3. sg DMA的工作原理和描述符解析3.1 描述符长什么样的不同IP的sg描述符结构略有差异但核心字段是趋同的。以Xilinx AXI DMA IP的SGDMA描述符为例每个描述符在内存中占据固定的几个字比如4个字即16字节依次排列为下一个描述符指针NXTDESC指向链表下一项由软件在传输前填好硬件处理完当前项后通过它找到下一段地址。缓冲地址BUFFER_ADDRESS指向当前这一片段的源或目的物理地址。控制字段CONTROL包含传输长度、是否要产生中断、是否是最后一个描述符、以及TX方向时是否发送TLAST等标志。状态字段STATUS硬件在完成传输后回写包括传输是否成功、实际传输的字节数、是否出错等。描述符本身也存在主存里。硬件可以有一块别名区也可以动态地通过描述符指针跳转。Xilinx IP还有一个“缓存描述符”cyclic BD模式用在比如DAC连续输出的场景所有描述符预先连成一个环硬件永远循环跑不存在“最后一个描述符”的概念。这是sg模式一个非常实用的变体。3.2 硬件如何沿着链表一路搬数据为了把sg DMA的工作流程讲透我们把它拆成几个阶段。第一阶段软件准备描述符。CPU在内存中申请一块连续内存或者几块散页只要描述符本身能通过物理地址索引到即可逐项填充描述符内容最后一项的NXTDESC写成0或者写成首地址构成环然后通过寄存器把起始描述符地址写入DMA控制器的当前描述符指针寄存器。第二阶段DMA控制器取描述符。硬件一旦接到启动命令首先从当前描述符指针指向的内存地址读取第一个描述符。这个读操作本身也是一种AXI访存操作通常由主接口完成。读回来后硬件解析出缓冲地址、传输长度和控制标志进入数据搬运状态。第三阶段数据搬运。根据方向如果是从内存往外设Memory-Mapped to StreamMM2S搬运硬件会按AXI4读地址通道发起读突发拿到数据后通过AXI-stream写通道把所有数据按流式协议送出。反方向Stream to Memory-MappedS2MM则刚好相反硬件从AXI-stream接口接收数据通过AXI4写通道写进内存。第四阶段状态回写与中断。当前描述符传输完成后硬件将状态字段回写到该描述符的STATUS区域如果控制字段里设置了产生中断硬件就在此刻向中断控制器发出中断请求。然后检查NXTDESC如果非零就把当前描述符指针更新为它重复第二阶段如果为零则进入idle状态并向软件报告传输完成。整个过程看起来线性但实际工程里往往还要处理“半满中断”——即已经用了一半描述符时提前通知软件填充下一批描述符避免DMA出现空洞。Linux内核里dmaengine框架的tx_submit、tx_status机制就是围绕这整套状态机设计的。3.3 中断和缓存一致性的“暗坑”在带CPU的SoC上跑sg DMA还有一个躲不开的话题缓存一致性。现代CPU通常都有一级二级缓存CPU写描述符时数据可能还在Cache里没有被写回内存DMA控制器直接去读内存就会读到旧数据。反之硬件回写STATUS到内存CPU去读也可能读到Cache里的旧值。最简单的解法是在软件配置前和硬件结束后做Cache flush/invalidate操作。Linux内核里dma_map_single、dma_alloc_coherent这些API本质就是在帮你管理DMA缓冲区的物理地址映射和Cache同步。如果你是在裸机环境自己写驱动千万不要忘了在填完描述符后做一次cache clean操作在读取STATUS之前做一次cache invalidate否则调试起来会怀疑人生。4. 软件视角配置sg DMA的完整流程4.1 寄存器视图和启动流程这里拿Xilinx AXI DMA的SG模式最常用的寄存器来举例因为在实际项目里遇到它的概率极高。先说寄存器基地址一般通过AXI4-Lite接口访问常见的有以下这些MM2S_DMACR0x00MM2S DMA控制寄存器第2位是Reset第4位是Keyhole第7位是Cyclic BD Enable第12位是IOC_IrqEn中断使能第14位是Run/Stop。MM2S_DMASR0x04状态寄存器包括Halted、Idle、IOC_Irq、Dly_Irq等标志位IRQ清0方式是写1清0。MM2S_CURDESC0x08当前描述符地址软件预先写入起始描述符物理地址硬件搬运时自动更新。MM2S_TAILDESC0x10尾描述符地址软件写入链表尾部描述符地址后硬件认为链表闭合同时开始搬运。MM2S_SA0x18在简单的non-sg模式下用sg模式下忽略。S2MM方向对应的寄存器类似偏移在0x30开始。一个典型的MM2S sg传输流程是这样的分配描述符内存并用Cache flush确保描述符已经到物理内存。初始化DMA控制器先把DMACR的Reset位写1再写0等待DMASR里的Halted位变高完成复位。然后配置Cyclic位和中断使能位。把起始描述符地址写入CURDESC寄存器。把尾描述符地址写入TAILDESC寄存器。这一步是一个“触发”动作写入后硬件就会立刻开始取描述符、搬数据。等中断。中断服务程序里读DMASR、判断IOC_Irq位清理中断查看STATUS字段确认传输结果然后填充下一批描述符。S2MM方向的核心流程一致只是硬件会在接收完数据后把数据写到描述符指定的内存地址。如果你在嵌入式Linux里用dmaengine框架这些寄存器操作会被dmaengine的驱动封装掉例如xilinx_dma的of_dma_router注册好之后用dma_request_chan和dmaengine_prep_slave_sg就可以直接提交一个sg request。4.2 描述符链表的维护策略软件维护描述符链表时最容易出的问题是对“当前描述符已经被硬件读走了吗”的判断错误。一个比较稳妥的做法是给每个描述符的STATUS字段加一个归属标志位。比如用STATUS低32位的bit 31表示“owned by hardware”初始为1硬件处理完后写成0。软件分配新描述符时检查这个位若还是1说明硬件还没处理完不能改否则覆盖描述符会导致DMA读到不一致的数据。另一个策略是轮询STATUS里记录的实际传输字节数。如果你发现某个描述符的累计长度与预期不符往往说明描述符被提前覆盖或地址配置错了。在实际项目中我建议一次性把描述符池开大一些例如128个描述符而不是只开几个。这样就算中断响应有延迟也不容易把硬件“饿死”。4.3 驱动代码的骨架示例下面给一段简化的裸机驱动代码骨架帮助理解软硬件配合关系。这里省略了具体总线访问函数但流程是完全可参考的。#define DMACR_OFFSET 0x00 #define DMASR_OFFSET 0x04 #define CURDESC_OFFSET 0x08 #define TAILDESC_OFFSET 0x10 struct sg_desc { uint32_t nxtdesc; uint32_t buf_addr; uint32_t control; uint32_t status; } __attribute__((aligned(16))); static struct sg_desc desc_pool[128]; static struct sg_desc *cur_desc; void dma_prepare_tx_desc(struct sg_desc *desc, uint32_t dest_addr, uint32_t len) { desc-nxtdesc 0; desc-buf_addr dest_addr; desc-control len | SG_CTRL_TXSOF | SG_CTRL_TXEOF; desc-status 0x80000000; // owned by hardware } void dma_submit_tx(struct sg_desc *head, struct sg_desc *tail) { // 确保描述符已经写回内存 cache_clean((uint32_t)head, sizeof(struct sg_desc) * 128); write_reg(DMACR_OFFSET, DMACR_RESET); while (read_reg(DMASR_OFFSET) DMASR_HALTED 0); write_reg(DMACR_OFFSET, DMACR_IOC_IRQEN | DMACR_RUN); write_reg(CURDESC_OFFSET, (uint32_t)head); write_reg(TAILDESC_OFFSET, (uint32_t)tail); // 触发传输 }实际的Linux驱动里Xilinx的xilinx_dma驱动还会在tx_submit里把描述符挂到ring队列并在tx interrupt里重新填充新的描述符。这里就不展开讲完整的Linux驱动了。5. 硬件视角sg DMA数据通路的关键设计5.1 取描述符状态机的设计如果要在FPGA里自己写sg DMA的RTL核心是设计一个状态机常见状态包括IDLE、FETCH_DESC、START_DATA、RUN_DATA、WRITE_STATUS、FETCH_NEXT。IDLE下等待软件启动FETCH_DESC状态下发起一次AXI4读请求去读描述符读回后解析字段进入RUN_DATA状态搬运数据当一次突发或一个descriptor的缓存长度耗尽回到WRITE_STATUS回写状态最后根据NXTDESC判断是继续取下一个描述符还是回IDLE。状态机里有一个需要重点处理的信号在RUN_DATA状态中如果AXI-stream的对端一直不拉TREADY或者AXI4总线的读数据返回非常慢DMA不能卡死。通常做法是让内部FIFO先把数据缓冲起来backpressure由FIFO的满信号提供。说白了内部数据通路要有解耦能力。另外地址递增逻辑也很关键。每个描述符对应一个独立的缓冲区起始地址在搬运该描述符时读地址或写地址要从BUFFER_ADDRESS开始每拍递增递增粒度取决于数据总线的位宽。例如64-bit数据总线相当于8个字节那每拍地址增加8。如果你在写RTL时把地址递增和TLAST信号对不上就可能出现存到内存里的数据整体错位。5.2 跨时钟域处理与性能权衡在异构SoC里AXI-stream接口的时钟往往是外设的时钟比如125MHz的MAC时钟而AXI4内存接口则跑在DDR控制器的时钟域比如300MHz。sg DMA的取描述符状态机是跑在哪个时钟域呢通常是AXI4侧的时钟域。这样描述符的读写和内存搬运都在一个时钟域里而AXI-stream侧只需要用异步FIFO做数据缓冲和跨时钟域转换即可。性能好坏往往就卡在这里。如果你设计的FIFO深度太小比如只有32个64-bit字那么当AXI-stream侧在高速持续发送数据时FIFO很快满TREADY被拉低外设被暂停。高吞吐场景下建议FIFO深度至少能缓存一个AXI4突发长度的数据比如8个256-bit字的突发对应64字节。如果做视频这种大流量场景FIFO深度至少要给到1KB~4KB才能在频率抖动时保持吞吐。5.3 描述符预取与延迟隐藏每次传输前都要从外部DDR取描述符如果DDR访问延迟较高DMA的搬运动作会时不时出现“气泡”。一种改进方案是增加描述符预取硬件在搬运当前描述符的同时提前把下一个描述符读回来放在FIFO里。这样当下一个描述符开始时地址已经就绪了。类似CPU分支预测的思路。代价是状态机复杂度上升因为可能出现在预取描述符时外部DDR带回了比预期更多的数据需要额外的缓冲空间管理。实际做的时候可以设计一个小的描述符缓存比如4个描述符的深度按顺序预取按顺序消费。这样即使DDR的访问需要大量cycle只要缓存不空DMA就能持续以线速搬运。按我的经验内部数据通路的带宽至少要留出20%的余量。比如你设计运行在200MHz、数据总线128bit理论带宽是3.2GB/s但实际应用只需要2.5GB/s那期间取描述符、回写状态等操作才不会拖后腿。把余量压缩到10%以内碰到DDR刷新周期或者QoS仲裁性能波动会非常明显。6. 常见问题与排查技巧实录6.1 数据总是错位或者最后多出一截这类问题优先查AXI-stream的TKEEP信号。TKEEP是按字节去标记有效数据在哪里的当一个传输的最后几个字节不足一个总线位宽比如总线64bit、数据只有7字节最后那拍TKEEP不能是全F。如果硬件把TLAST置位的同时没有将TKEEP的对应高位清零外设就会把多余字节当成有效数据收进去。还有一种常见原因是地址递增宽度与数据总线宽度不匹配导致DESC里的BUFFER_ADDRESS是按字节跳了但读数据时每次都用相同的低地址结果每拍都在读同一个位置。排查手段很简单先关掉sg模式用一个已知图案的连续地址做一轮简单搬运抓取写入内存的数据比对首尾字节。如果连续模式下数据正常再切换到sg模式随机设置几段长度各异的描述符重点观察每一段边界的数据。这样能快速把问题定位到“地址计算”还是“握手时序”。6.2 描述符回写状态为0或者超时收不到中断先确认CURDESC里的值是否被硬件更新了。如果硬件根本没去取描述符通常是因为DMACR里的Run/Stop位没置位或者TAILDESC写入后没有真正触发。另一个极其常见的坑是描述符地址写错了写成了虚拟地址。DMA访问的是物理地址在MMU开启的情况下驱动里拿到虚拟地址必须通过virt_to_phys或dma_map_single转换否则硬件读回来的描述符内容是明显错误的。如果你检查下来地址没问题中断也一直没有那就查一下中断控制器侧比如GIC里是否使能了对应的SPI。Xilinx的AXI DMA IP在ISE/Vivado里提供了中断引脚但一般都要在系统级设备树里正确配置中断号。设备树里dma-channel的中断属性配错会导致驱动启动了但永远等不到中断回调。6.3 高负载下偶尔传输失败重启后恢复这类“偶发问题”十有八九和缓存一致性或者缓冲区生命周期管理有关。用过Linux内核的朋友应该知道dma_alloc_coherent拿到的缓冲区是没有Cache的用来做描述符是最稳的。如果拿普通kmalloc内存做描述符又没有在适当时间做dma_sync_single_for_device就可能出现硬件读到了“半个新描述符、半个旧描述符”的混合态。另一种偶发失败场景是描述符被过早复用。当软件在中断服务里填充新的描述符时如果硬件还没来得及把上一个描述符的STATUS回写软件就把描述符改了之后硬件回写STATUS会覆盖新值造成状态错乱。规避办法也很直接每个描述符加一个独立的“done”标志位只有看到done为1时才允许重新填充。宁可多等待几个cycle也不要自作聪明去抢描述符的所有权。6.4 吞吐量上不去总线上出现大量气泡关注两个方向。一个是AXI-stream侧的TREADY拉低频率如果TREADY经常为低说明接收端FIFO不够或后端处理不够快另一个是AXI4侧的写突发长度burst length。AXI4突发长度上限是256拍但很多DMA控制器默认只给16拍、32拍的短突发这会导致每个突发都要重新仲裁一次总线开销很大。把这些控制寄存器里的突发长度调到最大同时确认内存控制器允许对应的INCR突发往往立刻就能看到性能提升。除此之外描述符取回的间隔也值得关注。有些IP的设计是“搬完一个描述符才取下一个”这样在DDR访问延迟高的系统里描述符取回的几十个cycle就是纯粹的气泡。换到支持预取的IP或者自己优化RTL加入预取逻辑吞吐可以拉升20%左右。在我手头一个实际案例里视频输出通路原本跑720p都吃力查来查去最后发现是AXI4读突发只有16拍把突发长度改成64拍后1080p轻松不少。这类问题往往不体现在单次传输的对错上而是隐藏在对总线带宽利用率的影响里需要边测边调。7. 经验总结和一些调试建议7.1 调试sg DMA时怎么搭测试环境对于sg DMA的调试我的建议是抛弃“先写全驱动再调试”的思路。应该分三步走。第一步用最简单的non-sg模式只搬运一块固定地址连续的数据确认AXI4读写通路本身是通的。第二步写一个最小的sg使能程序用两个描述符各搬一段数据人为打印硬件回写的STATUS和实际传输长度确认第二个描述符能正确衔接。第三步再引入中断和Cache管理。这里调试工具的选择也很重要。FPGA侧用ILA抓AXI-stream和AXI4的接口信号重点关注TVALID/TREADY同时拉高的频率以及TLAST位置是否与描述符长度吻合。SoC侧用串口打印描述符状态字配合Bus Monitor工具观察DDR读写的地址分布。两种手段结合基本能覆盖大多数场景。7.2 踩过几次坑之后的几点建议第一点描述符的结构体定义一定不能有编译器的自动填充。C语言结构体会按照对齐规则在成员之间插入padding如果你的描述符期望是固定的16字节而结构体实际占了20字节硬件解析就会错位。所以在驱动代码里务必用pragma pack或者手动填充uint32_t字段保证结构体大小与硬件预期一致。第二点中断服务函数里不要做复杂操作。sg传输完成后的数据整理应该放在下半部处理比如tasklet或workqueueISR里能做的就是读状态寄存器、清中断、回收描述符索引、唤醒处理线程。如果直接在ISR里做大量Cache操作和DMA映射中断延迟会暴涨还可能影响到其他实时性要求高的中断。第三点链路一旦有异常不要急着改硬件。先把软件里的描述符状态全部打印出来看看是哪个描述符卡住然后反推硬件流程。很多所谓硬件问题最后发现是描述符的ownership位用反了。7.3 后续可以往哪个方向扩展sg DMA这套机制本身非常成熟如果你已经把它搞通了下一步可以尝试在FPGA上实现一个自定义的SG引擎比如同时管理多个通道、支持描述符的优先级调度、实现多个队列的并行搬运。这些对网络芯片、视频处理芯片设计都很有用。另外可以关注一下AXI-stream的tkeep/tstrb和字节交换逻辑在视频数据重排上的应用这属于AXI-stream在图像预处理中非常经典的玩法。如果对协议还有兴趣建议认真读一遍AXI4-Stream Protocol SpecificationARM IHI 0051A以及Xilinx AXI DMA的PG021文档尤其里面关于环形描述符和scatter-gather的章节写得比一般博客清楚很多。不过文档终究是文档真到了调板子的时候还是得靠状态机时序图和串口打印来定位问题。