ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA UDP通信模块设计与实现:从RGMII接口到上板调试全解析

FPGA UDP通信模块设计与实现:从RGMII接口到上板调试全解析 做FPGA开发的人十有八九都会走到以太网这一步。串口速度再快也就那点带宽PCIe门槛又高UDP成了最务实的中间选择——协议简单、吞吐可观、调试方便PC端还有现成的网络调试工具。这篇文章是系列第8篇前面我们已经把FPGA开发流程、时序约束、仿真技巧过了个遍这次就专心把UDP模块的代码设计啃下来。我默认你已经会用Verilog写状态机能跑Vivado或Quartus仿真对AXI接口也有个基本概念。如果这些还不太熟建议先翻翻前面的文章。这篇文章不会给你贴一大坨完整工程代码而是拆开讲设计思路和代码骨架。等你看明白数据从用户逻辑到网线的完整路径再回头写代码逻辑会清晰得多。我会从协议裁剪、模块架构、发送通路、接收通路、仿真验证、上板实测六个方面展开最后把最常见的坑挨个说一遍。1. 为什么要自己写UDP协议栈裁剪思路1.1 先把UDP帧格式画清楚很多新手上来就找完整的UDP/IP协议栈代码动辄几千行结果看两天直接劝退。实际上在FPGA里做UDP通信根本不需要实现完整的协议栈我们只需要把数据链路层MAC帧、网络层IP、传输层UDP这三层里最核心的字段填对就能和PC端通信。一张以太网帧在FPGA内部从用户数据到有线信号会经历三次包装第一层是MAC帧前导码7字节0x55加帧起始符1字节0xD5然后是目的MAC地址6字节、源MAC地址6字节、类型字段2字节IP包填0x0800后面跟数据最后是CRC32校验4字节。第二层是IP头标准IPv4头部20字节包括版本号IHL0x45、服务类型0x00即为0、总长度2字节、标识2字节、标志与偏移2字节、TTL1字节填0x40或0x80都行、协议号1字节UDP填17、头部校验和2字节、源IP4字节、目的IP4字节。第三层是UDP头源端口2字节、目的端口2字节、UDP长度2字节8字节头加数据长度、校验和2字节。注意MAC帧的数据字段最小是46字节如果IP包加UDP包整体不足46字节后面要补0填充。以太网帧总长度最小64字节不含前导码不管实际数据多短这个下限必须满足。1.2 自实现与现成IP核的取舍既然是做FPGA开发就得先回答一个问题直接用Xilinx/Intel自带的MAC IP核还是自己写我的建议是分阶段第一阶段自己写一个精简的GMII/RGMII接口MAC加UDP逻辑先把通路跑通第二阶段再切换到官方IP核做性能优化和稳定性加固。为什么这么建议官方MAC核功能全支持流控、巨型帧、VLAN但配置项多初次上手光搞懂那些寄存器就要花不少时间。而且MAC核的AXI接口封装程度高出了问题很难定位是接口时序还是内部逻辑。自己写一个精简MAC收发状态机加一起不到500行每个字节怎么拼接都清清楚楚后面排查问题效率反而高。当然自己写也有代价CRC32计算逻辑要自己搞定前导码和帧间距时序要严格对齐RGMII的信号延迟需要约束好。这些内容后面会有专门的小节来讲。2. 顶层架构模块划分与关键端口2.1 与MAC核对接的RGMII信号先看整体模块划分。我把整个UDP通信模块拆成五个子模块udp_tx发送状态机、udp_rx接收状态机、crc32CRC计算、tx_fifo发送缓冲、rx_fifo接收缓冲。顶层模块叫udp_stack对外暴露RGMII接口和用户接口。RGMII接口是现在网口芯片最常见的接口标准。千兆模式下GTX_CLK时钟125MHz数据在时钟上升沿和下降沿都采样——TXD[3:0]和RXD[3:0]各4位上升沿传低4位下降沿传高4位。这个双沿采样机制是新手最容易写错的地方后面会细说。控制信号方面tx_ctl在上升沿表示TX_EN下降沿表示TX_ER发送错误rx_ctl在上升沿表示RX_DV数据有效下降沿表示RX_ER接收错误。发送侧还要注意复位后TX_CLK必须稳定输出否则对端PHY芯片可能一直检测不到链路。顶层模块的端口定义大致如下module udp_stack #( parameter SRC_MAC 48h00_11_22_33_44_55, parameter DST_MAC 48hff_ff_ff_ff_ff_ff, parameter SRC_IP {8d192, 8d168, 8d1, 8d10}, parameter DST_IP {8d192, 8d168, 8d1, 8d100}, parameter SRC_PORT 16d8080, parameter DST_PORT 16d8080 )( input wire rst_n, input wire clk_125m, // RGMII接口 output wire [3:0] rgmii_txd, output wire rgmii_tx_ctl, output wire rgmii_tx_clk, input wire [3:0] rgmii_rxd, input wire rgmii_rx_ctl, input wire rgmii_rx_clk, // 用户发送接口 input wire [31:0] user_tdata, input wire user_tvalid, output wire user_tready, input wire user_tlast, // 用户接收接口 output wire [31:0] user_rdata, output wire user_rvalid, input wire user_rready, output wire user_rlast );用parameter定义MAC地址、IP和端口是为了方便在不同板卡上适配。板子上的PHY芯片地址和MAC地址直接在例化时改参数就行不用改内部逻辑。2.2 跨时钟域与FIFO缓冲这里有个细节要提前说清楚UDP发送和接收的逻辑时钟跟用户逻辑时钟通常不是同一个。RGMII接口工作在125MHz但用户逻辑可能跑在100MHz甚至更低。所以必须在中间加异步FIFO做跨时钟域缓冲。发送侧的结构是用户逻辑100MHz→ 异步FIFO → UDP发送状态机125MHz→ RGMII → PHY。接收侧反过来PHY → UDP接收状态机125MHz→ 异步FIFO → 用户逻辑。异步FIFO建议直接用Xilinx的FIFO Generator IP核选Independent Clocks模式数据宽度32位深度选512或1024都够用——单包最大1500字节大约是375个32位字512深度留了余量也不浪费资源。有人会问为什么不直接用官方MAC核的AXI接口省掉FIFO因为官方MAC核的发送接口虽然是AXI-Stream但时钟域已经绑定在MAC核内部时钟上用户逻辑如果不做跨时钟域处理时序问题早晚会冒出来。自己加一道FIFO边界清晰排查问题也方便。3. 发送通路代码设计从用户数据到网线电信号3.1 发送状态机设计发送通路的状态机是整个模块的核心我把它分成7个状态IDLE、PRE、MAC_HEAD、IP_HEAD、UDP_HEAD、DATA、PAD_CRC。状态跳转逻辑如下IDLE等待FIFO非空信号一旦检测到数据就跳转到PRE同时拉高rgmii_tx_ctl表示数据有效。PRE发送8字节前导码7字节0x55加1字节0xD5发送完毕后进入MAC_HEAD。MAC_HEAD发送目的MAC、源MAC和类型字段共14字节然后进入IP_HEAD。IP_HEAD发送20字节IP头进入UDP_HEAD。UDP_HEAD发送8字节UDP头进入DATA。DATA从FIFO中读出用户数据按字节发送。用户接口是32位宽但RGMII接口是4位宽千兆双沿等效8位所以要做宽度转换。当用户数据发送完毕收到tlast或者FIFO读空且长度计数器满进入PAD_CRC。PAD_CRC如果总数据长度不足46字节补充0字节然后拼接4字节CRC32最后拉低tx_ctl回到IDLE。这个状态机写起来不算复杂但有几个细节特别容易错。3.2 CRC32与UDP校验和的工程实现CRC32在以太网里用的多项式是0x04C11DB7按MSB-first方式计算。这里先给大家一个可以直接使用的CRC32模块我在多个项目中都用过靠得住module crc32_d8 ( input wire clk, input wire rst_n, input wire [7:0] data_in, input wire crc_en, output reg [31:0] crc_out ); reg [31:0] lfsr_q; wire [31:0] lfsr_c; wire [31:0] lfsr_next; assign lfsr_c[0] lfsr_q[24] ^ lfsr_q[30] ^ data_in[0] ^ data_in[6]; assign lfsr_c[1] lfsr_q[25] ^ lfsr_q[31] ^ data_in[0] ^ data_in[1] ^ data_in[7] ^ data_in[6]; // 中间省略标准的CRC32比特级展开公式 assign lfsr_c[31] lfsr_q[23] ^ lfsr_q[29] ^ data_in[7] ^ data_in[0] ^ data_in[5] ^ data_in[6]; assign lfsr_next crc_en ? lfsr_c : lfsr_q; always (posedge clk or negedge rst_n) begin if (!rst_n) lfsr_q 32hffff_ffff; else lfsr_q lfsr_next; end assign crc_out lfsr_q; endmodule关于CRC32有几个必须知道的坑。第一发送时CRC初值是0xFFFFFFFF计算完成后要把结果按位取反再发送这就是以太网标准里的补码CRC。直接发送原始lfsr_q会导致对端校验失败。第二CRC32在以太网中是按字节处理的不是按32位字。如果你的用户数据是32位宽就要注意字节顺序转换。第三CRC计算范围是从目的MAC地址的第一个字节到IP包、UDP包数据结束为止不包括前导码和CRC本身。发送侧在MAC_HEAD状态之前就应该启动CRC使能否则会把前导码也算进去。再来看UDP校验和。UDP校验和的计算比较特殊它引入了一个伪头部的概念计算时在UDP头前面加上源IP、目的IP、协议号0x11和UDP长度组成一个12字节的伪头部然后对整个伪头部加上UDP头加上数据一起做反码求和最后取反填入校验和字段。如果计算结果为0则填0xFFFF注意发送端计算完如果反码求和结果为0x0000填入的是0xFFFF这是协议规定的特殊情况。工程上更常用的做法是先一次性计算伪头部加UDP头的校验和数据部分边发送边累加计算。这样不用先把整包数据缓存下来再算延迟更低。具体来说reg [31:0] checksum_acc; reg [15:0] checksum_result; always (*) begin checksum_acc 32h0; // 伪头部源IP高16位 源IP低16位 checksum_acc checksum_acc {SRC_IP[31:16], SRC_IP[15:0]}; // 目的IP checksum_acc checksum_acc {DST_IP[31:16], DST_IP[15:0]}; // 协议号(17) 0 checksum_acc checksum_acc {8h0, 8d17}; // UDP长度 checksum_acc checksum_acc udp_len; // UDP源端口、目的端口 checksum_acc checksum_acc {SRC_PORT, DST_PORT}; // UDP长度再次相加 checksum_acc checksum_acc udp_len; end // 在DATA状态下累加用户数据 always (posedge clk) begin if (state DATA tx_valid) checksum_acc checksum_acc {data_byte[7:0], 8h00}; end // 最终结果累加高位进位再取反 wire [15:0] checksum_final; assign checksum_final ~(checksum_acc[31:16] checksum_acc[15:0]);3.3 最小帧长填充技巧前文提到以太网帧最短64字节换算一下MAC头14字节IP头20字节UDP头8字节数据至少得22字节。如果用户数据少于22字节MAC层会对后面填充0到22字节。很多人想当然觉得填充0不就行了其实填充会导致接收端解析出错——接收端怎么知道哪些数据是真实数据哪些是填充字节答案是IP头总长度字段。IP头的第4到5字节记录了IP包总长度IP头加UDP头加真实数据接收端会按照这个长度来截取有效数据后面的填充字节自动丢弃。所以发送端在填充字节之后CRC32的计算要包含填充字节但IP头的总长度字段不能把填充字节算进去。这个细节必须严格对应不然对端要么解析出乱码要么Wireshark里报[Malformed Packet]。我自己第一次做的时候就在这里栽过跟头填了填充字节但忘了修正IP总长度抓包软件里一直提示长度不对排查了好几个小时。后来总结出一个顺序先根据用户数据长度算出IP总长度和UDP长度然后照这个长度发送数据数据不够就填充最后算CRC32。另外补充一个实际使用中的小技巧如果用户数据经常很短比如只有几十字节可以在上层就约定一个固定包长比如512字节短包统一填充这样接收端解析更简单发送状态机也不用频繁切换长度判断逻辑时序上会更稳定。4. 接收通路代码设计解析每一个字节4.1 接收状态机与字节对齐接收状态机比发送稍复杂难点在于字节对齐。RGMII接口的rx_ctl信号在上升沿表示数据有效下降沿表示错误标志数据在上升沿采到低4位下降沿采到高4位。这意味着你需要在每个时钟周期内采两次数据拼成一个完整字节——但第一次采到rx_ctl上升沿时当前的半字节是哪一位不同的PHY芯片可能有差异。这里给出一个通用的字节拼接逻辑reg [7:0] rx_byte; reg rx_byte_valid; reg [3:0] rx_nibble_low; reg [3:0] rx_nibble_high; always (posedge rgmii_rx_clk or negedge rst_n) begin if (!rst_n) begin rx_byte_valid 1b0; end else begin // 上升沿采样低4位 rx_nibble_low rgmii_rxd; // 下降沿采样高4位 // RGMII接口实现中常用IDDR原语 rx_byte {rx_nibble_high, rx_nibble_low}; rx_byte_valid rgmii_rx_ctl; end end实际工程中RGMII的接收侧数据捕获一般用IDDR原语完成Vivado和Quartus都有对应的原语。如果是Intel平台ALTDDIO_IN类似。这里我建议直接用原语而不是always (posedge clk or negedge clk)因为综合工具对后者支持不太好而且时序约束也不好做。IDDR的代码在官方模板库里能直接找这里不展开。接收状态机本身和发送类似只是方向反过来先检测IDLE状态下rx_ctl拉高然后依次解析MAC头、IP头、UDP头最后进入数据状态。需要注意IP头里的协议字段如果不是17UDP直接丢弃整包回到IDLE如果IP头总长度和UDP头长度字段不一致也做丢弃处理。4.2 校验逻辑与输出接口接收侧的校验主要做两件事CRC32校验和UDP校验和校验。CRC32校验比较简单整个包收完看CRC余数是否为0xC704DD7B——这是以太网CRC32校验的魔数计算完成后直接比对即可。如果你嫌麻烦也可以把接收到的CRC字节存下来和本地计算的CRC比较两者效果一样只是前者少存储4字节。UDP校验和的计算在接收侧稍微麻烦一点因为计算范围是伪头部加UDP头加数据。这里我采用边收边算的方式从解析到IP头里的源IP和目的IP开始累加然后累加协议号、UDP头前4字节接着边收数据边累加。收完再把UDP头里的校验和字段加进来做最终的反码求和判断。如果最终结果为0xFFFF校验通过否则丢弃。输出接口是简单的AXI-Stream风格user_rvalid拉高表示数据有效user_rlast表示最后一个字节。配合user_rready做流控——如果用户逻辑没准备好接收rready拉低接收状态机就要暂停读FIFO防止数据丢失。还有一个容易忽略的点接收FIFO的ALMOST_FULL标志应该接到状态机上当FIFO快满时接收状态机要暂停读取RGMII数据。但问题在于RGMII接口是连续采样的暂停读取会导致后续数据覆盖前面的数据——所以根本解法还是FIFO深度要够或者把接收侧改成暂停读取时拉低对端PHY的流控信号。不过在实际调试中PC端发UDP包的速率通常远低于千兆线速只要不是打流压测FIFO深度512就够用了。5. 仿真验证上板之前先让它飞起来5.1 testbench的搭建思路UDP模块的仿真要分两段来写发送通路仿真和接收通路仿真这两段逻辑差别很大混在一起写容易被互相干扰。发送通路的testbench核心是模拟用户逻辑向user_tdata写入一个已知内容的数据包然后在rgmii_txd上捕获输出按协议解析成以太网帧再和预期值对比。我的建议是把解析逻辑直接写成task这样一次写好后后面加测试用例只需换个数据源就行。一个关键技巧是仿真时不能只对着rgmii_txd看波形要把发送的每一字节都收集到一个数组里等整包收完后统一解析。这样检查CRC、IP头、UDP头是否正确一目了然不用在波形图里一个字节一个字节地数。写testbench的时候可以用Verilog的$fwrite把字节流导出成文件再用Python或者Wireshark的text2pcap工具转成pcap直接打开看协议解析结果——这个工作流我在实际项目中用了很久效率非常高。接收通路的testbench就要反过来构造一个以太网帧的字节流通过rgmii_rxd和rgmii_rx_ctl输入到DUT然后在用户接口端验证解析结果。这里要注意构造以太网帧时CRC32必须算对否则DUT会一直丢弃仿真波形一片空白还排查不出原因。建议也写一个task负责自动计算CRC32不要手算。5.2 常见仿真Bug与排查仿真阶段常见的Bug我列几个出现频率最高的第一RGMII接口双沿采样。如果testbench里用#4延迟控制数据变化但时钟周期是8ns那么上升沿和下降沿采样的数据会有竞争问题。正确做法是用时钟的上升沿和下降沿分别驱动数据或者用虚拟时钟的(posedge clk)和(negedge clk)分两次赋值。第二CRC32计算把前导码也算进去了。这个问题仿真时看不出来因为CRC比较的是输出值输出值不匹配而rx_crc_error信号根本没拉高——需要对比以太网帧里自带CRC和本地计算CRC如果两者不一致多半是使能信号的时序提前了一个周期。建议CRC使能用状态寄存器打一拍从PRE状态开始使能到了MAC_HEAD正式计算。第三状态机死锁。最常见的是发送数据还没发完FIFO就已经读空状态机一直在DATA状态空转。这个可以在testbench里故意构造短包——用户数据长度等于1字节FIFO写入后立即读模拟边界情况。还有一种是我自己遇到过的tx_ctl信号在IDLE状态下没有拉低导致对端PHY一直认为数据有效接收方解析错位。仿真过了不代表上板能通但仿真没过肯定上板不通。我见过不少同学跳过仿真直接上板调试最后被各种时序问题折腾到怀疑人生。仿真阶段的付出在后面积累到PC端网络协议栈这种复杂模块时能帮你省下无数排错时间。6. 上板实测与调试经验6.1 用Wireshark验证你的UDP报文仿真通过以后上板调试的第一步不是直接和上位机通信而是先做一次自发自收测试让FPGA自己向自己发送UDP包同时用Wireshark在PC端抓包。为什么这么做因为如果PC端工具箱和数据通路都有问题你没法判断是FPGA发的问题还是PC收的问题。先保证本地链路正确再去联调外部设备。在Wireshark里看到FPGA发过来的UDP包优先看这几个地方MAC地址是否正确源MAC和目的MAC不能反。很多PHY芯片会过滤掉非本机MAC的包一旦MAC写错包根本到不了上位机。IP首部校验和Wireshark会在IP层自动校验如果报Header checksum incorrect说明IP头里的校验和计算错了。这里有个细节IP头校验和和UDP校验和不同IP头校验和只算IP头那20字节不算UDP和数据。UDP长度字段如果报Length异常多半是IP总长度或者UDP长度在填充字节的处理上没对齐。校验和错误Wireshark会在UDP层标红。如果接口的checksum offloading开着可能需要先关掉才能看到错误标志。6.2 回环测试与性能评估链路通了以后建议做一次完整的回环测试PC端通过网络调试工具比如SocketTool、野人调试助手都行向FPGA发数据FPGA收到后原封不动地发回来。这样能同时验证接收和发送通路。回环测试通过以后再跑一下性能测试。如果你的FPGA逻辑时钟是125MHzRGMII接口理论上可以跑千兆线速——但用户逻辑常常处理不过来。这里有个实际经验不要一上来就追求线速先用小包64字节测吞吐再用大包1472字节这个长度对应MTU 1500减去UDP和IP头测吞吐看看差距在哪里。我用iperf3做过实测一个简化的UDP模块256字节小包大概只能跑到600Mbps左右1472字节大包能到940Mbps。瓶颈往往在用户接口的数据宽度——如果用户接口是32位宽125MHz下理论带宽就是500MB/s换算成UDP吞吐大约是940Mbps基本符合预期。如果用户逻辑时钟只有100MHz那上限就卡在100MHz×4字节400MB/s再优化MAC层也没用。另外UDP发送的间隔控制也很重要。有些FPGA工程里用户逻辑一股脑把数据塞进FIFO发送状态机拼命发PC端网卡可能因为缓冲区溢出丢包。这时候需要在发送端做包间隔控制最简单的做法是在PAD_CRC状态结束后插入几个空闲周期或者在更上层做一个APB/AXI寄存器接口让上位机可以动态配置发包间隔。调试过程中我建议把udp_tx模块里的计数器和状态值引到ILA集成逻辑分析仪里触发条件选state DATA这样能看到每个状态停留的周期数快速定位状态机卡住的位置。这个排查方法比对着rgmii_txd的信号看要高效得多。6.3 回头看看那些年踩过的坑写这个模块前前后后调试了两周把踩过的坑总结一下基本都是网上查不到、只能自己踩的第一个坑是复位问题。PHY芯片的复位需要上电后拉低至少10ms但FPGA内部的复位逻辑如果也依赖PHY复位完成信号可能会出现FPGA逻辑已经跑起来、PHY还在复位的情况。解决方法是FPGA和PHY分开控制复位PHY复位用专门的复位模块内部逻辑复位只依赖FPGA自身的复位信号。第二个坑是时序约束。RGMII接口如果不加约束综合工具默认按理想时钟处理上板后时序大概率不过。Vivado里面要新建一个XDC文件对rgmii_tx_clk做create_generated_clock处理并且对rgmii_txd和rgmii_tx_ctl设置set_output_delay。这个约束值不同PHY芯片略有差异参考PHY数据手册里的tSU/tH参数计算。具体来说RGMII标准规定TXD在TX_CLK上升沿前约2ns建立所以set_output_delay -clock [get_clocks {rgmii_tx_clk}] -max 2.0 [get_ports {rgmii_txd[*]}]这类约束是常见做法。第三个坑是字节序。我从PC端发出的数据到了FPGA收到的字节顺序总是反的——原因是以太网是Big-Endian传输而PC端本地存储是Little-Endian。FPGA内部如果按32位字处理数据要把{byte0, byte1, byte2, byte3}重新排成{byte3, byte2, byte1, byte0}才能和PC端对应上。这个坑极其隐蔽光靠时序分析根本查不出来只有对比收发数据内容才能发现。每次我回头看这个UDP模块的代码都觉得如果一开始就有人提醒这些坑能少走不少弯路。这也是为什么我在同事带新人时第一课就让他们从最原始的MAC层开始手写UDP而不是直接塞一个现成协议栈——有些东西只有亲手踩过坑才能真正长成自己的肌肉记忆。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进