4. MDIO 读写时序分析:帧格式与 Turnaround 细节

好,咱们接着聊。搞明白 MDIO 的帧结构,你才算真正入了门。很多朋友拿着 PHY 芯片手册,看那时序图跟看天书似的。其实没那么复杂。我当年刚开始调的时候,也差点被那“Turnaround”位给绕晕了。今天咱们就把这层窗户纸捅破。

4.1 读操作帧格式:谁在说话?

先看读操作。MDIO 是双向总线,你得搞清楚什么时候我在说,什么时候 PHY 在说。

一个完整的读帧是 64 个时钟周期。里面分两大段:前 32 个时钟是控制器(也就是咱们的 MAC 或 CPU)在发号施令。后 32 个时钟是 PHY 芯片在回话。

位域 宽度 (bit) 方向 值(读操作) 用途
PREAMBLE 32 控制器→PHY 全 1 同步,让 PHY 准备好接收
ST 2 控制器→PHY 0b10 帧起始,读操作专用
OP 2 控制器→PHY 0b10 操作码,明确告诉 PHY:“我要读”
PHYAD 5 控制器→PHY [4:0] 寻址哪个 PHY
REGAD 5 控制器→PHY [4:0] 读哪个寄存器
TA 2 高阻转输出 Z0 → D1 总线周转(Turnaround)
DATA 16 PHY→控制器 [15:0] 读回来的寄存器值
IDLE - 高阻 高阻 三态,交给下一帧

看明白了吗?读操作里,前 32 位全是“我”在发。PREAMBLE 是连续 32 个 1,说白了就是给 PHY 热身的。然后 ST 是 0b10,OP 也是 0b10。这两个组合在一起,PHY 就知道:“哦,老大要读我的数据了。”

一个小经验:我刚开始写驱动时,总把 ST 和 OP 弄混。记住就行:ST 永远等于 0b10(不管是读是写),读操作 OP 是 0b10,写操作是 0b01。这两个别搞反了。

4.2 写操作帧格式:全程都是你在控场

写操作就简单一些了。因为数据方向从头到尾都是控制器在发,PHY 只是个“听众”。

位域 宽度 (bit) 方向 值(写操作) 用途
PREAMBLE 32 控制器→PHY 全 1 同步
ST 2 控制器→PHY 0b10 帧起始
OP 2 控制器→PHY 0b01 操作码,告诉 PHY:“我要写”
PHYAD 5 控制器→PHY [4:0] 哪个 PHY
REGAD 5 控制器→PHY [4:0] 哪个寄存器
TA 2 控制器→PHY 10 写操作无周转,直接发送数据
DATA 16 控制器→PHY [15:0] 你要写入的值
IDLE - 高阻 高阻 三态

写操作里,你会发现 TA 位是 0b10。为什么不是“Z0”呢?因为不需要换方向,总线一直由我占用。

注意:很多新手会忽略写操作帧的 TA 位,也写成 Z0。这在大多数 PHY 上可能“碰巧”能用,但严格来说是错误的,而且兼容性很差。我遇到过一款比较老的 PHY,TA 位必须是严格的 10,否则它就不认这笔写操作。

4.3 Turnaround 细节:最容易踩坑的地方

重点来了。Turnaround,我习惯叫它“总线周转时间”,简写 TA。这是 MDIO 协议里最微妙的地方。

为什么需要 TA?因为是半双工总线。MDIO 只有一根数据线(MDIO)和一根时钟线(MDC)。读的时候,前 32 个时钟我说,后 32 个时钟你说。中间交接那一下,要是大家同时往总线上怼数据,那就短路了。

协议规定:

  • 读操作 TA: 第一个时钟周期(TA1),控制器必须把 MDIO 拉高(Z 状态释放),变成输入高阻。第二个时钟周期(TA2),由 PHY 将 MDIO 拉低(驱动为 0),然后再发送数据。
  • 写操作 TA: 不需要交换总线所有权,所以 TA1 和 TA2 都由控制器驱动,输出 10 即可。
避坑指南: 我曾经在一个项目里,PHY 芯片死活读不回数据。示波器一抓,发现是 TA 位时序不对。我当时写的驱动在 TA1 时刻直接把 MDIO 输出 0 了,这等于把总线抢在自己手里,PHY 根本没机会说话。改成高阻后,问题立刻解决。切记:读操作的 TA1,必须设置为输入(高阻),而不是输出 0。

你想想看,如果 TA1 时刻你输出 0,PHY 在 TA2 时刻也输出 0,那没问题,总线是 0。但如果 PHY 在 TA2 时刻想输出 1,你这边还在强拉 0,那就冲突了。轻则数据错乱,重则可能损坏驱动电路。

