ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

FPGA实现ZYNQ 7020 ARP地址解析:从RGMII状态机到AXI-Lite驱动

FPGA实现ZYNQ 7020 ARP地址解析:从RGMII状态机到AXI-Lite驱动

简介:这份资源围绕ZYNQ 7020 SoC的以太网ARP地址解析与FPGA驱动开发,覆盖ARP请求发送、响应解析、缓存维护等关键环节,面向嵌入式系统、FPGA逻辑设计与网络协议栈学习人群,提供一套可直接编译运行的工程代码与配套脚本,解决在可编程逻辑中高效实现ARP协议及驱动协同的问题。压缩包共242个文件,大小1.91MB,主要包含Verilog/VHDL硬件源码(v、vh、vhdl)、Vivado工程设计文件(xdc、xci、dcp、bit)、Linux驱动与自动化测试脚本(c、tcl、sh、do)以及文档日志(rst、txt、rpt)等,便于按硬件接口、协议实现、中断处理、验证测试等模块对照查阅。已有172人学习下载。通过阅读分析工程,可理解FPGA与ARM处理器如何协同完成MAC帧构造、ARP请求响应处理及映射缓存更新,掌握驱动程序中资源管理、中断响应与性能优化的具体写法,并获得可复用的FPGA驱动库框架,为后续扩展到IPv6 NDP或其他网络协议硬件加速提供坚实基础。

1. 接上板子 Ping 不通,才发现 ZYNQ 7020 的 ARP 该由 FPGA 自己答

拿到一块 ZYNQ 7020,想在 FPGA 里实现以太网 ARP 地址解析,再把驱动挂到 PS 侧,很多人一开始都以为这是个不起眼的小功能;可真上了板,才发现 ARP 应答这件事在 Linux 内核里只是协议栈的一次查表,换成 PL 侧自研的 RGMII 收发通道就处处是坑。这篇笔记针对的是 XC7Z020 这类器件上自研以太网收发、不想被 PS 侧硬 GEM 绑死的场景,覆盖 ARP 请求识别、应答帧生成、AXI-Lite 寄存器驱动和上板调试排错。适合已经在点灯、串口之外开始碰网络接口的 FPGA 工程师,也适合想搞明白 7020 上 PS 和 PL 到底谁该来跑协议栈的嵌入式开发者。

2. ARP 报文怎么走、谁该应答:ZYNQ 7020 的协议拆解与 PS/PL 分工

2.1 一个 42 字节的 ARP 帧:请求与应答的字段逐个拆

ARP 地址解析的核心是 28 字节的 ARP 载荷,外面再套一层 14 字节的以太网头,合起来 42 字节。以太网头里,目的 MAC、源 MAC 各占 6 字节,随后的 2 字节 EtherType 必须是0x0806,交换机、PC 和路由器都是靠这个字段认出 ARP 帧的。ARP 载荷前 4 个字段固定:硬件类型0x0001表示以太网,协议类型0x0800表示 IPv4,硬件地址长度 6,协议地址长度 4。第 7、8 字节的操作码决定了这是请求还是应答,请求是0x0001,应答是0x0002。

后面的 20 字节才是真正干活的地方。请求方 MAC 和请求方 IP 告诉对方“我是谁、我在哪”,目标 MAC 在请求里全写 0,目标 IP 是想要查的那个地址。应答方收到请求后,把操作码改成 2,把自己的 MAC 填进发送方硬件地址,把请求方的 MAC 填进目标硬件地址,原路送回。PC 侧的 Wireshark 只认这套字段匹配,任何一处的字节序颠倒、长度算错,都会让对端直接把这个帧丢掉。

下面是完整的字段分布,上板前最好对着这张表看一遍,很多“回包了但对方不认”的问题都出在这 28 字节上。

