MDIO 读写操作流程:读操作时序、写操作时序、总线仲裁

MDIO 读写,说白了就是通过 MDC 和 MDIO 两根线,把 PHY 寄存器里的值读出来或者写进去。协议本身不复杂,但时序细节一旦搞错,抓回来的波形就是一团乱麻。我刚调 PHY 那会儿就吃过这个亏——读回来的数据全是 0xFFFF,折腾了半天才发现是 TA 阶段释放总线的时间没算对。

这一节咱们就把读操作、写操作和总线仲裁掰开揉碎了讲。你想想看,搞清楚了这三个东西,MDIO 调试你就掌握了七八成。

MDIO 读写操作流程图 MDIO 读写操作流程 IDLE START (ST=2'b01) 前导码 32 个 1 OP CODE 判断 读: 10 / 写: 01 仲裁检测点 多 STA 同时驱动时 监控 MDIO 电平冲突 PHYAD[4:0] + REGAD[4:0] 各 5 位,共 10 位 TA 转向时间 2 位 读 (OP=10) 写 (OP=01) DATA(读) PHY 驱动 16 位数据 DATA(写) STA 驱动 16 位数据 END 绿色箭头 = 读路径 · 红色箭头 = 写路径

读操作时序

读操作的目标是从 PHY 的某个寄存器里读出 16 位数据。整个过程由 STA 发起,PHY 负责响应。嗯,这里有个关键点:读操作中总线控制权会切换一次。

我记得第一次调试读时序时,波形上数据总是不对。后来发现是 TA 阶段 STA 释放总线晚了半个周期,PHY 拉低 MDIO 时两边怼上了。你想想看,一个是推挽输出,一个是开漏输出,不打架才怪。

标准读操作帧结构如下:

  1. 前导码(PRE):STA 连续驱动 32 个 1,让总线进入同步状态。
  2. 起始码(ST):发送 2'b01,标志一帧的开始。
  3. 操作码(OP):发送 2'b10,表示本次为读操作。
  4. PHY 地址:发送 5 位 PHY 地址,高位在前。
  5. 寄存器地址:发送 5 位寄存器地址,高位在前。
  6. 转向时间(TA):STA 释放 MDIO 线(高阻),PHY 接管并驱动 2 位。第一位 PHY 拉高,第二位 PHY 拉低。
  7. 数据(DATA):PHY 连续驱动 16 位数据,STA 在每个 MDC 上升沿采样。
  8. 结束:MDIO 回到高阻或空闲状态。
我的小技巧:读操作时,TA 阶段的第一位是 PHY 的应答信号。如果这一位没有拉低,说明 PHY 没响应——要么地址不对,要么 PHY 还在复位。抓波形时优先看这个位置。

写操作时序

写操作比读操作简单一些,因为总线从头到尾都由 STA 控制,没有控制权切换。说白了,就是 STA 一个人把整帧数据推完。

标准写操作帧结构如下:

  1. 前导码(PRE):STA 连续驱动 32 个 1。
  2. 起始码(ST):发送 2'b01。
  3. 操作码(OP):发送 2'b01,表示本次为写操作。
  4. PHY 地址:发送 5 位 PHY 地址。
  5. 寄存器地址:发送 5 位寄存器地址。
  6. 转向时间(TA):STA 继续驱动 MDIO 线,发送 2'b10。第一位拉高,第二位拉低。
  7. 数据(DATA):STA 驱动 16 位数据,PHY 在每个 MDC 上升沿采样。
  8. 结束:MDIO 回到空闲状态。

我建议写操作完成后,紧接着读同一个寄存器做回读验证。这样能第一时间发现写入是否生效,避免后续调试时怀疑人生。

读操作与写操作的对比

对比项 读操作 写操作
操作码(OP) 2'b10 2'b01
TA 阶段驱动方 STA 释放,PHY 驱动 STA 全程驱动
TA 阶段数据 Z0(高阻 + 低电平) 10(高电平 + 低电平)
数据驱动方 PHY STA
回读验证 天然回读 建议额外发起读操作
总线冲突风险 较高(控制权切换) 较低(单方控制)

总线仲裁

MDIO 总线可以挂多个 STA 设备。但问题来了——如果两个 STA 同时发起操作,谁说了算?

MDIO 协议没有像 I2C 那样复杂的仲裁逻辑,它靠的是电平冲突检测。具体机制是这样的:

  • 每个 STA 在驱动 MDIO 之前,先检测总线是否空闲。
  • 如果检测到 MDIO 为低,说明总线上有活动,退避等待。
  • 如果两个 STA 同时开始驱动,通过监控 MDIO 实际电平来发现冲突。
  • 一旦某个 STA 试图拉低 MDIO 但实际读到高电平,说明有其他 STA 在拉高,冲突发生,该 STA 立即释放总线。
避坑指南:我曾经在一个项目里遇到双 CPU 共享 MDIO 总线的情况。两个 CPU 都没有做冲突检测,结果读数据经常莫名出错。抓波形发现,两个 CPU 同时发起了读操作,MDIO 线上波形完全乱掉了。后来给每个 CPU 加了总线占用检测,并在驱动前加随机退避,问题才解决。

我个人习惯在多 STA 共享总线的场景下,给每个 STA 分配不同的优先级,或者用软件握手来避免冲突。硬件仲裁虽然能用,但调试起来太痛苦了。

核心要点:总线仲裁的关键在于——先检测后驱动,冲突即退避。任何时候都不要假设总线是空闲的。

代码示例:MDIO 读写实现

下面是一个简化的 C 语言 MDIO 读写实现,重点展示了时序控制逻辑。实际项目中需要根据硬件平台调整 GPIO 操作和时序延迟。

// MDIO 读操作
uint16_t mdio_read(uint8_t phy_addr, uint8_t reg_addr) {
    uint16_t data = 0;
    int i;

    // 前导码:32 个 1
    for (i = 0; i < 32; i++) {
        mdio_out(1);
        mdc_pulse();
    }

    // 起始码 ST = 2'b01
    mdio_out(0); mdc_pulse();
    mdio_out(1); mdc_pulse();

    // 操作码 OP = 2'b10(读)
    mdio_out(1); mdc_pulse();
    mdio_out(0); mdc_pulse();

    // PHY 地址(5 位,高位在前)
    for (i = 4; i >= 0; i--) {
        mdio_out((phy_addr >> i) & 1);
        mdc_pulse();
    }

    // 寄存器地址(5 位,高位在前)
    for (i = 4; i >= 0; i--) {
        mdio_out((reg_addr >> i) & 1);
        mdc_pulse();
    }

    // TA 阶段:STA 释放总线,PHY 驱动
    mdio_input();           // 切换为输入
    mdc_pulse();            // 第一位:PHY 拉高
    if (mdio_read_pin() != 1) {
        return 0xFFFF;      // PHY 未应答
    }
    mdc_pulse();            // 第二位:PHY 拉低

    // 读取 16 位数据
    for (i = 15; i >= 0; i--) {
        mdc_pulse();        // MDC 上升沿前数据已稳定
        data |= (mdio_read_pin() << i);
        mdc_pulse();        // 完成一个完整的 MDC 周期
    }

    return data;
}

// MDIO 写操作
void mdio_write(uint8_t phy_addr, uint8_t reg_addr, uint16_t data) {
    int i;

    // 前导码:32 个 1
    for (i = 0; i < 32; i++) {
        mdio_out(1);
        mdc_pulse();
    }

    // 起始码 ST = 2'b01
    mdio_out(0); mdc_pulse();
    mdio_out(1); mdc_pulse();

    // 操作码 OP = 2'b01(写)
    mdio_out(0); mdc_pulse();
    mdio_out(1); mdc_pulse();

    // PHY 地址(5 位)
    for (i = 4; i >= 0; i--) {
        mdio_out((phy_addr >> i) & 1);
        mdc_pulse();
    }

    // 寄存器地址(5 位)
    for (i = 4; i >= 0; i--) {
        mdio_out((reg_addr >> i) & 1);
        mdc_pulse();
    }

    // TA 阶段:STA 继续驱动,发送 2'b10
    mdio_out(1); mdc_pulse();
    mdio_out(0); mdc_pulse();

    // 写入 16 位数据
    for (i = 15; i >= 0; i--) {
        mdio_out((data >> i) & 1);
        mdc_pulse();
        mdc_pulse();
    }
}

代码里读操作和写操作的结构基本一致,区别就在 OP 码和 TA 阶段的行为。读操作多了一个总线释放和应答检查,写操作则全程由 STA 驱动。嗯,这里要注意的是,实际硬件中 mdc_pulse() 的时序一定要满足 MDC 的频率要求——标准 MDIO 最高 2.5 MHz,别跑太快了。

调试建议:如果读回来全是 0xFFFF,优先检查 TA 阶段第一位的电平。用示波器抓 MDIO 线,看释放后是不是被 PHY 拉高了。没拉高?说明 PHY 根本没认你这个地址。