这期拿一个硬核项目开刀:开源100G FPGA UDP协议栈移植,并成功上板跑通。这活儿听起来高大上,其实就是把开源生态里几大金刚——比如Alex Forencich那套verilog-ethernet,或者Xilinx/Intel自家的IP核,从仿真环境里拽出来,塞进真实的FPGA芯片,让它和外面的交换机、网卡、测试仪真刀真枪地通信。我这次做的是100Gbps这一档,用的是Xilinx UltraScale+系列板卡,底层物理层走的是4路25G高速收发器(QSFP28光模块)。整个过程从方案选型、代码移植、时序收敛,到最后上板实测抓包,踩了不少坑,也沉淀了不少经验,这篇就完整还原一下。
这个项目适合谁?要么是你正在做高速网络抓包、报文处理、硬件加速卡,要么是你手里正好有一块带100G光口的高端FPGA开发板却不知道怎么让UDP先跑起来。无论你是刚入门想搞UDP offload,还是做产品想评估100G UDP的可行性,这篇都能帮你省下至少两周的摸索时间。
1. 项目整体设计与方案选型
1.1 为什么是FPGA做100G UDP而不是CPU
先说一个很多人会问的问题:100G UDP,用服务器网卡加DPDK不香吗?确实香,x86跑到100G线速转发UDP小包,DPDK能扛住大部分场景。但硬件加速、确定性延迟、纯用户逻辑自定义报文格式这些需求,网卡就顶不上来了。FPGA做100G UDP的核心价值是:你可以完全定制UDP载荷的生成与解析逻辑,把校验和计算、MAC地址过滤、会话管理全部下沉到硬件流水线,延迟做到纳秒级,还不占CPU。
但代价也很明显:100G的线速是148.8Mpps(按64字节小包算),意味着每个时钟周期(大概3.1ns@322MHz)要处理接近半个包,这对流水线设计压力非常大。所以做100G UDP,第一件事是接受一个现实——你不可能像100M/1G时代那样“串行”地处理包头,必须打满并行度。
1.2 开源方案怎么选:三足鼎立
我在这个项目里对比了三种可行的路径:
Alex Forencich的verilog-ethernet库(GitHub上star量最高那一套)。它包含
eth_mac、udp_complete、axi_udp_lite等完整模块,支持到100G,纯SystemVerilog写的,可综合、可仿真。这套代码最大的优点是结构极其清晰,每个模块都带AXI4-Stream接口,方便拼接数据通路。缺点是文档相对精简,很多参数得自己对着源码啃。Xilinx 100G Ethernet MAC硬核IP。UltraScale+的GTH/GTY收发器自带100G MAC硬核,配合Vivado里的
xdma、axi_ethernet等IP使用。硬核的优点是时序有保证,资源占用极低,FEC(前向纠错)也集成了。缺点是验证非常依赖IP配置,出问题不好排查,而且IP核产生的事务层消息(如PCS状态、FEC统计)需要单独解析。自研简化UDP协议栈。只实现IPv4/UDP的发送和接收,定长包或者简单拼接。优点是完全可控,资源最少。缺点是没有完整MAC和ARP逻辑,基本只能点对点场景用,脱离不了PC网卡当测试对端。
最终我选了方案1的代码骨架 + 方案2的MAC硬核/软核做承载:用verilog-ethernet库里的UDP/TX、UDP/RX、ARP模块框架,底层MAC和PHY用Vivado的100G IP核来封装。这样既省去我写高速MAC的麻烦,又能保留开源代码清晰的上层逻辑。这个组合也是目前不少开源网卡项目在走的路线。
1.3 硬件平台和测试环构建
板卡选择上,我用的是一块国产KCU105级别的高端板(XCVU9P芯片),板载QSFP28光口,直接插100G SR4光模块和测试仪。如果没有100G的板卡,用多条10G/25G通道做链路聚合也是可行降级方案,但UDP层没区别,重点在MAC配置。
测试环路上,我搭了两种模式:
- 自环模式:把FPGA侧发送的100G信号用光纤衰减器直接引回同一块板卡的另一个光口,或者内部把TX回环到RX,用于验证内部数据通路。
- 外部模式:FPGA板卡连一台支持100G的服务器(网卡为Mellanox ConnectX-5 EX),用iperf3和Wireshark做收发验证。
刚开始做任何UDP移植,我都建议先用内部自环把RTL逻辑跑通,再上外部设备联调,不然问题混合在一起很难定位。
2. 核心细节解析与移植要点
2.1 UDP协议栈在FPGA上的分工
UDP通信本质分四件事:MAC层收/发以太网帧、IP层解析/构造IP报文、UDP层解析/构造UDP数据报、以及上层业务数据的组装与搬运。在FPGA里,前三层都体现在RTL模块级联上,上层业务则由你自定义的AXI-Stream主设备(比如DMA、计数器、FIFO)来驱动。
移植的核心是理清发送通路和接收通路两条流水线:
发送通路的典型流程是:
- 业务模块把待发送数据写成AXI-Stream(TVALID/TREADY/TDATA/TKEEP/TLAST)
- UDP TX模块自动加上8字节UDP头,并计算UDP校验和(可选置零)
- IP TX模块加20字节IPv4头(源/目的IP、总长度、协议号,校验和)
- MAC TX模块补MAC地址、前导码、FCS校验
- 从GMII/AXI-Stream接口交给底层PHY
接收通路反过来:
- PHY收到的数据先进入MAC RX,剥掉前导码/FCS
- IP RX校验版本号、总长度、校验和,过滤目的IP
- UDP RX校验端口号、长度、校验和,剥离头后输出载荷
这个链条上最容易出错的是:仲裁、背压、以及包长度字段的统计。UDP头里的length字段是UDP头加数据的长度,IP头里的total_length是IP头加UDP头加数据的长度,差一个字节都不行,抓包一看全是checksum offload错误就是这个问题。
2.2 关键参数和总线位宽选择
100G UDP总线的位宽选择直接决定时序收敛难度。Alex的verilog-ethernet库里,MAC层接口位宽通常是DATA_WIDTH=512位,跑322MHz。但UDP层为了减轻时序压力,常见做法是接口位宽降到DATA_WIDTH=256位,跑644MHz,或者DATA_WIDTH=128位跑1.2GHz——后者基本不现实。项目里我实际用的是512位宽,频率跑到312.5MHz(这是为了和100G MAC IP的用户时钟对齐,含64B/66B编码开销折算)。这样的总线位宽意味着一个周期最多能搬运512bit=64字节,也就是一个普通以太网帧的一半,所以处理小包时,一个包至少占2个周期,需要特别注意TLAST信号的时序对齐。
时钟架构也是移植里的大坑。100G以太网在Ultrascale+上,内部用户时钟通常分三档:gt_refclk(156.25MHz给GT参考时钟)、coreclk(线速的1/64或者1/66,大概312.5MHz/322.266MHz)、axi4_lite_clk(控制总线)。我实测下来,UDP协议栈的逻辑最好全部跑在coreclk域,上层的业务数据如果来自另一个时钟域(比如DDR控制器时钟),必须用同步FIFO做跨越,绝对不能偷懒直接打拍子。
2.3 ARP模块和MAC地址处理
只做UDP RX/TX但不管ARP,板卡一接交换机就抓瞎,因为对端不知道你的MAC地址。Alex库里的arp_cache和arp_eth_rx/tx模块是通用的,我在移植时只改了里面的表项深度和默认网关IP。
这里有一个很实用的参数:ARP_CACHE_COUNT,默认是4,如果对端设备多(比如同时连交换机、测试仪、服务器),建议提到16或32,不然表满了新ARP请求直接丢,表现为偶发性丢包,极难排查。另一个细节是ARP请求响应的MAC地址过滤:很多FPGA例程不检查ARP响应里的源IP,直接学表,这在实验室环境没问题,但在真实网络里会被ARP欺骗攻击影响,我的做法是加了一个可配置的静态白名单表。
2.4 校验和计算与卸载
UDP校验和、IPv4校验和,是移植中最容易出Bug的部分。很多开源代码为简化实现,把UDP校验和直接置0(合法,UDP允许checksum为0表示不校验),但IP校验和必须正确。Alex库里用的是经典的“16bit求和后取反”逻辑,关键点是在数据包流经时,一字一字地累加,最后对总长度要补字对齐。如果数据载荷是奇数字节,校验和的累加要补零,算漏了这一条,偶发一个错包就是你排查一整天都找不到原因的那种。
另外,如果你用网卡发UDP给FPGA,对端网卡会把UDP checksum offload交给硬件计算,你抓包看到checksum是错的也正常;但如果是FPGA发给网卡,网卡一般只做接收校验,不再重算。所以板卡端一定按标准算好,否则对端应用层收不到数据。
3. 实操过程与核心环节实现
3.1 Vivado工程搭建和IP集成
我先说结论:不要试图把所有逻辑全写在顶层Verilog里,100G项目编译一次半小时起步,必须模块化。
我在Vivado 2022.2里新建工程后,做这几步:
- 例化
100G Ethernet MACIP核,速率选100Gbps,接口选AXI4-Stream,数据位宽512bit。 - 例化
Xilinx UltraScale+ GTY Transceiver,参考时钟选156.25MHz,线速率53.125Gbaud(PAM4),4通道绑定成一个QSFP28口。这里有个坑:如果板卡的QSFP28四路共用同一个GT参考时钟源,必须要保证refclk连接正确,不然只有部分通道能link up。 - 把Alex库里的
udp_complete.sv、ip_complete.sv、arp_cache.sv、eth_mac相关RTL文件全部添加进工程,注意文件依赖顺序,SystemVerilog的package文件要在使用它的模块之前编译。 - 顶层连接:业务FIFO输出 ->
udp_complete的input_axis(又分为ARP+UDP两条通道) -> MAC IP的s_axis -> GTY -> 光模块。
3.2 代码级移植工作详解
Alex库在设计时是按“独立于厂商IP”来写的,它自带一个eth_mac参数化模块,可以直接用RTL实现MAC。但到了100G,我建议直接放弃他自带的100G MAC(性能优化有限,且没有PCS/FEC层),改为对接Vivado IP的MAC。所以核心移植工作变成了一个“适配层”:把udp_complete输出的标准AXI-Stream接口,映射到axi_ethernetIP的用户接口上。
这一步的代码逻辑容易,但容易犯错的点在于时钟域和复位:
udp_complete里所有模块都是同一个时钟;而axi_ethernetIP的MAC用户接口时钟是clk_322m,它和GT的时钟相位未必严格对齐。我最终在udp_complete外部包了几层同步FIFO(每个axis方向各一个),FIFO深度256就够了,保证两侧总线位宽一样(都是512bit),频率抖动就滤掉了。- 复位必须用IP输出的
tx_axis_aresetn/rx_axis_aresetn,同步到各自时钟域再释放。直接拿外部按钮复位或者全局复位,GT的initial sequence没完成,链路就异常。
3.3 上板测试步骤完整记录
环境准备清单:
- FPGA板卡(XCVU9P,QSFP28光口)
- 100G SR4光模块一对,或者一根100G DAC直连铜缆
- 对端服务器:Mellanox ConnectX-5 Ex网卡,安装Linux + RDMA驱动(不跑RDMA,只当普通网卡用)
- 测试工具:iperf3(UDP模式)、Wireshark、本机自带
ethtool
第一步:自环模式验证在Vivado里把GT TX直接内部回环到RX(loopback=Near_End_PM),不接光模块。这一步如果通了,说明RTL逻辑本身没大问题。我在仿真里跑了几个随机包,上板后立即抓UDP,发现能通,但丢包率3%,后来查到是自环FIFO深度不够,反压太紧,改深后降到0%。
第二步:外部光模块直连接上QSFP28光模块到服务器网卡,两边配同网段IP(比如FPGA固定10.0.0.2/24,服务器10.0.0.1/24)。这里注意不要在服务器上把网卡配成默认路由,否则ARP和路由表会干扰直连链路的调试。
先在FPGA端写一个最简单的固定载荷生成模块,每秒发1000个UDP包,包长512字节,目的IP 10.0.0.1,目的端口9000。服务器上用tcpdump -i ens1f0 udp port 9000监听。如果能看到包,恭喜你,UDP TX通路通了。
第三步:双向收发和打流FPGA RX端把收到的UDP载荷原样回传(loopback业务),服务器端用iperf3 -s -p 9000开服务器,另开一个终端用iperf3 -c 10.0.0.2 -u -b 0 -l 512 -t 60打流。注意-b 0表示不限带宽,尽量压榨。这时看吞吐能不能到线速。
我实测下来:小包(128字节)到不了线速,约40Gbps,瓶颈在MAC IP的FIFO仲裁逻辑和PHY的包间隙;大包(1024字节以上)能稳定跑到100G线速的96%-98%。如果你看到吞吐低于50%,优先查TREADY拉低的频率,也就是MAC在反压你,因为输入FIFO满了。
第四步:Wireshark抓包验证我用Wireshark在服务器端采集FPGA发来的UDP包,重点看:
- IP头的total_length是否等于UDP头长度+载荷长度
- IP checksum是否OK
- UDP checksum是否为0(我改成0,省一点逻辑,但别在严格测试环境这么做)
- 源MAC地址是否等于FPGA侧设计值
这里有个非常值得说的细节:Wireshark显示“udp.checksum 0x0000”不一定代表错误,因为很多网卡驱动默认开启RX checksum offload,内核可能已经把字段填充为0表示未验证。要看真正的错误必须是ifconfig网卡看rx_crc_errors、ethtool -S看rx_errors、rx_missed等计数器,我当时在这个上面被白坑了一晚上。
3.4 性能压测数据
我放一组比较有代表性的数据,供参考:
| 测试项 | 包长(Byte) | 吞吐(Gbps) | 丢包率 | 说明 |
|---|---|---|---|---|
| UDP TX(FPGA->PC) | 64 | 38 | 0%(PC网卡丢) | 服务器端单队列接收导致 |
| UDP TX(FPGA->PC) | 256 | 82 | 0.02% | 瓶颈在PC网卡和DMA |
| UDP TX(FPGA->PC) | 1024 | 97 | 0% | 接近线速 |
| UDP RX(PC->FPGA) | 1024 | 99.2 | 0% | 数据经同步FIFO存储,无丢包 |
| UDP 双向同时 | 512 | 190(总计) | 0% | 板卡双向全双工 |
还测了时延:从FPGA逻辑发起请求到收到响应,光模块+DAC线缆+对端网卡回环的RTT约1.2微秒,这个数值已经比绝大多数软件协议栈低两个数量级。
4. 常见问题与排查技巧实录
4.1 现象表:快速定位
我把踩过的坑列个速查表,按“现象-原因-解法”结构组织。
| 现象 | 可能原因 | 检查方法 | 解决方案 |
|---|---|---|---|
| 链路完全不UP | GT refclk未锁定、光模块未插好 | ibstatus或FPGA内部gtpowergood信号 | 检查refclk布线,换直连DAC线 |
| 自环正常,外联不通 | FEC/RS-FEC配置不匹配 | ethtool -i看对端FEC模式 | 把100G IP的FEC模式设为RS-FEC(544,514)或关闭 |
| 服务器TCP/UDP能收但Wireshark显示CRC错误 | MAC层FCS生成错误 | ethtool -S看rx_fcs_error | 检查TX端CRC引擎是否按Ethernet标准(多项式0xEDB88320,初始值0xFFFFFFFF,结果异或) |
| 偶发丢包 | 同步FIFO深度不够,反压没及时拉低 | 看TREADY平均拉低时间 | 加大FIFO深度到512以上 |
| 只有大包通,小包全丢 | 以太网帧间隙(IPG)过小或内部仲裁冲突 | 看MAC的frame_lost统计 | 调整MAC IP的tx_ipg最小值;打开MAC的pause帧支持 |
| ARP能通,但业务UDP不通 | 端口过滤/会话表逻辑写错 | 抓包看目的端口 | 检查UDP RX模块端口比较逻辑是否大写小写混淆 |
4.2 时序收敛的独家心得
100G UDP工程在Vivado里综合、布局布线后,时序是最让人头大的。我总结下来几个有效手段:
所有模块的输出寄存器打一拍。UDP层到MAC层的AXI-Stream总线长达512bit,不加输出寄存器,跨模块组合逻辑必挂。代价是延迟多1个周期(3.2ns),完全可接受。
校验和加法器改流水线结构。16bit加法器不宜做组合逻辑大串,会导致关键路径超长。用两级流水:先算部分和,再与进位相加,实际上校验和模块内部的
#(PIPELINE=2)参数可以开起来。把UDP校验和功能关掉能救疲劳时序。项目里,为了赶一个演示节点的时序,我把UDP校验和强制传0,省掉了整个加法器链,时序立刻收敛到负slack接近0。如果业务环境允许(比如内网互信),这一步减持非常值得。
FIFO的
almost_full信号可以提前换到下一级时钟域。当时为了降低同步FIFO满信号的延迟,我用两级同步器提前两个周期锁存almost_full,效果明显,把反压抖动控制在了3个周期内,不丢包。
4.3 Wireshark与iperf3的小技巧
iperf3打UDP流时,默认窗口大小很小,-w 4M加上,不然后端网卡缓冲直接丢包。我们还遇到过Windows端用iperf从WSL2打到FPGA不通的问题,本质是WSL2的虚拟交换机绕过了物理网卡,务必用-B指定物理网卡IP,或者在Windows PowerShell下用原生iperf3跑。
Wireshark上抓UDP包,如果流量太大(100G线速,网卡都爆),先在交换机或服务器NIC上配置tcpdump -s 96 -n只抓包头,减少抓包文件写入压力。别想着把100G全流量都抓下来,没有一块普通盘能接住,正确的做法是分层验证:物理层看错包计数器,IP层看tcpdump头部,业务层看应用日志。
4.4 排查丢包的一个高效方法论
丢包定位顺序,强烈建议从外到内:
- 物理层:
ethtool -S看rx_crc_errors_phy、rx_symbol_errors、link_down_events,如果有增加,查光模块、DAC、FEC。 - MAC层:看
rx_pause、rx_oversize、rx_undersize计数,判断是否存在帧间隙/长度错误。 - IP/UDP层:在FPGA内部加计数器(drop_cnt、checksum_err_cnt、total_rx_cnt),打印到串口或AXI-Lite寄存器。
- 应用层:上位机PC看是否有
UDP buffer overflow,Linux里用netstat -su看RcvbufErrors。
排查时每一步都留一个全局计数器,比任何仿真都管用。我最后就是靠FPGA内部加的rx_udp_crc_err_cnt计数器,定位到IP校验和偶算出错,才修好一个隐蔽bug:IP头总长度字段在倒序字节序下少加了一次头长。
5. 资源占用率与性能瓶颈分析
5.1 FPGA资源消耗估算
移植完成后,我统计了资源占用,放在Vivado的Report Utilization里看:
- 逻辑资源(LUT):UDP TX+RX+ARP完整协议栈约 24k LUT(占XCVU9P不到2%),大部分被FIFO和控制状态机吃掉。
- 寄存器(FF):约28k。
- BRAM:同步FIFO和ARP缓存共约12块(36Kb)。
- 高速收发器:4个GTY通道(一共用了一个QSFP28口),完整的100G全速率链路需要占用2个GT reference clock。
- DSP:基本没用,UDP本身不是计算密集,主要是数据整形和缓存。
如果你用的是Intel的Agilex或Stratix 10,Vivado换成Quartus后,IP例化基本对标25G/100G Ethernet MACHIP,代码移植量主要在对齐avalon-ST和AXI-Stream接口的字节序和valid/ready时序上。
5.2 100G UDP的真正瓶颈在哪
上板测完之后,我仔细想了一下,硬件自身的瓶颈其实很少,真正制约100G UDP实用化的是三件套:
DDR带宽与DMA架构。UDP数据要落地到内存或搬运到CPU,DMA描述符的效率和读写内存带宽决定上限。如果只是把数据在FPGA内做转发(line rate),FPGA可以轻松跑到线速;一旦加DDR缓存,就要考虑内存页冲突和带宽损耗,常见方案是多bank交叉加上
Write Buffer。PC侧接收能力。服务器的PCIe通道、网卡中断速率、应用层Socket缓冲区,随便一个都可能成为瓶颈。我测试时如果不调大
rmem_max和net.core.rmem_default,UDP接收侧到100Gbps必然在几十毫秒后开始丢包。像Windows系统还要去注册表改GlobalUdpSystemBuffer,这里引入的热搜词“修改Window全局UDP系统缓存区”说的就是这个痛点,调大后对端打流稳定性提升很明显。协议栈的状态存储能力。100G线速要求每个包处理周期极短,如果UDP会话数很大(比如同时几万条流),哈希表查询和老化机制就要设计好。我这边用静态查表,适合单流压测;真正的多流场景,建议改用BRAM里的关联存储器(CAM)或者开放地址哈希表,并加老化定时器。
6. 个人实操体会与后续扩展
最后聊点贴身的经验。FPGA 100G UDP移植,你问我最难的是哪一步?不是RTL设计,也不是Debug,而是心态从“仿真过了”切换到“上板要稳”。仿真里你拍着胸脯说“这代码没问题”,上板之后一堆K7、V7、UltraScale不同工艺带来的时序、复位、相位问题全部铺面而来。我这次最深的教训是:永远先量一下复位和时钟的上升沿对齐情况,很多诡异的功能错误,最后发现是异步复位释放太晚导致第一个包状态机错乱。
另外一个建议:刚开始别追求做到100G线速,先把功能跑通。先用1G/10G的MAC IP验证UDP业务逻辑,再切到100G PHY,这样每一步的风险都拆小了。我是直接在100G上干,结果一个小小字节序错误就花了我整整两天的抓包时间。
至于后续扩展,我打算在这套100G UDP链路上再叠三样东西:
- 一个轻量的
UDP Offload完整状态表(支持NAT和端口映射),用来做硬件网关原型。 - RS-FEC和Link Training的完整状态上报,把物理层自愈能力做成标准寄存器接口,方便上位机监控。
- 把协议栈接入XDMA或者QDMA,打通服务器内存和FPGA之间的零拷贝通路,这样100G UDP就能真正变成一个低延迟网络加速卡的基础。
如果你也在折腾同款项目,希望这篇能帮你省下我踩过的那两周。有啥具体问题,欢迎按现象描述清楚,我尽量回复。