偏移(相对 ARP 载荷)字段长度(字节)请求取值应答取值
0硬件类型20x00010x0001
2协议类型20x08000x0800
4硬件地址长度166
5协议地址长度144
6操作码20x00010x0002
8发送方 MAC6本机 MAC本机 MAC
14发送方 IP4本机 IP本机 IP
18目标 MAC6全 0请求方 MAC
24目标 IP4对端 IP请求方 IP

注意操作码和 IP 地址在字节流里都是高位在前,也就是大端序。写状态机的时候容易把0x0002拆成02 00,这一错,抓包软件立刻标成Unknown操作,回包等于白发。

2.2 硬 GEM 还是 PL 软 MAC:做 ARP 应答的三条路线

ZYNQ 7020 的 PS 侧自带一个千兆以太网 MAC(GEM),跑 Linux 时 ARP 由内核协议栈回答,这是绝大多数开发板默认连网方式。但这个方案有个前提:你的板子要把 PS 的 GEM 引脚接到 PHY 上。很多自研板把 PHY 放在 PL 侧,或者板子上有多个以太网口,PS 只有一个 GEM 不够用,这时候就必须在 PL 里另起炉灶。

在做 PL 侧 ARP 时,业内通常走三条路线。第一条是例化 Xilinx 的 AXI Ethernet IP,由它完成 MAC 和 RGMII 时序,ARP 交给 PS 侧软件协议栈,PL 只做链路层。第二条是自研 RGMII 收发器,把 ARP 硬逻辑做进 FPGA,PS 侧只负责通过 AXI-Lite 写 IP 地址和 MAC。第三条是折中方案,用软核处理器(MicroBlaze)跑 lwIP,ARP 交给软核,PL 侧负责收发。

我一般选第二条。原因很现实:AXI Ethernet 虽然省事,但 IP 核加许可和配置消耗的时间,够把 ARP 模块自己写两遍了;MicroBlaze 跑 lwIP 要挂 DDR,启动时间慢,低延迟场景扛不住。真正的 ARP 逻辑只用几十行状态机,PL 侧做进去之后,PS 侧不参与也能独立应答,这个特性在工业交换和板卡自检场景里非常值钱。

2.3 最小帧、填充与 FCS:拿到 PHY 之前先算对的三个数

ARP 报文标准长度只有 42 字节,而 IEEE 802.3 要求以太网帧最小 64 字节,这是上板最常见的翻车点。64 字节的构成是:目的 MAC 6 字节、源 MAC 6 字节、EtherType 2 字节、有效载荷 46 字节、FCS 校验 4 字节。ARP 的有效载荷只有 28 字节,所以必须填充 18 字节的0x00,凑满 46 字节,否则交换机和 PC 网卡会按帧短错误处理,直接丢弃。

FCS(帧校验序列)是 CRC32,由 MAC 发送逻辑算。写 FPGA 驱动时,FCS 可以在 ARP 应答模块末尾补,也可以在发送通道里统一算。最省事的做法是在 ARP 引擎之外单独放一个 CRC32 计算块,对所有待发帧统一追加 4 字节,这样 UDP、ICMP 的回包也能复用同一套发送逻辑。落到代码时,64 字节的计数要从 0 数到 63,其中第 42 到第 59 字节是填充,第 60 到第 63 字节是 CRC;如果填充或 CRC 少发一个字节,对端网卡直接丢帧,而你这个“能发出去就是没人回”的现象抓半天抓不出来。

3. 在 PL 侧写 ARP 应答引擎:从 RGMII 收包到回包的状态机

3.1 顶层模块划分:recv / parse / reply / send 四块

自研 ARP 模块的顶层可以拆成四个子功能:RGMII 接收通道、ARP 帧解析器、应答生成器、RGMII 发送通道。接收通道负责把 PHY 串行过来的数据按字节切出来,并做半字节到字节的对齐,这一步在 RGMII 上尤其容易丢字节,因为 RGMII 是在时钟上升沿和下降沿各采 4 bit,采回来的两个半字节必须合并成一个完整字节。解析器从字节流里识别 EtherType 和操作码,同时记录发送方 MAC、发送方 IP 和目标 IP。应答生成器拿到这些字段后,构造一个完整的 ARP 应答帧。发送通道负责按 125 MHz 的时序把应答帧逐字节发到 PHY。

