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 就知道:“哦,老大要读我的数据了。”
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”呢?因为不需要换方向,总线一直由我占用。
4.3 Turnaround 细节:最容易踩坑的地方
重点来了。Turnaround,我习惯叫它“总线周转时间”,简写 TA。这是 MDIO 协议里最微妙的地方。
为什么需要 TA?因为是半双工总线。MDIO 只有一根数据线(MDIO)和一根时钟线(MDC)。读的时候,前 32 个时钟我说,后 32 个时钟你说。中间交接那一下,要是大家同时往总线上怼数据,那就短路了。
协议规定:
- 读操作 TA: 第一个时钟周期(TA1),控制器必须把 MDIO 拉高(Z 状态释放),变成输入高阻。第二个时钟周期(TA2),由 PHY 将 MDIO 拉低(驱动为 0),然后再发送数据。
- 写操作 TA: 不需要交换总线所有权,所以 TA1 和 TA2 都由控制器驱动,输出 10 即可。
你想想看,如果 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 个时钟顺序?看这张图就够了。
这张图很直观。读操作在 TA 那里有个“交接”,写操作则一路顺风。记住这个,调试的时候你就知道问题可能出在哪个阶段了。
4.6 关于 IDLE 状态
还有个细节。每帧结束后,MDIO 应该回到 IDLE 状态。协议要求是“高阻”,或者你可以主动拉高。我个人习惯在帧结束后把 MDIO 拉高,同时保持输出方向。这样下一帧的 PREAMBLE 第一个 1 就能直接无缝衔接,不用额外切方向。很多现成的 IP Core 也是这么做的。
但有一点要注意:如果你拉高,必须保证这个“高”电平不会被 PHY 的低电平干扰。正常情况都不会,除非你选的 PHY 支持“Inter-Frame Gap”控制,那种情况极少,这里先不展开。
这些时序细节,你在代码里写一遍,再用示波器抓一遍,基本就一劳永逸了。嗯,咱们这节就到这儿。