资讯动态

FPGA直接驱动以太网PHY:从MDIO到RGMII的完整实现

发布时间:2026/10/18 12:06:25 来源:尧图企业网站定制
简介面向FPGA开发者这份参考代码解决的是如何用Altera公司的EP3C40系列FPGA直接驱动以太网PHY芯片AR8031的问题。工程完整覆盖了PHY寄存器配置、MDIO管理接口、RGMII收发数据通路、时钟复位处理等关键模块并给出针对其他PHY芯片的寄存器适配思路方便使用者移植到不同型号的收发器。压缩包约19.88MB共包含2000个文件以Quartus工程设置与编译数据库文件qpf、qsf、qip、cdb、hdb为主同时包含Verilog与VHDL硬件描述源码v、vhd、tdf以及sof位流、引脚分配bsf、时序约束sdc和仿真脚本等类型划分清晰目录结构完整可直接在Quartus II中打开工程进行综合与仿真。此外还附带了项目说明文档有助于理解各文件用途和整体设计。目前已有1894人学习下载适合有一定FPGA基础、正在调试以太网接口的工程师参考也可作为学习PHY芯片驱动开发的入门样例。1. FPGA直接驱动以太网PHY从MDIO寄存器到RGMII收发链路一块FPGA板子上接了千兆PHY芯片第一反应往往是去找现成的MAC IP核。但IP核没授权或者工具版本对不上项目就直接卡在起跑线。这份参考代码的价值在于它不依赖付费MAC IP直接在FPGA里用RGMII和MDIO两条链路把AR8031这颗PHY芯片接管起来完成初始化、自协商、数据收发全流程。反直觉的是整份代码里最核心、最不容易一把过的模块并不是数据收发而是初始化时的MDIO寄存器读写——寄存器通路一通百通不通后面全是空中楼阁。它适合刚接手FPGA以太网开发的工程师也适合想把PHY从黑匣子变成可控外设的开发者。代码做到链路层MAC层以上留给你自己扩展。它能让你在一天之内看到以下结果PHY被成功配置、link状态拉高、数据在IO上进出而不是卡在数据手册里出不来。后面几章我会按“接口识别→初始化→数据收发→排查→改造”的顺序把这套参考代码拆开过一遍。2. 硬件接口与顶层架构RGMII和MDIO两路分工2.1 先认三组信号数据、管理、配置FPGA和PHY之间的连接并没有想象中那么多。以AR8031为例按功能分三组。数据信号组是RGMII接口的引脚TXD[3:0]、TX_CTL、TXC以及RXD[3:0]、RX_CTL、RXC。TXC和RXC在千兆模式下是125MHz时钟TXD和RXD都以DDR方式在时钟的上下沿各传半个字节合起来每个时钟周期传一个完整字节。把收发两个字面拆开看发送方向的TXD是FPGA输出接收方向的RXD是PHY输出。管理接口就是MDIO只有MDC和MDIO两根线。MDC是FPGA输出的时钟MDIO是双向数据线。PHY这边有配套的寄存器地址空间FPGA通过MDIO对寄存器进行读写从而控制PHY的工作模式、查看链路状态。MDIO的基本读写帧是32位前导码加14位命令加上16位数据实际速度很慢MDC通常工作在2.5MHz左右。参考代码里没有调用任何IP核直接用状态机逐位操作这两根线这就是后面第三章的主要内容。第三组是配置引脚。这类引脚不在MAC接口列表里而是PHY上电时的strap引脚。板子上的上下拉电阻决定PHY地址、RGMII还是SGMII模式、主从时钟来源等。参考代码的XDC约束文件里一般只约束RGMII和MDIO但硬件原理图上的几个电阻是决定初始化能否成功的前提。常见问题是板子上把PHY地址拉成了001代码里却还按000去访问就会出现MDIO读写无响应。RGMII接口信号的含义可以用一个表格来表示这块在调试时几乎每个引脚都要核对一遍信号方向说明TXD[3:0]FPGA - PHYDDR模式与TXC上下沿对齐TX_CTLFPGA - PHYTX_EN信号高表示数据有效TXCFPGA - PHY千兆125MHz百兆25MHzRXD[3:0]PHY - FPGA接收数据同样DDR对齐RX_CTLPHY - FPGARX_DV与错误标志混合编码RXCPHY - FPGA接收时钟由PHY恢复输出MDCFPGA - PHY管理时钟建议低于2.5MHzMDIO双向管理数据线读时需要释放总线表里RX_CTL那行的“混合编码”值得多说一句。RGMII规定RX_CTL在RXC的上升沿表示RX_DV在下降沿表示RX_DV异或RX_ERR。也就是说不能把它当一个普通信号来接得在FPGA内部用DDR方式采样然后组合还原出真实的数据有效信号。TX_CTL逻辑上相对简单就是TX_EN信号但发送时同样要做DDR输出因为它也要在TXC的上下沿同时传信息。2.2 参考代码顶层怎么拆看清模块骨架拿到FPGA工程先别急着跑仿真我习惯先打开目录结构把每个文件对应到硬件链路上。这份参考代码的典型目录长这样eth_phy_top/ rtl/ eth_phy_top.v // 顶层例化收发与MDIO模块 mdio_phy_ctrl.v // MDIO读写状态机负责寄存器配置 rgmii_rx.v // 接收侧DDR采样与对齐 rgmii_tx.v // 发送侧数据锁存与DDR输出 async_fifo.v // 跨时钟域FIFO包装 sim/ phy_model.v // PHY行为模型模拟MDIO寄存器 tb_top.sv // 顶层测试用例 syn/ eth_phy_top.xdc // 引脚与时序约束eth_phy_top.v是唯一对外可见的模块它把RGMII引脚、MDIO引脚、复位和时钟引出来内部例化三个子模块mdio_phy_ctrl负责启动初始化时序rgmii_rx处理接收字节流rgmii_tx处理发送字节流。初始化完成后mdio_phy_ctrl会拉高一个phy_ready信号rgmii_rx和rgmii_tx才开始工作。数据流走向是内部用户逻辑把待发送的字节写到发送FIFOrgmii_tx从FIFO读数据并拼成DDR时序输出到TXD另一侧rgmii_rx把RXC上的DDR数据拆成完整的8bit写入异步FIFO再由用户时钟域的读侧取走。PHY侧有一个较小的监控模块循环读取状态寄存器将link up、协商速度等信息输出成几个标志位。这块在仿真时很有用因为在板子上看链路状态还要动用示波器仿真里直接看寄存器值就能判断PHY是否正常。这种“初始化通道和数据通道分开”的结构是值得保留的好设计。MDIO状态机工作在慢速时钟下数据链路工作在125MHz下两者在顶层通过phy_ready握手互不干扰。后面改代码时如果想把MDIO部分换成串行管理接口或者改成某种自检模式也不会波及数据通路。3. MDIO状态机与PHY启动序列读不回ID后面全白搭3.1 MDIO读写状态机逐位操作也能稳定读写MDIO协议是典型的串行管理接口。每帧结构为32位前导码全1接着2位ST码2位操作码OP读为10写为015位PHY地址5位寄存器地址2位转向时间16位数据。这里没有神秘之处但写代码时很考验对时机条件的理解MDIO的数据必须在MDC上升沿之前的低电平阶段稳定下来。参考代码的写法是把mdc和mdio分开控制mdc上升沿来之前先给mdio赋值。// mdio_phy_ctrl.v 中按位发送的核心片段 task send_bit(input bit_val); begin MDIO_O bit_val; // 数据先就绪 MDC_O 1b0; // 保持低电平 #MDC_HALF_PERIOD; MDC_O 1b1; // 上升沿PHY采样 #MDC_HALF_PERIOD; MDC_O 1b0; // 回到低电平 end endtask这段代码的问题是仿真里面能用#延时综合之后没有意义。参考代码如果直接上板就会把send_bit改写成可综合状态机。状态机每次在MDC上升沿前的状态变化中把数据放到MDIO_O再拉高MDC再拉低MDC如此循环。所以看仿真代码时一种是任务加延时看综合代码时一定用的是计数器。另一种常见的实现是按位循环移位。先把要发送的内容拼成一个32位的移位寄存器然后用一个计数器逐个送出reg [31:0] tx_buf; // 装填好的发送帧 reg [ 5:0] bit_cnt; always (posedge clk_mdc) begin if (start) begin tx_buf {1b0, 1b1, 1b0, 1b1, 5h00, 5h01, 2b10, 16h0000}; bit_cnt 6d0; end else if (bit_cnt 32) begin MDIO_O tx_buf[31]; tx_buf {tx_buf[30:0], 1b0}; bit_cnt bit_cnt 1; end end这个状态机的逻辑说明tx_buf按帧格式拼好bit_cnt从0数到31每个MDC上升沿输出tx_buf[31]再左移一位。读操作要麻烦一些在第32位之后插入两个转向时钟周期第一个周期继续输出高阻第二个周期释放总线然后从MDIO_I采回16位。采样时刻要避开MDC边沿附近常见做法是在MDC下降沿后半个周期采样避免受到上升沿数据变化的干扰。3.2 PHY复位与自协商配置寄存器0、寄存器9和0x04怎么配MDIO通路通了下一步是给PHY写寄存器。AR8031遵循IEEE 802.3的寄存器布局0x00是控制寄存器0x01是状态寄存器0x02和0x03是PHY ID寄存器0x04是自协商广播寄存器0x09和0x0A是1000BASE-T控制与状态寄存器。参考代码的初始化序列大体是软复位、等复位完成、写自协商广播、检查link状态。常见初始化序列如下// eth_phy_top.v 中的配置流程step由状态机驱动 case (cfg_step) 0: begin // 读取寄存器0x02判断PHY ID高16位 rd_phy(5h00, 5h02); end 1: begin // 读到的值应该与器件手册相符 if (rd_data[15:0] 16h004D) cfg_step 2; else begin cfg_err 1b1; cfg_step 7b111; end end 2: begin // 软复位写0x00的bit15为1 wr_phy(5h00, 5h00, 16h8000); end // 后续步骤接着写自协商寄存器 endcase寄存器0x00的bit15是软件复位位写入1会让PHY内部复位完成之后此位自动清0。写完复位之后建议至少等待几百微秒再把自协商相关寄存器写好。AR8031的自协商广播建议这样配寄存器0x04写入0x01E1表示广播10BASE-T半双工、10BASE-T全双工、100BASE-TX半双工和100BASE-TX全双工寄存器9写入0x0200表示广播1000BASE-T全双工。这样在千兆交换机下不会出现断流。填值的时候不要忘记bit0到bit4各代表一个能力位少配一个就相当于放弃一种速率。实际调试时会有玄学问题比如写寄存器0x00的值看数据手册怎么都对但PHY就是协商不到千兆。后来发现是漏写了寄存器9。这是因为百兆自协商通告写在0x04里而千兆通告是另一套寄存器两个都要写。参考代码把这两个地址拆成单独步骤注释写得很清楚抄代码的时候不要自作主张合并。3.3 回读PHY ID确认初始化第一次成功对PHY芯片做初始化第一步永远是读ID。AR8031的PHY ID分布在寄存器0x02和0x03各16位。参考代码在初始化状态机里单独留了读ID逻辑读出来的值会锁在phy_id_reg中reg [31:0] phy_id_reg; wire phy_id_ok (phy_id_reg[31:16] 16h004D);这段代码把高16位固定值和型号比对低16位在不同版本硅片上有差异所以只比对高16位。如果仿真或上板时phy_id_ok拉不高说明MDIO通路还没通这不是自协商能解决的要回头查时钟、查引脚约束、查MDC频率。从那以后我的习惯是初始化代码里永远保留这个回读断言板子一上电就看这个信号比肉眼盯着示波器省事。先验证再配置后面才谈数据收发。4. RGMII收发通路实现DDR采样与跨时钟域隔离4.1 接收侧DDR采样把RGMII还原成字节流第2章提到RX_CTL是混合编码RXD本身也是DDR。接收侧要做的第一件事是还原数据和有效信号。代码常例化IDDR原语// rgmii_rx.v 中的核心例化对4bit数据各自做DDR采样 genvar i; generate for (i 0; i 4; i i 1) begin: rxd_ddr IDDR #(.DDR_CLK_EDGE(SAME_EDGE)) u_iddr_rxd ( .Q0(rxd_low[i]), // 上升沿采到的低4bit .Q1(rxd_high[i]), // 下降沿采到的高4bit .C(rxc_int), // PHY恢复的RXC .D(rxd[i]) ); end endgenerateIDDR原语把DDR输入拆成两路单沿数据。SAME_EDGE模式表示Q0和Q1在同一个时钟沿更新下游逻辑不用处理半个时钟周期的错位直接拼接即可。拼接的方法是rx_byte {rxd_high, rxd_low}注意高位在前拼好的字节再送进后续模块。RX_CTL也是同样的DDR方式采样上升沿出来的rx_ctl_r就是RX_DV下降沿出来的rx_ctl_f要与rx_ctl_r异或后作为错误标志。参考代码里一般不会真的去组合出error信号而是直接把rx_ctl_r作为写使能rx_ctl_f挂在调试总线上。RGMII的DDR采集还有一个容易忽略的问题PHY输出的RXC与RXD存在相对偏斜。规范允许RXD相对RXC有最多约2ns的偏斜在125MHz边缘附近稍有不慎就会采到中间态。参考代码在XDC里针对输入延迟做了约束create_clock -name rgmii_rxc -period 8.000 [get_ports {RXC}] set_input_delay -clock rgmii_rxc -max 2.000 [get_ports {RXD[*]}] set_input_delay -clock rgmii_rxc -min 0.500 [get_ports {RXD[*]}]如果板子上PHY和FPGA距离较长还需要把RX_CTL的delay也加进去。这类约束不是摆设它影响的是综合在RXC到IDDR数据端这条路径上的时序分析结果。不加约束工具默认RXC和RXD同源同时到达结果布线可能把数据路径拉得很长上板后偶尔丢字节。4.2 发送侧TX_CTL与TXD的产生发送侧的DDR输出同样绕不开原语。FPGA内部要发出的信号实际上是8位并行数据和1位使能但RGMII引脚上需要DDR输出。以Xilinx的OSERDESE2为例它可以完成这种并转串OSERDESE2 #( .DATA_RATE_OQ(DDR), .DATA_WIDTH(4) ) u_oserdes_txctl ( .D1(tx_en_clk), // 上升沿要发出去的使能 .D2(tx_err_clk), // 下降沿发出去的错误标志位 .OQ(tx_ctl), .CLK(txc_int), .CLKDIV(tx_clk_div), .RST(!rst_n) );发送方向TXD[3:0]四个引脚分别例化一个OSERDESE2D1/D2分别是内部8位数据的高4bit和低4bit。关键是TX_CTL也要走DDR。如果图省事把它直接接到内部tx_en而内部tx_en是按单沿时机输出的TXC下降沿处就会因为错位出现毛刺。很多深夜里查CRC error查到最后根因都是TX_CTL的DDR路径没走。另外要做的工作是发送字节缓冲。参考代码通常在rgmii_tx里放一个同步FIFO用户逻辑把待发数据写入发送逻辑在TXC域里读出再拆成DDR输出。这样用户逻辑不需要在125MHz下严格对齐节拍只要FIFO不满就可以继续写。FIFO深度不必太大做以太网一包最多1518字节深度1024足够缓存一整包。4.3 时钟域与异步FIFO别让125MHz把你锁死整个链路涉及多个时钟域MDIO对应的慢速配置时钟、RXC对应的PHY接收时钟、以及用户逻辑自己的时钟。参考代码里数据通路没有简单地把所有逻辑都放在RXC域里而是在接收侧插入异步FIFO// 用xpm_fifo_async把RXC域和用户时钟域隔离 xpm_fifo_async #( .READ_DATA_WIDTH(8), .WRITE_DATA_WIDTH(8), .WRITE_DEPTH(1024), .FIFO_MEMORY_TYPE(auto) ) u_rx_fifo ( .wr_clk(rxc_int), // 写侧时钟 .wr_en(rx_wr_en), .din(rx_byte), .rd_clk(user_clk), // 用户逻辑时钟 .rd_en(user_rd_en), .dout(user_rx_byte), .full(wr_full), .empty(rd_empty) );读侧把异步FIFO的读时钟接到用户逻辑的工作时钟用户逻辑不必非得跑在125MHz也不怕PHY的时钟和本地时钟存在小的频差。实际项目里如果直接把RXC当全局时钟用用户逻辑的时序收敛会很痛苦尤其当PHY的时钟输入到BUFG的路径上又铺了不少组合逻辑时。常见做法是只在RXC域里做DDR采样和字节拼装拼完立刻进FIFO。发送侧一般不需要异步FIFO因为发送时钟是FPGA自己给的本地时钟和用户时钟是同源的用同步FIFO即可。但如果发送端内部接了某些慢速总线发送路径上的同步FIFO也可以起到平滑作用。所以参考代码给收发两侧各保留一个FIFO是给二次开发留的缓冲余地。5. 避坑排查哪些细节能让参考代码变成废纸5.1 现象PHY ID读回全FMDIO像没接上板后第一步读PHY ID读回来0xFFFFFFFF这是最常见的翻车现场。很多人的第一反应是怀疑芯片坏了其实绝大多数情况下是MDIO链路没有真正建立。原因大致有三种MDC时钟太快PHY来不及响应MDIO引脚方向没切换读的时候还在输出或者PHY地址和代码里的地址不匹配。MDIO的规范速度在2.5MHz左右如果FPGA直接给125MHzPHY的串行状态机根本跟不上。引脚方向问题则出现在读操作状态机没有把MDIO_O置为高阻总线被FPGA自己拉死。解决方法是先分频再调试。把MDC分频到2.5MHz读方向在转向时钟里至少留出两个周期给总线释放然后重新跑仿真确认phy_id_ok能拉高。之后上板用示波器量MDC和MDIO波形先看有没有时钟再看数据线上的前导码。5.2 现象link灯亮但收不到数据包链路协商成功了灯也亮了但FPGA用户侧始终收不到完整包。这类问题最浪费工时因为所有人都以为链路通就是数据通。原因多半是RX_CTL的混合编码还原错了。RGMII规范里RX_CTL在上升沿给RX_DV下降沿给RX_DV和RX_ERR的异或值。如果只采样了上升沿或者把下降沿值当成独立信号就会出现数据写入使能比实际数据早一拍或者晚一拍的情况包里全是错位字节。解决方法是把RX_CTL也走IDDR然后把rx_ctl_r和rx_ctl_f还原成真正的rx_valid。具体要盯的是rx_ctl_r拉高的时刻是否和第一个字节出现在RXD上的时刻对齐。仿真里用phy_model产生一帧典型数据直接对比波形如果valid上升沿比第一个字节早了半个时钟周期在FIFO写使能路径上加一拍延迟或者在拼接逻辑里做字节对齐。5.3 现象CRC校验错偶发丢字节收发都能跑但上板跑一段时间就出现CRC错误或者接收侧字节偶发丢失。这类问题最隐蔽因为不是必现频率又不足以用逻辑分析仪抓。常见原因是跨时钟域处理不彻底。比如发送侧用户时钟和TXC不同源却直接用了同步FIFO或者接收侧把RXC域信号直接拉到用户时钟域做判断。另一个高频原因是外部PHY和FPGA之间的时序约束缺失导致布局布线结果差时序收敛边界状态不稳定。解决方法是把所有跨时钟路径都收进FIFO不要用打拍方式处理连续数据流。然后把XDC里的输入输出延迟约束补全至少对RXC和RXD、TXC和TXD这两组路径做set_input_delay和set_output_delay。补完约束再跑一次时序收敛通常能发现有一两条路径在8ns周期下只剩不到0.1ns的余量这种路径就是定时炸弹。5.4 现象仿真过了上板链路不亮仿真里PHY模型工作正常phy_id_ok也拉高了但一上板link灯就是不亮。这种一般不是逻辑问题而是硬件配置引脚和代码逻辑打架。常见原因是PHY的strap引脚在板子上被电阻拉到了某种固定模式比如强制100M模式代码里却往寄存器写了自协商两边冲突。还有一种是PHY地址引脚和复位时序没对上PHY留在复位状态MDIO永远无响应。解决方法是先查原理图PHY地址若干bit、模式选择引脚、时钟来源引脚分别被拉成什么电平把strap值列成表确认和代码里访问的PHY地址一致。然后检查复位上电时序很多PHY要求参考时钟稳定后至少10ms才能撤复位FPGA逻辑里要做一个延时计数器而不是简单把外部复位反相接过去。6. 二次开发改造把参考代码改成可复用的MAC前端6.1 移植到别的板子引脚、PHY地址和时钟频率把参考代码搬到自己的板子有三处必须改引脚约束、PHY地址、RGMII模式。RGMII在AR8031上可能支持多种模式比如RGMII to MII或者RGMII with clock delay。参考代码默认是不加内部delay、由FPGA端用IDDR和ODDR满足DDR时序。如果你的板子在PHY侧加了延迟电阻那么XDC里的input delay和output delay数值都要按板子走线长度重新评估。PHY地址这一项最容易漏。参考代码里把PHY地址作为参数提出来了:parameter PHY_ADDR 3b000;在AR8031上strap引脚配置PHY地址常见取值000到001。代码里所有rd_phy、wr_phy的PHY地址参数都来自PHY_ADDR移植时只需要改这一处。寄存器地址不推荐参数化因为不同PHY的寄存器0x04和0x09布局差异较大宁可多写两条配置宏不要为了省事把寄存器地址也套成变量。6.2 改造成参数化配置列表后续换PHY芯片不慌参考代码把初始化序列写成case分支每步配置一个寄存器。这样做直观但换PHY芯片时改动量大。更通用的做法是把配置步骤定义成数组每条记录包含PHY地址、寄存器地址、数据值和一个结束标志localparam [31:0] INIT_SEQ [0:5] { {3b000, 5h02, 32h00000000}, // 占位读ID用 {3b000, 5h00, 32h00008000}, // 软复位 {3b000, 5h00, 32h00001000}, // 开自协商 {3b000, 5h04, 32h000001E1}, // 广播百兆能力 {3b000, 5h09, 32h00000200}, // 广播千兆全双工 {3b000, 5h00, 32h00000000} // 结束 };这种改造比硬编码的case多一层地址解码逻辑但换来的是初始化序列和代码主体分离。我一般把INIT_SEQ放在独立.vh头文件里换PHY时只改头文件。需要增加一步寄存器配置时不用重新梳理状态机只需要在数组末尾补一条。上板验证时也方便把某一行的数据值改成错误值看phy_id_ok或者link状态是否按预期变化能快速判断配置流程是否正确执行。6.3 向上扩展收下来的字节流怎么接MAC参考代码做到字节流这一层再往上就要自己实现MAC。一个比较顺手的做法是用AXI-Stream接口包一层把接收侧FIFO的读侧变成AXI-Stream从端发送侧FIFO的写侧变成AXI-Stream主端。这样后续接DMA或者接软核处理报文都很顺手。接口大概长这样// AXI-Stream从端接收方向接口由rx_fifo读侧产生 axis_rx_if.master axis_rx; // tdata[7:0], tvalid, tready, tlast // AXI-Stream主端发送方向接口由tx_fifo写侧产生 axis_tx_if.slave axis_tx; // tdata[7:0], tvalid, tready, tlasttlast在MAC层对应一帧的结束RG MII接收侧怎么知道一帧结束参考代码里只给了rx_ctl信号帧结束可以由外部MAC根据包长计数器识别也可以把rx_ctl_f异或信息利用起来。我的经验是先用接收侧包计数器配合用户逻辑做简单组帧在仿真里对比收发的包长度验证DDR采样不会丢字节再接DMA就省心很多。这套代码本身只解决PHY驱动不解决协议栈。曾有段时间我也想着直接在FPGA里把ARP、UDP全写了后来发现工作量翻倍。正确的做法是把参考代码当成一个可复用的外设前端MAC层和IP层放给软核或者专用IP。从那以后我每次换PHY芯片都先读回ID再配寄存器不再跳过改造代码时也强制把PHY地址和寄存器序列提成参数仿真、上板、查错都照着同一套流程走希望能帮到跟我一样在PHY上摔过跟头的开发者。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