这里有个设计取舍:ARP 模块要不要先存整帧再判断。接收进来的 ARP 请求一共 42 字节,用 FIFO 暂存是最稳的做法,但会多花一组 BRAM;如果只做请求应答,完全可以在接收过程中边收边判,等 42 字节走完,直接就知道该不该回。我的经验是,做最小实现时不加 FIFO,把解析器做成流水,省资源也省延迟。后续要扩展 UDP 收发时,再在前面补一个 64 字节的异步 FIFO 做缓冲。

3.2 收包与识别:ARP 请求的判定状态机

下面这个状态机是 ARP 模块最核心的一段代码,它接收字节流并完成请求识别。状态定义里特意把S_ETH_TYPE单独拎出来,方便以后扩展 IPv4 之外的协议,不用改动其他状态。

// arp_engine.v —— 从 RGMII 字节流中识别发给自己的 ARP 请求 module arp_engine #( parameter [47:0] MY_MAC = 48'h00_0a_35_00_01_64, // 板卡 MAC,可上位机配置 parameter [31:0] MY_IP = 32'hc0_a8_01_64, // 板卡 IP,上电默认 192.168.1.100 parameter ONEHOT = 0 // 0=二进制编码,1=独热码 )( input wire clk_125m, // RGMII 的 GTX_CLK,由 MMCM 产生 input wire rst_n, input wire [7:0] rx_data, // 接收通道对齐后的字节 input wire rx_valid, output reg reply_need, // 应答请求标记 output reg [47:0] peer_mac, // 请求方 MAC output reg [31:0] peer_ip, // 请求方 IP output reg frame_bad // 帧异常标记 ); localparam S_IDLE = 3'd0; localparam S_DST_MAC = 3'd1; localparam S_ETH_TYPE = 3'd2; localparam S_OP = 3'd3; localparam S_SENDER = 3'd4; localparam S_TARGET = 3'd5; localparam S_DONE = 3'd6; reg [2:0] state_cnt; reg [5:0] byte_cnt; // 0~41 字节计数 wire [7:0] local_byte; // 当前字节按位置拼接 always @(posedge clk_125m or negedge rst_n) begin if (!rst_n) begin state_cnt <= S_IDLE; byte_cnt <= 6'd0; peer_mac <= 48'd0; peer_ip <= 32'd0; reply_need <= 1'b0; frame_bad <= 1'b0; end else if (rx_valid) begin case (state_cnt) S_IDLE: begin state_cnt <= S_DST_MAC; byte_cnt <= 6'd0; end S_DST_MAC: begin // 前 6 字节为目标 MAC,广播地址 fc 全 1 if (byte_cnt == 6'd5) begin byte_cnt <= 6'd6; state_cnt <= S_ETH_TYPE; end else begin byte_cnt <= byte_cnt + 1'b1; end end S_ETH_TYPE: begin // 第 12、13 字节必须为 0x0806,否则不是 ARP if (byte_cnt == 6'd12 && rx_data != 8'h08) frame_bad <= 1'b1; else if (byte_cnt == 6'd13 && rx_data != 8'h06) frame_bad <= 1'b1; else if (byte_cnt == 6'd13) begin state_cnt <= S_OP; byte_cnt <= 6'd20; // 跳过第 14~19 字节 end end S_OP: begin // 字段 20、21 是操作码,请求应为 0x0001 if (byte_cnt == 6'd20 && rx_data == 8'h00) begin byte_cnt <= 6'd21; end else if (byte_cnt == 6'd21 && rx_data == 8'h01) begin state_cnt <= S_SENDER; byte_cnt <= 6'd22; end else begin frame_bad <= 1'b1; end end S_SENDER: begin // 第 22~33 字节依次是发送方 MAC 和 IP case (byte_cnt) 6'd22: peer_mac[47:40] <= rx_data; 6'd23: peer_mac[39:32] <= rx_data; 6'd24: peer_mac[31:24] <= rx_data; 6'd25: peer_mac[23:16] <= rx_data; 6'd26: peer_mac[15:8] <= rx_data; 6'd27: peer_mac[7:0] <= rx_data; 6'd28: peer_ip[31:24] <= rx_data; 6'd29: peer_ip[23:16] <= rx_data; 6'd30: peer_ip[15:8] <= rx_data; 6'd31: peer_ip[7:0] <= rx_data; endcase if (byte_cnt == 6'd31) begin state_cnt <= S_TARGET; byte_cnt <= 6'd32; end else begin byte_cnt <= byte_cnt + 1'b1; end end S_TARGET: begin // 第 38~41 字节是目标 IP,必须等于 MY_IP if (byte_cnt == 6'd38 && rx_data == MY_IP[31:24]) byte_cnt <= 6'd39; else if (byte_cnt == 6'd39 && rx_data == MY_IP[23:16]) byte_cnt <= 6'd40; else if (byte_cnt == 6'd40 && rx_data == MY_IP[15:8]) byte_cnt <= 6'd41; else if (byte_cnt == 6'd41 && rx_data == MY_IP[7:0]) begin reply_need <= 1'b1; state_cnt <= S_DONE; end else begin frame_bad <= 1'b1; end end S_DONE: begin reply_need <= 1'b0; state_cnt <= S_IDLE; end endcase end end endmodule

