news 2026/9/18 8:47:41

拆解verilog-ethernet:FPGA UDP协议栈实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
拆解verilog-ethernet:FPGA UDP协议栈实战

1. 从"点灯、按键、串口"到能收发以太网帧,中间缺的到底是什么

第一次把verilog-ethernet拖进 Vivado 工程的时候,我盯着rtl目录里那几十个.v文件看了差不多半个小时,脑子里只有一个念头:这玩意儿跟我之前写的 UART 收发完全是两个物种。前面九个 part 里我们干过的事情——分频、消抖、状态机、FIFO、简单的 AXI-Lite 寄存器——都属于"一个模块解决一个明确问题"。而verilog-ethernet是一个完整的FPGA UDP 协议栈,它把以太网 MAC、ARP、IP、UDP 四层堆在一起,还要处理跨时钟域、校验和、帧间隙、ARP 缓存老化这些工程细节。

这篇是系列的第十篇,我打算把这套开源工程拆开讲透。它不是"教你从零手写一个 MAC"的教程,而是"教你怎么读懂、跑通、改对一套成熟的 FPGA 以太网协议栈"。verilog-ethernet是 GitHub 上 Alex Forencich 维护的一套用纯 Verilog 写的以太网 IP 库,覆盖 1G/10G/25G 以太网 MAC、GMII/RGMII/XGMII 各种 PHY 接口、ARP、IPv4、UDP、PTP 时间戳,配套还有 AXI-Stream 基础设施库和 cocotb 仿真测试。它最大的价值在于:你可以直接拿它当"能跑通的参考答案",反过来学协议本身的实现方式,而不是对着一份 IEEE 802.3 的英文标准啃半个月。

我把这篇的定位说清楚:适合已经会写状态机和 FIFO、会跑基本仿真的 FPGA 初学者,以及想给自己的项目快速加上网络功能的工程师。如果你连always @(posedge clk)和阻塞非阻塞赋值都没理清,建议先回去把前几篇补完。这篇会覆盖四件事:这套工程的结构到底怎么分层、AXI-Stream 这套握手协议在以太网场景下的具体含义、UDP 帧从网线到用户逻辑的完整拆解过程、以及上板之后收发不通时该怎么一步步排查。

2. 别急着看 udp_complete,先把目录结构和依赖关系理清楚

2.1 一个很多人第一天就卡住的地方:工程根本编译不过

我见过不止一个人把verilog-ethernet克隆下来,加进工程,然后发现满屏的module not found。原因很简单:这套工程不是自包含的,它依赖另外两个库

具体依赖是这样的:

依赖仓库提供什么是否必须
verilog-ethernet以太网 MAC、IP、UDP、ARP、PTP 主体必须
verilog-axisaxis_fifoaxis_async_fifoaxis_adapteraxis_dwidth_converter等 AXI-Stream 基础设施必须
verilog-axiAXI4/AXI-Lite 互连、寄存器接口用到寄存器配置时才需要
cocotb+cocotbext-ethPython 仿真测试框架跑官方仿真时必需

所以正确的做法是:把verilog-ethernetverilog-axis克隆到同一个父目录下的两个平级文件夹里,然后工程的 include 路径同时指向这两个目录的rtl子目录。官方的 tb Makefile 里就是用AXIS_DIR ?= ../verilog-axis这种相对路径来找的,你如果放到别的地方,就要手动改 Makefile 里的变量。

提示:不要试图把verilog-axis的几十个文件拷贝到verilog-ethernet/rtl里"合并一下"。两个库都在持续更新,手动合并之后你再也同步不了上游的 bugfix 了。

2.2 rtl 目录的真实分层地图

打开rtl目录,文件看起来很多,但按层次归类其实非常清晰。我把它分成五层:

第一层是 PHY 接口层,负责把芯片引脚上的差分/单端信号翻译成逻辑电平。eth_mac_1g_gmii系列对应 GMII 接口,eth_mac_1g_rgmii系列对应 RGMII 接口,eth_mac_1g_mii对应百兆 MII。这一层里还会用到IDDRODDRIDELAYE2这类厂商原语,所以它的可移植性是最差的,换一家 FPGA 厂商基本要重写。

第二层是 MAC 层,核心模块是axis_gmii_rxaxis_gmii_tx。接收方向负责识别前导码和 SFD、把字节流解出来、校验 FCS、剥离 CRC 并把结果放到 AXI-Stream 的tuser上;发送方向负责插入前导码、按需补齐到 60 字节最小长度、计算 CRC32、输出字节流,还要保证帧间隙。这一层是纯协议逻辑,跟厂商无关,也是你最值得精读的部分。

