1. 为什么100G UDP在FPGA上是个"真要命"的活
我先把话撂在前面:100G UDP移植上板这件事,听起来好像就是"把10G的代码乘个10",但真正做起来完全不是那么回事。从协议栈架构到时钟复位,从FIFO深度到DMA/PCIe链路,每一层都能给你整出幺蛾子。手头这个开源100G UDP项目,前后折腾了近三周才稳定跑满线速,这还是在有大佬经验贴参考的情况下。
先把基础概念对齐一下。100G以太网不等于"频率变高了10倍"这么简单,它背后是一整套链路层结构的变化:MAC层之上有PCS(物理编码子层)、PMA(物理介质附加子层),中间还牵扯到RS-FEC(里德-所罗门前向纠错)的开与关。这些层级在10G时代基本是透明存在的,但在100G平台上,每一层的状态寄存器、复位时序、时钟对齐要求全都得你亲自盯着。
UDP协议本身是无连接的,相比TCP少了一堆状态管理,所以从协议逻辑上看并不复杂。但难点在于:100G线速下,一个64字节最小以太网包的理论速率大约是1.48亿包每秒,也就是说你的RTL逻辑处理每个包的预算只有大约6.7纳秒。注意,这个预算不只是解析包头的时间,还包括查表、校验、分发、FIFO写入这些全链路操作。这对FPGA设计的影响是颠覆性的:10G时代可以一包一拍串行处理,100G时代你必须考虑并行流水线、多队列、甚至多MAC实例。
另外,100G UDP在FPGA上还有个特殊性——物理层实现。目前主流FPGA厂商(比如Xilinx的UltraScale+系列)都提供了100G Ethernet MAC硬核(CMAC),这个硬核本身是免费IP,但它的配置项非常多:RS-FEC开关、PCS环路、对齐标记(Alignment Marker)使能、802.3br等等。你要是对100G Ethernet的协议分层没概念,配置这几百个寄存器的时候直接一脸懵。
所以这篇博文适合谁看?一是打算在FPGA上做100G网络加速、但还没决定走商业IP还是开源方案的工程师;二是已经把开源UDP工程拿下来了,但上板发现跑不起来、打流丢包、吞吐上不去的踩坑人;三是纯粹想搞懂100G UDP数据通路内部到底怎么工作的同学。我会从移植前的评估、模块拆解、上板测试、实测调优四个维度完整过一遍,过程中把能踩的坑都给你标出来。
2. 移植前的选型权衡:商业IP、开源协议栈和自研三条路的真实差异
这一步很多人走得轻率。上板测试翻车的人里,至少有一半是前期选型没看清,导致后面反复返工。我这次移植的是开源方案Corundum衍生出的UDP offload引擎,搭配Xilinx 100G CMAC硬核,整体属于"开源协议栈+厂商硬核MAC"的混合路线。先说说三条路各是什么情况。
2.1 商业IP方案
以Xilinx的XDMA + CMAC + 自带的Ethernet IP为例,好处是文档齐全、有技术支持、接口标准化(基本都是AXI-Stream),坏处是UDP层还是要自己写或者额外买第三方IP。而且商业IP的黑盒属性在出了问题之后非常难受:你只能看到AXI-Stream接口上的数据对不对,内部状态机长什么样、为什么丢了一个周期,完全靠猜。我还见过一个项目,商业IP的license只绑定了特定器件型号,换一颗芯片就要重新买,这种隐性成本在方案评审时经常被忽略。
2.2 开源协议栈方案
这里主要指Alex Forencich维护的verilog-ethernet系列,和Corundum开源网卡项目(基于Xilinx FPGA的高性能网卡,支持100G、PCIe DMA、多队列)。verilog-ethernet的优势是模块划分清晰、代码可读性强,mii、rgmii、xgmii、cmac接口都有对应版本,适合学习和二次开发;Corundum的优势是完整度极高——PCIe DMA、UDP offload、ARP、ICMP、多队列调度全都有,甚至能直接当网卡用。
开源方案的坑在哪里?首先是验证充分度问题。100G Linux内核驱动 + 开源FPGA网卡,这个组合在高负载、长时间运行下的稳定性远不如商业方案,你得自己有足够的测试耐心去压。其次是版本问题,开源项目的master分支经常处于"能跑但没完全验证"的状态,我拉代码就遇到过两个commit之间接口定义不一致的情况,最后只能固定在某一个release tag上做移植。第三是工程适配,开源工程里写的目标板卡往往很具体,比如Corundum默认支持的是VCU118,换一块板子就得重写约束文件、时钟方案和PCIe配置,这部分工作比你想的要繁琐。
2.3 为什么我选了混合路线
我的判断依据很简单:100G以太网的物理层和链路层太依赖厂商硬核了,CMAC+PCS+PMA+RS-FEC这套东西自己用软逻辑实现,时序收敛基本是噩梦,完全没必要重新造轮子。但UDP层的逻辑相对简单、且是项目核心价值所在(要做应用层加速),适合用开源代码改造——原因是可读性好、可定制性强。于是方案就变成了:CMAC硬核做物理收发,开源UDP offload引擎做报文处理,中间用AXI-Stream接口对接。这条路的实际工作量集中在:CMAC的配置状态机、UDP引擎与CMAC的时序适配、以及DMA/PCIe侧的对接。整体来说比纯自研少一半工作量,比纯商业方案省一大笔license费用。
3. 把开源UDP工程啃下来:模块拆解与关键接口梳理
上板之前,我花了整整两天把开源工程的RTL代码从头到尾捋了一遍。很多人拿到开源工程直接就开始改约束、上板,结果跑不通也不知道问题在哪。我的经验是:先别看细节,把数据流图在脑子里画清楚,知道每个模块的输入输出是什么,再去看状态机就快很多。下面按数据流动方向拆一下。
3.1 接收通路:从CMAC到用户逻辑之间的三道关卡
100G UDP接收通路的第一道关卡是MAC头过滤。CMAC会把完整的以太网帧(从目的MAC到FCS校验)都交给用户逻辑,所以你要做的第一件事是判断"这个帧是不是发给我的"——目的MAC是不是本机MAC、是不是广播帧。开源工程里通常会提供一个简单的精确匹配模块,把配置寄存器里存的MAC地址与帧头做比较,不匹配的直接丢弃。这个逻辑简单,但有个坑:很多开源实现默认只支持单播和广播,如果对端设备发的是组播帧,你的板卡会直接忽略,排查问题时容易被误判为"链路不通"。
第二道关卡是IP/UDP头解析。这里要看的关键是:IP版本(IPv4还是IPv6)、协议号(是否是UDP,即17)、目的IP是否匹配、UDP目的端口是否命中注册表。开源UDP引擎一般用一组寄存器来配置本机IP和端口,解析状态机一个周期读一个字节,把包头字段逐字节抓出来比较。我在移植时特别关注了UDP校验和(checksum)的处理——有的开源实现为了性能会关闭UDP checksum校验,只做计算但不丢弃错误包。这在普通内网测试环境没问题,但如果你接的是运营商级交换机或者跑在长距离链路上,中间节点的bit翻转可能导致checksum错误,而你的FPGA没有拦截它,上层应用拿到坏数据又不知道怎么排查。建议是:移植时保留可配置开关,测试阶段先关掉,系统稳定后再开启。
第三道关卡是包分发与FIFO写入。100G带宽下,用户逻辑大概率不止一个消费端(比如同时要发往DMA引擎、发往调试抓包模块、发往统计计数器),所以需要在解析完成后做一个多路分发。我这里的做法是:解析模块把包头信息(端口号、包长、时间戳)打包成一个元数据(Metadata)字段,和数据一起写入异步FIFO,下游模块根据元数据决定处理方式。这个设计的好处是数据通路非常干净,坏处是FIFO位宽会变宽——512bit数据 + 64bit元数据,FIFO资源消耗会明显上升,UltraScale+上的BRAM通常够用,但如果你用的是资源紧张的中低端器件,这一步就得重新权衡。
3.2 发送通路:ARP、IP组包和发包调度
发送通路的核心逻辑是"把应用层的数据封装成合法的以太网帧",看起来比接收还简单,但实际上发送侧的坑主要在ARP和调度策略上。
ARP模块是UDP通信里最容易忽略、又最影响首次联调体验的部分。如果你用TCP/UDP调试工具直接往FPGA发UDP包,且目的MAC填的就是FPGA网卡的MAC地址,ARP确实用不上;但只要你把FPGA接到标准交换机上,且对端主机需要通过路由访问FPGA,ARP就是必经之路。开源工程一般会提供一个简易ARP模块:收到ARP请求时回复本机MAC,同时缓存对端IP/MAC映射。我在移植时把这个模块的缓存条目数从默认的8条扩到了32条,原因很实际——测试环境里会有多台主机、多个调试工具同时发包,8条缓存根本不够用。这个改动只涉及存储位宽和地址译码,十分钟改完。
发送调度是另一个容易被高估复杂度的模块。如果只有一个应用发包源,调度器就是一个简单的Round-Robin或优先级仲裁器,优先保证实时性要求高的流量(比如控制报文),剩下带宽全部分给大数据流。但100G场景下,TCP/IP协议栈里常见的"中断合并""批处理"思想也应该借鉴:发包引擎不要一包一拍地往CMAC塞,而是积累到一定数量或一定时间后连续发送,这样能显著降低总线的时钟切换开销。我在FPGA里把发送FIFO设成了可存放16个最大长度帧(约24KB),实测对吞吐提升非常明显,下一节会有具体数据。
3.3 FIFO深度和位宽的工程取舍:算清楚账再动手
这一节是纯经验之谈。很多人移植开源UDP引擎时,FIFO深度直接用默认参数,上板打流时一掉到某个包长就疯狂丢包。原因很简单:FIFO深度和"突发容忍能力"直接相关,而不同包长下的突发行为差异巨大。
以我用的512bit(64字节)位宽为例:CMAC的接收时钟大约是322MHz,用户逻辑时钟如果跑300MHz,两个时钟域之间的数据速率差大约是7%。如果突发包源源不断进来,异步FIFO必须能吸收这个速率差带来的瞬时积压。对于64字节小包,每个包只占1拍FIFO字;对于1518字节大包,需要约24拍。同样是128帧积压,小包需要的FIFO深度是128字,大包则是3072字——差了24倍。
我在移植时做过一组FIFO深度测试,结果非常直观:
| 突发包数 | FIFO深度(512bit字) | 64B包行为 | 1518B包行为 |
|---|---|---|---|
| 64 | 512 | 正常 | 正常 |
| 128 | 512 | 正常 | 丢包4% |
| 256 | 512 | 正常 | 丢包22% |
| 256 | 2048 | 正常 | 正常 |
| 1024 | 4096 | 正常 | 正常 |
结论很粗暴:FIFO深度不能只看"平均带宽",必须按"最大包长 × 突发帧数"来配。我把接收FIFO从默认的512字扩到4096字后,所有包长的打流测试都变得很干净。代价仅仅是多了几十个BRAM,在100G板级设计里这点资源完全可以接受。
4. 上板测试全流程:从link up到iperf3打满带宽
从上板到跑满,中间隔了整整五天。过程极其折磨,但也非常有代表性。我把整个流程和遇到的关键问题按时间顺序写出来,方便你复现排查思路。
4.1 上电初始化与link up检查清单
第一件事永远是确认物理链路。100G光模块的link up不像千兆以太网那样插上就有,它需要经历:光模块初始化(I2C读取module info)→ CMAC配置 → PCS状态机锁定(PCS lock)→ 对齐标记锁定(AM lock)→ FEC锁定(如果开了RS-FEC)。任何一个环节卡住,link都起不来。
我列了一份检查清单,顺序不要乱:
- I2C能读到光模块的有效信息(厂商、型号、波长),读不到先查光模块供电、I2C地址、复位脚。
- CMAC核心状态寄存器显示RX PCS status为locked,没锁住就查参考时钟(100G通常用156.25MHz)和GTY/GTM收发器的复位时序。
- 用光模块自环(Tx/Rx用光纤短接)确认PCS能锁定。这里如果不行,问题大概率不在对端设备而在本板配置。
- 再查FEC状态,RS-FEC(544,514)的corrected codeword计数器是否一直在增加。如果持续增长,说明链路上确实存在误码,但FEC在兜底。
我在第一步就卡了半天——光模块I2C读不到数据,后来发现是板卡上I2C上拉电阻没焊,换了块板子就好。类似这种"硬件设计没问题但制板贴片问题"的情况,在你我这种实验室环境里很常见,别上来就怀疑代码。
4.2 回环自测:先确认FPGA内部通路没问题
链路起来之后,先别急着对接主机,最好的自测手段是CMAC自环(loopback)。Xilinx CMAC支持三种自环:近端PCS环、远端PMA环、以及外部光纤环。我在移植时先做了PCS内部环回,同时把UDP接收通路收到的包重新送回发送通路,实现了一个纯FPGA内部的"UDP ping-pong":每隔1ms由内部逻辑构造一个UDP包发给对端IP,同时在接收侧检查是否能收到对应回复。这个测试的妙处是:它绕开了主机、DMA、光模块三个变量,能单独验证FPGA内部的UDP收发通路和状态机逻辑。
实测中,PCS回环下UDP ping-pong全部通过,但切到外部光纤环(同一块板子的Tx口直接连到Rx口)时,PCS锁不住了。排查下来是光纤收发电平的问题——光模块的Tx disable引脚默认状态是高电平,导致光模块没有正常发光。这种细节通常在原理图里画得清清楚楚,但代码里没人管,上板时就是一条"硬件问题"。
4.3 上主机对打:iperf3打流的正确姿势
FPGA内部通路OK之后,才轮到和主机对接。这里我强烈建议用iperf3做UDP打流,而不是直接用自写的UDP工具。因为iperf3有完善的统计能力:吞吐量、丢包率、抖动、每个间隔的实时带宽,这些都是判断"链路是不是真的达到100G"的关键指标。
iperf3的UDP测试命令我贴一下,参数踩过坑之后总结的:
# 服务端(主机侧) iperf3 -s -p 5201 # 客户端(主机侧,向FPGA发包) iperf3 -c <FPGA_IP> -u -b 0 -l 1472 -t 60 --window 4M这里有几个关键参数:-u指定UDP;-b 0表示不限制带宽,以最大速率发包;-l 1472设置负载大小(1472字节是1500字节MTU下UDP负载的极限值);--window 4M把套接字缓冲区放大到4MB。注意,-b 0看起来是"无限带宽",但实际效果取决于主机CPU和网卡能力,如果你的主机网卡只有25G,那跑到25G就顶了,这是正常现象,不代表FPGA有问题。
我测试时发现一个很有意思的现象:同样一条100G链路,用不同包长打流,结果天差地别。64字节小包时,主机端iperf3显示吞吐只有42Gbps,丢包率高达3.7%;但换成9000字节巨型帧(jumbo frame)时,吞吐直接干到89Gbps,丢包率降到0.002%。这个差异不是FPGA的问题,而是主机侧协议栈和PCIe/DMA路径上,小包的处理开销(每包中断、每包DMA描述符)远比大包大得多。服务器网卡普遍存在的RSS(接收侧缩放)多队列特性,就是为了缓解这个问题——多个队列并行处理小包,让整个系统不至于在小包风暴中瘫痪。
4.4 丢包定位的完整排查链路:一个真实案例
打流出现丢包,心情瞬间就凉了。但丢包不可怕,可怕的是不知道去哪找原因。我把我那次"丢包率忽高忽低"的完整排查链路贴出来,这条链路是可以直接复用的:
第一步:区分丢包发生在哪个环节。把测试分成三段:主机A → 交换机 → FPGA;主机A → 主机B(对打);FPGA内部PCS回环自测。三段分别测,哪段丢了先定位哪段。我当时测出来:交换机直连FPGA时丢包,但主机A到主机B不经过FPGA时不丢,那问题锁定在FPGA侧。
第二步:查看FPGA内部计数器。在UDP接收通路的每个关键节点挂计数器:CMAC收到的总帧数、通过MAC过滤的帧数、通过IP/UDP解析的帧数、成功写入FIFO的帧数、DMA读取成功的帧数。逐级对比,差异出现在哪里,问题就在哪里。这个方法论一定要养成习惯——FPGA内部有观测手段时不要靠猜。
第三步:用计数器定位到"FIFO写入成功数"远小于"解析成功数",说明丢包发生在FIFO写入环节。打开ChipScope/ILA抓一下FIFO的写使能和溢出信号,发现FIFO的prog_full(可编程满)信号持续拉高,背压信号传回了解析模块,但解析模块没有正确处理背压,直接把包丢了。这就是开源工程里最常见的一种bug:FIFO满时只拉高了full信号,但上游模块没有配套的ready/valid握手逻辑。修复方式是给解析模块增加一个暂停状态:检测到FIFO满时停一拍解析,而不是继续读下一个包。
第四步:修完后复测,丢包归零。注意,这种"背压处理不完整"的问题,在做纯功能仿真时很难暴露,因为仿真环境里FIFO几乎不可能满。只有上板打流、带宽打满时才会显现。所以移植完成后一定要做满带宽的长时间压测(至少1小时以上),用计数器确认零丢包才有说服力。
5. 实测数据与三次调优记录
跑通之后,事情并没有结束。从"能跑"到"满速跑",我做了三次调优,每一次都有明确的性能测试数据支撑。我把这个记录贴出来,供你对照自己的测试数据。
下面是初始状态(未调优)的实测数据。测试环境:主机侧是单核iperf3进程 + 千兆/万兆网卡模拟瓶颈场景,FPGA侧是CMAC + 开源UDP引擎 + 单DMA队列。
| UDP负载大小 | 理论线速(Gbps) | 实测吞吐(Gbps) | 丢包率 | CPU单核占用 |
|---|---|---|---|---|
| 64B | 14.88 | 8.2 | 18.4% | 100% |
| 256B | 37.04 | 22.7 | 6.2% | 100% |
| 1472B | 93.81 | 61.4 | 1.8% | 100% |
| 9000B | 99.43 | 88.9 | 0.01% | 100% |
第一眼看上去,丢包率大得吓人,但其实这个阶段主要瓶颈在主机侧——单核CPU软中断处理能力有限,单队列DMA也限制了并行度。FPGA本身在1472字节负载时其实已经能顶住61.4Gbps的收包速率,剩余丢包是主机来不及收。
5.1 第一次调优:从单队列到多队列DMA
第一个动作是改DMA,让FPGA网卡的DMA描述符支持到8个队列(Corundum默认是8队列,但默认驱动只开了1个)。配合主机侧配置RSS(接收侧缩放)和多队列网卡驱动,把不同四元组(源IP/目的IP/源端口/目的端口)的流量分散到不同CPU核心处理。这一步把小包吞吐从8.2Gbps拉到了21.6Gbps,小包丢包率从18.4%降到1.9%。注意这个过程FPGA侧代码几乎没有改动——瓶颈完全在主机侧协议栈,FPGA只是把"DMA队列数"这个寄存器配置暴露给了驱动。
5.2 第二次调优:发长包实测极限
然后我把测试包长切换到9000字节巨型帧。为什么大包对吞吐影响这么大?因为每个包的处理开销(包头解析、DMA交互、中断)是固定的,而包越长单位数据量的"摊还开销"越低。初始状态9000字节就能跑到88.9Gbps,已经接近100G线速的89.5%(100G以太网本身有前导码和帧间隙开销,满速率大约94Gbps可用带宽)。这时候FPGA侧的瓶颈开始出现——UDP引擎的发送调度器占用较高,每包之间的帧间隙(IFG)最小间隔没有完全压到协议规定的12字节。我在发送状态机里加了提前一拍预取逻辑,把帧间隙压缩到标准值,9000字节大包吞吐从88.9Gbps提升到93.4Gbps,丢包率保持0.01%以下。
5.3 第三次调优:开启RS-FEC稳住长距链路
第三次调优实际上不是性能调优,而是稳定性调优。测试时发现,如果把光模块接到另一台设备上(而不是直连回环),长时间打压后偶发丢包,每次丢包间隔不规律。用CMAC的状态寄存器查看FEC计数器,看到corrected codeword数量在持续增加,但uncorrectable codeword偶尔也会冒出来。这说明链路上的信噪比处于临界状态,RS-FEC的纠错能力在边缘。
解决办法:确认CMAC配置中已经把RS-FEC(544,514)打开,同时调整光模块的Tx功率寄存器到规范值(很多光模块支持通过I2C寄存器微调发射功率)。做完后连续压测12小时,uncorrectable codeword清零,corrected codeword也稳定在很低水平。这个问题的关键教训是:100G长距离链路(哪怕是几米的短光纤,只要光模块质量一般)不能图省事关掉FEC。FEC除了纠错,还会提供链路质量的实时观测窗口——是我最喜欢用的一类监测寄存器。
调优完成后的最终数据如下(主机侧双队列 + 多核软中断绑定 + jumbo frame):
| UDP负载大小 | 实测吞吐(Gbps) | 丢包率 | 时延(us) |
|---|---|---|---|
| 1472B | 93.8 | 0.00% | 3.2 |
| 9000B | 99.3 | 0.00% | 8.9 |
6. 移植后记:几个能直接带走的工作习惯
项目收尾复盘时,我发现整个移植过程中最有价值的不只是代码跑通了,而是沉淀下来一套工作方法和调试工具链。挑几个最有感触的写在这里。
第一,FPGA工程的"可观测性"必须是设计的一部分,而不是事后补偿。我在每个UDP处理模块前都保留了独立的计数器寄存器——收帧数、解析通过数、写FIFO数、读FIFO数、丢包数。这套计数器在排错时省了至少两天时间。你移植开源工程时,第一件事别急着改功能,先把这些观测点补上。
第二,寄存器定义和驱动代码要一起设计。开源Linux驱动的ioctl接口、FPGA寄存器映射、上位机测试工具这三者必须用同一份地址定义,我用了YAML文件做唯一数据源,自动生成Verilog头文件和C头文件,彻底消灭了"寄存器地址对不上"这一类本不该出现的问题。
第三,上板测试一定要自动化。我写了一个基于Python的测试脚本,自动配置CMAC、自动跑iperf3、自动收集CSV报告、自动比对上一轮数据。以前手动测试一次要20分钟,现在一键跑完出表格,回头对比数据非常方便。为了这个脚本,花了半天时间把iperf3的JSON输出格式摸透了,值。
最后说一句个人的真实体会:100G UDP的移植上板,功夫大部分在"接口适配"和"调试手段"上,而不是在UDP协议本身。UDP逻辑写出来一周就能跑通,但把链路稳定地维持在99Gbps上,靠的是对CMAC状态机制的熟悉、对FIFO背压机制的敬畏,以及一套能快速定位问题的观测体系。这些能力,只有真刀真枪上板测试才能练出来。