资讯动态

vxworks下用GPIO模拟MDIO时序实现PHY寄存器读写的方法与驱动代码

发布时间:2026/10/17 11:19:54 来源:尧图企业网站定制
简介在VxWorks嵌入式系统开发中若硬件缺少专用MDC/MDIO管理接口可利用GPIO模拟SMI总线与以太网PHY芯片通信。这份驱动代码面向需要配置PHY寄存器、读取链路状态的BSP开发人员采用纯软件方式生成MDC时钟并控制双向MDIO数据线尤其适合无SMI控制器的嵌入式板卡。压缩包内仅含一个C源文件bsp_gpioMdioOp.c包体约2KB代码紧凑覆盖GPIO引脚初始化、时钟周期延时、MDIO读写时序及数据错误检测等关键环节便于直接阅读、移植和二次修改。在无专用硬件时通过系统延时函数模拟约2.5MHz的MDC时钟按协议完成起始位、命令/地址、数据位与停止位的收发并针对返回数据做异常重试判断便于快速定位时序问题。目前已有1558人学习对于在VxWorks环境下开发无专用MDIO硬件接口的板卡驱动具有直接参考价值。1. 这个驱动有点反直觉没有 MDIO 控制器的主控照样读写 PHY 寄存器做嵌入式网络开发的遇到一个典型的尴尬场景板卡设计的很赶硬件工程师把 PHY 的 MDIO 和 MDC 引脚分别接到了两个普通的 GPIO 上而不是主控芯片自带的 MDIO 控制器引脚。这时候系统里没有现成的 MDIO 控制器可直接用PHY 的寄存器读不了网口的协商状态、LED 配置、能量检测全都成了黑匣子。这个资源提供的正是一套在 vxworks 下用 GPIO 模拟 MDC/MDIO 时序的驱动代码让普通 GPIO 承担起 SMI 总线的职责完整实现 PHY 寄存器的读写。适合的人群很明确做 vxworks BSP 适配的工程师或者手里有一块硬件上草率了、只能靠软件补位的主板。代码的思路也不复杂——MDIO 本身就是一种半双工的串行协议只要用 GPIO 精确控制电平和时钟就能模拟出时序。这套驱动代码给出了从 GPIO 初始化、时钟产生、读写时序到挂接 vxworks I/O 系统的完整实现可以直接编译进 BSP也可以通过 app 层 open 设备节点来操作。2. MDIO/MDC 协议与 GPIO 模拟实现先搞懂 SMI 总线时序再动手2.1 MDIO 到底在传输什么寄存器读写帧的结构MDIOManagement Data Input/Output和 MDCManagement Data Clock合在一起构成 SMISerial Management Interface是 IEEE 802.3 定义的 PHY 管理接口。MDC 是时钟最高一般支持 2.5MHzMDIO 是数据线双向半双工。帧结构非常固定读和写的区别只在操作码和 turnaround 阶段。一帧完整的 MDIO 读写帧由以下字段组成排布顺序写在下面。字段位数值定义preamble32连续 32 个 1用于同步start2固定为 01opcode2读为 10写为 01PHYAD5PHY 地址0~31REGAD5寄存器地址 0~31turnaround2读时由主机释放总线PHY 驱动写时主机输出 10DATA16寄存器数据读操作时在 opcode 和地址发送完后主机需要把 MDIO 引脚设为输入释放总线等 PHY 回一个 0 和一个 1 的 turnaround然后接收 16 位数据。写操作则不需要释放总线turnaround 直接输出 10。这套流程用 GPIO 模拟的关键在于时钟沿和数据采样时刻的配合。MDIO 数据在 MDC 上升沿时由发送方更新接收方在 MDC 上升沿采样因此模拟时我们往往在时钟置高电平后延迟一小段再更新或读取数据。2.2 如何用 GPIO 完成时序翻转延迟循环和引脚方向控制用 GPIO 模拟 MDIO本质上是在软件里构造每一个时钟周期。最常见的做法是把两个 GPIO 引脚分别配置为输出和双向一个作为 MDC一个作为 MDIO。MDIO 引脚需要支持输入输出切换因为在读操作的最后阶段要读 PHY 返回的数据。引脚配置完成后关键是如何产生一个可靠的时钟周期。static void mdc_clock_high(gpio_dev_t *dev) { gpio_write(dev-mdc_port, dev-mdc_pin, 1); udelay_custom(2); /* 高电平保持时间 */ } static void mdc_clock_low(gpio_dev_t *dev) { gpio_write(dev-mdc_port, dev-mdc_pin, 0); udelay_custom(2); /* 低电平保持时间 */ }udelay_custom是最容易出问题的地方。vxworks 提供sysInDelay之类的延时函数但如果在非内核态或者在没有 tick 的环境下调用精度可能不够。我一般直接用一个寄存器级别的小空循环来兜底。空循环的次数需要根据 CPU 主频标定否则会出现数据采样错位。参数上mdc_port是 GPIO 控制器基地址mdc_pin是引脚号这两个值在 BSP 里配置好驱动代码本身不要写死。2.3 vxworks 下的驱动框架选择字符设备还是内核模块vxworks 下做这种低速管理接口驱动通常有两种挂接方式。一种是把读 PHY 寄存器的函数直接编译进 BSP在应用里调用一个外部符号另一种是做成字符设备注册设备号让应用层通过open和ioctl访问。这个资源里的代码走的是后者理由是 SMI 总线是半双工如果有多个任务同时读写需要一个互斥锁来保护字符设备的ioctl层天然提供了一个集中入口。int smi_open(void *pDev, int openMode) { SMI_DEV *pSm (SMI_DEV *)pDev; semTake(pSm-lockSem, WAIT_FOREVER); /* 初始化 GPIO 方向默认 MDIO 为输出 */ gpio_set_direction(pSm-gpio_mdio, GPIO_DIRECTION_OUTPUT); return OK; }设备节点通过iosDrvInstall挂进 vxworks 的 I/O 系统然后应用层就能以/smi/0这样的路径访问。这种方式的好处是后续在 BSP 里加网络驱动可以直接调用同一个设备节点不用重复实现时序逻辑。3. 核心代码拆解GPIO 初始化到 PHY 寄存器读写的完整链路3.1 GPIO 初始化和引脚方向控制驱动启动的第一步是把 GPIO 引脚配置好。MDC 始终是输出MDIO 在写帧阶段是输出在读数据阶段要切成输入。这个切换如果做得不及时总线就会冲突。下面的代码展示了一个典型的 GPIO 初始化流程。#define MDC_GPIO_PIN 23 /* 板级配置MDC 引脚 */ #define MDIO_GPIO_PIN 24 /* 板级配置MDIO 引脚 */ static void smi_gpio_init(SMI_DEV *pSm) { /* 配置 MDC 为输出初始低电平 */ gpio_set_direction(pSm-mdc_port, MDC_GPIO_PIN, GPIO_DIRECTION_OUTPUT); gpio_write(pSm-mdc_port, MDC_GPIO_PIN, 0); /* 配置 MDIO 为输出初始高电平 */ gpio_set_direction(pSm-mdio_port, MDIO_GPIO_PIN, GPIO_DIRECTION_OUTPUT); gpio_write(pSm-mdio_port, MDIO_GPIO_PIN, 1); /* 创建互斥锁防止多任务并发读写 SMI 总线 */ pSm-lockSem semMCreate(SEM_Q_PRIORITY | SEM_INVERSION_SAFE); }gpio_set_direction在不同芯片的 GPIO 控制器里实现方式差别很大可能是写方向寄存器也可能是先禁止输入时钟。这里把引脚配置和方向切换封装成函数具体硬件相关的部分留到 BSP 板级文件去实现。互斥锁我用的是 vxworks 的semMCreate它可以解决优先级翻转问题这在实时系统里很重要。3.2 模拟 MDC 时钟的产生MDC 时钟的频率决定了 SMI 总线速率。协议规定 MDC 最高 2.5MHz但模拟实现时最好留出余量因为 GPIO 翻转和延迟循环本身有时间抖动。一个完整的时钟周期是高电平保持 t1低电平保持 t2。对于 2.5MHz周期是 400ns高电平 200ns低电平 200ns。如果 CPU 主频只有几百兆赫兹一个 GPIO 写操作本身就要消耗几十纳秒所以循环里的延迟参数要充分考虑。#define MDC_HALF_PERIOD_US 1 /* 半周期微秒数按需调整 */ static void smi_clock_tick(SMI_DEV *pSm) { gpio_write(pSm-mdc_port, pSm-mdc_pin, 1); udelay( MDC_HALF_PERIOD_US ); gpio_write(pSm-mdc_port, pSm-mdc_pin, 0); udelay( MDC_HALF_PERIOD_US ); }注意这里udelay的参数是整数微秒而 2.5MHz 的半周期是 0.2 微秒。所以实际使用中MDC 频率会被降到 500kHz 左右低速下时序更稳。对于绝大多数 PHY 来说500kHz 完全够用。如果板子对功耗和抗干扰有要求可以把半周期调大降到 250kHz 都行。调大参数不会影响功能只会让每次寄存器读取慢几微秒。3.3 MDIO 读函数的实现翻转时机决定成败读操作的难点在于 turnaround 阶段的总线方向切换。下面的代码展示了完整流程关键注释写在每一段。static uint16_t smi_read_reg(SMI_DEV *pSm, uint8_t phy_addr, uint8_t reg_addr) { uint16_t data 0; int i; /* 1. 发送 preamble32 个 1 */ gpio_set_direction(pSm-mdio_port, GPIO_DIRECTION_OUTPUT); for (i 0; i 32; i) { gpio_write(pSm-mdio_port, 1); smi_clock_tick(pSm); } /* 2. 发送 start 码 01 */ gpio_write(pSm-mdio_port, 0); smi_clock_tick(pSm); gpio_write(pSm-mdio_port, 1); smi_clock_tick(pSm); /* 3. 发送 opcode 读 10 */ gpio_write(pSm-mdio_port, 1); smi_clock_tick(pSm); gpio_write(pSm-mdio_port, 0); smi_clock_tick(pSm); /* 4. 发送 PHY 地址和寄存器地址 */ for (i 4; i 0; i--) { gpio_write(pSm-mdio_port, (phy_addr i) 0x1); smi_clock_tick(pSm); } for (i 4; i 0; i--) { gpio_write(pSm-mdio_port, (reg_addr i) 0x1); smi_clock_tick(pSm); } /* 5. turnaroundMDIO 切换为输入等待 PHY 驱动总线 */ gpio_set_direction(pSm-mdio_port, GPIO_DIRECTION_INPUT); smi_clock_tick(pSm); /* 第一个 TA0 */ smi_clock_tick(pSm); /* 第二个 TA1 */ /* 6. 读取 16 位数据上升沿采样 */ for (i 15; i 0; i--) { smi_clock_tick(pSm); data | (gpio_read(pSm-mdio_port) i); } /* 7. 结束后把 MDIO 恢复为输出防止干扰下一个帧 */ gpio_set_direction(pSm-mdio_port, GPIO_DIRECTION_OUTPUT); gpio_write(pSm-mdio_port, 1); return data; }逻辑说明第 5 步是方向切换的关键点。代码在发送完地址后立刻把 MDIO 引脚设为输入然后打两个时钟。第一个时钟周期 PHY 会回复一个 0第二个时钟周期回复一个 1这就完成了 turnaround。之后每个时钟上升沿采集一次 MDIO 数据。最后一步把引脚恢复成输出并置高避免长悬空电平干扰后续帧。参数说明phy_addr是 PHY 的地址通常由硬件管脚配置取值 0~31reg_addr是 PHY 寄存器地址比如常用的 0 对应控制寄存器1 对应状态寄存器。3.4 MDIO 写函数的实现不需要抓取数据但时序更紧凑写操作和读操作的前半段几乎相同只有 opcode 变成了 01且 turnaround 阶段是主机继续输出 10。static void smi_write_reg(SMI_DEV *pSm, uint8_t phy_addr, uint8_t reg_addr, uint16_t val) { int i; gpio_set_direction(pSm-mdio_port, GPIO_DIRECTION_OUTPUT); /* preamble 和 start 代码与读函数相同省略 */ /* opcode 写01 */ gpio_write(pSm-mdio_port, 0); smi_clock_tick(pSm); gpio_write(pSm-mdio_port, 1); smi_clock_tick(pSm); /* 发送 PHY 地址与寄存器地址逻辑同读函数 */ /* turnaround输出 10 */ gpio_write(pSm-mdio_port, 1); smi_clock_tick(pSm); gpio_write(pSm-mdio_port, 0); smi_clock_tick(pSm); /* 发送 16 位数据 */ for (i 15; i 0; i--) { gpio_write(pSm-mdio_port, (val i) 0x1); smi_clock_tick(pSm); } /* 最后拉高 MDIO释放总线 */ gpio_write(pSm-mdio_port, 1); }这里有个容易被忽略的细节写操作结束后MDIO 不应该立即变成高阻或输入因为 SMI 总线是共享的如果后面还挂着其他协议保持输出高电平是一种约定俗成的默认态。有些代码在写完后把 MDIO 切成输入然后靠外部上拉电阻维持高电平这也可以但引脚方向切换需要额外时间所以驱动里直接继续输出高电平更干脆。3.5 把驱动挂到 vxworks 的 I/O 系统上有了读写函数下一步是把它封装成 vxworks 设备。vxworks 的 I/O 系统支持xbd或者经典的iosDevAdd方式。这个驱动采用最简单的字符设备方式让应用可以像访问文件一样访问 PHY。static const SMI_FUNCS smiFuncs { smi_open, smi_close, smi_read, smi_write, smi_ioctl }; void smiDevInit(void) { int drvNum; SMI_DEV *pSm; drvNum iosDrvInstall(smiFuncs, NULL); pSm (SMI_DEV *)malloc(sizeof(SMI_DEV)); /* 省略 GPIO 寄存器基地址等初始化 */ iosDevAdd(pSm, /smi/0, drvNum); }应用层随后可以open(/smi/0, O_RDWR, 0)然后通过ioctl传入 PHY 地址、寄存器地址和读写标志。iosDrvInstall返回的驱动号相当于文件系统里驱动表的索引iosDevAdd则是将设备的路径和驱动绑定起来。这样整个 SMI 总线就变成了 vxworks 应用编程接口中的一个文件设备网络协议栈和上层工具都可以通过它来访问 PHY。4. 在 vxworks 上把这套代码跑起来编译、加载和应用层调用4.1 BSP 中添加驱动源文件并初始化拿到代码后第一步不是编译而是确认板级配置里两个 GPIO 的端口号和引脚号。打开 BSP 目录下的config.h或者sysLib.c找到 GPIO 管脚定义区把 MDC 和 MDIO 的端口基地址、引脚号换成实际电路图上的值。资源代码里通常用宏隔开板级差异比如#ifdef SIM_GPIO_MDC_PIN。/* sysLib.c 或 config.h 中 */ #define SMI_GPIO_MDC_PORT 0x44E00000 /* GPIO 控制器基地址示例 */ #define SMI_GPIO_MDC_PIN 23 #define SMI_GPIO_MDIO_PORT 0x44E00000 #define SMI_GPIO_MDIO_PIN 24然后在 BSP 初始化列表里加入smiDevInit()。vxworks 的 BSP 启动流程中usrInit之后的usrKernelInit或sysHwInit阶段都可以挂驱动。我这里习惯放在sysHwInit里因为它在设备驱动初始化阶段执行时序上不会和网络协议栈抢资源。注意smiDevInit中不要做太多耗时的 GPIO 操作如果 PHY 初始化需要等待尽量放到smi_open时候做懒加载避免拖慢 BSP 启动。4.2 加载驱动并编译进内核镜像如果链接的是 vxworks 镜像工程直接把smi_drv.c文件加入工程依赖并在sysLib.c中调用smiDevInit。如果用的是vxWorks内核态模块则编译成.o文件通过ld命令加载到内存里然后手动调用初始化函数。替换镜像里的驱动后重新烧写系统启动后可以在 shell 下用devs命令查看设备节点。# vxworks shell 下 - devs如果看到/smi/0说明驱动注册成功。这一步跑到这里很多新手会忽略一个验证点在访问设备前先用示波器量一下 MDC 引脚有没有时钟翻转。如果始终是平线大概率是初始化没执行或者 GPIO 配置错了。我踩过这个坑后来养成了先量波形再调码的习惯。4.3 应用层通过 open/ioctl 读写 PHY 寄存器驱动挂好后应用层可以用标准 POSIX 接口操作。#include ioLib.h #include smi_dev.h int fd; SMI_IOCTL_REQ req; fd open(/smi/0, O_RDWR, 0); if (fd ERROR) { printf(open smi failed\n); return -1; } req.op SMI_READ; req.phy_addr 1; req.reg_addr 1; /* PHY 状态寄存器 */ req.data 0; ioctl(fd, SMI_IOCTL_REQ, (int)req); printf(PHY ID: 0x%04x %04x\n, req.data, req.data2); /* 示例 */ close(fd);ioctl的第二个参数是命令码第三个参数是请求结构体指针。在驱动内部smi_ioctl会根据req.op调用smi_read_reg或smi_write_reg。这里有个细节要注意req里的phy_addr和reg_addr字段一定要用无符号类型因为 PHY 地址 0 是合法的如果用了有符号类型并且传入 0有可能会被误判为错误参数。很多芯片的 PHY 地址默认是 0 或者 1和网口在板级硬件上的 strapping 引脚相关。4.4 用一个脚本验证多个寄存器连续读取为了快速验证驱动我一般会在 shell 里写一小段脚本连续读取某个 PHY 的 0~31 个寄存器观察有没有读到非零且符合预期的数据。寄存器 2 和 3 是 PHY 标识符高 16 位和低 16 位几乎不可能全为零。如果读出来全是0xffff或0x0000说明总线上可能根本没有设备或者时序没有成功。/* 伪代码连续读取验证 */ for (reg 0; reg 32; reg) { req.op SMI_READ; req.phy_addr phy; req.reg_addr reg; ioctl(fd, SMI_IOCTL_REQ, (int)req); printf(reg[%02d] 0x%04x\n, reg, req.data); }看到 PHY ID 寄存器有值整个 GPIO 模拟链路基本就通了。后面如果网口驱动需要读取 PHY 模式状态可以直接复用这套ioctl接口。5. 避坑指南GPIO 模拟 MDIO 最容易翻车的五个细节5.1 MDC 时钟频率过快导致 PHY 完全无响应现象用示波器量 MDC 引脚有明显翻转但读写 PHY 寄存器全部返回0xffffPHY 偶尔能被识别但概率很低。原因MDIO 协议规定 MDC 最大 2.5MHz但 GPIO 模拟的延迟循环受 CPU 频率和编译器优化影响。如果一个时钟周期实际只有几百纳秒PHY 内部的输入建立时间不足或者同时采样到不稳定电平帧结构就乱套了。解决把延迟参数调大让 MDC 频率降到 500kHz 以下。我在板子上最终用的半周期是 2 微秒即 MDC 频率 250kHz稳定运行很久。不要为了追求速度去压榨时序这个模拟驱动的目的是功能可用不是性能。5.2 GPIO 方向切换不及时导致读写数据错乱现象读操作时前 16 位数据有几位错值或者读出数据中混入了自己发送的地址位。原因在 turnaround 阶段代码发送完地址后立即把 MDIO 设为输入但 GPIO 方向切换本身需要几十 ns 的时间。如果切换动作发生在时钟上升沿附近PHY 可能还没有驱动总线主机就采到了自己上一次输出电平的余晖。解决在发完地址后先空转一个时钟周期再开启输入模式然后再跑一个时钟周期作为 turnaround 的第二个沿。相当于把 turnaround 阶段人为拉长一个周期代价是每次读操作多 4 微秒左右但可靠性大幅提升。5.3 在中断或临界区里调用驱动导致系统卡死现象网口驱动在中断处理函数里调用读 PHY 寄存器结果是整个系统直接挂死看门狗定时重启。原因中断上下文中锁信号量semTake(WAIT_FOREVER)一旦锁被别的任务占着中断等待锁而此时该任务没有被调度形成死循环。另一种情况是udelay依赖 tick中断里关闭了中断抢占ticks 不再增长delay 永远等不完。解决把 PHY 寄存器读写的调用移出中断上下文。中断里只设置一个标志位让低优先级任务去执行实际读写。如果非要留在中断里则使用自旋锁和 busy-loop 延时不要调用任何和调度器相关的函数。5.4 多个 PHY 共享总线设备地址写错了直接找不到现象板卡上有四个网口四个 PHY 共用一对 MDIO/MDC配置了不同的 PHY 地址。驱动只能读到某个 PHY其他地址返回0xffff。原因PHY 地址并不是单纯靠代码里的phy_addr参数就行的。它由硬件引脚的上下拉决定比如常用地址00001对应 PHYAD[4:0] 引脚全部拉低或特定组合。如果板级配置和实际硬件不一致自然会找不到。解决逐个读取 32 个可能地址打印出来观察哪些地址能返回非全ff的数据。然后把这个地址固化到 BSP 配置表里。注意有些 PHY 在低功耗模式下地址会变化需要先通过 GPIO 控制 PHY 的复位引脚让它正常启动再访问。5.5 编译器优化掉延时循环导致时序完全乱套现象调试版驱动一切正常编译优化等级调高后PHY 寄存器读写立刻失效。原因空循环没有副作用编译器在优化时可能直接将其删除或者反复执行顺序发生变化。比如for (i 0; i 1000; i);这种代码在 -O2 下可能被优化成一条指令。解决使用 vxworks 内核提供的sysInDelay或tickDelay如果实在没有用volatile修饰循环变量或者调用一个外部不可内联的函数来完成延迟。我一般写一个delay_tight(unsigned int n)内部访问一个volatile寄存器来消耗时间这样编译器无法优化掉。static void delay_tight(unsigned int n) { volatile unsigned int i; for (i 0; i n; i) { /* 空循环volatile 防止被优化 */ } }6. 二次开发把这套驱动改造成自己的板子和协议栈6.1 调整时序参数适配不同 PHY 的 MDC 最高频率不同厂家的 PHY 对 MDC 频率要求不一样多数支持 25MHz 以上但也有一些老型号最大 2.5MHz。改造时不要复用原有延迟参数直接做一次参数标定把 MDC 半周期从 1us 开始每次加 0.5us读一次 PHY ID 寄存器直到读数稳定。这个流程我每次换板子都会重做一遍因为 CPU 主频变了GPIO 写指令的执行时间也会变。参数标定用一段测试代码来完成会比手动改宏高效得多。可以加一个smi_ioctl命令动态修改SMI_DEV里的half_period字段这样不用每次重编镜像。typedef struct { uint32_t half_period_us; uint32_t mdc_polarity; uint32_t mdio_drive_strength; } SMI_CONFIG;6.2 让驱动支持多个 PHY 的轮询如果总线上挂了多个 PHY常用做法是在ioctl里传入 PHY 地址让底层读写函数每次都带着地址去访问。这已经够用。但如果需要批量读取多个 PHY 的状态可以把每个 PHY 的地址、寄存器列表做成配置表在驱动内部循环处理减少应用层的ioctl调用次数。typedef struct { uint8_t phy_addr; uint16_t reg_addr; uint16_t value; } PHY_ACCESS_ITEM;驱动里增加一个批量命令接收指针和数量逐条执行。注意批量命令持有信号量的时间变长其他任务访问 SMI 的等待时间也会变长所以批量长度不要超过 64 条避免实时任务被饿死。6.3 增加错误重试和超时机制SMI 总线不像 SPI 有明确错误帧回报只能靠读取结果判断。如果连续多次读到0xffff大概率是 PHY 掉电或者总线故障。我改造这套驱动时加了个简单的重试逻辑每次读操作失败后重新发送一次 preamble再尝试读写连续三次失败才返回错误。这个逻辑看起来简单但在实际板子上救了很多次因为某些 PHY 在电源波动时会出现短暂不响应。重试的时候注意要重新发送完整的 32 个 1 的 preamble而不是从失败位置继续否则 PHY 的状态机依然不认。这算是血泪经验一开始只在出错位重发数据结果问题没解决后面加了完整帧重写才好。另外这套驱动里我习惯把每个 PHY 寄存器读操作的 ms 耗时常量暴露成宏。如果以后要移植到一个主频更低的 CPU 上只需要改这个宏不用碰其他逻辑。现在每次拿到新板子我都会先用这套驱动把每个 PHY 的 ID 寄存器刷一遍确认 GPIO 配置和时序都对了才继续调底层网络驱动。希望这个流程能帮你少踩几个坑。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