
简介在VxWorks嵌入式系统开发中硬件平台有时缺少专用SMI控制器即MDC/MDIO硬件接口导致无法直接读写以太网PHY芯片的管理寄存器此时可通过普通GPIO引脚利用软件延时精确模拟总线的时钟与数据时序完成通信。这份驱动代码正是面向此类场景适合底层驱动开发者、BSP移植及板级调试人员参考资源包仅含1个C语言源文件约2KB体量轻小无需复杂工程即可快速阅读已有1556人学习。代码详细实现了GPIO方向动态切换、MDC时钟周期生成、MDIO双向数据收发状态切换以及读写命令的按位组织mdioWrite与mdioRead函数可直接提取复用能够适配常见的PHY寄存器配置流程。通读代码还可以清晰理解起始位、命令/地址、16位数据、停止位等MDIO帧格式并掌握基于延时函数实现精确时序控制的方法便于在缺少SMI硬件接口时快速迁移到其他嵌入式平台或操作系统通过该示例开发者还能快速打通PHY芯片的寄存器读写链路为后续网络驱动的自协商、链路检测等功能奠定基础。1. MDC/MDIO驱动为什么非要跟GPIO较劲做嵌入式开发的人应该都遇过这样的尴尬手头一颗交换芯片、PHY芯片或者光模块明明走的是标准的MDC/MDIO管理接口结果主控芯片上偏偏没有对应的硬件控制器。要么是用的MCU太入门要么是引脚被复用占满了总之就是没法直接用芯片自带的MDIO外设。这时候“GPIO模拟MDC/MDIO”就成了绕不开的活。这个标题里的“gpio模拟的mdc mdio驱动代码.rar”说白了就是一套用普通GPIO引脚通过软件时序去模拟MDC时钟和MDIO数据线的驱动实现。它解决的痛点是在没有硬件MDIO控制器、或者硬件控制器不够用的情况下依然能够完成对PHY、交换芯片、光模块等设备的寄存器读写从而实现链路协商、状态查询、功率配置、掉电控制等一系列管理操作。这类代码在工业控制板卡、路由器主板调试、传感器采集板这些场景里特别常用。适合谁看适合那些正在调试PHY芯片却发现主控没有MDIO控制器的人适合想做低成本管理口扩展的硬件工程师也适合刚接触嵌入式驱动、想搞清楚时序到底是怎么回事的学生。整篇文章我会把这套驱动从硬件原理、代码架构、时序实现到踩坑实录全部拆开讲保证你看完能直接照着重写一份。2. 先从MDC/MDIO协议本身说起2.1 两根线怎么完成所有管理操作MDCManagement Data Clock和MDIOManagement Data Input/Output是IEEE 802.3 Clause 22定义的管理接口专门用来访问PHY芯片内部的寄存器。物理上就两根线一根时钟线MDC一根数据线MDIO。MDC由主控主动产生最高频率通常建议不超过2.5MHzMDIO是双向数据线读的时候由从设备驱动写的时候由主控驱动。整个访问过程是围绕帧结构来做的。每一帧包含前导码Preamble、帧起始符Start of Frame、操作码Opcode、物理地址PHY Address、寄存器地址Register Address、转折周期Turnaround和数据段Data。其中前导码是32个连续的“1”用来让从设备同步帧起始符是“01”操作码读为“10”、写为“01”物理地址5位、寄存器地址5位转折周期读操作是2个时钟周期的空闲切换写操作是“10”数据段16位。写操作是主控直接发送16位数据读操作是主控释放总线从设备在第2个转折时钟驱动数据线。2.2 为什么GPIO模拟在效率上完全可行很多人第一次接触GPIO模拟时会担心软件翻转GPIO的速度能跟上吗以常见的1MHz MDC时钟为例每个时钟周期1微秒一次完整的读操作需要64个时钟周期前导码32 起始2 操作码2 地址10 转折2 数据16算下来一次读操作只要64微秒左右寄存器数量也不多管理操作本身就是低频的。就算主控主频只有72MHzSTM32F103级别GPIO翻转也完全跟得上所以GPIO模拟MDIO在性能和可靠性上都没有问题关键是把时序写对。2.3 数据线方向切换是核心中的核心写操作好说始终由主控输出。但读操作有个关键动作从发送完地址和转折位之后主控必须把MDIO引脚从输出模式切换成输入模式然后把总线释放给从设备。如果这一步没做或者切换时机不对读回来的数据必定是错的。这也是GPIO模拟驱动里最容易出bug的地方。3. 代码整体设计思路3.1 文件结构与分层思想一份典型的GPIO模拟MDC/MDIO驱动代码压缩包里通常包含这么几个文件mdio_gpio.h宏定义、引脚配置结构体、对外API声明mdio_gpio.c时序实现、读写函数主体phy_reg.h常用PHY寄存器地址宏定义如BMCR、BMSR、PHYID1、PHYID2main.c示例演示如何调用驱动初始化PHY、读取状态部分工程还会带一个readme.txt说明接线方式和适配的MCU平台这个分层的好处是底层只用GPIO操作上层只关心寄存器地址和数据中间层是协议时序。换平台时只需要改GPIO操作的底层函数协议层完全复用。3.2 API设计要足够干净我见过不少工科生写的模拟驱动把所有代码堆在一个函数里主函数里全是delay看半天也不知道在干什么。好的设计应该是这样的typedef struct { void (*set_mdc)(uint8_t level); void (*set_mdio)(uint8_t level); uint8_t (*get_mdio)(void); void (*mdio_dir_input)(void); void (*mdio_dir_output)(void); void (*delay_us)(uint32_t us); } mdio_gpio_ops_t; void mdio_gpio_init(const mdio_gpio_ops_t *ops); void mdio_gpio_read(uint8_t phy_addr, uint8_t reg_addr, uint16_t *data); void mdio_gpio_write(uint8_t phy_addr, uint8_t reg_addr, uint16_t data);把GPIO相关操作抽象成函数指针结构体这样驱动核心代码完全不依赖具体MCU。换平台只需要填充这个结构体就行。delay_us单独抽出来是因为不同平台延时方式不同有些用systick有些用定时器有些直接空循环抽象出来方便适配。3.3 时钟频率的折中策略MDC频率可以自己定但要注意两点一是不能超过PHY芯片规格书上的最大值常见的是2.5MHz也有5MHz的二是要留足时序裕量。我习惯把MDC半周期设定在1微秒左右也就是MDC频率约500kHz。这个速度比最大速率低但换来了更强的抗干扰能力和更宽松的时序要求。特别是飞线调试的时候线长、寄生电容都会影响信号质量慢一点更稳。4. 核心细节解析与实操要点4.1 前导码的发送细节现在的PHY芯片基本都支持跳过前导码但为了最大兼容性驱动里还是老老实实发满32个“1”。发送方法很简单循环32次每次先拉高MDC然后置MDIO为高再拉低MDC。注意MDIO在MDC上升沿之前就要稳定所以要先改数据再给时钟脉冲。static void mdio_send_preamble(void) { uint8_t i; for (i 0; i 32; i) { set_mdio(1); set_mdc(1); delay_us(1); set_mdc(0); delay_us(1); } }4.2 写时序的完整拆解写操作时序如下前导码32个1然后是帧起始“01”操作码“01”物理地址5位寄存器地址5位转折位“10”然后16位数据。整个过程中MDIO始终是输出模式直接按位发送即可。我习惯写一个通用的发送位函数static void mdio_send_bit(uint8_t bit) { set_mdio(bit ? 1 : 0); set_mdc(1); delay_us(1); set_mdc(0); delay_us(1); }然后写操作就变成了简单的串行输出void mdio_gpio_write(uint8_t phy_addr, uint8_t reg_addr, uint16_t data) { uint16_t frame; uint8_t i; // 前导码 for (i 0; i 32; i) { set_mdio(1); set_mdc(1); delay_us(1); set_mdc(0); delay_us(1); } // 帧起始 操作码 地址 转折 // ST(2bit) 01 mdio_send_bit(0); mdio_send_bit(1); // OP(2bit) 01 (write) mdio_send_bit(0); mdio_send_bit(1); // PHY address (5bit) for (i 4; i 8; i--) { mdio_send_bit((phy_addr i) 0x01); } // Reg address (5bit) for (i 4; i 8; i--) { mdio_send_bit((reg_addr i) 0x01); } // Turnaround (2bit) 10 mdio_send_bit(1); mdio_send_bit(0); // Data (16bit) for (i 15; i 16; i--) { mdio_send_bit((data i) 0x01); } }注意循环写法我用了i 4; i 8; i--这种从高位到低位的遍历方式。第一次循环i4发送最高位i递减到0然后i--变成-1退出循环。这种写法看起来有点绕但效率最高。上面数据段的i 15; i 16; i--同理从15递减到0发送完16位后i变成-1退出。4.3 读时序的方向切换时机读操作此前导码和地址部分完全一样区别在于转折位。写操作的转折位是两个时钟周期输出“10”而读操作的转折位是第一个时钟周期主控输出高阻即把MDIO切成输入模式第二个时钟周期开始由从设备驱动数据线。正确做法是发送完寄存器地址的最后一位之后立刻把MDIO引脚切换为输入模式然后给一个时钟脉冲这个脉冲期间MDIO呈高阻态从设备借机接管总线再给第二个时钟脉冲在这个时钟的上升沿之后、下一个上升沿之前采样数据。看一个常见的实现void mdio_gpio_read(uint8_t phy_addr, uint8_t reg_addr, uint16_t *data) { uint16_t val 0; uint8_t i; // 前导码、起始、操作码、地址部分与写相同略去 // ... // 发送完寄存器地址后切换方向 mdio_dir_input(); // Turnaround 第1个时钟总线释放从设备准备 set_mdc(1); delay_us(1); set_mdc(0); delay_us(1); // Turnaround 第2个时钟从设备开始驱动数据主控需在第2个时钟周期内完成方向确认 set_mdc(1); delay_us(1); // 此时还不能马上采样需要等到MDC低电平期间数据稳定 set_mdc(0); delay_us(0.5); // 半个周期等待数据稳定 for (i 15; i 16; i--) { // 在MDC低电平期间读取MDIO val | (get_mdio() ? 1 : 0) i; set_mdc(1); delay_us(1); set_mdc(0); delay_us(1); } *data val; // 读完恢复输出模式 mdio_dir_output(); }这里有个细节读数据的最佳采样点是在MDC低电平期间因为PHY芯片通常在MDC上升沿更新数据在MDC低电平期间数据保持稳定。所以每次读取时先把MDC拉低延时一小段时间等数据稳定再读取MDIO电平。上面代码中转折位第2个时钟的上升沿后从设备开始驱动第一个数据位等到MDC拉低再延时这个时候数据已经稳定了。4.4 延时函数的选择与精度要求GPIO模拟的时序精度完全取决于延时函数。很多人喜欢用for循环做空延时这在没有操作系统、主频固定的情况下也能用但换主频就要重新标定。更可靠的做法是用硬件定时器或者systick做微秒级延时。我通常在STM32上用DWT或者SysTick实现一个delay_us精度在几十纳秒级别。对于500kHz的MDC时钟半周期1微秒1微秒的延时误差最多偏差几百纳秒完全在协议允许范围内。不过要注意调用延时函数本身有函数调用开销如果用的是HAL库的HAL_Delay只能到毫秒级根本不能用。5. 实操过程与核心环节实现5.1 硬件接线与平台适配我这次调试用的平台是STM32F103C8T6板上有一颗需要配置的PHY芯片比如LAN8720A。接线很简单MDC - PA0MDIO - PA1GND共地需要注意MDIO线上最好加一个上拉电阻阻值范围2.2kΩ到10kΩ。原因是MDIO是双向线从设备驱动时为开漏结构主控释放总线时线缆呈现高阻态上拉电阻能保证空闲状态为高电平防止误判。调试板上如果没有上拉电阻可以飞线加一个实测不加也能工作但长线时容易出错。5.2 引脚初始化与方向切换实现在STM32平台上GPIO的输入输出切换要特别注意。初始化时可以先把MDIO配置为推挽输出读操作时切换到浮空输入读完后切回推挽输出。用标准库写就是这样void mdio_pin_init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // MDC: 推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // MDIO: 初始化为推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); } void mdio_dir_input(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); } void mdio_dir_output(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); }如果用HAL库则要用GPIO_Init函数反复修改GPIO_InitStruct.Mode切换频率高时会有一定开销但MDIO本身操作频率不高所以完全没问题。5.3 主控端功能验证流程驱动写好后不要直接去读业务寄存器先从基础验证开始。代码流程如下int main(void) { uint16_t phy_id1, phy_id2; uint16_t bmsr; delay_init(); mdio_pin_init(); mdio_gpio_write(0x01, 0x00, 0x8000); // 复位PHY寄存器0 bit15 1 delay_ms(100); // 等待复位完成 // 读取两个ID寄存器验证读写通路 mdio_gpio_read(0x01, 0x02, phy_id1); // PHY ID1 mdio_gpio_read(0x01, 0x03, phy_id2); // PHY ID2 while (1) { mdio_gpio_read(0x01, 0x01, bmsr); // 读取BMSR状态 // 根据bmsr第2位判断链路是否建立 if (bmsr 0x04) { // link up } else { // link down } delay_ms(500); } }我调试时习惯先在PHY ID寄存器上验证因为ID是固定的比如LAN8720A的ID1是0x0007ID2是0xA000读出来的值跟手册对上了说明时序基本正确。然后读BMSR寄存器1检查link状态。5.4 用逻辑分析仪核对时序如果手里有逻辑分析仪强烈建议抓一次波形。把MDC、MDIO两路接到分析仪上触发条件设为MDC上升沿采样率至少10MHz。抓到的波形应该能看到清晰的32个前导码高电平然后是完整的帧结构。我放一个简化版的时序图文字描述方便理解前导码段MDC时钟连续翻转MDIO恒为高起始段MDC上升沿前MDIO变为低再下一个时钟MDIO为高形成“01”操作码段接下来的两个时钟MDIO状态区分读/写地址段10个时钟5位PHY地址5位寄存器地址转折段2个时钟写模式MDIO输出“10”读模式MDIO变高阻数据段16个时钟在MDC低电平期间读取MDIO电平这个波形一旦对了基本就能放心去读PHY的其它寄存器了。6. 常见问题与排查技巧实录6.1 读回的数据全为0xFF或全为0这个问题出现的频率最高。全为0xFF说明MDIO线一直处于高电平也就是主控释放总线后没有从设备驱动它或者从设备没响应。可能原因有几个PHY地址不对。PHY地址由芯片外围引脚决定常见的是0x00、0x01、0x04等要查硬件确认。从设备根本没上电或者复位没完成。先检查PHY芯片的供电和复位引脚时序很多PHY芯片需要主控给一段时间的复位脉冲才能正常工作。MDIO线上没有上拉电阻且PHY芯片内部未集成上拉。这时候空闲电平可能被干扰拉低导致数据读回0。全为0则通常是MDIO引脚一直处于输出模式且输出低电平检查一下读操作前有没有正确切换输入方向。6.2 写寄存器成功但读出来不对这种情况多半是采样点问题。比如你在MDC上升沿采集数据但PHY的输出在上升沿附近还没稳定采集到的是上一个数据的残余电平。解决办法是把采样点放到MDC低电平的中间位置也就是拉低MDC后延时0.5微秒左右再读。如果还是不对可以把延时调大一点比如半周期1.5微秒牺牲一点速度换取稳定。6.3 时钟频率上不去波形变形如果发现MDC波形上升沿和下降沿都很缓像是被电容充电一样基本就是引脚驱动能力不足或者线路太长。解决办法是降低MDC频率把半周期从1微秒拉到2微秒同时检查GPIO有没有配置成开漏模式——开漏模式下如果没有外部上拉上升沿会非常慢导致时序失效。MDC和MDIO在推挽模式下工作时输出高低电平都很锐利最适合高速翻转。6.4 同一条总线接多个PHY芯片MDIO协议本身支持挂在同一条总线上的多个PHY通过PHY地址区分。驱动上不需要改动读写时把phy_addr参数换成对应对备地址即可。但要注意MDIO线上的上拉电阻要计算并联值多个PHY内部都有弱上拉外部再加一个2.2kΩ通常够了。如果发现读多个从设备时相互干扰可以在每读写完一个芯片后把MDIO引脚拉高一小段时间确保总线恢复到空闲状态。6.5 代码移植到别的MCU时改了引脚就不工作最常见的原因是新平台的GPIO翻转速度太慢比如某些单片机主频只有8MHz翻转一次GPIO要好几微秒。这时需要把延时调大不要固执地保持原来的频率。另外要注意新平台的GPIO输入输出切换方式有些MCU需要先配置模式再操作数据寄存器有些需要先切输入再释放总线顺序反了就出问题。7. 驱动优化与扩展思路7.1 用位带操作提升翻转效率如果是在Cortex-M3/M4平台上可以借助位带别名区直接操作GPIO输出寄存器减少通过HAL库函数调用的开销。比如把MDC引脚映射到位带地址写*mdc_bit 1就能实现单周期翻转。实测在72MHz主频下用位带操作能把MDC频率推到接近2MHz而且波形更稳定。对于需要快速扫描大量寄存器的场景比如光模块的DDM信息读取这个优化很有意义。7.2 支持Clause 45扩展协议部分高速PHY和光模块的寄存器地址超过5位需要用到Clause 45的MDIO帧格式。Clause 45的帧结构和Clause 22不同它通过操作码来区分设备地址和数据地址需要额外实现一套帧发送逻辑。如果项目里用到了10G PHY或者SFP光模块建议直接把Clause 45的支持写进去也可以将两者封装成统一的接口按芯片类型选择帧格式。7.3 增加错误重试和超时机制工业场景下总线偶尔会被干扰读回数据可能偶尔出错。可以加一个简单的校验机制连续读三次寄存器取两次相同的结果作为有效值或者对PHY ID这类固定值做比对不一致就重读。重试次数超过3次后报错这样可以避免因为单次读取错误导致业务逻辑误判。7.4 把驱动改成中断等待模式非阻塞思路也可以考虑。常规驱动是同步方式每次读写都要占用几十微秒在任务系统中会造成阻塞。可以用一个定时器产生MDC时钟中断在中断里维护一个状态机每次中断发送/读取一个bit。这种做法的代码复杂度高很多但能释放CPU适合在RTOS环境下使用。如果是裸机项目同步方式已经足够没必要自找麻烦。8. 写在最后的实际体会GPIO模拟MDC/MDIO这套驱动我前后写过好几个版本从最早直接操作寄存器、什么都写死在main.c里的粗糙版到后来抽象成ops结构体、一套代码兼容多个平台的通用版中间踩过不少坑。最让我印象深刻的是第一次调读时序时因为方向切换晚了半拍读回来的ID永远是0x0000当时拿着示波器对着波形看了整整半天才发现转折位后第一个时钟就应该切输入而不是第二个时钟才切。这种问题如果只是看参考代码的注释很难发现时序上微小的差别。如果你也要写这套驱动我建议先把PHY芯片的手册找出来把MDIO帧结构的时间参数表仔细看一遍尤其是数据建立时间、保持时间、输出延迟这几个参数。然后对照逻辑分析仪的波形去校准延时比对着代码空想要快得多。驱动写完之后最好留一个寄存器dump的函数可以把所有支持不到32个的寄存器读出来打印这对后续排查PHY初始化问题特别有帮助。这套东西看着不起眼但在实际项目里能救命的天数可不少。本文还有配套的精品资源点击获取