MDIO/MDC 总线:协议规范、操作时序、多设备访问

调试 Marvell PHY,绕不开 MDIO/MDC。这两根线就是你和 PHY 芯片之间的“对讲机”。我刚开始接触时,总觉得它很简单——两根线嘛,能有多复杂?结果有一次读寄存器死活返回 0xFFFF,查了半天才发现是时序没卡对。嗯,这期咱们就把这个“对讲机”彻底聊透。

1. 协议规范:帧结构长什么样

MDIO(Management Data Input/Output)和 MDC(Management Data Clock)一起构成管理接口。标准 IEEE 802.3 Clause 22 定义了它的帧格式。

每次通信是一帧,共 64 位。我习惯把它拆成五段:

  • 前导码(Preamble):32 个连续的 "1"(0xFFFF_FFFF)。同步用。
  • 起始码(Start of Frame):2 位,固定 "01"。
  • 操作码(Opcode):2 位。"10" 表示读,"01" 表示写。
  • 物理地址(PHY ADDR):5 位,最多 32 个 PHY。
  • 寄存器地址(REG ADDR):5 位,每个 PHY 最多 32 个寄存器。

然后还有 2 个 Turnaround 位(读时由 PHY 驱动,写时由主机驱动)和 16 位数据。说白了,整个帧就是“握手+寻址+数据”。

字段位宽读操作值写操作值
Preamble32全 1全 1
SoF20101
Opcode21001
PHY ADDR50~310~31
REG ADDR50~310~31
TA2Z010
Data16PHY 输出主机输入
Tips:TA 位读操作时,第一个 TA 位是高阻(Z),第二个是 PHY 驱动的 "0"。写操作则直接 "10"。很多新手读写混用时容易漏掉这个区别。

2. 操作时序:怎么卡准边沿

MDC 是时钟,频率通常 2.5 MHz 或 12.5 MHz。MDIO 在 MDC 的上升沿采样,下降沿输出。我见过有人在 SPI 项目里反过来用,结果数据全部错位。

读写时序可以概括为:

  • 读流程:主机发前导码 + 起始码 + 操作码 + 地址 + TA(第一个位高阻),然后释放 MDIO,PHY 在第二个 TA 位拉低后开始输出数据。
  • 写流程:主机全程控制 MDIO,连续发完整帧,PHY 只接收。
注意:读操作时,主机必须在 TA 第一位之前把 MDIO 设为高阻。有些廉价 MCU 的 GPIO 不能快速切换方向,需要外加上拉电阻。我曾经用过一款 STM32F1,GPIO 从输出切输入需要两个时钟周期,导致 TA 位被主机强行拉低,PHY 没机会输出数据——折腾了两天才找到原因。

关键时序参数以 2.5 MHz 为例:

参数最小值典型值单位
MDC 频率—2.5MHz
高电平宽度160200ns
低电平宽度160200ns
MDIO 数据建立时间10—ns
MDIO 数据保持时间10—ns

为什么是 2.5 MHz?因为 MDC 周期 400 ns,主频 25 MHz 的 MCU 用软件模拟要忙 64×400 ns = 25.6 µs 读一次。如果你做实时控制,这个开销不小。我建议用硬件 MDC 模块或 DMA 方式,避免 CPU 空转。

3. 多设备访问:总线冲突怎么解

一条 MDIO 总线上最多挂 32 个 PHY。每个 PHY 有自己的 5 位地址(硬件拉高/拉低引脚决定)。常见冲突有两种:

  • 地址冲突:两个 PHY 设成相同地址。数据线就会打架,读回的值是线与后的乱码。
  • 电平冲突:主机的 MDIO 引脚在读操作时没切到高阻,强行拉低 PHY 的输出。

我遇到过最坑的一个问题:某款 PHY 默认地址是 0x00,而我板子上另一个设备也用了 0x00。结果写寄存器没问题(只有主机驱动),读的时候两片 PHY 同时拉 MDIO,读到的数据全是 0x0000。排查时一度怀疑时序,最后用示波器看到 MDIO 上有三态竞争才恍然大悟。

