
我学FPGA那会儿最难的不是点灯也不是串口而是让板子和电脑之间用UDP协议栈把数据真正跑通。查了一圈资料要么是CPU上面跑lwIP要么直接套厂商MAC IP核想在FPGA里自己掌握“从网口到UDP数据”这条完整链路的资料少得可怜。后来我把alexforencich的verilog-ethernet开源工程整个啃了一遍豁然开朗。这份代码不是简单demo它把千兆以太网的MAC、ARP、IP、UDP、ICMP全部用Verilog实现配套模块划分清晰、接口统一特别适合已经具备基础Verilog和AXI-Stream常识的FPGA开发者做进阶学习。本文就按我的阅读顺序和踩坑记录带你把这套开源UDP协议栈工程彻底拆开看明白。1. 为什么要在FPGA里自己搭UDP协议栈1.1 从“会收发”到“懂协议”之间的鸿沟很多同学做到百兆以太网收发之后觉得自己已经会网络了。但真到项目里要“把ADC采样数据发到PC端显示”或者“接收上位机的配置指令”时立刻卡住——因为厂商的MAC IP核只负责物理层和数据链路层它给你的是一个带前导码、带FCS的帧或者一个AXI-Stream接口根本不帮你处理IP地址、端口号、ARP缓存这些逻辑。我见过太多人在这时候硬着头皮自己写协议栈结果写出来的IP头校验和是错的UDP校验和不知道伪头部这回事ARP缓存更是直接做成一锤子买卖。这些坑我基本全踩过一遍。自己重写一遍当然能学到东西但更高效的路子是找一份成熟开源工程精读站在前人的肩膀上理解“协议栈在硬件里到底长什么样”然后照着改、照着扩展。verilog-ethernet就是这么一份适合精读的工程。1.2 verilog-ethernet项目定位与核心价值verilog-ethernet是Alex Forencich维护的一个开源Verilog以太网组件库。它不像某些教程项目只给你一个不可综合的示意图而是提供了完整可综合的MAC层、ARP层、IP层、UDP层模块还带一堆官方example工程能直接在常见FPGA板卡上跑起来。这个项目最值钱的地方有四个一是模块边界切得好每一层都是一个独立模块接口基本都是AXI-Stream看懂一个就能举一反三二是代码风格一致状态机和组合逻辑分得很清楚适合当Verilog阅读范例三是官方example里包含大量“回环测试”设计比如UDP回环、ARP回环拿来就能上板验证四是它不绑定厂商Xilinx、Intel、Lattice都能用只要你有一个支持GMII/RGMII接口的PHY芯片就行。从学习路径上看这份代码是一个完美的“中间层”硬件上你只需要懂GMII/RGMII软件上你只需要懂网络调试助手和Wireshark中间这坨协议栈就是这次要啃的主体。1.3 核心模块清单速览在开始读代码之前我建议先把工程根目录下的rtl文件夹整个翻一遍不用细读只看文件名和例化关系建立一张模块地图。下面这张表是我整理的核心模块作用帮你快速定位模块作用学习优先级eth_mac_1g_fifo千兆MAC含收发FIFO对外是GMII/RGMII必须懂eth_axis_rx / eth_axis_tx以太网帧与AXI-Stream之间的转换负责剥/加头部必须懂arp_cacheARP缓存表维护IP和MAC映射重点难点arp_eth_rx / arp_eth_txARP请求/应答帧的解析与生成重点难点ip_eth_rx / ip_eth_txIPv4层收/发负责IP头校验、分派上层协议必须懂udp_ip_rx / udp_ip_txUDP层收/发负责端口判断和UDP头处理必须懂udp_checksum_gen / udp_checksum_verifyUDP校验和生成与校验重点难点icmp_eth_rx / icmp_eth_txICMP层主要做Ping回显建议懂这张表不是让你背而是让你在读代码时有个全局观协议栈不是一层套一层的神秘黑盒它就是一串模块按固定顺序连接起来的数据通路。后面我们沿着这条通路走一遍比对着代码看十遍还管用。2. 工程架构拆解数据在协议栈里是怎么流的2.1 数据发送链路从用户数据到网口先看发送方向。用户逻辑比如ADC采样控制模块产生一段payload数据通过AXI-Stream接口送进udp_ip_tx。udp_ip_tx帮你加上UDP头内容包括源端口、目的端口、UDP长度和校验和。它内部会算一个“伪头部”参与校验和计算这个后面单独讲。加完UDP头后数据流进入ip_eth_tx。这一层负责添加IP头版本号、头部长度、总长度、协议号、源IP地址、目的IP地址然后计算IP头校验和。IP层不管下面的以太网怎么发它只管把IP报文完整地交给下一层。再往下是eth_axis_tx。它把IP报文封装成以太网帧目的MAC地址、源MAC地址、类型字段0x0800然后交给eth_mac_1g_fifo。MAC层负责加前导码、SFD、还有帧尾的FCS校验字段最终通过GMII/RGMII接口送到PHY芯片变成网线上的电信号。这一整条链路里每一层只干自己那一件事。用户逻辑永远不需要关心IP头怎么算、MAC地址哪里来、CRC32多项式是什么它只要构造payload然后看tready拉不拉高就行。这种分层思想和软件里的TCP/IP协议栈是一模一样的只是用硬件状态机实现而已。2.2 数据接收链路从网口到用户数据接收方向和发送方向刚好反过来。PHY芯片从网线上收到信号恢复出时钟和数据通过RX接口交给eth_mac_1g_fifo。MAC层判断帧开始、帧结束检验FCS去掉前导码和SFD把完整以太网帧目的MAC、源MAC、类型、payload交给eth_axis_rx。eth_axis_rx先查类型字段如果等于0x0806说明是ARP帧直接把数据交给arp_eth_rx处理如果等于0x0800说明是IP帧交给ip_eth_rx处理其他类型先不管在你没开VLAN等功能的时候基本就是丢弃。ip_eth_rx解析IP头校验版本号、总长度、协议号。如果协议号等于17说明上层是UDP把数据交给udp_ip_rx如果协议号等于1说明是ICMP交给icmp_eth_rx处理用来回Ping请求。udp_ip_rx最后根据目的端口判断这个包是不是发给本机的是的话就把UDP头剥掉把payload通过AXI-Stream接口送给用户逻辑。所以接收链路的核心思路就是逐层剥头逐层分发。每一层都在做“检查头部字段 决定下一站是谁”这两件事。读这份代码时你只要盯住每一层模块的“数据输入”和“数据输出”两个接口就能把整条链路串起来。2.3 eth_mac与eth_fifoMAC层内部的黑盒verilog-ethernet里有两类MAC模块裸的eth_mac_1g和带FIFO的eth_mac_1g_fifo。新手建议直接看带FIFO的版本因为裸MAC接口对时序要求很苛刻而FIFO版本把跨时钟域问题封装掉了用户侧只需要一个稳定的user_clk。eth_mac_1g_fifo内部其实干了两件事一是真正的MAC核心负责GMII/RGMII时序、帧间隔、FCS生成与校验二是收发FIFO把PHY侧时钟域的数据缓冲到用户时钟域。这个FIFO是理解整个工程时序的关键发送方向用户逻辑在user_clk下写FIFOMAC核心从FIFO读数据按GMII时序发出去接收方向反过来PHY恢复出的RX时钟和RX数据进FIFO用户逻辑在user_clk下读出来。两边时钟频率不需要一样数据量不大时FIFO就能起到弹性缓冲作用。如果哪天上板发现数据偶尔丢先查这里。2.4 一个简单的回环工程是如何连起来的官方example里最常见的结构就是UDP回环UDP loopback。它的连接方式特别简单把udp_ip_rx输出的payload原封不动接到udp_ip_tx的输入同时把接收到的源IP和源端口记录到一个寄存器里发送时把它当成目的IP和目的端口填回去。这样上位机发什么开发板就回什么。我截一段伪代码帮你理解回环的连接方式// 回环核心连接 udp_ip_rx #(...) u_udp_ip_rx ( .clk(user_clk), .rst(rst), .output_axis_tdata(rx_payload), .output_axis_tvalid(rx_valid), .output_axis_tlast(rx_last), .output_axis_tready(rx_ready), .src_ip(rx_src_ip), .src_port(rx_src_port) ); udp_ip_tx #(...) u_udp_ip_tx ( .clk(user_clk), .rst(rst), .input_axis_tdata(rx_payload), .input_axis_tvalid(rx_valid), .input_axis_tlast(rx_last), .input_axis_tready(rx_ready), .dst_ip(rx_src_ip), .dst_port(rx_src_port) );注意一个新的UDP包进来时udp_ip_tx可能还处在上一包的回环过程中所以官方example里必须加一个小的状态机或FIFO做缓冲防止两个包打架。这也是初学者常踩的坑光看回环觉得简单一到两个连续包就白屏了。后面第4章我会讲怎么处理这个问题。3. 核心细节解析那些最容易看懵的代码模块3.1 UDP校验和的计算与“伪头部”问题很多人在软件里写UDP校验和没问题但到了FPGA里就糊涂核心原因是伪头部。伪头部是一个12字节的临时结构包含源IP地址4字节、目的IP地址4字节、01字节、协议号1字节UDP为17、UDP长度2字节。这个伪头部并不真正发送到网线上它只参与校验和计算。计算过程是把UDP报文UDP头数据和伪头部拼在一起按16位为一组做大端序累加累加结果如果有进位就回卷最后取反得到校验和。所以UDP长度这个字段在伪头部和UDP头里各出现了一次这很反直觉但RFC768就是这么定义的。硬件实现要比软件麻烦一点因为发送数据是一拍一拍的流水线伪头部里有些字段比如UDP总长度要到最后一拍才能确定。verilog-ethernet的做法是先用一个累加器对UDP头和payload逐拍累加等数据流发送到最后一拍时再把伪头部拼进去完成最终计算这样就能在流式发送中算出校验和。3.2 IP头校验和为什么可以“提前算好”IPv4头校验和比UDP校验和简单得多。IP头固定长度20字节不带选项时也就是10个16位单词。发送方把校验和字段本身视为0对前20字节按16位累加进位回卷后取反结果填进校验和字段。因为在发送一帧数据之前源IP、目的IP、协议号、总长度这些字段都已经确定了IP头校验和完全可以在生成头部的那一瞬间算出来不需要等payload发完。所以ip_eth_tx里的做法是组合逻辑直接算好一个校验和初值然后随着头部一起发出去。这个区别非常关键IP头校验和是“头固定一次算完”UDP校验和是“数据流式尾部收尾”。读代码时如果分不清这两种思路很容易被udp_checksum_gen里的延迟线绕晕。接收方向的校验也是同理。ip_eth_rx收到一帧后先把IP头按16位累加如果结果不是0xFFFF说明头部在传输中损坏直接丢帧。udp_ip_rx那边则要等整个payload收完才能做UDP校验和结果判断因为UDP校验和覆盖整个UDP报文。3.3 ARP缓存表是如何学习与应答的ARP是让IP层能够找到对应MAC地址的协议。verilog-ethernet把ARP拆成三个模块arp_cache负责维护映射表arp_eth_rx负责接收ARP请求/应答arp_eth_tx负责生成ARP请求/应答。先看接收方向。开发板收到一个ARP请求时arp_eth_rx会检查“目标IP”是不是自己。如果不是丢弃如果是做两件事第一把请求者的IP和MAC学到arp_cache里第二触发arp_eth_tx生成一个ARP应答帧告诉对方“这个IP对应的MAC是我”。再看主动发送方向。当FPGA想主动给PC发UDP数据时ip_eth_tx需要目的MAC地址它会查arp_cache。如果命中直接封装以太网帧发送如果没命中状态机会进入“等待ARP响应”状态先发一个ARP请求同时暂停当前用户数据直到收到ARP应答后再发送原来的UDP数据。这个状态机是初学者最容易看晕的地方因为它把“用户数据发送”和“ARP等待”两个过程耦合在一起。我建议你读的时候画一个简单状态图idle - check_arp - send_arp_request - wait_arp_reply - send_udp。理解了这一条arp相关代码就通了。3.4 跨时钟域处理一个经常被忽略的难点以太网和普通逻辑最大的区别在于时钟域特别多。以最常见的RGMII接口为例PHY恢复出的RX时钟和本地用户时钟完全是异步的如果直接把RX数据拿到user_clk下使用很容易出现亚稳态和偶发错误。verilog-ethernet的解决办法就是FIFO。eth_mac_1g_fifo里面的FIFO本质上就是一个异步FIFO写时钟是GMII/RGMII的收发时钟读时钟是用户时钟。这类FIFO在整个工程里承担了所有跨时钟域职责代码里大量使用gray code做指针同步这也是FPGA异步设计的标准做法。我在调试时遇到过一个典型的坑RGMII模式下PHY芯片的RX clock和RX data之间的delay没校准偶尔出现CRC错误。这个不是跨时钟域FIFO的问题而是RGMII本身的源同步时序要求。遇到CRC报错先去查PHY的寄存器配置和引脚约束别一上来就怀疑协议栈代码。3.5 AXI-Stream接口与tkeep/tlast的处理verilog-ethernet所有模块之间的数据交换都用AXI-Stream协议。这个协议说穿了就四个关键信号tvalid表示数据有效tready表示接收方准备好tdata是数据总线tlast表示这是这一包的最后一个节拍。真正让新手头疼的是tkeep。比如UDP payload长度是5字节但数据总线宽度是8字节64bit那么最后一拍只有前5字节有效tkeep就会等于5‘b11111后面的字节是无效的。如果用户逻辑忽略了tkeep直接整包存储就会把垃圾字节也写进去。这类问题在“UDP收发不定长数据”时特别常见。我给你的建议是所有用户逻辑在处理AXI-Stream数据时都要同步处理tkeep并且把tlast当成帧同步信号用。verilog-ethernet内部模块都对tkeep做了正确处理你自己写应用模块时也要养成这个习惯否则数据错位是必然的。4. 从0开始的实践路径怎么啃这份代码4.1 硬件与软件准备跑这份工程最好有一块带千兆以太网口的FPGA开发板不管是Xilinx还是其他厂商的都行。板载PHY芯片只要是常见的RTL8211、88E1512、KSZ9031这种一般都能适配顶多改改RGMII引脚约束。软件方面Xilinx用户用VivadoIntel用户用Quartus。另外强烈建议装好Wireshark和一个网络调试助手比如经典的小工具NetAssist或者Python自己写一个UDP收发脚本都行。调试网络协议栈抓包工具是刚需不要省。如果你用的是纯逻辑开发板没有软核CPU也能跑通纯RTL回环example只是在配置IP地址和MAC地址方面要多花点功夫。verilog-ethernet的example里通常会提供简单的寄存器配置接口或者直接通过参数写死建议先把参数写死跑通再考虑做动态配置。4.2 第一步先把官方example跑起来官方example里一般都有纯回环工程。你先别改任何逻辑直接打开工程按自己的板卡改三样东西PHY的引脚约束文件XDC/SDC、时钟频率通常用户时钟需要125MHz、还有本地IP和MAC地址参数。上板之后先用Wireshark抓一下网线流量看开发板上电后有没有自动发ARP广播。如果有说明MAC、PHY、时钟链路基本正常。然后命令提示符ping一下开发板的IP能通就说明MAC、IP、ICMP、ARP收发都没问题这是第一个里程碑。最后用网络调试助手向开发板发UDP包如果回环工程运行正常你会立刻收到一模一样的数据。这时候你的成就感会非常大但记住这只是开始接下来要做的才是真正把代码啃懂。4.3 第二步用ILA/VIO观察内部关键信号跑通回环之后不要急着做下一件事回到Vivado里给回环工程加ILA观察udp_ip_rx的输出和udp_ip_tx的输入。触发条件建议这样设置抓rx_axis_tvalid上升沿并且把tlast也勾上这样能抓到一整包数据。我自己的习惯是同时观察三组信号第一组是PHY接口侧信号比如rx_dv、rx_data确认MAC层收到的数据是不是对的第二组是eth_axis_rx输出看以太网帧头是不是被正确剥离第三组udp_ip_rx输出看payload是不是完整。从物理层一步步往上走哪一层出了问题一目了然。这时候你要学会一个很实用的技巧用VIO手动把某个内部寄存器置位/拉低模拟一个外部事件。比如你可以通过VIO直接触发udp_ip_tx发送一包固定数据观察发送链路每一层的波形。这个技巧在后续调试自己的模块时非常管用。4.4 第三步做自己的第一个变体工程跑通回环并且看完波形之后强烈建议做一个自己的小变体不是把收到的数据原样回显而是做成“接收一包配置指令然后回传一个固定波形或传感器数据”的简单逻辑。这样你就被迫理解udp_ip_rx的payload怎么解析udp_ip_tx的发送时机怎么控制。我当时做的就是上位机发一个4字节指令通道号采样点数FPGA收到后回传一段递增序列。核心逻辑拆成三块一个指令解析状态机、一个数据生成状态机、一个发送控制模块。指令解析状态机在udp_ip_rx的tlast拉高时判断收到了完整指令数据生成状态机产生一段连续数据发送控制模块等数据生成完毕再拉高udp_ip_tx的tvalid开始传输。这里有个关键点udp_ip_tx的输入端你不可能同时发两包数据所以发送控制模块必须维护一个“忙”标志。上一包还没发完时新指令来了要缓存或直接丢弃。这个业务问题在真实工程里非常常见verilog-ethernet本身不管完全由用户应用层决定。5. 常见问题与排查技巧5.1 能Ping通但UDP不通这种情况我遇到得最多。Ping能通说明MAC、IP、ICMP、ARP这一路都是好的问题基本出在UDP层的端口过滤上。udp_ip_rx里有一个目的端口检查逻辑如果你的上位机发送端口不在允许列表里UDP payload压根不会送出来但ICMP类型的包不受影响所以Ping正常。检查方法很简单先把udp_ip_rx的端口检查相关参数改成“接收所有端口”或者用Vivado ILA抓一下udp_ip_rx输出看tvalid有没有拉高。如果端口检查没问题但tvalid仍不拉高再看IP头里的协议号是否正确有没有被防火墙或驱动改掉。还有一个隐蔽的坑上位机发送UDP时用了checksum但checksum算错了Wireshark里可以直接看到udp checksum错误这种情况FPGA会直接丢帧表现就是“上位机显示已发送板子毛都没收到”。5.2 数据总是错位或者丢包数据错位绝大多数是字节序问题。以太网协议规定多字节字段按大端序传输也就是网络字节序。如果你的用户逻辑是小端习惯比如从ARM、RISC-V角度设计很容易把payload里两个字节搞反。排查方法是在回环里发一个递增序列比如0x00、0x01、0x02……然后在PC端看收到的数据排列。如果看到0x01、0x00、0x03、0x02这种说明字节序反了在udp_ip_tx输入前做一次字节重排就行。丢包问题则复杂一些多半出在发送控制逻辑上比如tvalid拉高后没有等待tready或者上一包还没发完就启动了下一包。用ILA看tvalid/tready时序基本能立刻定位。5.3 CRC错误在涨CRC错误意味着物理链路或MAC收发有问题。先分清是发送方向还是接收方向发送方向CRC增长八成是GMII/RGMII输出时序没做好比如ODDR原语没配对、TXD延时不对接收方向CRC增长八成是PHY芯片的RX时钟和数据偏斜没校准或者时钟约束没写对。RGMII模式下最常见的情况是PHY芯片的RX/TX内部延时没有正确使能。很多PHY芯片靠引脚电平选择是否插入时钟延时这个配置不对就会出现“偶尔能通、大流量必CRC错”的诡异现象。建议先查PHY手册里的相关配置引脚再看看PHY内部寄存器反馈的link状态。5.4 长时间运行后协议栈卡死这种问题通常只有一个原因状态机卡住了。arp_state或者udp发送状态机等一个永远等不到的信号比如ARP请求发出去了但PC一直没回或者rx端收到了半包数据tlast一直没来状态机就死在那里。解决思路很简单也很工程化给关键状态机加超时计时器。比如ARP请求发出后2秒没有收到应答就放弃当前包回到idle状态接收方向连续若干拍没收到tvalid并且也没收到tlast就把当前缓冲清空重新开始。verilog-ethernet很多example里其实带了简化处理但在实际项目里这种健壮性设计必须你自己补齐。5.5 常见问题速查表现象可能原因排查手段能Ping通但UDP收不到端口过滤、UDP校验错误ILA抓udp_ip_rxWireshark看checksum数据错位字节序问题发送递增序列对比PC端数据偶发丢包发送控制逻辑未背压抓tvalid/tready看握手是否完整CRC错误持续增长PHY时序偏斜、约束缺失检查RGMII延时、XDC约束运行一段时间卡死状态机卡死给ARP、发送状态机加超时复位彻底不通PHY复位、时钟未起振先用VIO看PHY的有源时钟输出这张表不是标准答案是我自己调试时沉淀下来的排查顺序。实际项目里还会遇到各种各样稀奇古怪的问题核心思路始终是分层定位、逐级加ILA、别跳步。6. 啃完这份代码之后的下一步扩展6.1 试着给UDP回环加上“分包重组”逻辑跑通基本回环后第一个值得做的练习是“把一个大块数据拆成多个UDP包发送并在对端重组”。这一步会逼你理解UDP包长限制MTU通常1500字节IP头20字节加上UDP头8字节payload最多1472字节还会逼你处理发送定时和确认机制。实际工程里FPGA经常要连续发送大量数据比如图像的一整行、一整帧一个UDP包装不下。很多新手直接在udp_ip_tx输入端一股脑拉高tvalid结果包与包之间没有正确打断对端收到的是错乱数据。正确处理方式是做一个发送状态机每个包设置好包头信息发出tlast之后等待短暂间隔再发下一个包。6.2 从1G走向10G/25G思路迁移verilog-ethernet里还有10G以太网版本核心思路完全一样只是数据总线从8bit变成64bit或者256bit用户时钟频率变高MAC和PHY接口从GMII变成XGMII。你花时间把1G版本吃透之后迁移到10G只是改接口、改位宽的问题。UDP/IP/ARP的处理逻辑在10G下并没有本质变化唯一真正的工作量和难点在MAC层和FIFO位宽、CRC计算方式上。这也是为什么我特别推荐从1G版本学起它把协议栈的知识和底层时序解耦了学完真的能复用。6.3 把协议栈嵌入到带CPU的系统里再进一步可以把这个纯RTL UDP协议栈挂到AXI总线上配合MicroBlaze或者ZYNQ里的ARM核做成“CPU协议栈卸载引擎”。上位机把数据写到DMA描述符协议栈自动加头发包接收方向协议栈收完数据通过DMA直接搬运到内存。这就是网卡硬件卸载的雏形。verilog-ethernet作者还维护着对应的DMA和PCIe相关组件配合起来可以组成一整套高性能网络数据传输方案。如果你以后要做高速数据采集、软件无线电、机器视觉实时传输这类项目这套组合拳会非常值钱。6.4 我的学习建议回到“近似0基础”这个定位。很多人觉得协议栈是高不可攀的东西实际上它只是一堆状态机加校验和逻辑。我自己的体会是读verilog-ethernet时别追求一口吃成胖子一个模块一个模块地啃每啃完一个模块就写一段自己的总结再用小实验验证。等你把arp_cache的查表更新逻辑和udp_checksum_gen的流式计算逻辑都亲手画过时序图你就不再是“会用IP核”的开发而是真正“懂网络”的FPGA工程师。最后分享一个小技巧别一开始就去看10G和DMA部分先守着1G以太网加一个packet generator把回环跑上三天三夜再开始改代码这是我觉得最稳妥的进阶路线。