简介:面向FPGA网络通信开发者与高校相关课题学习者,这份千兆/百兆自适应以太网UDP传输工程围绕链路速率识别、GMII/RGMII转换及UDP收发通路展开,可用于理解硬件协议栈在FPGA内的封装、校验与缓存调度。压缩包共19个文件、约69KB,以Verilog源码为主体,配合Xilinx IP配置、工程描述、引脚与时序约束及Tcl脚本,便于查看模块组织、IP连接与实现约束。内容涉及速率检测、发送控制、接收处理、CRC32校验、FIFO缓冲和顶层整合,兼顾千兆与百兆场景的速率适配;目录组织清晰,覆盖物理接口、时钟与FIFO IP配置以及UDP协议处理,便于对照理解各模块职责与数据流走向。已有1302人学习,适合作为以太网UDP硬件实现参考,帮助梳理从PHY接口到UDP数据通路的模块划分、信号连接与调试切入点,也便于按需裁剪移植到自有FPGA平台。
1. 一块板子两种速率:这套 Verilog 到底替你省了哪段活
上板第一天最容易被忽略的场面是这样的:PC 端网卡自协商成百兆,FPGA 里跑着千兆 GMII 的代码,抓包软件一片空白,示波器上 rx_clk 只有 25MHz,而你还在怀疑 CRC 写错了。千兆-百兆自适应以太网 UDP 传输这套 Verilog 工程,要解决的就是这件事——同一份 RTL,插千兆交换机跑 1000Mbps,插百兆老设备自动落到 100Mbps,上层 UDP 收发逻辑一行不改。它面向的是手里有 FPGA 开发板、要快速把数据从板子送到 PC 的人:图像采集卡回传、ADC 采样流上传、自研板卡的调试通道,都属于典型的 FPGA UDP 传输场景。包里值钱的部分不是 UDP 那一层,而是速率检测、时钟切换和位宽映射这三块胶水。
2. 自适应不是玄学:速率握手、时钟切换与位宽映射
先把一个容易被误解的点说清:所谓“千兆-百兆自适应”,绝大多数时候不是 MAC 去猜对方速率,而是 PHY 芯片自协商完成后,把结果放进自己的状态寄存器,MAC 侧去读。谁去做这件事,直接决定代码结构。
2.1 三条实现路线的取舍
工程里落地自适应,常见的做法有三条,代价差别很大。
第一条是读 PHY 状态寄存器。PHY 自协商结束、link up 之后,速率和双工状态就固定写在某个寄存器里,MAC 侧通过 MDIO 两线接口周期性轮询即可。好处是判定权威、不受信号质量干扰;坏处是要多写一个 MDIO 主机状态机,还要处理 link 抖动期的误判。
第二条是测时钟频率。千兆 GMII 的 rx_clk 是 125MHz,百兆 MII 是 25MHz,用一个参考时钟去数 rx_clk 的周期数就能区分。好处是不依赖 PHY 寄存器,坏处是冷启动阶段时钟还没稳定,容易在 link 刚起来的那几百毫秒里判错,而且 10M 下 MII 是 2.5MHz,量程要拉到很宽,计数器位宽不划算。
第三条是双套 MAC 并行。千兆一套、百兆一套,靠一个开关切换输出。听着暴力,实际在资源紧张的小器件上并不划算,而且两套逻辑都要各自验证一遍,维护成本翻倍。
我一般会把第一条作为主判据,第二条作为兜底校验:MDIO 读到 1000M,但 rx_clk 实测只有 25MHz,就判为异常并回退到百兆通路。这样单点故障不至于让整条链路失联。
| 路线 | 判定依据 | 冷启动可靠性 | 额外资源 | 适用场景 |
|---|---|---|---|---|
| 读 PHY 寄存器 | MDIO 读专用状态寄存器 | 高,需去抖 | 一个 MDIO 主机 | 首选,通用 |
| 测 rx_clk 频率 | 参考时钟计数 | 中,需等时钟稳 | 计数器 + 比较器 | 兜底校验 |
| 双套 MAC | 外部开关/软件配置 | 高 | 接近两倍 MAC | 速率固定的专用板 |
2.2 用 MDIO 把链路速率读回来
MDIO 的时序不复杂,但第一次写的人常常在帧格式上翻车:读操作要发两帧,先发地址帧再发数据帧,中间还有个转向拍 TA。下面这段是读单个寄存器的状态机骨架。
module mdio_read #( parameter [4:0] PHY_ADDR = 5'h01, // PHY 地址,由板卡 strapping 电阻决定 parameter [4:0] REG_ADDR = 5'h11, // 速率/双工状态寄存器,具体地址查 PHY 手册 parameter [7:0] DIV = 8'd19 // 50MHz / (2*(19+1)) = 1.25MHz,满足 MDC <= 2.5MHz )( input wire clk, // 50MHz 系统时钟 input wire rst_n, input wire start, // 单拍启动脉冲 output reg [15:0] rd_data, output reg rd_valid, // 数据有效,单拍 output reg mdc, output reg mdio_o, output reg mdio_oe, // 1=FPGA 驱动,0=释放给 PHY input wire mdio_i ); localparam S_IDLE = 3'd0, S_PRE = 3'd1, S_HEAD = 3'd2, S_TA = 3'd3, S_DATA = 3'd4, S_DONE = 3'd5; reg [2:0] state; reg [7:0] div_cnt; reg [5:0] bit_cnt; reg [31:0] tx_shift; // 前导码 + ST + OP + PHYAD + REGAD,共 32bit reg [15:0] rx_shift; wire mdc_tick = (div_cnt == DIV); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_IDLE; mdc <= 1'b0; div_cnt <= 8'd0; bit_cnt <= 6'd0; mdio_oe <= 1'b0; rd_valid <= 1'b0; end else begin div_cnt <= mdc_tick ? 8'd0 : div_cnt + 8'd1; if (mdc_tick) mdc <= ~mdc; // MDC 由分频计数翻转产生 rd_valid <= 1'b0; case (state) S_IDLE: if (start) begin // 32'b111...1_01_10_PHYAD_REGAD:前导 + ST=01 + OP=10(读) + 地址 tx_shift <= {32'hFFFF_FFFF, 2'b01, 2'b10, PHY_ADDR, REG_ADDR}; bit_cnt <= 6'd31; mdio_oe <= 1'b1; state <= S_PRE; end S_PRE, S_HEAD: if (mdc_tick && !mdc) begin mdio_o <= tx_shift[bit_cnt]; tx_shift <= {tx_shift[30:0], 1'b0}; bit_cnt <= bit_cnt - 6'd1; if (bit_cnt == 6'd0) begin bit_cnt <= 6'd1; state <= S_TA; end end S_TA: if (mdc_tick && !mdc) begin mdio_oe <= 1'b0; // 转向拍:释放总线给 PHY bit_cnt <= bit_cnt - 6'd1; if (bit_cnt == 6'd0) begin bit_cnt <= 6'd15; state <= S_DATA; end end S_DATA: if (mdc_tick && mdc) begin rx_shift <= {rx_shift[14:0], mdio_i}; // MDC 上升沿后采样,符合规范 bit_cnt <= bit_cnt - 6'd1; if (bit_cnt == 6'd0) state <= S_DONE; end S_DONE: begin rd_data <= rx_shift; rd_valid <= 1'b1; mdio_oe <= 1'b1; state <= S_IDLE; end default: state <= S_IDLE; endcase end end endmodule这段代码有几个参数必须按板子改:PHY_ADDR由硬件 strapping 决定,常见 0x01 到 0x1F,读错了会一直返回 0xFFFF;REG_ADDR是最容易踩坑的地方,标准寄存器 0x01(BMSR)只反映“能力”,不反映“当前链路速率”,真正的当前速率通常在厂商专用寄存器里(0x10/0x11/0x1F 都有可能),必须对着 PHY 手册确认;DIV决定 MDC 频率,规范上限 2.5MHz,用 50MHz 系统时钟时取 19 就够,改成 24 也不会错,只是慢一点。
采样沿也要注意:S_DATA里在mdc为高的时钟拍采样,等价于 MDC 上升沿之后取数,这是 PHY 输出建立时间的常规做法。如果读回来的数据整体右移一位或左移一位,先怀疑这里,而不是怀疑 PHY 坏了。
拿到寄存器值后,判定逻辑就三行:
// 以 0x11 为例:bit15=1 表示 1000M,bit14=1 表示全双工 // 1000M 之外再用 0x01 的 bit13 区分 100M 与 10M wire link_1000m = reg11[15]; wire full_duplex = reg11[14]; wire link_100m = !reg11[15] && reg01[13];判定前要做两级处理:一是link up 去抖,状态位连续 8 次轮询结果一致才认定切换,轮询周期取 10ms 量级;二是切换窗口屏蔽,速率一旦变换,先复位 MAC 收发通路和 FIFO,等 20ms 稳定后再开闸放数据,否则第一帧基本必错。
2.3 8bit@125MHz 与 4bit@25MHz 如何汇到同一条数据通路
这是自适应真正的工程量所在。千兆 GMII 是 8bit@125MHz(DDR 的 RGMII 另算),百兆 MII 是 4bit@25MHz,两者的数据位宽和节拍完全不同。
我的做法是内部统一成 8bit@125MHz,接口层做差异适配:
- 千兆下,GMII 的 8bit 数据直接进 125MHz 域,不需要任何转换,一拍一字节。
- 百兆下,用 IDDR 原语在 25MHz 的上下沿各采 4bit,拼成一个 8bit 字节,得到 8bit@25MHz 的流,再写进异步 FIFO,读侧挂 125MHz。因为百兆线速只有 100Mbps,125MHz 侧读得比写得快,FIFO 几乎不会积压,深度给 512 字节足够。
关键点是时钟从哪来。接收侧的 rx_clk 由 PHY 提供,发送侧的 tx_clk 在千兆下是 125MHz、百兆下是 25MHz,切换速率时这个时钟本身会变。有两种处理:一是用 MMCM 动态重配,切速率时改分频比,好处是全局时钟质量好,坏处是重配期间时钟会短暂中断,必须先把收发状态机冻结;二是准备两个时钟源,用 BUFGMUX 原语切换,切换瞬间同样要停流。我用后者多一些,因为逻辑简单,切之前拉低mac_en,等两个时钟周期确认后再切选择信号。
这里有个容易被忽略的细节:FIFO 的复位必须跟着速率切换走,但不能异步硬复位。硬复位会把跨时钟域的格雷码指针打断,出现指针错位、FIFO 假满或假空。稳妥的写法是复位信号在写时钟域打两拍、在读时钟域打两拍,各自同步后再清指针,并且同步复位持续至少 4 个各自时钟周期。这一点在后面第 5 章还会再提一次,因为它是丢包问题的头号来源。
3. 帧组装状态机:从 UDP 载荷到线上比特的 12 个节拍
上层给你的是 8bit 或 32bit 的用户数据,线缆上要的是带前导码、以太网头、IP 头、UDP 头、载荷和 FCS 的完整帧。中间这层就是发送状态机,也是初学者最容易写成“能发但跑不满”的地方。
3.1 一帧到底由哪些字段拼成
先把账算清楚,后面所有节拍核算都基于这张表。以最常见的 IPv4 + UDP、不发 VLAN 标签为例:
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| 前导码 Preamble | 7 | 全 0x55,用于接收端时钟恢复 |
| 帧起始定界符 SFD | 1 | 0xD5 |
| 目的 MAC | 6 | 对端网卡或广播地址 |
| 源 MAC | 6 | 本板地址,同网段内唯一即可 |
| 类型 Type | 2 | 0x0800 表示 IPv4 |
| IP 首部 | 20 | 不含选项,首部校验和必须正确 |
| UDP 首部 | 8 | 源端口、目的端口、长度、校验和 |
| 载荷 Payload | 18~1472 | 小于 18 需填充,大于 1472 会触发分片 |
| FCS | 4 | 以太网 CRC32,发端计算并附在末尾 |
| 帧间隔 IFG | 12 | 千兆按字节时间计,与帧长无关 |
千兆和百兆在这张表上的差别只有两处:一是 IFG 在 8bit 通路下是 12 拍、在 4bit 通路下是 24 拍;二是最小帧 64 字节的约束在百兆下同样适用,如果载荷不足,必须补零到 46 字节(64 减去 14 字节头、4 字节 FCS)。补零位置在载荷之后、FCS 之前,长度字段仍然写真实的 UDP 长度,不是补零后的长度,这一点写错的话 PC 端能收到包但解析会报畸形。
3.2 发送状态机的 Verilog 骨架与节拍核算
状态机按“前导 → 头 → 数据 → FCS → IFG”五段推进,用tx_en和tx_data两根线对接 MAC 层或直接对接 PHY 接口适配层。
module udp_tx_fsm #( parameter [47:0] SRC_MAC = 48'h02_00_00_00_00_01, parameter [47:0] DST_MAC = 48'hFF_FF_FF_FF_FF_FF, // 广播,避免先做 ARP parameter [15:0] SRC_PORT = 16'd5000, parameter [15:0] DST_PORT = 16'd5000, parameter [15:0] SRC_IP = {8'd192, 8'd168, 8'd1, 8'd10}, parameter [15:0] DST_IP = {8'd192, 8'd168, 8'd1, 8'd100} )( input wire clk, // 内部 8bit@125MHz input wire rst_n, input wire tx_req, // 上层请求发送,单拍 input wire [15:0] payload_len,// 真实载荷长度,18~1472 output reg fifo_rd, // 读上层数据 FIFO input wire [7:0] fifo_dout, output reg [7:0] tx_data, output reg tx_en, // 高有效,接 MAC 的 gmii_tx_en output reg tx_done // 整帧发完,单拍 ); localparam S_IDLE = 3'd0, S_PRE = 3'd1, S_HDR = 3'd2, S_PAY = 3'd3, S_PAD = 3'd4, S_FCS = 3'd5, S_IFG = 3'd6; reg [2:0] state; reg [7:0] hdr_rom [0:41]; // 14 字节以太网头 + 20 字节 IP 头 + 8 字节 UDP 头 reg [5:0] hdr_idx; reg [15:0] byte_cnt; // 已发字节数(不含前导和 FCS) reg [15:0] pad_cnt; reg [7:0] ifg_cnt; wire [31:0] crc_out; // 头部用 ROM 存,切换目标地址只改例化参数,不改状态机 // 首部校验和在综合前用脚本算好写入,或者上电时算一次锁存(见 3.3) assign crc_valid = (state == S_HDR) || (state == S_PAY) || (state == S_PAD); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_IDLE; tx_en <= 1'b0; tx_done <= 1'b0; hdr_idx <= 6'd0; byte_cnt <= 16'd0; fifo_rd <= 1'b0; end else begin tx_done <= 1'b0; case (state) S_IDLE: begin tx_en <= 1'b0; if (tx_req) begin hdr_idx <= 6'd0; byte_cnt <= 16'd0; state <= S_PRE; end end S_PRE: begin // 7 字节前导 + 1 字节 SFD,共 8 拍 tx_en <= 1'b1; tx_data <= (byte_cnt < 16'd7) ? 8'h55 : 8'hD5; byte_cnt <= byte_cnt + 16'd1; if (byte_cnt == 16'd7) begin byte_cnt <= 16'd0; state <= S_HDR; end end S_HDR: begin // 42 字节固定头,一拍一字节 tx_data <= hdr_rom[hdr_idx]; hdr_idx <= hdr_idx + 6'd1; byte_cnt <= byte_cnt + 16'd1; if (hdr_idx == 6'd41) begin state <= S_PAY; fifo_rd <= 1'b1; end end S_PAY: begin tx_data <= fifo_dout; byte_cnt <= byte_cnt + 16'd1; fifo_rd <= (byte_cnt < payload_len - 16'd1); if (byte_cnt == payload_len - 16'd1) begin fifo_rd <= 1'b0; // 帧长(不含前导、含 FCS)不足 64 字节时补零 state <= ({16'd0, payload_len} + 16'd18 < 16'd64) ? S_PAD : S_FCS; pad_cnt <= 16'd64 - ({16'd0, payload_len} + 16'd18); end end S_PAD: begin tx_data <= 8'h00; pad_cnt <= pad_cnt - 16'd1; if (pad_cnt == 16'd1) state <= S_FCS; end S_FCS: begin // 4 字节 CRC,低位先出 tx_data <= crc_out[7:0]; byte_cnt <= byte_cnt + 16'd1; if (byte_cnt == payload_len + 16'd3 + {16'd0, payload_len} * 16'd0) begin // 实际工程里用独立计数器计 FCS 的 4 拍 tx_en <= 1'b0; state <= S_IFG; ifg_cnt <= 8'd12; end end S_IFG: begin // 千兆 12 拍,百兆在 4bit 通路下折算为 24 拍 if (ifg_cnt == 8'd0) begin tx_done <= 1'b1; state <= S_IDLE; end else ifg_cnt <= ifg_cnt - 8'd1; end default: state <= S_IDLE; endcase end end endmodule这段骨架的用意是把节拍关系摆明,实际落地时S_FCS那一段建议单独加一个 0~3 的计数器,比在byte_cnt上绕表达式清晰得多,上面保留原样是为了说明这里确实容易写乱。参数上要注意几点:payload_len必须由上层保证在 18 到 1472 之间,越界的清空在调用侧做,别指望状态机去截断;DST_MAC用广播地址是为了绕开 ARP,如果对端要求单播,就得先补一个 ARP 请求/应答模块,那是另一个工作量的活;hdr_rom里的 IP 首部校验和字段不能随手填零,除非对端不校验(不少嵌入式协议栈确实不校验,但 PC 端会)。
节拍核算方面,一帧最短的 64 字节帧,千兆下线上占用是 (64 + 8 前导 + 12 IFG) × 8bit = 672 个时钟拍,125MHz 下约 5.4 微秒。这意味着发送状态机的最小调度周期就是 5.4 微秒,如果上层每 2 微秒就想发一帧,必然积压,FIFO 迟早满。反过来算,最大帧 1518 字节,线上占用 (1518 + 20) × 8 = 12304 拍,约 98 微秒。写状态机之前先把这两个数算出来,后面做背压和 FIFO 深度估算全靠它。
3.3 IP 首部校验和与 UDP 校验和:算还是不算
这里有个很实际的选择题。IP 首部校验和是必算的,因为很多接收端协议栈会校验,而且它只覆盖 20 字节首部,代价极低。算法是把首部按 16bit 分组做反码求和,再取反:
// 20 字节 IP 首部按 16bit 累加,最后折叠进位取反 reg [31:0] sum; reg [15:0] chk; always @(posedge clk) begin if (chk_clr) sum <= 32'd0; else if (chk_en) sum <= sum + {16'd0, ip_word}; end wire [15:0] fold = sum[15:0] + sum[31:16]; assign chk = ~(fold[15:0] + {15'd0, fold[16]]); // 二次进位折叠参数说明:ip_word是首部按大端顺序拼出的 16bit 字,注意 IP 首部里每个 16bit 字段在网络序下就是高字节在前,直接从hdr_rom[14]和hdr_rom[15]拼即可,不要做字节交换。chk写回首部第 10、11 字节时同样高字节在前。折叠那一步不能省,20 字节累加最多产生 4bit 进位,一次折叠后仍可能再溢出一次,所以要折两次。
UDP 校验和就是另一回事了。IPv4 下 UDP 校验和字段填 0 表示“发送端未计算”,接收端可以选择跳过。绝大多数 FPGA 侧实现都填 0,原因是 UDP 校验和要构造伪首部(源 IP、目的 IP、协议号、UDP 长度),还要把载荷全部过一遍累加,等于把数据通路再走一次,时序和面积都不划算。如果对端是 PC 上的自研程序,填 0 完全没问题;如果对端是严格校验的商用协议栈,就必须算,这时候建议把累加器放在发送数据通路上顺手做,别单独再读一遍 FIFO。
以太网 FCS 的 CRC32 是必算项,多项式 0x04C11DB7,初值全 1,输入按字节低位先入,结果取反后低位先出。这个模块网上版本很多,选的时候只看两点:一是位序对不对,二是末尾取反有没有做。位序错的表现是抓包软件显示“校验和错误”,但帧内容完全正常——这个现象很有辨识度,看到就知道是 CRC 模块的问题,不用怀疑别的。
4. 上板联调:约束、跨时钟 FIFO 与抓包闭环
RTL 综合过了不等于能通,自适应工程的问题八成出在约束和跨时钟域上。
4.1 时序约束里最容易漏的三条
第一条是rx_clk 和 tx_clk 要按实际来源约束。这两个时钟由 PHY 提供,不是板上晶振,create_clock的周期要按速率分支写,千兆 8ns、百兆 40ns。自适应工程里两个周期都得写,用set_clock_groups把它们和系统时钟分成异步组。只在千兆下验证过、百兆下没重跑约束,是“千兆好好的、百兆时序违例”的典型来源。
第二条是GMII/MII 的输入输出延迟。set_input_delay要覆盖 PHY 的时钟到数据输出延迟(手册里叫 tCO 之类),一般给 -1.5ns 到 -2.5ns 之间,具体看手册;set_output_delay同理。省掉这两条,综合工具会把接口路径当纯内部路径优化,上板后随温度漂移出现偶发错帧,这类问题在实验室空调房里几乎复现不出来。
第三条是异步 FIFO 的指针路径要设 false_path。跨时钟域的格雷码指针比较,工具不知道那是同步后的信号,会拼命去满足建立时间,结果是布局布线被拖垮,或者更糟——工具“满足”了一个本不该满足的路径。正确做法是在两个时钟域之间的指针同步寄存器上打set_false_path,同时确保代码里确实做了两级同步。
# 千兆与百兆两套时钟约束,按速率参数分支生成 create_clock -name rx_clk_1000 -period 8.000 [get_ports rx_clk] create_clock -name rx_clk_100 -period 40.000 [get_ports rx_clk] -add create_clock -name tx_clk_1000 -period 8.000 [get_ports tx_clk] create_clock -name tx_clk_100 -period 40.000 [get_ports tx_clk] -add create_clock -name sys_clk -period 8.000 [get_ports sys_clk] # PHY 接口的输入输出延迟,数值以 PHY 手册为准,这里给典型区间 set_input_delay -clock rx_clk_1000 -max 2.0 [get_ports {gmii_rxd[*] gmii_rx_dv}] set_input_delay -clock rx_clk_1000 -min 0.5 [get_ports {gmii_rxd[*] gmii_rx_dv}] set_output_delay -clock tx_clk_1000 -max 2.0 [get_ports {gmii_txd[*] gmii_tx_en}] # 跨时钟域:FIFO 指针同步寄存器之间不做时序分析 set_false_path -from [get_clocks rx_clk_1000] -to [get_clocks sys_clk] set_false_path -from [get_clocks sys_clk] -to [get_clocks rx_clk_1000]这段约束里-add是关键,不加的话后一条create_clock会覆盖前一条,百兆时钟就丢了。实际工程里更推荐用参数化的约束脚本,按SPEED_MODE参数只生成一套,避免两套时钟同时在网表里引起工具困惑。另外set_false_path只对确实做了同步的路径用,没做同步就直接设 false_path,等于把亚稳态问题藏起来,上板表现为随机丢包、随机错帧,极难定位。
4.2 接收 FIFO 深度怎么估
接收通路的 FIFO 一头是 rx_clk(千兆 125MHz 或百兆 25MHz),一头是用户逻辑时钟(通常是系统 100~200MHz)。深度估算看最坏情况:用户逻辑被其他模块抢占、连续若干个周期不读。
千兆下每拍进 1 字节,125MHz 意味着 8ns 进一字节。如果用户侧最坏情况有 1 微秒不读,就要 125 字节,取整到 256 字节有安全余量;如果用户侧是突发式读(比如一次读 64 字节再处理),深度至少要是突发长度加上上面那段余量,512 字节起步比较稳。
百兆下 25MHz 进数,压力小得多,但别因此把深度砍到 64,因为速率切换瞬间 FIFO 里可能还有残留数据,深度留一点缓冲能避免切换时直接溢出。
比深度更重要的是反压路径。千兆线速下你没有反压对端的能力(以太网没有流控帧的情况下),FIFO 满了只能丢包。所以工程里必须有溢出计数器,记录“因 FIFO 满而丢弃的帧数”,这个计数器比任何波形都好用——联调时先看它是不是零,是零再去看上层为什么收得慢。
// 接收侧丢包统计,溢出时打一拍,PC 端可读 always @(posedge rx_clk or negedge rst_n) begin if (!rst_n) drop_cnt <= 32'd0; else if (fifo_wr && fifo_full) drop_cnt <= drop_cnt + 32'd1; endfifo_full用写时钟域的满标志,不要用读时钟域的同步版本,同步有延迟,等你看到“满”的时候已经写进去几个字节了。丢包计数按帧计还是按字节计要看需求,按帧计更直观,代价是要在帧起始处判断,多一个状态位。
4.3 抓包验证的三层闭环
联调不要一上来就发业务数据,按三层走。
第一层,只看物理层有没有帧。用抓包软件挂在 PC 网卡上,FPGA 循环发固定内容的 64 字节帧。能看到帧、但显示 FCS 错误,问题在 CRC 模块或发送节拍;完全看不到帧,问题在 PHY 配置、时钟或tx_en时序,先别碰协议层。
第二层,看帧内容对不对。抓包软件里展开以太网头、IP 头、UDP 头逐字段核对,重点看 IP 首部校验和是否报错、UDP 长度字段是否等于真实载荷长度、目的端口对不对。这一步能抓出 90% 的“能通但业务收不到”的问题。
第三层,看时序特征。观察帧间隔是否稳定、有没有连续的短帧突发。帧间隔抖动大,通常是上层 FIFO 供给不稳;出现长度异常的帧,多半是payload_len在状态机运行中被改了,这属于典型的跨模块握手没做锁存。
三层都过,再切到百兆重跑一遍。百兆下的坑和千兆不完全一样:前导码和 IFG 在 4bit 通路下的折算容易错,补零长度的判断也容易错,务必重跑这三层而不是“千兆通了百兆肯定通”。
5. 避坑与排查:五条上板翻车记录
5.1 链路与时钟类
现象一:千兆下跑一天没问题,切到百兆后前几十帧正常,之后持续丢包。原因多半是速率切换时 FIFO 复位没做干净。异步 FIFO 的读写指针分属两个时钟域,切换瞬间如果只复位了写侧,读侧指针还停在旧位置,FIFO 会表现为“假满”,写侧一直不进去,看起来就是丢包。解决办法是把复位信号各自在写时钟域、读时钟域同步两拍以上,两个域都清完再放开,同时切换期间拉低mac_en冻结收发状态机,等 20ms 稳定后再开。这个坑我吃过不止一次,后来干脆把“切换后必须观察 FIFO 空满标志翻转一次”写进了自检流程。
现象二:MDIO 读回来的速率值一直是 0xFFFF,或者一直是旧值。前者的原因是 PHY 地址配错,或者 MDIO 的上拉缺失,总线释放后没有上拉就是浮空,读出来全 1。后者是时序问题:link 刚起来的几百毫秒内,PHY 状态寄存器还没稳定,这时候读到的可能是上一次协商的残留值。做法是加去抖,连续 8 次轮询(每次间隔 10ms)结果一致才认定,并且 link down 事件要立即触发重新轮询,不能等下一个慢周期。
现象三:千兆下时序收敛,百兆下偶发错帧,换个房间就好了。这是典型的接口约束缺失。只对系统时钟做了约束,没对 PHY 提供的 rx_clk/tx_clk 写set_input_delay/set_output_delay,工具按理想情况优化了接口路径,实际板子上时钟到数据的延迟随温度变化,在临界点附近就出错。解决是把两条时钟都约束上,输入输出延迟按 PHY 手册给范围,并且用set_clock_groups -asynchronous把 PHY 时钟和系统时钟分开。改完之后重跑静态时序分析,建立时间余量至少留 0.3ns。
5.2 帧与校验类
现象四:64 字节小帧在抓包软件里全部报 FCS 错误,大帧正常。排查方向是小帧的补零和 IFG。两种情况:一是载荷不足 18 字节时没补零,帧长小于 64 字节,接收端直接丢弃或报错;二是 FCS 发完之后立刻发下一帧的前导码,IFG 不足 12 字节,接收端来不及准备。前者看帧长就知道,抓包软件里显示的帧长如果是 60 而不是 64,就是这个原因;后者表现为帧间隔小于 12 字节时间,抓包软件里看时间戳间隔能发现。修法是补零逻辑用独立的pad_cnt控制,IFG 在 8bit 通路下给足 12 拍,百兆下按 4bit 折算成 24 拍,别在状态机里用表达式绕。
现象五:PC 端能收到包,但业务程序解析失败,报“UDP 长度不匹配”。十有八九是 UDP 首部里的长度字段写成了补零后的长度,或者写成了包含 IP 头、UDP 头的总长度。UDP 长度字段的定义是“UDP 首部 + 载荷”,不含 IP 头,也不含补零字节。正确值是8 + payload_len。顺带一提,IP 首部的总长度字段是“IP 首部 + UDP 首部 + 载荷”,两个字段含义不同,很多人在这里把+8加错了地方。改的时候建议把两个字段的计算写成两句独立的赋值,别图省事复用同一个表达式。
排查时按这张表先定位大类,能省掉大半时间:
| 现象 | 优先怀疑 | 快速验证 |
|---|---|---|
| 完全收不到帧 | PHY 配置、时钟、tx_en | 测 tx_clk 有无、看 PHY link 灯 |
| 收到帧但 FCS 错 | CRC 位序、取反 | 换已知正确的 CRC 模块对比 |
| 帧长异常 | 补零逻辑、payload_len 锁存 | 抓包看帧长分布 |
| 长度字段错 | UDP/IP 长度计算 | 逐字段展开核对 |
| 随机丢包 | 跨时钟域、时序余量 | 看溢出计数器、重跑时序分析 |
6. 吞吐压测与一个我每次都走的验证动作
自适应跑通之后,下一个问题必然是“到底能跑多快”。这里不需要复杂工具,一端用 FPGA 持续发固定长度帧,另一端用一小段脚本统计收包速率和丢包率即可。
import socket, time sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind(("192.168.1.100", 5000)) sock.settimeout(1.0) total_bytes, total_pkts, t0 = 0, 0, time.time() last_report = t0 try: while True: data, _ = sock.recvfrom(2048) total_bytes += len(data) total_pkts += 1 now = time.time() if now - last_report >= 1.0: # 每秒打印一次瞬时速率 mbps = total_bytes * 8 / (now - t0) / 1e6 print(f"pkts={total_pkts} avg={mbps:.1f} Mbps " f"last_sec={len(data)}B") last_report = now except KeyboardInterrupt: dt = time.time() - t0 print(f"共 {total_pkts} 包 {total_bytes} 字节,历时 {dt:.1f}s," f"平均 {total_bytes*8/dt/1e6:.1f} Mbps")脚本只做一件事:把收到的字节数换算成 Mbps,并区分平均值和瞬时值。参数上要注意recvfrom的缓冲区给到 2048,覆盖最大帧 1518 字节;settimeout给 1 秒,是为了在链路中断时脚本能退出而不是卡死;瞬时值那一行打印的len(data)是最后一包的长度,方便确认对端确实在发固定长度的帧。
下面这张表是理论上限,用来判断实测值是不是“已经到位”。计算口径是有效载荷 × 8 / (帧长 + 8 前导 + 12 IFG) × 8,千兆下:
| 载荷字节 | 帧长(含 FCS) | 有效吞吐(千兆) | 有效吞吐(百兆) |
|---|---|---|---|
| 18 | 64 | 约 214 Mbps | 约 21 Mbps |
| 128 | 174 | 约 588 Mbps | 约 59 Mbps |
| 512 | 558 | 约 826 Mbps | 约 83 Mbps |
| 1024 | 1070 | 约 891 Mbps | 约 89 Mbps |
| 1472 | 1518 | 约 957 Mbps | 约 96 Mbps |
实测落在理论值的 85% 以上就算正常,差距主要来自上层供给间歇和 PC 端收包能力。如果只有理论值的一半,先看看是不是帧长选成了 64 字节——这是新手最常见的“吞吐上不去”原因,64 字节小帧的协议开销占了近八成,换成 1024 或 1472 立刻翻几倍。
最后说一个我每次都走的动作:发一帧、停三秒、再发一帧。这个反直觉的操作专门用来暴露“只在持续发送时才会掩盖的问题”。持续高速发送时,FIFO 一直有数据,状态机的边界条件被流量掩盖了;单帧加长间隔,等于把每一帧的完整生命周期单独拎出来看,前导码节拍、补零、IFG、FCS 尾字节有没有多发或少发,全部暴露。我一般会在自检里固定发 64、65、1518 三种长度各一帧,间隔三秒,抓包确认帧长和内容全对,再开高速流。从那以后我每次改完发送状态机,上板第一件事都是这个三秒单帧验证,比直接跑压测省下太多排查时间。希望帮到你。
本文还有配套的精品资源,点击获取