news 2026/9/17 6:49:59

FPGA开发:基于verilog-ethernet实现UDP协议栈与回环Demo

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
FPGA开发:基于verilog-ethernet实现UDP协议栈与回环Demo

这一篇是《从近似0基础开始FPGA开发》系列的第10篇,前9篇我们玩过流水灯、按键消抖、UART收发、SPI Flash读写、HDMI输出,也硬着头皮看过一些模块化设计。到了这一步,你手里应该有Vivado或Quartus的基本操作能力,能看懂always块、wire和reg,也知道什么是时序约束和跨时钟域。

但如果你和我当初一样,心里一直憋着一句话——“我想让FPGA和电脑之间高速收发数据,不想再调UART了,想上网络,但一看UDP协议栈就犯怵”——那么这篇就是为你准备的。

先说结论:verilog-ethernet这套开源工程,是我见过的最适合“近似0基础”玩家切入UDP协议栈的代码。它由Alex Forencich维护,GitHub上直接搜verilog-ethernet就能找到。它把MAC层、IP层、UDP层、ARP层全部用纯Verilog写好了,你不需要碰任何加密IP核,也不需要读懂IEEE 802.3协议全文。你需要做的,是理解它的数据流架构,把它例化进自己的工程,然后把用户逻辑接到它预留的接口上。

这篇文章我会从工程框架、代码结构、时钟和复位、RGMII物理层接口、ARP的必要性、AXI-Stream握手时序、一个能跑通的UDP回环Demo、以及调试踩坑这几个维度,完整拆一遍。全部基于我实际在Xilinx Artix-7开发板上跑通的经历,提到的问题也都是我真实撞过的墙,不是网上抄来的。

读完这篇,你应该能自己搭出一个通过网线收发UDP数据的FPGA工程。如果你之前只写过流水灯,这篇文章也基本能跟上——我会把很多“默认你懂”的东西掰开说清楚。

1. 先看明白这个工程到底帮你干了什么活

很多人拿到FPGA网络相关的开源代码,第一反应是打开一个顶层.v文件,开始从上往下读。这个习惯在小的demo工程里没问题,但到了以太网协议栈这种规模,这么干很容易三天之后放弃。

我建议你先建立一个整体的分层认知。

1.1 以太网数据是怎么一层层套上去的

我们要通过网线把数据从FPGA发给电脑,数据不是裸着在网线上跑的。它要经过几层封装:

  • 最底层是物理层,网线上实际传输的是差分模拟信号,这个由PHY芯片处理,FPGA这边通过RGMII接口和PHY芯片打交道;
  • 往上一层是MAC层,负责组以太网帧。一个完整的以太网帧包含:目的MAC地址(6字节)、源MAC地址(6字节)、类型/长度(2字节)、数据(46到1500字节)、帧校验FCS(4字节);
  • 再往上是IP层,负责在以太网帧的“数据”区域里再封装一个IP包,里面有源IP、目的IP、协议号等;
  • 最上面才是UDP层,在IP包的负载区域里再封装UDP头(源端口、目的端口、长度、校验和),之后才是你的用户数据。

一套完整的UDP协议栈,就是要把你的用户数据,一层套一层的加上头部信息,最后变成一个以太网帧扔到PHY上发出去。反过来,接收方向就是一个解封装的过程。

verilog-ethernet这个工程最大的价值在于:它把这四层里的MAC、IP、UDP、ARP都已经写成了独立的Verilog模块,并且用统一的AXI-Stream接口把它们串联起来。你不需要自己组装头部、计算校验,只需要按照它定义的接口往里塞数据、往外取数据。

1.2 模块层级和文件依赖关系

第一次打开这个工程的rtl目录,你可能会被一堆文件名搞晕。我直接把关键文件和它们的职责列一个表:

模块文件名 职责 eth_mac_1g_fifo 1G MAC层,自带发送/接收FIFO,处理以太网帧的组帧和解帧 eth_axis_rx 接收方向的AXI-Stream解析,从MAC层数据里提取IP包 eth_axis_tx 发送方向的AXI-Stream封装,把IP包变成MAC帧格式 eth_ip IP层,处理IP头的解析和构造,支持IPv4 eth_arp ARP模块,处理地址解析协议 eth_udp UDP层,处理UDP头的解析和构造 eth_rgmii RGMII物理层接口,连接MAC和PHY芯片 eth_clock MMCM/PLL时钟管理,生成用户逻辑所需的时钟

我第二次看这个工程时,整理出的调用关系是这样的:最顶层一般是你自己的top模块,往下例化eth_mac_1g_fifo,它内部包含eth_mac_1g和收发FIFO;MAC层往上接eth_axis_rx和eth_axis_tx,这两个模块解析/封装以太网帧头;再往上接eth_ip,再往上接eth_udp;eth_arp是一个独立旁路模块,它监听接收方向的数据,发现是ARP请求就直接回复,不打扰UDP通路。

