FPGA实现UDP协议栈:开源verilog-ethernet工程详解与上板验证 博主去年年中还在和数码管较劲转眼到这个系列的Part 10已经可以带着大家啃verilog-ethernet这个开源UDP协议栈工程了。先说结论如果你已经做过几个FPGA小项目会用Vivado跑仿真理解FIFO和跨时钟域的基本概念那么从“近似0基础”到在自己板子上把UDP收发跑通其实没有想象中那么难。难的是你被一堆名词吓住——RGMII、ARP、IP校验和、CRC、MTU每个都能搜到一堆资料但拼不到一张图里。这篇文章就干一件事以alexforencich的verilog-ethernet代码为主线把从网线到用户逻辑之间每一层搞明白再给你一条可以直接抄作业的上板验证路径。为什么我推这个开源工程而不是直接用Vivado里的Tri Mode Ethernet MAC IP核后面会详细讲。这里先给自己算笔账用自带IP核最快要半天才能拉通仿真而且出错时你面对的是黑盒用verilog-ethernet模块都是可读的Verilog你可以在每一层插入监视点仿真里直接观察帧头、IP头、UDP头的变化。对学习来说这种“看得见”的价值远大于节省的那点例化时间。1. 学这套开源协议栈之前得先想清楚几个问题1.1 verilog-ethernet 到底是什么凭什么敢用它做工程这是alexforencich维护的一套开源以太网IP核集合仓库里涵盖了从100M到100G的多套MAC以及ARP、IPv4、UDP、TCP、PCIe、DMA等一堆模块。我们这里只关心千兆RGMII这一条链路用到的模块大概十几个。许可协议是LGPL 2.1意思是你可以在商业项目里使用但如果修改了库本身的模块需要把修改部分开源。拿来做学习、做毕业设计、做公司内部原型验证完全没问题。它和Xilinx自带IP核最大的区别在于代码可读。你点开eth_mac_rgmii.v能看到CRC是怎么逐比特计算的看到RGMII的双沿采样是怎么用IDDR做的看到发送FIFO的时序是怎么打拍子的。这些东西在自带IP核里全是黑盒出了问题只能靠猜。我遇到过不止一次用户逻辑明明没问题MAC IP核的配置寄存器一个位写错整个链路就死掉排查半天。用开源代码至少能断点式排查。另外要提醒一点仓库更新较快早期版本和当前版本接口差别很大。老版本大量使用独立的valid/ready信号新版本统一成AXI4-Stream还加了tdest信号做通道路由。你搜网上的教程如果是两三年前的很可能拿到的是老接口例化代码对不上。我的建议是直接拉master分支以仓库自带example和README为准不要迷信教程里的代码。这个坑我踩过后面会展开说。1.2 数据是怎么从PC飞到FPGA再回到PC的先建立一个总体认识。你的电脑网口出来的是差分信号或单端信号经过网线到达板子上的PHY芯片比如瑞昱的RTL8211、裕太微的YT8531。PHY芯片把模拟信号转换成数字的RGMII总线一边连着FPGA一边由FPGA提供时钟。FPGA内部第一层是MAC模块负责处理以太网帧前导码、帧起始符、目的MAC、源MAC、类型、数据、CRC。MAC之上是ARP模块回答“这个IP对应的MAC是谁”。再往上是IP层处理IPv4头和校验和。最上面是UDP层处理端口号。你写的用户逻辑实际上挂在UDP层之上看到的就是最干净的载荷数据。发送方向同理用户在UDP层填入目的IP、目的端口、载荷一层层封装下去最后变成网线上的电平信号。这个过程中每一层都只关心自己该加的头模块之间用AXI4-Stream接口连接与数据宽度无关。所谓“协议栈”其实就是这么一层层堆出来的。你不需要理解TCP/IP的完整体系不需要会写网卡驱动甚至不需要知道ARP缓存在系统里怎么工作。只需要记住一条UDP包的载荷最大是1472字节因为以太网MTU是1500减去IP头20字节再减UDP头8字节。想发大数据必须自己拆包。这一点后面在代码里会看到。2. 工程结构拆解MAC、IP、UDP各自的活2.1 核心模块一览一个最小UDP收发系统需要哪几块先把最小系统的模块清单列出来对照表格后面例化的时候直接照抄。模块名职责和你打交道的方式eth_mac_rgmii千兆RGMII MAC处理PHY侧收发、CRC、前导码物理引脚例化eth_mac_1gMAC内部通用收发逻辑被上层调用一般不用直接碰eth_axis_rx / eth_axis_tx把MAC数据转换成AXI4-Stream流内部信号eth_arp处理ARP请求和应答自动工作你会看到ARP帧出来eth_ip封装IPv4头校验和计算需要配置本机IPeth_udp封装UDP头按端口号匹配收发核心配置入口axis_fifoAXI4-Stream FIFO跨时钟域缓冲用于用户时钟与MAC时钟隔离这个七层模块组成了最简链路。数据宽度上RGMII一个时钟沿传4bit双沿就是8bit所以MAC层内部是8bit 125MHz。用户逻辑如果跑100MHz数据总线可以配成32bit中间经过axis_fifo做位宽和时钟转换。你不需要自己设计跨时钟域直接用仓库里的axis_fifo即可但要理解这个FIFO的tkeep和tuser信号怎么用后文会讲。2.2 帧格式与跨层封装为什么1472这么关键看一段以太网帧的构成从网线上抓下来的样子从前往后依次是前导码7字节每个字节0x55帧起始符0xD5目的MAC 6字节源MAC 6字节类型/长度 2字节IPv4一般是0x0800IP头20字节起包含源IP、目的IP、协议号、校验和UDP头8字节包含源端口、目的端口、长度、校验和用户载荷FCS校验和4字节CRC32如果你用Wireshark抓包前导码和FCS一般是不显示的因为网卡已经帮你处理掉了但你用FPGA抓就是全的。这也是为什么FPGA初学者对着仿真波形会懵——MAC输出到用户侧的时候其实已经去掉了前导码和FCS你看到的是从目的MAC开始的完整帧。而UDP模块进一步剥掉MAC和IP头之后用户侧就只剩端口号加上载荷。1472这个数字的来历我再算一遍标准以太网帧MTU 1500字节是指从目的MAC到载荷结尾的总长度。去掉MAC头14字节目的MAC6源MAC6类型2还剩1500字节是IP包总长。IP头固定20字节的版本里UDP数据报总长就是1480字节IP头20 UDP头8 载荷1472。所以UDP载荷上限1472。如果你发的数据超过1472IP层必须分片。verilog-ethernet的IP模块设计上没有实现分片你发超大包结果通常是发送方直接丢包或者出错。应用层拆包是必须做的。2.3 用户侧接口怎么把业务数据接上去这里以eth_udp_tx为例看一下你真正需要打交道的信号。仓库里的例化名称可能因版本略有差异但核心就这几组s_axis_udp_tdata要发送的UDP载荷数据。s_axis_udp_tvalid发送有效标志。s_axis_udp_tlast本包最后一个数据。s_axis_udp_tkeep最后一周期的有效字节掩码。s_axis_udp_treadyUDP模块反压信号。src_ip、dst_ip、src_mac、dst_mac、src_port、dst_port这些配置寄存器通常以输入端口形式给出。发送一个包最简单的动作是先配置好目的IP和目的端口这部分可以在运行时动态改也可以用parameter固定然后在某个时钟周期把tdata打上去拉高tvalid等tready拉高后表示数据被接收。最后一个数据时tlast拉高tkeep指示有效字节数。只要满足这个规则底层怎么组帧你完全不用管。接收方向类似eth_udp_rx会输出s_axis_udp_tdata、tvalid、tlast等信号同时提供rx_source_ip、rx_source_port等字段告诉你包是从哪来的。拿到这些用户的业务逻辑就可以决定是点亮LED、写入FIFO、驱动DAC还是回传数据。顺带说一句整个工程对AXI4-Stream的支持很完整。如果你之前折腾过AXI接口上手非常快。如果没接触过就记住tdata是数据、tvalid是有效、tready是对端准备好了、tlast是包尾、tkeep是最后一拍的有效字节掩码这几条够了。3. 从零搭工程仿真跑通一条UDP发送链路3.1 源码获取与工程组织注意依赖问题第一件事是拉代码。建议建一个工作目录把依赖仓库一起拉下来git clone https://github.com/alexforencich/verilog-ethernet.git git clone https://github.com/alexforencich/verilog-axis.gitverilog-ethernet的很多模块内部会依赖verilog-axis里的axis_fifo、axis_adapter这些文件所以不能只拷一个仓库。有人说我明明把ethernet的rtl全加进工程了怎么还报找不到模块八成就是缺了axis的源码。我用Vivado建工程时习惯把两个仓库的rtl都作为一个source组添加Vivado会自动分析依赖只综合被例化的模块。有些教程建议手动一个个添加文件那样容易漏。直接整目录添加让工具自己去筛省心得多。仿真文件夹里自带testbench覆盖了绝大多数模块这个后面要好好利用。版本匹配我要再多说一句拉代码时看下仓库的提交时间和README说明尽量用最新release或master。网上有些教程使用2019年前后的旧版本接口名都对不上新代码。如果教程里出现了eth_udp_tx_32这种带位宽后缀的模块名那基本就是老版本了新代码统一用参数指定位宽。3.2 搭建最小仿真环境预编译testbench怎么跑verilog-ethernet仓库的tb目录下有大量测试文件比如eth_udp_tb.sv、eth_ip_tb.sv、eth_mac_rgmii_tb.sv。这些测试文件通常用Verilator或Icarus编写不保证直接能在Vivado自带的xsim里跑。我的做法是临时用iverilog快速验证一遍确认代码能编译再在Vivado里跑波形。Icarus跑起来很快cd verilog-ethernet iverilog -g2012 -o tb.vvp -I rtl rtl/*.v tb/eth_udp_tb.sv vvp tb.vvp如果报错说某个模块找不到先检查rtl是否包含齐全再检查依赖仓库的rtl路径是否在-I参数里。实际需要哪些文件Vivado的log会给你答案缺什么补什么。如果你想在Vivado里跑仿真建议新建一个simulation source把tb文件加进去scope选择对应的模块。跑完再看波形重点观察UDP发送模块里当tvalid和tready同时为高数据是否逐拍进入发送FIFO以及MAC层输出的帧头是否符合预期。第一次跑通这几个波形你对协议栈的理解会立刻立体起来。3.3 写一个简单用户逻辑把固定数据发出仿真环境准备好了自己写一个最简单的发送激励。目标每10000个时钟周期发送一个UDP包载荷固定为4个32bit数据内容分别是十六进制的DEADBEEF、11223344、55667788、0A0B0C0Dreg [31:0] cycle_cnt; reg [3:0] beat_cnt; reg s_axis_udp_tvalid_reg; reg s_axis_udp_tlast_reg; reg [31:0] s_axis_udp_tdata_reg; always (posedge clk) begin if (rst) begin cycle_cnt 0; beat_cnt 0; s_axis_udp_tvalid_reg 0; s_axis_udp_tlast_reg 0; s_axis_udp_tdata_reg 0; end else begin s_axis_udp_tvalid_reg 0; s_axis_udp_tlast_reg 0; if (cycle_cnt 10000) begin cycle_cnt cycle_cnt 1; end else begin cycle_cnt 0; end if (cycle_cnt 9998 tready) begin beat_cnt 0; end else if (tvalid tready) begin if (beat_cnt 3) begin beat_cnt beat_cnt 1; end end if (cycle_cnt 9999) begin s_axis_udp_tvalid_reg 1; s_axis_udp_tdata_reg 32hDEADBEEF; end else if (tvalid tready) begin case (beat_cnt) 0: begin s_axis_udp_tvalid_reg 1; s_axis_udp_tdata_reg 32h11223344; end 1: begin s_axis_udp_tvalid_reg 1; s_axis_udp_tdata_reg 32h55667788; end 2: begin s_axis_udp_tvalid_reg 1; s_axis_udp_tdata_reg 32h0A0B0C0D; s_axis_udp_tlast_reg 1; end endcase end end end注意这段代码只是为了演示没有处理背压和concurrent发送的情况。真实设计里正确的写法是根据FIFO的空满状态产生发送请求并且任何一拍tvalid为高都要等tready逻辑不能想当然地认为“我发了你就能收”。这点初学者最容易错。仿真时把eth_udp_tx的输入和输出都拉出来看再拉MAC层输出。你会看到UDP模块自动在前面拼上了UDP头、IP头、MAC头最后通过RGMII总线把数据打出去。那一刻你对“封装”的理解会非常直观。4. 上板验证从Wireshark抓包到Python回环4.1 顶层例化与引脚约束仿真通过后才是真正开始上板折腾。顶层例化时有几个点必须想清楚。第一时钟。RGMII的接收时钟rxc由外部PHY提供125MHz。发送时钟txc一般由FPGA内部产生通常用一个PLL或MMCM将125MHz参考时钟倍频到125MHz并输出。很多开发板上有125MHz有源晶振也可以直接拿来当参考。如果用的是像正点原子、黑金这类带RTL8211的板子原理图怎么接的参考demo例程的约束就行。第二复位。MAC模块的复位要和其他模块同步释放。我习惯用一个简单的复位同步器把外部异步复位转成同步复位再接给所有模块reg [3:0] rst_sync; always (posedge clk) begin if (ext_rst) rst_sync 4b1111; else if (rst_sync ! 0) rst_sync rst_sync 1; end wire rst rst_sync[0];第三PHY芯片配置。RTL8211等PHY上电后默认状态通常可以自动协商成千兆全双工但很多板子的PHY需要你通过MDIO接口确认一下。verilog-ethernet有eth_mdio模块你也可以不用MDIO先把PC网卡速率固定成千兆看链路是否协商成功。如果ARP始终不通先检查PHY的link状态。引脚约束以RTL8211为例RGMII信号一般是set_property PACKAGE_PIN AE17 [get_ports rgmii_rxd[0]] set_property IOSTANDARD LVCMOS18 [get_ports rgmii_rxd[0]]注意RGMII是单端信号不是高速串行收发器别把它接到GTP/GTX引脚上。板子上的RJ45网口走的是PHY芯片PHY到FPGA才是RGMII总线。4.2 用Wireshark确认链路通了一半上板后PC端先配一个静态IP。假设PC是192.168.1.10子网掩码255.255.255.0FPGA端在顶层里固定IP为192.168.1.50MAC地址随便用一个本地管理地址比如52:54:00:12:34:56。注意MAC的第一个字节最低位是0表示单播第二低位是0表示全局唯一0x52满足本地管理不会和真实网卡冲突这个细节很实用。打开Wireshark选择对应网卡过滤条件写arp或udp。然后在PC上ping一下192.168.1.50。这里必须澄清一个常见的误解ping不通不代表你的FPGA链路有问题因为verilog-ethernet默认是没有实现ICMP回显的。但ping之前会先发送ARP请求以太网里的设备要通信必须先知道对方的MAC地址。FPGA里的eth_arp模块看到ARP请求后会应答。所以Wireshark里只要能抓到FPGA返回的ARP应答包就说明你的MAC层收发链路、PHY协商、FPGA时钟全部正常。这是我推荐的第一道上板验证关卡。ARP应答怎么看Wireshark过滤arp你会看到PC发出的请求里带的是广播MAC ff:ff:ff:ff:ff:ff然后FPGA的MAC地址回了一个单播包。如果这个包出现恭喜物理层和MAC层通了。4.3 Python回环测试确认双向收发MAC层通了接下来验证UDP。FPGA端写一个简单的循环逻辑收到PC发来的UDP包后把载荷原样发回去。或者更简单先用第3节那个固定发送逻辑让FPGA周期性向PC发送一组数据。PC端用Python收包import socket sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((192.168.1.10, 9000)) sock.settimeout(5) while True: try: data, addr sock.recvfrom(2048) print(addr, data.hex()) except socket.timeout: print(timeout, no packet received)如果PC能打印出DEADBEEF11223344556677880A0B0C0D这样的数据发送链路就全通了。然后验证PC到FPGA方向。FPGA端例化eth_udp_rx收到数据后直接点亮LED或写回UDP回环。用Python发送import socket sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.sendto(bhello fpga, (192.168.1.50, 6000))FPGA收到后把数据回传PC再收一遍。双向都通你的FPGA UDP协议栈就真正跑起来了。以后任何业务——图像采集、ADC数据回传、外部接口控制——都可以挂在这条链路上。5. 踩坑记录与问题排查建议5.1 仿真正常上板不通先检查时钟复位和PHY配置最常见的问题就是同一套代码Vivado仿真里UDP包发得干干净净一上板Wireshark什么都看不到。这时候不要怀疑协议栈先看板级硬件。先看PHY芯片的复位引脚是不是被拉高了很多板子PHY复位简单接了个RC延时电路上电后需要几十毫秒才释放如果FPGA逻辑过早开始发数据链路还没稳定。所以顶层里建议加一个上电延时计数器至少等10毫秒再让MAC开始干活。再看RGMII接收时钟。RGMII标准下rxc与rxd的相位关系是中心对齐也就是说数据在时钟上升沿和下降沿都是稳定的但因为PCB走线和PHY内部delayFPGA采样时需要做delay调整。在Xilinx 7系列上eth_mac_rgmii内部用IDELAYE2来微调约束文件里要有set_property IDELAY_VALUE这样的设置。如果时序不满足你就会看到Wireshark里要么收不到任何包要么收到的全是CRC错误。CRC错帧是RGMII采样问题的典型信号。另外确认MDIO有没有初始化PHY。RTL8211默认通常能自动协商成千兆但有些板子的strap引脚配置不对导致PHY工作在100M甚至10M模式。检查PHY的link status或者强制在PC网卡属性里设置为1Gbps Full Duplex两边速率匹配是关键。如果在PC端看到速率是100Mbps说明PHY配置有问题先解决这个再往下走。5.2 突发丢包和CRC错误怎么定位丢包问题需要分清是发送丢的还是接收丢的。FPGA内部加两个计数器一个统计应用层发起发送的次数一个统计MAC层实际完成发送的帧数。两个数字对比如果应用层发起多而MAC完成少说明发送侧反压没处理好FIFO溢出或者tready拉低时应用逻辑丢失了握手。AXI4-Stream的规则是tvalid拉高表示这一拍想要发送tready拉高才真正发送成功两者同时为高才是一个有效beat。很多丢包代码错误是tvalid已经拉高但看到tready为低时没有保持数据下一拍就把tvalid撤了。这对普通FIFO可能不算问题但UDP模块会认为你这包已经结束从而封装出错。接收方向丢包常见因素是MAC层到用户逻辑的FIFO深度不够。PC端以千兆线速轰炸瞬间会有大量包涌入而你的应用逻辑可能来不及处理导致FIFO溢出。UDP本身不保证可靠传输所以丢包在协议层面是允许的但如果业务不能接受就需要做流控或者加大FIFO。一个经验值是用户处理逻辑最差情况下吞吐量要高于PC发送平均速率否则FIFO再大也会溢出。CRC错误除了RGMII时序还有可能是电压问题。RGMII如果走线过长或电平不匹配上升沿和下降沿的数据保持时间不够就会随机出错。可以在PHY到FPGA的RGMII数据线上加约束最小时序或者用IDELAY逐步调整延迟值。网上好多FPGA以太网调试的帖子最后都是靠调IDELAY_VALUE治好了CRC错误这不是玄学是RGMII接口的物理特性决定的。5.3 问题速查表现象可能原因检查方向Wireshark什么都抓不到网线/PHY复位/MAC没工作先看PHY link灯再抓ARP有ARP应答ping不通没实现ICMP回显正常用UDP脚本验证收包但全是CRC错误RGMII采样时序偏移调IDELAY_VALUE查电平约束发送端tready总为0MAC未up或发送FIFO满查MAC复位、检查用户FIFO深度丢包严重反压处理错误/FIFO溢出加计数核对有效beat旧教程代码例化报错接口版本不匹配用新版AXI4-Stream接口收到的数据错位或乱序tkeep/tlast用错检查最后一拍掩码检查包长配置了目的MAC但无法通信ARP缓存未更新先用Wireshark看ARP应答是否正常这个表只覆盖了高频问题。实际调试中你还会遇到跨时钟域亚稳态、IP校验和配置错误、UDP端口没对上、板子电源纹波导致的偶发死机等等。但调试思路是通用的从最底层往上层逐段验证。物理层看PHY link灯MAC层看ARP应答网络层看IP头传输层看UDP数据。每一层通过后再往上一层问题就无处遁形。我个人在实际操作中的体会是学这套协议栈最忌讳的就是把网上的完整工程下载下来直接烧录。看着好像跑通了但你自己什么也没学会。反而是花一个下午把eth_ip.v和eth_udp.v里头的状态机一条条读过去跟着仿真波形把一帧数据的封装和解析过程走一遍那种收获比烧十个demo工程都大。所以这篇文章写的不是“快速跑通”而是“跑通的同时把链路搞明白”。你在自己的板子上把UDP跑通之后再往这个框架里塞自己的业务逻辑比如把ADC采样数据打包上传、接收上位机指令控制外设就会觉得一切都顺理成章了。