在做嵌入式网络设备底软和硬件调试时我经常遇到一个很折腾的处境手里主控的以太网 MAC 已经接好外挂的 PHY 芯片也焊在板子上但负责管理配置的 MDIO 接口要么没有对应的硬件控制器要么引脚被硬件工程师复用给了别的功能。最后翻遍原理图能给出来的只有几个空闲 GPIO。这时候“使用 GPIO 模拟 MDIO 接口配置以太网 PHY”就成了唯一靠谱的出路——用代码把 MDIO 的时序一位一位“敲”出来完成对 PHY 寄存器的读写。这篇文章我不会去讲现成的 MDIO 控制器怎么用而是聚焦在没有控制器、只有 GPIO 的情况下如何把 PHY 配置跑通。内容包括 MDIO 协议本身的帧格式与时序规则、Linux 内核里现成的 mdio-gpio 接入方式、裸机/RTOS 下的状态机实现、怎么用逻辑分析仪验证波形以及工程落地时容易踩的上拉、电平域、采样沿等一堆坑。适合正在做网络接口驱动调试、板级 bring-up、硬件验证的工程师也适合第一次被迫拿 GPIO 去“抠”协议的朋友收藏备用。1. 为什么放着现成的 MDIO 控制器不用非要拿 GPIO 去模拟1.1 最常见的三种现实场景第一类场景是主控压根没有硬件 MDIO 控制器。很多低成本 MCU 或入门级 SoC虽然内部集成了以太网 MAC但为了省面积、省引脚并没有把 MDIO 管理接口做成独立外设。MAC 可以把 MII/RMII 的数据线、时钟线正常工作但你找不到任何寄存器能直接代表 MDC 和 MDIO 两根线的行为。此时 PHY 的寄存器配置就成了悬空问题只能靠 GPIO 代劳。第二类场景是引脚冲突。有些主控本身支持 MDIO但硬件设计的时候MDC/MDIO 引脚被复用到了别的功能上比如 LCD 的像素接口、PCIe 的辅助信号或者干脆被引去做了调试串口。硬件已经流片回来、PCB 已经量产这个时候要改设计周期太长软件被迫走 GPIO 模拟把 PHY 管理信号从别的引脚“借”出来。第三类场景是调试阶段的临时探测需求。新板子第一次上电PHY 的地址可能和原理图标的不一致寄存器映射文档也可能存在错误。这时候如果用硬件 MDIO还得先改设备树、配置地址成本很高而用 GPIO 模拟写一个人扫地址的小工具几分钟就能把挂在总线上的所有 PHY 的 ID 读出来快速定位器件枚举和硬件连接问题。1.2 硬件 MDIO 控制器和 GPIO 模拟的本质区别GPIO 模拟并不是什么黑魔法。MDIO 本质上就是一根时钟线加一根数据线的两线串行协议只要 CPU 能精确控制 GPIO 的电平和方向就完全可以复现同样的时序。区别主要体现在速度、CPU 占用和时序精度上。对比维度硬件 MDIO 控制器GPIO 模拟工作时钟标准 SMI 最高 2.5MHz实际建议降到 100kHz~1MHzCPU 占用下发命令后等待中断或轮询完成每一位都由 CPU 翻转 GPIO读操作耗时尤其明显时序精度硬件逻辑保证建立保持时间依赖软件延时和 GPIO 操作开销抖动较大引脚约束必须绑定控制器的固定引脚任意 GPIO 可用切换灵活适用场景量产产品常规方案调试、验证、低成本系统、临时替代我见过不少工程师第一次做 GPIO 模拟时直接按 2.5MHz 来翻转 GPIO结果波形乱得一塌糊涂。原因很简单硬件控制器的时序是专用逻辑电路生成的精确到纳秒级而软件里一次 GPIO 翻转可能要经历寄存器写、缓存刷写、系统调度打断等一堆事情在几百纳秒的窗口里很难保证稳定。与其和时钟上限较劲不如直接降速稳定压倒一切。2. 动手写时序前必须吃透 MDIO 帧结构和电平约定2.1 MDIO 帧格式逐位拆解MDIO 总线上的管理帧结构是固定的无论读还是写都要按顺序发送以下字段。字段名位宽说明PREAMBLE32全为 1用于建立同步ST2固定为 01表示 Clause 22 标准帧OP2读操作 10写操作 01PHYAD5PHY 地址范围 0~31REGAD5寄存器地址范围 0~31TA2读时第一位高阻、第二位由 PHY 驱动为 0写时固定为 10DATA16要读取或写入的寄存器数据这里最容易被忽略的是 TA 段。很多人习惯性地把整帧当成纯串行数据发出去忽略了一个关键事实读操作时MDIO 线上的控制权要从主设备切换到 PHY 设备。也就是在 TA 的第一个位周期内主设备要释放总线把 GPIO 方向从输出切到输入然后由 PHY 驱动第二个位为低电平再接着输出 16 位数据。这个方向切换就是 GPIO 模拟 MDIO 和硬件控制器最大的区别。硬件控制器内部的 I/O 缓冲器会在 TA 段自动切换方向而 GPIO 模拟必须自己控制方向寄存器。切换早了或晚了读回来的数据就会错位。2.2 读、写、地址扩展三种帧的区别平时接触最多的百兆/千兆以太网 PHY基本都支持 Clause 22 的标准管理帧也就是上面表格里的格式。读帧和写帧的差别只在 OP 字段读帧ST01OP10TAZ0然后读回 16 位数据写帧ST01OP01TA10然后发送 16 位数据地址扩展帧主要用于 Clause 45 方式访问更复杂的 PHYOP00ST00中间还会插入扩展地址字段一般用于 10G PHY 或者某些厂商私有寄存器如果最开始只为了配置普通千兆 PHY我建议先把 Clause 22 的标准读/写跑通这能覆盖 90% 的日常需求。Clause 45 的地址扩展帧等真正遇到需要访问扩展寄存器时再去实现否则一上来就图大而全只会让排查问题变得困难。2.3 时序参数里最容易忽略的三点第一个是时钟频率。标准 SMI 的 MDC 最高 2.5MHz对应半个时钟周期 200ns。GPIO 模拟实际很少跑满原因前面说过。我自己的经验是初始版本先跑 500kHz 左右也就是每个半周期 1us。这个速度下普通 GPIO 翻转加几个空循环的延时就能稳定工作逻辑分析仪抓波形也足够清楚。第二个是建立保持时间。MDIO 数据在 MDC 上升沿被采样所以发送端要保证每次数据变化都发生在 MDC 上升沿之前足够远的窗口。最简单的稳妥策略是在 MDC 下降沿之后改变 MDIO 电平保持到下一个上升沿再下降沿时继续改数据。这样做数据从变化到采样之间有整整半个时钟周期的稳定时间怎么都不会采错。第三个是方向切换延迟。GPIO 从输出模式切到输入模式不是瞬间完成的。方向寄存器写入生效需要几个时钟周期同时 MDIO 线上的电容电荷也需要时间释放。所以在切换方向后不能立刻采样至少要多等几百纳秒。很多调试失败都栽在这一步方向切了但采样太早读到的是上一次输出的残留电平。3. 驱动实现的两种路线对比内核 bitbang 机制与裸机状态机3.1 内核里最省事的方案直接复用 mdio-gpio 驱动如果你的系统跑的是 Linux第一步先不要急着自己写位时序。内核源码里已经存在一个通用的 mdio-bitbang 抽象以及基于它实现的 mdio-gpio 平台驱动。这套机制可以把两根 GPIO 封装成一个 mii_bus后面对 PHY 的访问全走标准接口位时序的编解码由内核帮你完成。设备树里的写法类似这样mdio0: mdio { compatible virtual,mdio-gpio; #address-cells 1; #size-cells 0; mdc-gpios gpio0 5 GPIO_ACTIVE_HIGH; mdio-gpios gpio0 6 GPIO_ACTIVE_HIGH; ethernet-phy2 { reg 2; device_type ethernet-phy; }; };注意几点mdc-gpios 对应 MDC 时钟线mdio-gpios 对应 MDIO 数据线两个 GPIO 方向由驱动内部管理。compatible 字符串在不同内核版本和厂商 BSP 里可能不一样有的要按自己 BSP 给的专用字符串来填不能照抄。phy 子节点的 reg 就是 PHY 地址要和硬件实际布线一致。配置好设备树后内核会把这个节点注册成一个 MDIO 总线。PHY 驱动通过标准接口读写的寄存器底层逻辑其实是走到了 GPIO 翻转。如果你只是想接个 PHY 跑网络功能这一层根本不用关心可以说非常省事。3.2 如果用的是厂商 SDK 或自定义内核很多网络 SoC 用的是厂商提供的 SDK内核并不一定包含完整的 mdio-gpio 驱动。这时候可以自己实现一个轻量版的 bitbang 驱动。核心思路是向内核注册一个 mii_bus然后实现几个回调函数。static void gpio_mdio_set_mdc(struct mii_bus *bus, int val) { gpiod_set_value_cansleep(priv-mdc, val); } static void gpio_mdio_set_mdio_dir(struct mii_bus *bus, int dir) { gpiod_direction_output(priv-mdio, 1); if (dir MDIO_DIR_IN) gpiod_direction_input(priv-mdio); } static void gpio_mdio_set_mdio_data(struct mii_bus *bus, int val) { gpiod_set_value_cansleep(priv-mdio, val); } static int gpio_mdio_get_mdio_data(struct mii_bus *bus) { return gpiod_get_value_cansleep(priv-mdio); } static int gpio_mdio_reset(struct mii_bus *bus) { return 0; }这些回调分别对应时钟翻转、数据输出、方向切换和读取。内核的 bitbang 层会按协议帧要求逐位调用这些函数。真正和硬件打交道的只有这四个操作其他协议细节都可以交给框架。当然如果你的内核连 bitbang 框架都没有那就只能裸写时序了。3.3 裸机/MCU 下状态机的完整思路在裸机或者 RTOS 环境下没有内核框架帮忙代码更加直接。核心就是一个发送位函数和一个读取位函数。static void mdio_clock(void) { gpio_write(MDC_PIN, 1); delay_ns(1000); gpio_write(MDC_PIN, 0); delay_ns(1000); } static void mdio_send_bit(int bit) { gpio_set_dir(MDIO_PIN, GPIO_DIR_OUT); gpio_write(MDIO_PIN, bit); mdio_clock(); } static int mdio_read_bit(void) { int value; gpio_set_dir(MDIO_PIN, GPIO_DIR_IN); /* 释放总线 */ delay_ns(200); /* 让方向切换和线电平稳定 */ gpio_write(MDC_PIN, 1); delay_ns(500); value gpio_read(MDIO_PIN); gpio_write(MDC_PIN, 0); delay_ns(500); return value; }然后按照帧格式组装读写函数static int mdio_read_reg(int phy_addr, int reg_addr) { int i; int value 0; /* 32 位 PREAMBLE */ for (i 0; i 32; i) mdio_send_bit(1); /* ST 01 */ mdio_send_bit(0); mdio_send_bit(1); /* OP 10表示读 */ mdio_send_bit(1); mdio_send_bit(0); /* PHYAD 5 bit */ for (i 4; i 0; i--) mdio_send_bit((phy_addr i) 1); /* REGAD 5 bit */ for (i 4; i 0; i--) mdio_send_bit((reg_addr i) 1); /* TA第一个 bit 切换方向不发送第二个 bit 读回 0 */ gpio_set_dir(MDIO_PIN, GPIO_DIR_IN); mdio_clock(); /* TA 高阻期 */ mdio_read_bit(); /* TA 应为 0 */ /* 读 16 bit 数据 */ for (i 15; i 0; i--) { value | mdio_read_bit() i; } return value; }写函数也类似区别在于 OP 字段变成 01TA 字段直接发送 10然后发送 16 位要写入的数据。关键点还是那句话所有读相关的位置GPIO 必须切成输入所有发送相关的位置GPIO 必须切成输出。如果发现读回来的数据全是 1 或者全是 0第一检查方向切换第二检查外部上拉。我见过很多裸机实现为了省事把 MDIO 数据引脚一直配成输出读操作时也硬读。这种写法在信号质量好、线上电容小的实验室环境下可能碰巧能跑通但换一块板子就废。老老实实切换方向才能在不同硬件之间稳定复用。3.4 用户态调试工具和临时方案还有一类场景不需要长期跑这套模拟时序只是临时在某个板子上看几个 PHY 寄存器。这种需求可以写个用户态小程序通过 libgpiod 或者 sysfs 直接操作 GPIO把上面的 mdio_send_bit / mdio_read_bit 搬到用户态。用户态调试工具有个明显的缺点稳定性受系统调度影响可能中途被其他进程打断导致波形上出现异常宽的时钟脉冲。所以这种工具只建议用来看寄存器、验证思路不建议把它当作量产方案。如果你需要在 boot 阶段就为 PHY 完成配置最好还是把代码放进内核驱动或者 bootloader 阶段避开复杂调度环境。4. 波形验证与 ID 回读如何确认时序真正跑通4.1 第一步永远是从读 PHY ID 开始GPIO 模拟时序写完第一次上板测试千万不要急着读 BMSR 或者 BMCR。这些寄存器值的变化容易被误读而 PHY ID 寄存器是硬件固定值一旦正确读出来基本就能确认物理层链路已经建立。PHY ID 有两个寄存器0x02 和 0x03每个 16 位合并起来是 32 位 ID。如果 PHY 地址未知可以写一个循环从 0 到 31 依次读 0x02。常见的读回结果全 0xffff要么 MDIO 线没有上拉要么读操作时 GPIO 没有正确切成输入要么 PHY 根本没上电全 0x0000MDIO 线被强拉低或者 PHY 地址不对或者 PHY 处于复位状态一个看起来“有名有姓”的数值恭喜基本成功了拿到 ID 后还要再读 0x03把它和芯片手册里的 OUI/型号/版本字段做对比。如果 ID 能对上说明从帧格式到方向切换时序全部正确后面再读其他寄存器就有了底气。4.2 逻辑分析仪上应该看到什么样的波形没有逻辑分析仪的话GPIO 模拟调试会非常痛苦。我建议抓波形时采样率开到 20MHz 以上同时钩住 MDC 和 MDIO 两根信号线。正常的一帧读操作波形应该有这样的特征MDC 是一串规则的方波高低电平时长基本一致没有明显的宽窄抖动MDIO 在准备阶段保持高电平正好 32 个时钟周期进入 ST 字段后MDIO 开始按 01 变化在 TA 段第一个时钟周期内 MDIO 既不拉高也不拉低呈现高阻态逻辑分析仪上往往显示一个不确定电平或轻微毛刺之后 16 个数据位由 PHY 驱动波形应该和读到的寄存器值一致如果发现 MDC 波形有拉长或缩短多半是代码里某个操作的耗时不稳定比如打印日志、中断处理、GPIO 操作缓存未刷新。此时优先关掉调试输出或者把时钟降更慢。4.3 常见失败模式、原因与排查顺序现象最可能的原因建议排查步骤读回全为 0xffff无上拉、未切输入、PHY 未上电量 MDIO 静态电平查电源抓波形看 TA 段读回全为 0x0000线被拉死、PHY 地址不对、PHY 处于复位检查短路遍历地址读复位状态MDC 有方波但 MDIO 无响应PHY 处于掉电/复位态或地址错误操作 PHY 复位引脚确认地址跳线数据偶发错位采样窗口太窄、GPIO 切换过慢降低时钟频率增加建立时间和方向切换延时能读 ID 但配置不生效写帧 TA 字段发送错误或 PHY 进入低功耗抓写帧波形读回寄存器确认写入值4.4 用寄存器组合拳验证链路真正可用ID 能读出来只是第一步还不能说明整个链路真的能干活。我习惯在 GPIO 模拟跑通后立即做一轮寄存器读写组合验证。先从 BMCR 开始读回默认值确认与手册一致。然后写入 bit15 软复位不断轮询直到该位自动清零。随后读 BMSR看链路状态位和能力位是否和实际 PHY 能力匹配。再写 ANAR 配置自协商能力开启自协商最后回读刚才写过的寄存器确认写操作真的落盘了而不是只发了一帧假的时序。这套流程全部走通GPIO 模拟 MDIO 才算是真正可靠后续接起来上层驱动、进入网络协商也只是顺理成章的事情。5. 工程落地中的高频坑位与排查思路5.1 上拉电阻是最大的“隐形杀手”GPIO 模拟 MDIO 和硬件控制器最大的不同在于专用控制器通常自带内部上拉或者缓冲器驱动能力而 GPIO 模拟依赖的是外部电路。MDIO 是双向共享线标准规范里就要求外部上拉电阻阻值一般在 1.5kΩ 到 10kΩ 之间。很多板子在设计时为了省芯片和省静态电流把上拉电阻省略了。实验室环境下GPIO 输出阶段波形看起来没问题因为输出模式下 GPIO 本身在驱动但一旦切换到输入模式MDIO 线就变成悬浮状态。此时读回的全是 1看起来就像“PHY 没有应答”实际是物理层根本没把线拉下来。遇到离线第一反应是查上拉百试百灵。如果不确定板子上有没有上拉可以在调试阶段把 GPIO 内部上拉打开试试。虽然 GPIO 内部上拉阻值比较大效果不如外部电阻但足够验证协议本身是否正确。5.2 电平域不匹配时不能直接连很多主控的 GPIO 电平是 1.8V 或 2.5V而外挂 PHY 的 SMI 接口可能是 3.3V。直接连的话轻则信号识别不稳定重则烧坏引脚。这种情况必须加电平转换电路常见的是双向电平转换芯片。电平转换芯片会带来额外的上升沿延迟通常在几十纳秒量级。前面专门强调了 GPIO 模拟要降速其中一个重要原因就在这里慢时钟下电平转换引入的延迟不会成为定时瓶颈信号依然稳定。反过来如果强行跑 2.5MHz电平转换芯片的延迟叠加 GPIO 软件翻转的抖动采样的正确率会断崖式下降。5.3 采样沿错位写代码时的最常见失误MDIO 的数据在 MDC 上升沿被采样。如果代码里是“先抬高 MDC再修改 MDIO 数据”那么数据变化到上升沿之间的间隔可能只有几十纳秒甚至数据还没稳定就被采样了读回的结果经常出现单个 bit 翻转错误。最稳妥的节奏是MDC 低电平期间改变 MDIO 数据拉高 MDC触发采样保持 MDIO 数据稳定拉低 MDC进入下一拍可以这样记像 SPI 一样先把数据放到线上再打时钟采样数据不能和时钟同时跳变。所有 bit 操作都遵守这个顺序时序验证一次就过。5.4 中断、调度与实时性的影响GPIO 模拟 MDIO 的代码如果跑在普通线程里很容易被其他中断、调度、打印打断。打印一次日志可能耗时几百微秒而这一帧的时钟周期可能才几十微秒波形直接乱掉。解决思路有几个层次。调试阶段最直接的是关掉日志输出降低系统负载再跑时序。如果设备实际运行中需要在初始化阶段频繁操作 PHY则把 bitbang 代码放到高优先级任务或者关中断执行整个过程。更稳妥的是用硬件定时器产生时钟中断中断服务程序里翻转 GPIO 数据线这样时钟的均匀性明显好很多。5.5 临时方案和量产方案的边界GPIO 模拟 MDIO 在很多项目里被当作“临时方案”但实际落地时往往就转正了。低成本网络设备、工业网关这类对价格敏感的电路省掉一个专用 MDIO 控制器的成本用 GPIO 模拟替代是常见做法。如果真要把这套方案放进量产就要在上面的基础上加固维持足够的时钟低周期至少 1us 以上给系统调度和电平转换留余量每一次访问 PHY 寄存器都检查返回值合法性连续错误达到阈值就报错并重试PHY 地址通过配置项传入不要写死在代码里条件允许时仍然建议保留硬件 MDIO 控制器优先方案GPIO 模拟作为备选和调试通道说到底GPIO 模拟 MDIO 不是什么高深技术它的难点在于把细小的方向切换、时序窗口和硬件电路细节都处理到位。把这几点想清楚绝大多数 PHY 配置问题都能用一根 GPIO 敲出来。我第一次用 GPIO 模拟 MDIO 时折腾了大半天以为是帧格式写错最后发现只是 MDIO 引脚缺了一颗上拉电阻。后来我把那套 PHY dump 工具留了下来每次拿到新板子先不急着配系统先用 GPIO 模拟把 PHY ID 扫一遍再决定下一步怎么初始化。如果你也要用手头的 GPIO 去“抠”MDIO建议按这个顺序来先确认硬件上有没有上拉再抓一帧波形再读 ID最后再动配置。方向切换和采样时序处理对了之后你会发现问题比我当初遇到的简单得多。
阅读完成 · 觉得有帮助?