从Verilog到实战:深入开源UDP协议栈,掌握FPGA以太网设计精髓

从Verilog到实战:深入开源UDP协议栈,掌握FPGA以太网设计精髓 从第1篇写到现在这个系列总算到第10篇了。前面几篇把时序约束、跨时钟域、AXI总线、FIFO这些FPGA开发的“地基”过了一遍这一篇终于可以碰一个像样的大工程——verilog-ethernet开源UDP协议栈。这个工程在FPGA开源圈子里算名气不小的一套代码作者用非常清晰的Verilog风格实现了从以太网MAC层到UDP层的完整收发通路代码层次分明、注释齐全、自带testbench对学完基础语法、想进阶真实项目的学习者来说是一份很理想的实战教材。这篇文章就记录我啃这个工程的完整思路它讲的是什么、内部怎么分工、我从哪几个模块开始看、以及最后怎么把它跑在自己板子上的全过程。1. 为什么选verilog-ethernet当进阶教材1.1 我挑开源工程的标准很多朋友问我FPGA基础学完之后下一步看什么。我的答案通常是别急着看各种“XX天入门”的板级教程先去找一个能被真实产品使用的开源IP核从源码开始读。原因很简单教科书里的示例代码为了讲语法往往刻意回避了工程化的复杂性——跨时钟如何处理、反压如何传递、模块边界如何切分这些只有真实项目才会逼你去面对。选择verilog-ethernet我当初有四个硬性标准。第一代码风格要一致不能东一块西一块读起来像拼盘这个工程全部模块坚持同样的状态机写法和命名习惯从顶层看到底层基本不会产生“这是两个人写的”的撕裂感。第二接口要标准所有内部数据通路都走AXI-Stream这对已经熟悉AXI总线的人来说非常友好同时也能把“总线协议到底用来干嘛”这个问题彻底搞清楚。第三必须有仿真环境不能只给RTL让人干瞪眼这个工程每个模块都有配套testbench跑仿真时能看到完整波形极大降低了理解成本。第四可移植性好工程不绑定某一家厂商的专用原语大部分逻辑是纯Verilog可综合代码放在Vivado、Quartus或者开源工具链里都能用。对照这四条verilog-ethernet全部满足。而且它覆盖的知识面恰好是网络通信设备里最常见的那一摊MAC、ARP、IP、UDP全部在FPGA内部用逻辑实现。学完这个再去碰PCIE、DDR这类更复杂的高速接口至少在“大模块协作”这件事上不会心虚。1.2 整个仓库的模块地图刚把仓库clone下来时rtl目录下几十个.v文件确实让人有点懵。我建议不要按文件名顺序读而是先抓主干理解整个工程只有一条“从MAC收数据”和一条“往MAC发数据”的主通路。往简单了说这个工程可以看成三层结构。第一层是PHY和MAC的适配层负责和板子上的以太网PHY芯片打交道完成串行数据到字节流的转换、CRC校验、流控。对应代码里是eth_mac_1g、eth_phy_1g、eth_axis_rx、eth_axis_tx这些模块。第二层是网络层处理负责识别并且过滤以太网帧、解析IP包头同时还要处理ARP协议对应eth_udp模块内部的arp_cache和各类解析状态机。第三层才是UDP协议栈本身对外暴露的是纯粹的AXI-Stream接口用户只需要往接口上丢payload数据模块会自动帮你包上以太网头、IP头、UDP头另一侧则自动把这些头剥掉把payload干净地交出来。我读完整个工程之后最大的感受是所谓“协议栈”并不神秘本质上就是一组精心安排的状态机按照数据包的电平信号、字节对齐关系把固定字段抽出来做比较或填充。每一层都是对字节流的变换而层与层之间靠标准的AXI-Stream接口衔接。1.3 学这个工程能顺带弄懂的隐藏知识除了一堆网络协议字段这个工程还能让你在另外几个维度获得提升。一个是参数化设计的思路。整个工程的模块几乎全部用parameter定义了数据位宽、FIFO深度、使能选项。改一行参数就能把数据通路从32位切到64位或者关掉ARP功能。这种“配置项前置”的意识写小demo时根本不会碰到。另一个是仿真环境怎么搭。看它的testbench你会发现作者并不是简单地给几个激励然后看波形而是会用task封装一次发送操作、用自动比较器检查输出的包头字段是否正确。这种“自动化验证”的思维比代码本身的复用价值还要高。还有一点是背压制造与传递。以太网是流式数据一旦开始发送一个包中间不能随便停顿但用户侧的FIFO又可能随时空掉。整个设计里大量使用ready/valid握手机制并且会在关键位置做包级别的缓冲和裁剪这些处理手法才是真正的工作经验。2. 动手读代码之前先把这三块基础补牢直接硬读源码不是不行但我自己试过效果很差读了几百行状态机之后脑子会变成一团浆糊。比较靠谱的做法是先把必备基础知识补齐让大脑里有一个“预期结构”再去看代码时每一步都知道它为什么这么写。2.1 以太网帧、IP头、UDP头到底长什么样用FPGA做以太网和上位机写Socket完全是两码事。上位机里操作系统把数据包解好了应用层拿到的是干净的payloadFPGA眼里只有一串字节流包头包尾都是你的事。所以第一件事就是把三层协议的字节布局背下来起码能做到看到前几个字节就知道是IPv4还是ARP包。以太网帧在FPGA内部处理时一般从目的MAC开始看字段长度含义目的MAC6字节接收方MAC地址源MAC6字节发送方MAC地址EtherType2字节0x0800表示IPv40x0806表示ARPPayload46-1500字节上层数据FCS4字节CRC32校验由MAC层生成/检查注意一点IEEE 802.3帧头和这个略有区别多了长度字段但绝大多数以太网设备用的是EtherType这版verilog-ethernet也是按这个解析的。IPv4头部最小20字节。FPGA需要关注的字段有版本前4bit固定为4、IHL头部长度通常为5、总长度2字节、协议号第9字节UDP是0x11、源IP、目的IP。IPv4选项字段很少见解析时可以忽略只用最小20字节的头部。UDP头就8字节简洁得多字段长度含义源端口2字节发送方端口目的端口2字节接收方端口UDP长度2字节8字节头 payload长度校验和2字节可以填0表示不校验这些字段在FPGA里没有“变量”概念全部是寄存器里的bit按字节序一点点移位拼接出来的。理解这一点之后再看udp_ip_tx模块里那一堆组合逻辑就会明白它只是在一个字节计数器驱动下按顺序往tdata上塞固定值。2.2 AXI-Stream握手应该怎么看这个工程内部所有payload通路都走AXI-Stream。很多初学者一看到tvalid、tready、tlast这些信号就头大其实它的行为规则非常简单——类比成两个人传递货箱。发送方把货物放到传送带上同时拉高tvalid表示“这一拍的数据有效”。接收方如果准备好接收就拉高tready表示“我收”。只有当tvalid和tready同时为高的时钟沿数据才算真正完成一次握手传输。tlast拉高则说明这是当前包的最后一拍数据。tkeep用来表示最后一拍里哪些字节有效。事务不能半途而废一旦开始一拍握手数据必须保持稳定直到握手完成。这个规则决定了你在设计反压时必须小心发送方的数据在tready为低时不能变否则就会丢数。这里最容易忽略的是tuser信号。verilog-ethernet用tuser携带了包级别的元信息比如当前包是否出错、实际有效字节长度。接收方向上tuser在包最后一个字节时有效发送方向上tuser用来告诉模块一些额外信息例如“发送包的类型”。读代码前先把这个接口协议搞熟后面看udp_ip_rx和udp_ip_tx时会轻松非常多。2.3 ARP在链路中扮演的角色用PC向FPGA发UDP数据时首先遭遇到的问题不是UDP而是ARP。PC只知道目的IP地址不知道目的MAC地址而在以太网链路上每一帧都要带目的MAC所以PC必须先发一个ARP请求这个IP比如192.168.1.10的MAC地址是什么PC发出ARP请求时目的MAC是广播地址ff:ff:ff:ff:ff:ffFPGA如果使能了ARP处理就会识别出这是ARP请求然后回一个ARP应答把自己的MAC地址告诉PC。PC把IP和MAC的对应关系缓存到本地ARP表里之后才真正开始发送UDP包。这就解释了一个现象很多FPGA以太网demo第一次通信前都要先“ping一下”或者多等几秒钟实际上是让双方先把MAC地址交换完。verilog-ethernet里有一个arp_cache模块专门干这件事它不仅负责响应请求自己也会缓存收到的IP/MAC对应关系用来在FPGA主动发包时填充目的MAC地址。所以我在文章中建议所有初学者先理解ARP再去看UDP否则看到发送模块里“自动填充目的MAC”的逻辑会一脸懵。3. 从eth_udp顶层往下拆rx和tx各有分工把基础补齐后就可以正式开始啃源码了。我的路线是先看顶层eth_udp把输入输出接口认清再分别往接收方向和发送方向跟进。3.1 顶层eth_udp的接口设计顶层模块eth_udp是整个UDP卸载引擎的入口。例化它时主要关注以下几组信号接口侧连接MAC的接收/发送数据通路包括eth_rx_eth_hdr_ready这类状态信号以及标准AXI-Stream信号axis_rx_tdata、axis_tx_tdata等。用户侧axis_rx_*是收到UDP payload的输出axis_tx_*是用户要发送数据的输入。配置侧local_mac、local_ip这些参数决定FPGA自己的身份gateway_ip默认网关。看到这里就应该明白对用户来说FPGA内部的MAC头封装、ARP处理、IP头组装全都不需要关心。你要发数据只需把payload放到axis_tx_tdata上拉高valid模块会按之前配置好的IP、端口信息自动装包发出去。这个顶层设计最妙的一点是用户侧的时钟和接口侧MAC的时钟可以是异步的。也就是说MAC跑125MHz用户逻辑甚至可以跑完全不同的时钟模块内部通过FIFO完成时钟域转换。这个细节在工作里非常实用因为FPGA设计中很少能保证全芯片只有一个时钟域。3.2 接收方向UDP包怎么被一层层拆出来接收方向的核心是udp_ip_rx模块。它本质上就是一个“三阶段状态机”加一堆过滤器。第一阶段判断以太网帧头。目的MAC必须等于本地配置的MAC地址或者是广播地址EtherType必须是0x0800否则整个帧直接丢弃。这个阶段的实现非常简单计数器数到第12个字节把收到的EtherType和常量比较结果不符就拉低内部使能信号。第二阶段解析IP头。检查版本号是否为4、头部长度是否为20字节、协议号是否为UDP。同时把源IP、目的IP、IP头总长度这些字段存到寄存器里留给上层使用。第三阶段解析UDP头。检查目的端口是否和配置的端口相等再结合IP头总长度计算出payload实际有多少字节。payload之外多余的数据在以太网帧里其实是填充字节要跳过不能当有效数据交给用户。全部通过之后从UDP头结束的那个字节开始payload数据会通过AXI-Stream接口送给用户。最后一个字节时tlast拉高同时tuser里会带出包括源IP、源端口、包长度这些解析结果。实际调试中我建议大家做的第一件事就是在仿真波形里跟踪一个完整UDP包从输入到输出的全过程。看着字节计数器的值从0长到几十看着tlast在正确位置出现你会对“协议解析”这四个字有完全不一样的理解。3.3 发送方向用户数据怎么被一层层包出去发送方向是udp_ip_tx模块逻辑上比接收方向复杂一些因为它要“无中生有”地把各种包头拼出来。它的思路是用一个主状态机控制整个发送流程先把用户数据收进内部FIFO缓存然后按顺序往外发。发送顺序是7字节前导码和1字节SFD这部分由MAC层处理UDP模块不管→ 目的MAC、源MAC、EtherType → IP版本、头部长度、总长度、标识、协议号、源IP、目的IP → 源端口、目的端口、UDP长度、校验和 → 用户payload。这里的难点在于IP头里的总长度字段和UDP头里的长度字段都需要先知道payload的实际字节数但FPGA是流式处理无法一边发送一边同时知道“这一包总长是多少”。源代码的解决办法是要求用户侧在发送数据的同时通过一个字段/tuser同步告诉模块本次要发送的包长度或者模块内部存整包后再计算。如果你只是简单地把接收方向的payload直接接到发送方向会发现逻辑能跑但波形乱糟糟核心原因就和这个“长度前置”有关系。后面第4节我会展开实际操作时的解决办法。3.4 会卡住新手的几个细节读源码时最容易卡住的地方我遇到过三个。第一eth_arbiter模块的存在很多人体会不到。发送方向上有两路数据来源一路是用户要发的UDP/IP包另一路是ARP模块要回的ARP应答包。这两路不能同时占住MAC发送接口eth_arbiter就是干这个仲裁的。它给ARP更高的优先级因为ARP慢一拍PC那边可能就超时重发了而UDP包本身通常可以容忍短暂的等待。第二IP校验和的计算方式。IPv4要求网络层头部有16位反码和校验但代码里很多读起来像“打转”的异或和累加操作都是为了在流水线上高效算出这个校验值。初学者看不懂没关系只要知道它运行之后会给IP头部一个合法的校验字段即可。第三UDP校验和的计算在IPv4里不是强制的verilog-ethernet默认把它置0这能省掉一大块复杂逻辑。以太网链路本身还有FCS校验所以UDP校验和省略在局域网场景下问题不大。读代码时不要纠结为什么UDP校验和没算这不是作者的失误而是工程上做出的取舍。4. 实际操作把工程拿回来跑通从仿真到上板代码读得差不多就该动手跑了。如果你和我一样是开发板玩家手头大概率是Xilinx Artix-7这类芯片加一颗常见的RTL8211或者YT8512 PHY也就是1Gbps以太网。跑这个工程我建议把目标定为先把仿真跑起来再上板把UDP回环调通。4.1 先跑仿真用自带testbench建立信心从GitHub仓库clone下来之后不要急着建Vivado工程先在仿真环境里把自带的testbench跑起来。我用的工具是Vivado自带的xsim方便而且不用额外装软件。流程是先把rtl目录下所有.v文件加进工程再把tb目录下的eth_udp_tb.v也加进来设置成仿真顶层然后跑behavioral simulation。testbench里会模拟PHY侧发送一个完整的UDP包进来同时也测试发送方向能不能把数据正确封装出去。跑完仿真后重点看两个东西。第一接收方向解析出来的payload数据是不是和注入的一致第二发送方向波形里MAC头、IP头、UDP头各字段是不是和预期值对齐。如果你之前没怎么写过testbench这个工程的测试风格值得好好学。testbench里用task封装了“发一个包”“检查一个包”的操作主流程读起来就像一段自然语言这种基于事务的验证思路比在波形上肉眼对比数据高效太多。4.2 例化eth_udp并适配自己的板子仿真通过后开始在Vivado里搭真正要上板的工程。这一步比较容易出问题因为verilog-ethernet仓库里的example倾向于使用自带的总线模型或者厂商无关的PHY接口而我们的开发板大多是RGMII接口的PHY还需要厂商的MAC/PCS/PMA或者用纯逻辑实现MII时序。我的做法是分两步。第一步先不管外部PHY在工程里例化eth_udp把它的MAC侧接口做成通用GMII接口接到板级约束上。但这里有个坑开发板PHY的接口几乎都是RGMII数据在时钟上升沿和下降沿都采样而GMII是单沿采样。所以必须要在中间加一个IDDR/ODDR转换逻辑或者直接调用厂商的RGMII IP。如果用的Xilinx最简单的方案是在IP Catalog里搜索“RGMII”用Vivado的RGMII IP把PHY的RGMII信号转成GMII再接到eth_udp的MAC侧。如果你用的是正点原子或者黑金的开发板网上已经有很多类似的参考工程照葫芦画瓢把引脚分配和时钟约束添上即可。第二步把PHY芯片的复位和配置引脚先拉好。很多PHY支持通过MDIO接口配置寄存器但这个工程本身不带MDIO控制器我们上板验证初期最简单的办法是使用PHY芯片的引脚配置模式通过上下拉电阻把速率和模式固定下来比如强制1000Mbps全双工模式。RTL8211这类芯片出厂通常默认就是千兆自适应所以我们先把复位引脚拉对让它正常起来再说。4.3 从PC收发UDP最简单的回环验证上板之后如果FPGA侧的IP和MAC都已经配置好就可以和PC联调了。为了让验证闭环我在FPGA内部把接收方向和发送方向打通做成一个UDP回环PC发一个UDP包到FPGAFPGA收到后自动再把同样的数据发回PCPC这边能收到就说明收、发两条通路都正常。刚开始我图省事直接把接收侧的AXI-Stream信号和发送侧信号一对一短接。结果发现波形不对接收侧数据才刚开始输出发送侧可能还在发MAC头两边节奏对不上丢包。正确的做法是在中间加一个FIFO等接收到完整的UDP payload后再转发给发送方向。verilog-ethernet仓库里就有现成的axis_fifo模块直接用即可。代码大体是这个形态// 回环顶层示意 eth_udp #( .DATA_WIDTH (64), .ENABLE_ARP (1), .ENABLE_UDP_CHECKSUM (0) ) u_eth_udp ( .tx_clk (user_clk), .rx_clk (user_clk), .tx_rst (~pll_locked), .rx_rst (~pll_locked), .tx_axis_tdata (loopback_tdata), .tx_axis_tvalid (loopback_tvalid), .tx_axis_tready (loopback_tready), .rx_axis_tdata (rx_axis_tdata), .rx_axis_tvalid (rx_axis_tvalid), .rx_axis_tready (rx_axis_tready), .rx_axis_tlast (rx_axis_tlast), .rx_axis_tuser (rx_axis_tuser), .local_mac (48h00_11_22_33_44_55), .local_ip (32hc0a8010a) // 192.168.1.10 );PC这边我用Python的socket库做测试简单直接import socket s socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.settimeout(2) s.bind((192.168.1.100, 9000)) # 发送到FPGA s.sendto(bhello fpga udp, (192.168.1.10, 1234)) # 接收回环数据 data, addr s.recvfrom(1024) print(recv:, data)这里提醒一个细节PC和FPGA要配同一个网段PC的IP可以是192.168.1.100掩码255.255.255.0FPGA的IP就是代码里配置的192.168.1.10。如果物理链路没通发ARP请求都得不到回应socket会直接卡在sendto上——真到这一步先去排查PHY是否正常启动比如看板子的link灯有没有亮。5. 我在实际调试中踩过的坑5.1 PHY芯片复位和配置的坑这是我在这个项目上花的第一个跟头也是最耗时间的。板子上电后PHY芯片的复位引脚由FPGA控制但很多PHY芯片复位完成后还需要延时等待内部时钟稳定否则MDIO配置或者RGMII时序都会乱。我一开始图方便在约束里直接把复位引脚拉高以为PHY上电自己就能工作结果link灯时亮时不亮数据收发完全不正常。后来老老实实地加了复位逻辑FPGA锁定后拉低PHY复位至少10ms再释放复位然后再等至少100ms让PHY完成自协商。这个时间参数在PHY的数据手册里都有翻一翻就能找到。写复位状态机时一定要把相关时序做对。另外一个坑是RGMII接口在Xilinx FPGA上必须注意时钟相移。RGMII的接收时钟和数据的相位关系有一个90度的约束厂商的RGMII IP或者原语里都会处理但如果你自己写IDDR逻辑很容易忽略这个相位关系导致数据采样错误。上板跑起来如果发现PC收不到FLAG或者收到的数据是乱码先检查这部分时序。5.2 仿真正常但上板不通先别怀疑逻辑仿真和上板行为不一致是FPGA调试里最让人头疼的问题之一但很多情况下不是逻辑错了而是环境和约束的问题。我遇到过一次仿真里UDP包头字段完全正确上板就是收不到数据。排查一圈下来发现问题出在时钟约束上。当时我图省事没有给用户侧时钟单独建constraint导致Vivado认为所有时钟都是异步的布局布线把关键路径布得稀烂实际工作频率根本达不到预期。从此以后的教训是新建工程的第一步先把所有输入时钟用create_clock声明清楚哪怕只有一个时钟也要写。对于用到RGMII的以太网设计还要正确设置input delay和output delay否则PHY采样数据时很可能采到变化沿而不是稳定区间。这是RGMII设计里最容易忽略、但也最致命的一环。5.3 学会看信号而不仅仅是看灯调试FPGA以太网时“灯不亮”往往让人无从下手。我的建议是上板调试时分层次看信号。第一层看PHY的状态。link灯是否亮、自协商是否完成。第二层用Wireshark在PC端抓包看PC有没有发出ARP请求FPGA有没有回应。如果PC发了很多ARP但没人回应问题大概率在FPGA的接收通路或PHY的RGMII采样上。第三层如果ARP能通、UDP收不到再把ILA集成逻辑分析仪插到eth_udp的接收接口上看tvalid有没有拉高、tuser带出的状态是什么就能定位是拒绝还是丢弃。这个三板斧的排查思路比单纯看代码管用得多。因为协议栈这种设计问题往往藏在PHY物理层和MAC层之间的信号完整性问题里而不是UDP逻辑本身。6. 关于走通这个工程之后我再多说两句跑通这个工程后再回头看之前学的基础知识感受完全不同。时序约束不再只是纸上谈兵因为RGMII的建立保持时间就摆在眼前跨时钟域也不再是抽象概念因为MAC时钟和用户时钟之间隔着FIFO你想不体会都难。协议栈这个题目选得刚刚好难度足够让你遇到真问题又不会复杂到让人直接劝退。我把这个工程完整理解下来大概花了两周时间第一周读代码第二周在调试中反复折腾PHY和XDC约束。如果你也是从零基础一路自学过来的卡住的时候不用怀疑自己因为这方面的资料确实分散多数时候要靠自己试错。把这个工程吃透之后再接PCIE、DDR、光纤这种高速接口起码在同级的学习曲线上不会觉得太陡。