FPGA实现UDP回环:verilog-ethernet协议栈实战详解
2026/9/18 19:42:06 网站建设 项目流程

把一块FPGA开发板用网线连到电脑,然后在电脑上发一个UDP包,让板子收到后再原样弹回来——这个在很多人看来是“搞网络的人才会碰”的事,其实用FPGA做也不难,关键是要找对工程、理清协议栈的层次。这期笔记就是我以一个接近零基础的视角,把verilog-ethernet这个开源UDP协议栈工程啃下来的完整过程。适合刚学完Verilog语法、想把FPGA和以太网串起来的新手,也适合那些看开源代码总是“看懂了但不会用”的朋友。

verilog-ethernet是Alex Forencich维护的一个开源项目,里面包含了从10G到1G的MAC、ARP、IP、UDP等一堆以太网相关模块。我最开始打开仓库的时候其实有点慌,文件一大堆,名字又长,根本不知道从哪看起。但真正花时间捋下来之后会发现,它的分层非常清楚:PHY在最底下,往上是以太网MAC层,再往上是ARP和UDP这类网络层协议模块,最上面才是用户逻辑。只要理解了每一层的职责,整个工程就像搭积木一样,选自己需要的模块接上去就行。

我这次的目标很明确:用一块常见的千兆以太网开发板,把verilog-ethernet里的UDP模块跑起来,实现和PC之间的双向收发。下面就从环境选型、架构拆解、仿真验证、上板调试、常见坑这五个方向,把我实际操作中记录下来的东西完整分享出来。

1. 为什么选verilog-ethernet,而不是自己写协议栈

1.1 三种方案的现实对比

在动手之前,其实有三个选择摆在面前:第一是自己从零写MAC和UDP,第二是直接用厂商IP核,第三就是基于verilog-ethernet这种开源工程来改。我最初还真试着写过一小段UDP发送逻辑,结果发现一个很残酷的现实:自己写MAC的CRC校验还好说,但要做到千兆速率下不丢帧、时序收敛、跨时钟域处理好,真不是新手两周能搞定的。

我也对比过Xilinx和Altera官方的以太网IP核。厂商IP核好处是专业、稳定,但坏处也很明显:接口通常带着一堆AXI4-Lite配置寄存器,状态机复杂得多,而且要想在IP核外面接UDP逻辑,还得自己搞定AXI-Stream的时序。对只想要一个“能收发UDP就行”的入门需求来说,官方的全功能IP核反而显得笨重。

verilog-ethernet这个开源工程则精准地卡在中间:它的MAC层是完整的,支持GMII、RGMII等主流接口,数据通路统一用AXI-Stream对外,这样上层协议模块(ARP、IP、UDP)就能像流水线一样串起来。我只需要关心自己这部分用户逻辑,不用去碰PHY芯片的寄存器配置细节。

1.2 工程结构里藏着设计思路

把仓库克隆下来之后,第一件事是看rtl目录下的文件列表。里面分了几个关键模块:eth_mac_1g是千兆MAC,eth_mac_1g_fifo是带FIFO缓冲的MAC版本,eth_udp、eth_arp、eth_ip是网络层模块,还有eth_axis_rx、eth_axis_tx负责AXI-Stream接口格式转换。

这个文件划分本身就说明了设计套路:每个模块只干一件事,模块之间用标准接口互相连接。比如eth_mac_1g只管把MAC帧转换成AXI-Stream流,或者反过来把流打包成MAC帧;eth_udp只关心UDP头,收到数据就剥离头交给上层,要发送就加上头往下层传。这种解耦方式让我这种新手读代码时非常舒服,想查哪个问题就直奔对应文件,不用在一大坨代码里找逻辑。

我最开始以为要搞懂所有模块才能跑通,后来才发现,最简单的UDP回环工程只需要四个核心组件:eth_mac_1g_fifo、eth_axis_rx、eth_axis_tx、eth_udp。如果还要让PC能发现FPGA的MAC地址,还要加一个eth_arp。模块之间用标准的8位AXI-Stream接口连起来,数据流方向清清楚楚。

2. 吃透架构:从RGMII引脚到UDP报文要穿过哪些层

2.1 物理层接口的选型与区别

verilog-ethernet里最常用的MAC接口有两种——GMII和RGMII。GMII是8位数据接口,在125MHz时钟下满跑就能达到1Gbps,但是占用的引脚太多了(大概24根左右)。RGMII则把数据线压缩到4位,利用时钟的双沿采样,这样可以用更少的引脚跑同样的带宽,是绝大多数千兆PHY芯片和FPGA开发板的默认选择。