这段代码的判定逻辑是边收边判,不在帧结束后补判,所以只要目标 IP 字段比对成功,立刻置reply_need。逻辑上要注意byte_cnt的跳变:第 13 字节判断完 EtherType 后,直接把计数指到 20,跳过 ARP 载荷里前 6 个固定字段。这样省掉了 4 个状态,也减少了综合后 LUT 的少量压缩。这个状态机我特意不用独热码,4 个以下状态用二进制编码,资源最省;如果以后扩成十几状态的流控机,再考虑独热码,那时独热码的下一状态译码会少一级逻辑,时序更好压,代价是状态寄存器数量翻倍。

参数MY_MAC和MY_IP是编译期写死的,上板要改地址就得重新综合,不方便。实际工程里习惯是把这两个参数从顶层接出去,由第 4 章的 AXI-Lite 寄存器实时改写,这样板子运行中也能换 IP。

3.3 回包生成:应答帧的逐字节构造

下面的代码把收到的peer_mac和peer_ip回填到应答帧里。发送侧我单独用一个tx_cnt计数,把应答帧按 64 字节发完,其中 42 字节是 ARP 帧,18 字节填充,最后 4 字节留给 CRC 模块补。

// arp_reply_gen.v —— 构造 ARP 应答帧,逐字节送入发送通道 module arp_reply_gen #( parameter [47:0] MY_MAC = 48'h00_0a_35_00_01_64 )( input wire clk_125m, input wire rst_n, input wire reply_need, input wire [47:0] peer_mac, input wire [31:0] peer_ip, output reg [7:0] tx_data, output reg tx_valid, output reg tx_last, input wire tx_ready ); localparam IDLE = 2'd0; localparam SEND = 2'd1; localparam WAIT = 2'd2; reg [1:0] st; reg [5:0] tx_cnt; always @(posedge clk_125m or negedge rst_n) begin if (!rst_n) begin st <= IDLE; tx_cnt <= 6'd0; tx_data <= 8'h00; tx_valid <= 1'b0; tx_last <= 1'b0; end else begin case (st) IDLE: begin if (reply_need) begin tx_cnt <= 6'd0; st <= SEND; end end SEND: begin tx_valid <= 1'b1; case (tx_cnt) 6'd0: tx_data <= peer_mac[47:40]; // 目的 MAC 6'd1: tx_data <= peer_mac[39:32]; 6'd2: tx_data <= peer_mac[31:24]; 6'd3: tx_data <= peer_mac[23:16]; 6'd4: tx_data <= peer_mac[15:8]; 6'd5: tx_data <= peer_mac[7:0]; 6'd6: tx_data <= MY_MAC[47:40]; // 源 MAC 6'd7: tx_data <= MY_MAC[39:32]; 6'd8: tx_data <= MY_MAC[31:24]; 6'd9: tx_data <= MY_MAC[23:16]; 6'd10: tx_data <= MY_MAC[15:8]; 6'd11: tx_data <= MY_MAC[7:0]; 6'd12: tx_data <= 8'h08; // EtherType 0x0806 6'd13: tx_data <= 8'h06; 6'd14: tx_data <= 8'h00; // HTYPE 0x0001 6'd15: tx_data <= 8'h01; 6'd16: tx_data <= 8'h08; // PTYPE 0x0800 6'd17: tx_data <= 8'h00; 6'd18: tx_data <= 8'h06; // HLEN 6'd19: tx_data <= 8'h04; // PLEN 6'd20: tx_data <= 8'h00; // OP 0x0002 6'd21: tx_data <= 8'h02; 6'd22: tx_data <= MY_MAC[47:40]; // SHA 6'd23: tx_data <= MY_MAC[39:32]; 6'd24: tx_data <= MY_MAC[31:24]; 6'd25: tx_data <= MY_MAC[23:16]; 6'd26: tx_data <= MY_MAC[15:8]; 6'd27: tx_data <= MY_MAC[7:0]; 6'd28: tx_data <= 8'hc0; // SPA(本机 IP) 6'd29: tx_data <= 8'ha8; 6'd30: tx_data <= 8'h01; 6'd31: tx_data <= 8'h64; 6'd32: tx_data <= peer_mac[47:40]; // THA 6'd33: tx_data <= peer_mac[39:32]; 6'd34: tx_data <= peer_mac[31:24]; 6'd35: tx_data <= peer_mac[23:16]; 6'd36: tx_data <= peer_mac[15:8]; 6'd37: tx_data <= peer_mac[7:0]; 6'd38: tx_data <= peer_ip[31:24]; // TPA 6'd39: tx_data <= peer_ip[23:16]; 6'd40: tx_data <= peer_ip[15:8]; 6'd41: tx_data <= peer_ip[7:0]; default: tx_data <= 8'h00; // 18 字节填充 endcase if (tx_cnt < 6'd63) tx_cnt <= tx_cnt + 1'b1; else begin tx_valid <= 1'b0; tx_last <= 1'b1; st <= WAIT; end end WAIT: begin tx_last <= 1'b0; st <= IDLE; end endcase end end endmodule