第三层是网络层,包括ipip_completearparp_cacheip_complete里又套了iparp,并且用ip_eth_rx/ip_eth_txarp_eth_rx/arp_eth_tx这几个小模块做协议间的桥接。这一层的核心工作是:判断以太网类型字段是0x0800(IPv4)还是0x0806(ARP),分别把帧路由给不同的处理逻辑。

第四层是传输层,主要是udpudp_completeudp里又包含udp_checksum_genudp_checksum_check两个校验和模块,以及udp_ip_rx/udp_ip_tx这两个与 IP 层对接的桥接模块。

第五层是时间同步ptp_clockptp_ts_*系列,跟 UDP 主流程相对独立,第一遍学可以先完全跳过。

2.3 udp_complete 不是一根管子,而是收发两条管子交叉

初学者最容易犯的认知错误是把udp_complete想象成一个"输入字节流、输出字节流"的黑盒。实际上它内部是两条完全独立的数据通路(RX 和 TX),每条通路上又有两股并行的流(头部和载荷)。理解这一点,后面所有接口的命名逻辑就全通了。

udp_complete的端口命名规律是这样的:s_前缀表示 slave 侧(数据进来),m_前缀表示 master 侧(数据出去),eth表示这一端对着 MAC,udp表示这一端对着用户逻辑。

所以接收方向是:MAC 送进来的以太网帧,从s_eth_*进,用户逻辑收到的 UDP 数据,从m_udp_*出。发送方向反过来:用户要从s_udp_*灌数据,最终从m_eth_*送给 MAC。

头部和载荷分开这件事,是这套设计里最精妙的地方。拿接收来说,s_eth_hdr_*只承载以太网头部那几个字节(目的 MAC、源 MAC、类型),s_eth_payload_*承载后面的全部内容。协议栈的状态机只盯着头部流做解析,一旦解析出"这是个 UDP 包、端口号是多少、长度是多少",它就把后面的载荷流直接透传到m_udp_payload_*,自己不碰数据。**这样做的好处是状态机极其简单、时钟周期开销极小,而且载荷可以不经修改直接进 FIFO 或者 DDR。**代价是你要同时接到两组接口,接错一组就会出现"头部对得上但数据全是乱的"这种诡异现象。

关于具体位宽和信号名,我这里提醒一句:这套库从 2019 年到现在重构过好几轮,早期版本是s_eth_hdr_tdata[7:0]这种 8 bit 通路,后来加了udp_complete_64这种 64 bit 版本。你手上的版本以本地rtl/udp_complete.v的端口列表为准,别照着某篇三年前的博客抄。

3. AXI-Stream 是这套代码里的"普通话",不学会寸步难行

3.1 tvalid/tready 握手的三种组合,以及为什么它比 FIFO 更好用

AXI-Stream 的核心只有五个信号,但很多人写了半年 FPGA 也没完全搞明白握手的边界条件。

  • tdata:数据本体,位宽可以是 8、32、64 甚至 512 bit。
  • tvalid:发送方声明"我这拍的数据有效"。
  • tready:接收方声明"我这拍能收"。
  • tlast:发送方声明"这是这一帧的最后一个数据拍"。
  • tuser:边带信息,在以太网场景里通常表示这一帧有错误。

数据传输只在tvalid && tready同时为高的那个时钟上升沿发生,其余任何组合都不产生传输。这句话请务必刻在脑子里,因为它直接决定了三种情形:

tvalidtready时钟沿发生了什么
11传输成功,双方可以更新数据
10发送方必须保持tdata/tlast/tuser不变,直到tready拉高
01无传输,接收方空等,发送方数据不变

第二行是新手最常翻车的地方。很多人写发送逻辑时习惯"每拍都换数据",结果接收端偶尔拉低tready,数据就丢了。规则很硬:只要tvalid拉起来而tready还没来,所有跟这一拍数据相关的信号一个都不能变。

还有一条容易被忽略的规则:tvalid一旦拉高,不允许在没有完成传输的情况下自己拉低。也就是说,不能出现"我发了但你没接,我等得不耐烦就撤销了"这种事。这在跨时钟域的场景里尤其重要,因为axis_async_fifo的写侧会假设你的tvalid是稳定的。

