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。
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);
但要注意,实际发送时需要串行逐位输出,上面的只是预组合。
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 知识结构图
3.11 常见坑点与个人心得
另外,PHY 地址通常由外部上拉/下拉电阻设定。有些开发板把 PHY 地址设为 0x01,但代码里写死成 0x00,结果读不到任何数据。我建议在驱动初始化时先发一个空读帧(读寄存器 0x00),然后检查返回数据是否为 0xFFFF(未连接)或 0x3100(常见 PHY ID 低 16 位)来判断 PHY 是否存在。
最后提一句,空闲位的长度没有严格上限,但建议至少给 1 个 MDC 周期。我在写 FPGA 驱动时,会在帧尾插入一个计数状态,延时 5 个时钟后再开始下一帧。这样既留有裕量,又不会影响吞吐。