简介:以普通GPIO模拟MDIO时序的嵌入式驱动源码,面向需要在不支持硬件MDIO接口的MCU上配置以太网PHY芯片的开发者。代码基于STM32实现,覆盖MDC时钟生成、MDI双向数据收发,并提供mdio_start、mdio_write_data、mdio_read_data、mdio_stop等函数,封装成寄存器读写接口,可用于查询链路状态、切换工作模式或识别PHY型号;驱动内部实现了半双工通信的起始、数据与停止时序,按帧格式完成PHY寄存器读写,便于接入现有工程。压缩包共3个文件,包含C源文件、头文件和说明文档,整体仅3KB,结构精简,便于直接移植和学习参考;内容还梳理了MDIO帧格式、时序要点和地址位说明,可帮助开发者快速理解协议底层逻辑,并在实际项目中自行调整IO口和时钟配置。目前已有1786人学习/下载,适合嵌入式网络应用开发、驱动调试及STM32进阶学习等场景。 做嵌入式驱动这几年,我碰到的PHY调试问题里,有相当一部分得靠读寄存器才能定位。但偏偏手头这块MCU没有MDIO控制器,或者硬件MDIO引脚被别的功能占了,又或者干脆就是想快速验证一下PHY芯片有没有正常起来。这个时候,用两个GPIO模拟MDIO时序去读写PHY寄存器,就成了最直接、最不吃资源的手段。这篇文章就专门聊这个做法,从协议细节到完整代码,再到我踩过的坑,一次性说清楚。适用对象是正在调PHY、想弄懂MDIO时序、或者手头MCU没有硬件MDIO外设的人。
1. 先搞明白MDIO在干什么
1.1 两根线怎么管住一个PHY
MDIO(Management Data Input/Output)是IEEE 802.3 Clause 22定义的管理接口,作用就是让MAC侧芯片能够读写PHY芯片里的寄存器。它只有两根线:MDC(管理时钟)和MDIO(管理数据)。
MDC是时钟线,由MAC或MCU单向输出;MDIO是数据线,双向传输,读写共用这一根。PHY芯片的地址通过硬件引脚(通常是PHYAD[4:0])设定,一个MDIO总线上最多挂32个PHY,每个PHY内部有32个16位寄存器。
MDIO时序的本质很简单:数据在MDC上升沿被采样,MDIO线上的电平必须满足建立/保持时间。GPIO模拟的思路,就是手动拉高拉低MDC,在正确的时刻把MDIO线设置成正确的电平,或者读取它的电平。
1.2 帧格式拆开看
MDIO读和写都是固定帧结构,读写帧长度不同。字段顺序如下:
| 字段 | 位数 | 读操作 | 写操作 |
|---|---|---|---|
| 前导码 Preamble | 32 | 连续32个1 | 连续32个1 |
| 帧起始 ST | 2 | 01 | 01 |
| 操作码 OP | 2 | 10(读) | 01(写) |
| PHY地址 PHYAD | 5 | 目标PHY地址 | 目标PHY地址 |
| 寄存器地址 REGAD | 5 | 目标寄存器地址 | 目标寄存器地址 |
| 转换状态 TA | 2 | 第1位高阻,第2位由PHY驱动为0 | 10 |
| 数据 DATA | 16 | PHY返回寄存器值 | MCU写入寄存器值 |
有几个容易忽略的细节:
- Preamble是32个连续的1,这个不能省。PHY需要通过这个前导序列完成同步。
- ST固定是01,不是10也不是11,这两个bit错了PHY根本不理会。
- 读操作的时候,TA的第1位MCU要释放总线(输出高阻或切换输入),第2位由PHY驱动输出0。从这一位开始,总线方向从MCU转到了PHY。
- 写操作的TA是10,依然是MCU驱动。
- 整个帧从Preamble到TA的所有bit都是最高位在前,数据域也是先发最高位。
这些细节直接决定代码怎么写,尤其是读操作的时序切换点,很多初学者在这里翻车。
1.3 时序里最重要的那句话
MDC上升沿采样,数据在上升沿之前准备好,在下降沿之后可以变化。这句话是GPIO模拟MDIO的核心。
对应到代码上,就是:
- 写bit:先把MDIO设为目标电平,再把MDC拉高,延时,再拉低MDC,延时。数据是在上升沿之前就稳定了。
- 读bit:先确保MDIO是输入方向,把MDC拉高,延时,读MDIO电平,再把MDC拉低。读取动作放在上升沿之后、下降沿之前完成。
2. 为什么不直接用硬件MDIO
2.1 硬件方案的局限性
很多MCU内置了MDIO控制器,但实际使用中会遇到几个问题:
- 引脚被复用:比如MDIO和某个以太网RMII引脚、UART引脚冲突,硬件板子已经定型改不了。
- MCU太便宜:低成本8位或32位MCU压根没有MDIO外设,或者有但没引出来。
- 调试阶段临时用:系统已经有主控在管理PHY,我想从另一个MCU读同一个PHY的寄存器做诊断,直接飞两根线过来,不想动主控代码。
GPIO模拟最大的优势就是只要两个引脚,任何MCU都能做,不用改硬件,不用等新的芯片版本。
2.2 模拟方案的时钟怎么定
MDC的规格上限是2.5MHz(周期400ns),但GPIO模拟不用追求这个上限。延时越小,读出来的数据越容易受到线路电容、上拉电阻、PHY响应速度的影响。
我自己的经验是:半周期用1us左右,一个完整周期2us,也就是MDC频率约500kHz。这个速度在GPIO模拟下很稳,而且不需要用定时器中断,一个忙等的delay函数就够。如果GPIO连的杜邦线比较长,或者环境干扰大,把半周期拉到5us甚至10us,照样工作,只是慢一点。
读寄存器一次需要约64个bit周期(32个preamble + 14个控制 + 2个TA + 16个数据),按2us一个周期算,大约128us,完全可以在驱动初始化阶段同步完成,不用考虑性能问题。
2.3 代码架构怎么分层
GPIO模拟MDIO的代码,我建议分两层:
- 硬件抽象层:负责具体的GPIO操作,包括MDC拉高拉低、MDIO输出高低电平、MDIO方向切换、读取MDIO输入电平、微秒级延时。
- 协议层:实现MDIO帧格式,包括写bit、读bit、读寄存器、写寄存器。
这样做的好处是移植到不同MCU时,只需要改硬件抽象层几个函数,协议层代码一行不用动。我现在给项目里写的这套结构,基本上换个平台就改6个函数。
3. 完整代码实现
3.1 硬件抽象层封装
先定义一个配置结构体,把GPIO操作和延时函数都作为函数指针放进去,方便不同的平台直接传入对应实现。
typedef struct { void (*mdc_set)(uint8_t level); // 设置MDC电平,1高0低 void (*mdio_set)(uint8_t level); // 设置MDIO输出电平,1高0低 void (*mdio_set_dir_out)(void); // MDIO切为输出 void (*mdio_set_dir_in)(void); // MDIO切为输入 uint8_t (*mdio_get)(void); // 读取MDIO输入电平 void (*delay_us)(uint32_t us); // 微秒级延时 } mdio_gpio_cfg_t;这个结构体是整个模块的核心。注意MDIO方向切换这个函数一定要有,因为读操作需要释放总线,直接切输入是最简单的释放方式。
3.2 底层bit级操作
写一个bit和读一个bit是最基本单元,务必写对。
static void mdio_write_bit(const mdio_gpio_cfg_t *cfg, uint8_t bit) { // 数据在MDC上升沿之前准备好 cfg->mdio_set(bit ? 1 : 0); cfg->delay_us(1); cfg->mdc_set(1); cfg->delay_us(1); cfg->mdc_set(0); cfg->mdio_set(1); // 空闲时释放输出,靠上拉保持高 } static uint8_t mdio_read_bit(const mdio_gpio_cfg_t *cfg) { uint8_t bit; // 确保输入方向,在上升沿之后采样 cfg->mdio_set_dir_in(); cfg->mdc_set(1); cfg->delay_us(1); bit = cfg->mdio_get(); cfg->mdc_set(0); cfg->delay_us(1); return bit; }写bit之后把MDIO输出置1,是为了让总线空闲回到高电平。MDIO是开漏加外部上拉的电气规范,读阶段总线释放后靠上拉维持高,空闲也不能让输出为0,否则后续PHY驱动数据时可能打架。
3.3 读寄存器实现
读时序的关键,在于TA第1位释放总线后,第2位开始读取方向由PHY主导。
static uint8_t mdio_read_reg_raw(const mdio_gpio_cfg_t *cfg, uint8_t phy_addr, uint8_t reg_addr) { int i; uint8_t bit; uint16_t value = 0; // 前导码:32个1 for (i = 0; i < 32; i++) { mdio_write_bit(cfg, 1); } // ST: 01 mdio_write_bit(cfg, 0); mdio_write_bit(cfg, 1); // OP: 10(读) mdio_write_bit(cfg, 1); mdio_write_bit(cfg, 0); // PHYAD: 5位,高位在前 for (i = 4; i >= 0; i--) { mdio_write_bit(cfg, (phy_addr >> i) & 0x01); } // REGAD: 5位,高位在前 for (i = 4; i >= 0; i--) { mdio_write_bit(cfg, (reg_addr >> i) & 0x01); } // TA第1位:释放总线(写一个1,但实际是Z态) mdio_write_bit(cfg, 1); // TA第2位:PHY驱动为0,读上来应该是0 mdio_read_bit(cfg); // 16位数据,高位在前 for (i = 0; i < 16; i++) { bit = mdio_read_bit(cfg); value = (value << 1) | bit; } // 读取完毕后,把MDIO切回输出,避免影响后续写操作 cfg->mdio_set_dir_out(); cfg->mdio_set(1); cfg->mdc_set(0); return value; }读寄存器完成后,MDIO切回输出状态这一行很多人会漏。如果漏了,下一次写操作时MDIO还是输入方向,写出去的bit全部无效,表现就是写寄存器没反应、读回来还是旧值。
3.4 写寄存器实现
写时序相对简单,全程MCU驱动数据线。
static void mdio_write_reg_raw(const mdio_gpio_cfg_t *cfg, uint8_t phy_addr, uint8_t reg_addr, uint16_t data) { int i; // 确保MDIO是输出方向 cfg->mdio_set_dir_out(); // 前导码:32个1 for (i = 0; i < 32; i++) { mdio_write_bit(cfg, 1); } // ST: 01 mdio_write_bit(cfg, 0); mdio_write_bit(cfg, 1); // OP: 01(写) mdio_write_bit(cfg, 0); mdio_write_bit(cfg, 1); // PHYAD: 5位,高位在前 for (i = 4; i >= 0; i--) { mdio_write_bit(cfg, (phy_addr >> i) & 0x01); } // REGAD: 5位,高位在前 for (i = 4; i >= 0; i--) { mdio_write_bit(cfg, (reg_addr >> i) & 0x01); } // TA: 10 mdio_write_bit(cfg, 1); mdio_write_bit(cfg, 0); // DATA: 16位,高位在前 for (i = 15; i >= 0; i--) { mdio_write_bit(cfg, (data >> i) & 0x01); } // 总线回到空闲状态 cfg->mdio_set(1); }3.5 上层封装与自检
实际项目里我习惯再包一层,可以增加重试机制,提高稳定性。
uint16_t mdio_read_reg(const mdio_gpio_cfg_t *cfg, uint8_t phy_addr, uint8_t reg_addr) { uint16_t val = 0; int retry = 3; while (retry--) { val = mdio_read_reg_raw(cfg, phy_addr, reg_addr); // 简单校验:读取结果至少不应是0xFFFF或0x0000 // 具体校验规则可以根据寄存器语义而定 if (val != 0xFFFF && val != 0x0000) { break; } } return val; } void mdio_write_reg(const mdio_gpio_cfg_t *cfg, uint8_t phy_addr, uint8_t reg_addr, uint16_t data) { mdio_write_reg_raw(cfg, phy_addr, reg_addr, data); // 写完回读比对,确认写成功 uint16_t check = mdio_read_reg(cfg, phy_addr, reg_addr); if (check != data) { // 这里可以根据项目需要打印错误信息或做其他处理 } }写完寄存器回读校验这一步,是我后来每次都加的。特别是在调试硬件阶段,写失败了不一定立刻表现出来,等后续link不上才去查,浪费半天时间。回读校验能尽早暴露问题。
3.6 PHY ID自检
拿到一个新板子,我第一件事就是读PHY ID。PHY ID存在寄存器2(PHYIDR1)和寄存器3(PHYIDR2)里,每个芯片厂商的ID是固定的。比如瑞昱RTL8211F的ID是0x001CC916,裕太微YT8512的ID是0x00000001。
读PHY ID的正确操作:
uint16_t id1 = mdio_read_reg(&cfg, phy_addr, 2); uint16_t id2 = mdio_read_reg(&cfg, phy_addr, 3);如果读出来的ID和datasheet对得上,说明MDIO模拟时序没问题,总线没问题,PHY供电和复位也没问题。如果对不上,先别急着查网络变压器和link,先解决总线问题。
4. 常见问题与排查技巧
4.1 读出来全是0xFFFF
这是最常见的情况。0xFFFF意味着MDIO电平一直是高,有几种可能:
- MDIO没有接上拉电阻。读操作释放总线后,MDIO线悬空或下拉,读到高就是0xFFFF的内容分布。检查硬件,MDIO上拉电阻推荐2.2k到4.7k,接PHY侧IO电源域。
- MDIO配置成了推挽输出,没有真正切入输入方向。用示波器或万用表量MDIO电平,如果一直稳定在高,且没有看到微弱变化,多半是方向没切成功。
- PHY芯片没有正常工作。可能是复位脚没释放、电源不对、晶振没起振。先查PHY供电和复位时序。
- PHY地址写错了。PHY地址由硬件引脚决定,常见的是0x00和0x01。可以写一个小循环,从0到31地址遍历读寄存器2,找ID值不是0xFFFF和0x0000的那个地址。
4.2 读出来全是0x0000
0x0000一般是总线被强行拉低或者时序错乱。可能原因:
- MDC和MDIO两根线接反了。这是最蠢但也最容易犯的错,先量一下PCB网络,确认哪根是MDC哪根是MDIO。
- PHY芯片要求MDC空闲为高,而代码里空闲为低。查PHY规格书,有些PHY要求空闲时MDC为低,有些则无关,但为了通用,我统一在空闲时保持MDC低。
- 前导码或ST字段发错了。用逻辑分析仪抓一次实际波形,对比标准帧格式,重点看ST是不是01,OP是不是10。
- PHY的MDIO引脚对时序要求更严格,比如建立时间较长。尝试把半周期延时从1us加大到5us,看数据是否变化。
4.3 PHY link不上的排查思路
这也是搜热词里很常见的一个问题。GPIO模拟MDIO本身不会导致link失败,但如果你发现MDIO能读到正确ID,而PHY就是link不上,那问题多半出在物理层或PHY配置上,排查顺序建议是:
| 排查项 | 操作 | 预期结果 |
|---|---|---|
| 读寄存器1(BMSR)bit2 | 链接状态 | 协商成功应读到1 |
| 读寄存器1(BMSR)bit5 | 自动协商完成 | 协商完成应读到1 |
| 读寄存器0(BMCR)bit12 | 自动协商使能 | 应为1,否则置1 |
| 检查网络变压器 | 中心抽头、共模电感 | 无明显短路断路 |
| 检查RJ45到PHY的差分线 | 阻抗、长度匹配 | 正常 |
| 对端设备 | 是否支持协商 | 确认对端状态 |
MCU这边通过模拟MDIO能读到ID,说明MCU到PHY的管理通道是通的。如果寄存器1的bit2始终为0,问题一定在PHY之外——变压器、RJ45、网线、对端。如果bit2为1但业务不通,那是数据通路的问题,和MDIO没关系。
4.4 MDIO读到的数值不稳定
偶发性错误最具迷惑性。数值时对时错,排查几个方向:
- 上拉电阻过大。MDIO是开漏结构,上拉太大导致上升沿变缓,高电平采不到。建议不超过4.7k。
- 走线过长。MDC和MDIO走线尽量短,远离时钟线、电源线。调试时用杜邦线,长度超过20cm就容易出问题,这时候把延时加大到10us能缓解,但治标不治本。
- 延时函数不准确。特别是依赖编译优化级别的NOP循环,换优化等级时序就变了。建议用定时器或DWT实现微秒延时。
- GPIO的IO等级配置问题。有的MCU需要把GPIO配置为数字功能,同时关闭模拟输入、关闭上下拉,否则读到的电平不对。
4.5 一个容易被坑的细节:TA位切换时机
读操作的TA第1位和第2位,切换时机非常关键。我见过有人写的代码,在发完REGAD后直接切输入,然后连续读18个bit(2个TA加16个数据)。这样做在某些PHY上能用,但不符合标准,而且PHY在第1个TA位时可能还在驱动总线,和MCU的输出冲突,导致波形失真。
正确做法是:TA第1位仍然以输出方式发高电平(实际总线放Z态),第2位再切输入。也就是说,整个帧的控制部分发完后,第一次切输入的位置,是从TA第2位开始的。这样PHY有充足的时间接管总线。
5. 实操心得与扩展建议
代码跑通之后,你会发现这套GPIO模拟方案在实际工程里非常香。我现在手头调试带PHY的板子,几乎不依赖硬件MDIO控制器,两根GPIO加一个逻辑分析仪就能把PHY所有寄存器读得明明白白,调试效率一点不低。
几个实践经验分享:
- 移植到新平台时,先把delay_us做准,用示波器量一下MDC频率。MDC波形固定、频率稳定,其他逻辑才有保障。
- MDIO总线空闲电平是高,初始化时务必把MDIO设为输出高,MDC设为输出低,再开始第一次操作。
- 某些PHY在上电后需要等待一段时间才能响应MDIO,比如100ms到200ms。MCU上电后不要马上读,先延时200ms再操作。
- 如果项目里需要频繁轮询PHY状态(比如链路断开重连检测),GPIO模拟的慢速会成为一个问题。可以考虑用定时器中断的方式来产生MDC,把主循环解放出来,但帧控制和中断之间的配合要处理好。
这个方案后续还可以扩展:比如把MDIO读到的PHY寄存器值通过串口输出,做成一个小工具;或者写一个扫描程序,自动识别总线上挂的PHY地址和ID,批量排查产线问题。只要协议层代码稳定,底层换什么MCU都不怕。
本文还有配套的精品资源,点击获取