那为什么不用普通 FIFO 呢?因为 AXI-Stream 天然带帧边界。以太网帧长度是可变的,从 64 字节到 1500 字节(不算巨型帧),如果不用tlast标记,接收端根本不知道一帧到哪儿结束。这也是为什么协议栈内部到处都在用tlast做状态跳转。

3.2 tkeep 在以太网里的具体含义

tkeep是一个很多人觉得"可有可无"的信号,但在以太网里它非常关键。tkeep的位数等于tdata的字节数——8 bit 数据通路有 1 bittkeep,64 bit 数据通路有 8 bittkeep。每一位对应tdata里的一个字节,1 表示这个字节有效,0 表示无效。

以太网帧的最小长度(含 FCS)是 64 字节,也就是说去掉 4 字节 CRC 之后,净载荷最少要有 60 字节。如果上层应用要发的 UDP 数据只有 10 个字节,MAC 层就必须自动补齐到 60 字节。在 8 bit 数据通路上,补齐直接体现为多插几个字节;但在 64 bit 通路上,最后一拍可能有 3 个有效字节 + 5 个填充字节,这时tkeep就等于3'b00000111(按位序排列)。接收端必须用tkeep来切掉尾巴上的填充字节,否则你的 UDP 长度字段和实际收到的数据长度就对不上了。

我在第一次调试的时候就踩了这个问题:应用发 10 字节,接收端却告诉我收到 46 字节。查了半天才发现是自己写的接收逻辑直接按字节计数,完全没看tkeep

3.3 一个真实踩坑记录:tlast 丢了,帧永远发不出去

这个坑我印象特别深,因为它表现得完全不像"数据问题",而像"链路断了"。

现象是:仿真里 MAC 层一切正常,PHY 也能收到时钟,但抓包工具上什么都看不到,连 ARP 请求都没有。我一开始怀疑是 PHY 配置、怀疑是 RGMII 延时、怀疑是板子硬件坏了,折腾了两天。

最后把波形拉出来一看:发送侧的tlast在整个仿真过程中一直是 0。

原因是我在用户侧写发送逻辑时,用了一个计数器来决定什么时候结束,但计数器的复位条件写错了,导致它从来没有触发过。axis_gmii_tx内部的状态机在等tlast,等不到就永远卡在"SEND"状态,不输出帧尾也不释放帧间隙,结果就是整条发送通路死锁。但它不会报错,也不会拉tuser,它只是安静地卡住。

这件事给我的教训是:**在 AXI-Stream 上做调试,第一个要看的不是数据,是tlasttvalid/tready的握手是否完整。**后来我养成了一个习惯,在自己的发送模块加一条断言:

// 发送完成前不能松手 always @(posedge clk) begin if (m_axis_tvalid && !m_axis_tready) begin assert (m_axis_tvalid == 1'b1); end end

4. UDP 帧从网线到用户逻辑的完整旅程

4.1 从引脚到 GMII:物理层那点事

不管后面协议多复杂,一切都要从 PHY 芯片出来的那几个信号开始。以最常见的 RGMII 为例,信号是RGMII_TXCRGMII_TXD[3:0]RGMII_TX_CTLRGMII_RXCRGMII_RXD[3:0]RGMII_RX_CTL。它在 1000 Mbps 下工作时钟是 125 MHz,采用 DDR(双沿)方式,所以每个时钟周期传输 4 bit × 2 = 8 bit,合起来正好 1000 Mbps。

注意RGMII_TX_CTLRGMII_RX_CTL这两个信号在 DDR 模式下的含义是复合的:上升沿表示TX_EN/RX_DV,下降沿表示TX_ER/RX_ER。这是 RGMII 规范和 GMII 最大的区别,也是为什么eth_mac_1g_rgmii里一定要用ODDR/IDDR原语——普通的always块根本表达不了这种双沿语义。

GMII 就简单多了:8 bit 数据宽,125 MHz 时钟,gmii_tx_engmii_txd[7:0]gmii_rx_dvgmii_rxd[7:0]各管各的。如果你的板子上 PHY 是通过 GMII 连到 FPGA 的,直接用eth_mac_1g_gmii_fifo就行,省掉一堆时序约束的麻烦。这也是我在教别人的时候建议的路线:入门阶段优先选 GMII 接口,等跑通了再啃 RGMII 的延时问题。

4.2 MAC 层做了什么:前导码、SFD、CRC32 和最小帧

