写这篇的时候,我其实纠结了很久。网上FPGA UDP协议栈的资料不少,但大多要么是"我调通了一个回环"的一笔带过,要么是魔改IP核之后"能用但说不清为什么能用"。verilog-ethernet这个开源工程,是我见过的少有的能把MAC、ARP、IP、UDP每一层都讲得明明白白的代码,对我来说它比任何教科书都值钱。
1. 为什么我选择verilog-ethernet而不是自己造轮子
1.1 这part到底要学什么
先说清楚,这一篇不是教你怎么用开发工具点几个按钮生成一个UDP IP核。我们要做的是把一个真正的、工业级的开源UDP协议栈工程,从头到尾扒一遍,搞清楚每一个模块在干什么、为什么这么写、信号之间怎么配合。
verilog-ethernet是GitHub上Alex Forencich维护的开源项目,用纯Verilog写了完整的以太网MAC层、ARP、IP、UDP协议栈,支持从百兆到100G的各种PHY接口。我用的是其中1G Ethernet + RGMII + UDP的部分,也就是FPGA直连一个千兆PHY芯片就能出去UDP包的那套东西。
这个项目最打动我的地方是:它没有用任何厂商私有IP,全部是跨平台可综合的RTL代码。这意味着你无论是用Xilinx、Altera还是国产FPGA,逻辑完全通用。而且代码风格极其规范,模块划分遵循OSI分层思想,每一层做的事情非常单一,简直是为学习量身定做的。
1.2 学习思路:先看大图,再抠细节
刚打开这个工程的人,第一反应多半是崩溃。rtl目录下面躺着几十个模块文件,加上sim目录里的仿真环境,看起来像个庞然大物。但不要慌,我的建议是:先在大脑里建立一张数据流地图,再逐个模块去填细节。
所谓数据流地图,就是搞清楚一个UDP数据包从你的逻辑代码产生,到最后变成网线上电平的完整过程。这张地图大概是这样的:你的用户逻辑把数据打包成AXI-Stream格式,交给eth_udp_tx模块,它加上UDP头;然后交给eth_ip_tx加上IP头;然后交给eth_eth_tx加上MAC头和FCS校验;最后通过eth_mac_1g_rgmii_fifo把数据翻译成RGMII时序发给PHY芯片。接收方向完全相反:PHY进来的信号先过MAC层剥掉帧头,再过IP层剥掉IP头,最后到UDP层把数据吐出来。
这个图想明白了,整个工程就拆成了一个个接力任务,每个模块只需要看懂自己的那一棒。
2. 动手前必须吃透的几个基础知识点
2.1 以太网帧格式与MII时序
不要嫌基础,很多人在仿真里出问题,就是栽在帧格式上。一个标准以太网帧,物理线上实际发送的内容依次是:前导码(7字节的0x55)、帧起始定界符SFD(0xD5)、目的MAC地址(6字节)、源MAC地址(6字节)、类型/长度字段(2字节)、负载数据、填充字节(如果负载不足46字节)、最后是4字节的帧校验FCS(即CRC32)。
这里有个常见的理解误区:PHY芯片唤醒会剥掉前导码、SFD和FCS,所以MAC层看到的"帧"是从目的MAC开始的。但是verilog-ethernet里的eth_mac模块,如果配置成带前导码输出(有带fifo的版本),你会在接口上看到的完整的数据仍然包含这些开头。你在分析仿真波形时,一定要先搞清楚你这根线上到底站的是哪一层的数据。
RGMII是Reduced Gigabit Media Independent Interface的缩写,为了减少引脚数量,它在时钟的上升沿和下降沿各采样一次数据,也就是DDR模式。千兆速率下时钟是125MHz,所以RGMII的数据吞吐能力就是125M×2×8bit=2Gbps,但实际有效数据带宽要扣除前导码、IFG、帧头帧尾这些开销,实测UDP有效带宽大概在900Mbps左右。注意RGMII还有tx_ctl信号,上升沿送TX_EN,下降沿送TX_ER,别搞反了。
2.2 ARP、IP、UDP三个头部逐个拆解
先说ARP,它解决的是"我知道对方IP,但我不知道对方MAC地址"的问题。ARP请求包:以太网类型字段是0x0806,负载是28字节的ARP头。请求时发送者的MAC、IP填自己的,目标MAC填全0,目标IP填要询问的地址。收到请求的一方发现目标IP是自己,就回一个ARP应答包,把应答的MAC地址填进去。这里要注意:ARP应答包的操作码是2,ARP请求操作码是1,代码里判断错了包就丢了,非常隐蔽。
IP头是20字节的固定部分,关键字段有:版本号(4)+首部长度(4),固定是0x45;总长度字段是整个IP包的总字节数,这个字段是字节序敏感的,处理不当包长就错了;协议字段是0x11表示UDP;源IP和目标IP各4字节;还有一个很重要的首部校验和。**IP首部校验和的计算方法是:把20字节的IP头按16bit一组,如果超过16位就回卷相加(end-around carry),最后取反。**发送方要填好校验和,接收方收到后拿同样的算法对整个头部算一遍,如果结果是0xFFFF就说明校验通过,否则丢包。
UDP头更加简单,只有源端口(2字节)、目标端口(2字节)、UDP长度(2字节,指UDP头+负载的总长度)、校验和(2字节)。UDP校验和是可选字段,IPv4下填0表示不校验,verilog-ethernet默认做了计算。它计算的范围包括一个伪头部(伪IP头),很多人不理解这个设计,其实是为了防止UDP包被路由到错误的目的地。伪头部包含源IP、目标IP、协议号0x11、UDP长度。计算方式同样是16bit反码和。
2.3 计算流程的Verilog实现套路
在Verilog里算IP校验和,最常规的做法是用组合逻辑做并行加法树,再用寄存器打拍拼接出最终结果。举个例子,如果IP头固定是20字节,就有10个16bit的数要相加。第一次加5个数,第二次加2个数,第三次加1个数,最后做一次回卷和取反。verilog-ethernet里用了generate语句和参数化宽度来写多级加法树,看起来有点绕,但看懂之后会发现这个写法非常优雅,它可以适配不同长度的IP头选项。
CRC32是另一个"看起来很简单、实际写起来全是坑"的东西。以太网FCS用的是CRC32算法,多项式0x04C11DB7,初值0xFFFFFFFF,输出要异或0xFFFFFFFF,而且数据是按bit从MSB开始处理的。如果你用常见的LFSR串行写法,1Gbps速率下处理125MHz×8bit并行数据,时序可能会不满足;verilog-ethernet提供了并行的CRC32计算表生成脚本,通过查表方式实现,性能很好。我建议初学者先用串行方式理解原理,再去看它的并行版本。
3. 工程实操:从克隆到首次仿真通过
3.1 工程目录结构与核心模块地图
建议动手第一步,先把仓库克隆下来,然后用你顺手的文件管理器看一遍目录。虽然官方给了文档,但我觉得看源码本身收获更大。
整个rtl目录下和UDP相关的主要模块,我列个简化版清单:
- eth_mac_1g_rgmii_fifo:MAC层实体,处理RGMII时序、帧收发和时钟域转换
- eth_eth_tx / eth_eth_rx:负责MAC帧的封装与解析,也就是加剥MAC头
- eth_arp:ARP协议处理,维护MAC/IP映射表
- eth_ip_tx / eth_ip_rx:IP层组包拆包,计算和校验IP首部校验和
- eth_udp_tx / eth_udp_rx:UDP层组包拆包,处理端口和校验和
- axis_adapter等AXI-Stream辅助模块:处理不同位宽之间的数据流转换,以及FIFO缓冲
仿真目录下,每个模块都有对应的testbench,测UDP收发通路是sim_eth_udp.v。这个testbench搭了一个虚拟的mac回环,让你不接PHY也能完整看到UDP包的发送和接收过程。我就是从这个仿真开始跑的,跑通了再去看波形,整个数据流就串起来了。
3.2 搭一个最小仿真环境
这里我想说一个很多人踩过的坑:直接打开他人的工程往往会被一堆平台版本、IP核版本问题纠缠。所以我的习惯是只抄代码,不借工程。自己新建一个Vivado工程,把rtl目录下的源码添加进去,然后新建一个顶层把eth_udp例化出来,再写一个最小testbench,模拟发送一个UDP包。
我用的是Vivado 2021.2自带的XSim仿真器,所有代码都是纯Verilog,不需要额外仿真库。流程是:先跑behavioral仿真,确认数据面通,再跑综合和上板验证。仿真testbench的思路是:给IP提供125MHz时钟(RGMII时钟),按下短暂复位,然后拉高axi_tvalid并打出64字节的数据,观察axi_tready是否正常握手,再看输出侧的数据是否符合预期。
一个简单的测试代码框架大概这样:
module tb_udp_loopback; reg clk = 0; reg rst = 0; always #4 clk = ~clk; // 125MHz initial begin #20 rst = 1; #20 rst = 0; end // 实例化你抄出来的eth_udp模块 eth_udp #( .MAC_ADDR(48'h00_0a_35_01_02_03), .IP_ADDR (32'hc0_a8_01_10), .PORT (16'd12345) ) u_eth_udp ( .clk(clk), .rst(rst), // ... 其他信号 ); initial begin // 模拟用户逻辑发送一个UDP包,注意AXI握手时序 end endmodule注意,仿真初始复位时间和AXI握手时序都要设计好,不然数据发不出去你还以为是协议栈的问题,其实是你激励信号就没给对。
3.3 上板之前的引脚约束实测
上板前还有一个必做的动作,是把RGMII引脚和自己的FPGA芯片引脚对应起来。如果你用的是黑金或正点原子这类带千兆PHY(比如RTL8211)的板子,厂商例程里通常有现成的约束文件,可以抄一部分。但需要注意几个细节。
一是PHY的复位和时钟:很多板子的PHY需要你给它一个复位信号,还要配置125MHz的TX时钟。你不能假设PHY上电就能用,必须在FPGA侧把复位和时钟稳定后释放。二是RGMII的引脚方向:TX是FPGA输出到PHY,RX是PHY输出到FPGA,在约束里要写清楚IOSTANDARD和驱动能力。三是时钟约束:125MHz时钟建议通过PLL生成,并把时钟约束写进XDC,否则上板后时序收敛会很难看。
实际调试时我遇到过一次非常刁钻的情况:FPGA逻辑仿真完全正常,上板后UDP包就是发不出去。后来用逻辑分析仪(ILA)抓RGMII信号,发现PHY芯片的RX_CTL引脚没有按RGMII标准在下降沿送出RX_DV,是PHY芯片配置的问题。调了一个PHY芯片的寄存器的值之后才稳定。所以上板调试一定不要把PHY芯片当成做"好的"黑盒,必要时用ILA抓所有RGMII信号看时序。
4. 核心代码模块逐段拆解
4.1 eth_udp模块的接口设计
整个UDP协议栈对外暴露的接口风格,和Xilinx的XAPP1026很像,或者说它就是这种AXI-Stream风格的成熟变体。接收方向是一条AXI4-Stream输入,信号包括axis_rx_tdata(8位或32位,取决于你配置的位宽)、axis_rx_tvalid、axis_rx_tready、axis_rx_tlast、axis_rx_tuser(用来带错误标记),以及axis_rx_tkeep(位宽不为8时标记哪些字节有效)。发送方向则是用户逻辑给UDP模块数据,模块自己加上UDP头、IP头、MAC头。
这里我建议先读eth_udp_tx.v这个文件,它是最容易理解的,本质上就是状态机转移:空闲等用户数据到来,来了之后先依次发送UDP头、IP头、MAC头,然后转入数据搬运,被上层数据流打过来的字节都原样送入下层FIFO,直到用户送tlast信号,这个时候要补上FCS并向PHY发送结束码。
注意一个细节:eth_udp_tx在发送头部字段时,采用的方式是把头部计算嵌入状态机而不是额外开一块RAM缓存,这样资源很省,但要求你对状态机的跳转时机非常清晰。如果你要改动头部内容(比如动态修改端口号),就要顺着这个状态机改。
4.2 eth_arp模块的缓存表结构
ARP模块里有一个"ARP缓存表",它维护一个由IP地址到MAC地址的映射关系。这个表不是简单的FIFO,而是可以按IP查MAC、按MAC匹配应答的阵列。每个表项包含IP地址、MAC地址和有效标志。模块内部会有定时清除逻辑,防止表项过期。
刚开始我跳过了这个模块,后来发现它是整个协议栈里最容易出现"玄学问题"的地方。比如你发UDP包到一台电脑,电脑回包了,但回包的目的MAC地址不对,那就不是因为UDP层问题,而是ARP缓存表没记对。调试这类问题,我的经验是先在串口打印ARP表内容,把发送端IP/MAC、对端IP/MAC全部打印出来,定位是表满了、表项被错误覆盖还是根本没学习到。
4.3 跨时钟域处理与FIFO设计
在verilog-ethernet里,eth_mac_1g_rgmii_fifo这个模块最值得细品。一个MAC实体,接口上有两个时钟域:一是用户侧的MAC时钟(通常就是125MHz),二是PHY侧的RGMII时钟。两个时钟可能不是来自同一个PLL,因此内部必须用异步FIFO来安全地搬运数据。
这里隐含了一个很重要的设计理念:FIFO不只是缓冲,更是时钟域的边界。verilog-ethernet中所有跨时钟域的FIFO都用了独立的写时钟和读时钟,并且在FIFO两端分别做了写指针和读指针的格雷码同步。上面有个full和empty信号,生产者和消费者各自依据full/empty来决定是否暂停。
关于FIFO,还涉及一个"水线"的问题。发送方向,如果用户线程一次性突发发2000字节而FIFO深度只有1024,那水线设置不当就溢出丢包。verilog-ethernet的FIFO支持可编程水线(prog_full),你要根据你的发送频率和数据量去设置水线,我的建议是:突发长度不超过FIFO深度的一半,或者在应用层做流控。但在我看来,更保险的方案是给上行数据通路加一个基于credit的反压信号,而不是单纯靠FIFO硬扛。
4.4 发送与接收状态机对比分析
把eth_udp_tx和eth_udp_rx这两个状态机放在一起读,收获会翻倍。发送方向的状态流程大致是:IDLE → UDP_HEADER → IP_HEADER → MAC_HEADER → PAYLOAD → PAD → FCS → WAIT_IFG;而接收方向则几乎是镜像:IDLE → 检测前导码/SFD → MAC_HEADER → IP_HEADER → UDP_HEADER → PAYLOAD → FCS校验。
对比之后,你会很清楚地看到协议栈中"对称"的思想。发送时先打头再发数据,接收时先剥头再收数据;发送时算校验和往头部填,接收时算校验和去核验。理解了这种对称性,以后你自己设计其他通信协议栈(比如自定义点到点协议)也能快速套用。
4.5 在用户逻辑里调用协议栈的推荐写法
最后说一下怎么把协议栈嵌到你的项目里。我是这么例化的:在顶层模块里例化eth_udp_config(一个处理配置寄存器的AXI-Lite模块)和eth_udp(数据通路模块)。用户逻辑通过AXI-Stream端口和eth_udp交互,收发都固定走端口号。
// 推荐直接例化eth_udp,而不是把内部每个模块单独拿出去 eth_udp #( .MAC_ADDR(48'h00_0a_35_01_02_03), .IP_ADDR (32'hc0_a8_01_10), .PORT (16'd50000) ) u_eth_udp ( .clk(clk_125m), .rst(~pll_locked), .tx_axis_tdata(tx_axis_tdata), .tx_axis_tvalid(tx_axis_tvalid), .tx_axis_tready(tx_axis_tready), .tx_axis_tlast(tx_axis_tlast), .rx_axis_tdata(rx_axis_tdata), .rx_axis_tvalid(rx_axis_tvalid), .rx_axis_tready(rx_axis_tready), .rx_axis_tlast(rx_axis_tlast), // 底层MAC和RGMII引脚相关信号,需要连到顶层 .rgmii_txd(rgmii_txd), .rgmii_tx_ctl(rgmii_tx_ctl), .rgmii_txc(rgmii_txc), .rgmii_rxd(rgmii_rxd), .rgmii_rx_ctl(rgmii_rx_ctl), .rgmii_rxc(rgmii_rxc) );如果你只需要固定的源端口和目的端口,直接用eth_udp就够了;如果你需要动态配置IP和端口,就要例化eth_udp_config,通过AXI-Lite寄存器去写。
5. 常见问题与排查技巧
5.1 仿真数据通过但字节顺序反了
这个问题出现频率极高。以太网是大端传输的,但FPGA内部的数据总线通常是按小端习惯放的。比如你想发一个IP地址192.168.1.1,按字节顺序是C0 A8 01 01,但在代码里拼接16位寄存器时,经常有人误写成了{8'hA8, 8'hC0}。表层现象是:ARP能通但PC收到UDP包显示源IP是1.1.168.192。
排查方法是:在testbench里把每一个头部字节用monitor任务打出来,和Wireshark抓到的包逐字节对比。Verilog的位拼接运算一定要时刻把"最低位在前"和"网络字节序"区分开。verilog-ethernet的代码它把每一段用常量16'h0800这种写法,其实暗示了发送字节的顺序是08 00而不是00 08,阅读时不能想当然。
5.2 ARP请求发出去了但PC不应答
先确认PHY是否成功协商成千兆模式并且link up。然后查ARP帧的长度和填充,ARP请求包整个以太网帧一般要填充到60字节以上。很多PHY或交换机会丢弃不满足最小帧长的短包。再看ARP发送的目标MAC地址,通常应该是全FF的广播地址FF:FF:FF:FF:FF:FF,如果你这里配置成了全0,包就直接被交换机丢弃,PC根本不会收到。
第三点就是ARP缓存表的有效标志。如果有效信号一直没拉高,说明模块没能从应答包里学习到MAC地址。我调试时发现有些接线问题会导致ARP应答包之前就被MAC层丢掉了,比如FCS校验没过。解决办法是把FCS校验错误计数引到调试口,看有没有非零值。
5.3 UDP包发出去了但PC端收到的内容不对
如果你用Wireshark能看到包,但内容不对,那问题几乎可以断定在UDP层。常见的是payload的字节错位,比如少了头、多了填充。这个问题的根源多半是AXI-Stream的tlast时机不对,或者是tkeep没有正确和tdata对齐。比如你的总线位宽是32位,最后只剩2个字节,tkeep就应该是4'b0011,如果tkeep是4'b1111,接收端就会认为后面还有两个有效字节。
经验做法是:在用户逻辑里做一个小型FIFO,先把待发送数据按32位对齐存起来,发送时严格控制tlast和tkeep的组合,千万不要出现tlast拉高的周期里tdata还有无效数据的情况。
5.4 中断忽长忽短:时钟约束和时序收敛
跑仿真出现时序warning通常不用太担心,但上板后时钟频率一高就出错,就基本可以判断是时序违例。RGMII的125MHz接口在FPGA上必须用专用时钟资源(BUFG/MMCM)来布线,时钟约束必须写在XDC里。不要直接拿全局时钟引脚硬接,否则过不了时序。
还有一种很隐蔽的情况:eth_udp_tx内部分配了较多的组合逻辑做校验和,如果用户逻辑还把它的输出又接入长组合路径,这部分就是时序收敛的大坑。我的建议是:只要改动过协议栈本身,就必须用report_timing_summary看关键路径,不要只看有没有功能问题。
5.5 调通后的稳定性测试:时长和重发
调通UDP通路只算完成了一半。要验证协议栈稳定,必须做长时间压力测试:PC端用Python脚本以固定的速率往FPGA发100万包,FPGA收到后原样回发,PC端统计丢包率。如果丢包率不为零,优先看两端数据的速率匹配,极大概率是上行FIFO溢出了。
我在实测中遇到过这么一件事:FPGA回环速率只有15MB/s,而上行发送速率是18MB/s,跑几分钟必丢包。丢包率很小,时间短根本发现不了,长测才暴露出来。后来我把上行FIFO的可编程满水线调低了一些,给协议栈留出足够的反应时间,丢包率才降到0。这种问题只能靠长时间大流量测试放出马。
6. 下一步可以怎么玩
学完这个工程,我强烈建议你尝试着做一次改造。比如把eth_udp模块改成支持动态端口配置,或者把MAC层换成GMII对接更老式的PHY,再或者加一个简单的自定义应用层协议。改造的过程里,你会发现读代码时理解的那些握手、时序、校验和,全部要变成你自己的设计能力了。
我个人觉得,这个项目就像通信协议的最佳教科书,它把RFC里一大堆抽象的文字转化成了可仿真的信号级描述,读透它,你以后再去读PCIe、读AXI、读任何别的复杂协议栈都会觉得顺畅很多。