FPGA千兆UDP以太网实现:verilog-ethernet协议栈移植与调试实战
2026/9/18 6:53:00 网站建设 项目流程

前九篇玩下来,计数器、状态机、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-Lite

verilog-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进来的数据首先进入MAC,MAC需要识别前导码、检查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(b"hello 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-2(Artix-7)上做了一次全功能编译,包含千兆MAC、RGMII适配、IP、ARP、UDP、ICMP响应和一个简单loopback逻辑,资源利用率大概在LUT 1800左右、FF 1500左右、BRAM 2-4块(不同参数会有差异)。这个数字不会太精确,不同版本和参数差异很大,但它证明了一件事:即使是入门级的Artix-7芯片,也能轻松塞下一套完整千兆UDP协议栈,剩下大把资源给用户逻辑。

性能方面,千兆理论速率约125MB/s,实际UDP吞吐会被帧间隙、包长、校验计算造成一些开销。如果一帧最多带1400字节payload,FIFO深度又够,实测跑满八九成带宽并不难。协议栈本身不是瓶颈,真正限制速度的往往是用户侧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时随手就能解决的“旧相识”。如果你也卡在网络通信这一关,按这个顺序走,稳的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询