3. MDIO 通信帧结构:前导码、起始码、操作码、PHY 地址、寄存器地址、数据、空闲归纳总结

做 MDIO 读写的时候,我最开始就是被那一长串 0 和 1 搞懵的。后来踩过几次坑才明白,帧结构就像是 PHY 和 MAC 之间约定好的“暗号”,少一位或者多一位都能让通信乱套。这一节咱们就把这个帧掰开揉碎了讲清楚。

3.1 帧结构总览

MDIO 的每个读写操作都封装在一个固定格式的帧里。标准 IEEE 802.3 规范的帧长是 64 位,具体结构如下:

字段名称 位宽(bit) 值(读/写) 说明
PREAMBLE(前导码) 32 连续 32 个 '1' 同步时钟,建立通信
START(起始码) 2 00(帧开始标志) 标识真正帧的开始
OP(操作码) 2 01=写,10=读 指示本次操作类型
PHYAD(PHY 地址) 5 0x00 ~ 0x1F 选择目标 PHY 器件
REGAD(寄存器地址) 5 0x00 ~ 0x1F 选择 PHY 内部寄存器
TA(转向时间) 2 写: 10,读: Z0 地址与数据间的切换周期
DATA(数据) 16 寄存器值 读/写的数据内容
IDLE(空闲) ≥1 高阻(Z) 释放总线给下一帧

这张表我建议你直接截图放桌面。我每次写驱动时都拿它对照,省得记混。

3.2 前导码(PREAMBLE)—— 32 个 '1'

前导码就是一连串 32 个高电平脉冲。说白了,它在告诉 PHY:"注意了,我要开始发数据了。" 我刚开始做 FPGA 时,以为前导码可以随便缩短,结果发现有些 PHY 对前 32 位非常敏感,少一位都不认。后来我养成了习惯:不管 PHY 支不支持跳过前导码,我每次都老老实实发满 32 个 1。

我的小技巧: 某些 PHY 支持“省略前导码”模式,可以只发 32 位帧。但如果你在调试还不确定对方的支持情况,千万别省这一步。省出来的时间往往不够填坑。

3.3 起始码(START)—— 固定 '00'

前导码之后接着就是 2 位 '00'。这个不是操作码,就是单纯告诉 PHY:"前面那些 1 都是热身,现在开始才是正式内容。" 你想想看,如果没有这个标记,PHY 很容易把前导码的最后几位和操作码混淆。实际项目中,我在逻辑分析仪上看到有些控制器发的起始码是 '01',结果 PHY 直接忽略整帧。嗯,这里一定要注意,起始码必须严格是 '00',一个 bit 都不能错。

3.4 操作码(OP)—— '01' 写 / '10' 读

操作码只有 2 位,但它是整个帧的“指令”。

  • '01' = 写操作:MAC 向 PHY 寄存器写入数据。
  • '10' = 读操作:MAC 从 PHY 寄存器读取数据。

编码其实很简单。但我在项目中遇到过一个问题:我写了一个状态机,结果在发送读操作时错误地把 OP 码写成了 '11'。PHY 没有任何响应,我查了半天才发现是编码写反了。建议你在代码里用宏定义:#define MDIO_OP_WRITE 0b01 和 #define MDIO_OP_READ 0b10,这样一眼就能看出意图。

3.5 PHY 地址(PHYAD)与寄存器地址(REGAD)—— 各 5 位

PHY 地址用 5 位二进制,范围 0~31。每个 MDIO 总线上最多挂 32 个 PHY。寄存器地址同样 5 位,每个 PHY 内部有 32 个基本寄存器(0~31)。

地址顺序是先发 PHYAD 的高位(MSB),再发低位。我一直用左移操作来组织数据:

uint16_t mdiu_frame = (OP_WRITE << 28) | (phy_addr << 23) | (reg_addr << 18) | (data << 2);

但要注意,实际发送时需要串行逐位输出,上面的只是预组合。

避坑提示: 我曾经把 PHY 地址和寄存器地址搞反了顺序,结果对着 0x1F 寄存器写数据,实际却操作了 0x1E。调试了很久才发现是位顺序错了。建议写驱动时,先在纸上画一遍 bit 顺序再编码。