这个分层结构的好处是:你可以只例化eth_udp和eth_ip,然后通过eth_axis_tx把数据交给MAC发送。如果你不需要IP层,也可以直接用eth_axis_tx,自己组IP包(不过一般没人这么干)。

1.3 用户逻辑到底和哪一层打交道

回到我们“近似0基础”的定位:你不需要关心MAC帧怎么打包,不需要关心IP校验和怎么算,你只需要关心两层接口:

  • 发送方向:用户逻辑把UDP数据(不带任何头部)写入eth_udp_tx模块的AXI-Stream接口,前提是告诉它目的IP、目的端口、源端口;
  • 接收方向:eth_udp_rx模块从网络里解出UDP数据,通过AXI-Stream接口把payload递给你,同时告诉你这个包来自哪个IP、哪个端口。

这也是为什么这套代码特别适合学习——它把一个复杂问题拆成了清晰的接口对接问题。你只要能看懂AXI-Stream握手协议,就能把用户逻辑接到协议栈上。

2. 动手搭工程前,先把AXI-Stream握手协议搞懂

如果你之前只写过简单的状态机和FIFO,AXI-Stream可能是你遇到的第一个“看着像FIFO但又不完全是”的接口。它其实很简单,但很多人上来就被TVALID、TREADY、TLAST、TKEEP、TUSER这些信号吓住了。

2.1 四个信号,一场握手

AXI-Stream的核心是四个信号:

  • TVALID:发送方拉高,表示“我现在给的数据是有效的”;
  • TREADY:接收方拉高,表示“我现在准备好接收了”;
  • TDATA:实际的数据总线,宽度可以是8、16、32、64位等;
  • TLAST:发送方拉高,表示“这是这一帧(包)的最后一个数据”。

握手规则只有一句话:当TVALID和TREADY同时为高时,这一拍数据才算是真正被传输了。

如果发送方拉高了TVALID,但接收方还没准备好(TREADY为低),数据就停在总线上,等待接收方就绪。发送方不能在这期间改变数据。反过来,如果接收方一直拉高TREADY,但发送方没给数据,那这一拍就空转了。

这本质上是Flow Control,防止数据丢失。很多人写用户逻辑时只拉TVALID不管TREADY,或者只发数据不看TLAST,最后要么数据发不出去,要么接收方把一帧数据切成两段。

2.2 TKEEP和TUSER这两个附加信号

TKEEP是Byte Qualifier,表示当前拍TDATA里哪些字节是有效的。比如你的TDATA是32位(4字节),但最后一拍只剩下1个有效字节,那TKEEP就应该是4'b0001。

TUSER在verilog-ethernet里被用来标记帧起始。发送方向,当TUSER拉高时,表示这一拍是一个新帧的第一个数据;接收方向,TUSER拉高同样表示新帧开始。这个信号在你需要区分“这是一帧的开头”时非常有用,比如你要从连续的数据流里提取不同的包。

2.3 一帧数据的完整时序长什么样

我直接画一个典型的发送帧序列,你照着这个信号波形就能理解整个流程:

时钟周期: 1 2 3 4 5 6 7 TVALID: 1 1 1 1 1 1 0 TREADY: 0 1 1 1 1 1 1 TDATA: d0 d1 d2 d3 d4 d5 xx TUSER: 1 0 0 0 0 0 0 TLAST: 0 0 0 0 0 1 0 TKEEP: F F F F F 1 xx

周期1:发送方给出第一个数据d0并拉高TVALID和TUSER,但接收方TREADY为低,数据没有传输成功。周期2接收方就绪,d0成功传输,TUSER同时有效,接收方知道这是帧头。周期2到5连续传了d1到d4。周期6TVALID和TREADY都有效,TLAST拉高,这一拍传的是最后一个字节d5,TKEEP为1表示只有低字节有效。周期7发送方拉低TVALID,整帧传输结束。

你需要记住的就一点:一帧以内,TVALID可以因为TREADY拉低而暂停,但TUSER只能在新帧第一个数据成功传输的那一拍拉高,TLAST也只能在最后一拍拉高,不能提前也不能延后。

3. 手把手搭建最小可运行工程

政治任务说完了,接下来是实操。这一节我直接带你从一个空工程开始,一步步把verilog-ethernet跑起来。我用的环境是Vivado 2020.2,开发板是Digilent Arty A7(XC7A35T),PHY芯片是板载的RTL8211E(千兆PHY,RGMII接口)。不同板子的PHY引脚和时钟频率略有差异,但流程完全一致。

