简介:面向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;它由两条线构成——MDC(Management Data Clock,管理数据时钟)和MDIO(Management 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,也就是每个周期400ns;MDIO在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芯片时一次就通了,希望帮到你。
本文还有配套的精品资源,点击获取