ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA千兆UDP以太网实现:verilog-ethernet协议栈移植与调试实战

FPGA千兆UDP以太网实现:verilog-ethernet协议栈移植与调试实战 前九篇玩下来计数器、状态机、FIFO、串口、SPI、I2C这些东西应该都摸过一遍了。这次我的目标是给FPGA插上一根网线让它能像一台小主机一样跟PC互相收发UDP数据包。从“近似0基础”到真正跑通以太网中间横着一个很现实的问题UDP/IP协议栈看着简单但涉及MAC帧、IP头校验、ARP、PHY芯片配置、RGMII时序任何一个环节出错抓包软件里就是什么都不出现。我的选择是直接啃一个开源工程——Alex Forencich维护的verilog-ethernet把它的UDP协议栈部分吃透再移植到自己手上这块Artix-7板卡上。这篇文章就把整个学习过程、调试记录和踩过的坑拆开讲给同样在这个阶段的朋友一条能直接走的路。1. 为什么我盯上verilog-ethernet先看懂轮子再决定要不要造轮子1.1 以太网协议栈的复杂度恰恰是新手最该藏着的一块很多刚接触FPGA网络通信的人第一反应是“UDP不就是把一个包头加上去嘛”然后一拍脑袋准备自己写。等真的动手会发现一堆麻烦事接踵而来以太网帧有前导码和帧起始定界符MAC地址要按序发送IPv4头里光校验和就要算一轮带循环进位的16bit加法UDP长度字段得知道整包数据有多长才能倒填而最坑的是你还要处理与外部PHY芯片之间的GMII/RGMII时序关系。我见过不少人自己写的“简易UDP协议栈”最终可以跟PC对上但只能在自己定制的PC端软件里用换Wireshark、换路由器、换交换机立刻暴露出问题CRC不对、帧长度有误、IP校验和被网卡丢弃。真正能把协议栈写到能上线水平的至少要啃完RFC 791、RFC 768、RFC 894这些文档再经过大量实网验证。对一个以学习为目的的FPGA玩家来说自己造这轮子的性价比非常低。1.2 verilog-ethernet和别的开源UDP方案差在哪GitHub上打着“FPGA UDP”标签的项目不少有的只贴了顶层源码有的依赖特定厂商IP核有的干脆是纯教学代码仿真能过但上板就是不行。verilog-ethernet不一样它是Alex Forencich长期维护的开源IP库和verilog-axi、verilog-pcie、verilog-aximm等系列是同一套风格。协议栈里每个模块都是独立的Verilog源文件不依赖Xilinx或Altera的加密IP这让它可以方便地在不同厂商的FPGA之间移植。更关键的是这套代码的质量非常高。它把以太网协议栈按功能拆成了清晰的分层结构最底层有各种串行接口的PHY适配模块中间是MAC层上面是IP层IP层上面是UDP和ARP等模块。每一层之间的接口都是标准AXI-Stream握手信号这也意味着用户在工程里到处都能看到tvalid、tready、tlast、tkeep这一套东西。对新手来说这套代码本身就是学习AXI-Stream协议的最佳教材。1.3 什么情况下你可以绕过这个库说句实在话也不是非要用这个库不可。如果你的目标是”只跟PC上自己写的一个上位机通信“并且双方都是你控制的那用最简单的伪随机组帧完全可以甚至物理层直接用UART加串口转网口模块都行。但如果你想让FPGA接交换机、想被别人写好的网络调试工具识别、想为以后玩高速接口打基础那老老实实吃透一个成熟协议栈比什么都值。我的策略是三步走先整个工程跑仿真再看核心源码理解每层在干什么最后改到自己的板卡上做实测。这个顺序千万不要反过来不然上板调试时你会分不清是代码问题、时序问题还是PHY配置问题。2. 仓库目录和模块家族verilog-ethernet给你准备了什么2.1 从PHY到MAC的接口层模块把仓库clone下来后第一件事就是看rtl目录结构。你会发现顶层rtl目录按功能分成几个子目录最底层的phy层提供了一系列和外部PHY芯片对接的接口模块比如GMII、RGMII、SGMII这些。对大部分市面上常见的FPGA开发板来说关注RGMII就够了因为绝大多数千兆PHY芯片RTL8211、KSZ9031、AR8035等对外接口都是RGMII。RGMII接口的特别之处在于DDR采样。千兆模式下发送时钟是125MHz数据线却只有4根TXD[3:0]在时钟上升沿送低4位下降沿送高4位两个沿拼起来组成8bit数据。这不是什么黑魔法就是接口标准规定的做法。在7系列Xilinx FPGA上实现时会用到ODDR原语把并行8bit数据转换成串行DDR输出。MAC层往上verilog-ethernet提供了三速以太网MAC模块eth_mac_1g它负责组以太网帧、插入前导码并计算CRC32。2.2 IP与UDP处理模块的分工再往上是ip目录和udp目录。这里的ip_64、udp_64都是带64bit数据位宽的精简实现而名字里带10g的则用于高速网卡。对于普通千兆应用ip_64和udp_lite系列就足够。IP层做的事情比我原来想的要琐碎得多要维护源IP和目的IP地址要处理分片标志要计算首部校验和还要根据目的IP判断这个包是不是给自己的不是就直接丢弃。UDP层相对简单一些只在IP数据报里再嵌套一个UDP头同时负责地址端口的匹配和可选校验和。整个设计思路是模块化的如果想让多个用户通道共享同一个网络接口IP层还有对应的仲裁模块不过刚开始接触时用不到这么复杂。2.3 用户侧接口AXI-Stream与AXI4-Liteverilog-ethernet给用户开放的接口不是简单的一根数据线加一个时钟而是标准AXI-Stream。为什么用AXI-Stream因为以太网包天然适合流式传输一个帧可以被看成一段连续的字节流帧结束用tlast标记帧中的数据是否有效用tvalid/tready这对握手信号控制。这种接口还有一个好处就是可以和verilog-axi库里的各种FIFO、DMA模块直接对接后续想玩PCIe网卡、DMA搬运都能顺滑接上。初次看这些源码时建议先从udp_lite收发模块的端口信号表下手把每个信号的宽名和功能看清楚。你会看到类似axis_udp_lite_tx_tdata、axis_udp_lite_tx_tvalid、axis_udp_lite_tx_tready这样的端口。对熟悉AXI-Stream的人来说一眼就懂不熟悉也没关系很多网络IP的官方文档都有接口信号表格对着看几遍就熟了。不要一上来就钻进状态机细节里先建立“用户侧是流接口、网络侧是BIT流”的整体图景后面才不会被细节带偏。3. 一个UDP包的生命周期从用户数据到网线上的完整链路3.1 发送方向按字节流切包、组帧、算校验我一直觉得理解协议栈最好的方式不是读代码而是追踪一个包从产生到发出的全过程。假设用户逻辑想通过UDP发一条“hello fpga”给PC数据只是简单的字节流形式送入AXI-Stream接口。第一个干活的模块是axis_udp_lite_tx它负责把用户数据拆成合适大小的段并在前面加上UDP头。此时UDP头里的源端口、目的端口都是用户配置好的但长度字段和校验和字段在这个阶段可能还来不及填。接着数据交给IP层ip_64模块会加20字节的IPv4基本头包括源IP、目的IP、协议号UDP是17、总长度、标识等。它会在缓冲整包数据的同时计算IP首部校验和。这里有个细节值得留意IP校验和不是简单的求和取反而是要把所有16bit字参与带进位回卷end-around carry的加法最后按位取反。硬件上实现时不可能等一个完整的包收完再算所以verilog-ethernet里的做法是流水线式边收边算很巧妙。IP层之后是MAC层。eth_mac_1g模块负责把IP层送来的整个网络层数据报再包上14字节以太网头也就是目的MAC、源MAC、类型字段。同时MAC层还会做一件容易被忽略的事如果整个帧的长度不足64字节它会在数据末尾自动补0这叫padding。为什么补齐64字节这源自半双工以太网的冲突检测机制虽然现在基本全双工但这个最小帧长限制仍然保留在协议里。3.2 接收方向解析、校验、分发接收方向流程完全反过来。从PHY进来的数据首先进入MACMAC需要识别前导码、检查CRC32、确认地址是否匹配。然后是IP层做校验和验证再根据IP头里的协议字段把数据交给UDP层。UDP层检查端口号如果目的端口跟本地配置一致就把payload部分完整地交给用户逻辑。每个模块都有各自的丢弃机制任何一层校验失败都会直接把包扔掉不会把错误包向上传递。这套流水线下一个UDP包在FPGA内部各层级之间流动时数据始终是以64bit位宽在走内部主时钟通常也是125MHz或用户自定义的时钟。这也是为什么很多例程里仿真波形长得很“整齐”——因为不管网络侧是8bit还是4bit接口内部统一转成64bit后再做处理方便后端逻辑和对接DMA。3.3 ARP和ICMP为什么ping通了才能收发UDP这里必须单独说ARP因为很多初学者卡在“FPGA发不出UDP包”的问题上最后发现根因是ARP没通。以太网帧里填的目的MAC地址不是我们手动指定的IP地址它需要根据目的IP去查询。verilog-ethernet里有一个arp_cache模块专门维护一张IP到MAC的映射表。在发送某个IP的UDP包之前如果表里没有对应表项FPGA会先发一个ARP请求等待PC回复ARP响应然后把目标MAC缓存起来再发UDP。我在实际测试中发现PC端第一次ping FPGA时ARP缓存通常还没建立前几个包会有几十毫秒的延迟甚至丢包等缓存建立后就会很稳定。这也是为什么很多UDP调试例程都建议在PC端先ping一下FPGA不是玄学而是为了建立ARP表。verilog-ethernet里还带了一个响应ICMP回显请求的模块这样用ping命令就能直接验证IP层和ARP层是否工作正常省去很多抓包推敲的时间。4. 在手中板卡上从0到1跑通环境准备与实测步骤4.1 确认PHY型号、RGMII引脚和时钟方案无论你用的是哪块开发板第一步永远是看原理图。确认板载PHY芯片的具体型号找到它的RGMII信号对FPGA的引脚分配同时看清PHY的复位引脚和MDIO引脚。很多PHY芯片在上电后默认工作模式不一定支持千兆可能需要通过MDIO接口配置寄存器才能切到1000M全双工。所以在跑协议栈之前最好先写一个极简的MDIO读写模块把PHY芯片ID读出来验证通信正常。时钟方案也要提前定好。我的板子上PHY给FPGA提供125MHz的RX时钟作为接收方向时钟发送方向需要FPGA给PHY提供125MHz的TX时钟。很多情况下需要用一个MMCM/PLL把125MHz倍频或调整相位驱动整个协议栈的内部逻辑。另外RGMII接口对时钟和数据之间的相位关系有要求发送方向TXC要对准数据中心接收方向RXC与数据的关系由PHY保证但FPGA内部需要用IDDR原语配合约束。4.2 Vivado工程搭建与关键约束我把verilog-ethernet里的rtl所有源文件加到Vivado工程后又自己写了一个顶层模块用于例化MAC、IP、UDP和用户测试逻辑。顶层其实不复杂大致就是例化eth_mac_1g把GMII信号转成RGMII后连到FPGA引脚例化eth_axis_rx和eth_axis_tx完成MAC侧和网络侧的数据位宽转换例化ip_64、udp_64把UDP用户接口接到两个小FIFO上用户逻辑做一个简单的loopback收到UDP数据后再原样发回。RGMII引脚约束之外还需要时序约束。我在第一次跑综合时完全没加相关约束结果时序报告惨不忍睹上板自然不通。参考官方Xilinx例程后我给RGMII的输入输出加了set_input_delay/set_output_delay约束并在数据路径上使用了ODDR和IDDR原语还把PHY的复位信号用软件拉高。这一步是整条链路上最枯燥但也最关键的部分建议直接把板卡厂商提供的参考约束原样放进去再根据实际报告微调。4.3 ILA抓包与PC端Python对测当工程能综合、时序收敛后烧录进去还不能高兴太早。先用ILA抓几个内部关键信号比如UDP接收模块的tvalid和tlast以及MAC层的接收完成标志。如果ILA里能看到正确的tlast脉冲说明FPGA至少已经成功解析了一个包。PC端我用Python写了一个小脚本做测试import socket sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((192.168.1.100, 12345)) sock.settimeout(3) # 先发一条UDP给FPGA sock.sendto(bhello fpga, (192.168.1.10, 8080)) try: data, addr sock.recvfrom(1024) print(recv:, data) except socket.timeout: print(timeout, no response)FPGA这边用户逻辑做的是收到什么回什么。第一次运行就超时了后来发现是PC防火墙拦截了UDP入站。关掉防火墙后立刻通了。这个排查过程看着有点啼笑皆非但真的很常见。5. 移植与二次开发如何把“别人的协议栈”改成“自己的协议栈”5.1 裁剪模块和参数关掉用不上的校验看懂协议栈后你会忍不住想“瘦身”。verilog-ethernet的设计初衷是尽量通用所以很多功能都做成可参数化。比如UDP校验和就是可选的如果你的应用跑在可信局域网内又不想让它占用过多逻辑可以直接关掉UDP层校验把负载空间省给业务逻辑。IP首部校验和在标准实现里不能随便关因为很多交换机、路由器会检查它不过如果只是点到点直连也可以手动屏蔽但我不建议这么做容易给自己挖坑。裁剪原则是先保留完整功能跑通再逐步砍掉确认没用的分支。我见过有人一上来就删掉ARP缓存觉得没必要结果每次发UDP都要先人为填MAC反而更麻烦。协议栈里很多模块之间是依赖关系删之前务必先看接口连接有没有断开。5.2 多端口与用户逻辑接入协议栈真正的开放接口其实在UDP层之上这也是它最大的价值。如果你的项目需要同时处理多个UDP端口可以例化多套UDP处理模块再加上简单的地址译码把不同端口的数据分发到不同处理单元。verilog-ethernet里IP层其实提供了多个用户通道的仲裁接口可以从顶层引出来做端口复用。我在一个图像采集小项目里就是这么用的一个端口接收PC下发的配置寄存器一个端口把图像数据块回传另一个端口对外输出状态。三个UDP连接共用同一个MAC和PHY互不干扰。整个接入过程基本上就是写AXI-Stream时序相比自己从头实现真是省了太多时间。5.3 资源与性能评估在XC7A35T上的实测很多人关心这套协议栈到底占多少资源。我在XC7A35T-2Artix-7上做了一次全功能编译包含千兆MAC、RGMII适配、IP、ARP、UDP、ICMP响应和一个简单loopback逻辑资源利用率大概在LUT 1800左右、FF 1500左右、BRAM 2-4块不同参数会有差异。这个数字不会太精确不同版本和参数差异很大但它证明了一件事即使是入门级的Artix-7芯片也能轻松塞下一套完整千兆UDP协议栈剩下大把资源给用户逻辑。性能方面千兆理论速率约125MB/s实际UDP吞吐会被帧间隙、包长、校验计算造成一些开销。如果一帧最多带1400字节payloadFIFO深度又够实测跑满八九成带宽并不难。协议栈本身不是瓶颈真正限制速度的往往是用户侧FIFO读写速度和打包策略。6. 踩坑实录跑通链路之后我撞上的那些墙6.1 RGMII时序约束不够千兆跑不通百兆却正常这是我在整个项目里卡得最久的一个问题。现象非常奇怪把PHY强制成百兆模式UDP通信一切正常切到千兆就完全不通。一开始怀疑是PHY寄存器配置问题反复折腾MDIO无果。后来在Vivado时序报告里看到RGMII输出路径存在严重violation才意识到问题出在约束。原因是百兆模式下RGMII时钟是25MHz时序裕量非常充裕千兆模式下时钟变成125MHz数据窗口窄了很多。如果TXC的相位和TXD不对齐PHY在时钟沿采到的数据就是错的。后来参考官方参考设计把ODDR和IDELAY的位置检查了一遍并调整了时钟偏置约束千兆模式才稳定下来。这里想提醒一句RGMII这种DDR接口的时序问题有很强隐蔽性数据速率高一点就出问题低一点就正常一旦遇到这种情况先用时序报告说话。6.2 PHY未配置MDIO链路一直建不起来另一个低级的坑是PHY的复位和配置。大部分PHY芯片上电后默认使能自动协商但不保证能协商成千兆全双工。如果MDIO接口没连接或者没初始化PHY可能停留在半速或半双工模式MAC发千兆帧PHY根本不往线上发。我当时在Vivado里加了ILA去抓MDIO的波形发现SDA线始终是0xFF才意识到PHY的复位信号根本没被拉起来。排查链路建议按这个顺序来先看PHY芯片的供电、时钟、复位再通过MDIO读PHY的链接状态寄存器然后用网线连接后看协商结果最后才怀疑MAC和FPGA逻辑。不要一上来就趴在HDL代码里找bug硬件链路上的问题必须先排除。6.3 仿真中遇到死锁和backpressure问题跑仿真时遇到的坑集中在AXI-Stream握手和FIFO深度上。协议栈为了提高吞吐内部用了不少流水线如果上游模块在下一拍就要求tready有效而下游模块要等收到完整一包才释放缓冲就会在特定时序下出现互相等对方的死锁。我遇到的一次死锁是在UDP发送路径上用户逻辑连续送入多个包源端FIFO几乎满而下游UDP校验模块正在等待当前包的全部数据算完导致tready一直拉低而上游由于没收到tready也一直保持tvalid。这种互相等待在波形上看特别像正常状态如果不对照源码仔细分析很容易误以为是数据还没到。解决办法很简单在用户逻辑和协议栈之间加一个适当深度的异步FIFO缓冲一下然后再通信就一帆风顺了。6.4 最小帧长与CRC的隐藏规则还有两个细节是仿真里容易漏掉、但上板就会“露馅”的最小帧长和CRC校验。当UDP payload不足18字节时整个IP数据报加UDP头加MAC头后不足64字节MAC模块会做padding自动补0。接收端析出数据时必须根据UDP长度字段截取有效数据不能把padding误当用户payload。我用Wireshark抓UDP短包时经常看到trailer一开始以为是协议栈算错了后来才明白那是padding。CRC的问题则更像雷区。以太网CRC是覆盖整个MAC帧的如果在接收方向只想自己解析数据而不关心MAC校验可以忽略CRC错误但在发送方向必须把CRC算对否则对端网卡直接丢包。verilog-ethernet的MAC模块在发送时已经自动处理好了CRC所以一般不需要用户操心但一旦你模拟MAC层数据或做自定义测试就必须自己生成CRC否则调试半天全白费。7. 下一步从UDP到自己的产品级网络通路7.1 该往TCP还是DMA方向走把UDP协议栈跑通到能稳定收发只是第一步。如果你是为了做数据采集或图像传输UDP已经够用下一步该研究的是DMA和PCIe让数据能从FPGA直接进PC内存而不是靠CPU走网络栈。verilog-axi库里已经有现成的DMA核可以直接和这套网络协议栈对接。如果你未来要跑的是远程控制、文件传输这类要求可靠协议的应用那早晚得碰TCP。不过TCP比UDP高一个量级状态机、滑动窗口、重传算法都得自己搞或移植别人现成的核建议先把UDP的整个框架和AXI接口吃透再考虑TCP否则学习曲线会一下子陡到想放弃。7.2 把协议栈嵌入实际项目时的工程化建议最后聊一点工程上的体会。把开源协议栈用进自己的项目不等于把源码拖进工程里就算完事。我会在顶层额外包一层“协议栈适配层”把和具体PHY芯片相关的引脚、复位时序、MDIO配置单独封装成一个模块这样换板子时只改这一层。业务逻辑和协议栈之间的AXI-Stream接口也统一加寄存器缓存方便以后接CPU软核或MCU。另外仿真环境一定要提前搭好。verilog-ethernet仓库里自带testbench但更多时候需要自己写针对业务逻辑的tb把UDP包的收发过程模拟出来。我现在的习惯是每加一段用户逻辑就先跑仿真确认AXI时序没问题再上板这能省下大量用ILA折腾的时间。学习这套代码最好的方式也确实是改它、测试它、让它为你的场景服务而不是背下来。这套链路真正跑通那天我看Wireshark里FPGA回显的UDP包心里还是挺感慨的。从一根网线连到逻辑分析仪的第一帧数据中间那些时序约束、MDIO寄存器、仿真死锁都成了后来移植PCIe接口、调双口UDP时随手就能解决的“旧相识”。如果你也卡在网络通信这一关按这个顺序走稳的。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进