做 FPGA 的人一旦碰过 100G UDP,就很难再回头用 10G 那套思路去凑合。我这次的需求很直接:前端采集板要把 ADC 数据以接近线速送到后端服务器,延迟要求微秒级,而且流量形态要完全可控。第一反应是买成品 100G 网卡,但实测下来,Linux 内核协议栈在每秒百万级小包的场景里,中断和软中断占比太高,UDP 虽然协议简单,收包路径上的锁和排队还是带来了明显的延迟抖动。最后方案落到了 FPGA 上,目标是在板卡上移植一套开源的 UDP 协议栈,跑通 100G 线速,然后完成上板测试。这篇文章就是我整个“选型、移植、上板、排坑”过程的完整记录,适合正在做 FPGA 网络加速、想用开源代码快速出活的工程师参考。
1. 为什么是 100G UDP:场景、延迟与开源栈的契机
1.1 低延迟场景里,UDP 比 TCP 友好太多
很多没写过硬件协议栈的人会问:UDP 这么简单,直接用 CPU 收不就行了?在 10G 时代,这个说法勉强成立;到了 100G,问题就完全不一样了。100G 以太网在小包场景下的线速大概是多少?64 字节以太网帧,在线路上实际占 84 字节(前导码和帧间隔都算进去),除以 100G 线速,得到约 148.8Mpps。也就是每秒要处理 1.48 亿个包。这个量级下,CPU 每收一个包都要走一次中断、一次协议栈解析、一次 socket 排队,无论怎么优化,单核都撑不住;多核分摊又会引入锁和分流的复杂度。
TCP 在硬件里实现更痛苦。它的状态机包含连接管理、序号跟踪、重传定时器、拥塞控制窗口,任何一个环节出问题,接收端都要能处理乱序和重复。UDP 就简单多了:无连接、无确认、无重传,协议头只有 8 字节,校验和计算也完全可以流水化。打个不太准确的比方,TCP 像挂号信,每一封都要回执,丢了还要补;UDP 像直接往信箱里塞传单,能到就行,不要求对方签收。对硬件而言,UDP 是最容易做到线速的传输层协议。
1.2 100G 线速给 FPGA 带来的真正挑战
很多人以为 100G UDP 的难点在 UDP 校验和,其实不是。校验和就是一个 16 位累加,FPGA 里用组合逻辑随便算。真正的难点是:512bit 宽的 AXI4-Stream 总线,每个时钟周期要同时处理 64 字节数据,状态机必须在非常短的节拍内完成头解析、路由查找、校验和更新、负载转发。一旦某个分支判断多耗了一个周期,就会在数据流上打洞,吞吐就掉下去。
这也是我坚持选开源方案而不是自己从头写协议栈的原因:MAC 层和 IP/UDP 层的边界处理、校验和逻辑、ARP 和 ICMP 回显,这些看似简单但细节很多的东西,直接用成熟代码能省掉大量调试时间。重点是理解它、把它接进自己的工程,而不是重新发明轮子。
2. 开源方案选型:我对比过的仓库与最终选择
初期我在几个开源项目之间犹豫了很久。简单列一下对比结果:
| 项目 | 定位 | 驱动/依赖 | 100G 支持 | 我放弃/选它的原因 |
|---|---|---|---|---|
| verilog-ethernet(Alex Forencich) | 纯 RTL 以太网 MAC + UDP/IP 栈 | 无,纯 FPGA 逻辑 | 有 eth_mac_100g 等模块 | 最终选用,模块化清晰,Vivado 下接入成本低 |
| Corundum | 完整 FPGA 网卡,含 PCIe DMA 和 Linux 内核驱动 | 强依赖 PCIe 和驱动框架 | 支持 | 功能太重,我的板子不是标准 PCIe 网卡形态,驱动适配成本高 |
| OpenNIC / ntap 一类 | 偏研究型网卡方案 | 依赖特定驱动和主机环境 | 部分支持 | 文档少,社区小,出问题不好查 |
2.1 为什么把 Corundum 排除了
Corundum 是非常优秀的开源网卡项目,如果我要做一块完整的、带 PCIe DMA 和自定义队列的智能网卡,很大概率会选它。但这次的需求不一样:我的数据通路是板卡自己产生数据,通过 UDP 直接发出去,不需要主机 CPU 参与收包,也不依赖 PCIe 的 DMA 描述符机制。如果强行上 Corundum,我还要解决 Linux 驱动的内核版本兼容、IOMMU、MSI-X 中断这些问题,显然偏离了“快速移植上板”的目标。
2.2 verilog-ethernet 的架构速览
Alex Forencich 的 verilog-ethernet 仓库,核心目录是 rtl 和 lib。rtl 下面按功能拆分得很清楚:eth_mac系列是不同速率的以太网 MAC,udp_ip_stack是 UDP/IPv4 栈的封装,这里面还带了 ARP 和 ICMP 回显;ip、udp、arp模块又各自独立。辅助的axis_*模块则用于 AXI4-Stream 位宽转换、异步 FIFO、注册表等,非常实用。
我这次实际用的是eth_mac_100g和udp_ip_stack的组合。选它的另一个原因是用户接口统一走 AXI4-Stream,只要理解了 tvalid/tready/tlast/tkeep 这几个信号,就能把上下游逻辑接起来。仓库里还有 testbench,可在仿真阶段先把独立功能跑通,大大降低上板后的排错成本。
3. 硬件准备:板卡、光模块与 CMAC 配置
3.1 板卡与线缆选择
100G 的 FPGA 方案,板卡选择不多但都有共同点:需要带 QSFP28 光口,需要足够的 GTY/GTM 高速收发器,最好还有一颗高频参考时钟。我手头用的这块是自研的 UltraScale+ 板,核心芯片是 VU9P,板载双 QSFP28;如果买现成的,VCU118、Alveo U200/U250 都是常见选择。Alveo 卡的输入时钟和电源管理做得比较省心,适合第一轮跑通逻辑;自研板则要自己核对参考时钟和复位电路。
线缆方面,强烈建议第一轮用 QSFP28 DAC 铜缆,不要一上来就用光模块加长光纤。DAC 铜缆没有光口洁净度和光功率问题,链路物理层出现问题的概率低很多,可以减少变量。PC 端配套的网卡我用的是 Mellanox ConnectX-5,100G 网卡里它兼容性最好,驱动在主流内核里都有。如果实验室实在没有 100G 网卡,也可以用两块 FPGA 板互相打流,但我觉得先用 PC 网卡做标准参考更方便抓包。
3.2 CMAC IP 配置与用户时钟
在 Vivado 里生成 Xilinx Integrated 100G Ethernet Subsystem(也就是大家常说的 CMAC)时,有几个关键配置值得说明。用户接口推荐选 AXI4-Stream,数据位宽 512bit,对应的用户时钟是 322.265625MHz,这是 IP 向导里常见的默认组合。GT 参考时钟一般是 161.1328125MHz,对应 4 个 25.78125Gbps 的 SerDes 通道,这个频率由板卡晶振提供,约束文件里要写清楚。
我个人建议第一步先跑 CMAC 自带的 example design,板上验证链路能否建立。CMAC 的复位逻辑比大多数人想的更啰嗦:GT 收发器的复位、PCS 复位、MAC 复位,还有各个 user clock 的 phase 关系,都要等对应状态机跑完。example design 里已经把这些复位信号的处理做好了,能帮你省掉大量查时序手册的时间。
RS-FEC 默认可以先关掉。如果用的是短距离 DAC 铜缆,线缆质量正常,关掉 FEC 也能稳定跑;开 FEC 虽然更接近长距离光模块的真实使用场景,但初期会多一个变量,不利于排查。
4. 移植步骤:从 GitHub 到 bitstream 的一条龙流程
4.1 代码准备与工程组织
先克隆 verilog-ethernet 仓库,然后把 rtl 目录下的所有 .v 源文件加入 Vivado 工程。需要明确一点:这个仓库的模块之间是松耦合的,虽然 git clone 后可以直接全量加入编译,但我更建议先只添加必要模块。比如用eth_mac_100g时,它依赖的axis_adapter、axi_*等基础模块会自动被引用;而如果同时加了eth_mac_10g,也只是多花点综合时间,不影响功能。对新手来说,全量加进工程最省事,只要确保没有同名模块冲突就可以。
4.2 例化 CMAC 与 UDP 栈
实例化结构大致是这样:CMAC 的 512bit AXI4-Stream 用户接口先接一个异步 FIFO,再通过位宽转换模块接到udp_ip_stack。为什么中间要加 FIFO?因为 CMAC 的用户时钟是 322.265625MHz,而栈和用户逻辑可以跑在另一个频率,比如 300MHz 或 200MHz。用axis_async_fifo做跨时钟域隔离,避免整个设计都绑死在 CMAC 的时钟上。频率不够高的时候,时序收敛会容易很多。
udp_ip_stack的关键参数就那么几个:本地 MAC 地址、本地 IP 地址、是否开启 ARP 缓存、是否开启 ICMP 回显。我用的配置如下:
localparam MAC_ADDR = 48'h00_1A_2B_3C_4D_5E; localparam IP_ADDR = 32'hC0_A8_01_0A; // 192.168.1.10注意udp_ip_stack默认的 MAC 参数接口可能因为版本不同而异,旧版本是my_mac的一组 8bit 输入,新版本更倾向于编译期参数。不管哪种,只需保证与 PC 网卡上的静态 ARP 条目一致即可。
4.3 用户侧发送与接收逻辑
移植引脚之前,先在用户逻辑里做两个简单的 test pattern,能极大方便后续排错。
发送方向:做一个计数器,每 1024 个周期组装一个 UDP 报文,payload 是递增数据。目的 IP 写成 PC 的 192.168.1.2,目的端口固定 5001,源端口固定 4000。这样用 tcpdump 一抓,就能清清楚楚看到每个字段。
接收方向:做回环模式。把从 UDP 栈收到的 payload 原封不动再塞回发送侧,目的 IP/端口换成原来的源 IP/端口。这样 PC 发一个包到 FPGA,FPGA 立刻回一个相同 payload 的包,验证通路非常高效。
AXI4-Stream 握手信号里最容易出错的是tlast和tkeep。tlast必须在最后一拍拉高,且tkeep要准确表示最后一拍的有效字节数。比如 8 字节位宽的接口,如果 payload 长度是 3,最后一拍 tkeep 就是 4'b0111,这时候 tdata 的高字节数据无效,下游不能拿来计算。这个细节在 512bit 总线上更致命:如果 tkeep 算错了,一整包数据就会错位。
4.4 约束与综合实现
上板前综合实现时,我注意到时序收敛整体比预期顺利,但有几个位置容易出红线:跨时钟域 FIFO 的读写指针逻辑、UDP 栈里的校验和流水线、以及 512bit 总线的位宽转换模块。第一版如果时序不过,不要急着改 RTL 算法,先看关键路径在哪个模块,把组合逻辑切几级寄存器通常就解决了。另外,复位信号尽量用 CMAC 输出的用户复位,不要自己用计数器延迟硬凑,否则容易出现复位释放时序违例。
5. 上板实测:抓包、打流与 ILA 三件套
5.1 链路建立检查
烧写比特流之后,第一件事不是发 UDP,而是先确认物理链路 up 了。PC 端看网卡状态,ethtool eth0能看到 Speed: 100000Mb/s 和 Link detected: yes。如果显示没有 link,优先检查 QSFP28 线缆是否插紧、CMAC 内部状态机是否跑完,用 Vivado Hardware Manager 里的 ILA 观测 CMAC 的gt_rxstatus和us_rxstatus信号,能快速判断是 SerDes 层还是 MAC 层的状态异常。
还有一个非常实用的自检手段是 CMAC 的 PRBS 测试。在 CMAC 配置里把 TX 侧设为 PRBS 发生器,RX 侧设为 PRBS 校验器,两端在两个 GT 通道之间做内部回环或通过外部 DAC 做环回,就能确认 SerDes 和线缆是否完好。链路都不干净,后面所有抓包都是浪费时间。
5.2 静态 ARP 与 Ping 验证
Linux 默认会发 ARP 请求来解析目的 IP 的 MAC。虽然 verilog-ethernet 的栈带 ARP 模块,但为了避免测试时间浪费在 ARP 缓存等待上,我建议一开始就在 PC 上配置静态 ARP:
ip addr add 192.168.1.2/24 dev eth0 ip neigh add 192.168.1.10 lladdr 00:1a:2b:3c:4d:5e dev eth0 nud permanent配置完之后,先 ping 一下 FPGA 的 IP。如果 UDP 栈开了 ICMP 回显,ping 能通,说明二层和三层基本 OK,校验和、MAC/IP 地址解析这条链路已经通了。ping 不通也别慌,先用 tcpdump 看 PC 有没有发出 ARP 请求、FPGA 有没有 ARP 回复,哪个方向没包就查哪一侧。
5.3 UDP 打流测试
ping 通了之后,把 FPGA 的 test pattern 发送逻辑打开,PC 上抓包:
tcpdump -i eth0 udp port 5001 -XX如果tcpdump正常打印出 UDP 报文,而且 payload 能看到 0x00、0x01、0x02 这样的递增序列,链路已经基本算通了。此时 tcpdump 结束时的统计信息里常会出现packets to unknown port received,这是正常现象:说明 UDP 头解析正确,只是 PC 上没有一个 socket 监听 5001 端口。很多网友遇到这个统计以为丢包或协议异常,其实不是,它恰恰证明包已经完整到达主机协议栈。
接下来用 iperf3 做带宽测试。PC 向 FPGA 发送方向,如果 FPGA 开的是回环模式,命令大概是:
iperf3 -u -c 192.168.1.10 -b 100G -t 10 -l 1400这里有个容易困惑的点,也正是很多人在网上问的:iperf3 用 UDP 跑 TX 时,到底看 sender 端还是 receiver 端?一定要两边都看。UDP 没有 ACK,sender 只能表示自己发出了多少包,实际到没到、丢没丢,要以 receiver 的接收包数、丢包率、抖动为准。而且单线程 iperf3 往往打不满 100G,受限于本机 CPU 和 PCIe 路径,建议加-P 8开多流,并且用-J输出 JSON 格式,方便脚本比对 sender 和 receiver 的 packet 数。
包长方面,默认 MTU 1500 时 UDP payload 上限约 1472,带宽测试想压到接近线速,最好开巨型帧:两端都设 MTU 9000,发起ping -M do -s 8972验证巨型帧通路。如果开巨型帧后 ICMP 不通,查交换机和网卡配置,而不是怀疑 FPGA 协议栈。
5.4 ILA 抓内部数据
外部抓包无误后,我还会再插一个 ILA 到udp_ip_stack的接收出口和发送入口上。ILA 触发条件设置成tvalid && tlast,也就是抓到一包结束时的那一拍。把抓到的 tdata 和 PC 发的原始报文逐字节比对,这一步对排查后面的字节序问题非常重要。
如果接口数据位宽是 64bit,一包 64 字节的 UDP 报文会被拆成 8 拍,ILA 看到的 tdata 是从低字节到高字节排列的。很多 FPGA 工程师习惯大端思维,看到 64bit 数据里的第一个字节出现在低位就懵了,以为数据反了。实际上以太网帧在线路上是高位在前,但硬件总线普遍按小端方式把低地址字节放在低 bit 位,这个映射关系搞清楚,字节序问题就解决了一半。
6. 移植中的坑:链路、校验和、字节序与时序收敛
6.1 链路 up 但收不到包:从 PHY 到 ARP 逐层排查
我遇到过一个很典型的问题:ethtool显示 link up,但 tcpdump 什么都抓不到。一开始以为是 UDP 栈配置问题,调了很久,最后发现是 CMAC 的复位逻辑没有把gt_rxuserrdy拉起来,导致 GT 接收侧根本没解出数据。这个状态在外部看链路确实是 up 的,因为物理信号已经建立,但内部用户接口一直没有数据出来。
排查顺序我建议固定下来:先看 CMAC 的gt_rxstatus、us_rxstatus是否为正常值,再看 PRBS 内部自检能否通过,接着用外部 DAC 环回看链路,最后才看到 UDP 栈。反过来排查容易把简单问题复杂化。
6.2 校验和与硬件卸载:PC 侧也在“搞鬼”
FPGA 发出去的报文,在 PC 上抓包时发现 UDP checksum 是 0x0000,但 FPGA 侧明明算了校验和。这是因为现代网卡默认开了 RX checksum offload,驱动会把硬件校验结果直接标记到 skb 上,抓包工具显示的 checksum 已经是“由网卡硬件验证过”的 0。判断方法很简单:在 tcpdump 里看bad udp csum提示,如果出现,才说明校验和真的有问题;或者用ethtool -K eth0 rx-checksumming off关掉卸载再抓包。
反过来,PC 向 FPGA 发送时,如果网卡开了 TX checksum offload,驱动可能不填 checksum,而是交给硬件在发送时计算。FPGA 侧收到校验和为 0 的包,如果栈严格校验,会当成坏包扔掉。调试期我建议把 PC 网卡的 tx/rx checksum offload 都关掉,让内核老老实实计算,排除这个变量后两边行为就确定可预期了。
6.3 512bit 总线的字节序
字节序问题是 100G 移植里最隐蔽的坑。同样一组数据,10G 时代用 32bit 总线,低字节在低 8bit,大家习惯成自然;到了 100G,总线条数翻到 512bit,每周期 64 字节,一旦源/目的 MAC 地址放错字节位置,报文整体就废了。常见现象是 tcpdump 能抓到包,但抓出来的目的 MAC 变成了5e:4d:3c:2b:1a:00这样的反序。
解决的办法很土但有效:先用固定 test pattern,比如 payload 从 0x00 递增到 0xFF,配合 ILA 逐拍对数据,确认哪一级模块把字节顺序搞反了。verilog-ethernet 的模块默认衔接正常,问题通常出在用户自己写的发送逻辑或位宽转换上,别一上来就怀疑开源代码。
6.4 tkeep 和 tlast 的边界条件
小包场景里,很多帧在一拍之内就完成了 tlast,此时 tkeep 可能不是全 1;大包场景里,最后一拍的 tkeep 又往往是部分有效。如果用户逻辑生成发送数据时没有正确计算 tkeep,UDP 栈加完头之后 payload 会错位,接收端解析出来的长度字段和实际数据长度对不上。建议用“包长模位宽”来生成 tkeep:余数为 0 时 tkeep 全 1,否则把低位对应位置 1,其余清零。
6.5 时序收敛与跨时钟域
最后聊聊时序。用户逻辑如果直接挂在 322.265625MHz 的 CMAC 用户时钟下,任何一点组合逻辑过长都会造成时序违例。我的做法是将 UDP 栈单独跑一个时钟域,用户业务逻辑跑一个更低频率的时钟域,中间用axis_async_fifo隔离。这样用户的计数器、RAM、状态机都不用在 322MHz 下布局布线,时序压力小很多。实测下来这套结构在 -2 速度等级的 UltraScale+ 上非常稳,布局布线一次通过。
整套移植下来,我最真实的感受是:100G UDP 的难点不在 UDP 本身,而在于底层 PHY 状态机、位宽转换和字节序这些“周边细节”。开源栈把协议处理已经封装得很好了,我们要做的是把它和厂商 IP、板卡环境正确衔接,再通过抓包和 ILA 一层层证明每一级都可靠。最后再分享一个小技巧:上板测试时先用 DAC 铜缆 + 静态 ARP + 回环模式,把物理层和协议层都验证干净后,再换光模块和长距离链路,能省掉至少一半的排错时间。