最佳做法:每个 PHY 的地址引脚通过分压电阻或跳线设置为唯一值。调试阶段先读每个 PHY 的寄存器 2(PHY Identifier)来确认你找到的是不是目标芯片。

多设备访问的流程通常是轮询。主机先发一帧写入某个 PHY 的寄存器地址,再发读帧读取数据。由于 MDC 是共用的,所有 PHY 都能收到前导码和地址,但只有地址匹配的 PHY 才响应读写。你想想看,这有点像巴士——地址就是站台号,只有对号的车才开门。

// 伪代码:读取 PHY 地址 0x01 的寄存器 0x10
uint16_t phy_read(uint8_t phy_addr, uint8_t reg_addr) {
    uint16_t data = 0;
    // 1. 发送前导码:32个1
    for (int i = 0; i < 32; i++) {
        mdc_pulse();        // 每次脉冲输出 MDIO=1
    }
    // 2. 起始码 01
    mdio_out(0); mdc_pulse();
    mdio_out(1); mdc_pulse();
    // 3. 操作码 10 (读)
    mdio_out(1); mdc_pulse();
    mdio_out(0); mdc_pulse();
    // 4. PHY 地址 0x01 (00001)
    for (int i = 4; i >= 0; i--) {
        mdio_out((phy_addr >> i) & 0x01);
        mdc_pulse();
    }
    // 5. 寄存器地址 0x10 (10000)
    for (int i = 4; i >= 0; i--) {
        mdio_out((reg_addr >> i) & 0x01);
        mdc_pulse();
    }
    // 6. TA 位:第一个高阻,第二个读 0
    mdio_high_z(); mdc_pulse();   // 主机释放
    // 注意:有些 MCU 需要延时等待 PHY 拉低
    if (mdio_in() != 0) {
        return 0xFFFF;  // 错误
    }
    mdc_pulse();        // 第二个 TA 位
    // 7. 读取 16 位数据
    for (int i = 15; i >= 0; i--) {
        data |= (mdio_in() << i);
        mdc_pulse();
    }
    return data;
}
个人习惯:我会在每次读操作后加一个 1 µs 的空闲期,让总线稳定。特别是多个 PHY 切换时,跳过这个空档可能会残留上一帧的电荷。
MDIO 协议帧结构与多设备访问 MDIO 协议帧结构与多设备访问流程 64 位帧结构 Preamble 32 bits SoF 2 Op 2 PHY Addr 5 Reg Addr 5 TA 2 Data 16 bits 同步时钟 01 读10/写01 0~31 0~31 Z0/10 读/写数据 多设备访问流程 主机 (MAC/CPU) MDC 时钟 MDIO 总线 PHY 0 (Addr=00) PHY 1 (Addr=01) PHY 2 (Addr=02) 只有地址匹配的 PHY 才会在 TA 位输出数据,其他 PHY 保持高阻

4. 避坑指南:我踩过的坑

  • 前导码不能省:有些 MCU 的 MDIO 模块会自动发前导码,但如果你用 GPIO 模拟,别偷懒不送 32 个 1。PHY 需要前导码来同步状态机。
  • 读操作后别直接写:读帧的最后 16 位数据由 PHY 驱动,主机会在下降沿采样。如果你紧接着立刻写,MDIO 方向切换太快,容易把 PHY 的输出和主机的指令混在一起。我习惯在读写之间加 1 个 MDC 空周期。
  • 地址不要从 0 开始:很多评估板默认 PHY 地址为 0,但你不知道板上有没有另一个设备也占用了 0。我的做法是让 PHY 至少跳到 1 或 2,并用 GPIO 读取 strap 确认。
血的教训:我曾经用软件模拟 MDIO 实现一个快速遍历所有 32 个地址的检测函数,结果因为没有及时释放 MDIO 总线,把三个 PHY 全部锁死在复位状态。后来加了个上拉电阻才解决。记住,MDIO 是双向三态总线,一定要用开漏输出或 GPIO 方向切换。

好了,MDIO/MDC 这部分就这些。你只要把帧结构刻在脑子里,时序参数记牢,多设备访问时保持总线清洁 —— 后面所有 PHY 调试都会轻松不少。