做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 MAC(TEMAC)和AXI Ethernet这些IP核,功能完整、经过硅验证,但缺点也很明显:首先是配置界面那一大堆选项,什么“VLAN support”“1588 timestamp”“flow control”,新手看着就懵,很多人连Generate成功都要折腾半天;其次是仿真模型不友好,IP核生成后的仿真需要额外的license或模型文件,换成自写RTL则完全不需要。更要命的是,TEMAC只做到MAC层,UDP/IP的逻辑还得自己写,与其两边对接,不如直接一鼓作气全写出来。
这里有一个真实的使用感受:对于1Gbps以下的以太网、数据率不超过几百Mbps的应用,自己写的简化MAC+UDP模块在资源上通常只用几百个LUT和个位数Block RAM,性能上完全够用,而且每一行代码你都知道它在干什么——这意味着出了问题你可以用逻辑分析仪一层层去查,而不是面对一个黑盒。IP核真正发挥价值是在10G/25G这种高速率、需要大量调优和TSN等高级特性的场景,那已经是另一个体量的工程了。
所以我的结论非常明确:如果是学习目的,或者项目只需要固定的单链路UDP收发,不要犹豫,直接自己写。等你把这个最小模块跑通之后,再回头看TEMAC的文档,会突然觉得那些选项都不难理解了——因为你已经知道背后每一层到底在做什么。
2. 数据从网线到FIFO要过几道关
2.1 物理层的RGMII接口
现在的FPGA开发板几乎都外接PHY芯片,最常见的是RTL8211、KSZ9031这类千兆PHY,FPGA和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帧就是网线上跑的最小数据单元,结构固定如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| 前导码Preamble | 7字节 | 每字节0x55,用于接收方时钟同步 |
| 帧起始定界符SFD | 1字节 | 0xD5,标志着MAC帧正式从下一字节开始 |
| 目的MAC地址DA | 6字节 | 接收方网卡地址,广播地址全FF |
| 源MAC地址SA | 6字节 | 发送方网卡地址 |
| EtherType/长度 | 2字节 | 0x0800表示IPv4,0x0806表示ARP |
| Payload | 46~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/IHL | 1字节 | 通常0x45,IPv4且头长20字节 |
| Total Length | 2字节 | IP头+UDP头+UDP负载的总长度 |
| Protocol | 1字节 | 0x11表示UDP |
| 源IP | 4字节 | 发送方IP地址 |
| 目的IP | 4字节 | 接收方IP地址 |
| Header Checksum | 2字节 | 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引脚到用户FIFO
3.1 RGMII转GMII
PHY芯片送过来的是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 = 4'd0; localparam SFD_WAIT = 4'd1; localparam MAC_DA = 4'd2; localparam MAC_SA = 4'd3; localparam ETH_TYPE = 4'd4; localparam IP_HEADER = 4'd5; localparam UDP_HEAD = 4'd6; localparam PAYLOAD = 4'd7; localparam FCS_CHECK = 4'd8; 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 == 8'h55) state <= SFD_WAIT; else state <= IDLE; end SFD_WAIT: begin if (gmii_rxd == 8'hD5) state <= MAC_DA; else if (gmii_rxd == 8'h55) state <= SFD_WAIT; else state <= IDLE; // 前导码异常,放弃本帧 end MAC_DA: begin byte_cnt <= byte_cnt + 1; if (byte_cnt == 11) state <= ETH_TYPE; // DA6+SA6-1=11 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 (12=0x08, 13=0x00 表示IPv4) // 14 : Version/IHL 应为0x45 // 22 : Protocol 应为0x11 // 23~24 : 源IP前两字节 // 26~27 : 目的IP后两字节 // 30~33 : UDP目的端口等然后写一个组合逻辑判断是否“本机命中”:
wire frame_is_ipv4 = (eth_type == 16'h0800); wire ip_is_udp = (ip_protocol == 8'h11); wire ip_dst_match = (ip_dst == LOCAL_IP); wire udp_port_match = (udp_dst_port == LOCAL_PORT); wire arp_request = (eth_type == 16'h0806);这里还需要单独提一下ARP。PC在发UDP之前,会先发一个ARP请求“谁是这个IP地址?请告诉我你的MAC”。FPGA收到之后必须回一个ARP应答,否则PC会报“Destination host unreachable”。ARP请求的特征是EtherType=0x0806,ARP头中操作码字段为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和用户逻辑时钟通常是不同的时钟域(比如用户逻辑跑150MHz,RX_CLK是125MHz)。所以接收FIFO一定用异步FIFO(Xilinx下用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 ? 32'hEDB88320 : 32'h0); end always_ff @(posedge tx_clk or posedge rst) begin if (rst) crc_q <= 32'hFFFF_FFFF; else if (crc_clear) crc_q <= 32'hFFFF_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字节) EtherType=0x0800(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 Length=8+100=108(0x006C),IP Total Length=20+108=128(0x0080)。
至于IP头里的Header Checksum,接收端PC通常会校验,但很多简化设计直接填0,因为Windows和Linux都对“IP校验和为0”的包容忍度较高。不过我还是建议顺手算一下,IP头校验和的算法非常简单:把IP头按16bit相加,进位回卷,取反。20字节的IP头只需要10次16bit加法,几十行状态机就能完成,而且是只加头不改payload,发送时计算一次即可。
发送侧还需要处理一个细节:当用户数据很少时,比如只发1字节payload,MAC帧总长度只有14+20+8+1+4=47字节,小于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(1'b1), .D1(gmii_txd[0]), // 上升沿输出 .D2(gmii_txd[4]) // 下降沿输出 );还有一个重要问题是TX_CLK从哪里来。RGMII千兆模式下,125MHz的TX_CLK必须由MAC(FPGA)提供给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/24,FPGA侧固定本机IP为192.168.1.10,MAC地址随便写一个但别和局域网里的设备冲突,比如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、UDP
ARP通了之后,可以用网络调试助手或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 == 1'b1 && 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的IP,Wireshark里只有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。很多代码只判断了EtherType=0x0806,忘了检查操作码是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_CLK(125MHz)、FPGA送给PHY的TX_CLK(125MHz)、用户逻辑时钟(比如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模块本身改动很频繁,每次都等十几分钟综合去改一个端口号,非常浪费生命。等整个模块稳定了,再把端口固化回去就行。