1. 从一次"网口死活不通"的调试经历说起:为什么要自己写MDIO控制器
第一次和MDIO打交道,完全是被一块板子逼的。板子做完回板,光口和电口都起不来,PHY芯片没有完成初始化,协议栈报了各式各样的链路故障。手上没有预置配置的EEPROM,PHY必须靠CPU通过MDIO总线写寄存器才能把工作模式、速率、自协商开关这些参数设置好。排查到最后,问题根本不在这颗PHY芯片本身,而在MDIO时序上。用逻辑分析仪一抓,MDC波形方方正正,可MDIO数据线上读回来的却是一大串0xFFFF。那一瞬间我意识到,想高效定位这类问题,不能只会点仿真器看一下表面现象,得真正把MDIO控制器的内部逻辑吃透,最好能亲手写一个。
这篇文章要做的就三件事:第一,把MDIO协议从帧格式到时序参数完整拆开讲清楚;第二,带你从零开始手写一个MDIO控制器,Verilog版本逐段精讲,每一段代码为什么这么写都说明白;第三,给一份VHDL对照实现,用同一个状态机设计思路,让你亲眼看到两种硬件描述语言在解决同一问题时的各自写法。适合正在做FPGA以太网接口、用到RTL8211这类MDIO接口PHY的工程师,也适合刚学完Verilog语法、想上手一个真实总线协议的学习者。如果你已经能调MII/RGMII接口,但每次配置PHY寄存器还是靠厂商驱动库和现成脚本,那这篇文章能帮你把总线底层控制权拿回自己手里,以后遇到寄存器操作不响应、读写超时、地址漂移这类疑难杂症,也不至于毫无头绪。
2. 和PHY对话的规矩:MDIO帧格式与寄存器访问规则
2.1 一条MDIO操作到底长什么样
MDIO(Management Data Input/Output)是IEEE 802.3 Clause 22定义的两线管理接口,一线是MDC时钟,一线是MDIO双向数据。它做的事情很简单:让MAC侧的处理器读或写PHY芯片内部的寄存器。但简单归简单,帧格式里的每一个字段都有严格的先后顺序和位宽规定,少一个周期或者多一拍,整条命令就会作废。
一次完整的MDIO操作帧是64位(不含IDLE空闲周期),划分如下:
| 字段 | 位宽 | 内容说明 |
|---|---|---|
| Preamble | 32位 | 连续32个逻辑1,表示一帧开始 |
| ST | 2位 | 固定为01,表示Clause 22帧 |
| OP | 2位 | 10表示读,01表示写 |
| PHYAD | 5位 | PHY芯片地址,范围0x00-0x1F |
| REGAD | 5位 | 寄存器地址,范围0x00-0x1F |
| TA | 2位 | 读操作是Z0,写操作是10 |
| DATA | 16位 | 读回或写入的数据 |
眼尖的朋友可能已经发现,64位刚好比一次协议操作多出1位,因为DATA的16位里有一位是没有用的?不是的,把32 + 2 + 2 + 5 + 5 + 2 + 16加起来是64,刚好是一帧完整长度。这是MDIO帧格式最基本也最关键的记忆点。
在空闲状态下,MDIO线处于高阻态,靠外部上拉电阻保持高电平。所以如果你用逻辑分析仪抓一段空闲波形,看到的是一条被拉高的平线。Preamble从IDLE进入第一个传输周期时,MDIO被主机驱动输出低电平?不对,Preamble是32个1,所以第一个位实际上是高电平,但从高阻到主动驱动高电平,逻辑分析仪上看起来是连续的高,这时就要靠下降沿来区分了。这里其实牵扯一个非常重要的时序细节:MDC上升沿发送数据、下降沿采样数据,主机和PHY都是按这个约定来的。
2.2 寄存器地址空间与MII标准寄存器
MDIO可访问的寄存器地址是5位,也就是0到31。IEEE 802.3在Clause 22里规定了0到15号寄存器的标准含义,16到31留给厂商自定义。这些寄存器里,0号控制寄存器(BMCR)和1号状态寄存器(BMSR)是几乎所有PHY都必须支持的,初始化时最常用的也是它们。
以RTL8211系列为例,0号寄存器里bit 13是速度选择,bit 12是自协商使能,bit 8是全双工模式,bit 9是自协商重启。1号寄存器里bit 5是自协商完成标志,bit 2是链路建立状态。这个链路状态位也是网口调试时最常读的寄存器之一,很多厂商的link led不亮,第一反应就是去读BMSR寄存器确认物理链路到底有没有建立。
实际项目中,厂商还会在16到31号寄存器里放很多控制项,比如RTL8211的绿色以太网节能功能、交叉检测配置、延迟补偿等,这些都要通过MDIO读写。所以MDIO控制器不能只做一个读或一个写,必须双向完整。
2.3 读操作和写操作在TA周期的关键区别
TA(Turnaround)周期是MDIO协议里最需要小心的地方,读和写在这里的处理方式完全不同。
写操作时,主机是总线的唯一驱动者,TA周期的两个位都主动输出10。第一个位是高电平,第二个位是低电平,PHY在这两拍里不做任何动作,纯粹是让总线保持连贯的转换时间。
读操作时,TA周期第一拍主机仍然驱动低电平(也有的控制器写0x0或0x1?严格标准是主机输出高阻或0,但最常见的是输出0),第二拍主机必须释放总线,把MDIO线改为高阻输入,由PHY接管并驱动第一个数据位。如果主机在TA第二拍没有释放总线,和PHY的输出就会发生总线冲突,轻则读到错误数据,重则损坏引脚。这就是为什么很多MDIO控制器设计里,读操作和写操作的状态机分支在TA阶段必须分开处理,不能图省事共用一套输出逻辑。
3. 架构先行:模块划分、状态机设计与时钟分频
3.1 为什么控制器必须用三段式状态机
MDIO控制器本质上是一个串行协议引擎,最自然的实现方式就是状态机。我在设计初期纠结过一个事:状态机的写法到底用一段式、二段式还是三段式?最终选择了三段式,原因是在MDIO这种"一边发控制位、一边采数据"的场景里,三段式的优势太明显了。
一段式把所有逻辑揉在一个always块里,代码短但时序难懂,而且输出容易产生毛刺。二段式把时序和组合分开,已经能解决大部分问题,但输出逻辑仍混在状态判断里。三段式把状态跳转、次态判断、输出逻辑分成三部分,每部分职责单一,尤其在MDIO这种需要在不同状态下驱动不同位宽数据、还要在三态门上做切换的场景,能避免很多隐藏的竞争问题。
举个例子,PRE状态要连续输出32个1,OP状态要输出2位操作码,PHYAD状态要输出5位地址,DATA状态要输出16位数据。如果用一段式,这些输出直接写在状态判断里,状态切换的瞬间输出可能产生毛刺。三段式用mealy型输出逻辑,把输出看作当前状态和输入信号的函数,可以在状态转换的同一拍就更新输出,时序更干净。
3.2 控制器端口规划
在设计端口之前,先想清楚这个控制器要服务谁。最典型的使用场景是:片上处理器(软核或硬核)或外部CPU通过总线接口,向控制器发出读PHY寄存器、写PHY寄存器的请求。因此控制器对外应该提供一组类似寄存器的简单接口,而不是直接暴露时序信号。
我设计的端口如下:
| 信号 | 方向 | 位宽 | 作用 |
|---|---|---|---|
| clk | in | 1 | 系统时钟 |
| rst_n | in | 1 | 异步复位,低有效 |
| mdc | out | 1 | 管理时钟输出 |
| mdio | inout | 1 | 管理数据线 |
| req | in | 1 | 请求发起一次操作 |
| wr_en | in | 1 | 1写0读 |
| phy_addr | in | 5 | PHY地址 |
| reg_addr | in | 5 | 寄存器地址 |
| wr_data | in | 16 | 写数据 |
| busy | out | 1 | 忙标志 |
| rd_data | out | 16 | 读返回数据 |
| rd_data_valid | out | 1 | 读数据有效标志 |
上层模块只要拉高req一个周期,输入好地址和数据(读操作时wr_data不用管),控制器就会自动完成整个时序,完成后拉低busy,读数据的时候rd_data_valid给一个单周期脉冲。这样上层逻辑完全不用关心MDC分频、位计数、三态切换这些底层细节,耦合度很低,拿到任何项目里都能直接复用。
3.3 状态序列设计与转移条件
状态设计的核心原则是:每个MDC周期对应一个状态或者一组状态,状态转移节奏与MDC同步。我定义的状态如下:
- IDLE:空闲,不驱动MDC,MDIO高阻
- PRE:输出32位Preamble,用计数器循环
- OP:输出2位操作码
- PHYAD:输出5位PHY地址
- REGAD:输出5位寄存器地址
- TA:2位turnaround周期,读和写在这里分支
- DATA:16位数据收发
- DONE:操作完成,回到IDLE
在PRE状态下,用一个6位计数器从0数到31,每个MDC周期加1,数满32个就跳到OP。OP状态下数2拍后跳转,PHYAD和REGAD各数5拍,TA数2拍,DATA数16拍。整体节奏是线性的,几乎不存在分支,唯一的分支在TA状态:写操作时两个位都主动输出,读操作时第二拍释放总线并开始采样。
MDC时钟不是每一拍都要输出。在IDLE和DONE状态下,MDC保持低电平,MDIO保持高阻,这样可以省掉一些不必要的翻转功耗,也让总线在空闲时更稳定。PRE状态开始后,MDC才按照分频比例产生方波。
3.4 MDC分频计算与时钟树设计
IEEE 802.3标准规定MDC最高频率是2.5MHz,但现在很多PHY实际能跑到更高,比如RTL8211系列手册里标注最大25MHz?实际上有些PHY能支持更高频率。不过稳妥起见,设计时分频目标还是控制在2.5MHz以内,特别是系统时钟比较高的FPGA里,这个分频系数很容易算。
假设系统时钟是100MHz,目标MDC是2.5MHz,那分频系数就是100 / 2.5 = 40,即每40个系统时钟周期产生一个完整的MDC周期。具体实现可以用一个计数器在0到19之间翻转:前20个周期MDC输出高,后20个周期输出低。
需要注意的是,MDIO数据在MDC上升沿附近变化,在MDC下降沿附近采样。所以控制器的内部逻辑应该以系统时钟为基准,分频产生MDC,同时在MDC下降沿的前一个系统时钟周期采样MDIO输入,这样能保证采样点落在数据稳定区域内。如果直接用always @(negedge mdc)去采样,由于MDC是分频产生的,它和系统时钟之间有相位关系,并不好约束,容易出现亚稳态。稳妥做法是:用计数器判断当前处于MDC周期的什么位置,在下降沿位置附近采样系统时钟同步后的MDIO信号。
4. Verilog实现逐段精讲:从状态跳转到三态门
4.1 模块端口与参数定义
先给出完整模块框架,这部分不复杂,但参数定义里有两个值得注意的坑。
module mdio_controller #( parameter CLK_DIV = 20, // 系统时钟/MDC/2,等比分频系数 parameter PREAMBLE_LEN = 32 // 前导码长度 )( input wire clk, input wire rst_n, output reg mdc, inout wire mdio, input wire req, input wire wr_en, input wire [4:0] phy_addr, input wire [4:0] reg_addr, input wire [15:0] wr_data, output reg busy, output reg [15:0] rd_data, output reg rd_data_valid );CLK_DIV这个参数我用的是"半个MDC周期对应的系统时钟拍数"。系统时钟100MHz,目标MDC 2.5MHz,完整周期是40个系统时钟,半个周期就是20,所以CLK_DIV填20。这样mdc信号的翻转判断就变成counter == CLK_DIV-1时翻转,逻辑上更直接。
PREAMBLE_LEN默认32,但有些PHY可能不要求完整32位前导码也能识别,有些调试模式下甚至不需要前导码。保留这个参数可以让你在需要兼容特殊PHY时灵活调整,不用改状态机逻辑。
4.2 分频计数与MDC生成
MDC生成和状态机的时序配合,是很多初学者最容易写乱的地方。我采用的方式是:用一个计数器div_cnt在0到2CLK_DIV-1之间循环,当div_cnt等于CLK_DIV时MDC置高,等于2CLK_DIV-1时置低并完成一个周期。但这个计数器不是一直跑的,只在状态机离开IDLE后才工作,回到IDLE时清零。
reg [5:0] div_cnt; wire mdc_div_en = (state != IDLE) && (state != DONE); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt <= 0; mdc <= 1'b0; end else if (mdc_div_en) begin if (div_cnt == (CLK_DIV * 2 - 1)) begin div_cnt <= 0; end else begin div_cnt <= div_cnt + 1'b1; end if (div_cnt < CLK_DIV) begin mdc <= 1'b1; end else begin mdc <= 1'b0; end end else begin div_cnt <= 0; mdc <= 1'b0; end end这段代码里,div_cnt小于CLK_DIV时MDC为高,大于等于CLK_DIV时MDC为低,正好形成一个占空比50%的方波。要注意的是,IDLE状态下mdc_div_en为假,所以mdc被强制拉低,这个电平状态对逻辑分析仪判断总线空闲很有帮助。
4.3 三段式状态机的第一段:状态寄存器的次态跳转
状态寄存器用枚举类型定义,清晰直观。
localparam IDLE = 3'd0, PRE = 3'd1, OP = 3'd2, PHYAD= 3'd3, REGAD= 3'd4, TA = 3'd5, DATA = 3'd6, DONE = 3'd7; reg [2:0] state, next_state;第一段是纯粹的状态寄存器更新,时序逻辑:
always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= IDLE; else state <= next_state; end4.4 第二段:组合逻辑判断转移条件
组合逻辑部分需要有明确的跳变条件。我用一个bit_cnt计数器配合每个状态的分支判断:
reg [5:0] bit_cnt; // 当前状态内已经处理的bit数 always @(*) begin next_state = state; case (state) IDLE: begin if (req) next_state = PRE; end PRE: begin if (bit_cnt == PREAMBLE_LEN - 1) next_state = OP; end OP: begin if (bit_cnt == 1) next_state = PHYAD; end PHYAD: begin if (bit_cnt == 4) next_state = REGAD; end REGAD: begin if (bit_cnt == 4) next_state = TA; end TA: begin if (bit_cnt == 1) next_state = DATA; end DATA: begin if (bit_cnt == 15) next_state = DONE; end DONE: begin next_state = IDLE; end default: next_state = IDLE; endcase endbit_cnt的更新逻辑放在状态寄存器更新的时序块里,或者单独一个always块。每个状态内部数完需要的拍数就跳走,这个设计等价于把状态图"展开"成了一维计数跳转,优点是逻辑非常简单,不容易出现在多个状态间来回跳的bug。
4.5 第三段:输出逻辑与三态门处理
输出逻辑是MDIO控制器的灵魂,也是最容易写错的地方。先定义内部输出寄存器和三态使能信号:
reg mdio_out; reg mdio_en; // 1:驱动MDIO,0:释放总线 wire mdio_in; assign mdio = mdio_en ? mdio_out : 1'bz; assign mdio_in = mdio;三态门的关键在于mdio_en。在IDLE、读操作TA第二拍、DONE这几个时刻,mdio_en必须是0,让总线处于高阻态。其他状态下,mdio_en为1,控制器主动驱动数据线。
输出逻辑按照状态机+bit_cnt来确定当前要发送的位。这里我用一个移位寄存器来组织数据流,比在case里逐位判断要优雅得多:
reg [15:0] cmd_shift; reg [15:0] data_shift; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cmd_shift <= 0; data_shift <= 0; end else if (state == IDLE && req) begin // 组装指令序列:Preamble + ST + OP + PHYAD + REGAD + TA + DATA cmd_shift <= {32'hFFFFFFFF, 2'b01, (wr_en ? 2'b01 : 2'b10), phy_addr, reg_addr, (wr_en ? 2'b10 : 2'b00)}; data_shift <= wr_data; end else if (mdc_falling_edge) begin // 每个MDC周期移出一位 cmd_shift <= {cmd_shift[14:0], 1'b0}; data_shift <= {data_shift[14:0], 1'b0}; end end等等,这个移位寄存器方案其实有个问题:Preamble需要32位,而cmd_shift只有16位,塞不下。要处理32位前导码,要么把cmd_shift扩展到64位,要么在PRE状态单独用一个计数器循环发送1。我实际采用的方案是后者:PRE状态下mdio_out固定为1,直到bit_cnt数满PREAMBLE_LEN。这样既不需要大位宽移位寄存器,也方便在调试时通过参数调整前导码长度。
核心输出逻辑如下:
always @(*) begin mdio_out = 1'b1; mdio_en = 1'b0; case (state) IDLE, DONE: begin mdio_en = 1'b0; end PRE: begin mdio_en = 1'b1; mdio_out = 1'b1; end OP: begin mdio_en = 1'b1; mdio_out = (bit_cnt == 0) ? (wr_en ? 1'b0 : 1'b1) : (wr_en ? 1'b1 : 1'b0); end PHYAD: begin mdio_en = 1'b1; mdio_out = phy_addr[4 - bit_cnt]; end REGAD: begin mdio_en = 1'b1; mdio_out = reg_addr[4 - bit_cnt]; end TA: begin if (wr_en) begin mdio_en = 1'b1; mdio_out = 1'b0; // 写操作TA第二个位是0 end else begin if (bit_cnt == 0) mdio_en = 1'b1; // 第一拍仍驱动 else mdio_en = 1'b0; // 第二拍释放总线 mdio_out = 1'b0; end end DATA: begin if (wr_en) begin mdio_en = 1'b1; mdio_out = wr_data[15 - bit_cnt]; end else begin mdio_en = 1'b0; // 读模式 // 在bit_cnt等于0..15时都采样 end end endcase end在DATA状态读模式下,需要在每个MDC周期的下降沿附近采样mdio_in:
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rd_data <= 0; rd_data_valid <= 0; end else if (state == DATA && mdc_falling_edge) begin rd_data[15 - bit_cnt] <= mdio_in; if (bit_cnt == 15) rd_data_valid <= 1'b1; else rd_data_valid <= 1'b0; end else begin rd_data_valid <= 1'b0; end endmdc_falling_edge信号的产生很简单:组合逻辑判断div_cnt等于CLK_DIV-1(即半个周期结束,MDC即将从高变低)。有了这个脉冲,所有在下降沿采样的逻辑都能统一对齐。
5. VHDL对照实现:同一套状态机的第二种写法
5.1 枚举类型让状态定义更严格
VHDL版本最直观的差异就是状态机的定义方式。Verilog用localparam定义位宽和常量,本质上还是整数,编译时不会对非法状态值做严格检查。VHDL用枚举类型直接定义状态,编译器会强制要求状态集合合法,不小心写错状态名在综合时就能报错,这比Verilog在仿真时才暴露问题要提前一步。
type mdio_state_type is (IDLE, PRE, OP, PHYAD, REGAD, TA, DATA, DONE); signal state : mdio_state_type; signal next_state : mdio_state_type;这种写法在代码可读性上完胜Verilog。综合工具也能根据枚举值自动做编码优化,默认是二进制编码,也可以指定one-hot编码。
5.2 VHDL主控模块核心代码
VHDL里没有inout wire的统一三态写法,而是用std_logic类型的'Z'状态表示高阻。端口定义如下:
entity mdio_controller is generic ( CLK_DIV : integer := 20; PREAMBLE_LEN : integer := 32 ); port ( clk : in std_logic; rst_n : in std_logic; mdc : out std_logic; mdio : inout std_logic; req : in std_logic; wr_en : in std_logic; phy_addr : in std_logic_vector(4 downto 0); reg_addr : in std_logic_vector(4 downto 0); wr_data : in std_logic_vector(15 downto 0); busy : out std_logic; rd_data : out std_logic_vector(15 downto 0); rd_data_valid : out std_logic ); end mdio_controller;架构体里,核心状态机与Verilog版本一一对应,只是语法变成了process:
architecture rtl of mdio_controller is type mdio_state_type is (IDLE, PRE, OP, PHYAD, REGAD, TA, DATA, DONE); signal state, next_state : mdio_state_type; signal div_cnt : integer range 0 to 2 * CLK_DIV - 1; signal bit_cnt : integer range 0 to 31; signal mdio_out : std_logic; signal mdio_en : std_logic; signal rd_data_reg : std_logic_vector(15 downto 0); begin mdio <= mdio_out when mdio_en = '1' else 'Z'; state_proc : process(clk, rst_n) begin if rst_n = '0' then state <= IDLE; elsif rising_edge(clk) then state <= next_state; end if; end process;VHDL的signal赋值是延时赋值,这和Verilog的非阻塞赋值类似,所以在同一个process里给信号赋值,最终值要到process结束才生效。这点和Verilog的非阻塞赋值是一个原理,理解了Verilog的时序逻辑习惯就能很自然迁移过来。
5.3 Verilog与VHDL的关键差异对照表
| 对比维度 | Verilog | VHDL |
|---|---|---|
| 状态定义 | localparam + reg | type枚举类型 + signal |
| 时序逻辑 | always @(posedge clk) | process(clk) + rising_edge(clk) |
| 组合逻辑 | always @(*) 或 assign | process(敏感信号) 或 when-else |
| 信号赋值 | 非阻塞<= / 阻塞= | signal <= / variable := |
| 数据类型 | wire/reg宽松隐式转换 | std_logic/std_logic_vector强类型,必须显式转换 |
| 位宽裁剪 | 直接截断自动适配 | 必须用 resize 或显式截取,否则编译报错 |
| case语句 | case (x) 分支以begin end包裹 | case x is when 分支以when结束 |
| 三态门 | assign mdio = en ? out : 1'bz | mdio <= out when en='1' else 'Z' |
第一眼看VHDL,很多人会被强类型系统烦到。比如Verilog里可以直接写rd_data[15 - bit_cnt],VHDL里bit_cnt是integer,索引语法没问题,但如果你想把integer赋值给std_logic_vector,就必须用std_logic_vector(to_unsigned(phy_addr_i, 5))这样的转换,多写很多代码。不过好处是类型错误在编译期就暴露,不像Verilog写错了位宽可能只在特定输入下才出错,排错成本很高。
5.4 从Verilog迁移到VHDL时最容易踩的三个坑
第一个坑是process的完整敏感列表。Verilog的always @(*)会自动推断敏感列表,VHDL的process必须显式列出所有敏感信号,少写一个信号,综合工具可能仍然能推断出来,但仿真结果会和综合结果不一致。这种"仿真对、上板不对"的问题在VHDL里比Verilog更常见,排查起来也很费劲。
第二个坑是std_logic_vector的索引方向。如果定义signal phy_addr : std_logic_vector(4 downto 0),那phy_addr(4)是最高位,和Verilog里[4:0]一致。但一旦你哪天用了to方向,索引规则就全反了。建议整个项目统一downto,别混用。
第三个坑是整数和向量的边界处理。VHDL里integer类型默认是32位有符号整数,如果你用integer做计数器然后直接给端口赋值,综合工具会警告宽度不匹配。我在工程里统一用unsigned类型做计数,最后用to_stdlogicvector转换,能减少大量宽度警告。
6. 逻辑分析仪抓到的问题:实测中的坑与调试思路
6.1 功能仿真正常,上板却读回0xFFFF
这是MDIO调试里最常见的疑难杂症。仿真里明明协议时序完全正确,Preamble、ST、OP、地址都对,但一上板读BMSR永远是0xFFFF。用逻辑分析仪抓真实波形,才发现TA周期第二拍的主机输出和PHY驱动输出发生了某个微妙的重叠,或者根本就是MDIO引脚在释放总线后电平被拉高,但PHY在采样窗口里采到的是外部干扰。
排查这类问题时,一定要按以下顺序确认:
- MDIO有没有外部上拉电阻?标准要求4.7kΩ到10kΩ上拉到IO电压。没有上拉,读操作时总线浮空,PHY即使驱动低电平,采样端也可能采到不确定值。
- 采样窗口是否落在MDC下降沿?MDC下降沿后PHY输出数据,主机应该在这个时刻之后采样。建议采样点放在下降沿过后的1/4周期处,而不是恰好在下降沿。
- 逻辑分析仪探头的容性负载是否过大?MDIO是双向低速线,探头电容大了会影响边沿,我用示波器测过,长探头的边沿明显变缓。
6.2 MDC频率和时钟约束的坑
很多PHY手册标称MDC最高2.5MHz,但工程上为了让时序更稳,我一般把目标频率压到1MHz到1.25MHz之间。分频系数大了,控制器状态机里的位周期就长,采样窗口也宽,抗干扰能力更强。
还有一个容易被忽略的点:MDC和MDIO之间的约束关系。MDC是分频产生的,如果系统时钟100MHz,目标MDC 2.5MHz,MDC边沿误差就在一个系统时钟周期内,这对MDIO这种低速协议完全够用。但如果你用了PLL生成MDC而没有做I/O约束,布线工具可能把MDC和MDIO的延迟差拉大。稳妥做法是在XDC或SDC里对MDC和MDIO加set_max_delay约束,确保两者路径延迟差在几个纳秒以内。
6.3 多PHY地址冲突与硬件引脚上拉
MDIO总线上可以挂多个PHY,每个PHY通过芯片的PHYAD[4:0]引脚设置地址。设计时如果多个PHY的地址引脚都接到了固定的高或低电平,就会导致总线冲突。RTL8211这类芯片的PHYAD引脚一般内部有下拉,外部可以通过上拉电阻改变地址。
排查方法很简单:上电后用逻辑分析仪抓一下MDIO上电初期的PHY回应,或者读一遍0x02寄存器看能否读到厂商ID。读不到就检查PHYAD引脚电平,确认没有两个PHY共用一个地址。
多个PHY挂同一条MDIO总线时,读操作的TA第二拍需要格外小心,因为所有PHY都会在前导码后看到同一个地址和寄存器地址,只有地址匹配的那个PHY才会接管TA周期并驱动数据。不匹配的PHY在这个阶段保持高阻,只要地址不冲突,总线不会受影响。
6.4 MDIO与I2C的边界问题
调试时经常被问到:为什么有的PHY模块用I2C配置,有的用MDIO?其实两者定位不同。MDIO是IEEE 802.3定义的PHY管理接口,专门用来读写PHY寄存器。而很多高速光模块或者电口模块里的状态监控功能,比如温度、电压、光功率,走的是I2C接口。原因很简单,MDIO寄存器空间只有32个,主要用于PHY的控制和状态,塞不下完整的DMI诊断信息;而I2C的地址空间更大、速率也更灵活,适合承载这些监控数据。
RTL8211GC这类芯片本身是MDIO接口,但板级设计里常常旁边挂一片小的EEPROM,用MDIO去加载初始配置,同时又有一个I2C接口去读温度和告警。搞混这两个接口的话,很容易出现对着I2C地址发MDIO帧的情况,总线自然没有响应。
经过这一轮完整的实现和调试,MDIO控制器在我这里已经成了标准IP库里的固定模块。现在遇到网口初始化失败的问题,我不会再急着怀疑PHY芯片本身,而是先用逻辑分析仪抓MDIO波形,对照状态机里的每一个状态确认数据位是否正确,再检查硬件上拉和采样时序。这种"从协议到实现到硬件"全链路排查的感觉,比单纯调库函数靠猜要踏实太多。如果你也正在调MDIO相关的问题,建议按这个顺序走一遍,大概率能直接定位到根因。