简介:面向需要在FPGA内直接完成以太网PHY配置与数据收发的开发者,这份参考工程以AR8031为实例,给出从寄存器管理、初始化流程到收发接口的较完整实现思路,适合具备一定FPGA基础的硬件与逻辑工程师对照学习。工程基于Altera EP3C40系列FPGA,核心逻辑采用Verilog编写,并配有VHDL、AHDL及Quartus工程、综合与约束文件,便于在既有开发环境中编译、仿真和移植;PHY寄存器配置部分可按其他型号的定义调整,适配性较强。压缩包约19.88MB,共2000个文件,主要由Quartus数据库文件(cdb、hdb)、315个Verilog源文件、81个AHDL与21个VHDL文件,以及qip、qsf、tcl、sdc等工程与约束脚本构成,另有readme、说明文本和配置记录,覆盖源码、工程设置、编译输出与板级调试等层次。已有1896人学习下载,适合希望理解MDIO管理、PHY初始化、时序约束与联调方法的读者参考,也可作为替换其他PHY芯片时的对照模板,帮助快速定位配置项与接口改动点。
1. 只有 PHY 没有 MAC 的板子,FPGA 怎么把 AR8031 跑起来
板子上只有一颗 AR8031 和一片 FPGA,RGMII 直连,中间没有独立 MAC,也没有跑协议栈的软核——这种配置在自研采集板、工业网关和小型交换机模块里很常见。要做的事其实就三件:用 MDIO 把 PHY 配成自协商千兆全双工,用 RGMII 把 DDR 数据拆成字节流,最后把裸以太网帧发出去、把收到的帧交回上层。这份 FPGA 直连以太网 PHY 的参考代码给的就是这个最小闭环,从 PHY 复位、ID 校验、自协商等待,到 RGMII 收发、CRC 校验、前后导码处理,一条链路打通。它适合手里已经有 FPGA 加千兆 PHY 硬件、需要自己实现 MAC 层、又不想为一个 ETH 核去换器件或加软核的开发者,也适合刚接手别人板子、被 link 起不来折腾过几轮的人。
2. 硬件链路拆解:RGMII 的 12 根线、时钟归属和复位时序
2.1 RGMII 相比 GMII 省掉了什么
GMII 是 8 位数据加 125MHz 单沿采样,收发各要 8 根数据线加 1 根控制线,再加两根时钟,一个千兆口要吃掉二十多根 IO。RGMII 把数据宽度砍到 4 位,用 DDR 双沿传输,一个时钟周期传 8 位,引脚数直接减半。代价是时序窗口也减半,接收侧的有效窗口只有 1ns 量级,这也是后面调延时寄存器时最容易出问题的地方。
具体到信号,收发各 6 根:
| 信号 | 方向 | 说明 |
|---|---|---|
| TXC | FPGA → PHY | 125MHz 发送时钟,由 MAC 侧产生 |
| TXD[3:0] | FPGA → PHY | 双沿数据,上升沿 bit[3:0],下降沿 bit[7:4] |
| TX_CTL | FPGA → PHY | 上升沿 TXEN,下降沿 TXERR |
| RXC | PHY → FPGA | 125MHz 接收恢复时钟 |
| RXD[3:0] | PHY → FPGA | 双沿数据,编码同 TXD |
| RX_CTL | PHY → FPGA | 上升沿 RXDV,下降沿 RXERR |
再加 MDC/MDIO 两根管理线和一根复位,一个千兆口在 FPGA 侧占用不到 20 个引脚。注意 TX_CTL 和 RX_CTL 在下降沿承载的是错误标志而不是有效位,很多人在第一版代码里把这个下降沿当成有效数据一起塞进 FIFO,结果就是收到的包长度莫名其妙多一倍。
2.2 时钟从谁出:RXC 由 PHY 供,TXC 必须自己发
RGMII 规范里 RXC 由 PHY 侧提供,即使没有 link,PHY 在千兆模式下也会输出 125MHz 的参考时钟(部分型号在无 link 时会停振,这一点要查手册确认)。FPGA 拿 RXC 做接收逻辑的时钟源,这是最省事的做法,因为数据和时钟同源,只要在 IO 上加好延时就能满足建立保持时间。
TXC 则相反,必须由 FPGA 产生。常见做法是从系统时钟用 MMCM 分出 125MHz,然后通过 ODDR 原语把时钟前送到引脚,而不是直接用一个普通时钟输出。原因很直接:普通时钟走全局时钟网络到引脚,和数据路径的延迟不可控,skew 可能到 1ns 以上;用 ODDR 从同一个时钟域转发,时钟和数据在 IO 单元里走的是对称路径,skew 能压到几百 ps。我一般会这样写:
// 125MHz 发送时钟通过 ODDR 前送,保证与 TXD 的 skew 可控 ODDR #( .DDR_CLK_EDGE("SAME_EDGE"), .INIT (1'b0), .SRTYPE ("SYNC") ) u_txc_fwd ( .C (tx_clk_125m), // 来自 MMCM 的 125MHz .CE (1'b1), .D (1'b1), // 高电平取反后输出,等效于时钟翻转 .Q (rgmii_txc), .R (1'b0), .S (1'b0) );参数DDR_CLK_EDGE决定 Q 在哪个沿更新,SAME_EDGE表示上升沿同时采两个 D,这里的用法是把 D 固定为 1,C 每翻转一次 Q 就翻转一次,得到占空比接近 50% 的 125MHz。如果综合工具报"ODDR 只能放在 IO 附近"之类的错误,检查一下有没有被约束到普通逻辑区域,位置约束没写对,工具就会把它挪走。
还有一个容易忽略的点:PHY 侧的自协商需要一个 25MHz 参考时钟,通常由板上的无源晶振直接给,跟 FPGA 无关。如果这颗晶振没起振,MDIO 能读能写,但 link 永远起不来,现象和寄存器配错几乎一样,排查时容易走错方向。
2.3 复位时序、PHY 地址和上电顺序
AR8031 的复位引脚低有效,手册要求拉低至少 10ms,释放后再等一段时间才能访问 MDIO,常见做法是等 100ms。这个时间不是随便给的,内部 PLL 锁定和寄存器加载都需要时间,提前访问经常读到全 0 或者全 F。
PHY 地址由 PHYAD[4:0] 几个引脚的上拉下拉决定,AR8031 支持 0x00 到 0x1F。很多参考板默认拉到 0x04 或者 0x00,写代码前先看原理图确认,别硬编码一个地址然后怀疑 MDIO 时序。稳妥的做法是上电后做一次地址扫描,把 0x00 到 0x1F 都读一遍 ID 寄存器,能读到合法 OUI 的就是实际地址。这一步多花几毫秒,能省掉后面几个小时。
// 上电复位与访问使能:复位低有效保持 10ms,之后等待 100ms localparam RESET_HOLD_CNT = 20'd1_000_000; // 100MHz 下 10ms localparam RESET_WAIT_CNT = 24'd10_000_000; // 100MHz 下 100ms always @(posedge clk or negedge sys_rst_n) begin if (!sys_rst_n) begin phy_rst_n <= 1'b0; phy_ready <= 1'b0; hold_cnt <= 20'd0; wait_cnt <= 24'd0; end else if (hold_cnt < RESET_HOLD_CNT) begin hold_cnt <= hold_cnt + 1'b1; phy_rst_n <= 1'b0; end else if (wait_cnt < RESET_WAIT_CNT) begin phy_rst_n <= 1'b1; wait_cnt <= wait_cnt + 1'b1; end else begin phy_ready <= 1'b1; // 此后才允许启动 MDIO 配置 end end这段计数逻辑看着平淡,但把"复位结束"和"可以访问"拆成两个状态非常有必要。见过不止一次把复位释放和 MDIO 启动接在同一个信号上,结果就是第一帧 MDIO 发出去的时候 PHY 还没准备好,读回来的 ID 是 0xFFFF,代码判定为"芯片不存在",然后整个初始化流程被跳过。
2.4 参考工程的目录结构
代码按功能切分,收发和配置互不依赖,方便单独仿真:
eth_phy_ref/ ├── rtl/ │ ├── top_eth_ref.v # 顶层,例化各模块并连 IO │ ├── mdio_master.v # clause 22 读写状态机 │ ├── phy_init_ctrl.v # 上电配置序列、自协商等待 │ ├── rgmii_rx.v # IDDR 采样、字节拼接、CRC 校验 │ ├── rgmii_tx.v # ODDR 发送、前导码与 IFG 生成 │ ├── eth_crc32.v # 以太网 FCS 计算 │ ├── mac_rx_parser.v # 解析目的 MAC / 类型字段 │ └── mac_tx_builder.v # 组帧,供上层写入 payload ├── sim/ │ ├── tb_mdio_master.v # 用行为模型模拟 PHY 应答 │ └── tb_rgmii_loop.v # 收发回环,验证 CRC ├── con/ │ └── eth_ref.xdc # 引脚与 IO 延时约束 └── doc/ └── phy_reg_map.md # 用到的寄存器地址与取值说明顶层只做三件事:把 IO 原语放对位置、把时钟域理清、把收发数据引到用户端口。像mac_rx_parser这种纯组合逻辑的模块,单独拿出来做仿真比整体跑波形快得多。
3. MDIO 配置模块:从读 ID 到自协商完成的完整状态机
3.1 clause 22 帧格式与时序参数
MDIO 走的是 clause 22 帧,一帧 64 个 MDC 周期:32 位前导全 1、2 位起始码01、2 位操作码(读10、写01)、5 位 PHY 地址、5 位寄存器地址、2 位 turnaround、16 位数据。写操作时 turnaround 是10(主机驱动两位),读操作时是Z0,第一位释放总线让 PHY 接管,第二位 PHY 输出 0。
MDC 频率上限一般是 25MHz,但没必要跑那么快。我用 2.5MHz 左右,一帧下来 25us,几十个寄存器配置完也就几毫秒,对启动时间毫无影响,而低速对走线和上拉电阻更宽容。
module mdio_master #( parameter CLK_DIV = 6'd40 // 100MHz / 40 = 2.5MHz MDC )( input wire clk, input wire rst_n, input wire start, // 单周期脉冲启动一帧 input wire op, // 1=读, 0=写 input wire [4:0] phy_addr, input wire [4:0] reg_addr, input wire [15:0] wr_data, output reg [15:0] rd_data, output reg busy, output reg done, output reg mdc, output reg mdo, output reg mdo_oe // 1=主机驱动, 0=释放给 PHY ); localparam S_IDLE = 3'd0, S_PRE = 3'd1, S_HDR = 3'd2, S_TA = 3'd3, S_DATA= 3'd4, S_END = 3'd5; reg [2:0] state; reg [5:0] div_cnt; reg [5:0] bit_cnt; reg [31:0] shift_reg; // 前导 + 头 + 写数据统一移位 reg rd_phase; wire mdc_tick = (div_cnt == CLK_DIV - 1'b1); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_IDLE; mdc <= 1'b0; mdo_oe <= 1'b0; busy <= 1'b0; done <= 1'b0; div_cnt <= 6'd0; end else begin div_cnt <= mdc_tick ? 6'd0 : div_cnt + 1'b1; done <= 1'b0; case (state) S_IDLE: begin mdc <= 1'b0; mdo_oe <= 1'b0; if (start) begin // {32'hFFFF_FFFF, 2'b01, op, phy_addr, reg_addr, wr_data} shift_reg <= {32'hFFFF_FFFF, 2'b01, op ? 2'b10 : 2'b01, phy_addr, reg_addr, op ? 16'h0000 : wr_data}; bit_cnt <= 6'd0; busy <= 1'b1; state <= S_PRE; end end S_PRE, S_HDR: begin if (mdc_tick) begin mdc <= 1'b0; // 下降沿换数据 mdo <= shift_reg[31]; mdo_oe <= 1'b1; shift_reg <= {shift_reg[30:0], 1'b0}; bit_cnt <= bit_cnt + 1'b1; if (bit_cnt == 6'd45) state <= S_TA; // 32+2+2+5+5=46 位 end else begin mdc <= 1'b1; // 上升沿 PHY 采样 end end S_TA: begin if (mdc_tick) begin if (op) begin mdo_oe <= 1'b0; // 读: 释放总线 end else begin mdo <= (bit_cnt == 6'd46) ? 1'b1 : 1'b0; mdo_oe <= 1'b1; end bit_cnt <= bit_cnt + 1'b1; if (bit_cnt == 6'd47) begin state <= S_DATA; rd_phase <= op; end end else mdc <= 1'b1; end S_DATA: begin if (mdc_tick) begin if (rd_phase) begin rd_data <= {rd_data[14:0], mdio_in}; // 上升沿后采样 end else begin mdo <= shift_reg[31]; shift_reg <= {shift_reg[30:0], 1'b0}; end bit_cnt <= bit_cnt + 1'b1; if (bit_cnt == 6'd63) state <= S_END; end else mdc <= 1'b1; end S_END: begin mdc <= 1'b0; mdo_oe <= 1'b0; busy <= 1'b0; done <= 1'b1; state <= S_IDLE; end endcase end end endmodule状态机把前导码和头部打包进一个 32 位移位寄存器,S_PRE和S_HDR共用一套移位逻辑,省掉一个状态。关键参数是CLK_DIV,按系统时钟除以它得到 MDC;mdc_tick产生的那个周期里拉低 MDC 并更新 MDO,其余时间 MDC 保持高,这样 PHY 在 MDC 上升沿采到的就是已经建立好的数据。
mdo_oe这个信号必须要有,读操作时如果一直驱动总线,PHY 输出的 0 会和主机的 1 打架,读回来的数据位全错。在顶层把它接到 IO 的 T 端:
// 顶层三态:mdo_oe=1 时输出,=0 时高阻,交给 PHY 驱动 IOBUF u_mdio_buf ( .IO (mdio_pad), .O (mdio_in), .I (mdo), .T (~mdo_oe) );mdio_in是经过输入缓冲后的总线值,直接进状态机采样。上拉电阻不能省,一般 1.5k 到 10k 都有人用,我倾向 4.7k,太快容易和 PHY 内部驱动冲突,太慢上升沿爬不上去。
3.2 上电初始化序列:复位、ID 校验、自协商、等 link
配置序列不是随便写几个寄存器就行,顺序有讲究。我一般按这个流程走:
| 步骤 | 寄存器 | 取值 | 目的 |
|---|---|---|---|
| 1 | 0x00 BMCR | 0x8000 | 软复位,等自清 |
| 2 | 0x02/0x03 | 只读 | 读 PHY ID,确认通信正常 |
| 3 | 0x04 ANAR | 0x01E1 | 通告 10/100 全双工与半双工 |
| 4 | 0x09 1000BASE-T | 0x0300 | 通告千兆全双工与半双工 |
| 5 | 0x00 BMCR | 0x1140 | 使能自协商并重启 |
| 6 | 0x01 BMSR | 轮询 | 等 bit5 自协商完成、bit2 link up |
第 2 步的 ID 校验是整个流程的分水岭。AR8031 的 ID 寄存器读出来通常是0x004D和0xD074这一组,跟手册对不上就说明 MDIO 通路有问题,后面所有配置都没有意义。这一步一定要卡死,读到0x0000或0xFFFF直接报错退出,不要继续往下配。
// 初始化主状态机(节选):按序发写命令,读回值做比对 localparam I_RST = 4'd0, I_RST_W = 4'd1, I_ID1 = 4'd2, I_ID1_W = 4'd3, I_ID2 = 4'd4, I_ID2_W = 4'd5, I_ANAR= 4'd6, I_ANAR_W= 4'd7, I_GBCR = 4'd8, I_GBCR_W= 4'd9, I_AN = 4'd10,I_AN_W = 4'd11, I_WAIT = 4'd12,I_DONE = 4'd13; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin init_state <= I_RST; mdio_start <= 1'b0; end else if (!phy_ready) begin init_state <= I_RST; // PHY 复位期间不动作 end else begin mdio_start <= 1'b0; case (init_state) I_RST: if (!mdio_busy) begin mdio_op <= 1'b0; mdio_reg <= 5'h00; mdio_wdata <= 16'h8000; mdio_start <= 1'b1; init_state <= I_RST_W; end I_RST_W: if (mdio_done) init_state <= I_ID1; I_ID1: if (!mdio_busy) begin mdio_op <= 1'b1; mdio_reg <= 5'h02; // PHYID1 mdio_start <= 1'b1; init_state <= I_ID1_W; end I_ID1_W: if (mdio_done) begin if (mdio_rdata != 16'h004D) init_state <= I_FAIL; else init_state <= I_ID2; end // ... 中间各步结构相同,省略 I_AN: if (!mdio_busy) begin mdio_op <= 1'b0; mdio_reg <= 5'h00; mdio_wdata <= 16'h1140; // AN enable + restart mdio_start <= 1'b1; init_state <= I_AN_W; end I_AN_W: if (mdio_done) init_state <= I_WAIT; I_WAIT: if (bmsr_link_up && bmsr_an_done) init_state <= I_DONE; // 轮询 BMSR,超时 3s 判失败 default: ; endcase end end0x1140这个值拆开看:bit12 置 1 使能自协商,bit9 置 1 重启协商,bit8 和 bit6 分别是全双工和速度的默认位,实际速度由协商结果决定,这两位在使能自协商时保留默认即可。写入后自协商过程由 PHY 自己完成,FPGA 只需要轮询 0x01 的 bit5(协商完成)和 bit2(link 状态)。
BMSR 的 link 位是 latch-low 的,读一次会清零,所以要连续读两次取第二次的值才准。这个坑我在第一版里踩过:单次读到 0 就判定 link down,实际上 link 是好的,只是上次读把锁存位清掉了。
3.3 AR8031 扩展寄存器与 RGMII 延时调参
标准 16 个寄存器之外,AR8031 有一组调试寄存器,通过 0x1D 写地址、0x1E 读写数据来访问。RGMII 的收发延时就在这组里,是调时序的关键。
// 访问 AR8031 扩展寄存器:先写地址到 0x1D,再读写 0x1E task ar8031_ext_write; input [15:0] ext_addr; input [15:0] ext_data; begin mdio_write(5'h1D, ext_addr); // 选中扩展寄存器 mdio_write(5'h1E, ext_data); // 写入数据 end endtask // 例:打开 RGMII 内部延时,RX 延时约 2.0ns,TX 延时约 0.9ns // 具体档位以手册为准,这里按常见的 0.1ns 一档估算 ar8031_ext_write(16'h0005, 16'h0029);那个0x0005是 RGMII 控制寄存器在扩展空间里的编号,0x0029高 4 位是 RX 延时档位、低 4 位是 TX 延时档位,具体每一档对应多少 ps 要看手册的表格,不同批次和不同版手册可能有出入,我一般调完之后用眼图或者误码统计反推。
这里有个判断依据值得记一下:如果 PHY 侧打开延时不够,FPGA 收到的数据就会在 RXC 边沿附近跳变,表现为大包 CRC 全错、小包偶尔正确;如果延时过大,同样错,但错法不一样,CRC 错的包比例更高且和线缆长度相关。方向反了就先关掉 PHY 的 TX 延时,改在 FPGA 侧用 IDELAY 调接收,两边同时加延时是最容易翻车的组合。
4. RGMII 收发链路:从双沿 DDR 数据到完整以太网帧
4.1 接收方向:IDDR 采样与字节重组
接收侧时钟是 PHY 给的 RXC,数据和时钟同源,但到了 FPGA 引脚上,走线和输入缓冲会引入几百 ps 的 skew,必须用原语在 IO 单元里做双沿采样,不能指望用普通 always 块跑 125MHz 双沿逻辑去采,工具综合出来的结果完全不可控。
各家 FPGA 的原语名字不一样,有的叫 IDDR,有的叫 DDIO_IN,功能一样:在时钟的两个沿各锁存一次数据,输出 Q1、Q2 两路单沿信号。
// 4 位数据 + 1 位控制,各用一个 IDDR,低沿进 Q1,高沿进 Q2 genvar i; generate for (i = 0; i < 4; i = i + 1) begin : g_rxd IDDR #( .DDR_CLK_EDGE ("SAME_EDGE_PIPELINED"), .INIT_Q1 (1'b0), .INIT_Q2 (1'b0), .SRTYPE ("SYNC") ) u_iddr ( .C (rgmii_rxc), .CE (1'b1), .D (rgmii_rxd[i]), .Q1 (rx_d_low[i]), // 上升沿采到的 bit[3:0] .Q2 (rx_d_high[i]), // 下降沿采到的 bit[7:4] .R (1'b0), .S (1'b0) ); end endgenerate // 控制信号同理,Q1 是 RXDV,Q2 是 RXERR IDDR #(.DDR_CLK_EDGE("SAME_EDGE_PIPELINED"), .INIT_Q1(1'b0), .INIT_Q2(1'b0), .SRTYPE("SYNC")) u_iddr_ctl ( .C (rgmii_rxc), .CE(1'b1), .D(rgmii_rx_ctl), .Q1(rx_dv), .Q2(rx_er), .R(1'b0), .S(1'b0) );SAME_EDGE_PIPELINED这个模式让 Q1 和 Q2 在同一个时钟沿更新,后面拼字节的时候不用再做相位对齐。如果用OPPOSITE_EDGE,Q2 会比 Q1 晚半个周期,拼出来的字节顺序是错的,现象是 MAC 地址完全对不上,但长度字段看着还行,很迷惑。
拼接的时候把两路合成一个 8 位:
// 每两个 RXC 周期产生一个有效字节,用 valid 做背压 always @(posedge rgmii_rxc) begin rx_byte_valid <= rx_dv; rx_byte_data <= {rx_d_high, rx_d_low}; // 高沿是高位 rx_byte_err <= rx_er; end顺序不能反,{rx_d_high, rx_d_low}对应 bit[7:4] 在前。写反了会得到一个字节内高低半字节互换的结果,现象是目的 MAC 读出来是88:77:66...这种规律性错误,一眼能看出来。
4.2 发送方向:ODDR 与 TX_CTL 的双沿编码
发送侧反过来,要把 8 位单沿数据拆成两次 4 位输出,用 ODDR 在时钟两个沿驱动:
// 8 位数据拆成低半字节和高半字节,分别送 ODDR 的 D1/D2 ODDR #( .DDR_CLK_EDGE ("SAME_EDGE"), .INIT (1'b0), .SRTYPE ("SYNC") ) u_oddr_d0 ( .C (tx_clk_125m), .CE (tx_en), .D1 (tx_byte[0]), // 上升沿输出 bit[0] .D2 (tx_byte[4]), // 下降沿输出 bit[4] .Q (rgmii_txd[0]), .R (1'b0), .S (1'b0) ); // 控制位:上升沿 TXEN,下降沿 TXERR ODDR #(.DDR_CLK_EDGE("SAME_EDGE"), .INIT(1'b0), .SRTYPE("SYNC")) u_oddr_ctl ( .C (tx_clk_125m), .CE(1'b1), .D1(tx_en), .D2(tx_err), .Q(rgmii_tx_ctl), .R(1'b0), .S(1'b0) );DDR_CLK_EDGE这里选SAME_EDGE,D1 和 D2 在时钟上升沿同时被采,输出分别落在上升沿和下降沿。注意CE接tx_en,空闲时 ODDR 停止更新,输出保持最后一个值,此时 TX_CTL 应该是低电平表示无数据,所以状态机在帧间隙要把tx_en拉低、tx_err清零。
帧间隙(IFG)至少 12 个字节时间,也就是 96ns。这个时间不够,对端 PHY 或者交换芯片会判定为异常帧直接丢弃,现象就是发包计数正常但对面收不到。我一般做成固定 16 字节,留点余量。
发送状态机的骨架:
localparam T_IFG = 3'd0, T_PRE = 3'd1, T_SFD = 3'd2, T_DAT = 3'd3, T_FCS = 3'd4; // 前导码 7 个 0x55,SFD 一个 0xD5,然后才是有效数据 always @(posedge tx_clk_125m or negedge rst_n) begin if (!rst_n) begin tx_state <= T_IFG; tx_en <= 1'b0; ifg_cnt <= 5'd0; end else case (tx_state) T_IFG: begin tx_en <= 1'b0; if (ifg_cnt == 5'd15) begin ifg_cnt <= 5'd0; tx_state <= T_PRE; end else ifg_cnt <= ifg_cnt + 1'b1; end T_PRE: begin // 发 7 个 0x55 tx_en <= 1'b1; tx_byte <= 8'h55; if (pre_cnt == 3'd6) tx_state <= T_SFD; end T_SFD: begin tx_byte <= 8'hD5; tx_state <= T_DAT; end T_DAT: begin tx_byte <= fifo_dout; if (fifo_empty) tx_state <= T_FCS; end T_FCS: begin tx_byte <= fcs_byte; tx_state <= T_IFG; end endcase endtx_en在T_PRE才拉高,前导码是要发的,但T_IFG期间不能有tx_en,否则对端会认为你在发一个超长帧。
4.3 CRC32 计算与前后导码处理
以太网 FCS 用的是 CRC-32,多项式0x04C11DB7,初值全 1,数据从目的 MAC 第一位开始逐位算,结果取反后按小端字节序附在帧尾。前导码和 SFD 不参与计算,这一点是新手最容易搞错的地方,把 0x55 也算进去,生成的多项式结果永远对不上。
// 以太网 CRC32:多项式 0x04C11DB7,低位先入,结果取反 function [31:0] eth_crc32_byte; input [31:0] crc_in; input [7:0] data; integer k; reg [31:0] c; begin c = crc_in; for (k = 0; k < 8; k = k + 1) begin if (c[0] ^ data[k]) c = (c >> 1) ^ 32'hEDB8_8320; // 0x04C11DB7 位反转 else c = c >> 1; end eth_crc32_byte = c; end endfunction // 使用:帧开始时清零为 0xFFFFFFFF,逐字节算,结束后取反 always @(posedge clk) begin if (frame_start) crc_reg <= 32'hFFFF_FFFF; else if (byte_valid) crc_reg <= eth_crc32_byte(crc_reg, byte_data); else if (frame_end) fcs_out <= ~crc_reg; end0xEDB88320是0x04C11DB7的位反转形式,因为这里用的是右移加低位先出的写法,两种写法结果一致但用反转多项式更顺手。如果综合工具报函数内部有变量声明问题,把integer k换成reg [3:0] k再试。
接收侧校验时,把整个帧(含 FCS 四个字节)一起送进 CRC 计算,正确的情况下最后的余数是一个固定值,通常是0x2144DF1C。用这个方式比"自己算一遍再比对"省一个比较器,而且能发现 FCS 字段本身被篡改的情况。
4.4 跨时钟域与 FIFO 缓冲
RXC 是 PHY 恢复的时钟,和系统时钟不同源,差个几十 ppm 很正常,不能直接拿 RXC 去写一个用系统时钟读的 FIFO 而不做任何处理。常见做法是用异步 FIFO,写侧时钟用 RXC,读侧用系统时钟,深度给到 2K 字节以上,能吸收帧间隙带来的突发。
单帧最大 1518 字节,FIFO 深度小于这个值时,一个满帧会在中途溢出,表现为长帧丢、短帧正常。我见过一个工程 FIFO 只配了 512 字节,ARP 请求(42 字节)能通,ping 大包(1500 字节)必丢,排查了半天以为 MTU 配错了。
写侧还要做个帧长统计,超过 1518(或者带 VLAN 的 1522)就标记为超长帧丢弃,不要往 FIFO 里写。这个保护能防止对端发来畸形帧把 FIFO 写满锁死,属于黑匣子级别的细节,加上之后线上跑几个月都没再出过整机卡死的问题。
5. 上板避坑:link 起不来、CRC 全错、大包丢失的五条排查记录
5.1 现象:MDIO 读回全是 0xFFFF
原因通常有三个:PHY 复位还没释放就去访问、MDIO 上拉电阻缺失导致总线浮空读到全 1、PHY 地址配错。
解决顺序是先量一下复位引脚电平,确认phy_ready之后才开始 MDIO;再量 MDIO 空闲电平,正常应该被上拉到高,如果量到的是中间电平说明上拉太小或者有多个器件在驱动;最后做地址扫描,0x00 到 0x1F 全读一遍 ID,找不到就说明硬件通路有问题,别在代码里继续猜。
5.2 现象:link 显示 up,但收到的帧 CRC 全错
大概率是 RGMII 延时没调对。先确认 PHY 侧扩展寄存器的 RX 延时配置生效,再确认 FPGA 侧有没有重复加 IDELAY。两边同时加延时是最常见的翻车方式,加起来超过半个时钟周期,数据眼直接被切掉。
判断方法很直接:用 ILA 抓 RXC 和 RXD,看数据跳变是不是落在时钟边沿附近。如果跳变紧贴边沿,调延时;如果已经偏离但 CRC 还错,检查字节顺序是不是高低半字节弄反了。
5.3 现象:小包正常,大包必丢
先看接收 FIFO 深度。深度小于 1522 字节时,满帧一定溢出。其次看写侧有没有做背压,如果 RXC 一直写而读侧来不及取,同样溢出。
还有一种情况是发送侧的 IFG 不够,具体表现为对端能收到 ARP 但收不到大包。这个和 FIFO 无关,是对端交换芯片把紧跟着前一帧尾巴的帧判成了错误帧。把 IFG 从 12 字节改成 16 字节基本就解决了。
5.4 现象:断电重启后能通,热复位后不通
热复位时 PHY 没有跟着复位,内部状态还停留在上一次协商的结果里,而 FPGA 侧重新走了一遍完整的初始化序列,往 BMCR 写了软复位,两边状态机打架。
解决办法是把 PHY 的复位引脚接到 FPGA 的复位逻辑上,软复位和硬复位一起走;或者在热复位时跳过 PHY 硬复位,只做自协商重启(写 BMCR 的 bit9)。后者更快,但要确保 MDIO 通路本身没有异常。
5.5 现象:千兆协商不上,只能跑到百兆
先读 0x0A 的千兆状态寄存器,看对端有没有通告千兆能力。如果对端是百兆设备,那本来就该是百兆,不是问题。
如果对端也是千兆,检查 0x09 有没有写入0x0300。这个寄存器默认可能是 0,不自协商千兆能力就永远协商不上。另外线缆也要看,劣质线或者只接了 4 根线的水晶头,物理层就协商不到千兆,这种情况寄存器读出来一切正常,但速率就是上不去。
6. 二次开发:把裸帧收发改成 UDP 回环并做端到端验证
参考代码跑通之后,最典型的二次开发是把裸帧收发封成 UDP 回环:收到目的端口匹配的 UDP 包,把 payload 原样发回去,用 PC 上的网络调试工具就能验证整条链路。改动量不大,但能把前面所有模块串起来测一遍。
第一步在mac_rx_parser.v里加一层解析。以太网类型字段为0x0800时进 IP 解析,IP 头的协议字段为0x11时进 UDP,再比较目的端口。偏移都是固定的:以太网头 14 字节,IP 头 20 字节(无选项),UDP 头 8 字节。抓 payload 的长度直接从 UDP 头里取。
// UDP 回环控制:命中端口后把 payload 推入发送 FIFO localparam ETH_HDR = 6'd14, IP_HDR = 6'd20, UDP_HDR = 6'd8; wire is_udp = (eth_type == 16'h0800) && (ip_proto == 8'h11); wire hit = is_udp && (udp_dport == 16'h1F90); // 8080 always @(posedge clk) begin if (hit && payload_valid) tx_fifo_wr <= 1'b1; // 边收边发,省一块缓存 else tx_fifo_wr <= 1'b0; if (hit && payload_last) tx_trigger <= 1'b1; // 收完最后一个字节启动发送 end边收边发的写法能省掉一整块帧缓存,代价是要求发送侧的响应足够快,否则接收 FIFO 会溢出。稳妥的做法是先收完整帧再发,多一块 2KB 的 RAM,换来逻辑简单很多。我第一次做的时候选了边收边发,结果 PC 端的工具连着发三个包就漏两个,后来改成整帧缓存才通。
第二步是发送侧。回环帧的目的 MAC 要填成请求帧的源 MAC,源 MAC 填本机,IP 头里的源和目的地址互换,UDP 端口互换。校验和这块,UDP 的 checksum 在 IPv4 下可以填 0 表示不校验,省掉一个累加器;IP 头的 checksum 必须算对,否则 PC 的协议栈直接丢包。
改完之后按这个顺序验证:
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | 抓发送帧,肉眼核对字段 | MAC/IP/端口与请求帧对称 |
| 2 | ping 本机 IP | 需要先实现 ARP 应答,否则不通 |
| 3 | 网络工具发 64 字节 UDP | 收到等长回包,内容一致 |
| 4 | 发 1472 字节 UDP | 收到等长回包,不丢 |
| 5 | 连续发 1000 包 | 丢包率 0,计数器无溢出 |
第 2 步的 ARP 应答是很多人漏掉的,PC 要先 ARP 拿到 MAC 才会发 UDP,没有 ARP 应答,后面的测试一步都走不了。ARP 帧处理很简单,抓类型0x0806,把发送方的 IP 和 MAC 抄过来做应答就行。
调延时那几天我改过十几版寄存器配置,每次都觉得是"某个玄学档位刚好对了",后来发现真正起作用的是把 PHY 的 RX 延时和自己加的 IDELAY 分离开,一次只动一个变量。从那以后我每次碰 RGMII 时序,都强制先用 ILA 抓一遍数据和时钟的相对位置再动手改寄存器,不再靠试。这套流程看着慢,实际比来回烧板子快得多。希望帮到你。
本文还有配套的精品资源,点击获取