GPIO模拟MDIO时序读写PHY寄存器:从协议到代码实战
简介以普通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 两根线怎么管住一个PHYMDIOManagement 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读和写都是固定帧结构读写帧长度不同。字段顺序如下字段位数读操作写操作前导码 Preamble32连续32个1连续32个1帧起始 ST20101操作码 OP210读01写PHY地址 PHYAD5目标PHY地址目标PHY地址寄存器地址 REGAD5目标寄存器地址目标寄存器地址转换状态 TA2第1位高阻第2位由PHY驱动为010数据 DATA16PHY返回寄存器值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. 为什么不直接用硬件MDIO2.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存在寄存器2PHYIDR1和寄存器3PHYIDR2里每个芯片厂商的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 读出来全是0x00000x0000一般是总线被强行拉低或者时序错乱。可能原因MDC和MDIO两根线接反了。这是最蠢但也最容易犯的错先量一下PCB网络确认哪根是MDC哪根是MDIO。PHY芯片要求MDC空闲为高而代码里空闲为低。查PHY规格书有些PHY要求空闲时MDC为低有些则无关但为了通用我统一在空闲时保持MDC低。前导码或ST字段发错了。用逻辑分析仪抓一次实际波形对比标准帧格式重点看ST是不是01OP是不是10。PHY的MDIO引脚对时序要求更严格比如建立时间较长。尝试把半周期延时从1us加大到5us看数据是否变化。4.3 PHY link不上的排查思路这也是搜热词里很常见的一个问题。GPIO模拟MDIO本身不会导致link失败但如果你发现MDIO能读到正确ID而PHY就是link不上那问题多半出在物理层或PHY配置上排查顺序建议是排查项操作预期结果读寄存器1BMSRbit2链接状态协商成功应读到1读寄存器1BMSRbit5自动协商完成协商完成应读到1读寄存器0BMCRbit12自动协商使能应为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个bit2个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都不怕。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →