ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

开源FPGA UDP协议栈verilog-ethernet代码深度解析与实战

开源FPGA UDP协议栈verilog-ethernet代码深度解析与实战 写这篇的时候我其实纠结了很久。网上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、帧起始定界符SFD0xD5、目的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×8bit2Gbps但实际有效数据带宽要扣除前导码、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应答包的操作码是2ARP请求操作码是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_fifoMAC层实体处理RGMII时序、帧收发和时钟域转换eth_eth_tx / eth_eth_rx负责MAC帧的封装与解析也就是加剥MAC头eth_arpARP协议处理维护MAC/IP映射表eth_ip_tx / eth_ip_rxIP层组包拆包计算和校验IP首部校验和eth_udp_tx / eth_udp_rxUDP层组包拆包处理端口和校验和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(48h00_0a_35_01_02_03), .IP_ADDR (32hc0_a8_01_10), .PORT (16d12345) ) 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输出到PHYRX是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_tdata8位或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(48h00_0a_35_01_02_03), .IP_ADDR (32hc0_a8_01_10), .PORT (16d50000) ) 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位寄存器时经常有人误写成了{8hA8, 8hC0}。表层现象是ARP能通但PC收到UDP包显示源IP是1.1.168.192。排查方法是在testbench里把每一个头部字节用monitor任务打出来和Wireshark抓到的包逐字节对比。Verilog的位拼接运算一定要时刻把最低位在前和网络字节序区分开。verilog-ethernet的代码它把每一段用常量16h0800这种写法其实暗示了发送字节的顺序是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就应该是4b0011如果tkeep是4b1111接收端就会认为后面还有两个有效字节。经验做法是在用户逻辑里做一个小型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、读任何别的复杂协议栈都会觉得顺畅很多。
返回列表