MDIO 读操作的底层原理——你我真的搞懂了吗?

很多人写 mdio-tool 的读命令,能跑通,但一旦遇到 PHY 不回数据就抓瞎了。说实话,MDIO 读操作没那么玄乎,但它里头的时序细节和帧结构确实容易绊倒人。我刚开始调 PHY 的时候,也栽过跟头——读回来的全是 0xffff,折腾了两天才发现是 turnaround 阶段电平没拉高。

这一节咱们就把 MDIO 读操作的原理掰开揉碎了讲。你理解了这一块,后面写驱动、排查问题都会顺手很多。

1. 读操作的帧结构

MDIO 读操作跟写操作最大的区别在于:主机先发命令,然后 PHY 在 turnaround 阶段接管总线,把数据传回来。整个读帧包含 64 个时钟周期,每个周期在上升沿采样数据。

帧结构拆开来看,是这样的:

字段 位数 方向 说明
Preamble 32 ST → PHY 连续 32 个高电平(1),用于同步
Start 2 ST → PHY 固定为 01,表示帧开始
Opcode 2 ST → PHY 读操作 opcode 固定为 10
PHY ADDR 5 ST → PHY 目标 PHY 的地址(0–31)
REG ADDR 5 ST → PHY 要读取的寄存器编号(0–31)
Turnaround 2 ST → PHY → ST 第 1 位高阻,第 2 位 PHY 驱动 0
DATA 16 PHY → ST PHY 返回的寄存器值,高位在前
Idle ≥ 2 高阻 释放总线,等待下一帧

重点记一下: 读操作里 Turnaround 是两个特殊的时钟。
第一个时钟主机必须置为高阻(MDIO 处于输入),第二个时钟 PHY 必须拉低电平作为应答。
如果 PHY 在第二个时钟没拉低,说明 PHY 没回应——这时候读到的 data 全是垃圾。

2. 时序上的“软开关”

MDIO 是半双工总线,读操作中总线控制权会从主机转移到 PHY。这个切换发生在 Turnaround 阶段。我记得有一次在 FPGA 上实现 MDIO 控制器,我忘了把 IO 方向从输出改成输入,结果第二个时钟 PHY 拉低时被 FPGA 的输出驱动给冲突了,直接把 PHY 的驱动电流拉穿,读回来的数据完全不对。

所以你需要保证:在 Turnaround 第一个时钟的下降沿之后,主机的 MDIO 引脚必须变为 输入模式(高阻)。等数据读完后,再变回输出模式。

3. 关键时序参数

规范里 MDC 时钟最高频率是 2.5 MHz(周期 400 ns),但实际上很多 PHY 支持 25 MHz 甚至更高。我个人习惯保守一点——默认跑 2.5 MHz,稳定性第一。

几个必须关注的参数:

  • MDC 最小周期: 400 ns(2.5 MHz),有些 PHY 能跑更快,但别超过 PHY 手册的极限。
  • MDIO 数据建立时间: 相对于 MDC 上升沿,至少需要 10 ns。
  • MDIO 数据保持时间: 上升沿后至少保持 10 ns。
  • 总线切换延迟: 从主机释放到 PHY 接管,通常预留半个 MDC 周期(约 200 ns @ 2.5 MHz)。

个人技巧: 如果你用 GPIO 模拟 MDIO,记得在 Turnaround 第一个时钟结束后先延迟 1 μs 再切换方向,给 PCB 上的上拉电阻一点缓冲时间。这个延迟我在好几块开发板上都验证过,非常管用。

4. 读操作的状态机流程

写代码的时候,脑子里要有一个清晰的状态机。我一般把它分成 5 个阶段:

  1. PREAMBLE: 连续输出 32 个 1,让 PHY 重新同步。
  2. COMMAND: 输出 Start(01) + Opcode(10) + PHY ADDR + REG ADDR,共 12 位。
  3. TURNAROUND: 第 1 位输出高阻,第 2 位读 PHY 的 0 回应。
  4. READ DATA: 连续读取 16 位,高位在前。
  5. IDLE: 至少保持 2 个时钟的高阻,之后可以开始下一帧。

