100G FPGA UDP协议栈移植实战:从CMAC接口到iperf3打流验证
搞 FPGA 网络方向的人应该都有个体会10G/25G 的 UDP 方案已经非常成熟代码网上随便扒但一上 100G整个难度立刻上一个台阶。100Gbps 的线速折合下来是 12.5GB/s 的吞吐靠 CPU 软协议栈几乎不可能跑到这时候 FPGA 硬逻辑做 UDP 分拣和转发就成了刚需。我前阵子刚完成一个基于开源代码的 100G FPGA UDP 移植并且顺利上板跑通了 iperf3 打流和抓包验证整个过程踩了不少坑这篇就把它从头到尾拆开讲一遍给要做高速网络加速、数据采集或者只是想入门 100G FPGA 设计的朋友一个能直接照着做的参考。1. 为什么要在 FPGA 上做 100G UDP1.1 100G UDP 解决的现实问题先说场景。我这次做的项目核心需求很简单外部设备通过 100G 光口把数据包发到 FPGAFPGA 解析 UDP 报文、提取有效数据再放入 DDR 供后续处理。反过来FPGA 也要能把内存里的数据封装成 UDP 报文从 100G 口发出去。这类需求在雷达信号采集、网络测量、分布式存储、高性能计算集群里非常常见特点就是数据量大、延迟敏感、包格式相对固定。如果用服务器加商用网卡来干这件事100G 网卡本身不难买难的是软件栈。Linux 下的 UDP 收包即使关闭中断合并、开启多队列单核也就能跑个几 Gbps多核跑到 40~80Gbps 已经很费劲还伴随很高的 CPU 占用和延迟抖动。FPGA 的优势在于报文的解析、校验、过滤、组包全部在硬件状态机里完成线速进、线速出CPU 只负责搬运最终的业务数据整个链路的性能和稳定性都可控。这也是为什么很多对时延和吞吐敏感的场景宁可花更大的开发成本也要在 FPGA 上做协议卸载。1.2 开源方案选型既然要“移植”就得先选一套拿来能改的代码。目前 GitHub 上常见的开源 FPGA UDP 方案一类是偏 MAC 层的比如 verilog-ethernet它把 10G/25G/40G/100G 的 MAC、UDP offload、ARP 这些模块拆得很清楚代码风格相对干净仿真环境也齐全另一类是直接给 100G UDP offload 引擎带上 DMA、描述符、DDR 搬运的一套。我这次选的是前者那一类因为它的模块边界清楚MAC 和 UDP 逻辑分得开方便我在中间插入自己的用户逻辑和 DMA 通路。选型的时候别光看 star 数我一般会先看四个点License 是不是允许商用和修改、模块划分是不是足够独立、对厂商 IP 的依赖有多深、最近一次提交在什么时候。很多开源工程是学生作业级别的代码能仿真但一上板全是问题尤其是跨时钟域和复位处理。另外要特别注意开源代码通常只是 RTL 源码不附带完整的板级工程CMAC 核、DDR 控制器、PCIe 这些还是要自己在 Vivado 里生成并接好这正是“移植”二字最花时间的地方。1.3 先搞懂这些概念再动手如果你是 FPGA 入门阶段直接跳到 100G建议先把几个基本概念过一遍不然看代码容易懵。第一个是 MAC 和 PHY 的关系。100G 以太网里Xilinx 的 CMAC IP100G Ethernet Subsystem已经集成了 PCS、PMA 等物理层逻辑对外走 QSFP28 光模块对内给用户的是一个 AXI-Stream 接口。我们写的 UDP 协议栈本质是挂在 CMAC 用户侧的“应用逻辑”。第二个是 AXI-Stream 总线。几乎所有开源 UDP 协议栈都用它做数据通路核心信号就是 tdata、tkeep、tlast、tvalid、tready理解握手时序是必须的。第三个是线速和包速率的区别。100Gbps 线速对于 64 字节小包理论包速率约为 148.8Mpps每包间隔只有 6.7ns而用 9000 字节巨型帧时包速率只有约 1.38Mpps。这个差异直接决定 FIFO 深度、DMA 描述符数量和统计模块的设计思路。2. 移植前的硬件与工具准备2.1 板卡选型与资源评估我先说结论做 100G UDP 移植FPGA 至少要满足三个条件——有支持 100G 的硬核 MAC比如 UltraScale 的 CMAC、有足够的高速收发器接 QSFP28/QSFP-DD 光口、有 DDR4/DDR5 控制器接口用来缓存数据。逻辑资源要求反而不高纯 UDP 协议栈加上 MAC 包装LUT 消耗一般也就几万BRAM/URAM 主要被 FIFO 和数据缓存吃掉。我这次用的是一块基于 UltraScale 的工程板资源情况大概可以做个参考资源类型开源协议栈估计消耗我预留的设计余量LUT2万~5万整体占用控制在50%以内FF3万~6万同上BRAM/URAM主要用于 FIFO 与缓存预留至少30%做调试GTY/GTM收发器4个对应100G关注光口数量100G CMAC硬核1个优先用硬核这里想多说一句别只盯着 FPGA 的资源还要看板卡的散热和供电。100G 光模块和 FPGA 高速收发器满载时功耗不小之前见过有人在小开发板上硬跑 100G结果芯片频繁过热降频链路稳定性一塌糊涂。2.2 Vivado 版本与 IP 版本匹配工具链的坑比想象中多。Xilinx 的 CMAC 核在不同 Vivado 版本下接口都有差异尤其是 AXI-Stream 侧的 tuser 信号定义、状态寄存器偏移哪怕你用的是同一份开源 RTL换个版本可能就要跟着改接口适配逻辑。我这次用的是 Vivado 2022.1配套生成的 CMAC 核版本是 3.x接口相对稳定。建议拿到代码后先建一个最小工程只生成 CMAC 核和对应的复位/时钟模块跑通综合实现确认时序收敛了再一层层把 UDP 协议栈加进去。千万别一上来就全工程编译否则报错你都不知道是 IP 版本问题还是代码问题。另外开源工程里如果带了 XCI 文件最好删掉后按你自己的 Vivado 版本重新生成 IPIP 的 license 也要确认CMAC 和部分 DDR IP 是需要授权才能用的。2.3 开源工程结构梳理拿到开源代码的第一步不是急着改而是把工程结构画出来。我一般会在纸上手绘一张数据流图标清楚收发两条路径上各模块的上下游关系。以我参考的 verilog-ethernet 为例典型结构是eth_mac_100g 或 cmac_wrapper对接 CMAC 硬核处理 AXI-Stream 到 MAC 的时序转换eth_axis_rx / eth_axis_tx把 AXI-Stream 数据流拆成帧级粒度的控制信号eth_udp_axis_rx / eth_udp_axis_txUDP 接收和发送的协议解析与组包eth_arpARP 请求和响应处理还有一些 stats 统计模块用来计数收到的帧、错误帧、校验错误等。这些模块名字在不同开源项目里可能不一样但功能边界大同小异。我做的第一件事是把收发两路的 AXI-Stream tdata 位宽统一到 512bit因为 CMAC 的用户接口通常是 512bit 位宽。如果开源代码里是 64bit 或 256bit那就必须在中间加位宽转换 FIFO这一步几乎每个移植工程都躲不掉。3. 核心移植步骤与接口适配3.1 时钟与复位设计这一节是移植里最容易翻车的部分。100G CMAC 核生成之后会有几个关键时钟GT 参考时钟来自板卡的时钟源频率要看 PHY 配置、CMAC 内部产生的用户时钟 user_clk以及我们自己业务逻辑的时钟。我这次用的 CMAC 配置是 512bit 数据位宽user_clk 为 322.265625MHz。这里可以算一笔账512bit 在 322.265625MHz 下瞬时带宽是 512 × 322.265625MHz 165Gbps远高于 100G 线速这个余量用来吸收帧间隙、前导码和 MAC 控制帧带来的额外开销。很多人刚接触时会问既然线速是 100G为什么用户时钟要跑这么高因为以太网线上除了数据还有前导码、帧间隙、CRCMAC 用户接口要保证在任何瞬间都能跟上线的突发流量必须留足余量。复位序列也特别重要。CMAC 的复位不是拉一个信号就完事它要求 GT 复位完成、PLL 锁定、复位释放的顺序正确否则 status 寄存器会一直报错。我在移植时专门写了一个复位状态机按照 CMAC 产品指南里的推荐序列执行并在每个阶段加超时检测如果卡住就上报错误寄存器。调试时强烈建议把 tx_axis_ready、rx_axis_ready、stat_rx_status 这几个信号用 ILA 拉出来看链路有没有起来一眼就能判断。3.2 CMAC 的 AXI-Stream 接口对接CMAC 的 AXI-Stream 接口和普通用户逻辑的 AXI-Stream 有一个明显区别它的 tuser 信号里携带了错误标志、帧序号等额外信息。开源协议栈里tuser 的语义未必和 CMAC 核一致对接时要么把 tuser 信号直接透传要么在 MAC wrapper 里做一次映射。我在这次移植中遇到的第一个大坑就是 tkeep 的连续性。CMAC 要求发送端帧数据中间不能有空洞tkeep 必须是连续的 1只有在帧尾 tlast 拉高时才可以出现部分字节有效。但开源协议栈的发送模块为了避免拥塞有时候会在帧中间拉低 tvalid这本身没问题问题在于它可能把 tkeep 也拉低。CMAC 对这种非连续 tkeep 的行为是“未定义”实测会出现 CRC 错误或者干脆丢帧。解决办法就是在 MAC wrapper 里加一个 512bit 位宽的 axis_async_fifo利用 FIFO 的读侧把数据平整地吐给 CMAC确保 tkeep 始终连续。另外MAC 层的错误帧过滤也要自己处理。CMAC 在接收方向会把 CRC 错误、长度错误等通过 tuser 标记出来应用层要决定是丢弃还是上抛。我参考的开源代码默认在 MAC wrapper 里直接丢弃错误帧但在调试阶段建议保留一个统计计数方便判断链路质量。3.3 UDP 协议栈逻辑核对协议栈本身的开源代码通常已经能跑但移植时必须逐项核对几个关键逻辑因为它们和使用的 MAC、IP 版本强相关。第一是 ARP 表。开源实现一般是动态学习表收到 ARP 请求就更新表项超时后删除。我测试时为了省事最初打算写死一个静态 ARP 表项结果发现代码里查找逻辑只认动态表后来还是改成动态学习。第二是 UDP 校验和。UDP 校验和计算用的是 ones complement 加法伪头部拼接需要包含源 IP、目的 IP、协议号、UDP 长度。这个字节序和位宽在 512bit 总线上一旦错位接收端会一直报 checksum 错误但抓包看又觉得帧格式完全正常非常难排查。第三是 MAC 地址过滤。FPGA 侧要决定响应哪些目的 MAC 地址的包单播、广播、组播的处理逻辑要分开否则 ping 不通或者 ARP 有去无回。我的建议是在正式和外部设备联调之前先用仿真把协议栈的收发包流程跑一遍重点看 UDP 源端口、目的端口、长度字段和校验和这几个关键字段在 512bit 数据总线上的字节排列顺序确认和 Wireshark 里看到的字节序一致再上板。3.4 用户侧数据通路与 DDR 缓存衔接UDP 数据解析完之后下一步就是把 payload 搬到 DDR或者从 DDR 读出数据组包发出去。这里有两种常见做法一种是用 Xilinx 的 AXI DMA IP配合描述符机制另一种是写一个简单的 AXI 主设备状态机自己管理写入地址。对于 100G 这个量级我比较推荐描述符方式因为 CPU 可以参与内存管理Flexible 得多。描述符机制的要点是环形缓冲区。发送端 CPU 填好描述符写入 doorbell 寄存器FPGA 的 DMA 引擎就去内存搬数据完成后更新完成寄存器接收端则反过来FPGA 把数据写进 DMA 指定的缓冲区更新完成描述符CPU 轮询或者收中断来取。这里最容易出问题的是描述符耗尽如果 CPU 补描述符的速度跟不上 100G 线速DMA 引擎就会因为无缓冲区而丢包。实测下来接收方向至少要保持几十个深度的描述符预取并且 CPU 侧要用轮询加中断混合模式才能做到高吞吐下不丢描述符。还有一点是 DDR 带宽规划。100G 线速意味着 12.5GB/s 的写入流量DDR4 单通道理论带宽虽然够但读写冲突、行切换、刷新开销都会吃掉实际带宽所以建议预留至少 30% 的带宽余量并且把 AXI 突发长度设大一些我这边用的是 256B 突发实测效率不错。4. 上板测试流程与结果分析4.1 上电检查与链路建立上板测试不是直接跑 iperf3 就完事前面还有一堆基础检查。先用 JTAG 加载 bitstream不着急烧 QSPI因为协议栈还要迭代改。加载完先看时钟有没有起来再看 CMAC 的 status 寄存器重点看 link_up、gt_powergood、rx/tx_pma_ready 这些位。第一次上电时我习惯先做一次 CMAC 内部回环测试也就是把发送端数据在核内部直接环回到接收端不走光模块。这个测试能快速判断 MAC 侧逻辑是否正确。内部回环通了之后再用一根短光纤把 QSFP28 的 TX 和 RX 连起来做外部回环。这里有个经验光模块插入后要检查 module present 和 LOS 信号有些兼容性差的光模块虽然能识别但 CDR 锁定不了表现就是 link_up 迟迟不拉高。遇到这种情况换个模块试一下往往比调代码有效得多。外部回环通过后再用 ILA 抓一下 AXIS 接口的信号确认我们发给 CMAC 的帧格式是完整的包括前导码处理、FCS 是否由 CMAC 自己添加。这一关过了才说明 FPGA 这一端的“物理链路”基本没问题。4.2 iperf3 UDP 打流测试链路通了之后就可以用 iperf3 做 UDP 打流测试。注意iperf3 的 UDP 测试模式默认是单向打流接收端的统计信息最有参考价值。我用的命令大致是这样# 接收端FPGA 对端服务器 iperf3 -s -u -i 1 # 发送端向 FPGA 的 IP 打流 iperf3 -c 192.168.10.10 -u -b 80G -l 9000 -t 30 --get-server-output参数含义很简单-u 表示 UDP 模式-b 是目标带宽-l 是包大小-t 是测试时长。实际测试时我从 10Gbps 开始每档增加 10Gbps记录每个档位的丢包率直到出现明显丢包为止。这样做的目的是找到系统的饱和点。我这次实测在 80Gbps、9000 字节巨型帧的情况下丢包率基本为 0接收端统计的带宽和发送端对齐打到 90Gbps 时开始出现少量丢包说明瓶颈不在 UDP 协议栈而在后端的 DDR 写入带宽或者 DMA 描述符供给速度。基于这个结果我把丢包定位到了 DMA 描述符补充不及时而不是 CMAC 接口问题——这一步测试给了非常明确的指引。4.3 Wireshark 抓包与帧格式验证iperf3 只能验证带宽和丢包帧格式对不对还要靠抓包。我习惯在 FPGA 对端服务器的网卡上用 Wireshark 抓包过滤条件设成ip.addr 192.168.10.10 udp主要看三点以太网类型字段是不是 0x0800、IP 头里的协议字段是不是 17UDP、UDP 校验和有没有报错。Wireshark 对 checksum 错误会有明显标记如果 FPGA 发出的包校验和不对抓包立刻就能发现。另外为了验证用户数据内容没有被协议栈改坏我让 iperf3 发送固定 pattern 的数据然后在 FPGA 内部加了一个统计模块对收到的 payload 做长度和内容校验。抓包确认的是“线上包格式”内部统计确认的是“FPGA 应用层收到的数据完整性”两边都对上才算真正跑通。这里有个细节如果你在 FPGA 和测试服务器之间还隔了交换机Wireshark 抓到的是经过交换机转发的帧VLAN 标签可能会变化UDP 校验和的伪头部计算也会因此受影响。所以联调初期最好把 FPGA 直连服务器网卡减少中间环节。4.4 性能数据解读与瓶颈定位拿到 iperf3 的输出后怎么解读数据也很关键。我们看一组典型输出[ ID] Interval Transfer Bitrate Jitter Lost/Total Datagrams [ 5] 0.00-30.00 sec 300 GBytes 85.9 Gbits/sec 0.005 ms 0/33333333 (0%)这个结果说明30 秒内发送了约 3.33 千万个包全部到达零丢包。配合前面的理论计算9000 字节帧在 100G 线速下理论包速率约为 1.385Mpps实际测试带宽 85.9Gbps 换算过来大约是 1.19Mpps说明系统还有余量。当出现丢包时我会按以下顺序定位先看 CMAC 的接收状态寄存器排除物理层问题再看协议栈输出的统计计数器确认是解析侧丢的还是后端反压丢的最后看 DMA 完成队列的更新频率判断描述符是否充足。用这种分层排查法基本上几分钟就能把问题缩小到一个模块不用满代码找。5. 常见问题与排查技巧实录5.1 链路起不来的几个原因这是我被问得最多的问题也是我调试时踩得最深的坑。链路不起现象通常有两种CMAC 的 link_up 一直为 0或者 link_up 是 1 但对端网卡始终 link down。前者多半是 FPGA 内部问题后者多半是光路或对端配置问题。我整理了一个排查表现象可能原因排查手段link_up 为 0GT 参考时钟未锁定用 ILA 查 gtpowergood、refclk 频率link_up 为 0CMAC 复位序列执行不对按产品指南检查复位状态机时序link_up 为 0光模块不兼容或未插好看 module present、LOS换模块试对端 link down光模块 TX/RX 接反检查光纤收发是否接对对端 link down速率协商不一致确认 CMAC 配置是 100G网卡开 100G这里建议大家在代码里多放几个状态寄存器把 gtpowergood、cdr_lock、tx/rx_pma_ready、link_up 这些关键信号全部暴露出来通过 JTAG 或者片上逻辑分析仪读取能省掉大量猜时间。5.2 丢包和带宽上不去怎么定位带宽上不去我的经验是先做减法关闭 UDP 协议栈让 CMAC 直接收发自造的 flood 帧看纯 MAC 层能不能跑到线速。如果纯 MAC 都不行问题在时钟和接口适配如果纯 MAC 能跑再把 UDP 协议栈一级一级加回去每层都统计计数丢包在哪个模块出现突破口就在哪。我遇到过一种比较隐蔽的情况发送方向 CMAC 的 FIFO 深度不够导致在背靠背突发大流量时FIFO 写满后反压了上游逻辑而设计里没有统计反压次数所以表面上看起来没丢包但实际吞吐被限制了。后来我在 FIFO 写侧加了计数器才发现反压占比高达 20%。这种问题不加统计光靠肉眼看波形很难定位。5.3 ARP、ICMP 和校验和的坑联调时最容易出的应用层问题排名第一的是 ARP 不通。原因往往很简单FPGA 的 MAC 地址没有配置或者代码里 MAC 地址写死成了一个和测试环境冲突的地址。解决方法是把 MAC 地址做成寄存器可配置并通过软件初始化时写入。第二个常见问题是 ICMP 的 ping 不通但 UDP 能通。这是因为有些精简版协议栈只实现了 UDP没有实现 ICMP echo 响应。如果你需要被 ping 通必须在代码里加一个 ICMP 处理模块。如果只是测试 UDP 数据通路这一步可以跳过。第三个是校验和问题。这个前面提过UDP 校验和伪头部拼错是经典坑。我遇到过一次板卡回放的 UDP 包在 Wireshark 里看长度、端口都正确但 checksum 一直标红。查到最后发现是小端序处理问题UDP 长度字段在 512bit 总线上跨字节时被高低位 swap 了。这个排查过程非常痛苦因为肉眼完全看不出来。5.4 调试工具与手法的组合最后分享一点调试心得我觉得对 100G 这类高速接口尤其重要善用计数器别只看波形。100G 的数据速率单靠 ILA 采样深度根本不够而且波形分析效率很低。我后来给每个关键模块都加了统计寄存器包括收帧数、发帧数、CRC 错误数、校验和错误数、FIFO 反压次数、DMA 完成描述符数通过 AXI-Lite 或 JTAG 读出来问题定位就变成了“读数字”而不是“看波形”。另一个很实用的技巧是先跑小包再跑大包。小包对协议栈和 DMA 的压力最大因为包速率极高能把描述符和 FIFO 的瓶颈暴露得淋漓尽致。如果 64 字节小包都能做到接近线速不丢包那大包一般就没问题。我这次用 64 字节小包打流时果然发现了 DMA 描述符更新不及时的问题改成深描述符队列并优化 CPU 轮询周期后才解决。最后再分享一个小习惯每次上板前我都会把 CMAC 状态、DDR 校准状态、GT 锁定状态、协议栈各计数器打一份快照作为一个“基线”。后面每次改动代码先跑一遍基线测试确认没有引入回归再转向新的验证点。这个习惯帮我避免了很多“改了 A 坏了 B”的尴尬情况在高带宽网络开发里尤其值得养成。