4.4 一个典型读操作时序示例

为了让你更清楚,我给你写一段伪代码,模拟读取 PHY 地址 0x03,寄存器 0x12 的过程。

// 假设已有函数:mdio_set_mdc() 设置时钟
// mdio_set_data(val) 输出数据
// mdio_get_data() 读取数据
// mdio_set_dir(output/input) 控制方向

void mdio_read_phy(uint8_t phy_addr, uint8_t reg_addr) {
    uint16_t data = 0;
    int i;

    // 1. PREAMBLE: 32个1
    for (i = 0; i < 32; i++) {
        mdio_set_mdc(0);
        mdio_set_data(1);
        mdio_set_mdc(1);
    }

    // 2. ST: 0b10
    for (i = 0; i < 2; i++) {
        mdio_set_mdc(0);
        mdio_set_data( (i == 0) ? 1 : 0 );
        mdio_set_mdc(1);
    }

    // 3. OP: 0b10 (读操作)
    for (i = 0; i < 2; i++) {
        mdio_set_mdc(0);
        mdio_set_data( (i == 0) ? 1 : 0 );
        mdio_set_mdc(1);
    }

    // 4. PHYAD: 5位, 假设地址0x03 = 00011
    for (i = 4; i >= 0; i--) {
        mdio_set_mdc(0);
        mdio_set_data( (phy_addr >> i) & 1 );
        mdio_set_mdc(1);
    }

    // 5. REGAD: 5位, 假设寄存器0x12 = 10010
    for (i = 4; i >= 0; i--) {
        mdio_set_mdc(0);
        mdio_set_data( (reg_addr >> i) & 1 );
        mdio_set_mdc(1);
    }

    // ===== 关键:Turnaround =====
    // TA1: 释放总线,换成输入
    mdio_set_mdc(0);
    mdio_set_dir(INPUT);   // 这里是关键!改成高阻输入
    mdio_set_mdc(1);       // 此时MDIO被PHY的上拉或下拉驱动?不,PHY会在TA2才驱动
    
    // TA2: PHY将MDIO拉为0
    mdio_set_mdc(0);       // PHY会在这个时钟沿之后驱动MDIO为0
    mdio_set_mdc(1);       // 你不需要做任何事,只需读

    // 6. DATA: 16位数据,从PHY读回
    for (i = 15; i >= 0; i--) {
        mdio_set_mdc(0);
        // 在时钟低电平时采样数据
        data |= (mdio_get_data() << i);
        mdio_set_mdc(1);
    }

    // 7. IDLE: 释放总线
    mdio_set_dir(OUTPUT);
    mdio_set_data(1); // 或者保持高阻,取决于后续操作

    return data;
}

看到 TA1 和 TA2 那两行注释了吗?这就是成败的分水岭。

4.5 时序核心逻辑图

下面这张图把读写流程一次性给你理清楚。记不住 64 个时钟顺序?看这张图就够了。

MDIO 读写操作帧结构对比图(64 时钟周期) 读操作 控制器→PHY:PREAMBLE(32) + ST(2) + OP(2) + PHYAD(5) + REGAD(5) = 46个时钟(前46个时钟) TA 2个时钟 PHY→控制器 DATA(16) = 16个时钟 TA1: 高阻 TA2: PHY拉低 写操作 控制器→PHY:PREAMBLE(32) + ST(2) + OP(2) + PHYAD(5) + REGAD(5) + TA(2) + DATA(16) = 64个时钟(全程控制器驱动) TA1: 输出1 TA2: 输出0 核心区别:读操作在TA处交换总线控制权,写操作全程由控制器驱动MDIO

这张图很直观。读操作在 TA 那里有个“交接”,写操作则一路顺风。记住这个,调试的时候你就知道问题可能出在哪个阶段了。

4.6 关于 IDLE 状态

还有个细节。每帧结束后,MDIO 应该回到 IDLE 状态。协议要求是“高阻”,或者你可以主动拉高。我个人习惯在帧结束后把 MDIO 拉高,同时保持输出方向。这样下一帧的 PREAMBLE 第一个 1 就能直接无缝衔接,不用额外切方向。很多现成的 IP Core 也是这么做的。

但有一点要注意:如果你拉高,必须保证这个“高”电平不会被 PHY 的低电平干扰。正常情况都不会,除非你选的 PHY 支持“Inter-Frame Gap”控制,那种情况极少,这里先不展开。

这些时序细节,你在代码里写一遍,再用示波器抓一遍,基本就一劳永逸了。嗯,咱们这节就到这儿。