axis_gmii_rx的状态机大致是这样走的。它先在内核里等,看到连续的0x55就进入前导码检测;检测到第 7 个0x55之后紧跟0xD5(这是 SFD,Start Frame Delimiter),就认为帧开始了。接下来的字节流,前 6 个是目的 MAC,再 6 个是源 MAC,然后是 2 字节类型/长度字段。之后的内容它会原封不动地透传到 AXI-Stream 上,同时把 CRC 校验的结果放到tuser

axis_gmii_tx则反过来:它先输出 7 个0x55和 1 个0xD5,然后按 AXI-Stream 送来的数据输出,同时把每个字节喂给 CRC32 计算器。数据发完之后输出 4 字节 CRC。**同时它内部有一个计数器,如果数据不足 60 字节,它会自动补0x00。**最后它还会插入至少 12 字节的帧间隙(IFG),保证不违反协议。

CRC32 用的是以太网标准的生成多项式0x04C11DB7,反射实现。这里有个小细节容易搞错:发送时算出来的 CRC 要先按位取反、再按字节逆序输出。如果你自己写 CRC 模块,这一条写错了,抓包工具会告诉你"FCS 错误",但不会告诉你错在哪儿。

4.3 IP 层:校验和、TTL 与"为什么我的包被路由器丢了"

帧穿过 MAC 层之后,ip_eth_rx会检查以太网类型字段。如果是0x0800,说明是 IPv4,交给ip模块;如果是0x0806,交给arp模块。

ip模块要做的第一件事是校验 IP 头部的校验和。IPv4 头部校验和的算法是:把 20 字节的头部按 16 bit 分组,全部相加(进位要回卷加到低位),最后取反。校验时必须重新算一遍,判断相加结果是不是0xFFFF。如果不对,整个包直接丢弃。

然后是 TTL(Time To Live)。**TTL 每经过一个路由器都会减 1,减到 0 就丢弃。**在 FPGA 直连 PC 或者直连交换机的场景下,TTL 通常是从 PC 发出来就是 64 或者 128。如果你发现包能发出去但对方收不到,而 TTL 又设成了 0 或者 1,那就是这个问题。

还有一个坑是分片。ip模块默认不支持 IP 分片重组,如果你的 UDP 数据超过了 MTU(以太网默认 1500 字节),上层协议栈会尝试分片,而这边收到分片包会直接扔掉。所以在 FPGA UDP 场景下,一定要把应用层数据控制在1500 - 20(IP 头) - 8(UDP 头) = 1472 字节以内。这是我在实际项目里吃过亏的地方:PC 端用 Python 发个 2000 字节的包,FPGA 这边一点反应都没有,查了半天才发现是被分片了。

4.4 UDP 层:校验和是可以关掉的,但关之前想清楚代价

UDP 头部只有 8 个字节:源端口(2)、目的端口(2)、长度(2)、校验和(2)。长度字段包含了 8 字节头部本身。

UDP 校验和的计算比 IP 更麻烦一点,因为它要引入伪头部:源 IP、目的 IP、一个字节的 0、协议号(UDP 是 17)、UDP 长度。把这些和 UDP 头、载荷拼在一起按 16 bit 求和取反。

这里有个很多实现会踩的细节:UDP 校验和的结果如果是0x0000,必须发送成0xFFFF,因为 0 在 UDP 里表示"发送方没有计算校验和"。verilog-ethernetudp_checksum_gen是正确处理了这个边界情况的,但如果你打算自己写,一定要注意。

udp_complete提供了一个参数UDP_CHECKSUM_GEN_ENABLE关掉它,发送时校验和字段填 0,能省掉一整个校验和计算模块和配套的 FIFO 缓存,资源省不少。但代价是:**有些操作系统和网络设备会对校验和为 0 的 UDP 包做严格检查,直接丢弃。**我在 Linux 和 Windows 上都测过,默认配置下都是接收的,但如果中间过了某些防火墙或者虚拟化层,就可能被拦。我的建议是调试阶段先关掉,等链路通了再打开,最后交付时务必打开。

5. 仿真跑通:这一步比上板省一百倍时间

5.1 官方测试环境怎么搭

verilog-ethernettb目录里带了一套完整的 cocotb 测试。搭起来其实很简单:

