简介:这份资源围绕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 | 硬件类型 | 2 | 0x0001 | 0x0001 |
| 2 | 协议类型 | 2 | 0x0800 | 0x0800 |
| 4 | 硬件地址长度 | 1 | 6 | 6 |
| 5 | 协议地址长度 | 1 | 4 | 4 |
| 6 | 操作码 | 2 | 0x0001 | 0x0002 |
| 8 | 发送方 MAC | 6 | 本机 MAC | 本机 MAC |
| 14 | 发送方 IP | 4 | 本机 IP | 本机 IP |
| 18 | 目标 MAC | 6 | 全 0 | 请求方 MAC |
| 24 | 目标 IP | 4 | 对端 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 个寄存器。
| 偏移 | 寄存器名 | 位宽 | 说明 |
|---|---|---|---|
| 0x00 | MAC_LOW | 32 | 板卡 MAC 低 4 字节 |
| 0x04 | MAC_HIGH | 16 | 板卡 MAC 高 2 字节 |
| 0x08 | IP_ADDR | 32 | 板卡 IPv4 地址,大端序 |
| 0x0C | CTRL | 32 | bit0=ARP 使能,bit1=免费 ARP 触发 |
| 0x10 | STATUS | 32 | bit0=最近一次应答成功,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 从玄学变成确定性的逻辑,少走一段弯路。希望帮到你。
本文还有配套的精品资源,点击获取