这段代码的关键是把应答帧的源 IP、源 MAC 放对位置。应答的 SPA 必须填本机 IP,SHA 必须填本机 MAC,如果顺手把请求方的 MAC 又填回 SHA,对端看起来等于一个“自己答自己”的包,Wireshark 会提示源地址异常,多数协议栈也会直接丢弃。tx_last在发送最后一帧拉高一个周期,让下游的 CRC 模块知道该在此刻追加 4 字节校验。发送通道的tx_ready是 PHY 侧的流控信号,如果 PHY 被拉低,这段简化代码里没有处理背压,扩展时要在这里补一个握手状态。

4. 把驱动接进 PS:AXI-Lite 寄存器映射与 ARM 侧读写

4.1 寄存器映射与地址分配:用 Vivado 把 AXI-Lite 挂到 PL

硬件逻辑只写死参数是没法用的,实际工程里需要 PS 侧动态改板卡的 IP 和 MAC,所以得给 ARP 引擎加一组 AXI-Lite 寄存器。在 Vivado 里把 ARP 引擎包装成 AXI 外设,做法是用 Create and Package New IP,把 arp_engine 的端口映射到 AXI-Lite 从机接口上,然后跑 Block Automation 连接到 PS 的 M_AXI_GP0 口。这里要注意,PS 的 M_AXI_GP 接口地址范围默认是 0x40000000 到 0x7FFFFFFF,Vivado 会从 0x40000000 开始为每个外设分配基址,如果你的工程里已经挂了别的 AXI 外设,记得改到不冲突的地址。