3.1 第一步:下载源码,挑出你要用的文件

把verilog-ethernet克隆到本地之后,不要直接整个工程往Vivado里塞。你只需要把以下文件从rtl目录拷进你自己的工程目录(我习惯建一个ip_src/ethernet子目录):

rtl/eth_mac_1g_fifo.v rtl/eth_mac_1g.v rtl/eth_mac_rx.v rtl/eth_mac_tx.v rtl/eth_axis_rx.v rtl/eth_axis_tx.v rtl/eth_ip.v rtl/eth_arp.v rtl/eth_udp.v rtl/eth_rgmii.v rtl/eth_clock.v rtl/axis_fifo.v rtl/eth_phy_reset.v rtl/ssio_*.v rtl/gmii_*.v

直接手动添加这些文件的笨办法反而最稳。我一开始图省事把整个rtl目录都加了进去,结果编译时多了一堆用不到的10G、25G MAC模块,虽然不影响功能,但综合时间长了不少。选文件的原则是:跟着eth_mac_1g_fifo这个模块的依赖走,它用到的才加。

3.2 第二步:创建Vivado工程并配置时钟

新建工程,器件选你的具体型号。然后在IP Catalog里搜索Clocking Wizard,生成一个MMCM/PLL,用于从板载时钟生成125MHz。

为什么要125MHz?因为千兆以太网的RGMII接口,数据线是4位DDR,时钟频率是125MHz,上升沿和下降沿各采一次,等效8位数据在125MHz下传输,正好是1000Mbps。你的MAC层和用户逻辑如果跑在125MHz,数据位宽是8位,那理论带宽就是1Gbps。如果用户逻辑跑在较低频率(比如100MHz),那么你需要用位宽转换或者FIFO来匹配速率。

我的设计里用了两个时钟:125MHz给eth_rgmii和MAC层,100MHz给用户逻辑。中间用AXI-Stream FIFO隔离。这里要用到Xilinx的axis_data_fifo IP核,宽度设成8位,深度512或1024,模式选Standard FIFO(关于Standard和FWFT的区别,后面踩坑部分再细说)。

3.3 第三步:例化顶层,接通UDP通路

这个顶层模块是整篇文章的核心,我直接给出一个可用的例化模板。这个模板做的事情是:把UDP接收到的数据原样发回去(回环),同时把收到的数据前4个字节当作目的端口覆盖源端口(用来验证端口解析)。

