简介:GPIO模拟SMI(MDIO)协议软件实现,提供了一套轻量级、可直接集成的驱动源码。它面向需要在MCU/SOC上实现软件SMI读写,但硬件不具备MDIO控制器或希望灵活复用任意引脚的嵌入式开发者。设计上允许指定任意MDC与MDIO端口,移植时只需对接少量底层函数;若使用HAL库平台,则可无修改直接编译运行。源码经过高频压力测试,连续读写寄存器1000万次以上仍保持稳定,适合用于PHY芯片配置、SFP光模块信息读取等场景。资源包共2个文件,分别是1个头文件与1个C源文件,整体仅3KB,结构紧凑、便于理解与二次开发。目前已有900余人学习下载,对从事网络通信、底层驱动开发的工程师具有直接参考价值。
1. 没有MDIO控制器的MCU,怎么用两根GPIO把PHY管起来
很多MCU在设计时并没有独立的MDIO控制器,或者控制器引脚被复用到了其他外设上,可项目里那颗PHY还等着配置。GPIO模拟SMI(MDIO)协议软件实现,就是拿两个普通GPIO引脚按IEEE 802.3管理接口的时序,把MDC时钟和MDIO数据线用软件一点一点“抠”出来,完成对PHY内部寄存器的稳定读写。这套做法最大的价值在于:不依赖芯片自带外设,凡是能输出电平、能读电平的引脚都能用,所以换一颗MCU或者换一个SoC,只改底层几个函数就能搬过去。适合做BSP移植、硬件调试以及对底层时序敏感的人。
2. 拆开SMI帧结构:软件模拟的本质是精确踩准时序边界
2.1 一帧MDIO报文的构成:从前导码到数据位
MDIO总线只有两根线,MDC是时钟,MDIO是双向数据。主机(MCU)负责产生MDC时钟,并根据操作类型在MDIO上发送命令字、读取PHY反馈的数据。PHY永远不会主动发起通信,它只在主机给的时钟边沿上被动响应。
IEEE 802.3 Clause 22定义了一帧完整的MDIO帧,读写都是64位,但每一位的归属不同。最容易被忽略的是前导码,它由32个连续的1组成,用来让PHY检测到总线进入同步状态。很多第一次写驱动的人会下意识少发几个前导位,结果PHY完全不响应。前导码之后是ST(2位,固定01)、OP(2位,读为10写为01)、PHYAD(5位,PHY的地址)、REGAD(5位,寄存器地址)、TA(2位)和DATA(16位)。
| 字段 | 位数 | 读操作 | 写操作 |
|---|---|---|---|
| PRE | 32 | 全1 | 全1 |
| ST | 2 | 01 | 01 |
| OP | 2 | 10 | 01 |
| PHYAD | 5 | PHY地址 | PHY地址 |
| REGAD | 5 | 寄存器地址 | 寄存器地址 |
| TA | 2 | Z0(第一位高阻,第二位PHY驱动为0) | 10(主机驱动) |
| DATA | 16 | PHY输出 | 主机输出 |
PREAMBLE可以多于32位,但不能少于32位。有些老PHY或者经过隔离变压器后的管理通道,对前导码的个数更敏感,这时候适当增加前导位会提升稳定性。PHYAD和REGAD都是高位在前,先发bit4再发bit0,这和I2C那种先发高位的习惯一致,但写移位代码时特别容易把方向写反,后面避坑部分会细说。
2.2 读和写的时序差异:MDIO方向切换发生在哪个时钟沿
SMI的时序核心就一句话:下降沿之后数据才允许变化,上升沿采样数据。无论读写都遵循这个规则,区别只在于谁在驱动MDIO。
写操作时,MDIO整帧都由主机驱动。主机在MDC低电平期间把数据放到MDIO上,然后拉高MDC产生上升沿,PHY在这个上升沿把数据锁存进去。也就是说,每一个MDC周期的顺序是:低电平改数据 -> 等待 -> 高电平让PHY采样 -> 等待 -> 下一个周期。这与SPI模式0很像,很多人按SPI模式3去写,把数据变化放到上升沿之后,就会导致采到的数据错位。
读操作比写操作多一个关键动作:方向切换。主机发送完PHYAD和REGAD之后,在TA的第一位必须把MDIO释放掉,也就是把GPIO从输出模式切到输入模式。此时PHY才会接管MDIO总线,在TA的第二位拉低应答,接着在后续16个时钟的下降沿之后依次输出数据位。主机在上升沿采样。如果这个方向切换晚了一个时钟周期,主机还在驱动总线,PHY根本拉不动MDIO,读回来的数据就会全变成主机自己发出的电平。
2.3 延时精度怎么选:忙等待与定时器的取舍
软件模拟SMI,本质上是在精确控制时间。MDC时钟频率上限是2.5MHz,对应周期400ns,高低电平最短不能低于160ns。但这只是上限,不是为了跑快而跑快,我一般把MDC控制在1MHz左右,半周期500ns,这样即使延时函数被中断打扰十几微秒,也不会破坏协议的最低要求。
忙等待是最常用的做法,用一个for循环空转消耗时间。它的问题是延时精度依赖编译器优化等级和主频,换优化选项可能就变了。稍微可靠一点的做法是用Cortex-M内核的DWT计数器,或者直接用一个通用定时器做耗时统计。考虑到这个驱动要“快速移植到任意MCU/SOC”,我建议在抽象层预留一个delay_us函数指针,具体实现按平台填,但不用追求微秒级精确,只要确保实际延时不低于设定值即可。
判断延时够不够有一个简单标准:抓一下MDC波形,高电平和低电平的实测宽度都不低于160ns,就满足协议要求。宁可把周期做成1.5MHz甚至1MHz,把高低电平拉宽,也不要为了性能把时序压到临界点。
3. 写一个能跨MCU移植的软件SMI驱动:抽象层加两层操作逻辑
3.1 先定硬件抽象层:把MDC和MDIO全部用函数指针隔离
想让驱动换芯片只改少量代码,第一步就是把所有和寄存器、引脚相关的操作统一抽象成函数指针。底层只需要提供5个能力:写MDC电平、写MDIO电平、读MDIO电平、切换MDIO方向、延时。
/* smi_hal.h */ #ifndef SMI_HAL_H #define SMI_HAL_H #include <stdint.h> typedef struct { void (*mdc_write)(uint8_t level); /* 设置MDC引脚输出电平 */ void (*mdio_write)(uint8_t level); /* 设置MDIO引脚输出电平,仅在输出模式下有效 */ uint8_t (*mdio_read)(void); /* 读取MDIO引脚当前电平 */ void (*mdio_dir)(uint8_t output); /* 切换MDIO方向,1=输出,0=输入 */ void (*delay_ns)(uint32_t ns); /* 纳秒级延时,实际精度可低于请求值 */ } smi_hal_t; typedef struct { smi_hal_t hal; uint32_t half_period_ns; /* MDC半周期,高低电平各保持多少纳秒 */ } smi_bus_t; #endif函数指针的字段名写清楚之后,移植的人不用关心PHY是什么型号,只需要把5个函数填上。mdio_dir是很多初版驱动最容易漏掉的一个接口,没有它,读操作的方向切换就只能靠重新初始化GPIO,代码会变得很难看。
初始化总线时,把底层函数填进去即可。以下以一个Cortex-M架构的MCU为例,这部分的重点是展示“如何把一个平台的GPIO操作塞进抽象层”,而不是绑定某个具体厂商的库。
/* platform_smi.c */ #include "smi_hal.h" #include "gpio.h" /* 这里换成平台实际的GPIO驱动头文件 */ static void mdc_set(uint8_t level) { gpio_write(MDC_GPIO_PORT, MDC_GPIO_PIN, level); } static void mdio_set(uint8_t level) { gpio_write(MDIO_GPIO_PORT, MDIO_GPIO_PIN, level); } static uint8_t mdio_get(void) { return gpio_read(MDIO_GPIO_PORT, MDIO_GPIO_PIN); } static void mdio_dir(uint8_t output) { if (output) { gpio_set_output(MDIO_GPIO_PORT, MDIO_GPIO_PIN); } else { gpio_set_input(MDIO_GPIO_PORT, MDIO_GPIO_PIN); } } static void delay_ns_impl(uint32_t ns) { /* 实际工程里换成平台延时函数,例如定时器或DWT轮询 */ volatile uint32_t loops = ns / 10; while (loops--) { __asm volatile("nop"); } } smi_bus_t g_smi = { .hal = { .mdc_write = mdc_set, .mdio_write = mdio_set, .mdio_read = mdio_get, .mdio_dir = mdio_dir, .delay_ns = delay_ns_impl, }, .half_period_ns = 500, /* 对应1MHz MDC */ };这里有个关键点:mdio_dir切换的是MDIO引脚方向,MDC始终保持输出模式。延时函数里用了volatile uint32_t防止编译器把循环优化掉,但如果换到高频MCU上,直接用DWT或定时器更靠谱。half_period_ns = 500意味着MDC周期2us,远低于2.5MHz上限,给中断处理留出了余量。
3.2 写操作实现:下降沿改数据,上升沿被采样
写操作整帧都是主机驱动,所以核心动作是“先把MDC拉低,再放数据,等稳定后拉高”。发送单个位的函数可以直接复用,把一串位按顺序发出去就构成写帧。
/* smi_core.c */ #include "smi_hal.h" /* 发送逻辑电平为level的一位,完整走过一个MDC周期:低->放数据->高->回低 */ static void smi_send_bit(smi_bus_t *bus, uint8_t level) { bus->hal.mdc_write(0); bus->hal.mdio_write(level); bus->hal.delay_ns(bus->half_period_ns); bus->hal.mdc_write(1); bus->hal.delay_ns(bus->half_period_ns); } /* 发送前导码与命令头,然后写16位数据 */ int smi_write_reg(smi_bus_t *bus, uint8_t phy_addr, uint8_t reg_addr, uint16_t data) { uint8_t i; /* 前导码:32个1 */ for (i = 0; i < 32; i++) { smi_send_bit(bus, 1); } /* ST=01 */ smi_send_bit(bus, 0); smi_send_bit(bus, 1); /* OP=01(写) */ smi_send_bit(bus, 0); smi_send_bit(bus, 1); /* PHYAD 5位,高位在前 */ for (i = 0; i < 5; i++) { smi_send_bit(bus, (phy_addr >> (4 - i)) & 1); } /* REGAD 5位,高位在前 */ for (i = 0; i < 5; i++) { smi_send_bit(bus, (reg_addr >> (4 - i)) & 1); } /* TA=10(写操作的应答阶段仍由主机驱动) */ smi_send_bit(bus, 1); smi_send_bit(bus, 0); /* DATA 16位,高位在前 */ for (i = 0; i < 16; i++) { smi_send_bit(bus, (data >> (15 - i)) & 1); } /* 结束后把MDC拉低,MDIO拉高,让总线回到空闲态 */ bus->hal.mdc_write(0); bus->hal.mdio_write(1); return 0; }注意读和写在OP和TA两个字段的差异。写帧的TA是10,不是Z0,很多改驱动的人直接把读帧的流程套到写帧上,结果写操作一直失败。写帧的DATA由主机在低电平期间准备好,PHY在上升沿锁存,所以数据建立时间等于整个低电平持续时间,500ns对任何PHY都足够。
3.3 读操作实现:TA位期间切换方向,在第16个上升沿前取数
读操作比写操作麻烦,关键在TA位。发完REGAD之后,TA的第一位要产生时钟,但MDIO必须在这段时间里释放。我习惯的做法是:单独处理TA第一位,先拉低MDC,把MDIO切到输入模式,再走完这个时钟周期。从TA第二位开始,之后的每一位都由PHY驱动,主机只负责在上升沿之后采样。
/* 读取一位数据:低电平期间等待PHY更新,高电平后延时再采样 */ static uint8_t smi_recv_bit(smi_bus_t *bus) { uint8_t val; bus->hal.mdc_write(0); bus->hal.delay_ns(bus->half_period_ns); bus->hal.mdc_write(1); /* 避开上升沿附近的抖动,延迟1/4周期再采样 */ bus->hal.delay_ns(bus->half_period_ns / 4); val = bus->hal.mdio_read(); bus->hal.delay_ns(bus->half_period_ns * 3 / 4); return val; } int smi_read_reg(smi_bus_t *bus, uint8_t phy_addr, uint8_t reg_addr, uint16_t *data) { uint8_t i; uint16_t value = 0; uint8_t ta_bit; for (i = 0; i < 32; i++) { smi_send_bit(bus, 1); } /* ST=01 */ smi_send_bit(bus, 0); smi_send_bit(bus, 1); /* OP=10(读) */ smi_send_bit(bus, 1); smi_send_bit(bus, 0); /* PHYAD 5位 */ for (i = 0; i < 5; i++) { smi_send_bit(bus, (phy_addr >> (4 - i)) & 1); } /* REGAD 5位 */ for (i = 0; i < 5; i++) { smi_send_bit(bus, (reg_addr >> (4 - i)) & 1); } /* TA第一位:释放MDIO总线 */ bus->hal.mdc_write(0); bus->hal.mdio_dir(0); /* 切换为输入,释放总线 */ bus->hal.delay_ns(bus->half_period_ns); bus->hal.mdc_write(1); bus->hal.delay_ns(bus->half_period_ns); /* TA第二位 + 16位数据,共17个采样周期 */ ta_bit = smi_recv_bit(bus); if (ta_bit != 0) { /* 正常情况下TA第二位是0,读到1说明PHY可能未响应 */ /* 但这里不立即返回,继续读完16位便于调试 */ } for (i = 0; i < 16; i++) { value = (uint16_t)((value << 1) | smi_recv_bit(bus)); } /* 读结束后把MDIO切回输出,并拉高,释放总线占用 */ bus->hal.mdio_dir(1); bus->hal.mdio_write(1); bus->hal.mdc_write(0); *data = value; return 0; }读采样放在上升沿之后的1/4周期,而不是上升沿瞬间。原因是GPIO在边沿附近读到的电平可能不稳定,PHY的数据在下降沿后就已经更新,上升沿之后它还至少要保持10ns,所以延迟采样反而更安全。ta_bit可以当作PHY是否存在的粗略判断:读到低电平说明PHY应答了,否则大概率是地址不对或PHY没上电。
3.4 换个MCU怎么移植:只改4个底层函数
整个驱动的移植面被压缩得非常小。迁移到另一颗MCU时,需要修改的只有初始化时的那5个函数:MDC输出、MDIO输出、MDIO输入、MDIO方向切换、延时。核心协议状态机完全不用动。
第一步,在原理图上确认MDC和MDIO接了哪个引脚,确认引脚没有被其他外设复用。第二步,实现GPIO读写和方向切换,这一步直接调用新平台提供的GPIO库或者操作寄存器。第三步,把延时函数换成新平台的可用实现。如果新平台主频很高,原来的for循环延时会大幅变短,务必重新标定一次。第四步,初始化总线结构体时,把引脚编号和端口基地址替换掉。
移植完最容易犯的错误是只改了输出和读取函数,忘了MDIO输入模式下是否需要使能内部上拉。有些MCU的GPIO默认是浮空输入,MDIO在释放总线时如果没有外部上拉,电平就会乱飘,读回来全是不确定值。建议在移植清单上加一条:确认MDIO引脚有外部上拉电阻,或者使能内部上拉。
4. 把读写调到稳定可靠:时序参数、上拉电阻与异常恢复
4.1 时序参数换算:MDC周期、建立时间和保持时间一次算清
SMI的稳定不是靠“写得快”,而是靠“留得够”。MDC时钟频率由半周期决定,跟采样时刻相关的几个时间点,建议全部以半周期为基准来分配。以下是一组我常用的参数配置,对应1MHz MDC。
| 参数 | 数值 | 说明 |
|---|---|---|
| half_period_ns | 500ns | MDC高、低电平各500ns,周期1MHz |
| 写数据建立时间 | 500ns | MDC低电平期间完成MDIO写入 |
| 写数据保持时间 | 500ns | MDC高电平期间保持不变 |
| 读采样延迟 | 125ns | 上升沿后1/4周期读取 |
| 重试间隔 | 10ms | 每次操作超时上限 |
2.5MHz是时钟频率上限,不是推荐值。MDC在400ns周期下,高低电平各自只有160ns可分配,再加上GPIO翻转本身的上升沿下降沿损耗,留给建立时间的窗口非常窄。降到1MHz之后,所有时间窗口成倍放宽,换来的是极高的稳定性。
延时标定有一个实操技巧:不直接改延时函数里的循环次数,而是调half_period_ns这个配置。抓波形发现MDC实测频率偏低,就调小这个值;偏高就调大。这样协议代码完全不用动,只改一个结构体字段,实测调试效率高很多。
4.2 硬件上三个必做的准备:上拉、电平容忍和引脚模式
软件调得再好,硬件上少一个上拉电阻,读操作就会间歇性抽风。MDIO在TA第一位被主机释放后,总线进入高阻状态,PHY接管之前总线电平必须被明确钳住,这个任务靠上拉电阻完成。上拉电阻阻值建议在2kΩ到10kΩ之间,太小时MDIO低电平可能拉不下去,太大时抗干扰能力弱。
第二个是电平匹配。很多百兆PHY的MDIO引脚是2.5V电平,而MCU的GPIO是3.3V。直接互联在多数情况下能工作,但长期可靠性存疑,最好加电平转换电路或者选择容忍5V输入的引脚。如果MCU引脚本身不支持电平容忍,写操作可能正常,读操作却会把2.5V的高电平误判成低电平。
第三个是引脚模式。MDIO引脚在初始化时就要配置成既能输出又能输入的模式,并且关闭外设复用。有的MCU引脚默认被调试器占用,比如有些芯片的SWDIO引脚可能和MDIO复用,需要检查启动配置。MDC引脚则只做推挽输出,不需要开漏。
4.3 软件上的两条保险:超时退出和错误帧重试
SMI总线没有硬性的超时机制,如果PHY因为内部状态异常而一直不释放MDIO,主机就会卡在读操作里一直发时钟。所以软件层需要加一个超时保护:每次读操作开始时记录时间戳,整个读帧超过设定时间就放弃并复位总线。
int smi_read_reg_timeout(smi_bus_t *bus, uint8_t phy_addr, uint8_t reg_addr, uint16_t *data, uint32_t timeout_ms) { uint32_t start = timer_get_ms(); while (1) { /* 如果MDIO一直为低,说明PHY可能把总线钳住了 */ if (bus->hal.mdio_read() == 0) { /* 给PHY一点恢复时间 */ bus->hal.delay_ns(20000); if (timer_get_ms() - start > timeout_ms) { /* 连续输出64个时钟,让PHY退出异常状态 */ for (int i = 0; i < 64; i++) { bus->hal.mdc_write(0); bus->hal.delay_ns(100); bus->hal.mdc_write(1); bus->hal.delay_ns(100); } return -1; } } else { return smi_read_reg(bus, phy_addr, reg_addr, data); } } }超时复位并不是为了让每次通信都成功,而是防止异常状态下整个管理通道永久卡死。连续输出64个时钟是一种常见的总线复位手法,很多PHY在收到异常数量的时钟后会重新同步。
错误重试方面,我一般做三层:读寄存器失败一次就重新完整发一帧,前导码和命令全部重新走;连续3次失败则做一次超时复位;复位后还是失败,才把错误上报到上层。这样既不会让上层看到偶发错误,也不会掩盖真正的硬件故障。
5. 常见问题与避坑:全FF数据、波形异常与偶发失败
5.1 排坑一:读回0xFFFF且MDIO一直为高
现象:调用读函数,返回的数据始终是0xFFFF,用示波器抓MDIO,发现它一直维持高电平,根本没有数据变化。
原因分两种。第一种是MDIO在TA阶段没有真正释放,GPIO方向切换函数写成了空操作,主机始终在驱动高电平,PHY拉不动总线。第二种是MDIO引脚缺少上拉电阻,主机释放后总线浮空,读数随机。
解决:先确认mdio_dir(0)真的把引脚切到了输入模式,最简单的方法是把MDIO引脚对地短接,如果还能读到1说明方向切换没生效。再检查外部上拉电阻,没有的话临时把引脚内部上拉打开应急。
5.2 排坑二:读ID寄存器得到0x0000,不是0xFFFF
现象:读PHY ID寄存器2和寄存器3,返回值全是0x0000,但PHY地址确认无误。
原因:这是采样时序过于提前。如果主机在上升沿的瞬间读取MDIO,而GPIO输入在边沿附近有回沟或建立延迟,就可能采到旧值。尤其是PHY的数据刚好在上升沿附近变化时,采到0的概率很大。
解决:把采样点从“上升沿立即读”改成“上升沿之后延时1/4周期再读”。另外检查读取代码是不是误用了读输出寄存器而不是输入寄存器,有些MCU的GPIO读输出寄存器和读输入寄存器是两个地址,写代码时容易搞混。
5.3 排坑三:偶发读写失败,失败率不高但很随机
现象:驱动能跑通,但长时间运行会偶发一次读回错误bit,复现率不固定。
原因:中断打乱了时序。如果中断发生在“写MDIO数据”和“拉高MDC”之间,MDIO的数据建立时间被压缩到极短,PHY在上升沿采样时数据还没稳定。另一种情况是中断延长了MDC低电平时间,导致实际频率低于预期,但这通常不会造成错误。
解决:在发送每一位和采样每一位的临界区关中断,或者至少保证MDIO写入到MDC上升沿之间有一段不受中断影响的最小延时。我习惯在smi_send_bit里先mdio_write,再空转几个nop指令,最后拉高MDC,这样即使有中断也能保住最低10ns的建立时间。
5.4 排坑四:换了一颗MCU,移植后完全不工作
现象:在原来的MCU上跑得好好的,换到另一颗内核或主频更低的MCU上,读写全部失败。
原因:移植时只改了GPIO操作,忘了延时函数是基于旧主频标定的。新主频变高或变低,for循环空转的实际时间完全变了。如果新平台主频变快,半周期实际只有几十纳秒,MDC频率远超PHY上限;如果变慢,整个流程会被拉得很慢,但这通常还能工作。
解决:移植后第一件事是抓MDC波形,实测高电平宽度。至少保证高低电平都大于160ns,并把实际频率记下来。调延时函数时,用示波器看的是半周期而不是整帧时间,因为整帧时间还包含了函数调用开销。
6. 验证驱动好不好的实用技巧:逻辑分析仪与压力自测
验证一套软件SMI驱动,我从来不看一次读写是否成功,要看三个指标:波形是否符合IEEE 802.3、大量读写是否零失败、异常恢复后是否还能回来。
先抓波形。逻辑分析仪采样率建议至少10MHz,两个通道分别接MDC和MDIO。触发条件设为MDC下降沿触发,然后展开看一帧。重点看三个位置:前导码是不是32个连续的1;读操作的TA第一位之后MDIO是否变成高阻;DATA位是否在每个上升沿前稳定。如果前导码只有31个1,基本可以断定是循环边界写错。
然后做压力自测。挑一个可读可写的PHY寄存器,写入随机值再读回,循环1万次,统计失败次数。这个测试必须在真实PHY上跑,并且建议在PHY连接网线、网络有业务的场景下做。原因是PHY在正常工作时会频繁调整内部状态,总线竞争比空闲时更激烈,暴露出来的问题也更真实。
uint32_t smi_stress_test(smi_bus_t *bus) { uint32_t fail = 0; uint16_t w, r; for (uint32_t i = 0; i < 10000; i++) { w = (uint16_t)(i * 2654435761u); if (smi_write_reg(bus, 0x1F, 0x10, w) != 0) { fail++; } else { if (smi_read_reg(bus, 0x1F, 0x10, &r) != 0) { fail++; } else if (r != w) { fail++; } } } return fail; }测试通过的标准是1万次零失败。如果出现几次失败,不要急着调快MDC,反而应该把频率降下来再跑一轮。很多问题是频率太快导致时序裕量不足,降频后立刻消失,那就说明驱动本身没有结构性问题,只是参数余量不够。
进阶玩法是把MDC频率做成动态可调,在应用初始化时用较慢的频率跑通,再逐步提升到硬件允许的最快值。这样做能顺带验证PCB布线质量,也能给不同PHY留出适配空间。我后来每次接到新平台,都会先把这套压力测试跑一遍再合入正式工程,它帮我挡掉了不少“换一颗芯片就翻车”的情况。
希望帮到你。
本文还有配套的精品资源,点击获取