1. MDIO是什么,为什么搞嵌入式的人绕不开它
干过网络设备、交换机、光模块、甚至智能网卡固件的人,十有八九都和MDIO打过交道。MDIO,全称Management Data Input/Output,也叫SMI(Serial Management Interface),是IEEE 802.3标准里定义的一套管理接口,专门用来访问PHY芯片(物理层收发器)内部的寄存器。你只要在板子上放过一个以太网PHY,不管是百兆、千兆还是2.5G、10G,就几乎必然要用MDIO去配置它、读取它的状态。
很多人一开始不理解:PHY芯片不是插上就能跑吗?为什么还要专门用一个接口去“管”它?这里有个非常实际的原因——PHY芯片上电后默认的工作模式未必是你想要的。比如你的板子用的是千兆PHY,默认可能工作在百兆模式,或者默认开了Auto-Negotiation(自动协商),但你的对端设备不协商,只强制千兆全双工。这种时候你就要通过MDIO去改写PHY的寄存器,把工作模式、速率、双工模式、流控开关这些参数扛到正确的状态。
另一方面,MDIO不光用来“写配置”,更要紧的是“读状态”。网络出问题,最先怀疑的就是链路是不是真的起来了,物理层到底协商到了多少速率。这些信息都在PHY的寄存器里,比如状态寄存器的bit 2是Link Status,协商寄存器里能看到对端能力。用MDIO把这些寄存器读出来,问题定位就完成了一半。所以在调试网络驱动、排查硬件链路问题时,MDIO几乎是必备工具。
MDIO的硬件实现很简单,就两根线:MDC(管理时钟)和MDIO(管理数据)。MDC由MAC侧提供,最高频率一般是2.5MHz或更高(不同标准版本有区别),MDIO是双向数据线,MAC发命令、PHY回数据,按特定帧格式在MDC的上升沿或下降沿采样。这个接口既不涉及中断,也不需要复杂的流控,时序简单到可以用GPIO模拟。也正因为如此,它才能从90年代一直沿用到现在,连10G、25G、50G甚至更高速率的PHY都还在用这套管理协议。理解了MDIO,再看PHY驱动、看交换芯片的寄存器配置、甚至自己用逻辑分析仪抓时序,都会顺很多。
这篇文章就围绕MDIO把几件事讲透:协议本身的帧结构、读写流程,硬件设计上多网口共用一个MDIO的坑,Linux系统下的驱动实现和排查方法,以及一个经常被问到的问题——如果PHY不支持MDIO,能不能用I2C代替,怎么在驱动里改。最后再把我这些年调试MDIO遇到过的问题整理成一份排查清单,给正在被PHY调试折磨的朋友做个参考。
2. MDIO协议细节拆解:帧结构、时序、读写流程
2.1 帧结构:两条线,32个bit,一段完整的故事
MDIO一次访问的基本单位是“帧”,每条帧的长度是固定的。从MAC发往PHY的帧一共包含64个bit,但真正需要关心的核心部分是从“PREAMBLE”之后的32个bit管理帧字段。
MDIO标准帧的完整结构是这样的:
| 字段 | 长度 | 说明 |
|---|---|---|
| PREAMBLE | 32位 | 连续32个“1”,用于同步 |
| ST | 2位 | 起始码,固定为01 |
| OP | 2位 | 操作码,读为10,写为01 |
| PHYAD | 5位 | PHY地址,取值0~31 |
| REGAD | 5位 | 寄存器地址,取值0~31 |
| TA | 2位 | 转向周期,读时为Z态,写时为10 |
| DATA | 16位 | 读/写的数据 |
注意看这个结构里的几个关键字段。PHYAD决定了你访问的是哪一颗PHY,这正好对应了多颗PHY挂在同一条MDIO总线上的场景——通过地址区分,互不干扰。REGAD是PHY内部寄存器的偏移地址,IEEE 802.3规定了一些基础寄存器的地址,比如寄存器0是控制寄存器、寄存器1是状态寄存器、寄存器2和3是PHY ID寄存器,后面的4~6、9等也都做了定义。DAT A就是实际要写入或者读回的数据,永远是16位。
这里有个细节,很多人刚接触时会忽略:PREAMBLE是32个连续的“1”。如果MDC频率比较高、总线上的电容负载又比较大,PREAMBLE部分最容易出问题。因为前面同步没建立好,后面的数据全是乱的。后面讲排查时会提到怎么通过抓时序来确认这一点。
2.2 MDIO读操作:从发送命令到接收数据的完整时间线
MDIO读操作比写操作复杂一点点,因为涉及数据方向切换。我实际调试时习惯把整个读流程拆成四个阶段来看:
阶段一,MAC发送PREAMBLE和命令头。也就是32个“1”,然后ST=01、OP=10、PHYAD、REGAD。这一部分全部由MAC侧驱动MDIO引脚输出。OP是10表示“我要读”,这个信息告诉所有挂在总线上的PHY:接下来你们听命令。
阶段二,TA周期。标准规定TA为2位,在写操作时,MAC在这两个时钟周期里输出“10”;在读操作时,MAC要把MDIO引脚释放掉,改为高阻态,由被选中的PHY接管。为什么要这样设计?因为读的时候下一个字段DATA是PHY往MAC方向传的,总线上不能同时有两个驱动源在推数据,所以需要一个切换空档,这2个TA周期就是给总线做方向切换用的。
阶段三,PHY在TA之后的16个MDC周期里,一位一位地把寄存器内容送到MDIO线上。注意这里的采样时机:PHY在MDC的上升沿把数据放到线上,MAC在下降沿采样。这是MDIO协议里很经典的一个约定,保证数据有足够的建立时间和保持时间。如果你自己用GPIO模拟MDIO,就一定要按这个节奏来,否则在MDC频率较高或者线缆较长时容易采到边沿上的毛刺。
阶段四,MAC收完16位数据后,整个读操作完成。此时PHY释放总线,MDIO线回到高阻,等待下一条命令。
2.3 MDIO写操作:比读操作简单,但更怕干扰
写操作没有数据方向切换,MAC从头到尾都把MDIO引脚驱动住。帧格式里OP=01,TA=10,DATA直接跟着TA后面由MAC输出。PHY在自己内部按MDC时钟去采这16位数据,然后在下一个命令到来之前完成寄存器更新。
写操作从时序角度更容易实现,但有个实际问题:如果一个寄存器写入后影响PHY的工作模式(比如强制千兆全双工、关闭协商),PHY可能需要一段时间才能完成切换。IEEE标准里建议写控制寄存器之后,驱动要适当等一段时间再去读状态确认,不要写完立刻判断结果。很多PHY的reset、速率切换都是几毫秒到几十毫秒级别的,读回的状态变化会有延迟。我在实际项目里就遇到过:写完控制寄存器后马上去读状态寄存器,看到的结果还是旧值,差点以为是MDIO通信坏了,后来查资料发现是PHY内部更新需要时间。
对MDIO帧结构的理解是调试一切PHY问题的基础。手里拿着逻辑分析仪,你是能直接数出哪一位是PHYAD、哪一位是DATA的。很多交换芯片的SDK、Linux的mdio工具,本质上就是帮你在软件层面拼好这些帧,然后通过MAC的MDIO控制器发出去。
3. 硬件设计与调试:多网口共用一条MDIO的那点事
3.1 为什么多颗PHY可以共用一条MDIO总线
MDIO总线是“多从一主”的拓扑。一个MAC主设备,最多可以管理32个PHY从设备,因为PHYAD只有5位。这32个PHY挂在同一条MDC和MDIO线上,靠地址区分彼此。这样做的好处非常明显:节省主控引脚,减少走线,软件上也只需要注册一组MDIO总线,就能访问所有PHY。
具体到硬件连接,MDC和MDIO信号从主控出来之后,以菊花链或者星型的方式接到每颗PHY的对应引脚。PHY的地址一般由硬件引脚决定,常见的做法是用PHYAD[4:0]引脚的外部上下拉电阻来配置,也可以在PHY的寄存器里改。板子上每一颗PHY的地址都必须不同,否则总线上就出现“一地址多设备”的冲突,读回的数据是乱的。
3.2 双网口共用一个MDIO的正确打开方式
“双网口共用一个MDIO”在设计上非常常见,比如一颗双口PHY,或者两颗独立PHY共MCU。这时要注意的不只是PHYAD不能一样,还有几个隐藏的坑。
第一个坑是MDIO引脚有没有内部上拉。MDIO线是双向的,空闲时应该处于高电平或者高阻态。很多PHY芯片的MDIO引脚内部有弱上拉,但也有一些需要外部加上拉电阻。如果有两颗PHY,后面那颗的上拉没处理,或者上拉电阻位置离主控远,就可能出现读回数据bit位翻转的问题。经验做法是在离主控最近的地方统一放一个2.2kΩ~4.7kΩ的上拉电阻到1.8V或2.5V(以PHY的IO电平为准)。
第二个坑是MDC时钟的分布延迟。MDC是主设备单方向输出的,在扇出到多颗PHY时,各PHY收到的MDC边沿可能有偏差。如果PHY之间距离远、走线长度差异大,就会影响采样时序。解决方法是尽量让MDC走线等长,或者降低MDC频率。有些平台的MDC频率可以配置,实际调试时可以用较低频率(1.25MHz或2.5MHz)来规避时序问题。
第三个坑是地址重叠问题。明明硬件上把PHY0设置成了地址1,PHY1设置成了地址2,但软件一读两个地址都能读到东西。这种情况多半是一个PHY在MDIO总线上“应答”了所有地址,或者其中一颗PHY的MDIO引脚未正确连接,导致它没有真正挂在总线上,但其他PHY回数据时它也跟着“串扰”。排查方法很直接:把所有PHY的上电复位都先做好,然后只挂一颗PHY,逐个读地址,确认每一个地址上到底有没有设备;再逐个加入,观察地址是否被“抢”。
3.3 MDIO的电气特性与PCB设计建议
MDIO的电气标准本质上是类似漏极开路的双向信号线。虽然不同厂家、不同PHY芯片对MDIO的驱动方式和电平定义略有差异,但通用的原则还是那几个:
- MDC频率不要超过PHY手册给出的上限,老一些的PHY或者经过连接器引出的场景,降频更稳妥。
- MDIO走线尽量短,远离时钟线、电源开关节点,避免被干扰。
- 在靠近PHY端放一个小电容(比如10pF~22pF)对地滤波,可以抑制一些边沿噪声,但电容太大会把信号边沿拖垮,要权衡。
- 多颗PHY共享总线时,MDIO线上的总负载变大,上升沿会变缓。如果发现读回的数据在特定bit上不稳定,优先检查上拉强度和总线的等效电容。
对硬件工程师来说,MDIO很少成为瓶颈,但一旦出问题就是“软件怎么也调不好”的玄学问题。把时序提前抓清楚,比事后慢慢排查划算得多。我的习惯是,新板子回来后第一件事就是用示波器或者逻辑分析仪抓一次MDIO读操作,确认波形正确,再往软件层走。
4. Linux下MDIO驱动的使用与I2C替代方案
4.1 Linux的mdio子系统和常用调试手段
到了Linux系统里,MDIO已经被抽象成了完整的子系统。从下到上大致是:MDIO总线控制器驱动(挂MDIO控制器到设备树)、PHY驱动(匹配PHY ID)、网络驱动(通过PHY抽象层访问PHY状态)。这个层次的好处是,你换了不同厂家的PHY,不需要改MAC驱动,只要确保设备树里MDIO总线的节点正确、PHY的节点挂对了,就能用统一的方式去读PHY寄存器。
设备树里典型的一段MDIO节点长这样:
&mdio0 { status = "okay"; phy0: ethernet-phy@1 { reg = <1>; /* 这里的reg就是PHYAD */ }; phy1: ethernet-phy@2 { reg = <2>; }; };注意reg字段对应的就是MDIO帧里的PHYAD。地址对不上,驱动probe阶段就会报找不到PHY。
在用户态调试MDIO,我常用下面这几个办法:
| 调试手段 | 用法 | 适用场景 |
|---|---|---|
| ethtool | ethtool eth0 / ethtool -s eth0 speed 1000 duplex full | 看链路状态、改速率双工 |
| mii-tool | mii-tool eth0 / mii-tool -r eth0 | 老工具,看协商结果、重启协商 |
| phy调试节点 | /sys/class/mdio_bus/*/ | 查看MDIO总线上的PHY节点 |
| 直接读寄存器 | devmem / 自写工具或bpftrace | 深挖特定寄存器 |
在实际项目中,我经常先在用户态用ethool确认PHY的基本状态,如果状态异常再上逻辑分析仪去抓MDIO帧。很多时候能省下大量怀疑代码的时间。
4.2 PHY不支持MDIO、只能用I2C,驱动怎么改
现在有个比较常见的场景,尤其是在一些低成本的PHY、或者某些Breakout板卡上的PHY实现里,MDIO接口没有被引出,取而代之的是I2C接口。这个现象其实并不罕见,因为I2C也是管理PHY寄存器的可行途径,有些PHY芯片同时支持MDIO和I2C两种管理方式,通过硬件引脚选择。这也就对应了热词里说的“linux phy 不使用mdio,使用i2c”。
如果你手里的PHY确实只能用I2C来访问,那么在Linux驱动层面,通常有两条路可走。
第一条路:找找看这个PHY的驱动是否自带I2C支持。不少PHY厂商的驱动里同时封装了mdio和i2c两种访问方式,比如某些Marvell、Broadcom、Realtek的PHY驱动就做过这样的兼容。驱动初始化时会根据设备树里挂载的bus类型来决定走哪条路。如果驱动本身支持,设备树里把PHY节点挂到i2c总线上,再指定 compatible 即可。
第二条路:自己写一个PHY驱动,或者给现有驱动打补丁,把读写的底层函数替换成I2C读写操作。Linux的PHY子系统为这种扩展留下了接口,主要就是实现 mdio_read 和 mdio_write 这两个回调。你可以在驱动里像下面这样把I2C读写封装成标准MDIO访问:
static int phy_i2c_read(struct mii_bus *bus, int phy_addr, int reg_addr) { /* 构造I2C消息,访问对应寄存器的读操作 */ u8 buf[2] = { reg_addr >> 8, reg_addr & 0xff }; struct i2c_msg msgs[2] = {...}; /* 使用i2c_transfer完成读 */ } static int phy_i2c_write(struct mii_bus *bus, int phy_addr, int reg_addr, u16 val) { /* 构造I2C写消息 */ } static struct mii_bus_ops phy_i2c_bus_ops = { .read = phy_i2c_read, .write = phy_i2c_write, };这样一来,上层的PHY驱动和网络驱动都不用动,只是在最底层换了一个访问媒介。逻辑上,Linux的phy_device层仍然认为自己在和“MDIO总线”通信,只不过这个“总线”的read/write函数实际是I2C的传输函数。
这里要特别注意一个点:I2C的访问频率远低于MDIO(I2C一般是100kHz~400kHz,MDIO可以到2.5MHz以上),所以每次读PHY寄存器都变慢。尤其是网络驱动在状态轮询时会定期读PHY的链路状态寄存器,这个频率通常不高(一般1秒到几秒一次),所以整体影响不大。但如果驱动里有大量连续读寄存器的逻辑,性能就要仔细评估了。
还有一件事:有些PHY在只用I2C管里时,引脚MDIO/MDC可以悬空,或者需要接到特定的电平来禁用MDIO功能。这点务必看PHY的硬件手册。我见过工程师把PHY的MODESEL引脚接错电源轨,结果PHY一直试图用MDIO模式,I2C总线上怎么也读不到数据。后来把引脚电平改对,I2C立刻通了。这种硬件配置问题比软件问题隐蔽得多。
4.3 设备树和驱动注册的实操模板
下面给一个实际可用的I2C访问PHY的设备树片段做参考:
&i2c2 { status = "okay"; phy@3 { compatible = "ethernet-phy-ieee802.3-c22"; reg = <3>; /* 这颗PHY挂在i2c2上,地址为3 */ }; };如果这个PHY的驱动本身支持I2C,那么把它挂到i2c节点下即可,Linux的PHY框架会自动通过该I2C设备来访问寄存器。如果你的PHY驱动不直接支持,你就要先注册一个mii_bus,并将这个mii_bus的read/write指向自己写的I2C封装函数,然后把这个mii_bus注册到系统中:
struct mii_bus *bus; bus = mii_bus_alloc(NULL); if (!bus) return -ENOMEM; bus->name = "phy-i2c-bus"; bus->read = phy_i2c_read; bus->write = phy_i2c_write; bus->parent = dev; strncpy(bus->id, "phy-i2c", MII_BUS_ID_SIZE); ret = mii_bus_register(bus); if (ret) goto err;然后网络驱动在初始化时调用phy_connect或phy_connect_direct,指定到这个bus上查PHY。这样,上层的PHY驱动完全不用感知底层到底是MDIO还是I2C。
我自己在项目中改过一次这样的方案。那次是因为一颗工业级PHY在客户的板子上没有把MDIO引脚引出来,客户又不想改版,只能走I2C。我花了半天把I2C的读写封装好、设备树调通、link状态轮询也正常。总结下来,只要PHY的寄存器资源能通过I2C访问到,Linux这一层完全能适配,关键是把I2C的时序和PHY的寄存器地址映射搞清楚。
5. 常见问题与排查实录:MDIO调试避坑指南
5.1 读回的数据全是0xFF或0x00,怎么办
这是MDIO调试里最常见的现象。读到0xFF,通常意味着总线上没有PHY在应答,数据线一直被上拉到高电平。可能原因包括:PHY没有上电、复位引脚一直拉低、PHY地址不对、MDIO引脚没接对、或者PHY的MDIO功能在硬件上被禁用了。读到0x00也是类似的,往往是没有上拉电阻或者PHY的驱动能力没起来。
排查顺序我建议是固定的,不要跳步骤:
- 用万用表量PHY各电源轨有没有电压、复位引脚电平对不对。
- 确认PHYAD引脚的外部电阻配置是否和软件里访问的地址一致。
- 拿逻辑分析仪抓MDC和MDIO线上的波形,先确认有没有帧发出来。
- 如果帧发出来了,但PHY没应答,把PHY的另一颗放在总线上试试,排除PHY芯片本身坏。
- 如果是多颗PHY共线,一次只接一颗,排除地址冲突。
5.2 MDIO时序上采到毛刺,怎么处理
有时候波形在逻辑分析仪上很好看,但实际PHY就是不稳定,偶尔读错。这种情况多半是电气噪声或者信号质量差。MDIO数据是在MDC下降沿被MAC采样的,但PHY是在上升沿送数据。如果线上有振铃,而你的逻辑分析仪触发阈值恰好设得不好,看到的数据就可能是错的。
处理办法有几种:在MDIO线上串一个几十欧姆的电阻(官方叫bulk resistor),可以抑制振铃;减小MDC频率可以让边沿变得更缓;在PHY侧加一个RDY上拉到正确电平。还有一个容易被忽略的点——MDIO线不要和MDC线离得太近,因为MDC是单端时钟,翻转时会对旁边数据线产生串扰。PCB布线时MDIO尽量包地或者拉开间距。
5.3 多网口共用一个MDIO时,链路状态上报乱跳
双网口共用一条MDIO后,网口A的link up了,但网口B的状态也跟着变,或者状态轮询时报错。我遇到过的情况,查到最后是设备树里两个网口节点引用了同一个PHY节点。也就是说,两个net dev 注册到了同一颗PHY上,状态自然互相影响。这不是MDIO硬件问题,而是软件资源冲突。
确认方法很简单:在/sys/class/mdio_bus/下面看有几个PHY节点,再对比两个网口的phy_device指针是不是同一个。修复方法就是把设备树里PHY节点分开,分别给两个网口挂不同的PHY地址。还有一个类似问题:一颗双口PHY芯片,两个端口内部对应两个PHY地址,但有人只配了一个地址,另一个端口访问不到。这种就要老老实实按双地址配,不能偷懒。
5.4 驱动里轮询PHY状态太频繁,影响MDIO上的其他事务
Linux的PHY状态机默认会定期读PHY的状态寄存器,比如每秒几次。如果同一个MDIO总线上还挂了一颗光模块的DDM信息读取或者其他慢速外设,而软件的轮询频率设置得太高,总线就可能会“拥挤”。
解决办法是合理调整PHY状态轮询间隔,或者把不同PHY分散到不同MDIO总线。光模块的寄存器访问通常也是走MDIO(SFF-8472规范里可用MDIO或I2C),这种场景下更需要规划好总线占用。我一般建议把光模块和主PHY分在不同的总线上,避免互相抢时间。如果实在不能分开,就把轮询频率降到合理范围,比如把phy_polling_interval调大一些。
5.5 一个完整的排查案例复盘
最后分享一个实际案例。某块板子上有两颗千兆PHY,共用一条MDIO,主控是某国产网络芯片。现象是:PHY0工作正常,PHY1的link状态时好时坏,读PHY1寄存器有时读到正确的ID,有时读到0xFFFF。
排查过程是这样的:先用ethtool看两个网口状态,PHY0正常,PHY1反复down/up。然后接逻辑分析仪抓两条MDIO访存的帧,发现PHY1地址的读命令发出后,总线上经常没有应答信号。查看硬件原理图,发现两个PHY的MDIO线共用没问题,但PHY1的MDIO引脚到主控的走线绕了很长一段,而且经过了一颗排阻的末端,没接上拉。再查PHY1的PHYAD配置电阻,发现它和PHY0一样,都配到了地址0。这就解释了为什么PHY1有时能响应、有时不行——总线上两个地址相同的PHY都在尝试应答,信号互相拉扯。
修改方案:把PHY1的地址引脚改成地址1,并在主控侧增加一个2.2kΩ上拉电阻。改动后PHY1读写稳定,link状态不再跳动。这个案例典型之处在于,硬件问题常常伪装成软件问题,而且不止一个原因叠加。遇到类似情况,不要只纠结在驱动代码上,先确认硬件基础和寄存器回读是否一致。
结尾:一点实战建议
MDIO协议本身不算复杂,复杂度全在硬件现场和软件框架的贴合上。我自己做过的项目中,真正把时间花在“读不明白PHY寄存器”上的次数,远比“程序写不出来”的次数多。所以给刚开始接触网络PHY的朋友一个建议:先把你手上PHY的寄存器手册从头到尾翻一遍,把控制寄存器、状态寄存器、协商寄存器这几个关键位置记下来,然后养成“读寄存器之前先抓时序波形”的习惯。不管用什么调试工具,先确认物理层是通的,再谈上层逻辑。
另外,如果项目允许,尽量在板卡上预留一个MDIO调试引脚或者测试点。哪怕量产时不用,调试阶段这几个点能救命。你永远不会知道哪次客户现场的网络问题,最后是靠一根杜邦线接上逻辑分析仪、读出一条PHY寄存器解决的。MDIO这个接口虽老,但在嵌入式网络开发中的地位一点没降,值得多花点心思把它吃透。