# 目录结构 mkdir fpga_eth && cd fpga_eth git clone https://github.com/alexforencich/verilog-ethernet.git git clone https://github.com/alexforencich/verilog-axis.git # Python 环境 python3 -m venv venv source venv/bin/activate pip install cocotb cocotbext-eth # 编译并运行 cd verilog-ethernet/tb make

默认用的是 Icarus Verilog。Makefile 里的AXI_DIR/AXIS_DIR变量必须指向你克隆下来的路径,如果目录结构跟我上面写的不一样,就要手动改。如果makemodule axis_async_fifo not found,一定是这一步没配对。

跑起来之后你会看到 cocotb 打印出一系列测试用例的执行结果,包括 ARP 请求响应、UDP 收发、校验和校验。这套测试的价值不只是"验证代码能跑",更重要的是它是你学习协议交互流程的最佳素材——你可以顺着 Python 侧发的包,一路对着波形看到它怎么被 RTL 解析。这比读任何文档都直观。

5.2 自己写一个最小回环测试

官方的测试虽然全,但耦合度也高,想改一个参数往往要动一堆文件。我的做法是另起一个极简 testbench,只测自己关心的那条路径

一个典型的骨架长这样:

module tb_udp_loopback; reg clk = 0; reg rst = 1; always #4 clk = ~clk; // 125MHz // 用户侧发送接口 reg [7:0] s_udp_tdata = 0; reg s_udp_tvalid = 0; reg s_udp_tlast = 0; wire s_udp_tready; // 用户侧接收接口 wire [7:0] m_udp_tdata; wire m_udp_tvalid; wire m_udp_tlast; wire m_udp_tuser; reg m_udp_tready = 1; // 这里例化 udp_complete,把 eth 侧接口接到虚拟 MAC 上 // ... initial begin repeat (10) @(posedge clk); rst = 0; repeat (10) @(posedge clk); // 发一个 8 字节的 UDP 载荷 s_udp_tdata = 8'h11; s_udp_tvalid = 1; @(posedge clk); s_udp_tdata = 8'h22; @(posedge clk); s_udp_tdata = 8'h33; @(posedge clk); // ... 省略几拍 s_udp_tlast = 1; @(posedge clk); s_udp_tvalid = 0; s_udp_tlast = 0; repeat (200) @(posedge clk); $finish; end endmodule

关键是要在 eth 侧造一个"虚拟 MAC",它看到m_eth_tvalid就把数据吞掉,同时把自己当作"网线另一端",把刚才收到的那帧原样再塞回s_eth_*。这样就能在完全没有任何硬件的情况下,验证完整的收发链路。我在做这类测试的时候,一般会把接收到的字节直接打印出来,用$display("%02x", m_udp_tdata),肉眼比对几次之后就能确认整条通路是通的。

5.3 波形里最该盯的五个信号

仿真跑起来之后,波形文件动辄几十兆,全看一遍是不可能的。我的经验是只盯五个信号

第一,m_udp_tvalidm_udp_tready的握手。这两个不同时拉高,说明对端没准备好,问题在接口层。

第二,m_udp_tlast。它必须在正确的位置出现一次,而且只出现一次。多一次说明帧长度算错了,少一次说明状态机卡住了。

第三,m_udp_tuser。**这个信号是错误指示器,正常情况下应该一直是 0。**它变成 1,说明 CRC 或者校验和有问题。

第四,s_eth那一侧有没有数据流动。如果 UDP 侧有数据但 eth 侧没有,问题在协议栈内部的 FIFO 或者状态机;如果 eth 侧也没有,问题在更上游。

第五,复位之后经过了多少个时钟周期才看到第一个tvalid。这个数字能帮你判断状态机是不是卡在了某个等待状态——比如 ARP 解析。如果复位后一直没动静,八成是 ARP 没解析成功,包在发送队列里被扣住了。

6. 上板才是真正的考验:时钟域、延时和约束

6.1 三个时钟域共存时的心智模型

udp_complete在典型配置下会涉及三个时钟:MAC 接收时钟rx_clk(由 PHY 提供)、MAC 发送时钟tx_clk(通常也是 PHY 提供或者由 FPGA 分频出来)、用户逻辑时钟(比如你系统里的 125 MHz 或者 100 MHz)。这三个时钟频率可能相同也可能不同,而且即使频率相同,相位关系也完全不确定——PHY 的恢复时钟和 FPGA 内部的 PLL 输出没有固定关系。

