开源 100G FPGA UDP 移植上板测试
做高速网络的朋友应该都有体会:10G 已经烂大街,25G 正在普及,而 100G 这个档位,开始从数据中心核心交换往边缘和专用设备渗透。但 100G 的方案,过去基本被专用网卡和商业 IP 垄断,想在自己的 FPGA 工程里塞一个 100G 的 UDP 通路,要么掏高额的授权费,要么啃几百页的协议文档自己造轮子。
我最近把一个开源的 100G UDP 硬件协议栈,完整移植到了手上的 FPGA 开发板,并且成功跑通了上板测试。这个项目解决的核心问题,就是在 FPGA 上实现一个能直接对接 100G 以太网的 UDP 收发通路,让主机侧或者逻辑侧可以像操作一块普通网卡一样,高速收发 UDP 数据包。整个过程涉及的链路非常长,从 GT Transceiver 到 MAC、从 UDP 卸载引擎到 DMA、从时序收敛到 iperf3 打流,每一步都有坑。这篇博文就把我踩过的坑、验证过的方案、以及最终稳定跑通的配置完整记录下来,给正在做或者准备做 100G UDP 的同行一个参考。
先说结论:这项技术的大致路径已经非常清晰,FPGA 上的 100G UDP 早已不是实验室级别的东西,但它依然不是一个“打开即用”的方案——协议栈选型、与用户逻辑的接口设计、时序约束以及上板验证方法,都有大量的工程细节需要处理。适合有 FPGA 基础、正在做高速网络采集或传输方案、或者纯粹想搞明白 100G 以太网在 FPGA 上是怎么实现的工程师阅读。
1. 项目背景与方案选型
1.1 为什么是 100G UDP
做网络加速或者数据采集的工程师,经常遇到一个尴尬场景:CPU 的软中断在高速网络面前顶不住。10G 的 UDP 小包,如果完全靠内核协议栈处理,几乎能把 CPU 打满,100G 就更不用说了,纯软件的方案基本没有机会。这时候就需要把数据通路“下沉”到硬件里,FPGA 在中间扮演一个关键角色——通过硬件协议栈将 UDP/TCP 的封装、解封装、校验和计算等重复性工作从 CPU 中剥离出来。
把网络协议栈卸载到 FPGA 上,最直接的价值有两个:一是释放 CPU,让主机 CPU 只做业务逻辑而不是搬运数据;二是降低延迟,硬件处理包的延迟是纳秒级到微秒级,而不是软件栈的几十微秒到毫秒级。选 UDP 而不是 TCP,还有一个原因:UDP 无状态,硬件实现简单得多。TCP 有连接管理、重传、拥塞控制、滑动窗口,这套逻辑放进 FPGA 里面,资源和开发成本都是指数级上升。而绝大多数的数据采集和视频传输类业务,本身就不需要 TCP 的可靠性,丢几个包换更大带宽和更低延迟,是完全可以接受的。
所以这个项目的定位就很明确:做一个硬件 UDP 协议栈,支持 100G 线速收发,为上层业务提供高带宽、低延迟、可预期的数据通路。
1.2 硬件平台与开源方案选型
移植一个 100G UDP 协议栈,第一个问题是选硬件平台。从工程角度,建议选带 100G 光口且 SerDes 资源足够的板子。我用的是 Xilinx UltraScale+ 系列的开发板,板载 4 路 25G 光口,通过 QSFP28 封装成 100G 的端口,配套的 FPGA 有足够的 GTY Transceiver 资源和逻辑资源。
选型上很多人会纠结用哪个厂商的板子。Xilinx 在高速以太网这块的 IP 相对成熟,Vivado 里的 100G Ethernet Subsystem IP 可以直接生成 MAC+PCS+PMA 的完整链路。Intel 的 Agilex 和 Stratix 10 也有对应的 100G IP,但使用体验和文档丰富度上,Xilinx 的资料明显更多,踩坑的时候能找到的案例也多,所以我建议新手从 Xilinx 平台开始。
开源协议栈方面的选择其实不算多。目前比较成熟的开源方案里,有一类是完整网卡方向,把整个以太网 MAC 到 PCIe DMA 都实现了,功能全但工程规模大,移植起来窗口期比较长;另一类是偏轻量级的 UDP offload 引擎,只做 IP/UDP 层的封装和解析,底层 MAC/PCS 直接用厂商 IP,这种集成速度快,适合我们这种“想在 FPGA 上快速加一个 100G UDP 端口”的场景。我最后选了后者,原因很简单:它屏蔽了以太网物理层的复杂度,同时把最核心的 UDP 逻辑做成参数化、可配置的模块,我们只需要关注接口和数据通路。
| 对比项 | 完整软网卡方案 | 轻量级 UDP offload 引擎 |
|---|---|---|
| 集成难度 | 高,涉及 PCIe DMA 等 | 较低,纯 UDP 通路相对独立 |
| 资源占用 | 高,逻辑+存储+SerDes | 中等,取决于付现 FIFO 大小 |
| 可靠性与生态 | 较高,有社区维护 | 中等,需自行验证细节 |
| 适合场景 | 通用网卡/云计算 | 专用数据采集/加速业务 |
2. 协议栈架构与核心原理拆解
2.1 UDP 硬件协议栈的组成结构
整个 UDP 硬件协议栈从下往上分,可以切成四层:物理层、链路层、网络层和传输层。在 FPGA 上,物理层和链路层通常都是直接用厂商的 Ethernet IP 核,而我们也确实应该这么做。自己写一个 100G 的 PCS/PMA 是完全不现实的,SerDes 的高速串行逻辑、时钟恢复、FEC 这些,都是厂商积累了很多年的核心资产,复用是最高效的路。
物理层对应的是 GTY Transceiver,完成 100G 光模块的光电信号转换和高速串行收发。链路层是 MAC 层,负责以太网帧的封装与解析,包括前导码、帧起始界定符、CRC 校验等。
再往上就是这次移植的核心:UDP offload 引擎。它做的事情,用一句话概括就是——把标准的 IPv4/UDP 数据报文的处理逻辑,用硬件逻辑实现。具体包括:
- 解析上行方向(从 MAC 收上来的数据)的以太网帧,识别出 IPv4 + UDP 报文,剥离 MAC 头、IP 头和 UDP 头,把纯 payload 交给上层;同时对 CRC、IP 首部校验和、UDP 校验和做检查。
- 处理下行方向(从上层要发出去的数据),把 payload 逐级封装成 UDP 报文、IP 报文、以太网帧,并在封装过程中硬件计算好 IP 首部校验和和 UDP 校验和。
这个引擎和 MAC 之间是通过 AXI4-Stream 接口通信的。AXI4-Stream 是 FPGA 里最常见的流式接口,没有地址线,只有 tdata、tvalid、tready、tlast、tkeep 等信号,非常适合用来做高速数据搬运。
除了收发引擎本身,还有一个很重要的模块:DMA。因为 100G 的端口如果只靠 FPGA 内部逻辑直接吞吐,数据最终还是要给主机 CPU 用,PCIe DMA 是绕不开的。不过这一步的复杂度不低,很多人在移植的时候会先放弃 DMA,而是把 UDP 数据直接接到自定义的用户逻辑——比如接一个采集模块、一个 DDR4 存储控制器,或者一个高速信号处理流水线。我这次就先把 UDP 通路验证通,再把数据接到 DDR4 控制器,实测可以做到大包线速写入内存,这个后面会说。
2.2 关键技术点:校验和、ARP 与分片
前面提到的校验和计算,看起来简单,实际上暗藏不少玄机。以太网帧的 CRC 是由 MAC 硬件完成的,一般不用我们操心。但 IPv4 首部校验和和 UDP 校验和,必须自己做。
IPv4 首部校验和的做法是把 IP 头按 16bit 为一组做二进制反码求和。这个计算在发送路径上是由硬件完成的,每次发包之前算一遍就行。关键点在于,IP 头里的字段是会变的,比如 ID 字段、总长度字段,每发一个包都会不同,所以不能只在初始化的时候算好一次。
UDP 校验和更麻烦一点,它有一个伪首部的概念,要把源 IP、目的 IP、UDP 长度、协议号(17)都参与到校验和的计算里。也就是说,计算 UDP 校验和的时候,不但要看 UDP 头,还要看 IP 头的相关信息。在纯硬件实现里,通常的做法是:先把 UDP 头和数据部分送入校验和计算逻辑,同时从 IP 头寄存器里读源 IP、目的 IP 和 UDP 长度等字段,拼成一个“伪首部”一起参与累加。为了不降低吞吐率,一般用两级的树形累加结构,而不是一个周期一个周期地串行累加。
还有个容易被忽略的点是 ARP 协议。要做标准的以太网通信,单纯把 UDP 报文从 FPGA 发出去是不够的——如果对端主机的 ARP 缓存里没有 FPGA 的 MAC 地址,报文会被丢掉。硬件栈必须在收到 ARP 请求时自动应答,而且要在开机的过程中主动发出 ARP 请求来获取对端的 MAC 地址。很多开源的 UDP offload 引擎里,ARP 模块反而是验证起来最啰嗦的部分,因为它的状态转移逻辑虽然不复杂,但涉及超时重传、缓存老化等边角情况。
分片问题是另一个容易踩坑的地方。IP 分片,在硬件里实现起来非常消耗资源,因为要缓存整个 IP 报文,然后按分片规则切分成多个分片报文。TCP/UDP 大包如果超过 MTU,在软件协议栈里由操作系统分片,在硬件卸载引擎里就得自己处理。我建议不要做分片,直接限制会话的 MTU,比如设置 1500 字节的 MTU,或者把上层业务的数据包控制在 1400 字节以内。做 100G 业务的一般都是数据中心内部环境,MTU 9000 的巨型帧也常见,但如果你用了 jumbo frame,一定要确认交换机支持,并且在 FPGA 侧同步调整最大帧长度的匹配规则。
2.3 时钟体系与跨时钟域设计
100G UDP 在 FPGA 上跑不起来的最常见原因,不是逻辑写错了,而是时序没过。而时序问题里面,有一半是时钟架构没设计好。
100G 以太网的线速率是 100Gbps,在 Xilinx UltraScale+ 平台上用 4 条 25G 通道,每条通道的线速率是 25.78125Gbps(64b/66b 编码后)。GT 参考时钟一般是 156.25MHz,这个频率是固定的,但 GT 内部通过 PLL 倍频到线速率。而用户逻辑侧的 AXI4-Stream 接口,数据位宽如果是 512 bit,那么时钟频率就是 100G / 512 ≈ 195.3MHz。如果位宽是 256 bit,时钟频率就得跑到约 390.6MHz,这对逻辑时序的压力会非常大。我这次选的是 512 bit 位宽、约 322MHz 的路径,实际上 100G MAC IP 输出的 AXI4-Stream 时钟通常是在 322MHz 左右(取决于 IP 内部总线位宽和版本),这个频率下逻辑设计要非常注意流水线级数和路径延迟。
另一个关键点是跨时钟域。UDP offload 引擎的 AXIS 时钟和 DMA/用户逻辑的时钟往往不在同一个频率,需要通过异步 FIFO 来做时钟域转换。这里有一个很实用的经验:异步 FIFO 的深度一定要留足,尤其是在接收方向。100G 线速下,一个 64B 的小包只有约 5ns 的间隔,如果上游的下游逻辑偶尔反压一个周期,FIFO 深度不够就会丢包。我用的接收侧 FIFO 深度是 512,这是在 100G 满速下实测不丢包的保守深度。
3. 移植适配与工程搭建全流程
3.1 移植前的准备工作
把开源代码拿到手,第一件事不要急着综合,先梳理它的目录结构和依赖项。我拿到这个协议栈之后,发现它依赖一个特定版本的 Xilinx 100G Ethernet Subsystem IP 和对应版本的 Vivado,版本不匹配会导致 IP 的端口名和接口时序对不上,移植难度倍增。
准备工作分三步走:
第一步,确认开发环境。我用的 Vivado 2023.1,目标器件是 XCU250 系列。开源仓库里用的可能是 2020.2 或者 2021.1,我会先建一个最小工程,只例化 100G Ethernet Subsystem IP,确认 IP 能正常生成、综合能够通过,再在这个基础上叠加协议栈代码。这一步的核心价值是把环境问题隔离掉,别等把所有代码都加进来了才发现是 IP 版本不兼容。
第二步,理清时钟和复位。FPGA 工程的时钟架构必须在上板之前就在脑袋里画清楚:GT 参考时钟是谁给的(板载晶振还是外部时钟),PCIe 参考时钟是谁给的,业务逻辑用的 user clock 是从哪里分出来的。这个项目里,100G MAC IP 输出的 user clock 是 322MHz,DDR4 控制器用的时钟是 300MHz,PCIe 的 clock 是 100MHz,三个时钟域之间有数据交互,必须全部通过异步 FIFO 或者寄存器同步器处理。
第三步,修订外设接口约束。开源代码里带的约束文件几乎肯定不能直接用,因为引脚分配、时钟约束、IO 标准都跟具体板卡强相关。我花了几个小时把开源的 XDC 文件逐行看了一遍,把和板卡相关的约束抽出来替换成当前板卡的实际引脚,把时钟约束改成开发板实际的时钟源频率和引脚位置。
这一步我建议不要偷懒直接重新写约束文件,因为开源工程里对 GT 的约束、对高速时钟资源的约束是有讲究的,它的 IBUFDS_GTE4 位置、GT_REF_CLK 的引脚分配方法都是可以直接参考的,直接推倒重来容易丢信息。
3.2 关键代码改写与接口适配
开源代码拿来之后能百分之百直接用的概率很低,我这里说的“适配”包含两层:一是对外部 IP 接口的适配,二是对内部数据通路的适配。
先看接口适配。这个协议栈假设的 MAC IP 接口标准是 Xilinx 官方 100G Ethernet Subsystem 的 AXI4-Stream 接口,但不同版本之间会有些微差别,比如 tkeep 的位宽定义、tuser 的字段含义、以及流控信号的握手方式。这些细节如果对不上,仿真阶段还不好暴露,仿真模型常常把 tready 拉高就完事了,但上板之后 IP 的行为跟模型不完全一致,就会出现丢包和死锁。
我的做法是写了一个很薄的 adapter 模块,它的唯一职责就是做接口时序的转换和信号的打拍对齐,让协议栈核心逻辑看到的接口是干净的、符合预期的。这个 adapter 不改变数据内容,但把 MAC IP 和协议栈之间容易产生歧义的地方全部消灭掉。
再看内部数据通路。开源代码里可能默认把收到的 UDP 数据直接送到了某个固定的用户接口,比如 AXI4-Stream FIFO,或者某个寄存器接口。我要做的是把这个数据出口替换成自己的模块。这里有一个容易踩的坑:开源代码的时序往往是刻意优化过的,比如它可能要求用户逻辑在收到 tlast 的同一个周期完成某些状态更新,如果替换后的用户模块跟不上这个时序,就会导致协议栈内部状态机挂死。解决办法是保留原代码里的核心状态机不动,只在数据出口加一级异步 FIFO 做缓冲,让时序解耦。
3.3 时序约束与综合实现
所有代码改完,进实现之前,先把约束文件过一遍。100G UDP 工程的约束重点有三个:时钟约束、GT 约束、以及跨时钟域约束。
时钟约束主要看主时钟定义对不对。如果 100G MAC IP 的时钟树已经由 IP 自动约束了,那我们只需要关注用户逻辑部分的时钟。最容易出问题的反而是很基础的管教约束,比如某个开发板上的差分时钟引脚没有声明成 DIFF 信号,导致综合器报错或者布线资源分配异常。
GT 约束的核心是把 GT 参考时钟引脚和 GT 通道正确绑定,这通常在 XDC 里用get_pins约束到 GT 的参考时钟端口。如果绑定错误,现象是上板之后链路起不来,GT 的复位状态一直拉不下去。
综合实现之前,我会先跑一遍report_clock_interaction,看看有没有未约束的时钟域交叉。100G 工程的跨时钟域路径非常多,如果全部交给工具去自动分析,时序报告会长得吓人,而且很多路径其实是异步 FIFO 的跨时钟路径,应该设置set_clock_groups -asynchronous来排除掉。这里有个经验:不要无脑把所有跨时钟路径都设成 false path,因为如果将来数据路径上加了组合逻辑,可能掩盖真实时序问题。
我的实测流程是,综合之后先看时序摘要,时序违例超过 100ps 的路径,用report_timing_summary逐条分析。在 100G 设计里,最常见的问题是 GT 输出的并行数据到 MAC 核内部的路径不收敛,这通常需要通过调整 IP 配置(比如 pipe-line stage 选项)来解决,而不是靠改 RTL。
4. 上板测试与性能验证
4.1 环回自测:先把通路跑通
代码综合、实现、生成 bitstream 之后,第一步不是直接接主机打流,而是先做片内环回。这步的意义是先把有没有逻辑错误这件事确定下来。
片内环回的方式很简单:在 100G Ethernet Subsystem IP 的配置里,把 PCS/PMA 层设置为近端环回模式,也就是数据从发送侧出来之后不经过光模块和线缆,直接在 FPGA 内部就绕回到接收侧。这个模式下,光模块没有信号,FPGA 自身的收发通道是闭合的。
跑环回测试用不着 iperf3,直接用一个简单的内部测试模块,发一个固定长度的 UDP 报文,然后检查接收侧能收到相同内容的报文。如果环回都跑不通,那问题出在协议栈内部的收发状态机、或者 MAC 的配置上,先别急着往主机侧接。
环回通过之后,需要把光模块插上,改用远端环回模式。远端环回是把光口和线缆接到一个远端设备上,比如一台支持 100G 的交换机或者对端服务器,数据从 FPGA 发出去之后经过光模块、线缆再到对端,由对端把报文回送回来。这一步测的是整个物理链路的可靠性。
这里有一个值得注意的细节:100G 光模块对信号质量非常敏感。如果远端环回之后出现大量 CRC 错误,先别怀疑协议栈,先把你手上的光模块和线缆换一对再测。我遇到过一次“莫名丢包”,排查了半天,最后发现是光模块的插拔次数多了,金手指脏了接触不良。
4.2 用 iperf3 实测 100G 带宽
环回通路没问题,就可以真正用主机打流了。打流工具我用的是 iperf3,命令很简单:
# 服务端(对端机器) iperf3 -s -u -i 1 # 客户端(FPGA 所在主机) iperf3 -c 192.168.1.10 -u -b 0 -i 1 -t 30-b 0表示不限带宽,用最大速率发包。UDP 模式下 iperf3 无法自动测出吞吐率,只能统计实际收到的包数,因此真正能证明 100G 线速的指标是收包速率接近线路速率。
这一阶段的典型情况分两种:如果 DMA 和用户逻辑已经打通,数据最终能落到主机内存,那 iperf3 能直接显示出 Mbps。但如果像我第一次那样,只验证 UDP 通路而数据最终直接丢弃,那 iperf3 输出的是“收到 XX 包”但带宽显示只有几个 G——这通常说明 usercore 逻辑吞吐率上限到了。
我这次的做法是把 UDP 接收侧做成“计数 + 定时上报”的模式,FPGA 内部统计每秒钟收到的字节数和包数,通过串口或者 PCIe 寄存器上报。这样即使不上报给 iperf3,也能直接证明线速能力。
实测下来,配置 4 通道 25G 的 100G MAC IP,在使用 1420 字节大包的时候,FPGA 收包速率可以达到线速,CPU 占用几乎为零。小包(64 字节)场景下,瓶颈不在协议栈而在包处理速率,FPGA 也需要做到每秒 1.48 亿个包的解析能力,这时候对内部流水线和 FIFO 带宽的压力明显上升。很多开源项目在文档里会标注“64 字节小包线速”作为性能指标,这个指标的含金量比大包高得多。
4.3 长稳测试与数据校验
带宽测出来没问题,不代表工程就交付了。高速网络设备最怕的不是速度不够,而是不稳定——跑 10 分钟挂死、跑 3 小时偶发丢包、一连跑 48 小时才出现一次 CRC 错,这种问题最难排查。
长稳测试我建议至少跑 24 小时以上。测试期间用 ILA(集成逻辑分析仪)监控几个关键信号,比如发送侧 tready 拉低的频率、接收侧丢包计数器的值、GT 的误码率计数。
数据校验这块,我用的是一个非常土但有效的办法:在发包侧预设一个递增值序列,比如每个包的第一个 8 字节是包序号,后面的数据是固定递增序列,接收侧检查包序号是否连续、数据内容是否跟预期一致。如果发现包序号跳变,说明有丢包;如果序号连续但数据内容错了,说明是翻转或者写时序问题。
5. 踩坑实录与调试技巧
5.1 常见问题速查表
这次移植过程中遇到的问题,我整理了一张速查表,基本覆盖了同类工程里最高频的故障:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| GT 的 tx/rx 状态一直无法 ready | GT 参考时钟未正确约束或硬件上没起振 | 用 ILA 观测 tx/rx byte aligned 信号,检查参考时钟引脚约束 |
| 环回模式能通,远端模式不通 | 光模块和线缆质量问题,或 PCS 层 FEC 未开启 | 换光模块和线缆,或在 MAC IP 里开启 RS-FEC |
| 大包通,小包丢 | 接收 FIFO 深度不足,反压导致丢弃 | 加大 FIFO 深度,或优化下游逻辑的反压链路 |
| IP 校验和错误,数据正常 | 校验和计算逻辑对伪首部的处理有误 | 检查 UDP 校验和,伪首部是否包含了源/目的 IP 和长度 |
| 长时间运行后丢包 | 某些边界状态没有彻底处理,比如 ARP 表老化 | 加长 ARP 缓存有效时间,或定期主动刷新 |
| DDR4 写入速度上不去 | 用户逻辑到 DDR4 控制器的 AXIS 接口位宽/时钟不匹配 | 检查 AXI 数据位宽和时钟频率,必要时加异步 FIFO 解偶 |
| 时序不过,关键路径在 GT 到 MAC 之间 | IP 核的 pipeline 级数配置不当 | 调整 100G Ethernet Subsystem 的 pipeline option,重新实现 |
5.2 调试手段与独家心得
有个心得我觉得值得单独说:调试 100G 设计一定不要上来就抓线上全速数据。把速率降下来跑,把包间隔拉大跑,一步一步往上加压力。
我第一次调试时,直接开满速,结果丢包立刻发生,但根本没法定位是哪里丢的。后来把测试改成:先以 1Gbps 的速率发,一次发 1000 个包,全部正确;再 10Gbps;再 50Gbps;最后才上 100G。这样一旦出现问题,我就可以根据出问题时的速率档位,初步判断瓶颈环节。比如在 50G 以下都正常,一上 50G 就丢包,那大概率不是逻辑功能问题,而是时序收敛或者 FIFO 带宽到了极限。
另外要善用 ILA 的触发条件。不要只触发一次采集,要设置更精细的触发条件,比如tvalid && !tready,也就是“反压发生”的信号。在满速状态下,反压是一直存在的,所以直接触发很可能立刻触发,数据全是反压点,价值有限。更好的方式是触发“连续 N 个周期反压”的事件,这在 Vivado 的 ILA 里可以通过级联计数器实现。这能帮你定位是哪个模块在反压,反压持续多久,顺着这个线索追下去,瓶颈位置就清楚了。
最后再分享一个经验,也是这次项目里最深的体会:很多开源 100G UDP 项目看起来功能齐全,但真正运行起来,大多数问题都出在很少有人会主动提到的“边界条件”上——比如 ARP 请求在链路刚起来时的高频重复、UDP 校验和为 0 时的特殊语义、还有对接外部交换机时对巨型帧长度的限制。这些细节决定了你的移植工作是顺利跑通还是陷入无穷无尽的调试。
如果你也在做类似的事情,记住一个原则:别盲目相信任何一方的“默认配置”——开源代码的默认配置不一定适配你的板子,厂商 IP 的默认配置也不一定适配你的业务。把所有接口的时序拿到手、把时钟树构建好、把数据通路的瓶颈前置到设计阶段去考虑,后面上板的时间就能省下一大半。