我用的开发板上PHY芯片是常见的RTL8211系列,和FPGA之间走的就是RGMII接口。引脚分配的时候要特别留意:RGMII的TX_CLK、TX_CTL、TXD[3:0]是FPGA输出到PHY的,RX_CLK、RX_CTL、RXD[3:0]是PHY输出给FPGA的。有个容易搞混的地方是,RGMII的时钟沿是错开的,接收数据在时钟的双沿都有效,所以FPGA内部必须用IDDR原语或者库里的专用模块来捕获,不能简单地在时钟上升沿打一拍。

对新手来说,这些引脚只要在约束文件里配对了,基本不需要自己写底层时序逻辑,因为eth_mac_1g内部已经把这些用原语封装好了。但你要理解整个过程的大致时序:PHY发来的RXD和RX_CTL在RX_CLK的双沿到达,MAC模块先在内部合成出“时钟使能+8位数据”的格式,再经过FIFO缓冲到用户时钟域。

2.2 数据是怎么从AXI-Stream流变成UDP报文的

AXI-Stream把数据打包成一个个“帧”,核心信号有tvalid、tready、tdata、tlast、tkeep。tvalid和tready握手表示数据有效,tlast标志这一帧的最后一个周期,tkeep则告诉接收方当前周期里哪些字节是有效的。这个概念有点像快递打包:tdata是车厢,tkeep告诉你哪些位置装了货,tlast是这辆车的尾灯。

eth_axis_tx模块负责把用户给的AXI-Stream流转换成MAC层能处理的格式;反过来,eth_axis_rx负责把MAC层收到的帧还原成AXI-Stream流。这两个模块本质上是在做接口适配,让用户逻辑不用关心MAC帧的前导码和FCS字段。

而eth_udp模块则是在MAC帧和UDP报文之间做翻译。发送方向:用户把UDP载荷和目的IP、目的端口交给eth_udp,它自动在前面加上8字节UDP头、20字节IP头,再交给MAC层加以太网头;接收方向:收到一帧数据后,先由内部逻辑检查IP头、UDP头,校验源IP、目的IP、源端口、目的端口是否匹配,匹配上了才把载荷部分通过AXI-Stream输出给用户。

2.3 ARP模块:让电脑能找到FPGA

这里有个非常关键的细节:PC上发送UDP包之前,操作系统会先想办法知道目标的MAC地址。如果目标IP不在本机ARP缓存表里,系统会先发一个ARP广播请求,询问“谁的IP是192.168.1.10,请告诉我你的MAC地址”。

verilog-ethernet里的eth_arp模块就是干这件事的。它会自动识别发给本机的ARP请求,然后回一个ARP应答包,告诉对方“本机的MAC是xx:xx:xx:xx:xx:xx”。同时,它也会缓存学习到的对端MAC地址,这样当上层UDP模块要发数据给某个IP时,可以先去查ARP缓存,如果缓存里没有,还可以主动发ARP请求去询问。

这个模块我一开始没接上,结果实测的时候电脑发了一堆ARP请求,开发板毫无反应。后来把eth_arp加进工程,PC端的网络连接立刻就不一样了。所以如果你想用PC直接和FPGA通信,ETH_ARP几乎是不能省的一环。

3. 先把仿真跑通:用Testbench验证UDP回环逻辑

3.1 选对仿真工具,别一上来就上板

我在学习过程中踩过最大的一个坑,就是太心急想上板,结果调试效率极低。后来老老实实回到仿真,把整个UDP收发流程在电脑上模拟一遍,一切就顺畅多了。仿真工具方面,用Vivado自带xsim或者ModelSim都可以,verilog-ethernet仓库里本身就带了不少testbench,直接打开跑就行。

我这里的做法是单独建一个顶层tb文件,例化一个极简的UDP回环用户逻辑:收到eth_udp输出的AXI-Stream数据后,原封不动地再通过eth_udp的发送通道发回去。Testbench中模拟一个PHY模型,用来代替真实的PHY芯片产生时钟和数据。

3.2 一个最简的用户回环逻辑

用户逻辑其实很简单。核心就是下面这样一个双向通道的搬运:

