如果之前一直在点亮LED、调状态机的人,第一次被要求让开发板通过网线和电脑通上数据,第一反应往往是打开IP核手册,或者去网上找现成的UDP协议栈代码。我也是这么走过来的。所以到了这个系列的第9篇文章,我决定把“数据链路层代码设计”和“UDP通信测试”放在一起讲清楚:为什么不能跳过MAC直接聊UDP,以及当你手上只有一块带RGMII网口的FPGA开发板、一个网络调试助手和一个Wireshark时,如何从零把一条以太网链路点通。
这套流程面向的读者,是已经掌握基本Verilog、熟悉同步FIFO/异步FIFO、对跨时钟域有概念,但还没正经碰过网络协议的FPGA学习者。看完之后你不会得到一个完整的TCP/IP协议栈,但你会拥有一个能跑通“PC到FPGA、FPGA到PC”双向UDP通信的最小闭环,并且知道数据链路层每一帧里每个字节到底在干什么。后面再去碰Tri-Mode Ethernet MAC、AXI Ethernet、甚至自己撸TCP,都是在这个基础上做加法。
1. 为什么这个阶段要手写数据链路层,而不是直接调用现成MAC IP核
1.1 网络分层在FPGA里到底怎么切
以太网通信默认要聊OSI七层模型,但做FPGA的人不需要把每一层都背下来。实际工程里,我们面对的分层非常朴素:物理层由外置PHY芯片负责,比如RTL8211、88E1512、YT8531这些,它们把差分信号转成数字逻辑电平;数据链路层(也就是MAC层)本来可以交给IP核,也可以自己写;IP层和传输层(UDP/TCP)更是灵活,小流量可以纯逻辑处理,大流量可以上软核处理器,比如MicroBlaze配合LwIP。
这里最容易被初学者忽略的一点是:FPGA和PHY芯片之间的接口只是“物理媒介”,它不负责理解以太网帧。真正判断一帧数据从哪开始、到哪结束、CRC对不对、目的MAC是不是自己,这些都是MAC层要做的事情。所以当有人说“FPGA里做UDP通信”,他其实说的是三件事:写一个MAC层收发逻辑、写一个最小IP/UDP封装、再写一个和用户接口对接的FIFO或寄存器读写逻辑。
数据链路层的基本功能,在FPGA实现里其实非常具体:
| 功能 | 教科书说法 | FPGA里对应实现 |
|---|---|---|
| 帧定界 | 识别帧的起止 | 前导码/SFD检测,状态机控制接收窗口 |
| 差错检测 | CRC校验 | CRC32计算与比较,错误帧丢弃 |
| 介质访问 | CSMA/CD、帧间隙 | 全双工下主要是IFG定时,半双工才需要复杂处理 |
| 接口适配 | 与物理层交互 | RGMII/GMII收发、ODDR/IDDR原语 |
记住这个对照表,后面写代码的时候心里就有数了:每一块逻辑都能对应到一个帧格式或时序要求,不会瞎写。
1.2 现成MAC IP核和手写数据链路层的取舍
Xilinx Tri-Mode Ethernet MAC、Altera TSE这些IP核功能完整,千兆、万兆、流控、VLAN、PTP全都有,但配置项多到让人头皮发麻。初始化的寄存器序列、AXI4-Stream接口时序、时钟与复位策略、多通道DMA,随便一个环节出错,板子上的网口就是红叉。对“近似0基础”的学习者来说,用IP核最大的问题不是不会用,而是出了问题没法排查:你不知道链路层状态机走到哪一步了,也没法用波形去对帧格式。
手写一个“够用版”数据链路层,好处则是所有信号都可见。状态机就三个、计数器就那么几个,抓出来一看就知道问题在哪。代价是功能边界必须缩得很小,我建议第一版只做下面这些:
- 只支持全双工,不做CSMA/CD和流控;
- 固定PHY速率,优先百兆,千兆放后面;
- 只有一个本地MAC地址,不用查表;
- 不处理VLAN tag、巨型帧;
- 只封装IPv4和UDP,ARP先手动绑定;
- IP首部校验和先算成常量,UDP校验和直接置0。
这样砍完之后,代码量大概在几百行到一千行之间,比MAC IP核的配置界面还容易理解。商业项目当然用IP核,但学习阶段,手写一遍的收益远大于节省的那点时间。
1.3 我定义的“够用版”边界
这个边界不止是功能上的,还是调试策略上的。最典型的例子是速率选择。RGMII在千兆模式下TXC是125MHz的DDR信号,时序约束要求input/output delay,新手一旦约束没写对,抓包就是满屏CRC错误。但如果先跑百兆,TXC变成25MHz,时序裕量大了很多,先跑通链路层逻辑再说,后面再把约束加上去升千兆。
另一个要注意的边界是“不要一上来就追求UDP回显”。我见过不少朋友把目标定成“FPGA收到PC的UDP包,原样回发”,然后卡在ARP上三天。正确的学习路径是:先让FPGA主动往PC发UDP包,用Wireshark确认帧结构正确,再去做PC到FPGA方向。每加一个功能,就要有一个明确的验证手段,这也是我把这章写进博文的原因。
2. 写代码前必须吃透的三件事:MAC帧、RGMII时序和CRC32
2.1 一帧数据从PHY到MAC要经历什么
一条以太网帧在线上实际发送的内容,比你在教科书上看到的“目的MAC+源MAC+类型+数据+FCS”要多8个字节:前面还有7字节前导码(每个字节都是0x55)和1字节帧起始定界符SFD(0xD5)。前导码的作用是让接收端PHY恢复时钟和比特同步,SFD表示后面开始就是真正的MAC帧。
所以完整的一帧从PHY的角度看是这样的:
前导码7字节 = AA AA AA AA AA AA AA(线上比特流是55的LSB先发) SFD 1字节 = AB(对,线上是0xD5的LSB先发,抓包显示常常是AB) 目的MAC 6字节 源MAC 6字节 类型/长度 2字节 数据 46~1500字节 FCS 4字节(CRC32)为什么数据域最少46字节?因为以太网规定最小帧长是64字节,从目的MAC开始到FCS结束。如果去掉14字节头、4字节FCS,数据域最少就是46字节。你的UDP包如果不够长,必须在数据域里补零。举个实际例子:UDP数据只有“hello” 5个字节,IP头20字节加UDP头8字节,一共33字节,MAC数据域空空如也只剩33字节,这时就要补13字节零,凑够46字节。很多第一次写发送逻辑的人漏了这一步,结果PC端网络调试助手收到的是长度不足的畸形帧,网卡可能直接丢弃。
2.2 RGMII接口为什么是初学的首选
RGMII现在几乎是所有千兆PHY的标配接口,原因很简单:引脚少。GMII需要8根数据线加4根控制线,RGMII把数据线压缩到4根,每根线上DDR方式传两个比特,控制线也合并成一根TCTL/RCTL。虽然时序变严苛了,但对初学者意味着布线容易、盯波形容易。
接口约定是这样的:TXC上升沿传低4位,下降沿传高4位。比如你要发一个字节0xAB,那么上升沿的时候TXD[3:0]放0xB,下降沿的时候TXD[3:0]放0xA。控制线TCTL类似,上升沿表示字节0或1有数据,下降沿表示字节2或3有数据。这个“低半字节先发”的约定,写代码的时候直接决定了你的拼接顺序。
时钟速率上,千兆TXC是125MHz,百兆25MHz,十兆2.5MHz。第一版强烈建议定在百兆或千兆其中一种,并且和PHY的协商结果保持一致。多数PHY上电后默认支持千兆自动协商,但如果你不写MDIO配置,有些PHY会因为外部配置引脚不同而工作在奇怪状态。这里有个很实用的土办法:看开发板原理图里PHY的中断/状态LED引脚接法,link灯亮不亮,能直接告诉你PHY是否协商成功。
RX方向更需要注意:RXC是PHY恢复出来的时钟,不是FPGA生成的,它和你内部逻辑时钟天然是异步的。所以接收侧所有信号都要经过IDDR采样后,再做跨时钟域处理,这个衔接点后面会专门说。
2.3 CRC32的位序陷阱与查表法思路
CRC32是数据链路层最容易翻车的地方,翻车原因几乎都是位序。以太网CRC32虽然多项式也是04C11DB7、初值FFFFFFFF、结果异或FFFFFFFF,但它的输入位序是LSB first,也就是每个字节最低位先进入移位寄存器。这和很多软件库默认的“MSB first逐字节查表”不是一回事。如果直接把CRC算法原封不动搬进FPGA,算出来的结果和线上跑的CRC永远对不上。
处理办法有两种。第一种是硬件常用的“给输入字节逐位反转”,也就是先把每个字节的bit0和bit7、bit1和bit6……兑换完,再丢进标准CRC计算逻辑,最后结果也做一次位反转并对FFFFFFFF异或。第二种是直接把CRC表按照LSB-first语义重做,查表法在FPGA里可以展开成组合逻辑,每一拍输入一个字节,更新一次32位CRC寄存器。
下面是一个按字节计算的示意代码,实际工程里这个函数会被综合成一堆组合逻辑,而不是循环:
function automatic [31:0] crc32_byte; input [31:0] crc; input [7:0] data; reg [31:0] c; integer i; begin c = crc ^ {24'b0, data}; // 注意:data需要按位反转后再异或 for (i = 0; i < 8; i = i + 1) begin if (c[31]) c = (c << 1) ^ 32'h04C11DB7; else c = c << 1; end crc32_byte = c; end endfunction这段代码不能直接抄了就综合,因为输入data的位反转没写,但结构就是标准CRC32的线性移位过程。调到真正的以太网上,你还需要处理CRC结果的发送顺序:FCS字段是CRC寄存器最高字节先发,然后依次到最低字节,但每个字节内部还是LSB first。如果你对这块没把握,最直观的办法是发固定数据,然后对照Wireshark里显示的帧校验序列慢慢调,总比自己推位序快。
3. 发送通路设计:组帧状态机、IP/UDP封装和发送FIFO
3.1 发送侧模块划分与时钟域处理
发送侧我觉得拆成三个模块就行:udp_gen负责把用户数据打包成IP/UDP报文,mac_tx负责加前导码、目的MAC、CRC和IFG,rgmii_tx负责把字节转成RGMII时序。模块之间用字节流和valid握手。
这里最容易被忽略的是时钟域。用户侧逻辑可能跑在100MHz、150MHz,而RGMII的发送时钟TXC要么是125MHz要么是25MHz,两个时钟并不同源。处理办法是加一个异步FIFO,用户逻辑往FIFO里写,mac_tx从FIFO里读。异步FIFO深度不用太大,64字节或者256字节足够应付最开始的UDP通信,后面的丢包问题也多半不是FIFO深度不够,而是别的地方。
3.2 发送状态机的状态定义与跳转
发送状态机是整个发送通路的核心,我习惯把状态拆成:IDLE、PRE、MAC_HEAD、IP_UDP_HEAD、PAYLOAD、PAD、CRC、IFG。看起来状态多,实际每个状态只做一件事:从固定值、发送FIFO或CRC寄存器里取字节,输出到rgmii_tx,然后计数器加1。
以发送一帧UDP数据为例,字节流的顺序是这样安排的:
PRE:连续发8个字节,前7字节0x55,最后1字节0xD5;MAC_HEAD:目的MAC 6字节、源MAC 6字节、类型2字节(IPv4就是0x0800);IP_UDP_HEAD:IP头20字节、UDP头8字节,内容按网络字节序排列;PAYLOAD:从发送FIFO读用户数据,每读一个字节,CRC寄存器更新一次;PAD:如果前面的数据总长度没到46字节,补零,同时CRC继续更新;CRC:把CRC寄存器的4个字节依次发出;IFG:等够96个bit时间,回到IDLE。
写状态机的时候有个细节:PAYLOAD阶段每一拍都在更新CRC,但MAC_HEAD和IP_UDP_HEAD阶段也要更新CRC。也就是说CRC计算必须从目的MAC第一个字节就开始,直到PAD结束,中间不能断。这里通常用一个crc_en信号,只要当前处于除PRE和CRC以外的状态,就把当拍字节输入CRC模块。
3.3 在MAC之上叠IP头与UDP头
MAC层只认以太网帧,它不知道IP和UDP是什么。所以我们还要在MAC帧的“数据”区域里塞一个完整的IP包。对一个最简单的UDP包来说,IP头是20字节,UDP头是8字节。
IP头里需要关心的字段是:版本和首部长度固定0x45;总长度 = 20 + 8 + UDP数据长度;协议字段固定17表示UDP;源IP、目的IP各4字节;首部校验和是对这20字节按16位累加取反。UDP头里则是源端口、目的端口各2字节,UDP长度 = 8 + UDP数据长度,校验和第一版直接写0。
初学者最容易懵的是IP首部校验和。我第一版调试时偷了个懒:既然源IP、目的IP固定,UDP数据也固定长度,那么整个IP头除了标识字段外全是常量,校验和完全可以预先算好写死。只要你不改数据长度,这个校验和就是对的。等后面要发变长数据,再补一个16位累加器去实时计算。这样的渐进路线,能让你先把链路层跑通,而不是卡在校验和的实现上。
另外还涉及一个大坑:ARP。PC如果要主动向FPGA发UDP包,它会先查ARP缓存,查不到就发ARP请求问“192.168.1.10的MAC地址是谁”。如果FPGA不管,PC永远只会发ARP请求,UDP包根本不会到FPGA。最快速的解决办法是在PC上静态绑定ARP:
arp -s 192.168.1.10 00:11:22:33:44:55这条命令需要管理员权限。绑定之后PC会直接把UDP帧发到指定MAC,不再发ARP请求。这样做的好处是省掉一个ARP回复模块,让第一版代码更短。但如果你想做成一个更通用的设备,后面还是要补一个“检测到ARP请求就回复应答”的逻辑,代码量其实不大,核心就是构造一个操作码为2的以太网帧。
3.4 一个最小发送模块的Verilog骨架
我习惯用参数化的字节数组来做帧头,把IP头20字节、UDP头8字节预先填好,发送时按索引往外发。Verilog里定义常量数组不如SystemVerilog方便,但也可以用localparam加函数,或者直接用一个reg [7:0] hdr[0:27]在初始化时填充。下面是个简化骨架:
typedef enum logic [3:0] { IDLE, PRE, MAC_HEAD, IP_UDP_HEAD, PAYLOAD, PAD_CRC, IFG } tx_state_t; logic [7:0] tx_byte; logic [31:0] crc_reg; logic [15:0] byte_cnt; always_ff @(posedge tx_clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; end else begin case (state) IDLE: begin if (tx_start) begin state <= PRE; byte_cnt <= 0; end end PRE: begin // 发前导码和SFD if (byte_cnt == 8) begin state <= MAC_HEAD; byte_cnt <= 0; end end ... endcase end end这个骨架的细节不需要背,关键是思路:每个状态只干一件事,字节计数器控制状态切换,CRC使能信号跟发送数据同步。写完之后用仿真先跑一发,对着自己画的帧格式图逐字节核对,再去上板。这能省掉至少一晚上的调试时间。
4. 接收通路设计:拆帧、CRC校验和回显逻辑
4.1 接收侧数据流与关键信号
接收通路的设计思路和发送通路是对称的,但有一处完全不同:发送侧时钟是FPGA自己给的,时序可控;接收侧时钟来自PHY的RXC,你只能接受。RGMII的RX信号在FPGA这边要用IDDR原语在每个时钟沿采集4bit,拼成一个字节。拼的顺序和发送相反:上升沿采到的是低4位,下降沿采到的是高4位。
从PHY到用户逻辑的完整数据流是:
rx_clk/rx_ctl/rxd[3:0] -> IDDR -> 拼接成8bit -> 接收状态机 -> 接收FIFO -> 用户解析模块接收状态机的关键信号是rx_ctl。它在帧的期间一直为高,从SFD之后到FCS结束,之后拉低。PHY在复位刚完成、链路还没稳定的阶段可能有毛刺,所以最好加一个简单的“连续几个周期rx_ctl为高才开始接收”的滤波逻辑,否则状态机被毛刺带偏,抓包全是错帧。
4.2 接收状态机的设计要点
接收状态机大体是:IDLE等待rx_ctl拉高、检测到SFD后进入DATA、一直收到rx_ctl拉低结束。和发送状态机不太一样的是,接收的时候数据可能随时断掉,也就是rx_ctl并不是一个稳定可预测长度的信号。所以状态切换不能靠“固定的字节计数”,而是要结合rx_ctl的电平来判断帧是否结束。
边收边存的策略是这样的:从MAC头开始,每收到一个字节就写入接收FIFO,同时并行计算CRC。帧结束时比较计算出的CRC和收到的FCS,不一致就标记为坏帧。判断坏帧之后,最简单的办法是不丢弃,而是在FIFO的帧头额外存一个标志位,由上层逻辑在读到这一帧时主动扔掉。这样虽然会占用一点带宽,但对初学者来说最简单。
另一个雷区是FIFO溢出。如果你的用户逻辑(比如UART打印)处理速度跟不上,接收FIFO会被写满,后面的数据全部丢掉。我建议在接收状态机里检查almost_full,一旦接近满,直接停止接收并把当前帧标记为坏帧。这不是最优做法,但能保证不会因为FIFO溢出产生半截帧,干扰上层解析。
4.3 从以太网帧里把UDP数据拎出来
接收FIFO里存的是从目的MAC开始的完整MAC帧,前导码和SFD已经被状态机剥掉了。要做的事就是按顺序解析:前12字节是目的MAC和源MAC;第13、14字节是类型;如果类型是0x0800,继续解析IP头;IP头里看协议字段,如果是17(UDP),继续解析UDP头;最后剩下的就是用户数据。
这里要特别提醒字节序问题。网络上所有多字节字段都是大端,也就是高字节在前。IP地址“192.168.1.10”在帧里就是C0 A8 01 0A,端口5000在帧里就是13 88。如果你用16位变量直接赋值并把它当成一个整体发送,很容易搞反高低字节。解决的办法是定义成字节数组,按索引赋值,不要依赖编译器帮你处理端序。
回显模式是联调阶段最值钱的功能。它要求FPGA收到UDP包后,把源MAC、源IP、源端口存下来,然后作为回包的“目的地”,把有效载荷原样发回去。这样一来,PC发什么,FPGA就回什么,闭环成立。代码上就是接收模块多存几个寄存器,发送模块在构造IP/UDP头时改用这些寄存器里的值。
4.4 回显模式:联调阶段的黄金测试方法
回显模式之所以好用,是因为它把接收和发送两条通路串成了一个闭环。PC端网络调试助手发送“hello”,如果能收到同样的“hello”,说明以下几件事同时成立:PHY接收正常、MAC拆帧正确、IP/UDP解析正确、发送组帧正确、CRC计算正确。一旦哪一环出错,你只需要看现象:没回包,优先怀疑接收解析;回包内容乱了,优先怀疑字节序;完全没反应,就从ARP查起。
我自己的习惯是第一版先不做回显,而是让FPGA每收到一帧,就把收到的目的端口和源IP通过串口打印出来。这个“打印调试法”听起来土,但在没有逻辑分析仪的情况下,能帮你快速看到接收通路到底有没有工作。等串口能打印出PC的IP和端口号了,再打开回显开关,往往一次就通。
5. 上板联调:从网络调试助手到Wireshark的完整验证方法
5.1 连接与PC端配置
联调之前先做三件事:开发板网口和电脑网口直连,中间不要插交换机;关闭电脑的WiFi,避免系统把包路由到无线网卡;给有线网卡设置一个静态IP,比如192.168.1.2,子网掩码255.255.255.0。FPGA侧的IP我习惯用192.168.1.10,MAC地址随便分配一个不冲突的,比如00:11:22:33:44:55。注意MAC地址不能是全0,有些网卡驱动会直接丢这种帧。
这里还有一个很容易被忽略的前置检查:先开网络调试助手,用两台电脑之间跑一次UDP收发。如果两台电脑互相都通不了,别急着怀疑FPGA,先把防火墙规则、网卡驱动这些环境问题排掉。这个过程五分钟,但能帮你省掉后面两小时的无效调试。
5.2 测试方向一:FPGA主动向PC发UDP
我强烈建议联调的第一个方向是FPGA主动发,而不是PC发。因为FPGA主动往外发不依赖ARP,只要发送组帧正确,电脑网卡就会收到,Wireshark里立刻能看到。
具体做法:FPGA内部用一个计数器或者按键触发,固定每100ms发送一个UDP包,目的IP是192.168.1.2,目的端口5000,数据内容是递增数或者“Hello FPGA”的ASCII码。PC端网络调试助手监听本地端口5000,如果收到数据且内容正确,FPGA到PC的反向通路就算通了。
这时候打开Wireshark,抓包过滤器直接写udp.port == 5000。重点看三件事:以太网层的类型是否是0x0800、源MAC和目的MAC是否和你设计的一致;IP层的总长度和标识对不对、IP校验和是否被Wireshark报错;UDP层的源端口、目的端口、长度是否正常。这一套看下来,你对“FPGA里构造的UDP包在PC眼里长什么样”就有了直观概念。
5.3 测试方向二:PC向FPGA发UDP并回显
PC发UDP给FPGA之前,必须解决ARP。如果你已经在PC上执行了arp -s 192.168.1.10 00:11:22:33:44:55静态绑定,这一步就跳过去了。如果没有,FPGA必须能回复ARP请求,否则PC会在发出ARP请求后一直等应答,UDP数据永远发不出去。
PC端的网络调试助手配置可以这样理解:对UDP通信来说,没有TCP那种固定“服务端/客户端”概念。PC的本地端口和远端端口都要填,PC发出的UDP包源端口是本地端口,目的端口是远端端口。假设PC本地端口设为6000、远端端口设为5001,那么FPGA侧的回显逻辑就应该从收到的UDP包头里提取源端口6000,然后回给PC的6000端口。这个“用接收包里的源信息作为回包目的信息”的思路,就是回显能工作的关键。
如果没有回显,别急着翻代码。先在Wireshark里过滤arp or udp.port == 5001 or udp.port == 6000,按这个顺序排查:
- PC发ARP请求了吗?如果发了,FPGA回ARP了吗?
- 如果ARP通了,PC发UDP了吗?如果没发,说明PC的ARP缓存可能还是没生效,或者静态绑定被系统清理了。
- 如果PC发了UDP但FPGA没回,说明接收通路有故障,回到第4章排查拆帧和解析逻辑。
- 如果FPGA回了但PC助手没显示,看看回包的源端口、目的端口是不是反了,这是最常见的低级错误。
5.4 Wireshark的关键过滤技巧
Wireshark是这个项目里最值得信任的调试工具,它会告诉你帧到底长什么样。常用的过滤条件就这么几个:
udp.port == 5000:只看端口为5000的包;ip.addr == 192.168.1.10:只看和FPGA IP相关的包;eth.addr == 00:11:22:33:44:55:只看指定MAC的帧;arp:单独看ARP交互;frame.time_delta_displayed:在列首右键添加这一列,可以直接看到相邻两个包的时间差,排查周期性发送非常方便。
抓包时你会看到很多无关的包,比如电脑自己的mDNS、NBNS、IPv6邻居发现、随机ARP请求,这些都是正常噪声,不要被干扰。用过滤器把它们屏蔽掉,一眼就能看到自己的UDP包。
这里有个经验:Wireshark里如果每一帧都显示“Bad CRC”,先别急着改代码。先用固定数据发一帧,在Wireshark里对照Hex内容,确认前导码、MAC头、IP头、UDP头、数据、FCS每一段是否和预期一致。很多时候“Bad CRC”是因为PHY或者网卡驱动做了校验和卸载,抓包点看到的CRC是后来补算的,而帧内容本身完全正常。分清这个问题,能少走很多弯路。
6. 调试路上躲不开的几个坑:从网卡红叉到丢包排查
6.1 网卡红叉、link灯不亮
如果电脑插上网线后没有任何反应,PHY的link灯不亮,问题几乎肯定在物理层和PHY配置,而不是你的MAC状态机。排查链路是这样的:先量PHY核心供电和复位脚,确认PHY没有一直处于复位状态;再查PHY的参考时钟,常见是25MHz晶振,没有时钟PHY就是死的;然后看RGMII的TXC是否有波形,没有波形就说明FPGA没把125MHz或25MHz时钟送出来;最后检查PHY的配置引脚,有些板子需要拨码开关或电阻选择PHY地址和模式,如果和默认值不一致,PHY可能工作在错误的速率或接口模式。
一个实用的建议是:上板之前先烧一遍开发板自带的网口例程,确认硬件本身没有问题。如果原厂例程连link灯都不亮,那是硬件或PHY配置问题,代码写得再对也没用。如果原厂例程能亮,再换成你自己的工程,问题就锁定在FPGA侧。
6.2 Wireshark报Bad CRC但数据内容看起来正常
这是所有手写MAC的初学者几乎都会遇到的一次磨难。数据内容全对,帧格式也对着呢,Wireshark却每一帧都报CRC错误。原因几乎都是CRC模块的位序处理不对,或者CRC结果输出字节序不对。
排查办法是把发送数据固定成一个好识别的模式,比如第一帧发送00 01 02 03 ... 3F,然后抓包。在Wireshark里把帧的Hex逐个字节抄下来,自己写一段Python脚本,对这个比特流重新计算一遍标准以太网CRC32。如果算出来的CRC和抓包里的FCS一致,说明你的FPGA代码没问题,问题在PC网卡的校验和卸载;如果不一致,就一字节一字节地对比,看是字节计数差了一位,还是CRC更新时机慢了一拍。
我还想多说一句:现在很多AI工具能帮你审查这类代码。你可以把CRC模块、接收状态机、发送状态机这些核心代码贴给AI,让它按照“LSB-first CRC32、RGMII DDR时序、异步FIFO跨时钟域”几个常见出错点做静态检查。它不一定能取代你自己的判断,但能帮你迅速圈定可疑位置。我自己就干过这事儿,AI确实能发现一些边界条件漏判的问题,比如接收状态机在rx_ctl突然拉低时没有回到IDLE。
6.3 数据错乱、字节颠倒与端口号不对
如果收到的UDP内容错乱,但Wireshark不报CRC错误,说明帧能通过校验,问题出在数据内容的字节序上。最常见的是端口号不对:想发给5000,结果PC助手收到的是0x8813这种被拆反的值。原因就是代码里用16位变量直接构造端口号,网络字节序要求高字节先发,但拼接或赋值时把高8位和低8位搞反了。
排查方法是发一个固定模式的数据,比如0x12 0x34 0x56 0x78,然后在Wireshark里查看这4个字节在UDP负载里的位置。如果变成0x78 0x56 0x34 0x12,那就不是简单的字节序问题,而是整帧被整体翻转了,这往往发生在RGMII采样拼接时上下半字节顺序反了,也就是上升沿和下降沿的数据拼反了。这个坑的频率比想象中高,因为RGMII本身规定“上升沿传低4位”,但有些PHY的实际行为会让人困惑,最后还是要靠抓包实锤。
6.4 丢包、吞吐上不去和后续优化方向
第一版跑通双向UDP之后,很多人会想试试连续大流量发送,结果发现丢包率惨不忍睹。先说结论:丢包不等于你的链路层写错了,更可能是FIFO满、PC助手缓冲不足、或者用户侧处理不过来的问题。TCP有重传机制,UDP没有,所以UDP丢包是非常正常的现象。
测试吞吐时还有个大坑:iPerf3不能直接测FPGA。因为iPerf3需要先建立一个TCP控制连接,光这个TCP栈就够你喝一壶了。想测FPGA的UDP吞吐,不如自己写一个Python脚本,循环发送UDP包并统计回显率,或者直接用Wireshark的IO Graph看每秒收到的包数。这样你就把“上位机工具的限制”和“FPGA本身的性能”分开看了。
如果确实想提高吞吐,后面的优化方向基本是这几条:接收FIFO改深,比如从256字节加到几KB;发送链路改成流水的CRC计算,避免在CRC状态停留太久;把用户逻辑改成双缓冲,在读数据的同时准备下一帧;再进一步就是上AXI DMA,把FPGA里的数据直接搬到DDR,绕开CPU。
这个项目做到回显通了之后,你再回头翻MAC IP核的用户手册,那种感觉是完全不一样的。你至少知道它在底层给你封装了什么、那些配置项改的是哪个寄存器的行为。我个人的建议是,这个最小链路不要求快,但每一个字节都要对着Wireshark核对一遍,把PC和FPGA之间能稳定互发UDP这事做到“条件反射”的程度。后面的千兆线速、TCP、PTP、甚至MIPI图像数据直接上以太网,都是在它基础上做加法。