GPIO模拟MDC/MDIO时序读写PHY寄存器实战(VxWorks实现)

发布时间:2026/10/21 4:24:08
GPIO模拟MDC/MDIO时序读写PHY寄存器实战(VxWorks实现)
简介面向VxWorks嵌入式开发者的GPIO模拟MDC与MDIO驱动源码包解决系统缺少专用SMI接口时配置以太网PHY芯片的难题。通过GPIO精准产生MDC时钟并控制MDIO双向数据线严格遵循读写时序适用于驱动开发、底层调试和硬件验证等场景。资源包仅包含1个C语言源码文件整体压缩后约2KB代码精简、可读性强便于直接参考或移植到项目中。源码覆盖了GPIO引脚初始化、MDC时钟周期生成、数据传输循环、命令与地址发送、数据接收及错误检测等完整流程并封装了数据写入与读取的核心函数对时序要求严苛的采样点位置、输入输出方向切换等细节均有清晰处理可帮助开发者深入理解MDIO协议位操作与VxWorks接口调用方法。已有1561人学习下载适合对以太网管理接口有一定基础、希望快速在VxWorks环境下实现PHY通信的嵌入式工程师也可作为SMI软件模拟驱动的教学参考。1. 先讲结论没有MDIO控制器GPIO照样把PHY寄存器读出来某次在一个VxWorks 5.5的老平台上做网络驱动适配板子上要接一颗百兆PHY芯片但BSP里压根没有MDIO控制器——主控只留了几个空闲GPIO。当时第一反应是换带SMI接口的主控但硬件已经画板了只能软件想办法。于是就有了这份用GPIO模拟MDC/MDIO时序的驱动代码读写PHY寄存器完全靠两个GPIO引脚翻转电平实测读PHY ID、配置速率、查链路状态都没有问题。它解决的正是嵌入式开发里一个很实际的痛点没有专用SMI控制器但又要管理和配置PHY芯片。这套代码跑在VxWorks上稍改几行就能挪到Linux或裸机环境对做BSP移植和网络驱动的开发者来说是一份可以直接抄作业的底稿。2. 先搞懂MDC/MDIO/SMI在做什么两个引脚如何读出上千个寄存器2.1 被很多人混淆的三个缩写MDC、MDIO和SMI是什么关系刚开始接触以太网驱动的人经常把MDC、MDIO、SMI三者搞混。我在第一次写这个模拟驱动时也绕了一阵子。简单梳理一下SMI是管理接口的总称全称Station Management Interface它由两条线构成——MDCManagement Data Clock管理数据时钟和MDIOManagement Data Input/Output管理数据输入输出。MDC是主控发出的时钟MDIO是一根双向数据线时分复用既用来发送命令也用来接收PHY返回的数据。PHY芯片内部有一组寄存器用来控制速率、双工模式、自协商开关以及查询链路状态、协商结果。这些寄存器就通过MDC/MDIO访问。标准以太网PHY的寄存器通常有32个地址0到31每个16位宽。读PHY ID是0x02和0x03寄存器控制寄存器是0x00状态寄存器是0x01。这就是你在做网络驱动时最常用到的那几个。有专用MDIO控制器的平台主控会帮你处理时序你只需要往寄存器里填地址和数据即可。但没有控制器的平台就麻烦了必须用GPIO模拟。GPIO模拟的关键在于MDC时钟频率上限是2.5MHz也就是每个周期400nsMDIO在MDC上升沿采样数据在下降沿切换数据。只要时序符合这个规则PHY是不关心你用硬件控制器还是GPIO翻转的。这就是这套代码能成立的底层逻辑。2.2 VxWorks下驱动分层结构模拟驱动挂在哪一层在VxWorks中网络驱动通常走MUX接口或直接调用底层函数但这套GPIO模拟MDIO的代码定位很明确——它是最底层PHY寄存器读写单元不依赖任何系统组件。代码在工程里的位置放在BSP目录下编译成目标文件后被网络驱动模块调用。分层看是这样的架构板级初始化层负责把两个GPIO配置成输出模式初始化引脚状态时序模拟层实现MDC时钟翻转、MDIO读写时序寄存器访问层封装对PHY的读操作和写操作调用层网络驱动通过读PHY ID验证链路或写控制寄存器配置PHY这个分层的意义在于网络驱动上层的代码完全可以复用标准VxWorks网络栈唯一的差异只在PHY寄存器访问底层。换个PHY芯片只需要调整寄存器访问层时序模拟层基本不动。2.3 MDIO帧格式一次读操作究竟发送了什么MDIO的帧结构是理解代码的前提。先看帧格式这是一张MAC层发起读操作的完整帧结构字段长度说明前导码32位连续1建立同步起始码2位固定01操作码2位10表示读01表示写PHY地址5位芯片地址常见为0或1寄存器地址5位寄存器编号0到31转换周期2位读操作时由PHY驱动写操作时由MAC驱动数据16位读时PHY返回写时MAC发送GPIO模拟的关键就是把上面每个bit按时序翻转出去。前导码是32个连续的1这是PHY用来同步的不能省。有些简化方案会缩短前导码实际中确实有PHY能容忍但不建议这么做——换个PHY就可能翻车。3. 核心代码拆解GPIO模拟MDC/MDIO的完整实现链路3.1 基础操作GPIO电平翻转和系统延时如何配合VxWorks下的GPIO操作和Linux不太一样它没有统一的gpiolib接口不同处理器BSP里的寄存器地址完全不同。我在这个项目里采用的做法是在最底层封装两个函数把GPIO操作收敛到一处上层代码不感知具体寄存器地址。/* 底层IO操作封装适配具体BSP */ #define MDIO_GPIO_BASE 0x56000000 /* 示例地址按实际板卡修改 */ #define MDC_PIN_OFFSET (1 5) /* 假设MDC接GPIO5 */ #define MDIO_PIN_OFFSET (1 6) /* 假设MDIO接GPIO6 */ static void gpio_pin_set(UINT32 pin_offset, BOOL high) { UINT32 reg_val; UINT8 *reg_addr (UINT8 *)MDIO_GPIO_BASE; if (high) { reg_val *(volatile UINT32 *)reg_addr; *(volatile UINT32 *)reg_addr reg_val | pin_offset; } else { reg_val *(volatile UINT32 *)reg_addr; *(volatile UINT32 *)reg_addr reg_val ~pin_offset; } } /* 毫秒级延时用于前导码和帧间隔 */ static void delay_us(int us) { int loop us * 20; /* 粗略延时按主频调整 */ while (loop-- 0) { /* 空循环 */ } }这段代码的逻辑很直白gpio_pin_set读回数据寄存器做位或和位与操作实现拉高拉低。MDIO_PIN_OFFSET和MDC_PIN_OFFSET是按硬件原理图定义的板子上GPIO接到PHY的哪两个引脚就把偏移量对应改成哪一位。delay_us是粗暴的空循环延时因为VxWorks的tick精度达不到微秒级直接数循环比调用系统延时函数更可控——这里写的20倍是针对某平台主频的经验值换平台要实测调整。3.2 MDC时钟产生为什么高电平和低电平时间要分开控制MDC是送给PHY的时钟信号PHY在MDC上升沿采样MDIO上的数据。时钟频率不能超过2.5MHz但也不能太慢。慢到什么程度有问题如果每个周期超过几微秒PHY内部的看门狗定时器会超时它会认为传输结束导致整帧数据无效。所以MDC的高电平保持时间和低电平保持时间都要精确控制。/* 产生一个MDC时钟周期 */ static void mdc_clock(void) { gpio_pin_set(MDC_PIN_OFFSET, TRUE); /* MDC拉高 */ delay_us(1); /* 高电平保持 */ gpio_pin_set(MDC_PIN_OFFSET, FALSE); /* MDC拉低 */ delay_us(1); /* 低电平保持 */ }每次调用mdc_clock就产生一个完整的时钟周期高1μs低1μs整体频率约500kHz在PHY允许的2.5MHz上限之内又不会慢到触发超时。为什么拉高和拉低之后都要延时因为PHY在上升沿采样数据采样完成后数据线才能切换——如果拉高后立刻拉低采样窗口太窄PHY可能采到不确定电平。高电平这段延时本质上是给PHY留采样窗低电平延时是给自己留数据切换窗口。3.3 读时序实现前导码、命令、数据三阶段的逐位处理读操作是这套驱动里最核心的函数。它要做的事情先发32个前导码让PHY同步再发送起始码和读命令然后切数据方向读取16位寄存器值。代码里方向切换这一步最容易出问题后面会专门讲坑。/* 读取PHY寄存器 */ int mdio_read_phy_reg(int phy_addr, int reg_addr, UINT16 *value) { int i; UINT16 data 0; /* 前导码32个连续的1 */ for (i 0; i 32; i) { gpio_pin_set(MDIO_PIN_OFFSET, TRUE); /* 数据线保持高 */ mdc_clock(); } /* 起始码01 */ gpio_pin_set(MDIO_PIN_OFFSET, TRUE); /* 第一位为0 */ mdc_clock(); gpio_pin_set(MDIO_PIN_OFFSET, FALSE); /* 第二位为1 */ mdc_clock(); /* 操作码10表示读 */ gpio_pin_set(MDIO_PIN_OFFSET, TRUE); /* 第一位为1 */ mdc_clock(); gpio_pin_set(MDIO_PIN_OFFSET, FALSE); /* 第二位为0 */ mdc_clock(); /* 发送PHY地址5位从高位开始 */ for (i 4; i 0; i--) { if (phy_addr (1 i)) { gpio_pin_set(MDIO_PIN_OFFSET, TRUE); } else { gpio_pin_set(MDIO_PIN_OFFSET, FALSE); } mdc_clock(); } /* 发送寄存器地址5位从高位开始 */ for (i 4; i 0; i--) { if (reg_addr (1 i)) { gpio_pin_set(MDIO_PIN_OFFSET, TRUE); } else { gpio_pin_set(MDIO_PIN_OFFSET, FALSE); } mdc_clock(); } /* 转换周期第1位释放总线第2位读PHY驱动状态 */ /* 方向切换在此完成MDIO线改为输入 */ mdio_pin_set_input(); /* 总线释放 */ mdc_clock(); /* 转换周期第一位 */ mdc_clock(); /* 转换周期第二位PHY开始驱动数据线 */ /* 读取16位数据 */ for (i 15; i 0; i--) { if (mdio_pin_get()) { data | (1 i); } mdc_clock(); } *value data; return 0; }读时序的完整链路分为五个阶段前导码同步、命令发送、地址发送、方向转换、数据读取。前导码和命令段没有分支顺序执行就够了。方向转换段的两个时钟周期是关键——第一个时钟周期里你将MDIO引脚从输出切换为输入此时数据线处于高阻态由板上拉电阻保持高电平第二个时钟周期开始PHY接管数据线此时你读到的是PHY驱动的电平。芯片内部的时序状态机严格规定了这两个周期的切换点多一个或少一个时钟都会导致后续数据错位。数据读取阶段是从最高位开始逐位采样每读一位就产生一个时钟。注意采样的执行顺序先读引脚电平再产生时钟下降沿。因为PHY在时钟上升沿采样数据线而数据线在下降沿切换所以你必须在上升沿之前读取也就是在mdc_clock内部拉高之前完成引脚读取。这段代码里先执行mdio_pin_get再做时钟动作顺序正好匹配。3.4 写时序实现方向转换与数据输出的节奏控制写操作和读操作的区别在转换周期部分。读操作需要让PHY驱动数据线写操作则始终保持主控驱动。转换周期的前两位MAC侧需要把数据线释放到高阻态然后由主控继续驱动写数据——实际上很多实现里写操作转换周期就是输出一个高电平再加一个高阻位在这里简化为输出高电平多数PHY都能正常工作。/* 写入PHY寄存器 */ int mdio_write_phy_reg(int phy_addr, int reg_addr, UINT16 value) { int i; /* 前导码32个1 */ for (i 0; i 32; i) { gpio_pin_set(MDIO_PIN_OFFSET, TRUE); mdc_clock(); } /* 起始码 */ gpio_pin_set(MDIO_PIN_OFFSET, TRUE); mdc_clock(); gpio_pin_set(MDIO_PIN_OFFSET, FALSE); mdc_clock(); /* 操作码01表示写 */ gpio_pin_set(MDIO_PIN_OFFSET, FALSE); mdc_clock(); gpio_pin_set(MDIO_PIN_OFFSET, TRUE); mdc_clock(); /* PHY地址 */ for (i 4; i 0; i--) { if (phy_addr (1 i)) { gpio_pin_set(MDIO_PIN_OFFSET, TRUE); } else { gpio_pin_set(MDIO_PIN_OFFSET, FALSE); } mdc_clock(); } /* 寄存器地址 */ for (i 4; i 0; i--) { if (reg_addr (1 i)) { gpio_pin_set(MDIO_PIN_OFFSET, TRUE); } else { gpio_pin_set(MDIO_PIN_OFFSET, FALSE); } mdc_clock(); } /* 转换周期写操作由MAC驱动直接拉高并产生两个时钟 */ gpio_pin_set(MDIO_PIN_OFFSET, TRUE); mdc_clock(); mdc_clock(); /* 发送16位数据 */ for (i 15; i 0; i--) { if (value (1 i)) { gpio_pin_set(MDIO_PIN_OFFSET, TRUE); } else { gpio_pin_set(MDIO_PIN_OFFSET, FALSE); } mdc_clock(); } return 0; }写时序的逻辑框架与读操作一致差异有三处操作码变成01低位在前发送转换周期不需要切方向数据发送从最高位开始逐位输出。这里有个细节发送数据时先置数据线电平再产生时钟这样PHY在上升沿采到的就是正确电平。如果反了先clk后置数据会出现半个周期的数据不稳定PHY采样时电平还在跳变。3.5 公用工具函数自动探测PHY地址和寄存器验证实际项目中经常要面对一个情况板子上PHY地址不确定可能是0也可能是1或者PHY芯片挂在不同地址上。我习惯在驱动里加一个PHY地址探测函数通过读取寄存器2和3的信息来确认。同时加一个读验证函数写操作后回读比对——PHY寄存器写失败是常事尤其在电源不稳或MDIO总线上有多个设备时。/* 自动探测PHY地址 */ int mdio_find_phy_addr(void) { int addr; UINT16 phy_id1 0, phy_id2 0; for (addr 0; addr 32; addr) { if (mdio_read_phy_reg(addr, 0x02, phy_id1)0 mdio_read_phy_reg(addr, 0x03, phy_id2)0) { /* PHY ID寄存器非全0且非全1视为有效PHY */ if (phy_id1 ! 0x0000 phy_id1 ! 0xffff phy_id2 ! 0x0000 phy_id2 ! 0xffff) { return addr; } } } return -1; } /* 回读验证写操作 */ int mdio_write_verify(int phy_addr, int reg_addr, UINT16 value) { UINT16 readback 0; mdio_write_phy_reg(phy_addr, reg_addr, value); mdio_read_phy_reg(phy_addr, reg_addr, readback); if (readback value) { return 0; } return -1; }探测函数循环扫描32个地址逐个尝试读取PHY ID——寄存器0x02存的是OUI高16位0x03存的是OUI低16位和型号编号。全0表示地址上没有PHY全1表示上拉导致总线空闲状态。只要读到的值在这两者之外基本可以确定为有效PHY。回读验证函数适合用在配置速率和自协商相关寄存器时这些寄存器的写入是有条件的写失败回读就能发现问题。4. 在VxWorks工程里跑起来从编译到读写PHY的完整步骤4.1 把驱动加进VxWorks工程源文件放置和头文件引用把这份代码集成到VxWorks工程里分为两步第一步把源文件放到编译路径下第二步在引用它的代码里包含头文件。VxWorks的工程通常用Workbench或老式的project makefile管理。# 假设BSP目录为 /home/user/vxWorks/bsp cp mdio_gpio.c mdio_gpio.h /home/user/vxWorks/bsp/ # 在bsp的makefile中追加源文件 vim /home/user/vxWorks/bsp/Makefile # 找到如下行在后面追加 mdio_gpio.o # BOOT_EXTRA_SRC # 改为 # BOOT_EXTRA_SRC mdio_gpio.o如果你用的是Workbench图形界面直接在工程里右键Add Source File把mdio_gpio.c加进去就行。编译时注意VxWorks老版本用GCC新版本也可能用Diab编译器但这份代码没有依赖特定编译器特性两个都能编过。4.2 调用示例上电后读PHY ID验证链路通不通编译通过后先做一个最简单的验证——上电读取PHY ID。网络驱动还没起来时可以在usrAppInit或者BSP初始化代码里直接调用。先用mdio_find_phy_addr探测地址再读寄存器0x02和0x03拿读到的值对比PHY数据手册里的ID能对上就说明链路正常。/* BSP初始化中调用示例 */ void bsp_phy_self_test(void) { int phy_addr; UINT16 id_high, id_low; phy_addr mdio_find_phy_addr(); if (phy_addr 0) { printf(No PHY found on MDIO bus\n); return -1; } mdio_read_phy_reg(phy_addr, 0x02, id_high); mdio_read_phy_reg(phy_addr, 0x03, id_low); printf(PHY found at addr: %d\n, phy_addr); printf(PHY ID: 0x%04x 0x%04x\n, id_high, id_low); }把这段代码挂在BSP上电流程的末尾串口输出里能看到地址和ID值。如果你的PHY芯片ID手册标的是0x0140和0xC123之类打印出来能对上那就是全链路已经打通。对不上的时候先查PHY地址是否对再查GPIO接线和时序延时参数。4.3 用示波器抓波形验证时序参数代码能跑出正确的PHY ID说明时序是准的但如果换一个PHY就出问题大概率是时序参数需要重新调。调时序最直接的手段是示波器抓MDC和MDIO两个引脚的波形看四项指标时钟频率是否在400kHz到2.5MHz之间高电平是否够高必须超过PHY的VIH阈值上升沿和下降沿是否干净读数据时序中数据线切换点是否在时钟下降沿附近。实际操作时把示波器探头改成单次捕获触发条件设为MDIO的下降沿抓一帧完整的读操作。数一下前导码是不是32个时钟周期起始码是否正确。这些用肉眼就能确认。5. 常见问题避坑GPIO模拟MDIO最容易踩的五个坑5.1 现象读回来的PHY ID是全FFFF或全0原因GPIO被复用成了其他功能或者引脚配置没生效。VxWorks里有些GPIO引脚默认是功能复用状态需要先在板级初始化里配置成纯GPIO模式。解决查BSP手册里GPIO复用寄存器确保MDC和MDIO对应的引脚配置为GPIO功能而不是UART或SPI功能。另外部分芯片的GPIO方向寄存器要先把方向设成输出才能正确拉电平。5.2 现象PHY ID读出来了但有些寄存器读值偶尔跳变原因MDIO上缺少上拉电阻或上拉电阻阻值过大。MDIO是三态线在转换周期和读数据之间会出现高阻态此时引脚电位浮空靠上拉电阻维持高电平。如果上拉电阻太大分布电容充放电慢上升沿变缓PHY采样时还没稳定。解决在MDIO引脚加一个10kΩ上拉电阻到3.3V缩短上升沿时间。实测10kΩ到4.7kΩ在这个场景下都能稳定工作阻值再大就可能出现随机跳变。5.3 现象时钟频率太高PHY不响应原因delay_us里的循环次数太少。我在这份代码里写的是循环20次代表1μs但不同CPU主频和编译优化等级下这个值差异很大。如果主频提升编译器优化开-O2同样20次循环可能只跑了200ns。解决编译后用示波器实测MDC频率调整delay_us里的循环次数把频率控制在500kHz到1MHz之间。不要追求越快越好时钟快了采样窗口变窄对GPIO翻转速度和代码执行效率要求都高容易出隐性时序问题。5.4 现象读写PHY正常但网络驱动初始化后PHY不工作原因PHY的配置寄存器写入失败或配置不当。GPIO模拟MDIO读写寄存器没问题但网络驱动初始化时通常会写PHY的控制寄存器来配置速率和自协商。如果写入没有得到PHY确认PHY会保持默认状态可能和MAC侧速率不匹配。解决把mdio_write_verify回读函数加进去每次写控制寄存器后回读确认。回读不一致就重新写连续三次失败再报错误这能排除掉GPIO时序抖动带来的偶发写失败。5.5 现象换了一片PHY后驱动就不工作了原因前导码长度或转换周期时序不符合新PHY的要求。不同厂商PHY的严格度不同有的PHY接受较短的前导码有的则严格要求32位全序列。解决把前导码和转换周期做成宏定义参数例如#define MDIO_PREAMBLE_LEN32在换PHY时通过修改宏来调整而不需要改动代码逻辑。宁可慢一点把前导码做满到32位兼容性最好。这个我在换PHY上吃过亏那之后所有模拟驱动代码都保留了这个参数化入口。6. 二次开发思路从读寄存器到完整PHY管理再加个超时保护拿到这份代码后最常见的改造方向有三个。第一个是把这层MDIO驱动接到VxWorks网络驱动的标准接口上替换掉原本的硬件SMI控制器操作——这种场景下你只需要保留mdio_read_phy_reg和mdio_write_phy_reg两个函数把PHY地址和寄存器地址参数传给网络驱动即可。第二个是改成查询PHY中断状态寄存器实现链路状态变化的异步通知。第三个是移植到Linux下用把gpio_pin_set和delay_us替换成gpiod接口加nanosleep其余逻辑不用动。我实际操作中发现最有价值的改造是给读写操作加超时保护。因为GPIO模拟最大风险在于如果PHY不在总线上或上电未完成你的代码会卡在读时序里——mdio_pin_get永远读不到期望的跳变函数一直循环不退出。这在VxWorks里表现为系统挂死在Linux里表现为软锁。/* 带超时的读操作避免PHY无响应时卡死 */ int mdio_read_phy_reg_timeout(int phy_addr, int reg_addr, UINT16 *value, int timeout_ms) { int elapsed 0; /* 检查PHY就绪防止复位期间访问 */ while (!mdio_pin_get()) { delay_ms(1); elapsed; if (elapsed timeout_ms) { printf(PHY not ready, timeout\n); return -1; } } return mdio_read_phy_reg(phy_addr, reg_addr, value); }这个超时版本的核心在于进入读时序前先检查总线上是否有设备在响应把原来的无限等待变成可超时退出。在VxWorks里如果要在死循环中响应系统任务切换你还可以在while循环里加入taskDelay(1)让出CPU避免独占处理器导致看门狗超时。从那以后我每次做GPIO模拟MDIO驱动都会强制走一遍这个流程先是示波器确认时序再写带超时的寄存器访问最后才接网络驱动上层接口。这么做下来后面几次换PHY芯片时一次就通了希望帮到你。本文还有配套的精品资源点击获取