module udp_loopback ( input wire clk, input wire rst, // 接收通道 input wire [7:0] rx_axis_tdata, input wire rx_axis_tvalid, output reg rx_axis_tready, input wire rx_axis_tlast, // 发送通道 output reg [7:0] tx_axis_tdata, output reg tx_axis_tvalid, input wire tx_axis_tready, output reg tx_axis_tlast ); reg [7:0] buf; reg have_data; always @(posedge clk) begin if (rst) begin rx_axis_tready <= 1'b0; tx_axis_tdata <= 8'd0; tx_axis_tvalid <= 1'b0; tx_axis_tlast <= 1'b0; have_data <= 1'b0; end else begin // 接收方向:能收就收,并把数据打进发送侧 if (rx_axis_tvalid && rx_axis_tready) begin buf <= rx_axis_tdata; have_data <= 1'b1; end else if (have_data && tx_axis_tready) begin tx_axis_tdata <= buf; tx_axis_tvalid <= 1'b1; tx_axis_tlast <= rx_axis_tlast; // 帧尾直接透传 have_data <= 1'b0; end else begin tx_axis_tvalid <= 1'b0; end end end endmodule

这段代码的意思是:只要接收通道上有数据且自己准备好接收,就把这个字节暂存起来,并在下一个周期由发送通道输出。严格来说这个“回环”不是无缝的,中间多了一个节拍的延迟,但作为验证完全够用。实际工程项目里通常用FIFO做缓冲,避免数据来太快时丢掉。

3.3 仿真中到底要观察哪些信号

仿真不是跑完就算,关键是要会看波形。我总结了自己重点盯的几个点:

  • eth_udp的rx_udp_valid和rx_udp_ready握手是否符合预期:rx_udp_valid拉高的同时rx_udp_ready也拉高,表示数据被有效接收。
  • tx_udp_valid和tx_udp_ready的握手过程是否完整:发送侧如果对方还没准备好就拉高tvalid,可能会卡住。
  • tlast信号:每一帧末尾必须准确出现一次,否则接收方的状态机可能认为帧不完整,直接丢弃整包数据。
  • 对着工程里的仿真脚本,把MAC帧的确定性数据(比如固定源MAC、目的MAC)解析出来,确认帧内容符合预期。

很多新手在仿真阶段遇到帧卡住不动,多半就是握手信号写错了。你光看valid拉高没用,必须确认ready也是拉高的,否则数据传输就是不成立的。

4. 上板实测:把PC和FPGA用网线连起来

4.1 硬件连线和基础配置

仿真通过之后,就可以上板实测了。准备一根网线,一头插开发板上的RJ45口,另一头直接插电脑的网口。然后在电脑上把以太网适配器的IP地址设置成静态IP,例如192.168.1.100,子网掩码255.255.255.0。FPGA工程的IP地址可以设成192.168.1.10,端口号选一个不常用的,比如8080。

在综合之前,必须检查引脚约束。不同的开发板PHY引脚完全不同,这个不能照抄别人的工程,一定要对着自己板的原理图确认。我因为引脚没配对,上板以后发现link灯都不亮,查了半天才发现有两根引脚反了。RGMII接口的引脚命名通常是ETH_RXC、ETH_RXCTL、ETH_RXD[3:0]、ETH_TXC、ETH_TXCTL、ETH_TXD[3:0]。

另外,PHY芯片的时钟通常需要外部晶振供给,但有些开发板设计成交由FPGA输出一个125MHz时钟给PHY,这个要看具体板子的设计。如果PHY没有时钟,link状态会一直起不来。

4.2 用网络调试助手验证UDP回环

上板开机后,先用Wireshark抓包看有没有ARP请求和应答。正常情况下,电脑会先发送一个广播ARP请求,询问192.168.1.10的MAC地址,开发板收到后会立刻回一个ARP应答。这个流程跑通了,说明PHY、MAC、ARP模块工作正常。

接着打开网络调试助手,协议选择UDP,本地端口随意,远程IP填192.168.1.10,远程端口填工程里配置的端口号,点击“打开”,然后发送一串测试数据,比如“hello_fpga”。如果一切正常,调试助手的接收窗口里会原样弹回这串数据。

我实测过程中遇到一次非常诡异的现象:数据偶尔能收到,但内容里多了一两个字节的乱码。后来发现是发送端数据长度处理有问题,eth_udp发送通道在用户发送帧时,必须用tlast精确标识帧结束,并且tkeep在最后一个周期要正确表示有效的字节数。UDP是数据报协议,每一帧的长度必须在发送时就确定好,不像TCP有流的概念可以随便切分。

4.3 用Wireshark验证协议细节

网络调试助手能跑通,已经说明核心链路是好的,但如果想更深一层理解协议,建议同时打开Wireshark抓包来看。抓包结果里能看到完整的以太网帧:目的MAC、源MAC、类型字段(0x0800表示IPv4)、IP头、UDP头、载荷、FCS校验。

我最初一直很好奇FPGA发出来的帧结构对不对,但看Wireshark的解析结果会直接显示“Destination unreachable”之类信息,所以其实不太需要自己去分析CRC对不对——硬件网卡已经帮你做了FCS校验。同时也要注意,Wireshark看到的报文和物理层实际跑的字节序都是一致的,如果帧解析出来IP头校验和不对,那大概率是IP头里的某个字段写错了。

5. 实操中遇到的坑和排查方法

5.1 板子link灯亮了,但PC永远ping不通FPGA

这是新手最容易困惑的问题。注意,verilog-ethernet里的eth_arp模块默认只会响应ARP请求,不支持ICMP协议,所以用ping这个工具是测不通的。ping走的是ICMP,UDP协议栈并不解析ICMP报文。就算PC能通过ARP获知FPGA的MAC地址,ping也永远无法得到回应,这并不代表工程有问题。

正确验证方式就是用UDP调试工具直接发包。如果UDP数据能收能发,那整个链路就是通的。

5.2 PC发UDP给FPGA,FPGA没反应

排查思路按顺序走:先抓包确认PC端有没有收到ARP响应。如果没有响应,说明问题大概率在PHY的配置或者引脚连接上。如果ARP响应正常,但还是收不到UDP数据,那就要看eth_udp模块里配置的IP地址和端口是否和PC设置一致。

这里容易踩坑的是端口号冲突。有些开发板例程默认用某个端口,如果你调试工具把本地端口和远程端口设成同一个,可能没问题,但一旦设错,报文就会被eth_udp模块默默丢弃,因为它没找到匹配的端口过滤规则。

5.3 数据能通,但偶尔会有乱码或多字节

这个问题的根源通常在AXI-Stream的tkeep信号上。tkeep告诉接收方当前这个时钟周期里tdata的哪些字节是有效的。如果发送侧在最后一拍没有把tkeep按实际数据长度拉高/拉低,接收方就可能把无效字节当成有效数据读进来。

还有一种情况是跨时钟域问题。MAC层的时钟和用户逻辑时钟频率不同,FIFO起到缓冲作用,但FIFO的读、写使能时序如果没调好,也可能产生半个字的错位。遇到这种问题,先检查用户侧时钟是不是和eth_udp模块使用的时钟一致,不一致就老老实实加异步FIFO,别自己用寄存器硬扛。

5.4 ARP缓存超时导致主机找不到目标机

PC操作系统的ARP缓存是有老化时间的,通常几十秒到几分钟就会消失。如果你把FPGA断电重启,电脑可能还在用旧的ARP缓存,而FPGA重启后状态已经清空,此时PC会直接发UDP包(但目的MAC是旧的),FPGA会识别不出来。

解决方式很简单:在PC上执行arp -d清空ARP缓存,或者在FPGA工程里给eth_arp模块加上主动发送ARP请求的逻辑,保证每次发送UDP之前先确认对方MAC还存在。这也是很多网络设备设计时都会做的链路探测。

5.5 跨时钟域带来的偶发丢包

verilog-ethernet在MAC层内置了FIFO,但用户逻辑和eth_udp模块之间的时钟域关系仍然要注意。很多开发板的用户逻辑时钟用的是125MHz,而PHY时钟也是125MHz,看起来同频,但两者可能来自不同时钟源,相位关系不确定。稳妥的做法是在设计里把用户逻辑的时钟也用同一个PLL产生,并让MAC层的TX clock和RX clock都统一到同一相位域,必要的时候用异步FIFO彻底隔离。

我这次因为比较赶,一开始直接沿用了开发板例程中提供的clk125和reset_n,结果出现了偶发的丢包。后来把用户逻辑也切到和MAC RX侧同一个125MHz时钟下,再经过一个小FIFO做缓冲,丢包率就明显降下来了。

6. 从代码到板的完整梳理:一个最小可用的UDP工程该长什么样

6.1 工程顶层模块怎么拼

最小工程在顶层需要例化五个部分:时钟管理模块(PLL/MMCM)、复位同步模块、eth_mac_1g_fifo、eth_axis_rx、eth_axis_tx、eth_udp和eth_arp。这里有个细节:eth_mac_1g_fifo本身已经带了发送和接收FIFO,用户可以不用再专门例化额外的跨时钟域FIFO,但前提是PHY和用户逻辑共用同一个时钟域。

下面是一个简化版顶层结构示意:

// 伪代码,仅展示模块连接关系 pll u_pll (.clkin(sys_clk), .clk125(clk125), .clk125_90(clk125_90)); eth_mac_1g_fifo u_mac ( .gtx_clk(clk125), .rx_clk(rx_clk_from_phy), .rgmii_tx(rgmii_tx), .rgmii_rx(rgmii_rx), .axis_rx_tdata(axis_rx_tdata), .axis_rx_tvalid(axis_rx_tvalid), .axis_rx_tlast(axis_rx_tlast), .axis_rx_tready(axis_rx_tready), .axis_tx_tdata(axis_tx_tdata), .axis_tx_tvalid(axis_tx_tvalid), .axis_tx_tlast(axis_tx_tlast), .axis_tx_tready(axis_tx_tready) ); // eth_udp 和 eth_arp 再在此基础上对接

关于时钟:eth_mac_1g需要的gtx_clk是125MHz,如果PHY工作模式是RGMII to GMII,这个时钟在FPGA内部产生即可;而接收侧的rx_clk是从PHY的RX_CLK引脚过来的。这两个时钟在本质上也可以同源,但为了安全,建议保持原样连接,不要随便对调。

6.2 状态机设计和发送流程

除开POR(上电复位),用户侧UDP发送逻辑的核心是一个简单状态机:

  • IDLE状态:等待上层触发发送,组好UDP头,拉高请求信号;
  • SEND_HEADER状态:依次输出目的MAC、源MAC、类型字段、IP头、UDP头;
  • SEND_DATA状态:按AXI-Stream协议把用户数据逐个字节输出;
  • SEND_LAST状态:拉高tlast,表示帧结束。

看起来简单,实际上帧头部分的每个字节都是有讲究的。以太网帧头目的MAC和源MAC各6字节,类型0x0800;IP头固定20字节,其中版本是4,首部长度是5,协议号是17(UDP);UDP头固定8字节,分别是源端口、目的端口、长度、校验和。

这堆字段如果不熟,直接抄Wireshark里抓到的正常帧改IP就行。但是有个坑:IP头的校验和是16位反码求和,UDP校验和如果为0表示不校验。在调试阶段可以先把UDP校验和填成0x0000,让IP/UDP协议栈不去校验它,方便快速跑通。等链路稳定了再补上正确的校验和计算逻辑。

6.3 测试框架与时序收敛的复盘

写完工程后,综合和布局布线阶段还会遇到时序问题。千兆以太网的用户逻辑工作在125MHz,这个频率不算高,但如果用户在关键路径上写了太多组合逻辑,比如把MAC帧头拼接写成一长串连续赋值,吃Timing是很正常的。我的经验是把帧头的组包逻辑拆成多拍,用寄存器分段处理,宁可多几个周期延迟,也别让组合逻辑链太长。

布局布线后如果时序不过,优先检查RGMII接口的output delay约束和PHY芯片的IO timing参数是否设置正确。这个约束很多开发板例程里已经给了,如果全新工程需要自己写,可以对着PHY datasheet里tSU/tH参数计算。

7. 回看这次学习:一点心得体会

把verilog-ethernet从仿真跑到上板,对我这个接近零基础的人来说,最大的收获不是“UDP收发通了”这个结果,而是终于理解了数据流在FPGA里到底是怎么一层层穿过去的。以前觉得网络协议栈是软件领域的东西,离硬件很远,但实际上在FPGA里用Verilog实现UDP,反而能逼着你去理解每一帧、每一个头字段的真实含义。

如果你也打算啃这个工程,我的建议是三条:第一,不要一上来就看完整源码,先从eth_udp模块的接口图入手,搞清楚数据从哪进、从哪出;第二,仿真环境一定先搭好,上板前至少把ARP交互和UDP回环在波形里跑通;第三,遇到“板子没反应”的问题,先从物理层排查,link灯、PHY配置、时钟引脚,这些硬件层面的问题远比逻辑问题隐蔽。

这个工程后续还有很多可以扩展的地方,比如加一个简单的ICMP应答模块让板子能ping通,或者把UDP收到的数据写进FIFO再转成串口输出,这样就能把FPGA变成一个便宜的数据采集/网口透传工具。协议栈本身不神秘,拆开来看就是帧头、校验、握手几个固定动作的组合。搞明白一遍之后,以后再用厂商IP核甚至自己写简化版协议栈,心里都有底了。

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

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

立即咨询