协议栈用axis_async_fifo做跨时钟域。它的本质是双口 RAM + 格雷码指针同步,是标准做法,没什么特别的。但有几个使用上的注意点:

  • **异步 FIFO 两边的复位要分开处理。**不要用同一个复位去捅两个时钟域,那会破坏格雷码指针同步的前提。
  • **FIFO 深度别省。**很多人为了省 BRAM 把深度设成 8 或者 16。UDP 突发流量下,PC 端一个sendto可能连着发好几帧,深度太小会丢包或者反压到上游。我的经验是接收侧至少 512 深度,发送侧至少 512 深度。
  • **不要在两个异步 FIFO 之间做组合逻辑。**跨时钟域之后任何组合逻辑都可能引入亚稳态传播。

如果你系统里有多个时钟,最好在顶层画一张"谁跟谁通信、用什么模块跨域"的图,贴在手边。我第一版设计就是因为没画这张图,结果 RX 路径漏了一个跨时钟域,仿真里完全看不出来(因为仿真里所有时钟都是同时从 0 开始的),上板之后随机丢包,查了整整一周。

6.2 RGMII 的延时问题:一个必须现场调的参数

RGMII 是 125 MHz DDR,时钟周期只有 8 ns,数据窗口非常窄。规范里要求发送端把时钟相对数据偏移 1~2 ns,接收端要保证在数据眼中间采样。

问题在于:**不同板子、不同 PHY 芯片、不同走线长度,这个延时需求都不一样。**有些 PHY 内置了延时(叫 Internal Delay 或者 RGMII Delay),有些没有;有些默认打开,有些默认关闭。

verilog-ethernet的 RGMII 模块里有一个CLK_DELAY相关的参数(不同版本名字不同,有的叫IODELAY_GROUP,有的用IDELAYE2INIT值)。调试的时候按这个顺序来:

  1. 先查 PHY 数据手册,确认它的 RGMII 内部延时是默认开还是默认关。
  2. 如果 PHY 内部延时打开,RTL 侧就把延时设成 0,否则两边延时叠加,一定采错。
  3. 如果 PHY 内部延时关闭,就需要在 FPGA 侧用IDELAYE2加延时,初值一般从 1.5 ns 左右(也就是 125 MHz 下 200 MHz 参考时钟的若干个 tap)开始试。
  4. 还是不通就用示波器看 RX_CLK 和 RX_CTL 的相位关系,别盲调。

还有一个经常被忽略的点:**IDELAYE2需要 200 MHz 的参考时钟,而且必须配合IDELAYCTRL使用。**很多人只例化了 IDELAY 忘了 IDELAYCTRL,综合工具不报错,但延时值完全不生效。

6.3 例化 udp_complete 的完整骨架

我把实际用到的例化结构简化一下,主线是这样:

// ---- 1. RGMII 到 GMII 的转换 + MAC(内含异步 FIFO)---- eth_mac_1g_rgmii_fifo #( .TARGET("XILINX") ) mac_inst ( .gtx_clk (clk_125m), .gtx_rst (rst_125m), .logic_clk (clk_125m), .logic_rst (rst_125m), .rgmii_rxc (rgmii_rxc), .rgmii_rx_ctl(rgmii_rx_ctl), .rgmii_rxd (rgmii_rxd), .rgmii_txc (rgmii_txc), .rgmii_tx_ctl(rgmii_tx_ctl), .rgmii_txd (rgmii_txd), // 送给协议栈的接收流(8 bit) .rx_axis_tdata (mac_rx_tdata), .rx_axis_tvalid (mac_rx_tvalid), .rx_axis_tready (mac_rx_tready), .rx_axis_tlast (mac_rx_tlast), .rx_axis_tuser (mac_rx_tuser), // 从协议栈收发送流 .tx_axis_tdata (mac_tx_tdata), .tx_axis_tvalid (mac_tx_tvalid), .tx_axis_tready (mac_tx_tready), .tx_axis_tlast (mac_tx_tlast), .tx_axis_tuser (mac_tx_tuser) ); // ---- 2. 协议栈本体 ---- udp_complete #( .UDP_CHECKSUM_GEN_ENABLE (1) ) udp_inst ( .clk (clk_125m), .rst (rst_125m), // 对着 MAC 的接收侧 .s_eth_hdr_tdata (mac_rx_tdata), .s_eth_hdr_tvalid (mac_rx_tvalid), .s_eth_hdr_tready (mac_rx_tready), .s_eth_hdr_tlast (mac_rx_tlast), .s_eth_hdr_tuser (mac_rx_tuser), // 对着 MAC 的发送侧 .m_eth_hdr_tdata (mac_tx_tdata), // ... 其余信号 // 给用户逻辑的接收接口 .m_udp_hdr_tdata (usr_rx_hdr_tdata), .m_udp_payload_tdata(usr_rx_pay_tdata), // ... 其余信号 // 用户逻辑的发送接口 .s_udp_hdr_tdata (usr_tx_hdr_tdata), .s_udp_payload_tdata(usr_tx_pay_tdata) // ... 其余信号 );

