从第一次抢到一片 FPGA 开发板,到前几篇能自己把流水灯、串口收发、简单状态机跑起来,这个过程大多数人都走得挺顺。可一旦到了“用 FPGA 做网络通信”这一步,很多人会突然卡住:网络上资料不少,但要么是教你调 Xilinx 的 IP 核,要么就是贴一段看着很玄的 Verilog,没人告诉你整条链路是怎么串起来的。我自己当初就是在这种状态下,翻到了 Alex Forencich 的开源项目 verilog-ethernet,用它把 UDP 从仿真跑到板卡,才算是把“FPGA 上的网络”这件事彻底想通了。
这篇 part.10 就是要把我接触这个开源协议栈工程时的完整过程拆开来讲。它不是一份简单的 README 翻译,而是从模块划分、数据流向、仿真验证到实际抓包的一条学习路径。如果你现在对 UDP 的理解只停留在“无连接、不可靠、头比较短”这个层面,同时手里又有一块差不多的 FPGA 板子,那这篇文章应该能帮你少走不少弯路。
1. 先搞清楚这个开源工程到底给了你什么
1.1 模块地图:别急着看代码,先看目录
verilog-ethernet 这个工程和很多开源 Verilog 项目一样,把功能拆得特别碎。第一次打开仓库目录时,很容易在几十个.v文件里迷失方向。我建议你先别管细节,先按功能把这几个核心模块看明白:
| 模块/文件 | 作用 | 难度 |
|---|---|---|
eth_mac_1g/eth_mac_10g | 以太网 MAC 层,负责处理帧间隙、前导码、CRC 校验 | 中 |
eth_axis_rx/eth_axis_tx | MAC 层和 AXI-Stream 接口之间的桥接 | 中 |
arp_cache+arp_eth_rx/tx | ARP 缓存表、ARP 请求/应答帧处理 | 中偏高 |
udp_ip_rx/udp_ip_tx | UDP/IP 协议的接收和发送核心 | 高 |
udp_checksum_gen/udp_checksum_calc | UDP 校验和生成与校验 | 中 |
axis_fifo/eth_axis_fifo | 跨时钟域、数据缓冲 | 低(但绕不开) |
如果你用的是 1G 以太网,那重点就是eth_mac_1g、udp_ip_tx、udp_ip_rx、arp_cache这四个模块。它们之间的连接关系大概是:上层应用通过 AXI-Stream 把用户数据交给udp_ip_tx,它负责拼出 UDP 头和 IP 头,再把整包数据交给eth_axis_tx,后者补上以太网头和 CRC,最终通过eth_mac_1g发出去。收包则是完全相反的一条链路。
这个结构第一次看会觉得复杂,但它其实是一个非常标准的“协议分层”思路,和你学 TCP/IP 时接触的分层模型完全一致。FPGA 里的分层不是说分软件的任务,而是把每一层用硬件逻辑单独实现,这也是这个工程最适合学习的地方:你能在代码里同时看到 ARP、IP、UDP 三种协议各自的处理逻辑,而不是被一大堆操作系统抽象层包着。
1.2 阅读顺序:按数据流读,不要按文件名读
有朋友问我,是不是要从eth_mac_1g开始读?我个人的体验是,不要从 MAC 层开始。MAC 层牵扯到太多的时序细节,尤其是 CRC 和帧间隙处理,对初学者来说很容易劝退。
我的建议是先读 UDP 发送方向:
- 先看
udp_ip_tx,理解“用户数据怎么变成 UDP/IP 报文”; - 再看
arp_cache和arp_eth_tx,理解“怎么知道目的 MAC 地址”; - 最后看
eth_axis_tx和eth_mac_1g,理解“报文怎么变成比特流发到线上”。
这其实也是你在实际开发时最容易遇到 bug 的顺序:先是报文内容不对,再是目的地址不对,最后才是物理接口不通。
1.3 学习前你需要掌握的三个基础
严格说是两个半。第一个必须是 AXI-Stream 总线协议,这是 verilog-ethernet 通用的数据接口。只要tvalid、tready、tlast、tdata这四个信号搞不清楚,后面所有代码读起来都是天书。第二个是抓包工具的基本使用,Wireshark 就够,不需要学太多,但必须能看懂一个 UDP 报文在以太网帧里的排列方式。剩下的半个是 Verilog 的 Testbench 基础,包括initial、#延时、$display、波形查看这些,因为我们要靠仿真来理解行为。
如果你这三个基础还没到位,先不要碰协议栈,回去把 AXI-Stream 握手搞明白。这不是浪费时间,因为在 FPGA 里做网络,本质上就是“把一串数据按协议要求,从一个接口搬到另一个接口”,而 AXI-Stream 就是那个“搬家公司”的通用规则。
2. ARP 与寻路:看起来最不起眼,却最容易翻车
2.1 为什么发 UDP 之前要先搞定 ARP
很多刚接触协议栈的人都会有个疑问:UDP 不是只需要 IP 地址和端口吗?为什么 FPGA 内部还要维护一个 ARP 缓存?
道理其实很简单。以太网是链路层协议,数据在局域网内传输时,网卡和交换机只认 MAC 地址。IP 地址是为了互联网层设计的逻辑地址,到了真正往网线上发数据那一刻,必须把目的 IP“翻译”成目的 MAC。这个翻译过程就是 ARP。你的 PC 通过操作系统自带协议栈早就维护好了 ARP 表,你感知不到,但 FPGA 上没有任何操作系统帮你做这件事,所以这个表得用硬件逻辑自己维护。
verilog-ethernet 里arp_cache做的事情就是:收到对端发来的 ARP 应答时,把 IP 和 MAC 的对应关系存进一张表;当需要发送 UDP 报文时,先从表里查目的 IP 对应的 MAC,如果没有,则发起一个 ARP 请求帧,等对方回复后再把真正要发的 UDP 数据送出去。
2.2 一个 ARP 请求帧的长相
学习硬件协议栈的一个好处是,你必须把一个帧的每一个字节都落清楚。ARP 请求帧的以太网头部里,目的 MAC 是全FF:FF:FF:FF:FF:FF,源 MAC 是自己的 MAC,EtherType 是0x0806。接着是 ARP 报文,其中硬件类型是0x0001(以太网),协议类型是0x0800(IPv4),硬件地址长度是 6,协议地址长度是 4,操作码0x0001表示请求。
这些字段在arp_eth_tx模块里会一个字节一个字节地拼出来,绝大多数情况下并不需要你手动去构造,但理解它有两个实际好处:一是调试时抓包能看懂每个字节的含义,二是如果你想让 FPGA 主动告诉对方“我在这里”,有时需要手动实现 gratuitous ARP 或者提前触发一次 ARP 请求。
2.3 ARP 表项超时,是等你遇到时才能体会的坑
arp_cache内部的表项是有寿命的,不是永久有效。默认情况下,表项会在一定时间后失效。这在 PC 上问题不大,因为操作系统会定期刷新。但在 FPGA 上,如果你的应用连续发送 UDP 但中间隔了很久,ARP 表项可能已经过期,下一次发送时协议栈会先重新发起 ARP 请求,这就会增加几十毫秒到几百毫秒不等的延迟。
更常见的坑是:你的 PC 端软件设置了比较短的发送间隔,但 FPGA 端应用层没有做 ARP 预请求,导致每次发 UDP 前都要等待 ARP 应答。我调某个演示工程时,就遇到过 PC 端每 500ms 发一次查询、FPGA 每 500ms 才回一个 UDP,但 PC 端总是显示“超时”。后来抓包才发现,FPGA 发出的 ARP 请求 PC 收到了,但 PC 的 ARP 应答在 FPGA 这边被当成无效表项丢弃了,因为arp_cache的例化参数里超时设置太短。把超时配大之后,问题立刻消失。
所以当你看到“PC 收不到 FPGA 的 UDP 数据”时,第一件事不应该是怀疑 UDP 模块本身,而是先抓包看有没有 ARP 请求、有没有 ARP 应答,这一步能排除掉一半的问题。
3. 首选通过仿真把协议学明白:跑 testbench 真的不难
3.1 拿到仓库后,我建议你第一个跑起来的仿真
很多新手拿到开源工程,第一反应是直接上板子。我强烈不建议这样做。verilog-ethernet 仓库里的模块大部分都带了配套的 testbench,比如tb_udp_ip_tx.v、tb_udp_ip_rx.v,运行仿真并不需要真实网卡和 PHY 芯片,只需要仿真工具。
以tb_udp_ip_tx为例,它会在仿真环境里生成一个完整的发送流程:产生一个带tlast的用户数据包,送入udp_ip_tx,同时模拟 ARP 表已经命中。你不需要改动任何 RTL 代码,直接跑一遍仿真就能看到 UDP 报文在输出端的波形。这是理解协议栈行为最快速的方式,也远比读代码直观。
具体操作上,如果你用 Vivado,可以直接把相关源文件和 testbench 添加进工程,然后在 Simulation 里跑行为仿真;如果你喜欢命令行,用 iverilog 也很方便。iverilog 加 GTKWave 的组合对学习足够用了,跑一个 testbench 基本就是几条命令的事情。
3.2 仿真时重点盯住 AXI-Stream 的握手细节
打开仿真波形后,我建议你第一个关注点放在s_axis_udp_tvalid和s_axis_udp_tready这两个信号上。AXI-Stream 的规则是:只有当tvalid和tready同时为高时,tdata上的数据才是有效传输。发送端拉高tvalid后必须等到接收端拉高tready才能开始传;接收端如果还没准备好,就可以一直不拉高tready。
在实际仿真波形里,你会看到udp_ip_tx内部拿到用户数据后,并不是立刻就能输出,它会先构造 IP 头和 UDP 头,等这些头部的准备工作完成,才拉高输出端的m_axis_ip_tvalid。这个过程中数据可能短暂地“卡住”,也就是tready被拉低。理解这个握手机制是读懂后续所有模块的基础,因为在数据通路上,几乎每个模块之间都靠 AXI-Stream 连接。
还有tlast,它标志一个包的结束。对 UDP 来说,一个 UDP 报文对应一组tvalid/tready握手序列,最后一个 beat 必须拉高tlast。如果应用层忘了拉tlast,协议栈会一直等你“还有后续数据”,永远拼不成一个完整报文,这在调试时也是一个非常隐蔽的坑。
3.3 通过仿真验证校验和:一次亲手算明白
UDP 的校验和计算是初学者最容易头晕的地方。它不像 IP 头校验和那样只算头部,而是要对“伪首部 + UDP 头 + 数据”整体做计算。所谓伪首部,源 IP、目的 IP、协议号、UDP 长度这五个字段拼出来的 12 个字节,它并不真正出现在网络上,只是用来参与校验计算。
verilog-ethernet 里有独立的校验和模块,发送方向有udp_checksum_gen,接收方向有udp_checksum_calc。你在仿真里可以看到它把数据分成 16 位一组,做二进制反码求和,再把结果取反得到校验和。仿真波形里虽然不好直接看到这个求和过程,但你可以通过$display打印中间结果来辅助理解。
这里我要提醒一个偷懒的办法:UDP 校验和在 IPv4 里是可选的,全 0 表示“没有计算”。很多简单的硬件 UDP 工程会直接把 UDP 校验和设为 0,这时候 Wireshark 抓到包会提示“校验和为 0”或者“需要 hardware offload”。这种做法在局域网内通常能正常工作,但不规范。verilog-ethernet 提供的模块是完整支持校验和计算的,我不建议你为了省事去关掉它,把校验和跑通才是真正理解 UDP。
4. 跑到板卡上:一款看得见抓得着的 UDP 回环 demo
4.1 硬件顶层的最简结构
仿真通过后,接下来就是把协议栈放到真实板卡上。我先说说最简的硬件结构,不涉及具体型号和厂商,但思路是通用的:
- PHY 芯片提供物理层接口,通过 RGMII(或 GMII/SGMII)与 FPGA 相连;
- FPGA 内部,
eth_mac_1g负责 MAC 层,把 RGMII 接口和内部数据通路连接起来; eth_axis_rx/eth_axis_tx把 MAC 层数据转成 AXI-Stream 格式;- UDP 协议栈核心与数据 FIFO 配合,完成 IP/UDP 解包;
- 你自己的应用逻辑(比如从拨码开关读数、从 UART 获得的数据)通过 FIFO 进入协议栈。
这个结构听上去模块不少,但在 Vivado 里用 Block Design 连接其实很简单。放一个eth_mac_1g软核、一个eth_axis_rx、一个eth_axis_tx、一个udp_ip_rx、一个udp_ip_tx、一个arp_cache,再加上几个 FIFO 转发跨时钟域的数据,就构成了一个最小的 UDP 通信链路。
4.2 跨时钟域,是初学者最容易漏掉的问题
协议栈里有一个不那么显眼、但几乎必踩的模块:FIFO。为什么需要 FIFO?因为协议栈内部有多个时钟域。最典型的是,MAC 层跑的是 125MHz 的 GTX 时钟(千兆以太网),而你的应用逻辑可能跑在 100MHz 或者更低的时钟下。跨时钟域不能直接用寄存器打拍解决,必须借助异步 FIFO。
verilog-ethernet 仓库里的axis_fifo就是一个通用的 AXI-Stream FIFO,你可以把它放在应用逻辑和 UDP 协议栈之间。它的配置项里有数据位宽、地址位宽(决定深度)、以及是否允许 FWFT(First Word Fall Through)。在实际工程里,我习惯在发送方向和接收方向各放一个 FIFO,这样即使上下游速率不匹配,也不会丢数据。
这里有个细节值得注意:FIFO 的复位信号必须正确释放,否则会漏掉第一个或者最后一个数据。不少人在仿真时没发现,上板后偶尔出现丢包,就是这个原因。建议在顶层设计里,把 FIFO 的复位做一个异步复位同步释放处理。
4.3 PC 端验证:用 Wireshark 加 Python 双保险
硬件通路搭好以后,验证最简单的方式是让 FPGA 主动周期性地发一个固定 UDP 报文,PC 端用 Wireshark 抓包。第一次看到自己的 FPGA 发出来的报文被 Wireshark 解析出来,那种感觉是仿真完全无法替代的。
Wireshark 里你会看到典型的输出:
Frame: 74 bytes on wire (592 bits) Ethernet II, Src: aa:bb:cc:dd:ee:ff, Dst: 11:22:33:44:55:66 Internet Protocol Version 4, Src: 192.168.1.10, Dst: 192.168.1.20 User Datagram Protocol, Src Port: 12345, Dst Port: 54321如果你愿意用 Python 写个接收脚本,还能顺便验证数据内容是不是你预期的。发送端如果是计数器,那接收端收到的数据应该是每次递增的序列。如果发现数据对不上,可以先用简单的固定数(比如0xAA)发送,验证通路没问题后再改成计数序列。
很多人到这一步会觉得“已经成功了”,但我想多说一句:这只是打通了单方向。更常见也更有价值的是做回环测试,就是 FPGA 收到 PC 发来的 UDP 报文后,原封不动地再发回去。PC 端发什么就收什么,这样既能验证收方向也能验证发方向。回环测试建议用固定的递增序列来做,因为一旦数据错位,你能立刻从递增序列的断裂处判断问题出在哪。
5. 实测里我踩过的几个坑:抓包、校验和、时序
5.1 抓包工具看不到包:先分层排查
“PC 抓不到 FPGA 发的包”是所有人都会经历的第一道坎。我的排查顺序基本是固定的,分享给你参考:
- 看 PHY 是否有 Link 状态,也就是 PHY 芯片的链接指示 LED 或者通过 MDIO 读状态寄存器。没有 Link,说明物理层就没通,后面都是空谈;
- 用信号分析仪或者逻辑分析仪看 RGMII 引脚上有没有数据活动。没有活动,说明 MAC 侧就没发出来,问题在 FPGA 内部;
- 在协议栈内部加载几个观测点,比如把
udp_ip_tx的m_axis_ip_tvalid引到一个 GPIO 上,看它有没有拉高。一直没拉高,说明应用数据根本没送进来; - 最后再怀疑协议栈配置,比如 IP 地址、MAC 地址是不是配对。
这个顺序的核心思想是“从物理层到应用层逐级排查”,不要一上来就改代码。网络问题用排除法定位是最快的。
5.2 校验和与总长度字段:两个最容易写错的地方
在校验和工作正常的情况下,仍然存在一个常见问题:IP 头里的总长度字段和 UDP 头里的长度字段写错。这两个字段都是 16 位,总长度是 IP 头加 UDP 头加数据的总字节数,UDP 长度是 UDP 头加数据的字节数。如果你的应用数据长度是固定的,这两个字段还可以在模块内部自动算出,但如果你在应用层手动拼过这个模块,就很容易把 UDP 长度写成包含 IP 头的值,导致 Wireshark 里显示“截断的 UDP 数据包”。
这种 bug 在仿真里其实很不好发现,因为仿真工具通常不检查长度字段,哪怕你写错了它也能通过。只有到 Wireshark 里,协议分析器会严格按长度字段拆包,才能看出问题。所以我建议你在仿真通过后,直接把抓包作为验收标准,把所有字段的十六进制对照着 Wireshark 的解析结果核对一遍。
5.3 RGMII 时序和复位:看起来是玄学,其实是规则
如果你的 PHY 用的 RGMII 接口,那还会遇到另一个经典问题:时钟相位。RGMII 是双沿采样,时钟的上升沿和下降沿各传 4 位数据。为了保证时序正确,MAC 输出的时钟和 PHY 输出的时钟之间可能需要一定的相位偏移。不同 PHY 的规格各有差别,通常 PHY 芯片会有 RXC 延迟控制寄存器或者 FPGA 内部的 IODELAY 可以调节。
如果你在硬件上发现“Link 是好的,但一个包都收不到”,大概率就是 RGMII 的时序问题。我调过的板子里,有一个就是必须把 PHY 的 RX clock 做 2ns 延迟才能稳定工作。这个参数不是写死在代码里的,通常需要实测微调。
另外就是复位。FPGA 复位策略里,亚稳态是个老生常谈的问题,在协议栈里尤为明显。因为协议栈内部有多个模块,互相之间用 AXI-Stream 连接,如果所有模块用同一个异步复位,但复位释放的时刻不一致,就可能出现某个模块已经开始处理数据,而它下游的模块还在复位状态。最直观的现象是头几个包丢失或者偶尔出现坏包。我现在的习惯是用一个同步复位器,把所有模块的复位统一成同步复位,并且保证释放时钟沿一致,这个问题就基本消失了。
6. 零基础上手协议栈,我的动作清单
最后整理一下我走完这个流程后的心得。如果你也是近乎零基础开始,我建议你严格按这个顺序来,不要跳步:
- 先把 AXI-Stream 握手练熟,这个没得商量;
- 用 iverilog 或 Vivado 跑通
tb_udp_ip_tx,在波形上看着数据从应用侧流到 MAC 侧; - 打开 Wireshark,对着仿真波形里的报文,自己画一张“以太网头 + IP 头 + UDP 头 + 负载”的字节图,理解每个字段的来源;
- 用开发板搭一个最小系统,先固定发一个 UDP 包,通过 Wireshark 验证字段正确;
- 再做回环测试,验证收方向;
- 最后再做 ARP 请求实测:拔掉网线再插上,看看 FPGA 是怎么发现 ARP 表项失效并发起新请求的。
这套流程走完之后,你对 FPGA 上做网络通信的理解就不再是“调用某个 IP 核”,而是真正有了“数据链路是分层构建”的工程直觉。接下来如果想继续深挖,可以去看tcp_ip_rx/tx模块,TCP 的状态机和重传逻辑又会是另一层复杂度,但有了 UDP 协议栈的底子,你已经能读进去那些代码了。
我现在回头看,当初选择 verilog-ethernet 当学习材料,最大的收获其实不是“会调 UDP”这件事,而是通过读源码形成了一套硬件协议栈的阅读方法:先看接口,再看时序,然后追数据流,最后才抠细节。这套方法放在任何通信协议模块上都能复用。写到这,我的 part.10 也该收尾了,下一块可以选择的方向还挺多,可能是把 UDP 收发带宽压到极限,也可能是往 TCP 方向推进,看自己项目需求吧。