module udp_loopback_top ( input wire sys_clk_100m, input wire sys_rst_n, // RGMII 接口 output wire [3:0] rgmii_txd, output wire rgmii_tx_ctl, output wire rgmii_txc, input wire [3:0] rgmii_rxd, input wire rgmii_rx_ctl, input wire rgmii_rxc, // PHY 复位 output wire phy_rst_n ); localparam MAC_ADDR = 48'h00_0A_35_01_02_03; localparam IP_ADDR = 32'hC0_A8_01_0A; // 192.168.1.10 wire clk_125m; wire rst_125n; wire clk_100m; wire rst_100n; // 100MHz -> 125MHz 时钟生成 clk_wiz_0 clk_gen_inst ( .clk_out1(clk_125m), .clk_out2(clk_100m), .reset(~sys_rst_n), .locked(clk_locked) ); assign rst_125n = clk_locked; assign rst_100n = clk_locked; eth_clock eth_clock_inst ( .gtx_clk_125m(clk_125m), .ref_clk_125m(clk_125m), .clk_125m(clk_125m) ); // RGMII 物理接口 wire [7:0] mac_rx_data; wire mac_rx_valid; wire mac_rx_last; wire [7:0] mac_tx_data; wire mac_tx_valid; wire mac_tx_last; wire mac_tx_ready; eth_rgmii #( .TARGET("XILINX") ) rgmii_inst ( .eth_clk_125m(clk_125m), .eth_rst_n(rst_125n), // 接收方向 .rgmii_rxc(rgmii_rxc), .rgmii_rxd(rgmii_rxd), .rgmii_rx_ctl(rgmii_rx_ctl), .gmii_rx_d(mac_rx_data), .gmii_rx_dv(mac_rx_valid), .gmii_rx_er(), // 发送方向 .rgmii_txc(rgmii_txc), .rgmii_txd(rgmii_txd), .rgmii_tx_ctl(rgmii_tx_ctl), .gmii_tx_d(mac_tx_data), .gmii_tx_en(mac_tx_valid), .gmii_tx_er(1'b0) ); // MAC 层 wire [7:0] axis_rx_tdata; wire axis_rx_tvalid; wire axis_rx_tready; wire axis_rx_tlast; wire axis_rx_tuser; wire [7:0] axis_tx_tdata; wire axis_tx_tvalid; wire axis_tx_tready; wire axis_tx_tlast; wire axis_tx_tuser; eth_mac_1g_fifo #( .ENABLE_PADDING(1), .MIN_FRAME_LEN(64), .ENABLE_FCS(1) ) mac_inst ( .gtx_clk(clk_125m), .gtx_rst(~rst_125n), .logic_clk(clk_125m), .logic_rst(~rst_125n), // 发送方向(用户逻辑 -> MAC) .tx_axis_tdata(axis_tx_tdata), .tx_axis_tvalid(axis_tx_tvalid), .tx_axis_tready(axis_tx_tready), .tx_axis_tlast(axis_tx_tlast), .tx_axis_tuser(axis_tx_tuser), // 接收方向(MAC -> 用户逻辑) .rx_axis_tdata(axis_rx_tdata), .rx_axis_tvalid(axis_rx_tvalid), .rx_axis_tready(axis_rx_tready), .rx_axis_tlast(axis_rx_tlast), .rx_axis_tuser(axis_rx_tuser), // MAC 到 RGMII .rx_mac_aclk(clk_125m), .rx_mac_resetn(rst_125n), .tx_mac_aclk(clk_125m), .tx_mac_resetn(rst_125n), .gmii_rx_d(mac_rx_data), .gmii_rx_dv(mac_rx_valid), .gmii_rx_er(1'b0), .gmii_tx_d(mac_tx_data), .gmii_tx_en(mac_tx_valid), .gmii_tx_er(), // 统计信息,先不管 .tx_frame_larger_1k(), .rx_frame_larger_1k(), .tx_pause_frame(), .rx_pause_frame(), .tx_ifg_delay(8'd12) ); // IP + UDP 层 wire [7:0] udp_rx_tdata; wire udp_rx_tvalid; wire udp_rx_tready; wire udp_rx_tlast; wire udp_rx_tuser; wire [31:0] udp_rx_ip; wire [15:0] udp_rx_src_port; wire [15:0] udp_rx_dst_port; wire [7:0] udp_tx_tdata; wire udp_tx_tvalid; wire udp_tx_tready; wire udp_tx_tlast; wire udp_tx_tuser; eth_udp #( .MAC_ADDR(MAC_ADDR), .IP_ADDR(IP_ADDR) ) udp_inst ( .clk(clk_125m), .rst(~rst_125n), // 接收方向(MAC -> UDP解析) .rx_axis_tdata(axis_rx_tdata), .rx_axis_tvalid(axis_rx_tvalid), .rx_axis_tready(axis_rx_tready), .rx_axis_tlast(axis_rx_tlast), .rx_axis_tuser(axis_rx_tuser), .rx_axis_tkeep(8'hFF), // 接收结果 .rx_udp_tdata(udp_rx_tdata), .rx_udp_tvalid(udp_rx_tvalid), .rx_udp_tready(udp_rx_tready), .rx_udp_tlast(udp_rx_tlast), .rx_udp_tuser(udp_rx_tuser), .rx_udp_tkeep(8'hFF), .rx_udp_source_ip(udp_rx_ip), .rx_udp_source_port(udp_rx_src_port), .rx_udp_dest_port(udp_rx_dst_port), .rx_udp_error(), // 发送方向(用户逻辑 -> UDP封装) .tx_axis_tdata(udp_tx_tdata), .tx_axis_tvalid(udp_tx_tvalid), .tx_axis_tready(udp_tx_tready), .tx_axis_tlast(udp_tx_tlast), .tx_axis_tuser(udp_tx_tuser), .tx_axis_tkeep(8'hFF), .tx_udp_src_port(16'd1234), .tx_udp_dst_port(udp_rx_src_port), .tx_udp_ip(udp_rx_ip), // ARP .arp_req_count(), .arp_rsp_count() );

注意几个细节:

  • 例化eth_udp时,发送方向的目的端口和目的IP我直接从接收结果里取,这就实现了“回环到来源地址和来源端口”。如果你要发给固定地址,这里直接给常量就行。
  • MAC地址和IP地址的本地常量,要改成你自己的。MAC地址随便编一个合法的单播地址(bit0为0)就行,IP地址根据你的网段来,后面绑IP要用。
  • 顶层里PHY复位信号别忘记:assign phy_rst_n = rst_125n;

3.4 第四步:接上用户逻辑,实现一个最简单的UDP回环

有了上面的udp_rx_*和udp_tx_*接口,用户逻辑就只需要做一件事:把接收的数据原封不动搬到发送接口上。

// 接收 -> 发送直通回环 assign udp_tx_tdata = udp_rx_tdata; assign udp_tx_tvalid = udp_rx_tvalid && udp_tx_tready; assign udp_tx_tlast = udp_rx_tlast; assign udp_tx_tuser = udp_rx_tuser; assign udp_rx_tready = udp_tx_tready;

这里有个细节值得说:回环逻辑看起来是纯组合逻辑,直接把接收端和发送端对接了。但在实际工程里,我建议中间加一个简单的FIFO或者寄存器打拍,否则会有路径时序问题。因为接收数据从PHY进来是125MHz的时钟域,发送数据出去也是125MHz,看起来同一个时钟域没问题,但接收方向经过了MAC和IP的流水线,发送方向也要经过IP和MAC的流水线,两端时序路径比较长,直接组合反馈容易造成时序违例。

3.5 约束文件要点

如果你的开发板有官方例程,直接从里面把RGMII引脚约束拷过来最省事。如果没有,你需要自己添加以下几类约束:

create_clock -period 10.000 [get_ports sys_clk_100m] set_property PACKAGE_PIN E3 [get_ports sys_clk_100m] set_property IOSTANDARD LVCMOS33 [get_ports sys_clk_100m]

RGMII引脚约束按板卡原理图来,注意RGMII的时钟引脚要设成差分或LVCMOS并根据PHY要求来,比如RTL8211的RXC、TXC通常是LVCMOS或者HSTL,具体看原理图上的电平标准。

4. RGMII接口的细节——这里最容易出问题和踩坑

如果你用的是现成的Arty板卡,官方约束里已经写好了RGMII的引脚和电平,你直接用就行。但如果你想在自定义板卡上跑,或者想真正理解RGMII时序,下面这些是绕不开的。

4.1 收发时钟域其实是两个独立时钟域

很多人看RGMII,以为就是一个125MHz时钟下的4位DDR接口。其实收发方向有微妙差异:

  • 发送方向:FPGA给PHY提供GTX_CLK(125MHz),同时把TXD[3:0]和TX_CTL对齐到这个时钟的上升沿/下降沿。这个方向是源同步输出,你只要约束好输出延迟就行;
  • 接收方向:PHY给FPGA提供RX_CLK,同时把RXD[3:0]和RX_CTL送到FPGA。这个RX_CLK不是FPGA内部的125MHz,而是从收到的数据里恢复出来的。在千兆模式下,它同样是125MHz,但由于是PHY产生的,和FPGA内部的系统时钟没有任何相位关系。

所以,MAC层的接收逻辑必须完全使用RX_CLK作为时钟,发送逻辑完全使用内部125MHz作为时钟。在verilog-ethernet的eth_rgmii模块里,接收侧用的是rgmii_rxc,发送侧用的是eth_clk_125m,两者是异步的。这也是为什么eth_mac_1g_fifo内部一定要有异步FIFO来隔离收发时钟域。

4.2 IDELAY和IODDR原语的作用

eth_rgmii模块内部例化了Xilinx的IDDR、ODDR、IDELAYE2原语。IDDR把4位DDR数据在上升沿和下降沿各采一次,合成8位;ODDR反过来把8位数据变成4位DDR输出;IDELAYE2则用来调整接收数据的相位。

实测中发现一个很值得注意的问题:有些PHY芯片(尤其是RTL8211)输出的RXD相对于RXC有一定的延迟,而Vivado对IDELAYE2的默认延迟值是0,可能导致采样窗口不对。解决方法是逐步增大IDELAY的延迟值,我这里最终设成了约1.5ns到2ns之间才稳定。具体调试方法后面说。

4.3 PHY复位时序

PHY芯片带一个复位引脚,但很多PHY要求复位信号保持低电平至少10ms才能稳定工作。如果复位时间不够,链路协商可能失败,MAC里会使能信号一直为0,但你没报错,只是收发没反应。

我在第一次上板时,直接让phy_rst_n跟着系统复位走,结果PC网卡死活显示“网络电缆被拔出”。排查了半天,才发现是PHY没有完成复位初始化。后来我在复位逻辑里加了一个计数器,保证复位低电平保持至少20ms,问题就消失了。

这里给一个简单的复位延时生成逻辑思路:系统上电后,计数器从0开始计数,计数到2000000(在100MHz下等于20ms)后将phy_rst_n拉高,并保持为高。

5. ARP——为什么它决定了你的板子能不能"被找到"

很多初学者有一个误区:我要用UDP和电脑通信,那我直接从FPGA发UDP包到电脑的IP不就行了?为什么还要管ARP?

问题出在以太网帧的结构上。以太网帧头里填的是目的MAC地址,不是目的IP地址。你的FPGA知道电脑的IP(比如192.168.1.2),但它第一次并不知道这个IP对应的MAC地址是什么。这时候就需要ARP协议来问:“谁是这个IP地址?请把你的MAC地址告诉我。”

5.1 ARP的工作过程

  • 电脑要往FPGA发UDP包,但它也不知道FPGA的MAC地址;
  • 电脑先发一个ARP广播包:“谁是192.168.1.10?请回答”;
  • FPGA收到这个广播后,eth_arp模块自动回复一个ARP应答:“我是192.168.1.10,我的MAC是00:0A:35:01:02:03”;
  • 电脑收到应答后,把MAC地址存进ARP缓存表,然后用这个MAC地址封装以太网帧,真正开始发UDP数据。

verilog-ethernet的eth_arp模块实现了自动应答功能,你只要在例化eth_udp时把本地MAC地址和IP地址告诉它,它就会自动处理ARP请求。你不需要自己写ARP解析逻辑。

5.2 为什么一上来先ping而不是直接发UDP

这是我最想强调的调试习惯。第一次上板,先别急着用网络调试助手发UDP数据。先在你电脑上执行ping 192.168.1.10

为什么?因为ping的过程实际上就把ARP流程整个跑了一遍。如果ping通了,说明:

  • PHY链路协商是好的;
  • RGMII接收方向没问题(FPGA能收到ARP请求);
  • RGMII发送方向没问题(FPGA能回应ARP应答);
  • MAC层的收发和IP层的基本处理是好的。

这一步通了,UDP的收发问题就被隔离在UDP层本身。我见过太多人UDP发不通,然后去MAC层查了半天,最后发现是ARP表没建立,PC根本不知道往哪个MAC发。

5.3 抓包确认ARP交互

在电脑上用Wireshark抓包,你会看到这样的交互序列:

电脑发ARP请求,目标IP是192.168.1.10。FPGA收到后立刻回复一个ARP应答。如果你的Wireshark里能看到这个应答,说明FPGA的网络通路已经通了。看不到应答,就先检查PHY和MAC层的收发信号。

6. UDP回环Demo实测——从编译到Wireshark抓包全流程

下面我用自己实际跑通过的过程,带你走一遍完整的验证流程。假设你已经写好了顶层模块,Vivado综合、实现、生成比特流都没有问题。

6.1 第一步:用ILA抓关键信号,确认接收通路

在Vivado里加一个ILA核,采样时钟用125MHz,探针接到以下信号:

  • axis_rx_tdata、axis_rx_tvalid、axis_rx_tready、axis_rx_tlast:MAC层解帧之后的数据;
  • udp_rx_tdata、udp_rx_tvalid、udp_rx_tready:UDP层解出来的载荷;
  • rgmii_rxd、rgmii_rx_ctl:RGMII原始信号(只有信号数量够才放,不够可以放缩位)。

然后把板子插上网线,把电脑IP设成192.168.1.2、子网掩码255.255.255.0,在电脑上ping 192.168.1.10。

打开ILA抓一次,如果接收通路正常,你应该看到ARP请求包完整地流过rgmii_rxd和axis_rx_*。看到axis_rx_tvalid拉高,并且数据里有ARP包的特征字节(目的MAC 0xFFFFFFFFFFFF,以太网类型0x0806),就说明MAC接收是通的。

如果ILA里一片寂静,没有任何数据,重点检查:PHY复位是否完成、rx_clk是否有125MHz时钟、RGMII引脚约束是否正确。

6.2 第二步:验证回环功能

ping通了之后,打开PC上的网络调试助手,或者直接用Python写一个简单脚本:

import socket sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind(("192.168.1.2", 12345)) sock.settimeout(3) for i in range(10): msg = f"hello fpga {i}".encode() sock.sendto(msg, ("192.168.1.10", 1234)) try: data, addr = sock.recvfrom(1024) print(f"recv: {data} from {addr}") except socket.timeout: print("timeout")

这个脚本向FPGA的IP 192.168.1.10的1234端口发了一串字符串,然后等待FPGA回环回来的数据。如果打印出同样的字符串,说明整个UDP收发通路就是通的。

这里有一个系统编排上的关键点:eth_udp模块只处理发往本机IP地址且协议号为UDP(0x11)的包,如果目的IP或者目的端口不匹配,它会把包丢弃,不会往用户接口传。所以上面的脚本里,sendto的目的IP必须是192.168.1.10,目的端口必须是你例化eth_udp时设置的端口(我这里设置的接收端口是1234)。

6.3 第三步:Wireshark观察网络包结构

通了之后,你再用Wireshark抓包,看到的应该是这样的结构:

以太网帧头(14字节):目的MAC是你的电脑MAC,源MAC是FPGA MAC,类型是0x0800(IPv4); IP头(20字节):源IP是192.168.1.10,目的IP是192.168.1.2,协议号是17(UDP); UDP头(8字节):源端口1234,目的端口12345,长度; Payload:你发的字符串。

看到这个结构,你才能说真正理解了UDP协议栈做了什么。

6.4 汇总:回环Demo的实测性能

在我自己的测试里,这个回环Demo能稳定跑满约850Mbps的UDP载荷吞吐,原因是MAC层做了帧间隙和FCS处理,实际有效载荷占比大约85%。对于FPGA学习用途来说,这个数字已经说明协议栈本身的通路没有瓶颈。

如果你发现吞吐率上不去,先看发送方向FIFO是否频繁反压。如果udp_tx_tready长期为低,说明发送通路落后,多半是下行设备(网卡或软件)处理不过来。这时候可以加大发送FIFO,或者加上流控,但那是后话了。

7. 调试心得——那些仿真里发现不了的问题

这一章是全文含金量最高的部分,全部来自我实际踩坑后的记录。

7.1 问题一:上电后链路指示灯亮,但收发完全没反应

现象:PHY的link灯亮,PC端显示已连接1Gbps,但ping不通。

排查过程:我抓了RGMII接收原始信号,发现rgmii_rx_ctl一直保持高,但rgmii_rxd上一个数据都没有。这说明PHY链路协商虽然完成了,但它没有向MAC发送有效数据,或者数据被IDELAY采错了窗口。

最终定位是RGMII接收数据采样窗口偏了。RTL8211的RXD相对于RXC有一个小的相位偏移,我把IDELAYE2的延迟值从0调整到合适值后,数据开始正常。这个调试过程比较痛苦,因为要反复烧写bitstream并看ILA。

经验:如果你的RGMII接收数据是0或者错乱的,先查IDELAYE2的值。走完IDELAY之后,再用gmii_rx_dv作为有效信号来判断数据是否正常。

7.2 问题二:ping通但UDP发不出去——源端口和目的端口迷糊

现象:ping通了,PC可以收到FPGA发的ARP应答,但UDP数据收不到。

排查过程:我在用户逻辑里把固定目的IP和目的端口写好了,但在例化eth_udp时,发送方向的tx_udp_src_porttx_udp_dst_port没有给对,导致发出的UDP包端口不对,PC端网络调试助手监听的是另一个端口。

这个问题的本质是,UDP协议栈本身工作正常,问题出在我给的端口参数和PC端监听不一致。这种问题在仿真里几乎发现不了,因为仿真没有真正的端口概念,只有握手信号。

经验:先检查发往PC端的UDP包的源端口、目的端口、源IP、目的IP是否与预期一致。在Wireshark里一眼就能看出来。

7.3 问题三:板上跑一段时间后UDP突然不工作了——FIFO溢出

现象:板子连续跑了几分钟,UDP回环不再响应,但重新上电又好了。

排查过程:我怀疑是某个FIFO溢出或指针错乱。verilog-ethernet的eth_mac_1g_fifo内部用的是自己的异步FIFO实现,理论上不会出问题。但我用的axis_data_fifo IP配置的是Standard模式,在突发流量超过FIFO深度时会丢包,用户逻辑没有处理丢包,导致状态错乱。

经验:如果要应对突发流量,AXI-Stream FIFO建议配置为FWFT(First Word Fall Through)模式,并且要有背压机制。如果不需要高可靠,至少让用户逻辑在FIFO满的时候暂停产生数据,而不是不管TREADY直接硬写。

7.4 问题四:仿真通过了但上板时序违例

现象:综合实现后,时序报告里出现红色违例,路径在eth_rgmii接收侧。

排查过程:这个问题的根源在于,RGMII接收侧的数据和时钟是源同步关系。Vivado默认把它当作普通的跨时钟域来约束。我需要在约束文件里对RGMII接口添加输入延迟约束:

set_input_delay -clock [get_clocks rgmii_rxc] -max 2.0 [get_ports {rgmii_rxd[*]}] set_input_delay -clock [get_clocks rgmii_rxc] -min 0.5 [get_ports {rgmii_rxd[*]}]

这个约束告诉Vivado,输入数据相对时钟的窗口是多少。如果不做约束,实现工具可能用错误的时钟关系去布布线,导致实际工作时采错数据。

经验:任何源同步接口(RGMII、LVDS、MIPI等)都必须设置输入延迟约束,否则时序报告只是个摆设。

8. 在这个基础上,你可以继续做哪些事

跑通了UDP回环,你的FPGA网络开发已经跨过了最难的坎。从这一步出发,接下来的扩展方向非常多,我说几个我实际做过或者验证过的路径。

8.1 方向一:改成固定数据发送

把回环逻辑换成你平时用串口发送数据的习惯:一个按键触发发送一帧数据,数据内容是一段固定的正弦波采样值或者传感器读数。这时候你需要把udp_tx_tdata从一个计数器或者ROM里取,而不是从接收通道来。

代码逻辑很简单:状态机空闲时等待按键信号,然后进入发送状态,持续给udp_tx_tvalid和udp_tx_tdata赋值,直到发完N个字节,拉高tlast。

8.2 方向二:加上简单的数据采集和上传

比如用FPGA的ADC接口采集模拟信号,每采到1024个点打包成一个UDP包发给电脑。这个场景非常接近工业上FPGA高速数据采集的真实需求。这里有两个要点:

  • 采样的时钟和UDP发送时钟是异步的,需要FIFO缓冲;
  • 打包时要决定是连续采满一拍发一拍,还是按固定触发条件发一包。

8.3 方向三:多通道UDP(比如摄像头图像传输)

如果你对图像处理感兴趣,方向三几乎是最自然的下一步:把OV5640摄像头采集到的数据,经过去马赛克、色彩空间转换等处理,封装成UDP发到电脑。这里的关键是吞吐量的匹配:比如1080P30的RGB888原始数据大约要3Gbps,远超千兆网,所以通常要压缩成YUV422或者只发灰度图,或者用JPEG硬件压缩。这个方向能把FPGA的并行能力、流水线设计和网络协议栈三者结合起来,收获会非常大。

写在最后的体会

这篇是“近似0基础FPGA开发”系列的第十篇,也是我自己从“只会流水灯”到“敢说懂网络协议栈”的一个分水岭。verilog-ethernet这个开源工程帮了我大忙,但回头看不光是代码本身的价值,更是它逼我去搞懂了AXI-Stream握手、跨时钟域FIFO、RGMII源同步时序这些FPGA开发里躲不开的硬骨头。

如果你照着这篇把回环Demo跑通了,恭喜你,你已经不是“近似0基础”了。下一步无论是继续啃协议栈更深层的细节,还是转向图像采集、高速接口,你都有足够的底子去啃。

再分享一个我后来一直保留的习惯:任何新到的开发板,我第一个上板的通信外设一定是UDP回环,而不是串口。因为它的调试链路足够长,从PHY到MAC到IP到UDP,每一层出错都能通过抓包快速定位。这个习惯帮我评估一块新板子的网络相关设计时,省下了大量时间。你也可以试试。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 6:48:58

DU562音频DSP的2个GPIO:MCU控制、寄存器操作与工程避坑

做音频产品这些年,我经手过不少把音效处理交给专用芯片的方案,其中 DU562 这类“带 2 个 GPIO、可被主控 MCU 控制的 DSP 音频处理芯片”是性价比很高的一档。它本身是个做音频算法(EQ、混响、分频、限幅)的 DSP,但真正…

作者头像 李华
网站建设 2026/9/17 6:48:38

AI智能体评测:龙虾助手在场景适配与工具调用中的优势

1. 项目背景与核心价值最近半年AI智能体赛道突然火爆起来,各种"XX助手"类产品如雨后春笋般涌现。作为长期关注AI落地的从业者,我系统测试了市面上主流的6款AI智能体产品,其中"龙虾助手"因其独特的场景适配能力引起了我的…

作者头像 李华
网站建设 2026/9/17 6:46:48

参与go-modern-guidelines社区:Issue、PR与生态扩展全指南

参与go-modern-guidelines社区:Issue、PR与生态扩展全指南 【免费下载链接】go-modern-guidelines Help AI coding agents write modern Go 项目地址: https://gitcode.com/GitHub_Trending/go/go-modern-guidelines go-modern-guidelines 是一个帮助 AI 编程…

作者头像 李华
网站建设 2026/9/17 6:46:30

Zephyr RTOS 入门:Ubuntu 环境搭建、west工具链与Blinky编译烧录实战

最近在好几个嵌入式交流群里都被问到同一个问题:Zephyr RTOS 到底怎么入门?说实话,这个问题在五年前还挺难回答,因为资料少、生态新;但现在答案已经很明确了——先在一台 Ubuntu 上把环境搭起来,编译第一个…

作者头像 李华
网站建设 2026/9/17 6:43:43

三款主流远程控制软件深度对比与选型指南

1. 远程控制软件实测背景与需求分析在混合办公成为常态的今天,远程控制软件已经从专业IT工具变成了大众刚需。根据我过去五年为300家庭和企业部署远程方案的经验,普通用户主要面临三类典型场景:上班族需要临时接入公司电脑处理紧急文档子女需…

作者头像 李华