**这里面最容易接错的是s_eth_hdr_*s_eth_payload_*这一对。**注意别把它们接成同一根线——eth_hdr只承载以太网头部的 14 字节,eth_payload才承载后面的内容。我把这两个接反过一次,结果是 UDP 数据完全正确但目的 MAC 全错,抓包工具显示帧发到了广播地址上。

一个实操技巧:如果你只需要 UDP 收发,完全不需要关心 ARP、IP 这些层的端口,可以只看s_eth_*/m_eth_*udp_*这两组。中间那些层的接口在udp_complete里都已经连好了。

7. 收发不通时,我的分层排查链路

7.1 从物理层往上,一层一层排除

这是我这几年总结出来的顺序,基本能覆盖 95% 的问题。核心原则是从最底层开始,不要跳层。

层级检查什么典型症状
物理层PHY 有没有 link up、rx_clk有没有时钟、RGMII 延时对不对完全没有数据、rx_clk看不到
MAC 层CRC 有没有错、帧长对不对、IFG 有没有留够抓包工具显示 FCS Error
ARP 层ARP 请求有没有发出去、有没有收到响应、缓存里有没有条目前几个包丢失,之后正常
IP 层校验和、TTL、有没有被分片包发出了但对方丢掉
UDP 层端口号、长度字段、校验和开关抓到包但应用收不到

**第一件事永远是确认 PHY link 起来了。**如果rx_clk完全没有翻转,后面所有事都别谈。有些开发板的 PHY 需要 MDIO 配置才能进入 1000M 全双工模式,而verilog-ethernet默认不帮你做 MDIO 配置——**你要么用板子上的跳线/拨码开关设置成自协商,要么自己写个 MDIO 控制器去配置。**这一步不做,PHY 可能停在 100M 半双工,甚至根本没起来。

7.2 ARP 缓存:为什么第一个包总是丢

这个问题几乎每个用这套协议栈的人都会遇到,而且它会让你误以为是时序问题。

现象是:PC 连续 ping 或者发包,第一个包没反应,后面就正常了。

原因是这样的:FPGA 侧要发一个 UDP 包,但是协议栈只知道目的 IP,不知道目的 MAC。它必须先发一个 ARP 请求广播出去,等对方回应。**在等待回应这段时间里,那个待发的 UDP 包是被丢弃的,不是被缓存的。**等 ARP 响应回来,缓存里有了条目,后续的包才能正常发出去。

我第一次遇到这个现象时,以为是丢包,在接收侧加了一堆重传逻辑。后来读了arp.v的源码才明白:这是符合设计预期的行为,不是 bug。

应对方式有三种:

  • PC 侧先发一个包或者先 ping 一下,把 ARP 缓存建立起来。
  • 在 FPGA 里写一个"预热"逻辑,复位之后主动发一个 ARP 请求给固定的对端 IP。
  • 用静态 ARP 条目arp_cache里其实可以直接预置条目,如果你对端 MAC 是固定的(比如直连某块特定网卡),把它的 MAC 写死在初始化里,就完全绕过了 ARP 这个过程。

另外提醒一句:arp_cache的条目是有老化时间的,超过一定时间不刷新就会被清掉。如果你的应用是低频发送,隔几分钟发一次,每次都触发 ARP 重解析和丢第一个包,那体验会非常差。这时可以在应用层加一个心跳,定期刷一下缓存。

7.3 抓包工具给出的线索,以及怎么读

Wireshark 在这个流程里是不可替代的。但很多人只看了"有没有包",没看"包的细节"。我的习惯是重点看这几个字段:

  • Frame check sequence。显示FCS Error说明物理层或者 MAC 层有问题,多半是 RGMII 延时或者 CRC 计算错误。
  • Destination。如果显示成Broadcast而你以为发的是单播,说明 ARP 没解析成功,或者目的 MAC 字段被写成了全 F。
  • IP Header Checksum。Wireshark 会标incorrect,说明头部校验和算错了。
  • UDP Checksum。如果显示unverified而不是incorrect,通常是因为校验和字段是 0(你关掉了生成),这不一定是问题。
  • Length。UDP 长度字段和实际载荷对不上,说明tkeep或者填充逻辑有问题。

