简介:面向嵌入式开发者的STM32网络管理接口实践源码包,聚焦MDIO总线在STM32F103VGT6平台上的实现,并附带通过IIC读写RTL8367RB交换芯片的完整代码,适合需要打通MCU与交换芯片管理通道、调试PHY寄存器读写的工程师参考。压缩包共498个文件,约3.14MB,以327个C源文件与92个头文件为主体,涵盖HAL库外设驱动、DSP运算库及MDIO/IIC业务逻辑,另有43个汇编文件、28个ICF链接脚本与uvprojx、ioc等工程配置,可直接导入Keil与STM32CubeMX复现。资源实现了串口指令交互,便于在调试时下发命令观察MDIO时序与RTL8367RB寄存器状态。已有3395人学习下载,可作为MDIO协议理解、IIC器件驱动移植与交换芯片调试的参考工程,帮助读者缩短底层通信联调周期。
1. 从一块调不通的 PHY 说起:这套 STM32 MDIO 源码到底解决什么问题
去年帮朋友调一块带交换芯片的板子,IIC 读写寄存器一切正常,换成 MDIO 读 PHY 的 ID 寄存器,返回值永远是 0xFFFF。示波器一挂,MDC 时钟有,MDIO 数据线在 turnaround 阶段电平不对——问题不在芯片,在时序和方向切换。这套STM32 MDIO 源码就是冲着这类场景来的:用 STM32 的 GPIO 模拟 MDIO 总线,去访问 RTL8367RB 这类交换芯片内部的 PHY 寄存器,同时把 IIC 那条链路也一并封装好。它适合两类人:一是手上板子 PHY 调不通、需要一份能直接对照的位操作实现;二是想给自家 STM32 工程补一套轻量 MDIO 驱动、不想引入整套协议栈的开发者。源码不大,但把 MDIO 帧格式、时序延时、读写方向切换这些容易翻车的点都摊开写在了代码里。
2. 架构与目录:GPIO 模拟 MDIO 的分层设计
2.1 为什么不用硬件外设而选 GPIO 模拟
STM32 部分型号自带以太网 MAC,配套有硬件 MDIO 接口,但一旦你用的是 F1、F0 这类没有 ETH 外设的片子,或者 MAC 引脚已经被别的功能占用,硬件 MDIO 就走不通了。更现实的情况是:RTL8367RB 这类交换芯片的管理口往往挂在任意两个普通 GPIO 上,硬件外设根本对不上脚。所以这套源码的核心思路很直接——把 MDC 当时钟线、MDIO 当双向数据线,全部用 GPIO 推挽/开漏输出加输入切换来模拟,时序完全由软件延时控制。
这样做的代价是要自己保证 MDC 频率不超过芯片手册上限(RTL8367RB 的 MDC 一般不超过 25MHz,实际工程里压到 1MHz 左右更稳),好处是引脚随便选、移植到任何 STM32 型号都只改几个宏。常见做法是把延时做成可调的宏,先用大延时跑通,再逐步压缩。
2.2 目录结构与模块职责
源码按职责切成三层,读的时候按这个顺序看不会乱:
mdio_stm32/ ├── inc/ │ ├── mdio_gpio.h # 引脚宏定义、延时宏、方向切换宏 │ ├── mdio_core.h # MDIO 帧读写接口声明 │ └── iic_soft.h # IIC 软件模拟接口声明 ├── src/ │ ├── mdio_gpio.c # GPIO 初始化、MDC/MDIO 电平操作 │ ├── mdio_core.c # 帧组装、读写时序、turnaround 处理 │ └── iic_soft.c # IIC 起始/停止/字节收发 └── example/ └── main.c # 读 PHY ID、读写寄存器的调用示例mdio_gpio层只关心“把某根线拉高拉低、读某根线电平”,不含任何协议语义;mdio_core层负责拼 32 位帧、处理前导码、读写命令、PHY 地址、寄存器地址和 turnaround;iic_soft是并行的另一条管理通道,很多交换芯片既支持 MDIO 也支持 IIC 访问寄存器,两条路都留着方便对比调试。这种分层的好处是换芯片只动 core 层的地址位宽和时序参数,换 MCU 只动 gpio 层的宏。
2.3 关键宏与可配置参数
移植时真正要改的就集中在mdio_gpio.h顶部这几个宏,先看清楚再动手:
| 宏名 | 作用 | 典型取值 |
|---|---|---|
MDIO_MDC_PORT/PIN | MDC 时钟引脚 | 按实际板子填 |
MDIO_MDIO_PORT/PIN | MDIO 双向数据引脚 | 按实际板子填 |
MDIO_DELAY() | 半周期延时 | 空循环或__NOP()若干次 |
MDIO_READ_INPUT() | 切 MDIO 为输入 | 改 CRL/CRH 或 HAL 调用 |
MDIO_WRITE_OUTPUT() | 切 MDIO 为输出 | 同上 |
PHY_ADDR_DEFAULT | 默认 PHY 地址 | RTL8367RB 常见 0~4 |
延时宏是最容易被忽略的一项。很多人直接抄别人的for(i=0;i<10;i++);,结果换到主频更高的片子上 MDC 直接飙到十几兆,读出来全是错值。我一般会先用一个明显偏大的延时把功能跑通,再用示波器量 MDC 周期,按目标频率反推循环次数。
3. 核心代码拆解:一帧 MDIO 从组帧到读回
3.1 MDIO 帧格式与组帧逻辑
IEEE 802.3 的 MDIO 帧是 32 位:前导码 32 个 1(实际实现常省略或只发部分)、起始码01、读/写操作码10/01、5 位 PHY 地址、5 位寄存器地址、2 位 turnaround、16 位数据。这套源码把组帧拆成“逐位发送”的函数,逻辑一目了然:
/* mdio_core.c:写一帧 MDIO(读操作为例,data 为输出缓冲) */ static void mdio_send_bits(uint32_t value, uint8_t len) { for (int8_t i = len - 1; i >= 0; i--) { MDIO_MDC_LOW(); if (value & (1UL << i)) MDIO_MDIO_HIGH(); else MDIO_MDIO_LOW(); MDIO_DELAY(); MDIO_MDC_HIGH(); /* MDC 上升沿采样 */ MDIO_DELAY(); } } uint16_t mdio_read_reg(uint8_t phy, uint8_t reg) { uint16_t data = 0; MDIO_WRITE_OUTPUT(); mdio_send_bits(0xFFFFFFFF, 32); /* 前导码 */ mdio_send_bits(0x1, 2); /* 起始码 01 */ mdio_send_bits(0x2, 2); /* 读操作码 10 */ mdio_send_bits(phy & 0x1F, 5); /* PHY 地址 */ mdio_send_bits(reg & 0x1F, 5); /* 寄存器地址 */ MDIO_READ_INPUT(); /* turnaround 前切输入 */ MDIO_DELAY(); MDIO_MDC_HIGH(); MDIO_DELAY(); MDIO_MDC_LOW(); /* 第一个 TA 位 */ MDIO_DELAY(); MDIO_MDC_HIGH(); MDIO_DELAY(); MDIO_MDC_LOW(); /* 第二个 TA 位,PHY 驱动 */ for (int8_t i = 15; i >= 0; i--) { /* 读 16 位数据 */ MDIO_MDC_HIGH(); MDIO_DELAY(); if (MDIO_MDIO_READ()) data |= (1U << i); MDIO_MDC_LOW(); MDIO_DELAY(); } return data; }逻辑上有三个点必须说清。第一,前导码发 32 个 1 是为了让 PHY 同步时钟,有些实现只发 32 位里的部分,但发满最稳。第二,turnaround两位是方向切换的关键:读操作时第一位由主机驱动为高阻(这里切输入即可),第二位由 PHY 拉低表示它开始驱动数据线,所以切输入必须发生在第一个 TA 位之前,晚一步就会和 PHY 抢总线。第三,读数据是在 MDC 上升沿之后采样,代码里先拉高再读,顺序不能反。
参数上,phy和reg都只取低 5 位,超出范围会被截断,调用前自己保证合法。写操作把操作码换成0x1(01),turnaround 两位都由主机驱动,数据段改为发送,其余结构一致。
3.2 GPIO 方向切换与 IIC 复用
方向切换是这套代码里最容易翻车的地方。STM32 的 GPIO 在 F1 系列上要改 CRL/CRH 寄存器,HAL 库则用HAL_GPIO_Init重配。源码把这件事收进两个宏,避免在 core 层散落寄存器操作:
/* mdio_gpio.h:方向切换宏(以标准外设库风格示意) */ #define MDIO_READ_INPUT() do { \ GPIO_InitTypeDef g; \ g.GPIO_Pin = MDIO_MDIO_PIN; \ g.GPIO_Mode = GPIO_Mode_IPU; /* 上拉输入,防止悬空读错 */ \ GPIO_Init(MDIO_MDIO_PORT, &g); \ } while (0) #define MDIO_WRITE_OUTPUT() do { \ GPIO_InitTypeDef g; \ g.GPIO_Pin = MDIO_MDIO_PIN; \ g.GPIO_Mode = GPIO_Mode_Out_PP; \ g.GPIO_Speed = GPIO_Speed_50MHz; \ GPIO_Init(MDIO_MDIO_PORT, &g); \ } while (0)这里用上拉输入而不是浮空输入,是因为总线在 turnaround 间隙可能短暂无人驱动,浮空会读到随机电平,上拉能保证读到确定的 1。IIC 那条链路同理,iic_soft.c里 SDA 也是开漏加外部上拉、读时切输入,和 MDIO 的套路一致,所以两份代码可以互相参照着改。
3.3 读 PHY ID 的调用示例
拿到源码后第一件事不是读寄存器,是先读 PHY 的 ID 寄存器(寄存器 2 和 3),能读出非 0xFFFF、非 0x0000 的值,说明总线和时序基本对了:
/* example/main.c:上电自检 */ int main(void) { gpio_init(); /* MDC/MDIO 引脚初始化 */ uint16_t id_hi = mdio_read_reg(PHY_ADDR_DEFAULT, 2); uint16_t id_lo = mdio_read_reg(PHY_ADDR_DEFAULT, 3); uint32_t phy_id = ((uint32_t)id_hi << 16) | id_lo; if (phy_id == 0xFFFFFFFF || phy_id == 0x00000000) { /* 总线没通:先查引脚宏、再查延时、最后查 PHY 地址 */ } while (1) { } }PHY_ADDR_DEFAULT要按板子实际接线改,RTL8367RB 内部多个 PHY 的地址是固定的,扫 0 到 4 一般能命中。读出来的 ID 高 16 位是厂商 OUI,低 16 位是型号和版本,对照芯片手册能确认访问的确实是目标 PHY 而不是别的设备。
4. 跑起来:从裸机工程到第一次读到寄存器
4.1 工程搭建与文件接入
这套源码是纯 C 的裸机驱动,不依赖特定 IDE。用 Keil、IAR 或 STM32CubeIDE 都行,步骤一致:新建一个对应型号的工程,把src下三个.c加进编译列表,inc加进头文件路径,然后把mdio_gpio.h里的引脚宏改成自己板子的实际引脚。如果用的是 HAL 库工程,把方向切换宏里的标准库调用换成HAL_GPIO_Init即可,接口语义不变。
时钟初始化别忘:GPIO 端口时钟要在gpio_init()里使能,很多人移植后读不到值,最后发现是端口时钟没开,这种低级错误在赶进度时特别常见。
4.2 上电自检与波形验证
代码编译通过只是第一步,真正判断通没通要看两件事:读 PHY ID 是否合理,以及示波器上 MDC 频率和 MDIO 波形是否干净。建议按这个顺序验证:
- 只发前导码,用示波器看 MDC 是否有稳定方波,频率是否在目标范围。
- 读 PHY ID,看返回值是否落在合理区间。
- 读一个已知默认值的寄存器(比如控制寄存器复位后的值),和手册对照。
- 写一个可回读的寄存器,再读回来比对。
第 3、4 步能过,说明读写方向切换都没问题。如果第 2 步就失败,先别怀疑芯片,九成是引脚宏或延时。
4.3 与 RTL8367RB 的对接要点
RTL8367RB 的管理接口在芯片手册里有明确的 MDC/MDIO 时序要求,除了频率上限,还要注意上电后芯片需要一段初始化时间才能响应 MDIO。常见做法是在main里先延时几百毫秒再发起第一次读,否则可能读到全 1。另外这颗芯片的寄存器空间分页,访问高地址寄存器前要先写页寄存器,源码的mdio_core只提供单寄存器读写,分页逻辑要在应用层自己补——这也是二次开发时最常加的一层封装。
5. 避坑与排查:五个真实踩过的坑
5.1 读回恒为 0xFFFF
现象:PHY ID 两个寄存器都读回 0xFFFF。原因通常是 MDIO 线一直为高,PHY 根本没驱动总线,可能是引脚宏填错、PHY 地址不对,或者 MDC 频率超限导致 PHY 不响应。解决:先用示波器确认 MDC 有波形且频率合规,再扫一遍 PHY 地址 0 到 4,最后核对引脚宏和实际接线是否一一对应。
5.2 读回恒为 0x0000
现象:读回全 0。原因多半是 MDIO 被主机一直拉低,方向切换没生效——比如切输入时仍配成输出,或者上拉没开导致读到 0。解决:检查MDIO_READ_INPUT()是否真的把引脚改成了输入模式,输入模式选上拉,并在切换后加一个短延时再采样。
5.3 偶发读错、重读又对
现象:同一寄存器读十次错一两次。原因是延时太短,MDC 高电平时间不够 PHY 采样,或者方向切换和时钟沿挨得太近。解决:把MDIO_DELAY()加大一档先验证,确认是时序问题后再逐步压缩到手册允许的最小值,别一上来就追求高频。
5.4 写寄存器后读回不一致
现象:写进去的值和读出来的对不上。原因常见于写操作后没有等待芯片内部生效就立刻回读,或者写操作码、turnaround 位拼错。解决:写后加几微秒延时再读,并逐位核对写帧的操作码是否为01、TA 两位是否都由主机驱动。
5.5 换 MCU 后整体失效
现象:同一份代码换到另一块板子就读不到。原因是 GPIO 寄存器操作和延时循环都跟主频、库版本绑定。解决:把方向切换统一改成库函数调用,延时改成基于SysTick或DWT的微秒级延时,这样换片子只改引脚宏,不用重调循环次数。
6. 二次开发:把单寄存器读写封装成可维护的驱动层
源码给的是最小可用的位操作,真正放进项目里,我一般会在mdio_core之上再加一层“芯片抽象层”,把分页、批量读写、错误重试都收进去。核心思路是定义一个操作结构体,把读、写、延时三个函数指针挂上去,上层业务只调结构体方法,换芯片时换实现即可:
/* 芯片抽象层示意:把 MDIO 读写和分页逻辑收口 */ typedef struct { uint8_t phy; uint16_t cur_page; uint16_t (*read)(uint8_t phy, uint8_t reg); void (*write)(uint8_t phy, uint8_t reg, uint16_t val); } switch_dev_t; static int dev_read_page(switch_dev_t *dev, uint16_t reg, uint16_t *out) { uint16_t page = reg >> 5; if (page != dev->cur_page) { dev->write(dev->phy, REG_PAGE_SEL, page); /* 切页 */ dev->cur_page = page; } *out = dev->read(dev->phy, reg & 0x1F); return 0; }这样改的好处是:分页逻辑只写一次,业务代码不再关心寄存器地址怎么拆;读失败可以在这层加重试计数;将来换一颗同样走 MDIO 的交换芯片,只要换read/write的实现和页寄存器地址,上层不动。
验证方法上,我习惯在抽象层加一个自检函数,上电时把几个关键寄存器的默认值和手册比对,不一致就打印出来。改造时还有个小技巧:把MDIO_DELAY()做成运行时可调的全局变量,调试阶段用大延时,量产前再压到实测安全值,比反复改宏重编译省事得多。
血泪经验是,早期我图省事直接在业务代码里到处调mdio_read_reg,结果后来换芯片时改了三十多处,还漏了两处导致偶发读错。从那以后我每次接这类总线驱动,都强制先写一层抽象再往上堆业务,哪怕当时只有一颗芯片。希望这套源码和上面的拆解,能帮你少走一遍我踩过的弯路。
本文还有配套的精品资源,点击获取