寄存器分配我按下面这张表来定,偏移从基地址开始算。低 4 字节放 MAC 的低 32 位,高 2 字节放高 16 位,IP 放 32 位,控制寄存器最低位是使能位,状态寄存器最低位表示上一次 ARP 应答是否成功发出。这样 PS 侧驱动一次写 5 个寄存器就能完成全部配置,读状态也就 1 个寄存器。

偏移寄存器名位宽说明
0x00MAC_LOW32板卡 MAC 低 4 字节
0x04MAC_HIGH16板卡 MAC 高 2 字节
0x08IP_ADDR32板卡 IPv4 地址,大端序
0x0CCTRL32bit0=ARP 使能,bit1=免费 ARP 触发
0x10STATUS32bit0=最近一次应答成功,bit1=收到请求计数溢出

4.2 PS 侧裸机驱动:mmap 一口气把 MAC、IP 写进 PL

在 ZYNQ 的 Linux 里操作物理地址最直接的方式是 mmap 映射/dev/mem。下面的代码把所有配置写进 PL 侧的 ARP 引擎,写完再读一次状态寄存器确认使能生效。

// arp_pl_cfg.c —— 通过 /dev/mem 配置 PL 侧 ARP 引擎 #include <fcntl.h> #include <stdio.h> #include <stdint.h> #include <sys/mman.h> #include <unistd.h> #define ARP_BASE 0x43C00000UL // 由 Vivado 地址编辑器分配 #define REG_MAC_LO 0x00 #define REG_MAC_HI 0x04 #define REG_IP 0x08 #define REG_CTRL 0x0C #define REG_STATUS 0x10 int main(void) { int fd = open("/dev/mem", O_RDWR | O_SYNC); if (fd < 0) { perror("open /dev/mem"); return -1; } volatile uint8_t *base = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, ARP_BASE); if (base == MAP_FAILED) { perror("mmap"); close(fd); return -1; } uint32_t mac_lo = 0x00010164; // 00:0A:35:01:01:64 低 32 位 uint32_t mac_hi = 0x00000A35; // 高 16 位 0x000A,加 0x35 uint32_t ip = 0xC0A80164; // 192.168.1.100 *(volatile uint32_t *)(base + REG_MAC_LO) = mac_lo; *(volatile uint32_t *)(base + REG_MAC_HI) = mac_hi; *(volatile uint32_t *)(base + REG_IP) = ip; *(volatile uint32_t *)(base + REG_CTRL) = 0x1; // 使能 ARP 引擎 uint32_t status = *(volatile uint32_t *)(base + REG_STATUS); printf("ARP PL status: 0x%08x\n", status); munmap((void *)base, 4096); close(fd); return 0; }

这段驱动代码跨过了 Linux 内核,直接把物理地址映射进用户的地址空间,优点是调试时改参数最快,缺点是没有隔离,应用程序一旦越界写,板子大概率直接死机。参数里ARP_BASE必须和 Vivado 里实际分配的地址一致,常见错误是 PL 逻辑里改了基址,驱动还按老的 0x43C00000 去映射,结果写进了别的外设,后续行为完全随机。MAP_SHARED也不能改成MAP_PRIVATE,否则写操作不会落到物理内存上,读回来的还是缓存里的旧值。