**还有一个非常好用的技巧:在交换机或者路由器上做端口镜像,把上下行流量都抓下来。**只看 PC 侧的话,你分不清"包没发出去"和"发出去了但没被接收"这两种完全不同的故障。

7.4 一个反复出现的隐形坑:复位顺序

最后说一个不怎么起眼但很要命的问题。

协议栈内部有大量 FIFO 和状态机,**如果不同模块的复位释放时刻不一致,就可能出现指针错位、状态机卡死这类问题。**尤其是跨时钟域的异步 FIFO,写侧和读侧的复位必须分别在各自时钟域内同步释放。

我的做法是:

  • 用一个统一的复位源,但每个时钟域各自用两级触发器做同步。
  • 异步 FIFO 的复位一定要拉够时间,我一般给 16 个时钟周期以上。
  • 不要在复位还没释放完就开始往 AXI-Stream 上灌数据

还有一种情况是仿真里完全看不到的:上电时 PHY 的rx_clk比 FPGA 内部时钟晚起振好几毫秒。如果这时你的复位已经释放了,接收侧的异步 FIFO 就可能在读侧时钟存在、写侧时钟不存在的情况下工作一段时间。标准的做法是用 PHY 的rx_clk来同步接收通路的复位,或者干脆在软件里加一个"等待 PLL 锁定 + 延时"的上电时序。

我个人在实际项目里的体会是:这套开源协议栈本身的代码质量是很高的,绝大多数"不通"的问题都不在协议栈里,而是在它和你自己代码的接口上,以及板子本身的硬件配置上。把 PHY 配好、把时钟域理清、把 ARP 缓存逻辑想明白,这三点做扎实了,剩下的就都是时间问题了。

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

GPT-6 Astra深度解析:Computer-use、可搜索记忆与Agent成本控制

早上打开后台,看到 GPT-6 Astra 的消息时我其实愣了一下。倒不是因为参数翻了几倍这种常规升级,而是那句“强到被锁起来”的产品决策。做 AI 应用这几年,见惯了厂商把能力往大了吹,头一回见官方主动把自己最强的形态按住的。仔细扒…

作者头像 李华
网站建设 2026/9/18 8:45:29

垂直AI如何提升专业文本校对准确率与效率

1. 垂直AI如何重塑文本校对行业在文字内容爆炸式增长的今天,传统校对方式已经难以应对海量文本处理需求。我从业内了解到,某专业校对平台通过引入垂直领域AI技术,将校对准确率从行业平均的92%提升至98.5%,处理效率更是提高了近20倍…

作者头像 李华
网站建设 2026/9/18 8:44:52

Vue3 ref属性与TypeScript泛型实战指南

1. Vue3中的ref属性深度解析1.1 HTML元素上的ref使用详解在Vue3的Composition API中&#xff0c;ref不仅用于响应式数据声明&#xff0c;还可以直接获取DOM元素的引用。这种双重用途的设计体现了Vue3的API简洁性。让我们深入分析其工作机制&#xff1a;<template><div…

作者头像 李华
网站建设 2026/9/18 8:43:10

Windows 安装人大金仓 KingbaseES:环境自检、初始化与故障排查

1. 先想清楚&#xff1a;Windows 上跑人大金仓数据库到底适合什么场景聊 Windows 安装人大金仓数据库这件事&#xff0c;得先把定位摆正。人大金仓&#xff08;KingbaseES&#xff09;作为国产数据库里装机量比较靠前的一款&#xff0c;绝大多数生产环境是跑在 Linux 上的&…

作者头像 李华
网站建设 2026/9/18 8:42:51

GSM数字蜂窝网络架构与信令排障:从BSS/NSS到Um接口

简介&#xff1a;这是一份围绕GSM数字蜂窝移动通信系统的教学课件&#xff0c;整合了第6、第7章内容&#xff0c;面向通信工程专业学生、备考人员以及希望系统梳理移动通信基础知识的从业者。资源以PPTX格式封装&#xff0c;共1个文件&#xff0c;压缩包仅1.17MB&#xff0c;章…

作者头像 李华