简介:面向VxWorks嵌入式开发者的GPIO模拟MDC/MDIO驱动代码,用于系统缺少专用SMI接口时通过普通GPIO实现以太网PHY芯片管理寄存器的读写,解决BSP移植或驱动适配中的实际痛点。压缩包为RAR格式,仅包含一个C语言源文件,大小约2KB,代码虽短却覆盖了GPIO输入输出方向切换、2.5MHz MDC时钟的软定时生成、MDIO上升沿写入与下降沿读取等关键时序逻辑。目前已有1556人浏览学习,代码简洁清晰,成为不少开发者学习软件模拟MDIO协议的参考。通过研究代码,读者既能理解SMI总线帧结构中起始位、5位命令地址与16位数据位的传输过程,也能掌握在VxWorks任务环境下使用延时函数控制微秒级时钟边沿的处理思路。这份驱动代码可直接移植到不具备MDC/MDIO控制器的硬件平台,作为PHY寄存器读写的基础模块,具有一定工程复用价值。 拿到这个gpio模拟的mdc mdio驱动代码工程的时候,我刚从一堆PHY寄存器调试里爬出来。如果你也在做带以太网的嵌入式项目,大概率遇到过同样的情况:主控芯片没有MDIO控制器,或者MDIO引脚被别的功能占用了,可网口又是必须调通的硬指标。GPIO模拟MDC/MDIO就是在这种夹缝里最常见的解法。这篇博文,我把自己在STM32F103上用标准库从零调通GPIO模拟MDC/MDIO的完整过程、关键代码和踩坑记录整理出来,给正在跟PHY握手较劲的同学一个可以直接抄作业的参考。
1. 为什么放着现成的MDIO控制器不用,非要GPIO模拟
1.1 哪些场景下GPIO模拟是刚需
很多人一上来就质疑:现在的MCU不都内置MAC控制器吗?直接调MDIO控制器不就行了?理论上是这样,但实际项目里没这么理想。我遇到过的场景大概有三类。
第一类是主控压根没有MAC外设。比如你用STM32F103挂一个SPI转以太网模块还好说,但如果外接的是裸PHY(比如LAN8720、DP83848),F103的RMII接口支持有限,MDIO控制器更是没有,只能靠GPIO模拟。
第二类是引脚冲突。有些芯片的MDIO引脚和调试口、PWM输出、ADC采样通道复用,硬件已经画到别的功能上去了。改板子代价太大,软件模拟成了唯一选择。第三类是前期调试阶段,硬件还没完全定型,想要快速验证PHY是否能正常工作,先拉两根GPIO把链路打通,后面再切换回硬件MDIO控制器。
还有一点容易被忽略:硬件MDIO控制器访问时序是固定的,有些PHY对时序要求比较特殊,控制器配置不灵活。GPIO模拟虽然在速度上吃亏,但时序完全可控,遇到兼容性问题时反而更容易排查。所以这个"gpio模拟的mdc mdio驱动代码"看似取巧,实际上在工业级开发里是很有生命力的方案。
1.2 MDC/MDIO总线的通信协议
要写出正确的驱动,得先搞清楚MDC/MDIO到底是怎样的通信机制。MDIO(Management Data Input/Output)是IEEE 802.3标准定义的管理接口,用来读写PHY芯片内部的寄存器。它总共只有两根线:MDC是管理时钟,由主机产生,最高支持2.5MHz;MDIO是双向数据线,主机和PHY分时驱动。
整个访问过程是串行帧结构,一个完整帧有64个时钟周期,依次是:前导码(32个逻辑1)、起始码ST(2位,固定01)、操作码OP(2位,读为10,写为01)、PHY地址(5位,最多支持32个PHY)、寄存器地址(5位)、转换状态TA(2位)以及数据段(16位)。
可以这么理解:MDIO就像小区门口的对讲机,每次呼叫前先喊32声"喂"让屋里的人注意,然后报门牌号(PHY地址)、报房间号(寄存器地址),最后再说"我要听你说"(读)或者"我给你带句话"(写)。MDC则是让双方按同一个节拍说话的节拍器,节奏一旦乱掉,整个通信就废了。
1.3 为什么PHY手册里的时序图不能跳过
每个PHY芯片的数据手册第一部分都会给出MDC/MDIO时序图,很多人觉得这是套话直接翻过去,实际上这条总线对时序有个硬性要求:MDIO线上的数据必须在MDC上升沿附近被采样,也就是说MDC高电平期间MDIO数据必须稳定。这跟SPI的CPOL=0、CPHA=1模式很接近。
我在调试时发现,有些PHY对时序宽容度很高,随便拼凑的波形也能读回正确数据,但有些PHY(尤其是老款型号)对建立时间和保持时间要求极其严格。用GPIO模拟的好处就在这里——延时函数给足一点,时钟频率放慢一点,就能满足几乎所有PHY的要求。后面代码里我统一用1微秒级别的延时,对应MDC频率大概500kHz,远低于2.5MHz上限,稳定性优先。
2. 核心代码解析:GPIO模拟MDC/MDIO的完整实现
2.1 硬件配置与引脚选择
代码里我用的是STM32F103的标准外设库,硬件上选了PB6作为MDC输出,PB7作为MDIO数据线。选择引脚时有几个讲究:第一,MDC一定要接推挽输出引脚,且最好带50MHz翻转能力,虽然实际时钟频率不高,但引脚驱动能力太弱会影响信号边沿;第二,MDIO引脚必须支持输入输出切换,且进入输入模式时要使能内部上拉。
实际电路中MDIO一般默认就有外部上拉电阻,但对于板载PHY模块,部分设计为了省成本会省略这颗电阻,这时内部上拉就起到兜底作用。如果板上已经有外部上拉了,内部上拉开着问题也不大,不会影响信号完整性。
引脚初始化代码分两部分,MDC固定为推挽输出,MDIO则要封装成可切换的模式:
#define MDC_PORT GPIOB #define MDC_PIN GPIO_Pin_6 #define MDIO_PORT GPIOB #define MDIO_PIN GPIO_Pin_7 static void MDC_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = MDC_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(MDC_PORT, &GPIO_InitStructure); } static void MDIO_SetOutput(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = MDIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(MDIO_PORT, &GPIO_InitStructure); } static void MDIO_SetInput(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = MDIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(MDIO_PORT, &GPIO_InitStructure); }2.2 位操作层:一个时钟一个bit
MDC/MDIO的核心操作本质上就是"一个时钟周期内完成一次数据写入或读取"。这里的关键是理解数据变化和时钟沿的配合:数据必须在MDC低电平期间准备好,等MDC拉高后PHY采样,然后再把MDC拉低为下一个bit做准备。
所以写一个bit的流程是:先把MDIO拉高或拉低,然后让MDC输出一个完整的高-低脉冲。读一个bit的流程则是:先把MDIO切换为输入,然后拉高MDC,在MDC高电平期间读取引脚电平,最后把MDC拉低。下面这两个函数就是整条总线的最小积木。
static void MDC_Delay(void) { volatile uint32_t i; for (i = 0; i < 20; i++); /* 约1us @72MHz,需要根据主频微调 */ } static void MDIO_WriteBit(uint8_t bit) { MDIO_SetOutput(); if (bit) { GPIO_SetBits(MDIO_PORT, MDIO_PIN); } else { GPIO_ResetBits(MDIO_PORT, MDIO_PIN); } MDC_Delay(); GPIO_SetBits(MDC_PORT, MDC_PIN); /* MDC拉高,PHY在上升沿采样 */ MDC_Delay(); GPIO_ResetBits(MDC_PORT, MDC_PIN); /* MDC拉低,允许数据变化 */ } static uint8_t MDIO_ReadBit(void) { uint8_t val; MDIO_SetInput(); MDC_Delay(); GPIO_SetBits(MDC_PORT, MDC_PIN); /* 先把时钟拉高 */ MDC_Delay(); val = GPIO_ReadInputDataBit(MDIO_PORT, MDIO_PIN); /* 高电平期间采样 */ GPIO_ResetBits(MDC_PORT, MDC_PIN); return val ? 1 : 0; }别小看这两个函数,调MDIO踩的坑一半都在这个层面。写操作和读操作的时序窗口完全不一样,写bit时数据要在MDC上升沿之前稳定,读bit时数据是PHY在MDC上升沿或下降沿之后才输出的,所以读操作必须在MDC高电平中间段采样,不能一拉高就立刻读。
2.3 帧组装层:读写PHY寄存器的完整流程
有了位操作,接下来就是按照协议帧格式逐位拼凑。写寄存器时全程由主机驱动MDIO,不需要切换方向;读寄存器时前44位由主机驱动,从TA阶段开始要切换成PHY驱动,这是最容易出错的地方。
先看写寄存器:
void MDIO_WriteReg(uint8_t phy_addr, uint8_t reg_addr, uint16_t value) { uint8_t i; MDIO_SetOutput(); /* 前导码:连续32个1 */ for (i = 0; i < 32; i++) { MDIO_WriteBit(1); } /* ST=01 */ MDIO_WriteBit(0); MDIO_WriteBit(1); /* OP=01 (写操作) */ MDIO_WriteBit(0); MDIO_WriteBit(1); /* PHYAD[4:0] */ for (i = 4; i < 5; i--) { MDIO_WriteBit((phy_addr >> i) & 0x01); } /* REGAD[4:0] */ for (i = 4; i < 5; i--) { MDIO_WriteBit((reg_addr >> i) & 0x01); } /* TA=10 */ MDIO_WriteBit(1); MDIO_WriteBit(0); /* 16位数据 */ for (i = 15; i < 16; i--) { MDIO_WriteBit((value >> i) & 0x01); } }再读寄存器。读操作在TA阶段有特殊处理:第一个TA时钟周期MDIO释放为高阻,第二个TA时钟周期PHY开始驱动MDIO输出0。实际代码里第一个周期可以简单输出1,因为PHY在这个周期本来不驱动总线,配上拉后读到的就是1,等第二个周期切换为输入去读0即可:
uint16_t MDIO_ReadReg(uint8_t phy_addr, uint8_t reg_addr) { uint8_t i; uint16_t value = 0; MDIO_SetOutput(); /* 前导码:连续32个1 */ for (i = 0; i < 32; i++) { MDIO_WriteBit(1); } /* ST=01 */ MDIO_WriteBit(0); MDIO_WriteBit(1); /* OP=10 (读操作) */ MDIO_WriteBit(1); MDIO_WriteBit(0); /* PHYAD[4:0] */ for (i = 4; i < 5; i--) { MDIO_WriteBit((phy_addr >> i) & 0x01); } /* REGAD[4:0] */ for (i = 4; i < 5; i--) { MDIO_WriteBit((reg_addr >> i) & 0x01); } /* TA:第一个时钟周期释放总线 */ MDIO_WriteBit(1); MDIO_SetInput(); MDC_Delay(); GPIO_SetBits(MDC_PORT, MDC_PIN); MDC_Delay(); GPIO_ResetBits(MDC_PORT, MDC_PIN); MDC_Delay(); /* 读取16位数据 */ for (i = 15; i < 16; i--) { value <<= 1; if (MDIO_ReadBit()) { value |= 0x0001; } } return value; }细心的读者会发现,我在发送PHY地址和寄存器地址时用的是for (i = 4; i < 5; i--),这里需要说明一下:i从4递减到0,循环条件i < 5永远为真,直到i从0减到255变成无符号溢出后才退出,正好循环5次取出高到低的5个bit。这种写法实际是依赖uint8_t回绕实现的,不够直观。建议改成更明确的写法:
for (i = 0; i < 5; i++) { MDIO_WriteBit((phy_addr >> (4 - i)) & 0x01); }上面的代码段保留原工程的风格,但实际项目里我推荐后者,可读性高,不容易误导后来人。
2.4 延时与速率的取舍
GPIO模拟MDC/MDIO最大的局限就是速度。硬件控制器能跑到2.5MHz,用GPIO模拟,即使主频72MHz的STM32F103,算上函数调用和GPIO翻转开销,实际能稳定跑到的MDC频率也就是500kHz左右。如果处理不好,时序就会变形。
我在MDC_Delay里用的是空循环延时,这在无RTOS的裸机环境里够用。但要注意:延时长短需要根据实际主频调整,72MHz主频下20次空循环大约1微秒。换成其他主频的MCU时,最好用示波器或逻辑分析仪测一下实际的MDC波形,再回来调整循环次数。有DWT(数据观察点与跟踪)单元的芯片可以直接用DWT->CYCCNT做精确延时,比空循环稳定得多。
至于为什么故意把速度放慢到1微秒级别,是因为MDIO总线对建立时间的要求通常在10纳秒级别,1微秒的延时给了至少100倍的余量。这样的速度读取几百个寄存器也只需几毫秒,完全不影响启动流程。
3. 上板实操:读取PHY芯片ID的全过程
3.1 创建工程与GPIO初始化
我把上面的代码整理成mdio_gpio.c和mdio_gpio.h两个文件,放进标准库工程里。入口是一个初始化函数:
void MDIO_Init(void) { MDC_GPIO_Init(); MDIO_SetOutput(); GPIO_SetBits(MDIO_PORT, MDIO_PIN); /* 总线空闲时MDIO保持高电平 */ }注意初始化时把MDIO默认拉高。MDIO总线在空闲状态下应该是高电平,这是PHY芯片识别总线空闲的依据。如果初始化后MDIO是低电平,PHY可能误认为数据还在传输中,导致后续通信异常。
接下来验证读写函数。最经典的验证方式就是读PHY的ID寄存器。IEEE规定PHY芯片的寄存器0x02存放厂商ID的高16位,0x03存放厂商ID的低16位。我实测过几款常见PHY:
| PHY芯片 | 寄存器0x02 | 寄存器0x03 |
|---|---|---|
| LAN8720A | 0x0007 | 0xC0F1 |
| DP83848C | 0x2000 | 0xA0CB |
| RTL8201F | 0x001C | 0xC816 |
不同芯片的ID不同,但读出来有确定值,就能基本确认GPIO模拟的时序和帧格式是对的。再加上PHY的基本状态寄存器0x01(含链接状态位)来验证链路是否建立,基本就能放心地继续往下调了。
3.2 main函数里的验证流程
在main函数里最简单的验证逻辑就是循环读PHY ID,并用串口打印:
int main(void) { uint16_t id1, id2; USART_Config(); /* 串口初始化 */ MDIO_Init(); while (1) { id1 = MDIO_ReadReg(0x00, 0x02); id2 = MDIO_ReadReg(0x00, 0x03); printf("PHY ID: 0x%04X 0x%04X\r\n", id1, id2); Delay_ms(500); } }第一次上电如果打印出来的是0x0000 0x0000或者0xFFFF 0xFFFF,别急着怀疑PHY坏了。0x0000多半是PHY没有响应,MDIO线始终被拉低,可能是上拉电阻缺失或引脚配置成开漏;0xFFFF多半是MDIO没读到数据,可能是地址不对、引脚虚焊或者时序错误。我把这些情况整理在后面的排查表里。
3.3 用逻辑分析仪确认波形
写驱动和调驱动是两回事。写的时候可以凭逻辑推,调的时候最好有工具佐证。条件允许的话,强烈建议在调试阶段把MDC和MDIO两根线接到逻辑分析仪上,用200MHz采样率抓一段波形。
正常读操作的波形应该是这样:先是一长串连续高电平(32个1组成的前导码),然后出现一个短的低脉冲(ST=01),随后是地址和数据段的方波簇,最后16个bit数据稳定可读。如果前导码长度不足,或者ST码不对,波形一出来就能看出来,不用靠猜。
我遇到过一次很奇怪的现象:逻辑分析仪显示波形完全正确,但PHY回的数据每次都不一样。后来发现是逻辑分析仪的接地夹松了,引入的噪声干扰了MDIO信号。所以说工具和操作手法同样重要,别一开始就怀疑代码。
4. 踩坑记录:MDIO模拟最容易忽略的5个问题
4.1 读操作读到0xFFFF
这是调试MDIO时最经典的故障现象。我在第一次调LAN8720时就遇到了。代码看起来没问题,波形也能抓出来,但读寄存器就是全1。
排查到最后发现是PHY地址搞错了。很多PHY的默认地址是0x00,但部分芯片(比如某些开发板上的PHY)通过外部引脚配置成了其他地址。读之前一定要看板子的原理图,找到PHY_AD0、PHY_AD1这些引脚的电平状态,计算出实际地址。
再有一个原因就是读操作的TA周期处理不对,数据方向切换慢了半拍,导致PHY输出数据时MDIO还处于输出模式,两边直接打架。
4.2 MDIO没有上拉导致电平飘忽
如果板子上MDIO线漏掉了外部上拉电阻,内部上拉又没开,那么MDIO在高阻状态下会呈现不确定电平。尤其是读操作的TA阶段,主机释放总线后,如果MDIO悬空,读回来的bit就随机了。
解决方法是确保MDIO_SetInput配置成上拉输入,而不是浮空输入。如果是外部PHY模块,还要检查模块原理图上是否有上拉电阻。我后来在做自己的小板子时,直接在设计上给MDIO加了4.7k外部上拉,从此再没出过这类问题。
4.3 引脚复用没关干净
STM32的引脚默认可能是JTAG、调试口或者复用功能,直接初始化成GPIO模式不一定会生效。我在某个项目里把MDC放在PB3上,折腾了很久波形都不对,最后发现PB3默认是JTDO引脚,需要用GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE)关闭JTAG复用才能正常使用。
因此在选引脚时尽量避开JTAG、SWD复用引脚,或者提前检查数据手册里引脚的默认功能。这种问题隐蔽性极强,波形看起来一直是拉不高的状态。
4.4 时钟太快导致从设备跟不上
GPIO模拟的延时函数通常是根据主频估算的,换了个主频更高的MCU,或者开了编译器优化后,空循环时间大幅缩短,MDC频率飙升,超过了PHY的最大时钟限制。这时候读出来的数据会偶发错误,时好时坏。
遇到这个情况,用逻辑分析仪看一下MDC的实际频率,超过2.5MHz就说明延时不够了。我后来写了一个精简的延时校准函数,上电时通过DWT计算1毫秒的计数值,生成延时基准,从根本上避免了编译器优化和主频差异带来的问题。
4.5 多PHY地址冲突
正常情况下MDIO总线可以挂多个PHY,通过5位PHY地址区分。但如果板上只有一个PHY,而两个PHY地址配置相同,它们会同时响应主机的访问,在MDIO总线上产生竞争,读回来的数据就是乱的。
排查方式很简单:把PHY地址从0到31全部扫一遍,逐个读0x02寄存器,看看哪些地址返回了有效ID。如果多个地址都返回同一个ID,多半是地址冲突。这时候需要调整PHY的硬件配置引脚,让每个PHY工作在独立地址上。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 读回0xFFFF | PHY地址不对、TA方向切换错误、无上拉 | 逐个地址扫描,检查波形,测量MDIO上拉 |
| 读回0x0000 | MDIO被拉低、引脚配置错误 | 检查上拉电阻与引脚模式 |
| 数据偶发错误 | 时钟太快、干扰 | 降低MDC频率,排查电磁干扰 |
| 前导码缺失 | PHY未识别起始 | 确认发送了完整32个1 |
| 写生效但读异常 | 方向切换时序不对 | 重点检查TA周期的高阻释放点 |
最后再分享一个调MDIO的高效习惯
调试MDIO驱动的时候,我习惯先把读寄存器做成命令行解析函数,通过串口输入"读哪个PHY的哪个寄存器",立刻就能看到结果。这个习惯帮我省了大量重新烧录固件的时间,尤其是不停调整时序参数时,串口比仿真器高效得多。
这套GPIO模拟的MDC/MDIO代码,虽然看起来只是几个简单的位操作函数,但它背后牵扯到时序理解、GPIO模式切换、信号完整性和协议细节。把这块啃下来,后面再接触I2C模拟、SPI模拟甚至1-Wire模拟,都是一通百通的事。希望这份经验对正在调PHY的你有点帮助。
本文还有配套的精品资源,点击获取