ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

100G FPGA板卡UDP协议栈移植实战:从回环到双向打流

100G FPGA板卡UDP协议栈移植实战:从回环到双向打流 把一块100G光口的FPGA板卡真正跑起来第一步往往不是写RTL而是先找到一条能用的UDP通路。我这次要做的项目就是把一套开源UDP协议栈整体移植到自研板卡上完成上板测试和双向打流验证。说难不难说简单也不简单中间遇到不少和文档对不上的坑实测链路从MAC层一路打到应用层光是抓包定位就花了两天。如果你正准备做类似的事这一篇的经验应该能帮你省掉不少弯路。这篇文章我会从板卡评估、协议栈选型、代码拆解、实际移植、上板测试到问题排查按我自己操作的真实顺序来写。重点是那些文档里不会告诉你的事接口时序怎么对齐、跨时钟域在哪里断、抓包抓不到首包、丢包到底是协议栈的问题还是DMA的问题。等你把这套流程走完一颗100G UDP收发的移植节奏基本就心里有数了。1. 移植前的板卡评估与开源协议栈选型1.1 板卡资源盘点光口、GT参考时钟与散热约束我手上的板卡是VU9P级别的器件板载两颗QSFP28光口配套光模块是100G SR4PCIe部分走Gen3 x16DDR4容量和带宽都够。正式写代码前我先把器件资源和引脚约束过了一遍这一步看起来很基础但在100G场景下真的不能跳过因为引脚约束错了后面定位问题会非常痛苦。首先要确认的是GTY高速收发器能不能覆盖100G Ethernet需要的速率。100G SR4在FPGA侧通常是4条25.78125Gbps的SerDes通道每条通道需要一个GTY Bank同时需要一个干净的高频参考时钟。Vivado里打开器件视图确认目标Bank能提供4个连续GTY通道并且参考时钟管脚没有被别的接口占用。我这次就遇到参考时钟管脚被PCIe核占掉的情况最后改从可编程时钟芯片引出一路156.25MHz给GT参考时钟这个频率对100G Ethernet是标准频率。然后是热设计。100G协议栈全速跑起来GTY阵列和MAC逻辑的功耗非常夸张。我上板前专门看了静态功耗报告在没跑业务时核心电流就有明显抬升如果板卡散热片或风道不够长时间打流会把器件温度顶到85度以上影响时序收敛和SerDes眼图。建议拿到板卡先跑一个30分钟的IBERT压力测试确认物理层在高温下不出现误码再做逻辑移植。这一步能省掉后面很多“逻辑莫名其妙出错”的排查时间。1.2 开源协议栈选型只看能不能编译还不够开源FPGA以太网协议栈有不少但我选型的标准不只是“能编译”而是看三个硬指标MAC是否原生支持100G、用户接口是否是标准AXI-Stream、代码里是否自带校验和卸载和ARP/ICMP处理。我最后选的是之前项目里用过的那套开源以太网IP套件仓库里有整理好的10G/25G/40G/100G MAC、UDP发送接收模块、ARP模块和ICMP模块用户接口统一走AXIS。相比官方闭源核它最大的好处是源码完全可见想要加多队列、改水线、自定义调度逻辑都方便不需要等厂商release。为什么不用闭源IP核一方面是许可证成本另一方面是调试自由度。闭源核在综合后只剩黑盒内部时序和状态机出了问题只能通过端口行为猜测而开源协议栈可以一路把ILA探针插到内部节点定位问题效率高得多。100G场景下问题往往出在跨时钟FIFO和位宽转换逻辑这些在黑盒核里很难观察。1.3 最小系统先行时钟、复位和MAC回环正式移植前我搭了一个最小系统来验证底层通路一个100G MAC核、一组时钟复位、一个发送FIFO、一个接收FIFO然后在逻辑内部把发送通路直接回环到接收通路。这一步不经过光模块也不经过UDP协议栈目的是验证MAC IP的user接口能不能正常收发数据。最小系统的时钟树要特别确认。100G MAC的用户逻辑接口通常是512bit位宽逻辑时钟在200MHz上下而GT侧恢复时钟来自CDR。两边的时钟域通过IP内部异步FIFO隔离但复位信号必须统一做异步复位同步释放否则会出现偶发的复位释放时序违例。Vivado默认会在IP内部处理一部分复位同步但如果你在用户逻辑里对MAC的复位信号又做了一次组合逻辑处理就可能引入毛刺。我习惯把MAC提供的user_clk和user_rst直接拉到顶层所有下游逻辑都只认这组时钟和复位不再额外生成自己的复位信号。最小系统跑通后用ILA抓一眼TLAST和TVALID的握手确认回环数据能连续流动再开始做UDP协议栈的移植。这一步虽然基础但能帮你在进入复杂协议栈前先排除物理层和IP配置的问题。2. 开源UDP协议栈代码拆解收发通路里的关键模块2.1 收方向MAC之后先过FIFO再解UDP开源的100G UDP协议栈在接收方向上的数据通路大致是MAC接收端先把GEARBOX和PCS处理好的数据包送出来经过一个异步FIFO跨越GT时钟域进入UDP接收模块。UDP接收模块做的事情很简单解析以太网头、IP头、UDP头把payload剥离出来打包成AXIS数据流送给用户逻辑。这个流程在10G时代很常见但100G下有两点必须留意。第一FIFO深度。100G线速下一个1500字节的包在512bit位宽总线上的持续时间很短如果用户逻辑没有及时拉低readyFIFO很快写满。开源代码默认FIFO深度可能只有几千字节在小包冲击下很容易溢出。我在移植前直接加大到8KB以上并且把FIFO的Almost Full水线留给用户逻辑做反压提前量。第二包头解析的时序。UDP接收模块通常是在tvalid拉高后用状态机把前几个周期的tdata当作以太网头和IP头来解析。这个逻辑在64bit位宽下很好处理因为一个周期就能读完整包头但在512bit位宽下一个周期进来的数据可能包含多个包也可能一个包跨多个周期解析逻辑就必须处理header跨边界的情况。开源代码里这一块实现得相对简单如果输入FIFO的位宽和MAC user接口位宽不匹配要先做位宽转换否则包头解析会错位。2.2 发方向校验和卸载和调度器的配合发送方向上用户逻辑把待发送的UDP payload写进AXIS接口UDP发送模块会自动填充以太网头、IP头和UDP头并且计算校验和。开源代码通常支持两种模式一是完全卸载即协议栈内部自己计算IP头和UDP头的校验和二是部分卸载即用户逻辑已经算好了校验和协议栈只负责填充。我强烈建议在实际移植中打开完全卸载模式。原因很简单100G线速小包场景下如果让用户逻辑软件算校验和几乎不可能跟上硬件速率而且一旦算错排查链路会异常复杂。硬件卸载模式下校验和模块会用增量更新算法在一个周期内完成多字节数据校验和的累加对时序影响可控。发送方向的另一个关键点是调度器。如果协议栈里同时有ARP响应、ICMP响应和UDP数据发送多个发送源会竞争MAC发送端口。开源代码一般会有一个简单的round-robin或优先级仲裁器。实测下来大流量UDP传输时如果ARP和ICMP的优先级太低ping响应会明显超时PC端会认为链路不稳定频繁重传。解决方式是把ARP和ICMP的优先级设成最高因为它们数据量很小只占极少的发送机会但能保住链路的“可管理性”。2.3 用户接口约定tuser、tkeep和tlast的含义这个开源协议栈的用户接口是标准的AXI-Stream但有几个信号约定值得单独说清楚否则上板后极容易踩坑。tkeep的作用是标注每个周期哪些字节有效。这个信号在普通数据流里似乎不影响什么但在包尾部分非常重要因为最后一个周期的有效字节长度通常不是满位宽。比如1501字节的以太网帧在512bit位宽下尾巴上只有一个字节有效tkeep要精确置位到bit0。如果你的用户逻辑在重组数据时忽略了tkeep会把填充字节当作有效payload传给上位机导致应用层解析出错。tuser一般用来标记错误包。接收方向上如果MAC检测到CRC错误或者包长不合法会通过tuser告诉下游协议栈默认会把坏包丢掉。这个行为也带来一个坑如果有坏包被丢弃但上游软件还在等待这个包PC端UDP应用可能表现为“长时间没收到数据但连接未断”。排查这种问题时不要只看上层应用要用ILA抓tuser信号确认协议栈是否在有意丢包。3. 把开源代码落进自己工程的三个适配点3.1 时钟域和复位域的切分在哪里断比怎么断更重要开源协议栈里的模块几乎都声明成同一个时钟驱动但真实工程里MAC user接口、UDP解析逻辑、用户业务逻辑和DMA接口往往分属不同的时钟域。如果直接把所有模块塞在同一个时钟下综合时序大概率一塌糊涂如果每个模块都自己搞一套异步处理又会让组合逻辑路径被打散同样时序收敛不了。我这次的切法是这样MAC user接口时钟作为协议栈主时钟所有UDP收发模块都在这个时钟域里跑只在协议栈与用户业务逻辑之间放两个异步FIFO一边是协议栈主时钟一边是用户业务时钟。这样做的理由是协议栈内部模块之间存在紧密的流水线依赖跨时钟冒险隔离点放得越少越好而真正的速率匹配发生在缓冲区边界。复位域和时钟域是同构的。异步FIFO两侧的复位信号分别由各自时钟域的复位同步器产生不能再跨时钟使用。最容易出错的地方是异步FIFO的复位释放时序Xilinx原语级的FIFO自带复位同步但开源代码里如果用通用寄存器搭的同步FIFO就必须在外围自己加两级同步器否则FIFO指针可能进入非法状态。3.2 位宽转换与AXIS对齐规则开源协议栈的UDP模块为了兼容不同MAC通常在内部按64bit或256bit处理数据但我的DMA和用户逻辑是512bit总线。两者不能直接相连必须插一个位宽转换器。位宽转换器的工作不是简单地把数据拼起来而是要同时处理tkeep和tlast的对齐。比如64bit转512bit时要把8个64bit周期拼成一个512bit周期拼接后的tkeep要根据原数据的tkeep组合生成不能擅自当成满位宽。如果转换器做得不严谨最常见的故障是上层收到的payload尾部多出一堆0xFF导致UDP应用层解包失败。这里有个经验位宽转换器最好用FPGA厂商原语自带的比如Xilinx的axis_width_converter IP它在tkeep和tlast的处理上经过大量验证比自己在RTL里手写拼接要可靠。开源代码里即使提供了位宽转换模块移植时也建议再包一层官方IP可以减少很多莫名其妙的字节错乱问题。3.3 DMA描述符接口把协议栈接到PCIe上软件要真正使用FPGA上的UDP收发通路协议栈接口并接到DMA描述符引擎。这一步在纯FPGA回环测试时可以不做但在双向打流测试中必须打通否则上位机看到的只是“硬件能收不能送”的残废链路。接收方向要做的是预先分配好一组描述符指向DMA缓冲区DMA引擎收到协议栈数据后按描述符地址搬运到内存并更新完成状态。这一步的关键是把中断合并和描述符数量配好。我一开始只配了512个描述符打大流量没几分钟就发现丢包原因不是协议栈处理不过来而是描述符用完DMA引擎只能把后续包丢掉直到软件释放描述符才恢复。发送方向要处理的则是内存到FPGA的搬运延迟。软件把UDP包内容写进DMA缓冲区后FPGA侧要能够通过描述符及时感知新数据并启动搬运。如果DMA引擎是轮询描述符状态轮询间隔太长发送带宽会明显掉下来。而如果依赖中断通知小包场景下中断风暴会导致CPU占用居高不下。这个矛盾的缓解方式后面第5章会细讲这里先把描述符数量和水线调大避免在协议栈层面被误判为丢包瓶颈。4. 上板实测回环、抓包与双向打流的完整链路4.1 第一轮内部逻辑回环先确认协议栈收发通路移植完代码后我没有立刻插光模块连PC而是先在FPGA内部做了一个逻辑回环把UDP发送模块输出的AXIS数据流直接接到接收路径的FIFO入口然后把回环出来的数据再送进接收模块。这样不用物理链路就能验证协议栈内部的发送、解析、校验和计算是否正常。为什么先做内部回环因为这样可以绕开所有物理层和DMA的不确定性把所有变量锁死在纯逻辑层面。如果这个回环跑不通问题一定在协议栈本身的解析或状态机上再往上是浪费时间。测试方法是上位机通过PCIe和DMA发送一段已知内容的UDP payload然后在FPGA内部把数据回环后再通过DMA送回上位机。两边比对看遍历长度、校验和、UDPSource/DestPort是否正确。这一轮确实抓到一个问题发送出来的UDP包IP头长度字段比正确值多了2字节查下来是发送模块在处理包头时把FCS占用的位置也算进length了在MAC自动追加FCS的配置下多余算了4字节。这个问题在纯收发的正常流程里可能看不出来但一旦打开逻辑回环错误立马现形。4.2 第二轮PC直连与ARP学习内部回环通过后下一步是把FPGA板卡通过QSFP28光缆直连到服务器的100G网卡上。上电后链路指示灯正常但服务器端死活学不到FPGA的MAC地址ping完全不通。我在这轮排查中走了不少弯路。怀疑过GT参考时钟、光模块兼容性、MAC地址配置最后是用逻辑分析仪抓了发送通路上的ARP响应包才发现问题根源协议栈发送的ARP响应字段里发送方MAC地址填的是广播地址FF:FF:FF:FF:FF:FF而不是我们在配置端口里设定的板卡MAC。根源在于底层代码从错误的位置读MAC地址寄存器高位和低位字节序颠倒了而内部回环时没有PC端ARP这个环节自然暴露不出来。修正办法很简单把MAC地址配置的字节序转换逻辑重新核对然后重新上板PC端arp -a能立即看到FPGA的MAC地址ping也通了。这一轮的经验是ARP是整个TCP/IP链路里最先触发的东西ARP不通后面全白搭而ARP响应的MAC地址字段是排查的重点。4.3 第三轮iperf3双向打流与线速评估链路通了之后开始用iperf3做UDP打流测试。服务器的100G网卡配置好IP地址FPGA端作为服务器端另一端用普通PC做客户端重点测三个维度单向接收带宽、单向发送带宽、双向同时收发时的稳定性。我用的打流命令是典型的UDP模式iperf3 -c 10.0.0.2 -u -b 0 -l 1400 -t 30。-u表示UDP模式-b 0表示不限带宽-l设置包长。首测结果还算理想单向100G带宽下1400字节UDP payload能跑到约99.2%线速且无丢包说明协议栈本身的收发通路没有明显瓶颈。但双向同时打流时问题就出来了FPGA向PC发包方向正常PC向FPGA发包方向却频繁出现丢包丢包率最高跑到40%以上。这个现象我在第5章会展开分析它并不一定是UDP协议栈本身的问题而更可能是DMA描述符和中断合并的不匹配。4.4 小包场景下的性能差异UDP打流还有一个隐藏雷区就是包长不同带来的性能差异。1400字节大包能跑线速不代表64字节小包也能跑100G线速下小包极限超过每秒1.4亿包对协议栈和DMA的压力完全是另一个量级。实测下来64字节小包单向能到约50Gbps再高就开始丢包。这个结果其实是符合预期的因为小包的处理瓶颈已经不在UDP协议栈的数据通路上而在DMA描述符搬运和PCIe中断处理频率上。小包场景下每一个包都占用一个描述符100G线速要求DMA每秒处理一亿多次描述符更新这已经严重超过PCIe Gen3 x16和操作系统的中断处理能力。如果你的应用场景大量依赖小包让硬件做包聚合GSO是更现实的方向而不是在协议栈层面死磕。这个优化我会在第6章展开。5. 测试中遇到的四个典型问题及定位过程5.1 双向打流丢包的真正原因DMA描述符耗尽第4章提到的双向打流丢包问题我一开始怀疑是协议栈接收FIFO溢出于是在FIFO满标志上加了很多反压逻辑但丢包率没有明显改善。随后用ILA同时抓了接收FIFO的写计数和DMA描述符状态定位到真正的瓶颈是接收描述符已经全部被DMA引擎占用没有空闲描述符可用来搬运新数据。这个问题的根因在上位机侧。服务器CPU通过中断通知来回收已完成搬运的描述符但在UDP高速打流时中断触发频率远远跟不上硬件搬运速度描述符回收速度低于消耗速度很快就耗尽了。100G线速下仅靠传统MSI中断几乎不可能满足描述符回收需求必须使用中断聚合和轮询模式来缓解。我当时的解决方式是先把手头可用的描述符数量从512增加到4096并打开DMA的中断聚合功能让驱动在收到N个完成事件或经过T微秒后才上报一次中断。调整之后双向打流的丢包率明显下降40%降到约2%但要做到零丢包还是得转向更底层的零拷贝和轮询机制。5.2 双向打流丢包的另一个视角PCIe带宽竞争双向打流丢包还有一个容易被忽视的原因就是PCIe带宽在收发方向上的竞争。100G单向带宽本身已经接近PCIe Gen3 x16的理论带宽双向同时收发时PCIe总线上同时有大量上行和下行数据如果DMA引擎的仲裁策略偏向某个方向另一方向就可能出现反压从而丢包。我通过Vivado的ILA抓DMA读写请求的时序发现上行方向FPGA到内存和下行方向内存到FPGA在争用同一组DDR带宽。解决方案有两个一是让发送DMA和接收DMA使用不同的DDR通道减少带宽竞争二是调整DMA的仲裁权重在上行业务繁忙时提高上行的优先级。实际测试中我把接收DMA的中断聚合时间和描述符数量调大之后双向打流的发包方向基本能保持在90Gbps以上收包方向丢包率也在可接受范围。如果你的应用对双向零丢包有硬要求单靠软件参数调整是不够的必须从PCIe带宽规划和DMA通道分配上重新设计。5.3 线上抓包看不到包抓包位置与镜像口在排查双向打流问题时我一度怀疑PC端根本没有把UDP包发给FPGA于是在服务器网卡上抓包结果一个UDP包都看不到。但FPGA侧数据明明在持续写入DMA缓冲区说明硬件是接收到数据的。这个矛盾的关键在于抓包位置不对。服务器网卡自带的抓包工具在某些驱动模式下只能抓到协议栈处理后的数据如果网卡开启了RSS或硬件卸载部分包会在驱动层被直接分发到多个队列使用默认的抓包工具在single queue模式下只能看到其中一部分流量。更可靠的办法是在FPGA侧把接收到的数据打一份镜像通过调试接口送出来或者用FPGA内部ILA抓取UDP payload的特征字节来判断是否有数据到达。另外一个很常见的坑是Wireshark的过滤条件。如果你的抓包网卡同时收到了其他广播流量而你又只过滤了特定UDP端口就很容易误判为“没有流量”。我后来改用网卡的promiscuous mode并把过滤条件放宽到只看IP层才看清链路上确实有大量的UDP包在往返。5.4 校验和为0导致接收端丢包在双向打流调试中PC端iperf3报告大量丢包但FPGA侧DMA计数器显示数据全部搬运完成并没有丢包。两边数据对不上最终在服务器网卡抓包时看到每一个UDP包的校验和字段都是0x0000。这个现象在IPv4下是允许的很多硬件默认不校验UDP校验和但iperf3这类工具在某些配置下会把校验和为0的包视为坏包并丢弃导致应用层统计缺包。FPGA协议栈如果默认关闭了UDP校验和计算就会出现这种“硬件收了但软件不收”的矛盾。解决方式是在协议栈的发送逻辑里打开UDP校验和计算开关确保发出的包带合法的校验和字段。IPv6场景下校验和是强制要求的如果打算从IPv4迁移到IPv6发送端必须完整实现伪头部校验和的计算逻辑不能沿用IPv4的忽略策略。6. 性能瓶颈评估与后续扩展方向6.1 现有方案的性能天花板在哪里经过多轮测试当前这套开源协议栈加DMA的框架性能大致是这样的大包单向能跑到线速双向大包也能跑到接近线速但小包单向只能到50Gbps左右双向小包会进一步掉到30Gbps以下。这个瓶颈并不在UDP协议栈的数据通路而在DMA描述符处理速率和PCIe中断频率。我的判断依据是把DMA侧改成高水位中断聚合后小包吞吐有明显提升但提升有上限而把协议栈内部FIFO深度和位宽调整后吞吐几乎没有变化。这说明协议栈本身的处理能力是够的是外部搬运和软件交互拖了后腿。如果业务场景对100G线速有硬性要求最彻底的方案是让FPGA侧做包聚合把多个小包拼成一个大包再交给DMA搬运软件端再拆包。这样可以把描述符处理频率降到原来的十分之一同时缓解中断压力。6.2 更远一步多队列与轮询模式另一个有效优化方向是把单一DMA描述符队列拆分成多个队列让不同的UDP五元组流量落到不同的队列中再由多核CPU分别处理。这个思路类似网卡的RSSFPGA侧需要增加一个简单的hash计算模块把UDP头的IP和端口信息hash成队列号然后分发到不同的DMA ring上。开源协议栈里不一定自带RSS模块但hash逻辑本身不复杂我在测试板上只花了大半天就加完了。配合驱动端的多队列支持小包吞吐可以在不改变协议栈核心逻辑的情况下提升不少。软件侧还可以把驱动从中断驱动改成轮询模式。Linux下的busy-poll机制能显著减少中断带来的调度开销在UDP高吞吐场景下尤其明显。实测下来打开busy-poll后小包吞吐从50Gbps提升到80Gbps以上代价是CPU占用率明显上升。如果你的应用对CPU占用不敏感这是性价比很高的优化手段。6.3 硬件卸载继续向协议栈深处走UDP协议栈移植到这个程度已经不只是转发IP/UDP头的问题了。硬件卸载可以继续往TCP、QUIC甚至应用层协议延伸但在100G UDP场景下最有价值的是把重传、乱序重组和流表管理这类功能下沉到FPGA。如果只是做数据采集或简单的点对点传输保持当前的UDP协议栈加DMA方案就够了但如果你要做的是面向多端点的视频分发或交易类业务就需要考虑在FPGA侧实现流表管理和可靠性传输逻辑而不是把所有可靠性都压在上位机协议栈里。我个人的体会是开源协议栈最大的价值不是拿来即用而是给你一个经过验证的起点让你能在这个基础上按自己的业务需求做深度定制。移植一遍之后你自然会对UDP协议栈内部每条关键路径的时序和资源消耗有直接的认知这时候再去设计自己的硬件卸载方案底气会完全不一样。另外再分享一个实操中容易忽略的细节上板测试前先把所有ILA探针信号规划好尤其是tkeep、tuser、FIFO水位和DMA描述符状态这四个信号组。很多问题如果一开始没有埋探针出现异常时只能盲猜重新综合一次100G工程动辄几个小时调试效率会大打折扣。等你把探针埋好整个链路的状态基本一目了然剩下的问题大多只是配置参数上的微调。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进