
每个月总有那么几个晚上是要拿来跟DMA、描述符和AXI-stream死磕的。这是AMBA总线系列的第15篇聊一聊AXI-stream上的sg模式。如果你做FPGA高速采集卡、网卡多队列收包、NVMe主控或者视频帧搬运大概率会被“scatter-gather”这个词卡住一段时间AXI-stream单独拎出来就是TVALID/TREADY握手加TLAST收尾简单得不像AMBA家族成员可一旦连上sg DMA控制器什么“描述符读回来不对”“包错位了”“下一个描述符没跳转”之类的怪问题全来了。这篇文章我就按自己实际调过的路子把sg模式的原理、描述符设计、状态机拆分、包边界保护FIFO以及调试现场的那些坑从头到尾捋一遍。1. 为什么AXI-stream的DMA一定要补上sg模式1.1 先从block模式的两个痛点说起传统的DMA块传输block mode流程很直白CPU先设置源地址寄存器、目的地址寄存器、传输长度寄存器然后启动DMA等传输完成中断再来做下一轮。这套模型在数据连续、长度已知的场景下没有任何问题比如把DDR里一段连续的图像数据搬到显示器控制器。但有两个痛点是block模式绕不开的。第一个痛点内存不连续。数据包在内存里往往是分散存放的比如操作系统给网卡驱动分配了一组环形缓冲区每块可能只有2KB或者4KB地址也不是连续的。如果让block模式去搬CPU必须针对每一段不连续内存发起一次独立的DMA传输搬完一段中断一次。每一次传输都要重新写一组寄存器、等一次中断、做一次软件清理中断开销在高速网络下会直接把CPU打满。第二个痛点传输长度预先不可知。拿网卡收包来说硬件在收到包之前根本不知道即将到来的包是64字节还是1518字节。如果用block模式你得提前设置一个足够大的目标缓冲区收到包后再根据实际长度做一次memcpy把多余部分清理掉或者在软件里裁剪。表面看是浪费一点内存实际上引入了大量数据拷贝性能直接打折。问题本质在于block模式把“传输一段数据”作为最小操作单元而这个最小单元要求源地址、目的地址、长度全部连续且已知。但真实系统的数据布局往往是非连续、非定长、动态的。sg模式就是为了解决“非连续非定长”这两个问题出现的。1.2 sg模式的本质用一张描述符表换掉CPU的重复劳动scatter-gather拆开看就是分散和聚集。scatter指源地址在内存中是分散的块gather指要把这些分散块按顺序聚合成一条连续的数据流反过来也可以理解为把一条连续数据流分散写入多个目标缓冲区。sg模式不再让CPU逐段配置DMA寄存器而是让CPU在内存中预先构建一张描述符链表每个描述符负责一段内存的搬运信息地址是多少、长度是多少、下一段跳到哪里。DMA控制器的硬件会自己去取描述符根据描述符里的信息搬运数据搬完一段回写一个状态然后自动跳到下一个描述符。我经常用一个比喻block模式类似你每次叫出租车都要跟司机现场说一遍目的地和走哪条路sg模式则是把一整张配送单交给司机司机按单子一单接一单跑完只需要在全部结束后回来跟你交差。CPU要做的就是把配送单写对剩下的全是DMA硬件的事。1.3 什么场景必须用sg网卡收包、视频帧、大数据文件实际工程里最常见的sg场景基本集中在三个方向网卡多队列收包。驱动预先在内存里铺好一批缓冲区描述符DMA每收到一个包就往当前描述符指向的缓冲区填充填入长度由包实际大小决定填完一个包就跳到下一个描述符。多个队列可以并行使用多条描述符链CPU只负责在软件层消费已经填好的包。视频抓帧。摄像头采集的一帧图像往往要求放到指定的framebuffer但这个framebuffer可能由多个不连续的行缓冲组成sg模式可以让每一行或每一组分块映射到不同地址。大数据块搬运。比如PCIE数据从设备DDR搬到主机内存主机侧的用户态缓冲区可能被虚拟内存系统切得七零八落物理地址不连续sg描述符可以把用户态缓冲区对应的物理页列表组织起来。这些场景的共同特征是数据天然是“包”或者“帧”的形态且缓冲区的物理分布不可控。sg模式本质上就是把“内存布局信息”从控制寄存器里抽出来放到描述符中让DMA在搬运过程中自行查阅。2. sg描述符的字段设计、对齐约束与链表组织2.1 描述符里到底该放哪些字段描述符是sg DMA的“指令集”字段设计直接决定硬件逻辑复杂度与软件易用性。我在项目里通常保留以下几项核心字段具体宽度按系统地址位宽和数据通路位宽调整。字段作用说明源地址 / 目的地址描述符对应的内存块地址根据DMA方向二选一双向DMA则两者都有传输长度本段要搬运的字节数单位必须全局统一建议用字节Next描述符指针指向下一条描述符的内存地址链表组织的关键控制字段包结束标志、中断使能、本链结束标志硬件解析并执行状态字段完成标志、错误标志、实际传输长度硬件回写软件轮询这里有一个容易犯迷糊的地方控制字段里的“描述符链结束”和“包结束”是两回事。描述符链结束表示后面没有更多搬运任务了这是sg传输的全局边界包结束表示当前描述符搬运的数据是一个逻辑包的末尾这是数据语义边界。一个包往往由多个描述符共同完成比如256KB的包由4个64KB描述符拼起来那么前3个描述符的包结束标志为0第4个为1。把这两个标志混在一个bit里后面调起来会很痛苦。2.2 对齐与长度单位最容易埋雷的两个点描述符本身的内存对齐要求实际项目中经常会写进寄存器手册但很多人不关注背后的原因。描述符必须地址对齐到AXI burst边界常见是32字节或者64字节对齐。原因是AXI总线的读突发要求起始地址和突发长度匹配如果描述符落在一个cacheline中间硬件读描述符就可能跨两个cacheline要么做两次读要么引入缓存一致性的额外处理。更实际的影响是一个跨cacheline的描述符软件更新它的时候可能只写了后半部分硬件读到的这半个描述符是旧数据表现成“描述符丢更新”。传输长度的单位也必须在设计初期定死。通常描述符里保存字节数而AXI-stream数据通路里计数器按beat数工作。如果数据位宽是64bit一个beat对应8字节。当字节长度不是8的倍数时最后一个beat上只有部分字节有效对应TSTRB/TKEEP的低n位为1。比如传7字节最后一个beat的TKEEP就是7h7F。很多第一次写DMA逻辑的人把字节长度直接当成beat数去递减导致包尾多传一拍或者少传一拍。2.3 链表组织方式链式、数组式、环形队列的取舍描述符的组织方式直接约束系统吞吐和软件交互开销三种方式各有适用场景。链式单链表最灵活每个描述符用next指针串联适合软件动态拼接任务。缺点是硬件每处理完一个描述符都要发起一次内存读去取下一个描述符的指针延迟较高而且遍历性能差。数组式固定数组把描述符连续放在一片内存中硬件通过基地址加索引访问下一个描述符连续读取时效率很高。代价是不够灵活一旦缓冲区个数固定死扩展性差。环形队列是目前用得最多的方案本质是数组式两个指针。软件维护一个尾指针tail pointer表示提交到哪个描述符为止硬件维护一个头指针head pointer表示处理到哪个描述符。软件写好后更新尾指针硬件发现尾指针前进后再取新描述符。处理完一组后硬件通过中断或者状态轮询通知软件。环形队列把“描述符的分配”简化成了指针增大DMA不必沿着链表跳来跳去吞吐最好。我自己的习惯是高速数据面优先用环形描述符队列而且描述符个数必须是2的幂这样头尾指针的推进可以用位掩码完成避免取模运算。软件侧也不用读取每个描述符的状态只轮询head指针性能优势非常明显。3. 从状态机角度看sg DMA控制器的正常路径与异常路径3.1 核心状态机的五个基本状态sg DMA控制器的主状态机不算复杂但每个状态里都有细节。标准流程可以归纳为五个基本状态。IDLE等待软件启动。收到启动命令后把描述符基地址加载到当前描述符指针寄存器。FETCH_DESC按当前描述符指针发起AXI读把描述符取回到内部寄存器。这里建议一次读够一个完整描述符不要拆成两个32位读。某些实现会在此时顺便校验描述符地址是否对齐、是否落在合法内存范围内。PARSE_DESC解析控制字段和长度字段判断当前描述符是否需要产生中断、是否是包结束、是否非法。如果合法根据描述符配置AXI读/写通道。TRANSFER产生AXI-stream数据流把数据从源描述符指向的地址读到内部FIFO再写到目的地址。传输过程中持续根据剩余长度更新内部计数器同时监视握手的反压情况。UPDATE_DESC回写状态字段把完成标志和错误标志写入内存在描述符里然后根据next指针或者环形队列头指针推进到下一个描述符。如果当前描述符带中断使能这个状态里还会触发中断。用伪代码描述这个循环大概是while (1) { desc read_memory(current_desc_addr); if (desc.invalid) { set_error(); break; } for (i 0; i desc.length / beat_size; i) { while (!axi_stream_ready()); transfer_beat(); } update_desc_status(desc, DONE); if (desc.chain_end) break; current_desc_addr desc.next_addr; }实际硬件里这五个状态还要根据AXI读写通道是否独立而进一步拆分因为读通道和写通道可以流水并行。做好流水后当前一个描述符还在写回状态时后一个描述符已经在读了sg链的切换开销能被掩盖掉大部分。3.2 AXI-stream在sg传输里承担的角色AXI-stream在sg DMA里不只是一个高带宽数据管道它还需要把“包边界”这个语义传递出来。AMBA协议中AXI-stream的TLAST就是干这个的当TLAST拉高时表示当前beat是当前包的最后一个beat。TKEEP/TSTRB表示当前beat里哪些字节有效。这给DMA控制器带来了一个有趣的对称性从内存读数据到AXI-stream时DMA需要根据描述符的长度和包结束标志在最后一个beat生成TLAST。反过来从AXI-stream收数据写到内存时DMA需要根据收到的TLAST确定当前包结束了进而决定回写状态、触发中断或者跳到下一个目标描述符。所以AXI-stream的sg DMA本质上是“内存地址空间”与“包流语义”之间的翻译器。描述符告诉它数据放哪里AXI-stream的TLAST告诉它包从哪里断。如果两者对不上比如描述符长度比实际包长多出8字节DMA就会把下一个包的包头一并搬到当前缓冲区造成支付数据错位。3.3 完成通知机制中断不是唯一选择sg模式里中断怎么用直接关系到CPU占用和延迟。最粗糙的做法是每个描述符完成都中断描述符一多软件就沦陷在中海里。较好的做法是按照语义批量通知比如只在包结束描述符上使能中断或者每隔N个描述符使能一次中断。另一种更高性能的机制是tail/head指针轮询。软件不依赖中断而是周期性地去读硬件维护的head指针发现head指针前进到自己提交的位置就知道这一批描述符处理完了。这种方式避免了中断上下文切换的开销适合CPU和DMA之间传递大量小包的场景。实践中我会把两种方式结合起来默认情况下软件用轮询head指针判断批量完成当系统进入空闲态、担心CPU空转时再打开中断作为唤醒信号。硬件设计上只需要给每个描述符的控制字段留一个中断使能位软件自己决定在哪些描述符上置位。3.4 异常路径描述符错误、总线错误与reset策略异常路径才是sg DMA设计里真正见功力的大方。常见的异常有以下几类描述符非法。传输长度为0、地址未对齐、next指针超出描述符表范围。硬件通常在PARSE_DESC状态就能发现做了校验后直接进入ERROR状态并把错误类型和描述符序号写入一组状态寄存器。一定要记录错误描述符的序号软件定位时省去遍历整个描述符表的痛苦。AXI读/写错误。AXI读通道返回RRESP非OKAY或者写通道返回BRESP非OKAY。有可能是地址越界、访问了不存在的内存也可能是PCIE链路发生ECRC错误。这类错误发生在TRANSFER状态中控制器必须停住不能继续推进head指针否则软件无法知道哪些数据传输成功了。总线超时。AXI-stream从设备长时间拉低TREADY导致传输卡死。如果系统里没有看门狗机制DMA会永远卡在TRANSFER状态。我建议在控制器内部设一个超时计数器超过预设门限就进入ERROR状态并触发中断。错误恢复策略方面我倾向于“检测到错误立即暂停等待软件干预”的模型而不是硬件自动跳过错描述符继续跑。自动跳过看起来更鲁棒但会让软件面对一堆成功、失败的交错状态很难保证数据一致性。硬件暂停后软件可以安全地读取错误寄存器、dump描述符表再决定是重置描述符链还是重新排队。4. 带包边界保护的AXI-stream FIFO从接口到实战4.1 为什么普通FIFO不够TLAST才是包边界的关键sg DMA的数据通路里几乎绕不开一个内部缓冲FIFO用来吸收AXI读通道和AXI-stream下游之间的速率差异。但如果只是用一个普通的异步/同步FIFO只存数据不存包边界信息问题很快就暴露出来。假设上游连续发来两个包第一个包长度是3个beat第二个是5个beat。普通FIFO会把两个包的8个beat连续存下来读侧根本不知道第3个beat之后是第二个包的头。如果下游是一个只按TLAST断包的模块它会把两个包拼成一个8-beat的大包。这在网络处理里是致命的在视频行缓冲里也可能造成数据串扰。要让FIFO具备包边界保护必须同时保存TLAST信息并在读侧根据TLAST钳制读操作不允许读出操作跨包。这也是目前带包边界保护的AXI-stream FIFO模块的核心思想。4.2 带边界保护FIFO的接口定义与内部结构下面是一个简洁可用的接口定义数据位宽按64bit设计深度可参数化适用于大部分AXI-stream应用。module axis_pkt_fifo #( parameter DATA_WIDTH 64, parameter DEPTH 512 )( input logic clk, input logic rst_n, // 写侧接DMA读通道或上游模块 input logic s_axis_tvalid, output logic s_axis_tready, input logic [DATA_WIDTH-1:0] s_axis_tdata, input logic [DATA_WIDTH/8-1:0] s_axis_tkeep, input logic s_axis_tlast, // 读侧接下游消费者 output logic m_axis_tvalid, input logic m_axis_tready, output logic [DATA_WIDTH-1:0] m_axis_tdata, output logic [DATA_WIDTH/8-1:0] m_axis_tkeep, output logic m_axis_tlast, // 统计信息 output logic [31:0] pkt_count, output logic full, output logic empty );内部结构建议采用标准双口RAM实现数据存储再附加一套与数据RAM并行读写的元数据RAM专门存TLAST标志。数据位宽继续按64bit走元数据位宽只有1bit。这样数据和元数据的生命周期完全一致读写控制逻辑不会出现错位。4.3 核心逻辑写侧打标、读侧钳位写侧逻辑非常简洁在写入每个beat时一并把TLAST值写入last标志RAM。代码示意always_ff (posedge clk) begin if (wr_en) begin data_ram[wr_ptr] s_axis_tdata; last_ram[wr_ptr] s_axis_tlast; end end读侧是重点。普通FIFO只要非空就可以输出数据但带包边界保护时需要增加一个pkt_end状态当上一个读出的beat已经标记为包尾即使FIFO里还有后续数据也不能继续输出必须等下游信号确认当前包已经接收完毕再放行下一个包的第一拍。简化逻辑可以是logic pkt_end; assign m_axis_tvalid !empty !pkt_end; assign m_axis_tdata data_ram[rd_ptr]; assign m_axis_tkeep keep_ram[rd_ptr]; assign m_axis_tlast last_ram[rd_ptr]; always_ff (posedge clk or negedge rst_n) begin if (!rst_n) pkt_end 1b0; else if (rd_en last_ram[rd_ptr]) pkt_end 1b1; else if (!m_axis_tvalid s_axis_master_issue_first_beat) pkt_end 1b0; end这里pkt_end在读出包尾beat之后的下一个周期置位让m_axis_tvalid拉低从而禁止下游继续读。直到下一个包的第一拍到来pkt_end清零输出窗口重新打开。需要注意的是这个简化逻辑假设下游坚持“包内连续读、包间允许间隙”的协议如果下游要求包间绝对无间隙则需要把pkt_end状态与下游握手结合得更紧密。更稳妥的做法是在RAM入口处统计每个包的拍数包头写入时登记长度读侧按长度计数输出输出计数等于登记长度时强制关断。这种方式对下游更友好但实现复杂度高一些需要处理包长超过FIFO深度的情况。我从实用角度推荐先用last标志法它简单、可综合且足够应对绝大多数AXI-stream场景。4.4 深度与阈值设计既要吞吐又不能丢数FIFO深度怎么定很多人直接拍脑袋选个512或1024其实应该从两个约束去推。第一个约束是吸收突发差异。AXI读通道一次突发可以读多个beat如果你希望DMA读一次突发时FIFO一定能承接整个突发那FIFO剩余空间至少要大于等于最大AXI突发拍数。比如AXI最大突发是256拍64bit位宽就是2KB那FIFO深度如果只有128拍就可能出现写到一半空间不足的情况要么打断突发要么让读通道反压。因此我通常把FIFO深度设为最大突发拍数的2倍以上。第二个约束是包长度分布。如果系统处理的最大包是4KB64bit位宽对应512拍那FIFO深度至少要能装下一个完整包否则大包到来时即使下游有反压也会因为FIFO溢出而丢包。深度取512拍起步比较稳妥如果同时要求吸收较长反压可以考虑设为1024拍。还要设计和读侧联动的阈值信号比如almost_full。当FIFO剩余空间小于最大AXI突发长度时提前拉低s_axis_tready让上游停止写入。这样保证任何时刻都有足够空间接下整个突发从源头避免溢出。5. 调试中踩过的坑与性能实测5.1 描述符与DMA拿到的不一致缓存一致性问题我花过最长时间调的sg问题不是RTL逻辑错而是“软件刚写好的描述符DMA读回来还是旧值”。这个现象在ARMFPGA的异构平台特别容易复现。原因是CPU写描述符时写的内容还待在CPU cache里没有真正落到DDR或者OCM中。DMA控制器访问的是物理内存看不到CPU cache里的新值于是拿到了旧描述符。解决办法有三条路按优先级从高到低把描述符所在内存区映射为non-cacheable或者device memory。这是最干净的做法描述符这种少量高频访问的结构完全不需要走cache。软件提交完描述符后执行cache clean/clean-and-invalidate操作。适用于描述符必须放在普通内存区域的场景但记得要在写完描述符和更新tail指针之间加入内存屏障防止乱序执行。硬件侧重读校验。DMA控制器取回描述符后回读一次描述符中某个事先约定的魔数校验失败则暂停并报错。虽然增加了延迟但调试阶段特别有用。类似的坑也出现在回写状态上硬件回写描述符的完成标志后软件读取时可能读到cache里缓存的旧值。此时软件侧需要在读取前做invalidate或者干脆把状态寄存器放在硬件寄存器空间而不是内存描述符里。5.2 长度对齐、TLAST错位典型问题现象与根因我调过一个PCIE采集场景从FPGA发10Gbps以太网包到主机内存软件收到的包总是不对看逻辑分析仪波形发现AXI-stream的TLAST出现在正常包结尾之后一拍导致主机侧认为包多了几个字节。根因出在描述符长度的换算上。硬件TRANSFER状态把描述符里的字节长度除以数据通路位宽得到beat数但最后一个beat的TKEEP没有正确生成。假设包长是259字节64bit位宽应该发33拍前32拍每拍8字节最后一拍只有3字节有效TKEEP应为8h07。硬件里那拍却把TKEEP保持成全1于是空字节被当成有效数据包尾自然错位了。排查链路是这样的先用ILA抓AXI-stream的TLAST和TKEEP确认TLAST拍上TKEEP的值再回到RTL里查最后一个beat的TKEEP生成逻辑发现长度计数器只算到“总拍数”没有根据余数修改TKEEP。修正后问题消失。这类问题最能说明一个道理sg DMA的传输长度计数不能只看“拍数”必须同时维护“最后一个beat的有效字节数”。建议在描述符回写的状态字段里也保存实际传输字节数方便软件校验。5.3 实测吞吐估算与大包小包的影响sg模式的实际吞吐不是只看AXI-stream位宽和时钟还要把描述符处理开销算进去。理论峰值很好算数据位宽8字节时钟200MHz理论峰值就是1.6GB/s。但物理可达带宽取决于描述符切换、FIFO深度、内存刷新和总线上其他master的竞争。以一个典型环形描述符队列为例每个描述符32字节硬件读它需要一次AXI读突发按4拍算传完一大块数据后回写状态又是一次写突发按4拍算。如果每次传输的数据量是32KB也就是4096拍描述符读写合计约8拍占总时间约0.2%几乎可以忽略。但如果软件把传输拆成很多64B的小描述符每个描述符只对应8拍数据描述符开销却还是8拍吞吐直接砍半。因此实际做驱动时会设一个阈值小于某个长度的数据尽量合并成一个描述符大于某个长度的数据再拆包避免碎描述符把总线带宽吃掉。实测下来当描述符粒度维持在2KB以上时sg DMA的有效带宽可以达到理论峰值的80%到90%。粒度掉到256B以下有效带宽可能跌到50%而且中断频率升高CPU核也会先顶不住。场景描述符大小理论峰值实测有效带宽损耗原因连续大块传输32KB1.6GB/s1.44GB/s描述符开销1%中等粒度传输2KB1.6GB/s1.31GB/s切换开销内存刷新碎块传输64B1.6GB/s0.73GB/s描述符读写占用带宽6. 一些针对sg模式设计的个人实操建议文章快结尾了我想把平时最依赖的几条sg设计准则直接列出来都是踩过坑后的教训。第一新项目不要一上来就铺满描述符链。先用block模式配合中断跑通一条完整链路确认AXI-stream通路、数据位宽、中断链路都没问题再切入sg模式。否则一旦出错软件和硬件嫌疑对半排错工作量翻倍。第二描述符的“链结束标志”和“包结束标志”一定要分开宁可多占一个bit也不要共用。我见过把两者合并的控制字段设计结果一个多描述符的包处理到中间误判链结束DMA提前停了后面的包全丢。第三错误状态寄存器务必完整。至少记录出错描述符序号、错误类型、AXI响应码、当前head指针。没有这组寄存器出问题就只能靠猜靠打印。调试sg DMA时这组寄存器相当于飞机的黑匣子关键时刻救命。第四包边界保护FIFO是sg DMA数据通路里最值得好好打磨的模块。不要贪图省事用普通FIFO硬顶一旦下游对包边界敏感后面补bug的成本远高于一开始多写几十行逻辑。建议把数据RAM和last标志RAM设计成同读同写读写指针完全统一减少逻辑中潜在的错位风险。第五仿真时一定加入随机反压和错误注入。我见过太多设计纯理想波形仿真全绿一上板子遇到TREADY随机拉低就出错。仿真时让AXI-stream从设备随机间隔拉低TREADY并且允许AXI读通道随机返回一次错误响应能提前逼出大部分时序和状态机问题。最后再说个小技巧调试sg模式时先让软件填充一种“描述符脏数据模式”比如在每个描述符的控制字段里写入0x5A5A然后启动DMA查看硬件更新后的状态字段是否按预期变化。这个习惯帮我查出来过两处硬件解析描述符位序的bug效率比对着波形逐拍追高得多。sg模式本身不复杂复杂的是它把内存地址、包语义、总线协议三者揉在一起只要把描述符、状态机、包边界和异常路径这四件事想透它就能成为一套非常可靠的高带宽搬运工具。