把一块100G光口的FPGA板卡真正跑起来,第一步往往不是写RTL,而是先找到一条能用的UDP通路。我这次要做的项目,就是把一套开源UDP协议栈整体移植到自研板卡上,完成上板测试和双向打流验证。说难不难,说简单也不简单,中间遇到不少和文档对不上的坑,实测链路从MAC层一路打到应用层,光是抓包定位就花了两天。如果你正准备做类似的事,这一篇的经验应该能帮你省掉不少弯路。
这篇文章我会从板卡评估、协议栈选型、代码拆解、实际移植、上板测试到问题排查,按我自己操作的真实顺序来写。重点是那些文档里不会告诉你的事:接口时序怎么对齐、跨时钟域在哪里断、抓包抓不到首包、丢包到底是协议栈的问题还是DMA的问题。等你把这套流程走完,一颗100G UDP收发的移植节奏基本就心里有数了。
1. 移植前的板卡评估与开源协议栈选型
1.1 板卡资源盘点:光口、GT参考时钟与散热约束
我手上的板卡是VU9P级别的器件,板载两颗QSFP28光口,配套光模块是100G SR4,PCIe部分走Gen3 x16,DDR4容量和带宽都够。正式写代码前,我先把器件资源和引脚约束过了一遍,这一步看起来很基础,但在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位宽总线上的持续时间很短,如果用户逻辑没有及时拉低ready,FIFO很快写满。开源代码默认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分别处理。这个思路类似网卡的RSS,FPGA侧需要增加一个简单的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工程动辄几个小时,调试效率会大打折扣。等你把探针埋好,整个链路的状态基本一目了然,剩下的问题大多只是配置参数上的微调。