从近似0基础开始FPGA开发 -- part.10 verilog-ethernet开源UDP协议栈工程学习
我不知道你有没有这种感觉:FPGA学了大半年,LED、数码管、按键、串口玩了个遍,UART收发已经能背下来了,甚至IIC、SPI也写过几轮,但心里总觉得没底。为什么?因为这些接口都是板级的东西,好歹是“看得见摸得着”的信号。可一旦要把数据从板子发到电脑、或者从电脑发到板子,涉及到网络协议,很多初学者就卡住了。我也卡了很久。
说实话,我卡的原因不是Verilog写不出来,而是根本不知道从哪里下手。以太网帧格式、IP头、UDP头、校验和、MAC地址、IP地址、ARP……这些概念每一个单独拿出来都能看懂,但要把它们串成一个能跑的硬件逻辑,完全不是一回事。直到后来我找到一个开源项目verilog-ethernet,才真正把这条路走通了。这篇就记录一下我学习这个工程、把UDP协议栈跑起来的完整过程,包括踩过的坑、看代码的顺序、以及最后怎么在板上验证通。
1. UDP协议栈到底在解决什么问题
1.1 为什么FPGA需要处理网络协议
很多做FPGA的人第一反应是:我要传数据,用串口不就行了吗?板子到PC之间一个USB转TTL就搞定了。没错,串口确实简单,但如果你要传的是高速数据流,比如ADC采样数据、图像传感器输出,或者想在PC上实时显示FPGA内部的状态波形,串口的几Mbps就完全不够看了。
千兆以太网能跑到接近线速的吞吐,实际有效载荷能到900Mbps以上,这个带宽足够应付大多数嵌入式场景。而且以太网的物理层芯片(PHY)现在价格不高,很多开发板本身就集成了RTL8211或者88E1512这样的千兆PHY,驱动起来也不算麻烦。
但问题在于:以太网不等于UDP。以太网链路层只是说“我能在这个局域网里把帧从一台设备送到另一台设备”,真正决定数据怎么封装、怎么路由、怎么被应用层识别的,是IP协议和传输层协议。UDP就是其中最常用、最适合FPGA实现的传输层协议。
1.2 UDP协议栈各层职责回顾
为了后面读代码不迷路,我先快速回顾一下这个栈里每一层各干哪些活。
链路层(以太网):维护MAC地址,负责把IP包封装成以太网帧(加上目的MAC、源MAC、类型字段),或者反过来从以太网帧里剥离出IP包。如果目标MAC是广播地址FF:FF:FF:FF:FF:FF,交换机就会把这个帧广播到所有端口——这是ARP的基础。
网络层(IP):维护IP地址,负责把UDP数据报封装成IP包(加上IP头、源IP、目的IP、协议号),计算IP头校验和,也负责解析收到的IP包。它不管数据能不能送到,只管封装、寻址和分片重组。
传输层(UDP):维护端口号,把应用层数据封装成UDP数据报(加上源端口、目的端口、长度、校验和)。UDP本身不保证可靠传输,没有确认机制、没有重传机制,但这恰恰适合FPGA——不需要维护发送缓冲区、不需要超时重传逻辑,电路规模小很多。
应用层(你的用户逻辑):只关心数据本身。在FPGA里,这一层通常就是你的硬件逻辑,通过寄存器或者FIFO接口把数据交给协议栈。
所以一个“UDP协议栈”本质上是把这三层协议在硬件里实现一遍,对外提供一个简单的业务接口,对内处理一大堆协议细节。verilog-ethernet其实就是把这件事做完了,而且做得相当彻底。
1.3 软核方案和纯硬件方案怎么选
在接触verilog-ethernet之前,我其实犹豫过要不要用软核。像MicroBlaze或者NIOS II,里面跑个lwIP,UDP栈就自动有了,上层写C代码,开发体验和MCU差不多。听起来很完美,对吧?
但我个人的经验是:如果不是特别复杂的控制逻辑,纯硬件实现UDP协议栈反而更合适。为什么?
第一是资源占用。一个MicroBlaze或者NIOS II软核,指令缓存、数据缓存、总线互联、外设IP,塞下去怎么也得占几千个LUT和一堆BRAM。而verilog-ethernet的UDP完整模块,优化后大概也就两三千个LUT的量级,省下来的资源全都可以留给你的核心业务逻辑。
第二是延迟和确定性。软核跑协议栈,最怕的是在高负载下响应不及时。硬件栈则是每个时钟周期都在处理数据,流水线一旦建立起来,吞吐是完全确定的,不会出现什么中断抢占导致丢包这种问题。
第三是调试复杂度。不带操作系统的软核工程,一旦跑飞了,想定位问题非常麻烦。纯硬件逻辑则可以用仿真把每一帧数据的流向看得清清楚楚,出了bug反而更好查。
所以我最终的选择是:硬件UDP协议栈,开源实现优先考虑verilog-ethernet。这不是说软核方案不好,而是说这个开源工程在性能和易用性上的平衡确实做到了位。
2. verilog-ethernet工程整体架构学习
2.1 仓库结构速览
verilog-ethernet这个项目在GitHub上由Alex Forencich维护,仓库结构非常清晰。顶层目录下,核心代码都放在rtl目录里。第一次进去的时候,我差点被文件名劝退——一大堆eth_、udp_、axi_开头的文件。
慢慢理下来,其实核心模块就那么几个,我按功能分组列一下:
- MAC层相关:eth_mac_10g、eth_mac_1g等,负责以太网MAC功能
- 完整UDP协议栈:udp_complete,这是把MAC、IP、ARP、UDP全整合在一个模块里的“一键方案”
- 拆开的协议组件:eth_udp_tx、eth_udp_rx、eth_ip_tx、eth_ip_rx、eth_arp、arp_cache等
- 通用接口组件:axis_fifo、eth_axis_rx、eth_axis_tx等,负责把MAC层帧和AXI-Stream数据流互相转换
这个仓库还有个好习惯:每个模块都自带一个testbench,在tb目录下。这意味着你可以直接在Vivado或者ModelSim里对单个模块做仿真,不需要先写testbench,对学习来说非常友好。
2.2 核心设计思路:拆开再组合
这个工程最值得学习的地方,是它的分层设计思路。它不是把所有协议逻辑堆在一个大模块里,而是每一层对应一个或几个模块,模块之间用标准的AXI-Stream总线连接。
链路层:eth_mac_1g负责和外部PHY芯片打交道,把GMII/RGMII接口的数据转成内部的AXI-Stream流。
网络层:eth_ip_tx和eth_ip_rx负责IP包的封装和解析,eth_arp和arp_cache负责ARP请求和响应,维护IP到MAC的映射表。
传输层:eth_udp_tx和eth_udp_rx负责UDP数据报的封装和解析。
最后,udp_complete把上面这些全部整合起来,对外只暴露两个AXI-Stream接口(用户发送、用户接收)和一组控制寄存器。
这种设计的最大好处是:每一层都是独立的,你可以单独仿真、单独替换。比如MAC层你要换成一个万兆的,网络层以上的逻辑完全不用动。我自己在学习的时候,就是按这个层次顺序逐个module读代码的,读一个、仿真一个、理解一个,最后再整体把udp_complete跑通。
2.3 为什么选udp_complete而不是自己拼装
仓库里提供了两种使用方式:一种是自己把eth_mac、eth_ip、eth_udp、eth_arp这些模块连起来,另一种是直接用udp_complete一步到位。
我第一次用的时候图省事,直接用了udp_complete。用下来发现这个选择是对的。原因很简单:udp_complete模块内部已经帮你处理好了所有中间交互的细节,包括流控信号握手、ARP缓存管理、错误帧丢弃这些烦人的边界情况。
如果你的需求是“尽快把UDP通信跑通”,不要自己拼。先把udp_complete用起来,等后面需要定制或者升级了,再拆开研究内部结构也不迟。这就像学做饭,先按成品调料包做一次,有感觉了再研究每味调料放多少。
3. 把udp_complete接入自己的工程
3.1 环境准备与文件清单
在把代码加入工程之前,建议手头准备好以下这些东西:
- 一块带千兆以太网PHY的FPGA开发板(我用的是Xilinx Artix-7系列,PHY芯片为RTL8211)
- Vivado开发环境
- 一个用于上板调试的PC端工具(Wireshark必装,用于抓包分析;再准备一个网络调试助手,用于发包回包测试)
从repo里需要拷贝的文件,如果是千兆以太网,至少需要以下几个(具体路径在rtl目录下):
- udp_complete.v
- eth_udp_tx.v、eth_udp_rx.v
- eth_ip_tx.v、eth_ip_rx.v
- eth_arp.v、arp_cache.v
- eth_eth_tx.v、eth_eth_rx.v
- eth_mac_1g.v、eth_mac_1g_fifo.v
- axis_fifo.v
- eth_axis_rx.v、eth_axis_tx.v
如果你用的PHY是RGMII接口的,可能还需要看下eth_phy_10g或相关适配模块,Xilinx的千兆以太网可以不用单独IP核,直接把GMII引脚映射到FPGA的IO上,配合RGMII转换逻辑使用。
3.2 顶层信号对接
udp_complete的顶层接口看起来复杂,其实功能分几组就清楚了。我按我的习惯重新分组了一下:
时钟复位组:clk(用户时钟,通常用PHY的GTX_CLK,125MHz)、rst(异步复位,高有效)。
PHY接口组:rgmii_rtl_* 系列,在RGMII模式下直接连到PHY芯片引脚;如果是GMII模式,用gmii_*那组。
控制与状态:local_mac、local_ip、local_port,这三个输入要配成你自己的MAC地址、IP地址和UDP端口号。还有status、debug这些状态输出,供逻辑分析仪抓取。
用户发送接口:input_axis_tdata、input_axis_tvalid、input_axis_tready、input_axis_tlast、input_axis_tkeep,这是标准的AXI-Stream从端接口,你的业务逻辑往里面喂数据就行。
用户接收接口:output_axis_tdata、output_axis_tvalid、output_axis_tready、output_axis_tlast、output_axis_tkeep,标准AXI-Stream主端接口,你从这里把收到的数据读走。
如果你之前用过Xilinx的AXI-Stream IP核,这套接口应该非常眼熟。有效信号和就绪信号握手,tlast表示一帧数据的结束,tkeep表示最后一拍的有效字节数。简单说:tvalid拉高且tready拉高,数据才算真正传输了一个节拍。
3.3 AXI-Stream流控时序要点
AXI-Stream的握手规则是这个工程里最核心的接口规则,我在这里吃过亏,多啰嗦两句。
基本规则:发送方拉高tvalid,接收方拉高tready,只有在两者同时为高的时钟上升沿,数据才被采样。
我给一个具体例子。比如你要发送一个UDP数据报,数据是64字节,那么你的发送逻辑应该这样:第一个时钟周期,把第一个32位数据放到tdata上,拉高tvalid;协议栈内部准备好接收时,会拉高tready;这个节拍数据被取走;持续这个过程,直到最后一拍,同时拉高tlast,表示帧结束。
如果接收方在中间某个周期没有准备好(tready拉低),发送方必须保持当前数据不变,tvalid也不能拉低,直到握手成功。这个规则在AXI协议里叫“valid不能等待ready”,你只能等,不能丢掉数据。很多自己写逻辑的人第一次在这里翻车,数据丢了之后整个帧结构错乱,接收端直接丢包。
3.4 与PHY芯片的对接细节
PHY芯片这边,我当时用的是RTL8211,工作在RGMII模式。需要注意的无非是以下几点:
时钟:RGMII模式下,125MHz的GTX_CLK由FPGA提供给PHY,PHY用它来同步发送数据。接收方向,PHY会提供一个125MHz的RXC时钟给FPGA。
数据线:RGMII用4根数据线,上升沿和下降沿各采样一次,分别对应低4位和高4位。所以FPGA侧要用DDR逻辑来处理,把4位变成8位。verilog-ethernet的例程里带了RGMII的收发转换逻辑,不用自己写。
复位:PHY芯片需要一个复位信号。有些板子的PHY复位脚还兼做MDIO配置,上电时序比较讲究。如果出现link up不了,大概率是复位时序或者配置没做好。
自发自收测试:很多PHY支持内部回环模式,可以通过MDIO寄存器0的bit14来打开。这在上板调试时非常有用,可以先确认FPGA到PHY的数据链路没问题,再怀疑外部网络对接问题。
3.5 地址与端口的分配建议
在配置local_mac、local_ip、local_port这三个参数时,我踩过一个很搞笑的坑。一开始我把local_ip设成了192.168.1.128,结果和公司路由器网段冲突,导致网络环境里出现了IP地址冲突,电脑都上不了网了。后来换成了192.168.2.128这种冷门网段才消停。
经验总结:FPGA板子的IP地址,尽量选一个和你局域网现有网段不冲突的子网。不要用网关地址、不要和别的设备重复。如果只是为了点对点测试,可以把PC的网口IP设成固定地址,比如192.168.2.100,板子设192.168.2.128,然后用一根网线直连,不经过交换机,这样最干净。
MAC地址也注意一下,不要用全零,不要用广播地址,随便编一个像00:11:22:33:44:55这种本地管理的地址就行。UDP端口号建议选1024以上的高位端口,避开系统保留端口。
4. 关键接口与仿真验证
4.1 仿真环境搭建
verilog-ethernet仓库的testbench写得非常好,但我个人建议先别急着跑官方的tb,因为你还不了解内部逻辑,跑了也看不懂。我的做法是:先搭一个最小仿真环境,只测udp_complete一个模块,能完整走通“发包->回环->收包”的链路。
官方tb里用的mii接口模拟器、axis接口模拟器,可以拿来实现用。它们本质上是一些任务函数,往接口上塞数据或者从接口上收数据。
仿真环境最核心的,是要用虚拟的PHY来模拟真实PHY芯片的行为。udp_complete的官方tb例子里就有这样一个虚拟ETH PHY模块,它接收FPGA发来的GMII/RGMII数据,并原封不动地发回到FPGA的接收端。这样做的目的是:不需要依赖外部网络环境,就可以验证“发送->回环->接收”的完整流程。
我把流程总结一下:
- 通过虚拟PHY建立一个回环链路
- 发送端通过AXI-Stream喂一组数据
- 观察接收端AXI-Stream输出了什么数据
- 用ModelSim或者Vivado仿真器抓内部信号,对照协议格式逐层校验
4.2 用实际抓包对照协议层
仿真跑通之后,想确认协议格式对不对,最好的办法就是抓包看。
这个抓包不是说去抓物理网线上的包,那得上板才行。在仿真里,你可以直接把udp_complete发送方向的信号导出来,在Testbench里写一个dumper任务,把eth_tx的数据按字节打印到文本文件里,做成PCAP格式再导入Wireshark。Wireshark会直接解析出以太网帧、IP头、UDP头,和你预期的数据对不对一目了然。
我自己在第一次跑通仿真的时候,就是用这种方式验证的:数据从PC端用Wireshark抓包看到的,和FPGA里dumper出来的,完全一致。
如果你不想折腾PCAP导出,还有个更笨但有效的办法:在Testbench里把发送方向的轴线数据导成hex文本,然后自己去对照UDP包格式手工解析一遍。多做几次这个过程,你对协议格式的印象会比看十遍文档都深刻。
4.3 接收路径的仿真手段
验证接收路径比发送路径麻烦一点,因为你要构造一个合法的UDP包发到FPGA里。如果自己造,要自己算IP头校验和、UDP长度、填充以太网帧头,非常痛苦。
我的办法是:在PC上直接构造一个UDP包,用Wireshark抓下来,保存成hex文件,然后在Testbench里通过$readmemh把这些数据读进去,通过虚拟PHY的接收通道灌给FPGA。这样做的好处是,PC那边生成的包一定是“真实世界”的合法包,FPGA收到的就是实际网络环境的输入。
等FPGA接收解析完成,你再检查接收端输出的数据是否符合预期。如果没问题,说明接收路径的逻辑也通了。
4.4 分析udp_complete内部状态机
仿真跑通后,我强烈建议你花时间把udp_complete内部的状态机看一遍,这是理解整个开源工程的关键步骤,比你照着顶层接口盲猜要高效得多。
dwfifo和状态机的大致数据流是这样的:
发送路径:用户数据进来,经过一个小的FIFO缓冲,进入eth_udp_tx,在这里被包上UDP头;然后送入eth_ip_tx,包上IP头;再送入eth_arp,根据目的IP查ARP缓存,找到MAC地址;最后送入eth_eth_tx,包上以太网头,通过MAC送到PHY。
接收路径是反过来的:eth_eth_rx收到以太网帧,剥掉MAC头;送到eth_ip_rx,解析IP头;再送到eth_udp_rx,解析UDP头;最后数据通过AXI-Stream交给用户逻辑。
每一层都有对应的状态机,每个状态机通常只有几个状态。你顺着发送路径看一遍状态转移条件,就能明白“一帧UDP数据在硬件里是怎么一步一步被包装出来的”。
特别是eth_udp_tx的状态机,我建议来回看三遍。它处理了数据包空闲、头部封装、数据传输、结尾校验这几个关键阶段,状态转移非常清晰,看完之后你对AXI-Stream流控的理解会提升一个档次。
4.5 官方testbench的利用方法
如果你不想自己从零搭仿真环境,直接用官方tb也可以,有一点注意事项。
官方tb(udp_complete_tb.v)里面其实已经包含了虚拟PHY、任务封装、数据比较逻辑,你不需要自己写testbench,只需要改一下目标文件路径和宏定义参数(比如IP地址、MAC),然后直接跑。跑完之后在仿真波形窗口里,能很直观地看到数据从发送接口进来,穿了一层又一层协议,最后从接收接口出来的完整过程。
这个tb的做法是:先通过发送通道发一个UDP包,同时把发出的数据存到一个队列里;由于虚拟PHY回环,FPGA会收到自己发出的数据,再走一遍接收路径并把解析结果输出;测试脚本会比对发出的数据和接收到的数据是否一致,如果一致就报告测试通过。
所以,即使你已经能自己写tb了,我也建议你先跑一遍官方tb,对照它检查一下你自己的仿真环境有没有问题。
仿真能过,只能说明逻辑行为正确,真正能不能和外界通信,还得看上板实测。
5. 上板调试经验实录
5.1 上板前的检查清单
上板之前,有四个地方值得反复确认,这是我踩过无数次坑之后总结出来的:
时钟是否正确。PHY的GTX_CLK是125MHz,必须保证FPGA的时钟约束正确。不要出现“程序里逻辑错了结果跟时序约束有关”这种鬼故事。用Vivado做时序收敛检查,跑完Implementation后看时序报告有没有violation。
引脚约束是否正确。RGMII的信号引脚、复位引脚、LED等都要仔细对板卡原理图,千万别映射错。尤其RGMII的TX和RX是有延迟要求的,一般需要在约束文件里加上input delay或output delay,否则高速信号很容易采样出错。这部分新手特别容易忽略。
PHY芯片配置是否正确。RTL8211上电时默认是千兆模式,但具体工作在GMII还是RGMII模式,取决于PHY的strap引脚电平配置。如果你的硬件设计上strap没拉对,PHY可能跑在别的模式,这时候FPGA接收不到正确数据。这种情况用示波器或者逻辑分析仪看引脚电平,能很快定位。
ARP缓存是否预热。第一次发送UDP包之前,FPGA必须知道目的MAC地址,这个信息需要通过ARP协议获取。如果ARP缓存为空,第一个数据包会被丢弃,同时发一个ARP请求出去。你可以先ping一下板子IP,让ARP缓存建立起来,再发UDP数据。
5.2 连线验证:PC与FPGA通信
上板之后,最经典的一步是用网线把FPGA开发板和电脑直连。这一步我建议这样操作:
电脑有线网卡设置为固定IP,和FPGA同一网段。
ping FPGA的IP地址,看能不能通。如果ping通,说明IP层和ARP都正常。如果ping不通,先看开发板上的link指示灯亮不亮。不亮就先去查PHY芯片的焊接和配置。
用网络调试助手(或者自己用Python写一个socket脚本)给FPGA的UDP端口发一串数据。FPGA收到后,可以通过LED或者串口把数据内容显示出来,也可以用ILA抓内部信号确认。
PC端发送UDP数据,FPGA回传数据。回传的验证方法是用Wireshark抓包,看有没有FPGA发出的UDP包。
如果PC能收到FPGA发出的UDP包,那基本可以认为协议栈的发送路径没有问题;如果还能正确解析PC发到板子的数据,接收路径也无误。
5.3 抓包工具的使用与常见判断
Wireshark是网络调试中最好用的工具,没有之一。我强烈建议你把过滤语法背下来几个常用的:udp、arp、ip.addr == 192.168.2.128这种。
在抓包的时候,有几个典型的现象需要注意:
现象一:ping时Wireshark里有ARP请求的包,但没有ARP回复。这说明FPGA收到了ARP请求但没正确回复,大概率是MAC地址寄存器配置不对,或者协议栈的ARP模块没正常工作,也可能是ARP回复的源MAC地址配错导致PC丢弃了。
现象二:ping通,但UDP数据发不出来。这种情况往往是AXI-Stream发送接口的握手有问题,或者FIFO没接对。你可以用ILA抓内部信号,看tvalid和tready有没有正确握手。
现象三:收到乱序或者错位的数据。大概率是tlast和tkeep的时序不对。比如发送逻辑在最后一拍没有拉高tlast,接收端就会一直等待,数据没法flush出去。
5.4 ILA在线调试的使用心得
上板调试和仿真不一样,很多问题只有在真实时钟频率下才暴露。Xilinx的ILA(集成逻辑分析仪)是DEBUG利器,我总结几个实用经验:
先抓发送方向的信号:input_axis_tdata、input_axis_tvalid、input_axis_tready,看你的业务逻辑有没有正确地把数据喂进来。这里最容易发现的问题,是tvalid已经拉高,但tready一直为低——说明协议栈还没准备好接收下一帧,可能是上一帧数据还在内部FIFO里没发完。
再抓PHY接口的信号:rgmii_txd、rgmii_tx_ctl,对照以太网帧格式,用ILA抓的数据和Wireshark抓到的发送数据对比,能快速定位是MAC层封帧错误,还是PHY的IO时序问题。
ILA的触发条件设置也要用心。如果发的是固定格式的数据包,可以设置触发条件为检测到某个特定的报文字节,比如以太网帧头的前导字节,这样能把抓取窗口对准帧的起始位置,看起来会更清晰。
5.5 一条实测通过的调试路径
我这里给出一条我实际用过的、验证过可行的调试路径,照着这个顺序走,可以少走很多弯路:
第一步,先用开发板自带的参考工程或者官方demo,把PHY芯片跑通,确认物理链路up。如果官方demo都ping不通,不要怀疑自己,先查硬件、查时钟、查引脚。我遇到过用错了PHY芯片的复位时序导致link一直不起来的情况,这时候调啥都没用。
第二步,在FPGA里接一个简单的常发数据源,比如计数器,每隔一段时间发一个UDP包。接好之后,PC端Wireshark看能不能收到。这个阶段不需要PC发数据给板子,只需单向验证发送路径。
第三步,PC端用网络调试助手向板子发数据,板子收到后触发一个LED翻转。这个阶段验证接收路径。
第四步,双向打通之后,再挂上你的真实业务逻辑。比如把ADC采样数据打包成UDP帧发到PC,或者在PC端下发参数控制FPGA里的寄存器。到这一步,UDP协议栈就真正变成你项目的基础设施了,和串口、SPI一样属于“默认可用”的模块。
6. ARP缓存与IP层处理的深入理解
6.1 ARP协议在FPGA里的状态机
很多初次接触的人会把ARP当成一个可有可无的东西,直到自己调试时发现第一个包永远发不出去、或者PC端ping不通板子,才开始正视它。
说穿了,ARP做的事就是“已知目的IP地址,查目的MAC地址”。以太网帧里填的是目的MAC地址,而不是IP地址,因为链路层只认MAC。交换机转发帧的时候也是看目的MAC,IP地址对它没有意义。
在FPGA里,ARP的处理逻辑是一个典型的状态机,大致经历这几个状态:
收到ARP请求,查看请求的目标IP是不是自己。如果是,则把请求者的IP和MAC记录下来,同时构造一个ARP回复应答。这个回复里填上自己的MAC地址和IP,以及对方的MAC地址和IP。
发出ARP请求,目的是获取目标IP对应的MAC。请求发出后,等待对应的ARP回复,超时后重发。verilog-ethernet的arp_cache模块会维护一个缓存表,记录已经解析出来的IP到MAC映射,并自动处理过期更新。
重要的一点:ARP模块只有在协议栈内部有数据要发送、但查不到目的MAC时,才会自动发出ARP请求。这个行为是自动的,不需要用户逻辑参与。用户逻辑只需要把UDP数据交给协议栈,如果目的MAC不在缓存表里,协议栈会先把数据包挂起,发完ARP请求并收到回复后,再把数据包发出去。如果收不到回复,数据包就一直在缓存里,直到超时被丢弃。
6.2 IP头校验和的硬件实现方式
IP头校验和的计算方法,是UDP协议栈里最容易让新手困惑的地方之一。它不是CRC,而是一个“反码求和”的过程。
计算方法是:把IP头按16位一组划分,把这些16位数值的反码求和,结果再加回去。写成伪代码就是:
sum = 0 for each 16-bit word in IP header: sum += word if sum carries over 16 bits: sum = (sum & 0xFFFF) + 1 checksum = ~sum
在硬件里,这个逻辑可以用一个16位的累加器和进位检测电路来实现,每次往FIFO里写入一个16位数据时,执行一次累加。verilog-ethernet里实现得比较巧妙,它把校验和的计算嵌在了数据包发往FIFO的过程中,发送完毕的同时校验和也就算出来了。
UDP校验和与IP头校验和有个重要区别:UDP校验和不仅覆盖UDP头,还覆盖了整个UDP数据部分,甚至还包括一个“伪IP头”(源IP、目的IP、协议号、UDP长度)。伪IP头不参与实际的网络传输,它只是参与校验和计算,用来防止IP地址错误。这一点在调试数据校验错误时非常容易忽略,如果你自己写一个UDP发送逻辑,校验和总是算不对,九成是伪头部没有参与计算。
6.3 校验和错误的典型症状
接收端校验和出错时,最常见的现象是:Wireshark里能抓到包,但显示“校验和错误”的红色警告。如果你的FPGA接收端开启了校验和过滤,这种情况下包会被直接丢弃,用户逻辑根本收不到数据。
这个问题的排查思路是:先用Wireshark抓包,如果在PC发往FPGA的方向出现校验和错误,那多半是PC端软件发包工具的问题,或者是你构造包时校验和没算对,别急着怀疑FPGA。如果是FPGA发往PC的方向出错,再用ILA抓FPGA发送方向的数据,对照协议格式逐字节检查,看是哪一个字段算错了。
还有个容易忽略的细节:很多PC网卡在发送UDP包时,会开启硬件校验和卸载(checksum offload),由网卡自动计算校验和并填充。这时候你用软件发包工具构造的校验和字段会被网卡覆盖重算,属于正常现象。但在FPGA里,可没有这个“免费午餐”,校验和必须自己算对。
7. 常见问题与排查技巧实录
7.1 链路层:link灯不亮
症状:板子和电脑网线连接后,开发板的以太网指示灯不亮。
排查思路:
先量PHY芯片的供电、时钟。有些PHY芯片需要的时钟频率不对,链路就建立不起来。
检查PHY的复位信号。很多板子的PHY复位脚被FPGA控制,如果你在FPGA逻辑里把它拉低了或者时序不对,PHY就一直处于复位状态。解决方法是检查硬件原理图,确保复位信号在初始化流程里按正确时序释放。
检查PHY芯片的模式配置引脚(strap pin)。有些PHY通过外部电阻配置工作模式,比如强制千兆、强制百兆、自动协商等。如果配置成了错误的模式,link就起不来。
在软件层面,可以通过MDIO接口读取PHY芯片的状态寄存器,查看当前链路状态和协商结果。MDIO接口本身也是学习FPGA的不错素材,值得花点时间研究。
7.2 网络层:ping不通
症状:PC端ping板子IP,没有任何回复。
排查思路:
先用Wireshark抓包,看PC发出的ARP请求有没有到达交换机(如果PC和板子直连,看抓包结果里有没有ARP重传)。如果没有ARP重传,说明ARP请求可能没有被交换机转发出去了,板子根本没收到。
如果ARP请求已经到达板子侧,但PC没收到ARP回复,那问题出在FPGA的ARP应答逻辑上。测试方法:在板子上用ILA抓eth_arp模块的输入输出,看是否收到了ARP请求,以及是否输出对应的ARP回复。
如果ping通了,但UDP不通,那问题基本确定在UDP发送或接收逻辑上,按前面说的方法去查。
7.3 传输层:数据发不出或收不到
症状:ping没问题,但PC发到FPGA的UDP数据收不到,或者FPGA发到PC的UDP数据看不到。
排查思路:
发送方向:用ILA抓input_axis接口,确认你的业务逻辑确实把数据交给了协议栈。如果在ILA里看到tvalid拉高、tready始终为低,说明协议栈内部FIFO满了或者还在处理上一帧数据。这个问题的概率最大。解决方法是在业务逻辑里做握手机制,确保数据在tready为高时才被送入。
接收方向:用ILA抓output_axis接口,确认接收端有没有数据输出。如果没有,再往上游看eth_udp_rx模块的状态机,看它卡在哪个状态。逐步往前排查,最终能定位到是PHY层没收到数据,还是MAC层丢弃了帧,还是IP/UDP头部解析没通过。
一个经验是:先检查目的端口号。很多初学者配置local_port的时候没注意字节序,导致PC发的数据端口号和FPGA配置的端口号不一致。UDP端口号在帧里是网络字节序(大端),如果你的配置逻辑没有做endian转换,极容易出错。
7.4 数据内容错乱或丢字节
症状:PC能收到FPGA发来的UDP数据,但数据内容对不上,或者字节顺序和预期不一致。
排查思路:
优先检查AXI-Stream接口的tdata位宽。udp_complete默认的AXI-Stream接口位宽是8字节(64bit),如果你的业务逻辑是按32bit的节奏发送数据,就要在接入前做一些位宽转换,比如用axis_adapter或者自己写一个简单的宽度变换模块。
检查字节顺序。以太网是网络字节序,也就是大端序。如果你的数据在FPGA内部是小端序,发出来可能就颠倒了。这个问题在PC上做大小端转换就行,但如果你要让FPGA按特定的数据格式发数据,就得在逻辑里自己处理。
检查tkeep信号。在多字节AXI-Stream接口中,每一拍传输的有效字节数由tkeep指定。如果你的发送逻辑没有正确设置tkeep,接收端可能把无效字节也当成有效数据接收了。
7.5 上板无法复现仿真的问题
症状:仿真全部通过,一上板就完全不工作,或者时好时坏。
排查思路:
这类问题多半是时序问题。仿真里默认都是零延迟,上板后所有信号都有实际延迟,如果时序约束没做对,跑在125MHz就可能出现采错信号的状况。
优先检查时钟约束:GTX_CLK是否正确约束成生成时钟。CLK是否正确地约束为主时钟。
检查RGMII的延迟。RGMII的采样时钟和数据线之间有严格的延时关系,需要在约束文件里正确设置input delay / output delay。这个不做的话,高速信号大概率采错。
上板之后不要直接跑业务逻辑,先把最简单的LED翻转代码烧进去,验证时钟、复位、引脚都是对的,再一步一步加复杂逻辑。这个小习惯能帮你省下大量的调试时间。
7.6 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| Link灯不亮 | PHY复位、时钟、模式配置错误 | 量硬件信号,读MDIO寄存器 |
| ping不通 | ARP回复逻辑有问题 | 抓ARP请求和回复 |
| ping通但UDP收不到 | 端口号配置错误或校验和错误 | 检查local_port、抓取接收端状态机 |
| UDP发不出去 | AXI-Stream握手失败 | ILA抓tvalid/tready信号 |
| 数据字节错乱 | 字节序、tkeep设置错误 | 对照Wireshark逐字节分析 |
| 上板后功能异常 | 时序约束缺失、时钟问题 | 检查约束文件、时序报告 |
7.7 调试经验小结
最后分享几个我在这个项目上摸爬滚打总结出来的心得,希望对你有帮助。
第一个心得是“永远先怀疑最简单的环节”。很多看起来像协议栈天大的问题,最后定位出来往往是时钟没给对、复位没释放、引脚映射错了。如果上板现象和仿真结果差得很远,90%是这类低级问题。所以我会把“先确认时钟复位引脚,再纠结协议逻辑”作为固定流程。
第二个心得是“日志和抓包是最好的老师”。在调试这个UDP栈的过程中,Wireshark帮了大忙。但Wireshark只能看到板子和PC之间的实际网络包,看不到FPGA内部的行为。所以ILA和Wireshark配合使用,一个看内部,一个看外部,对照着分析,能很快定位出问题。去年我在调试一个千兆图像传输项目时,收到的帧每隔几帧就丢一包,用ILA抓到tready周期性拉低,才发现是AXI-Stream的FIFO深度不够,导致反压,后来加了深度解决了。
第三个心得是“先跑通,再优化”。我见过不少初学者走上另一个极端,从一开始就研究怎么把BRAM使用降到最少、怎么把延迟降到最低。这种心态可以理解,但学习阶段完全没必要。先把协议栈跑起来,哪怕资源占用大一点、时序宽松一点,等稳定工作了再回头优化也不迟。如果一上来就给自己设定各种约束,学习成本会陡增好几倍。
8. 后续还能往哪些方向扩展
8.1 从UDP到TCP:硬件协议的进阶思考
UDP学会了之后,我猜很多人会和我一样好奇:TCP能不能也这样搞?
TCP和UDP有本质区别。TCP的可靠传输依赖序号确认、超时重传、滑动窗口等机制,这些机制在软件里是几行代码的事,在硬件里就是巨大的状态机和大量的存储资源。verilog-ethernet这个仓库不直接提供TCP协议栈,只有一些支持多连接管理的TCP封装缓存模块。我自己的感受是:FPGA里做TCP,适合的是TCP卸载引擎(TOE)这类高端应用,普通项目不需要碰它。UDP做到位,配合丢包重传机制,已经能解决绝大多数实时传输需求了。
8.2 从千兆到万兆:接口速率升级
如果你用的是万兆PHY和FPGA,比如Xilinx的10G Ethernet Subsystem,verilog-ethernet同样有对应的模块可以移植。核心的地方是把MAC层换成eth_mac_10g,上层逻辑基本可以复用。
不过万兆和千兆有个非常大的差异:在万兆速率下,数据是一个钟周期64位打底,甚至用256位的总线来搬运数据,这在FPGA内部会消耗巨大的逻辑资源。所以如果不做流式大数据传输,没必要上万兆。千兆UDP已经能覆盖绝大多数嵌入式场景了。
8.3 把UDP模块做成自己的IP
等你把verilog-ethernet的代码读透了,我推荐你做一件事:把这些模块封装成自己的IP核,集成到公司的公共IP库或者自己的工程模板里。封装成IP后,可以在Vivado的Block Design里拖拽使用,下次做项目就不用重复去改代码了。
封装时建议把AXI-Stream接口用标准的AXI4-Stream协议来定义,这样方便和Xilinx的DMA等IP直接连接,复用性会强很多。
8.4 在真实项目中如何模块化
我自己在后续的项目里,普遍采用这样一种模式:顶层用Block Design把MicroBlaze、DDR控制器、AXI互联总线、以及udp_complete的IP封装连在一起;MicroBlaze负责控制面逻辑(比如解析PC下发的一些简单配置命令),FPGA侧的逻辑通过AXI-Stream高速发数据。
这样做的好处是分工清晰:需要灵活应对的复杂流程交给CPU处理,需要高吞吐、低延迟的数据通道由硬件协议栈来承担。万一哪天要把协议栈从千兆换成万兆,只需要替换MAC层相关模块,上层和CPU侧的控制逻辑基本不用动。
每次我给别人讲FPGA网络开发,总喜欢用“盖房子”来打比方:串口是走入户门,PCIE是搬货的货车,而UDP协议栈则是给你装了一套正规的物流管道。刚开始你可能觉得这套管道又重又麻烦,但真正跑起来之后,你会发现它能省下的力气远比安装它的成本要高。
verilog-ethernet这个开源工程,是我认为FPGA学习路上最值得精读的项目之一。它的价值不只是给你一个能用的UDP协议栈,更是给你一份“硬件工程师如何做协议处理”的活教材。你把它啃下来之后,再去接触其他总线协议、高速接口、网络卸载引擎,都会觉得顺很多。
最后送大家一句话:代码可以抄,但总线时序和状态机设计思路必须自己吃透。你把这套栈里每个握手信号、每个状态转移都看懂了,才算是真正会了。