3.6 转向时间(TA)—— 关键的安全切换周期

这个字段特别容易被人忽略。TA 有 2 位:

  • 写操作: MAC 必须发送 '10'。因为写操作只需要单方向传输,MAC 一直驱动。
  • 读操作: MAC 先发送 'Z0'。第一个 bit 是高阻(Z),让 MAC 释放数据线;第二个 bit '0' 由 PHY 驱动,表示 PHY 已准备好输出数据。

实际波形上,读操作的 TA 位会出现一个“三态切换点”。如果驱动没有正确释放总线,PHY 和 MAC 同时驱动就会导致冲突。我亲测过,强行把 TA 写成 '00' 会导致读数据全部错乱。这是“MDIO 经典老坑”之一。

3.7 数据(DATA)—— 16 位寄存器内容

写操作时,MAC 在 TA 之后连续发送 16 位数据;读操作时,PHY 在 TA 的第二个 bit 之后开始发送 16 位数据。数据位顺序也是 MSB first。绝大多数 PHY 寄存器位宽是 16 位,但也有一些非标 PHY 用了更宽或更窄的寄存器,不过那属于特例,在此不展开。

关于数据拷贝,我习惯在驱动层准备一个 uint16_t 的数组,每次读写直接操作。这样可以减少位操作错误。

3.8 空闲(IDLE)—— 让你的总线喘口气

帧结束后,MAC 或 PHY 必须把 MDIO 线置为高阻态(Z)。空闲时间至少一个 MDC 时钟周期。少了这个空闲,下一帧的前导码可能被上一帧的残余信号污染。

我一直强调:不要在最后一拍之后立刻拉低时钟。我曾经吃过亏——急着发下一帧,空闲没给够,结果 PHY 把上一帧的数据当成下一帧的前导码,连续读错 3 个寄存器。最后在寄存器值里加了个延迟计数器才解决。

3.9 完整帧时序示例(写操作)

下面是一个写操作的真实时序示例(MDC 时钟未画出,只标数据线上的值):

MDIO: 11111111111111111111111111111111 00 01 00010 00101 10 1010101010101010 Z
       ↑ 前导码 32 个 1 ↑  ↑  ↑ PHY ↑  ↑  ↑  ↑  数据 16 位        ↑
                          ST OP ADDR  REG TA                             空闲

其中 PHY 地址 = 00010(0x02),寄存器地址 = 00101(0x05),数据 = 0xAAAA。

3.10 知识结构图

MDIO 帧结构(64位) PREAMBLE 32位 '1' ST OP PHYAD REGAD TA DATA 16位 IDLE 同步阶段 地址/控制阶段 数据阶段 释放总线 写操作 MDIO MAC 驱动全程 TA = 10 读操作 MDIO MAC 驱动至 TA 第1位 TA = Z0,PHY 驱动数据

3.11 常见坑点与个人心得

我曾经犯过的错: 读操作时忘记释放总线。我写了一个状态机,在 TA 第一个 bit 时没有把 MDIO 设为高阻,结果 MAC 继续拉低,PHY 看到的是 '00',认定是写操作,于是不送数据。导致读回来的数据全是 0xFF。排查了整整一个下午才意识到是总线冲突。

另外,PHY 地址通常由外部上拉/下拉电阻设定。有些开发板把 PHY 地址设为 0x01,但代码里写死成 0x00,结果读不到任何数据。我建议在驱动初始化时先发一个空读帧(读寄存器 0x00),然后检查返回数据是否为 0xFFFF(未连接)或 0x3100(常见 PHY ID 低 16 位)来判断 PHY 是否存在。

调试小神器: 用逻辑分析仪抓 MDIO 和 MDC 波形,一眼就能看出帧结构哪里不对。我在项目里常把触发条件设为 START 位,然后看 OP 码和地址是否正确。基本 5 分钟能定位 80% 的问题。

最后提一句,空闲位的长度没有严格上限,但建议至少给 1 个 MDC 周期。我在写 FPGA 驱动时,会在帧尾插入一个计数状态,延时 5 个时钟后再开始下一帧。这样既留有裕量,又不会影响吞吐。