上述代码编译后放到板子上运行时,先确认/dev/mem存在且当前用户有权限,然后用sz或者 NFS 把可执行文件拖进去,chmod +x后直接运行。运行完在 PC 上 ping 板卡,如果 ARP 模块工作了,arp -a能看到板卡的 MAC 和 IP 对应关系。

4.3 加一层 Linux 字符设备:中断还是轮询

裸机 mmap 只适合调试期,产品化还是要写一个正式的字符设备驱动,把 ARP 模块的配置暴露成/dev/arp_pl。写驱动时面临中断和轮询的选择。ARP 应答本身不依赖 CPU,PS 侧真正需要知道的是“板卡收没收到 ARP 请求”这类事件,用于统计或上报。轮询驱动最简单,内核里起一个定时器,50 毫秒读一次状态寄存器,缺点是实时性差、浪费总线带宽。

如果要求毫秒级响应,就在 PL 侧给reply_need信号接一个边沿检测,连到 PS 的 PL-PS 中断引脚(例如 IRQ_F2P[15:0])。之后在设备树里配interrupt-parent和interrupts属性,驱动里用request_irq注册中断处理函数,读 STATUS 寄存器清中断位。对于 ARP 这种低频事件,我一般仍选轮询,因为中断要处理共享中断号、中断丢失和嵌套问题,50 毫秒的轮询在管理面完全够用;如果是做 UDP 数据面,才需要中断配合 DMA。

5. 上板避坑:ARP 调试常见的 5 个翻车现场

5.1 现象:PC ping 板卡,首包超时 200ms,之后才通

原因:PC 的 ARP 缓存里没有板卡表项,第一次 ping 会先广播 ARP 请求。如果 FPGA 回复得慢,PC 认为请求超时,重发一次,第二次才建立映射。这里常见的是 FPGA 里 ARP 模块只处理了请求,没有处理 PC 已经缓存后的到期重发,表现为每次过一段时间就掉一拍。

解决:在 Wireshark 里抓包,看 PC 是不是在发重复的 ARP 请求。如果是,检查 FPGA 的回包是否在 1 毫秒内发出,重点查 RGMII 发送通道是否有额外的 FIFO 延迟。还有一个普遍现象是 PC 端把板卡 IP 静态绑定到了旧 MAC 上,用arp -d清一次缓存再测。

5.2 现象:回包发得出去,但对端 Wireshark 看不到

原因:这个翻车大概率出在 CRC 上。ARP 应答帧由 FPGA 生成,如果发送逻辑只有 42 字节就直接tx_last,CRC32 没有追加,网卡硬件在物理层就把帧丢弃了,PC 上层根本看不到。另一个常见原因是填充字节不足,42 字节的 ARP 帧没有补到 46 字节有效载荷,总帧长小于 64 字节。

解决:抓包看波形,把发送逻辑的字节计数改成数到 63 再拉tx_last,并在第 60 到第 63 字节输出 CRC32。CRC32 可以先用现成的校验 IP,注意 CRC 的初值、方向和按位取反,RGMII 下是 LSB-first 逐位送出,和 GMII 的 MSB-first 正好相反。

5.3 现象:PC 能 ping 通,但连交换机后其他设备单通

原因:交换机端口默认开启了 MAC 地址学习,ARP 应答的源 MAC 必须是板卡的唯一 MAC。如果板卡 MAC 和另一块板子重了,或者 PL 逻辑里使用了全 0 MAC,交换机会不停刷新 CAM 表,导致转发随机丢包。PC 直连时 ARP 表由对端缓存,掩盖了这个问题。

解决:为每块板卡分配独立 MAC,写进寄存器。可以用厂商 OUI 前缀,后 3 字节由拨码开关或 EEPROM 决定。还有一个隐蔽点是 ARP 应答里的 THA 字段填了请求方的 IP,而 TPA 字段却用了自己的 IP,这种错位在直连时能通,通过交换机时会被 ARP 防欺骗策略直接拦截。

