简介:这份资源面向需要让FPGA直接驱动以太网PHY芯片的开发者,提供了一套可参考的Verilog实现代码。示例基于Altera EP3C40系列FPGA与AR8031 PHY芯片,核心思路是通过寄存器配置完成链路建立,并可根据代码中的寄存器定义迁移适配到其他PHY型号,适合具备一定FPGA与以太网基础的工程师参考。压缩包共约2000个文件,整体19.88MB,以cdb、hdb等Quartus工程数据库文件为主,同时包含315个v与6个sv源文件、21个vhd、81个tdf,以及qsf、qpf等工程配置文件和若干txt说明,完整保留了工程编译与综合所需的目录结构。目前已有1895人学习下载。读者可从中获取PHY初始化、MDIO管理接口、收发时序控制等关键模块的实现思路,并借助工程文件快速复现与二次开发,减少从零调试以太网链路的时间成本。
1. 一块没有 MAC 的 PHY,怎么让 FPGA 把包发出去
手头有一块以太网接口板,上面焊着一颗 AR8031 PHY,旁边就是 FPGA,但中间没有独立 MAC 芯片。很多人第一反应是去找现成的 MAC IP,结果发现要么授权费劝退,要么接口对不上。其实 AR8031 这类 PHY 支持 RGMII 直连模式,FPGA 完全可以用普通 IO 模拟 RGMII 时序,自己实现一个精简的 MAC 收发逻辑。这份参考代码解决的就是这个场景:不依赖硬核 MAC,用 FPGA 直接驱动 PHY 完成链路建立、ARP 请求和 ICMP 回显。适合已经能跑通 FPGA 基本工程、想打通以太网物理层到数据链路层的硬件工程师和逻辑设计者。代码结构不复杂,但 RGMII 的时序约束和 PHY 寄存器配置是绕不开的两道坎,下面按实际调试顺序拆开讲。
2. RGMII 接口时序与 PHY 上电初始化:从复位到链路 UP
2.1 为什么选 RGMII 而不是 MII
AR8031 支持 MII、RMII、RGMII 三种 MAC 侧接口。MII 需要 4 位数据线加独立收发时钟,引脚多但时序宽松;RMII 把数据线压到 2 位,时钟提到 50MHz,引脚少但对 FPGA 的 IO 时序要求更高;RGMII 用 4 位数据线,在 125MHz 时钟的上下沿都采样,等效 8 位数据宽度,引脚数和带宽平衡得最好。常见做法是选 RGMII,因为大多数中低端 FPGA 的 IO 在 125MHz DDR 下还能收敛,而 RMII 的 50MHz 单沿采样虽然频率低,但建立保持窗口更窄,反而容易翻车。
RGMII 的关键点在于:TX 侧由 FPGA 输出TXC时钟和数据TXD[3:0]、控制信号TX_CTL,RX 侧由 PHY 输出RXC时钟和数据RXD[3:0]、控制信号RX_CTL。数据在时钟的上升沿传低 4 位,下降沿传高 4 位。控制信号也复用:TX_CTL 在上升沿表示 TXEN,下降沿表示 TXERR;RX_CTL 上升沿表示 RXDV,下降沿表示 RXERR。这个复用关系如果搞反,表现就是链路能 UP 但发不出包,或者收到的全是错包。
2.2 PHY 复位与寄存器配置流程
AR8031 上电后需要先复位,再通过 MDIO 写寄存器。MDIO 是两线接口:MDC时钟和MDIO双向数据。FPGA 侧需要实现一个 MDIO 主机控制器,按 IEEE 802.3 的帧格式读写 PHY 寄存器。帧格式是 32 位前导码加 2 位起始码、2 位操作码、5 位 PHY 地址、5 位寄存器地址、2 位转向和 16 位数据。
下面是一个简化的 MDIO 写时序代码片段,用状态机实现:
// MDIO 写寄存器状态机(简化版) // 输入:clk 为 25MHz,phy_addr 为 5 位 PHY 地址,reg_addr 为 5 位寄存器地址 // wr_data 为 16 位待写数据 localparam IDLE = 3'd0; localparam PRE = 3'd1; // 前导码 32 个 1 localparam ST = 3'd2; // 起始码 01 localparam OP = 3'd3; // 操作码 10 表示写 localparam ADDR = 3'd4; // PHY 地址 + 寄存器地址 localparam TA = 3'd5; // 转向位 10 localparam DATA = 3'd6; // 16 位数据 reg [4:0] bit_cnt; reg [31:0] shift_reg; always @(posedge clk) begin case (state) IDLE: if (start) begin shift_reg <= 32'hFFFFFFFF; // 前导码 bit_cnt <= 0; state <= PRE; end PRE: begin mdc <= ~mdc; if (mdc) begin mdio_out <= 1'b1; bit_cnt <= bit_cnt + 1; if (bit_cnt == 31) begin bit_cnt <= 0; state <= ST; end end end ST: begin // 发送 01 mdc <= ~mdc; if (mdc) begin mdio_out <= (bit_cnt == 0) ? 1'b0 : 1'b1; bit_cnt <= bit_cnt + 1; if (bit_cnt == 1) begin bit_cnt <= 0; state <= OP; end end end // ... 后续状态类似,依次发送操作码、地址、转向和数据 endcase end这段代码的逻辑说明:MDC 时钟由 FPGA 分频产生,通常不超过 2.5MHz,AR8031 手册里标称最高 25MHz,但实际调试时降到 1MHz 左右更稳。前导码是 32 个连续的 1,作用是让 PHY 的 MDIO 接口同步。起始码固定为01,操作码10表示写、01表示读。PHY 地址由硬件引脚决定,常见接法是上下拉电阻组合,代码里phy_addr要跟板子实际连接一致。转向位在写操作时是10,读操作时是Z0,这里容易写错,写错的表现是寄存器读回来全是 1 或全是 0。
参数说明:clk建议用 25MHz 晶振直接驱动,不要用 PLL 倍频到很高再分频,因为 MDC 的占空比和相位关系会影响建立保持。bit_cnt的位宽要够,前导码 32 位需要 5 位计数器。shift_reg在写操作里其实可以省掉,直接按位输出更直观,这里保留是为了兼容读操作的移位采样。
2.3 关键寄存器配置与链路建立
AR8031 需要配置的寄存器不多,但有几个必须写对。常见做法是上电后先读PHY ID寄存器确认通信正常,然后写BMCR做软复位,再写ANAR和ANLPAR配置自协商能力,最后写AR8031 扩展寄存器把 RGMII 延时调好。
| 寄存器地址 | 名称 | 配置值 | 作用 |
|---|---|---|---|
| 0x00 | BMCR | 0x9140 | 自协商使能、重启自协商 |
| 0x04 | ANAR | 0x01E1 | 通告 100M 全双工、10M 全双工 |
| 0x09 | 1000BASE-T 控制 | 0x0300 | 使能 1000M 全双工 |
| 0x1D | RGMII 延时控制 | 0x0005 | TX 延时 1.5ns,RX 延时 1.5ns |
RGMII 延时是调试中最容易踩的坑。AR8031 内部有可编程延时线,如果 PCB 走线长度不匹配,或者 FPGA 侧没有做 IO 延时约束,采样点就会偏。常见做法是先把 PHY 侧延时设成默认值,用示波器看 RXC 和 RXD 的相位关系,再微调0x1D寄存器的值。如果手头没有示波器,就写一个简单的收发测试:FPGA 发固定包,PHY 环回,看收到的是不是原包。收不到就每次改一个延时档位,直到通为止。
链路 UP 的判断方法是读BMSR寄存器的 bit 2(Link Status)。这个位是自清零的,读一次之后会清零,下次读之前需要先读一次丢弃,再读第二次才是真实状态。这个细节在 AR8031 手册里有写,但很多人第一次调的时候会以为链路没起来,其实是读法不对。
3. 用 FPGA 逻辑实现精简 MAC:收发状态机与 CRC 校验
3.1 发送路径:从字节流到 RGMII 时序
发送路径的核心是把上层给的字节流加上前导码、SFD、目的地址、源地址、类型/长度、数据和 FCS,然后按 RGMII 的 DDR 时序输出。前导码是 7 个0x55,SFD 是0xD5,这两个是固定格式。目的地址和源地址各 6 字节,类型/长度 2 字节,数据长度可变,FCS 是 4 字节 CRC32。
下面是一个发送状态机的核心片段:
// 发送状态机:按字节输出,每个字节拆成高低 4 位在 DDR 输出 // tx_data 为 8 位待发字节,tx_valid 为有效指示 localparam TX_IDLE = 2'd0; localparam TX_PRE = 2'd1; // 前导码 + SFD localparam TX_DATA = 2'd2; // 数据 + FCS reg [3:0] nibble_cnt; reg [7:0] tx_byte; reg [1:0] state; always @(posedge clk_125m) begin case (state) TX_IDLE: if (tx_valid) begin tx_byte <= 8'h55; // 前导码 nibble_cnt <= 0; state <= TX_PRE; end TX_PRE: begin // 每个字节分两个时钟沿输出 if (nibble_cnt == 0) begin txd <= tx_byte[3:0]; tx_ctl <= 1'b1; nibble_cnt <= 1; end else begin txd <= tx_byte[7:4]; tx_ctl <= 1'b1; nibble_cnt <= 0; // 前导码发完 7 个后发 SFD if (pre_cnt == 6) begin tx_byte <= 8'hD5; pre_cnt <= 0; state <= TX_DATA; end else begin tx_byte <= 8'h55; pre_cnt <= pre_cnt + 1; end end end TX_DATA: begin // 从 FIFO 读数据,计算 CRC,发完数据后发 FCS // ... end endcase end逻辑说明:clk_125m是 RGMII 的参考时钟,125MHz。每个字节在时钟的上升沿输出低 4 位,下降沿输出高 4 位,所以一个字节占一个时钟周期。tx_ctl在发送期间保持高,表示 TXEN 有效。前导码发 7 个0x55后发0xD5,然后进入数据状态。数据状态里需要同时做两件事:把数据字节输出,以及把字节送入 CRC32 计算模块。CRC 算完后取反,低字节先发。
参数说明:tx_byte的位序是低 4 位先发,对应 RGMII 的 DDR 上升沿。nibble_cnt用来标记当前是低 4 位还是高 4 位。pre_cnt计数前导码个数,到 6 的时候切换到 SFD。这里没有写完整的 FIFO 接口和 CRC 模块,实际代码里需要补上,但状态机的骨架就是这样。
3.2 接收路径:从 RGMII 采样到 CRC 校验
接收路径比发送麻烦一点,因为 RXC 是 PHY 输出的,FPGA 需要用这个时钟去采样 RXD。常见做法是用 IDDR 原语把 DDR 数据拆成两个单沿数据,或者用IDELAY加ISERDES做更精确的采样。如果 FPGA 不支持 IDDR,也可以用逻辑在 RXC 的上下沿分别寄存,但要注意时序约束。
接收状态机的流程是:检测到 RX_CTL 上升沿后开始找 SFD,找到 SFD 后开始收数据,同时算 CRC,收完后比较 CRC 是否正确。如果正确,把数据写入 FIFO;如果错误,丢弃并置错误标志。
// 接收状态机:用 IDDR 拆 DDR 数据 // RXC 为 PHY 输出时钟,RXD 为 4 位数据,RX_CTL 为控制 genvar i; generate for (i = 0; i < 4; i = i + 1) begin : iddr_rxd IDDR #( .DDR_CLK_EDGE("SAME_EDGE_PIPELINED"), .INIT_Q1(1'b0), .INIT_Q2(1'b0), .SRTYPE("SYNC") ) iddr_inst ( .Q1(rxd_low[i]), // 上升沿数据 .Q2(rxd_high[i]), // 下降沿数据 .C(RXC), .CE(1'b1), .D(RXD[i]), .R(1'b0), .S(1'b0) ); end endgenerate // 拼接成完整字节 wire [7:0] rx_byte = {rxd_high, rxd_low}; // 接收状态机 localparam RX_IDLE = 2'd0; localparam RX_SFD = 2'd1; localparam RX_DATA = 2'd2; always @(posedge RXC) begin case (rx_state) RX_IDLE: if (rx_ctl_rise) begin rx_state <= RX_SFD; byte_cnt <= 0; end RX_SFD: begin if (rx_byte == 8'hD5) begin rx_state <= RX_DATA; crc_init <= 1'b1; end end RX_DATA: begin // 写入 FIFO,同时更新 CRC if (rx_ctl_fall) begin // 收完,检查 CRC if (crc_ok) begin fifo_wr <= 1'b1; end else begin crc_err <= 1'b1; end rx_state <= RX_IDLE; end end endcase end逻辑说明:IDDR原语把 RXD 的 DDR 数据拆成rxd_low和rxd_high两个单沿信号,分别对应上升沿和下降沿的数据。拼接时注意位序,rxd_high是高 4 位,rxd_low是低 4 位。rx_ctl_rise和rx_ctl_fall是 RX_CTL 的边沿检测信号,用来判断帧的开始和结束。CRC 校验模块在crc_init有效时复位,然后每个字节更新一次,收完后比较结果。
参数说明:DDR_CLK_EDGE选SAME_EDGE_PIPELINED是为了让 Q1 和 Q2 在同一个时钟沿输出,方便后续拼接。如果选OPPOSITE_EDGE,Q1 和 Q2 会差半个周期,拼接时需要额外对齐。crc_init在 SFD 之后拉高一个周期,复位 CRC 寄存器。crc_ok是比较结果,通常 CRC32 的多项式是0x04C11DB7,初值全 1,结果取反。
3.3 CRC32 校验的两种实现方式
CRC32 可以用查表法或移位法。查表法速度快,但需要 256 个 32 位表项,占用 Block RAM;移位法省资源,但每个字节需要 8 个时钟周期。在 125MHz 的 RGMII 下,每个字节只有 8ns 的处理时间,移位法如果跑不到 125MHz 就会成为瓶颈。常见做法是用查表法,把表存在分布式 RAM 里,或者用 4 位查表加两级流水。
下面是一个简化的查表法 CRC32 代码:
// CRC32 查表法,每个时钟处理一个字节 // crc_reg 为当前 CRC 值,byte_in 为输入字节 reg [31:0] crc_reg; wire [7:0] index = crc_reg[7:0] ^ byte_in; wire [31:0] crc_next = {8'h00, crc_reg[31:8]} ^ crc_table[index]; always @(posedge clk) begin if (crc_init) begin crc_reg <= 32'hFFFFFFFF; end else if (crc_en) begin crc_reg <= crc_next; end end // crc_table 用 case 语句或 ROM 实现,这里省略具体表项逻辑说明:index是当前 CRC 低 8 位与输入字节的异或结果,用来查表。crc_next是移位后的 CRC 与查表结果的异或。crc_init有效时初值设为全 1,crc_en有效时每个时钟更新一次。发送时,数据字节依次送入,最后取反输出;接收时,包括 FCS 在内的所有字节都送入,最后比较结果是否为0xDEBB20E3(取反后的固定值)。
参数说明:crc_table的生成多项式是0x04C11DB7,可以用 Python 脚本生成,也可以在网上找现成的表。如果资源紧张,可以用 4 位查表,每个字节分两次处理,但控制逻辑会复杂一些。注意发送和接收的 CRC 初值和取反方式要一致,否则会出现发出去的包自己能收但对方收不到的情况。
4. 上板调试与避坑:链路不通、CRC 错包、时序不收敛
4.1 链路 UP 但 ping 不通
现象:MDIO 读BMSR显示 Link Status 为 1,但 PC 端 ping 不通。原因通常是 RGMII 延时不对,或者 TX_CTL 的复用关系搞反。先检查0x1D寄存器的延时值,AR8031 默认是 0,需要根据 PCB 走线长度调整。如果 TX 延时不够,PHY 采样不到正确的数据;如果 RX 延时不够,FPGA 采样不到正确的数据。解决方法是写一个简单的回环测试:FPGA 发固定包,PHY 内部环回,看收到的是不是原包。如果不通,每次改一个延时档位,直到通为止。
4.2 CRC 错包率居高不下
现象:能收到包,但 CRC 错误计数器一直在涨。原因可能是 RXC 的采样点偏了,或者 IDDR 的DDR_CLK_EDGE选错。常见做法是用IDELAY对 RXC 做微调,步进 78ps 左右,找到眼图中心。如果 FPGA 不支持IDELAY,可以在 RXC 上加一级反相器,相当于移半个周期,有时候能碰巧采到。另一个原因是 CRC 模块的初值或取反方式不对,检查发送和接收的 CRC 配置是否一致。
4.3 时序报告显示 RGMII 路径不收敛
现象:综合实现后时序报告里 RGMII 的建立保持时间为负。原因是 125MHz DDR 的时序余量本来就小,如果 IO 约束没写对,工具会按默认值布线。解决方法是手动写set_input_delay和set_output_delay,参考 PHY 手册里的建立保持时间。AR8031 的 RGMII 时序要求是:TXC 上升沿前数据有效窗口约 1ns,RXC 上升沿后数据有效窗口约 1ns。约束里要把这些值写进去,工具才会按这个目标去布线和优化。
4.4 MDIO 读回来全是 0xFFFF
现象:读PHY ID寄存器返回0xFFFF,说明 MDIO 通信没建立。原因可能是 MDC 频率太高,或者 PHY 地址不对。先把 MDC 降到 1MHz 以下,再确认phy_addr跟板子上的上下拉电阻一致。AR8031 的 PHY 地址由PHYAD[4:0]引脚决定,常见接法是全部下拉为 0,或者按位组合。如果地址不对,MDIO 帧会被 PHY 忽略,读回来就是全 1。
4.5 发送正常但接收不到任何包
现象:PC 端能收到 FPGA 发的 ARP 请求,但 FPGA 收不到 PC 的 ARP 回复。原因可能是 RX_CTL 的边沿检测逻辑有问题,或者 FIFO 的写使能没接对。先检查rx_ctl_rise和rx_ctl_fall是否在正确的时钟域下生成,如果 RXC 和系统时钟不同源,需要做跨时钟域处理。另一个常见原因是 FIFO 的读侧没有及时读走数据,导致溢出。可以在 FIFO 上加一个溢出标志,调试时看这个标志有没有拉高。
5. 进阶:把 ARP 和 ICMP 跑通,验证端到端链路
5.1 ARP 请求与回复的极简实现
ARP 包的结构是:以太网头 14 字节,ARP 头 28 字节。ARP 头里包括硬件类型、协议类型、硬件地址长度、协议地址长度、操作码、发送方 MAC、发送方 IP、目标 MAC、目标 IP。FPGA 收到 ARP 请求后,如果目标 IP 是自己的,就构造一个 ARP 回复,把发送方 MAC 和 IP 填进去,目标 MAC 和 IP 填成请求方的。
// ARP 回复构造:收到请求后,交换发送方和目标方的 MAC/IP // 输入:req_src_mac, req_src_ip, req_dst_ip // 输出:reply_dst_mac = req_src_mac, reply_src_mac = 本机 MAC // reply_src_ip = req_dst_ip, reply_dst_ip = req_src_ip always @(posedge clk) begin if (arp_req_valid && req_dst_ip == local_ip) begin // 构造回复包 eth_dst <= req_src_mac; eth_src <= local_mac; eth_type <= 16'h0806; arp_op <= 16'h0002; // 回复 arp_src_mac <= local_mac; arp_src_ip <= local_ip; arp_dst_mac <= req_src_mac; arp_dst_ip <= req_src_ip; arp_reply_valid <= 1'b1; end end逻辑说明:arp_req_valid是接收路径解析出的 ARP 请求有效信号,req_dst_ip是请求里的目标 IP。如果目标 IP 等于本机 IP,就构造回复。回复包的以太网目的地址是请求方的源 MAC,源地址是本机 MAC,类型是0x0806。ARP 操作码0x0002表示回复。发送方 MAC/IP 填本机,目标 MAC/IP 填请求方的源 MAC/IP。
参数说明:local_mac和local_ip是本地配置的 MAC 和 IP,通常用参数或寄存器配置。arp_reply_valid拉高一个周期后,发送状态机开始读这些字段并组包。注意 ARP 包的长度是 42 字节(不含 FCS),发送时要在数据字段后补 18 字节的填充,因为以太网最小帧长是 60 字节(不含 FCS)。
5.2 ICMP 回显请求与回复
ICMP 回显请求就是 ping 包。收到 ping 包后,把类型从0x08改成0x00,重新算校验和,然后发回去。ICMP 校验和是 16 位反码求和,计算方法是把 ICMP 头和数据按 16 位分组,逐组相加,溢出的位回卷,最后取反。
// ICMP 校验和计算:16 位反码求和 // data 为 16 位输入,valid 为有效指示 reg [31:0] sum; always @(posedge clk) begin if (icmp_init) begin sum <= 32'h0; end else if (icmp_valid) begin sum <= sum + {16'h0, data}; // 溢出的位回卷 if (sum[31:16] != 0) begin sum <= {16'h0, sum[15:0]} + sum[31:16]; end end end wire [15:0] checksum = ~sum[15:0];逻辑说明:sum是 32 位累加器,每次加一个 16 位数据。如果高 16 位不为 0,说明有溢出,把高 16 位加到低 16 位上。最后取反得到校验和。发送时,先把校验和字段填 0,算完后再填回去。接收时,包括校验和字段一起算,结果应该是0xFFFF。
参数说明:icmp_init在 ICMP 头开始时拉高一个周期,复位累加器。icmp_valid在每个 16 位数据有效时拉高。注意 ICMP 数据长度可能是奇数,最后一个字节要补 0 再算。校验和的字节序是大端,高字节在前。
5.3 端到端验证方法与一个习惯
验证顺序是:先确认 MDIO 能读到PHY ID,再确认链路 UP,然后发 ARP 请求看 PC 有没有回复,最后 ping 通。每一步都有对应的计数器或调试寄存器,不要跳步。我一般会在代码里留一个debug_reg,把链路状态、CRC 错误计数、收发包计数都映射进去,用 MDIO 读出来看。这样不用接示波器也能定位大部分问题。
从那以后我每次调以太网 PHY,都强制先跑一遍 MDIO 读写测试,确认寄存器能读能写,再往下做。这个习惯帮我省了很多来回改代码的时间。希望帮到你。
本文还有配套的精品资源,点击获取