下面我用一个简单的伪代码来模拟这个状态机:

// 伪代码 - MDIO 读操作
uint16_t mdio_read(uint8_t phy_addr, uint8_t reg_addr) {
    // 1. PREAMBLE: 输出32个1
    for (int i = 0; i < 32; i++) {
        MDC_clock();
        MDIO_out(1);
    }

    // 2. COMMAND: 01 + 10 + addr5 + reg5
    write_bits(2, 0b01);      // Start
    write_bits(2, 0b10);      // Opcode 读
    write_bits(5, phy_addr);
    write_bits(5, reg_addr);

    // 3. TURNAROUND: 第一个时钟高阻,第二个时钟读应答
    MDIO_set_input();         // 切换为输入
    MDC_clock();              // 第一个 TA 时钟,PHY 应该未驱动
    uint8_t ta2 = MDIO_read_bit(); // 读取第二个 TA 时钟
    if (ta2 != 0) {
        // 应答失败,返回错误
        return 0xFFFF;
    }

    // 4. 读取 16 位数据
    uint16_t data = 0;
    for (int i = 0; i < 16; i++) {
        MDC_clock();
        data = (data << 1) | MDIO_read_bit();
    }

    // 5. IDLE: 切回输出,发两个高阻时钟
    MDIO_set_output();
    MDIO_out(1);
    MDC_clock(); MDC_clock();

    return data;
}

警告: 上面的伪代码里,write_bits() 函数在每 bit 写入后会翻转 MDC。如果你直接用 GPIO 控制,要注意 MDC 的占空比最好在 40%–60% 之间,不要用太长的延迟,否则 PHY 可能无法采样正确。

5. 实战中常见的坑(我曾踩过的)

第一坑——Preamble 不够长。有些 PHY 对同步要求很严格,如果你上一次总线操作后线路空闲时间很久,PHY 的锁相环可能进入了低功耗模式。这时候只发 32 个 1 不一定够。我建议:在每次读前都先发 48 个 1 的 preamble,安全第一。

第二坑——Turnaround 的第二个时钟。很多新手会写成“在第一个时钟里就切方向”,结果第一个时钟被主机强制拉高/拉低,把 PHY 准备拉 0 的动作给破坏了。记住:第一个 TA 时钟 主机必须处于输入高阻,不要在时钟沿后立即切换。

第三坑——多片 PHY 共用一个 MDIO 总线。如果某片 PHY 没选中(地址不匹配),它应该保持高阻。但如果 PHY 芯片设计有缺陷,它可能仍然做无谓的驱动。我曾经在一块交换板子上遇到过:有两片 PHY 地址配错了,读一片的时候另一片也在抢总线,导致数据全是乱的。解决办法是:用示波器抓 MDIO 波形,看 Turnaround 第 2 位有没有被拉低,如果没有,大概率是地址冲突或 PHY 没响应。

6. 知识结构一目了然

下面的 SVG 图把 MDIO 读操作的关键节点、数据流向和时序关系串起来了。建议你把它截下来贴在笔记里,写代码时反复对照。

MDIO 读操作流程与帧结构 MDIO 读操作帧结构流程 Preamble Start Opcode PHY ADDR REG ADDR Turnaround DATA Idle ST→PHY ST→PHY ST→PHY ST→PHY ST→PHY 双向切换 PHY→ST 高阻 时序要点 • 每个字段在 MDC 上升沿锁存,数据在 MDC 下降沿变化 • Turnaround 阶段:第一个时钟主机浮空,第二个时钟 PHY 必须驱动为 0 • DATA 共 16 位,高位先传;读取完毕后主机恢复输出,发送 Idle 高阻 常见错误:Preamble 不足 32 位、Turnaround 未切 IO 方向、总线被多片 PHY 冲突

这张图把每个字段的相对位置和方向控制都标清楚了。写驱动的时候照着它逐 bit 操作,基本不会出错。

7. 小结

MDIO 读操作就是“发命令 -> 切方向 -> 读数据”三步。难点在于切换方向的时机和时序的精确控制。你只要掌握了帧结构、理解了 Turnaround 的切换点,再动手写几遍代码,就彻底吃透了。