5.4 现象:重新烧写 BOOT.bin 后 ARP 模块能收不能回

原因:ZYNQ 上电加载 PL bit 流时序和 PS 启动存在竞态。如果 PS 侧 Linux 启动时就把 ARP 引擎使能了,但那时 PL 逻辑还没完成初始化,寄存器写入会丢失,ARP 引擎停在一个非法状态。重新烧写 BOOT.bin 时如果只更新了 FSBL,没加入新的 bit 流,PL 侧跑的还是旧版本逻辑。

解决:在驱动里使能 ARP 之前,先读 STATUS 寄存器确认bit0为可写状态,或者让 FSBL 在 Linux 起来之前就完成 PL 下载。更稳的做法是在 CTRL 寄存器里加一个软复位位,驱动初始化时先置 0 再置 1,确保 ARP 状态机回到 IDLE。

5.5 现象:只有固定 IP 能通,换网段就没人应答

原因:ARP 模块只比较了目标 IP 的低 8 位,或者 IP 比较逻辑写死了某一段。有的代码图省事,只判断MY_IP[31:24]和MY_IP[7:0],中间 16 位放空,结果 ARP 请求目标 IP 只要是同网段开头就应答,换到别的网段又因为高位不匹配不响应。

解决:目标 IP 比对必须四个字节全部相等,并且要注意字节序。状态机里逐字节比较时,第 38 字节对MY_IP[31:24],第 39 字节对MY_IP[23:16],依次类推。调试时在S_TARGET状态里加一个计数器,看看到底哪一位不匹配,比盲改综合快得多。

6. 进阶技巧:ARP 缓存表、免费 ARP 与上板验证顺序

ARP 应答做通之后,想要往 UDP 数据通路扩展,有几个细节值得一起做进去。第一个是在 PL 里加一个小型 ARP 缓存表,把最近收到的请求方 IP 和 MAC 存下来,表项用寄存器堆实现,不必上 BRAM,存 8 条就够用。这样后续发 UDP 报文时,如果目标 IP 已经在缓存里,就不用等下一次 ARP 请求,直接按缓存 MAC 发帧。缓存每条记录需要一个有效位和一个老化计数器,一般 5 分钟老化一次,不然网关换地址后板卡还按旧 MAC 发。

第二个值得做的是免费 ARP(Gratuitous ARP)。上电后 FPGA 主动广播一帧 ARP 请求,目标 IP 填自己的 IP,目标 MAC 填全 0,通知整网“这块板子在这个 IP 上”。很多交换机和上位机软件靠这个报文刷新设备列表,不做这一步,上位机可能要等十几秒才认到板卡。免费 ARP 的发送逻辑可以直接复用 3.3 节那段生成器,把reply_need换成上电触发一次就行。

第三个是上板验证的顺序,我现在的固定流程是:先用 PC 和板卡直连,在 PC 上配一个同网段静态 IP,用arp -s 192.168.1.100 00-0a-35-01-01-64把 MAC 固定下来,验证纯数据通路;再清掉静态表项,用ping触发 ARP 学习,验证 ARP 引擎的应答能力;最后才接交换机测试。每一步都在 Wireshark 里加过滤器arp.opcode==1 || arp.opcode==2,盯着请求和应答的往返时间看。

这套流程帮我解决过不少自己制造的“玄学”问题。有一回板卡怎么 ping 都不通,抓包发现 FPGA 一直在回包,但 PC 始终发请求,后来才发现是 Windows 网卡驱动把板卡的 MAC 判定成非法组播地址,静态绑定一条才绕过。从那以后养成了个习惯:改完 ARP 逻辑,先不碰 UDP 和 DMA,只在抓包工具里确认 42 字节的请求和 42 字节的应答成对出现,再动上层。希望这些踩坑记录和验证步骤,能帮你在 ZYNQ 7020 上把 ARP 从玄学变成确定性的逻辑,少走一段弯路。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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