
做FPGA这件事从点灯到能上网中间隔着一条看不见的河。前几篇咱们通过UART把数据从板子搬到PC一次一个字节调试的时候感觉还行但真要做图像传输、高速采集回传这类正经应用那个带宽完全不够看。于是很多人就把目光转到以太网上而以太网里最适合入门、也最实用的就是UDP。UDP的优势在于它“无状态”——没有TCP那种三次握手、可靠重传、滑动窗口的复杂机制协议头短处理逻辑直来直去天然适合FPGA这种用硬件状态机去拼数据的器件。这篇Part.8把UDP模块的代码设计从头到尾过一遍从RGMII物理接口怎么转成GMII、MAC帧怎么解、IP/UDP头怎么过滤到发送时CRC32怎么算、帧怎么组装最后再用Wireshark和iperf3验证模块真的通了。适合已经有Verilog基础、跑通过FIFO和UART的读者读完你会得到一个能实际收发数据的UDP最小系统。代码均以Xilinx 7系列和常见RTL8211/GMII-to-RGMII组合为例其他平台思路完全通用。1. 写UDP模块之前先想明白你不需要什么1.1 为什么是UDP而不是TCP很多初学者一上来就问能不能直接写TCP我的建议是把TCP留到后面。TCP为了保证可靠传输要维护序列号、确认号、超时重传、拥塞窗口、乱序重组这些在软件里都是几十行代码的事放到硬件状态机里就变成了一坨复杂到让人想摔板的逻辑。想想看光是一个“等待ACK超时后重传”的分支就要同时处理发送状态、计时器、重传缓冲三个维度在FPGA里做出来调试成本极高而且绝大多数入门项目根本用不上它。UDP则完全相反接收方不看序列号不回应答不发窗口通告包丢了就丢了。协议栈需要处理的只有“把帧头剥掉、把payload拿出来”这一件事。它甚至不保证包顺序这在硬件上反而是个优势——因为这意味着收发通路可以做成两条互不干扰的流水线发和收互不等待。用一句话概括UDP是局域网高速传输问题的90%解而那缺失的10%可靠性通常用应用层的ACK机制去补而不是压在FPGA的协议栈里。1.2 最小功能集怎么裁剪那么一个“能跑起来”的UDP模块最少需要哪些功能我建议第一版只做以下几件事固定本机MAC和IP地址编译时用parameter写死接收方向识别发给本机的UDP包剥掉MAC/IP/UDP头部把payload写入FIFO发送方向从FIFO读数据拼上UDP头、IP头、MAC头加上CRC32后从PHY口发出去处理ARP请求并回ARP应答否则PC根本不知道FPGA的MAC地址UDP永远发不过来。有些教程还会让你做ARP缓存表、VLAN解析、巨型帧支持这些对第一版都是负资产。以最常见的直连PC场景为例PC的ARP缓存一旦拿到FPGA的MAC地址整个会话期间就固定了FPGA侧完全不需要维护什么ARP表只要做到“谁来问我我就回答谁”就行了。同理VLAN tag在绝大多数实验室环境里根本不会出现直接按“EtherType在长度/类型字段偏移12字节处”去解析即可。1.3 自己写RTL还是用现成IP核Xilinx有Tri-Mode Ethernet MACTEMAC和AXI Ethernet这些IP核功能完整、经过硅验证但缺点也很明显首先是配置界面那一大堆选项什么“VLAN support”“1588 timestamp”“flow control”新手看着就懵很多人连Generate成功都要折腾半天其次是仿真模型不友好IP核生成后的仿真需要额外的license或模型文件换成自写RTL则完全不需要。更要命的是TEMAC只做到MAC层UDP/IP的逻辑还得自己写与其两边对接不如直接一鼓作气全写出来。这里有一个真实的使用感受对于1Gbps以下的以太网、数据率不超过几百Mbps的应用自己写的简化MACUDP模块在资源上通常只用几百个LUT和个位数Block RAM性能上完全够用而且每一行代码你都知道它在干什么——这意味着出了问题你可以用逻辑分析仪一层层去查而不是面对一个黑盒。IP核真正发挥价值是在10G/25G这种高速率、需要大量调优和TSN等高级特性的场景那已经是另一个体量的工程了。所以我的结论非常明确如果是学习目的或者项目只需要固定的单链路UDP收发不要犹豫直接自己写。等你把这个最小模块跑通之后再回头看TEMAC的文档会突然觉得那些选项都不难理解了——因为你已经知道背后每一层到底在做什么。2. 数据从网线到FIFO要过几道关2.1 物理层的RGMII接口现在的FPGA开发板几乎都外接PHY芯片最常见的是RTL8211、KSZ9031这类千兆PHYFPGA和PHY之间的接口标准是RGMII。RGMII的全称是Reduced Gigabit Media Independent Interface它把传统GMII的8位数据线减半成4位靠双沿采样补足带宽——125MHz时钟的上升沿和下降沿各采4位拼起来就是8位。这个设计的巧妙之处在于把引脚数从24根减少到12根左右PCB好画FPGA引脚也省得多。对FPGA开发者来说RGMII的关键信号就那么几个发送时钟TX_CLK千兆时125MHz百兆时25MHz由MAC即FPGA提供输入给PHY、接收时钟RX_CLK由PHY恢复出来送给FPGA、4位发送数据TXD[3:0]、4位接收数据RXD[3:0]以及TX_CTL/RX_CTL携带数据有效标志和错误标志。需要注意千兆模式下TXD和TX_CTL都是DDR信号上升沿发低4位下降沿发高4位所以FPGA侧必须用ODDR原语或者等价逻辑去做输出。这里有个新手特别容易懵的点百兆模式下的TX_CLK是谁给的在RGMII规范里百兆的TX_CLK可以由PHY提供也可以由MAC提供具体看PHY芯片的配置。为了减少第一版的复杂度我建议直接把PHY配置成千兆自协商模式速率锁定在1000Mbps这样时钟关系最简单一个125MHz时钟TX和RX都是双沿。等千兆跑通了再回头研究10M/100M的时钟切花不迟。2.2 MAC帧以太网世界的集装箱以太网MAC帧就是网线上跑的最小数据单元结构固定如下字段长度说明前导码Preamble7字节每字节0x55用于接收方时钟同步帧起始定界符SFD1字节0xD5标志着MAC帧正式从下一字节开始目的MAC地址DA6字节接收方网卡地址广播地址全FF源MAC地址SA6字节发送方网卡地址EtherType/长度2字节0x0800表示IPv40x0806表示ARPPayload46~1500字节用户数据不够46字节须填充FCS帧校验4字节CRC32覆盖DA到Payload的所有字节在FPGA里接收数据时前导码和SFD是在_MAC层解析_的看到连续7个0x55再跟一个0xD5后面出来的字节才算有效帧的开始。也正因为有前导码PHY送过来的RX_DV有效信号在RGMII下并不是一上来就为高的——它在SFD之后才稳定为高所以我们的状态机不能光靠RX_DV判断“帧开始了”还得做一个前导码检测。以太网帧的最小长度是64字节从DA算起到FCS结束。如果UDP包很小比如只发几个字节的负载那么MAC帧总长度不足64字节发送方必须填充。很多第一次做UDP的人在这里栽跟头他们以为“发多少就填多少”结果抓包软件显示“Frame check sequence incorrect”或者干脆收不到因为PC网卡把小于64字节的帧当runt帧直接丢弃了。2.3 IP和UDP头真正的地址信息在哪MAC帧EtherType为0x0800时Payload部分就是IPv4报文。IPv4头的关键字段如下字段长度说明Version/IHL1字节通常0x45IPv4且头长20字节Total Length2字节IP头UDP头UDP负载的总长度Protocol1字节0x11表示UDP源IP4字节发送方IP地址目的IP4字节接收方IP地址Header Checksum2字节IP头校验和接收时通常可以忽略不验UDP头的结构更简单源端口2字节、目的端口2字节、长度2字节UDP头8字节负载、校验和2字节。IPv4下UDP校验和是可选的设置为0x0000表示未计算校验但要注意如果计算出的校验和正好是0x0000则必须按0xFFFF发送这是RFC 768的规定。第一版为了简化直接填0x0000完全没问题Wireshark会显示“Checksum: 0x0000 (none)”PC端照样能收。接收方向判一个包是不是“发给我的UDP包”需要依次检查目的MAC是否等于本机MAC、EtherType是否为0x0800、IP头的IHL是否为0x45、目的IP是否等于本机IP、Protocol是否为0x11、目的端口是否等于本机配置的端口。这六条全过才把后面的数据当有效payload送出。少验任何一条都可能出现“明明在抓包软件里看到了包但FIFO里就是没数据”的诡异现象。2.4 整条数据通路的轮廓把上面的层次串起来接收方向的数据流是这样的RGMII_RXD - RGMII转GMII - 前导码检测 - MAC帧解析 - IP头解析 - UDP头解析 - payload写入FIFO - 用户逻辑读取发送方向则完全反过来用户逻辑写FIFO - payload读出 - 填充UDP头 - 填充IP头 - 组装MAC帧 - CRC32计算 - 并串转换 - RGMII_TXD输出这里有个非常重要的架构决策接收通路和发送通路必须完全独立各自有自己的状态机和FIFO不要做成“一个状态机管收发”。原因有两点第一UDP通信天然是双向的PC可能在发数据的同时也在等FPGA回数据如果收发纠缠在一起很容易出现死锁第二调试难度完全不同两条独立通路意味着你可以在XML中单独屏蔽发送只做接收测试把问题定位到具体某一条链路。很多网上的例程为了展示方便做了一个loopback——收到的UDP payload原样再发回去。这个做法我极力推荐作为第一个demo因为它同时验证了接收和发送两条通路而且回环测试不需要PC端写任何应用层代码直接用网络调试助手发一串数据收回来一模一样就算基本通了。后面的章节我会以“接收通路怎么拆”“发送通路怎么拆”两条线分别展开再专门讲联调验证。3. 接收通路代码拆解从RGMII引脚到用户FIFO3.1 RGMII转GMIIPHY芯片送过来的是4位DDR数据FPGA内部逻辑是8位单沿处理所以第一步必须做转换。思路很简单用RX_CLK上升沿采低4位下降沿采高4位拼成8位。对应的伪代码如下reg [3:0] rx_data_r; // 上升沿采到的低4位 reg [3:0] rx_data_f; // 下降沿采到的高4位 reg rx_ctl_r; // 上升沿采到的控制信号 reg rx_ctl_f; // 下降沿采到的控制信号 always (posedge rgmii_rx_clk) begin rx_data_r rgmii_rxd; rx_ctl_r rgmii_rx_ctl; end always (negedge rgmii_rx_clk) begin rx_data_f rgmii_rxd; end wire [7:0] gmii_rxd {rx_data_f, rx_data_r}; wire gmii_rx_dv rx_ctl_r;注意几个坑。第一这里的gmii_rxd是把两个不同时钟沿采到的值拼接出来的组合逻辑务必用寄存器打一拍再进后续状态机避免毛刺。第二gmii_rx_dv只取了RX_CTL上升沿的值原因是RGMII 2.0规范里RX_CTL下降沿携带的是“(RX_DV) XOR (RX_ERR)”信息对第一版来说完全可以忽略但如果你发现偶尔有多余的数据冒出来就要检查是不是把错误标志也识别成有效数据了。第三Xilinx 7系列在FPGA的IOB里通常直接用IDDR原语逻辑上等价于上面这段代码但时序更可控IDDR #(.DDR_CLK_EDGE(SAME_EDGE_PIPELINED)) u_iddr_d0 ( .Q1(rx_data_r[0]), .Q2(rx_data_f[0]), .C(rgmii_rx_clk), .D(rgmii_rxd[0]) );3.2 MAC接收状态机拿到8位并行数据后下一步是把以太网帧的头信息解析出来。常用的是一个经典的字节计数状态机方案状态机从IDLE开始检测前导码然后依次进入DA、SA、EtherType、Payload等状态。这里我推荐的做法不是为每个字段单独写状态而是用一个“当前是帧内第几个字节”的计数器加case判断代码更紧凑也方便扩展localparam IDLE 4d0; localparam SFD_WAIT 4d1; localparam MAC_DA 4d2; localparam MAC_SA 4d3; localparam ETH_TYPE 4d4; localparam IP_HEADER 4d5; localparam UDP_HEAD 4d6; localparam PAYLOAD 4d7; localparam FCS_CHECK 4d8; reg [3:0] state; reg [11:0] byte_cnt; // 当前处理到帧内第几个字节 always (posedge rx_clk) begin if (!rx_dv) begin state IDLE; byte_cnt 0; end else begin case (state) IDLE: begin if (gmii_rxd 8h55) state SFD_WAIT; else state IDLE; end SFD_WAIT: begin if (gmii_rxd 8hD5) state MAC_DA; else if (gmii_rxd 8h55) state SFD_WAIT; else state IDLE; // 前导码异常放弃本帧 end MAC_DA: begin byte_cnt byte_cnt 1; if (byte_cnt 11) state ETH_TYPE; // DA6SA6-111 end ... endcase end end关键点是“什么时候开始存DA”。前导码和SFD不属于MAC帧必须从DA的第一字节才开始把数据写入接收FIFO或寄存器。但这里有个陷阱你不能等收到第12个字节EtherType的第二个字节再决定“前面11个字节要不要存”因为判断“这是不是发给本机的组播/广播包”必须从DA第一字节就开始比对。所以工程上常见处理是反正MAC帧完整长度最大也就1518字节先全部存进一个RAM/FIFO等EtherType和IP头都解析完再决定“这个包到底要不要往用户FIFO写”。如果你不想为无效包浪费存储则可以在DA阶段实时比对目的MAC一旦发现不匹配就标记“丢弃本帧”同时清掉之前已缓存的数据。3.3 IP/UDP过滤帧的结构解析出来之后下一步是做IP和UDP层的过滤。这里有个加速技巧MAC帧的头是定长的DA 6字节 SA 6字节 EtherType 2字节 14字节IPv4头一般情况下也是20字节UDP头8字节也就是说从帧起始算起第23字节就是UDP目的端口的高字节第35字节开始就是UDP payload。于是过滤器不需要等整个帧都收到只需在特定字节序号上做比较// 帧内字节偏移 // 0~5 : DA // 6~11 : SA // 12~13 : EtherType (120x08, 130x00 表示IPv4) // 14 : Version/IHL 应为0x45 // 22 : Protocol 应为0x11 // 23~24 : 源IP前两字节 // 26~27 : 目的IP后两字节 // 30~33 : UDP目的端口等然后写一个组合逻辑判断是否“本机命中”wire frame_is_ipv4 (eth_type 16h0800); wire ip_is_udp (ip_protocol 8h11); wire ip_dst_match (ip_dst LOCAL_IP); wire udp_port_match (udp_dst_port LOCAL_PORT); wire arp_request (eth_type 16h0806);这里还需要单独提一下ARP。PC在发UDP之前会先发一个ARP请求“谁是这个IP地址请告诉我你的MAC”。FPGA收到之后必须回一个ARP应答否则PC会报“Destination host unreachable”。ARP请求的特征是EtherType0x0806ARP头中操作码字段为1请求目的IP等于本机IP。应答时交换源/目的MAC和IP操作码改成2即可。这部分代码量也就30行左右但它是UDP能通的前提绝对省不掉。3.4 用户数据输出与时序过滤逻辑全部通过之后payload写入用户FIFO。这里最重要的一个决定是payload的wr_en应该从哪一个字节开始拉高。一种做法是把payload的所有字节包括UDP头部都先存进一个缓冲RAM然后再由后续逻辑把RAM里的UDP头剥掉只输出payload这样代码清晰但多占一份存储。另一种做法是在解析状态机里精确控制写使能当状态机走到PAYLOAD状态时才拉高wr_en这也是我推荐的方式wire wr_en (state PAYLOAD) rx_dv; wire [7:0] wr_data gmii_rxd; fifo_wrapper #(.DATA_WIDTH(8), .DEPTH(4096)) u_fifo_rx ( .clk(rx_clk), .wr_en(wr_en), .wr_data(wr_data), .rd_en(fifo_rd_en), .rd_data(fifo_rd_data), .full(rx_fifo_full), .empty(rx_fifo_empty) );这样写的好处是用户侧拿到的FIFO数据已经是纯UDP负载不需要再做任何协议剥离。但要注意一个细节wr_en必须在最后一个有效payload字节之后立即拉低也就是当RX_DV变低、状态机回到IDLE时写使能必须同步拉低否则会把“PHY在帧间隙发送的空闲符号”也当成数据写进FIFO。经验做法是用rx_dv state PAYLOAD做组合写使能rx_dv下降沿天然截止了FIFO写入可以避免多写一个字节的问题。还要考虑FIFO跨时钟域的问题。PHY的RX_CLK和用户逻辑时钟通常是不同的时钟域比如用户逻辑跑150MHzRX_CLK是125MHz。所以接收FIFO一定用异步FIFOXilinx下用FIFO generator IP或xpm_fifo_async原语把数据从RX时钟域搬到用户时钟域。这一步省不得很多新手图省事直接把rx_clk和user_clk都接到同一个125MHz时钟上板子上也可能能跑但一旦用到不同频率的时钟就会出随机性丢包。4. 发送通路代码拆解把数据打包成能在网线上跑的帧4.1 CRC32到底怎么算发送方向最容易被“看似简单、实则到处是坑”的就是CRC32。以太网FCS用的CRC32多项式是0x04C11DB7但网线传输时采用的是反射算法实际实现用的反转多项式是0xEDB88320初始值为0xFFFFFFFF处理后结果再异或0xFFFFFFFF。逐bit的算法可以写成logic [31:0] crc_q; logic [31:0] crc_next; logic crc_fb; always_comb begin crc_fb crc_q[0] ^ data_in_bit; crc_next (crc_q 1) ^ (crc_fb ? 32hEDB88320 : 32h0); end always_ff (posedge tx_clk or posedge rst) begin if (rst) crc_q 32hFFFF_FFFF; else if (crc_clear) crc_q 32hFFFF_FFFF; else if (crc_en) crc_q crc_next; end // 帧的全部字节DA到payload末尾处理完后: // fcs_4bytes ~crc_q; // 发送顺序: 先发 fcs[31:24], 再 fcs[23:16], fcs[15:8], fcs[7:0]这里有三个坑必须说清楚。第一是比特序字节从GMII总线进入CRC计算器时必须是LSB在前也就是每字节的第0比特先进入。GMII线上本身是MSB先传高比特在前所以你收到的gmii_rxd[7:0]不能直接从bit7往CRC里塞而要从bit0开始。第二是字节序最终算出来的~crc_q先发高位字节还是低位字节不同教程写法不同实测以太网是“最高字节先发”即crc[31:24]先上线路。第三是计算范围CRC只覆盖DA到payload的最后一个字节不包含前导码/SFD也不包含FCS本身。如果在PC上用Wireshark抓包看到“Frame check sequence incorrect”十有八九是上面三个顺序问题中的一个。这个我在第6章的排错实录里还会展开因为它的排查思路对其他协议问题也通用。4.2 帧组装每个字节都不能错位发送状态机本质上就是“按顺序把一帧的字节逐个送到发送FIFO或直接送到GMII输出”。以发送一个UDP包为例输出的字节顺序是7字节0x55前导码 1字节0xD5 目的MAC(6字节) 源MAC(6字节) EtherType0x0800(2字节) IP头(20字节): 0x45, 0x00, 总长度, ID, 标志/片偏移, TTL, 0x11, 校验和, 源IP, 目的IP UDP头(8字节): 源端口, 目的端口, UDP长度, 校验和(0x0000) payload(若干字节不足则填充0) CRC32(4字节)这里最容易出错的是“总长度字段”。IP头里的Total Length指的是“IP头UDP头payload”的总长度不含MAC帧头UDP头里的Length指的是“UDP头payload”的长度。初学者容易把这两个字段搞混或者把MAC头的14字节也算进去。假设payload长度是100字节那么UDP Length81001080x006CIP Total Length201081280x0080。至于IP头里的Header Checksum接收端PC通常会校验但很多简化设计直接填0因为Windows和Linux都对“IP校验和为0”的包容忍度较高。不过我还是建议顺手算一下IP头校验和的算法非常简单把IP头按16bit相加进位回卷取反。20字节的IP头只需要10次16bit加法几十行状态机就能完成而且是只加头不改payload发送时计算一次即可。发送侧还需要处理一个细节当用户数据很少时比如只发1字节payloadMAC帧总长度只有142081447字节小于64字节必须在payload和FCS之间填零补足到至少60字节这样加上FCS才64字节。填充的零也要参与CRC计算。有些网上的例程忘了这一条短包发出去PC网卡直接丢掉表现出来就是“大包能收到小包收不到”非常迷惑。4.3 RGMII输出的DDR时序GMII的8位数据要变成RGMII的4位DDR输出FPGA侧的做法是上升沿发低4位下降沿发高4位。用逻辑写就是reg [3:0] tx_data_r; reg [3:0] tx_data_f; reg tx_ctl_r; reg tx_ctl_f; always (posedge tx_clk) begin tx_data_r gmii_txd[3:0]; tx_ctl_r gmii_tx_en; tx_data_f gmii_txd[7:4]; end always (negedge tx_clk) begin tx_data_f gmii_txd[7:4]; tx_ctl_f gmii_tx_en; end assign rgmii_txd tx_clk ? tx_data_r : tx_data_f; assign rgmii_tx_ctl tx_clk ? tx_ctl_r : tx_ctl_f;这个写法的风险在于组合逻辑输出容易有毛刺而且时序约束不好做。实际工程中Xilinx平台更推荐用ODDR原语因为ODDR在IOB内部位置固定、时序受控ODDR #(.DDR_CLK_EDGE(SAME_EDGE)) u_oddr_txd0 ( .Q(rgmii_txd[0]), .C(tx_clk), .CE(1b1), .D1(gmii_txd[0]), // 上升沿输出 .D2(gmii_txd[4]) // 下降沿输出 );还有一个重要问题是TX_CLK从哪里来。RGMII千兆模式下125MHz的TX_CLK必须由MACFPGA提供给PHY。如果你在Xilinx 7系列上做最直接的办法是MMCM/PLL生成125MHz然后通过ODDR原语输出到PHY的TX_CLK引脚并且加上create_clock约束。别忘了PHY通常还需要一个参考时钟常见的是25MHz或125MHz接在PHY的时钟引脚上具体看板子原理图。我第一次做的时候只想着给TX_CLK忘了PHY自己的参考时钟没配置结果PHY完全没起来数据发了个寂寞。5. 联调验证用Wireshark和iperf3把模块“打到吐”5.1 板卡直连PC的第一个500行模块写完仿真也过了下一步就是上板。第一个验证场景我强烈建议用网线直连PC和FPGA板不要经过交换机或路由器少一层变量。PC网卡设置静态IP比如192.168.1.100/24FPGA侧固定本机IP为192.168.1.10MAC地址随便写一个但别和局域网里的设备冲突比如00:11:22:33:44:55。注意两者必须同一网段否则ARP会一直不成功。上电后先别急着发数据先用Wireshark在PC上抓包。你可能会看到PC在开机时发了一些广播包比如DHCP请求虽然没DHCP服务器以及当PC尝试和FPGA通信时发出的ARP请求。如果FPGA的ARP应答代码正常Wireshark里会看到“192.168.1.10 is at 00:11:22:33:44:55”的应答。这一步是“UDP模块活了”的第一个信号比任何调试工具都直观。如果没有ARP应答优先检查FPGA是不是收到了ARP请求——用Vivado的ILA抓gmii_rxd和gmii_rx_dv看到连续0x55 0x55 0xD5就说明PHY已正常工作问题出在解析逻辑上。5.2 wireshark看ARP、UDPARP通了之后可以用网络调试助手或Python脚本向FPGA的端口比如5005发一串UDP数据。此时在Wireshark里过滤udp.port 5005或者ip.addr 192.168.1.10应该能看到数据包里面MAC头、IP头、UDP头都被标注出来。这里有几个点要会看如果Wireshark显示[Expert Info (Error/Sequence): New frame]但FCS正常说明帧本身没问题只是Wireshark对RTP等更高层解析有猜测如果显示“Destination unreachable (Port unreachable)”那可能是PC发了UDP但FPGA没回ICMP错误——其实对FPGA来说不回ICMP是正常的因为我们的模块不会生成ICMP只要数据FIFO里能读到就行如果抓到的帧显示“Invalid frame check sequence”问题一定在发送方向的CRC参考4.1节的三个顺序去查。验证接收通路是否真正把数据写进了FIFO可以用最简单的办法在Vivado里加入ILA观测wr_en和wr_data。ILA触发条件设为wr_en 1b1 state PAYLOAD一旦PC发送数据就能看到FIFO写端口上的数据流。如果ILA能看到数据、但用户逻辑读FIFO读不出来那就去查异步FIFO的读时钟和读侧状态机多数是读使能时序没拉对。5.3 iperf3打流和收发统计链路基本通了之后就该测吞吐了。iperf3是Linux下最常用的打流工具Windows版本也直接可用。发UDP时命令大概是iperf3 -u -c 192.168.1.10 -p 5005 -b 200M -l 1400 -t 10注意这里-c指定的是接收端IP-b是目标带宽-l是每个UDP包payload大小1400是一个比较安全的值UDP payload最大1472但留点余量避免IP分片。跑完之后iperf3会输出“Sent X datagrams”“Received Y datagrams”“Lost Z datagrams”之类的统计。这个数字就是后续优化最直观的度量。如果是FPGA往PC发则PC端要开一个接收程序iperf3的server模式在Windows下可以用iperf3 -s开起来然后FPGA作为发送端。不过大部分FPGA入门阶段做的是“PC发到FPGA”所以-c指向FPGA即可FPGA内部用计数器统计收到的包数量通过UART打印出来和iperf3报的发送数对比。两边的数字一致就说明接收通路不丢包不一致且有丢包优先检查接收FIFO是不是满了没及时处理、时钟域是否真做了异步处理以及PC网卡的UDP接收缓冲区是不是太小——Windows的UDP缓存默认值偏小高速打流时会在操作系统层面就丢掉数据包这也是很多人在PC侧看到丢包而FPGA侧完全正常的原因。5.4 回环模式回环测试是验证收发两条链路的最强组合拳。实现方式特别简单把接收FIFO读出来的数据直接接到发送FIFO的写端口形成一个数据乒乓// loopback: rx_fifo_rd - tx_fifo_wr wire loop_wr_en ~rx_fifo_empty ~tx_fifo_full; wire [7:0] loop_data rx_fifo_rd_data; fifo_wrapper u_fifo_tx ( .clk(user_clk), .wr_en(loop_wr_en), .wr_data(loop_data), .rd_en(tx_rd_en), .rd_data(tx_payload), ... );当PC发送一串“hello FPGA”到FPGA的5005端口FPGA把它原样从发送端口发回目的IP和目的端口可以从收到的UDP头里取也可以固定配置成PC的IP和端口。PC端网络调试助手会收到一模一样的回包。这样一次测试就把接收解析、FIFO、发送组装、CRC全部串起来了任何一环有问题都会体现在“收不到回包”或者“回包内容错乱”上。有一点要提醒回环模式下FIFO的读写速率要匹配否则接收快时发送慢会产生背压。最简单的做法是把接收FIFO深度开大一些比如8K字节然后在回环路径上做一个简单的4字节缓冲/拼接逻辑保证读写稳定。真要打满千兆带宽回环还要考虑MAC帧间隙IFG不能小于12字节否则PC端会丢帧。6. 排错实录三个让新手卡好几天的经典问题6.1 FCS/CRC对不上Wireshark疯狂报错现象FPGA发的UDP包在Wireshark里能看到但帧头提示“Frame check sequence incorrect”PC上层应用完全收不到数据。排查思路一上来就假设代码有bug是容易走弯路的。我建议先抓“对”的包打开Wireshark的同时用PC自己给自己发一个UDP包或者用其他正常的网络设备发包看Wireshark解析出的FCS字段和发送数据的关系然后对照你的CRC计算逻辑。比如正常PC发的一个简单UDP包FCS是0x2E B1 23 41这样的4字节你可以把帧从DA开始的所有字节记录下来用Python的binascii.crc32或在线CRC计算器算一下看看字节序和比特序的关系。实际排查下来九成问题是这三个中的某一个没有做“反射”以太网CRC输入是按bit LSB-first而不是直接按字节顺序MSB-first。很多初学者拿着标准的多项式0x04C11DB7直接算理由是“网上查的CRC32就是这么用的”但那个是普通CRC32比如ZIP压缩用的和以太网FCS虽然多项式同源输入输出反射规则不同。FCS字节发送顺序搞反。正确顺序是crc寄存器高位字节先发不少例程写成了低位先发结果永远是“差一点点”。填充字节没有参与CRC。前面说的短包填充如果填充的0没有送进CRC计算器FCS自然错误。解决之后的表现是Wireshark不再报FCS错网络层和应用层数据能正确解析。这个坑之所以难查是因为FPGA侧收不到任何反馈——PHY本身不检查发送数据的CRC它只负责把bit发出去所以plenty of时候看起来“数据确实送出去了但PC就是不认”。6.2 ARP不回复PC一直“Destination host unreachable”现象PC ping不通FPGA的IPWireshark里只有PC发出的ARP请求“Who has 192.168.1.10? Tell 192.168.1.100”没有来自FPGA的ARP应答。排查顺序建议这样走先确认FPGA收到了ARP请求。用ILA抓接收侧数据看EtherType是不是0x0806。如果ILA里连0x0806都没出现说明PHY到FPGA的接收链路有问题或者PC压根没发出请求可以先ping一下验证。确认ARP解析逻辑能识别“请求”。ARP报文结构是硬件类型0x0001、协议类型0x0800、硬件地址长度6、协议地址长度4、操作码0x0001请求、发送方MAC、发送方IP、目标MAC全0、目标IP。很多代码只判断了EtherType0x0806忘了检查操作码是1还是2结果把应答和请求混在一起处理。确认应答帧的填写没有错位。ARP应答里源MAC要填FPGA自己的MAC源IP填FPGA自己的IP目的MAC和目的IP填请求方PC的。容易错的是“目标MAC填成广播地址”或者“源/目的MAC填反”这两种Wireshark都能看出来因为ARP包里面字段会异常。最后才考虑时钟域问题。如果ILA里看到接收数据本身已经错乱连续0x55 0x55 0xD5都不出现但RX_DV为高那就要查RGMII转GMII的采样沿以及PHY的RX_CLK是否稳定。有朋友问我ARP应答能不能不做直接让PC在UDP包里用已知的FPGA MAC地址发答案是可以Windows的arp -s命令能手动绑定静态ARP项。但这样一来换一台PC或者重启网络就要重新绑定而且掩盖了真正的问题。把ARP做好相当于免费验证了整个接收通路——因为回复ARP本身就是一次完整的接收解析发送过程能跑通ARP说明底层链路已经基本健康。6.3 跨时钟域导致的“时好时坏”现象模块单次调试正常一跑起来偶尔丢包或者FPGA逻辑时钟改了频率之后接收数据完全乱掉又或者数据在FIFO里读出来对但发到PC端就是有字节错位。这类问题的根源几乎都在跨时钟域。一个UDP模块内部至少有四个时钟域PHY的RX_CLK125MHz、FPGA送给PHY的TX_CLK125MHz、用户逻辑时钟比如150MHz、以及复位释放时钟。如果你把接收FIFO从RX_CLK域搬数据到用户时钟域却没有用异步FIFO而是直接把rx_clk当作user_clk用或者用一个同步FIFO冒充那么在时钟相位差不稳定的情况下FIFO的读写指针会偶发冲突表现出来就是“偶尔多一个字节、偶尔少一个字节”。解决办法是严格区分时钟域边界PHY收到的所有数据包括前导码检测、MAC解析、IP/UDP过滤全部放在RX_CLK域直到payload写进异步FIFO用户逻辑从异步FIFO读数据放到用户时钟域发送侧从用户时钟域的发送FIFO读数据跨到TX_CLK域时再用一个异步FIFO或者直接让发送状态机跑在TX_CLK域用户侧通过异步FIFO向它喂数据。同时复位信号也要做异步复位同步释放Xilinx下用XPM_CDC_ASYNC_RST或自己写两级同步器否则复位释放时可能落在时钟边沿附近导致内部状态机进入非法状态。我当时在这个问题上卡了整整一个周末表现就是第一次上电大概率正常拔了网线重新插或者按一下复位键立刻开始丢字节。后来用ILA抓发送状态机的byte_cnt才发现复位释放后第一个包从第7个字节开始发——前导码只发了3个0x55就跳到了SFD因为状态机从复位里恢复的瞬间正好赶上TX_CLK的上升沿计数器值没有被清零干净。所以不要小看复位同步它在跨时钟域调试里的坑位比想象中多得多。6.4 顺带提醒别忽略了PHY的配置最后提一个不是UDP代码本身、但几乎人人都会踩的坑PHY芯片需要初始化配置才能工作。很多板卡的PHY支持通过MDIO接口配置寄存器来实现速率、自协商、CLK方向等设置也有不少PHY有硬件strapping引脚上电时根据引脚电平默认配置。如果你用的开发板是后者通常不需要写MDIO逻辑但前提是你得看原理图确认PHY的时钟输入、复位引脚、模式选择引脚都接对了。我见过不止一个案例代码完全正确、仿真也过了就是上板不通最后发现PHY的复位引脚被接到了一个没被驱动的GPIO上PHY一直处于复位状态。验证PHY是否正常工作最快的方法还是看RX_CLK只要PHY收到了自己的参考时钟且没有复位125MHz的RX_CLK引脚上就应该有连续时钟输出。用示波器或者Vivado的时钟监测看一下比花半天调代码高效得多。最后再分享一个实战里特别实用的小技巧每个版本的UDP模块都在发送状态机里留一个“调试模式”开关——把PC发过来的第1个payload字节作为回包目的端口的高字节第2个作为低字节这样你在PC端换端口测试时不用重新编译FPGA。这个功能在协议联调阶段能省下大量综合时间因为UDP模块本身改动很频繁每次都等十几分钟综合去改一个端口号非常浪费生命。等整个模块稳定了再把端口固化回去就行。