ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

STM32 MDIO源码解析:RTL8367RB交换芯片寄存器读写与IIC复用避坑指南

STM32 MDIO源码解析:RTL8367RB交换芯片寄存器读写与IIC复用避坑指南

简介:这份源码资源面向使用STM32F103VGT6进行以太网交换芯片调试的嵌入式开发者,聚焦MDIO总线与IIC两种方式访问RTL8367RB的完整实现,并配套串口指令交互,可用于交换机寄存器读写验证、底层驱动移植与协议学习。压缩包共498个文件,约3.14MB,以327个C源文件与92个头文件为主体,涵盖HAL库外设驱动、DSP运算库及MDIO/IIC业务代码,另有43个汇编启动文件、28个IAR链接配置及工程与调试配置文件,工程结构完整,可直接导入编译。已有3395人学习下载,说明该方案在同类调试场景中具备一定参考价值。读者可从中获得MDIO时序与寄存器访问的落地写法、IIC读写RTL8367RB的通信流程、串口命令解析框架,以及STM32F1平台工程搭建与链接配置的参考,便于快速复现与二次开发。

1. 从一块调不通的交换芯片说起:这套 STM32 MDIO 源码到底解决什么问题

去年帮朋友救一个板子,现象很典型:STM32 主控跑得好好的,旁边挂的 RTL8367RB 交换芯片就是读不出 ID,MDIO 线上示波器一看,时钟有、数据也有,但芯片就是不认。翻了两天代码才发现,问题不在时序参数,而在 MDIO 帧的起始位和 turnaround 阶段被写成了 IIC 那套逻辑。这套STM32 MDIO 源码就是冲着这类场景来的——它把 MDIO(Management Data Input/Output)在 STM32 上的位操作实现、RTL8367RB 这类交换芯片的寄存器读写、以及和 IIC 复用同一组 GPIO 时的切换逻辑,全部拆成了可直接编译的工程代码。适合正在做交换机、网口扩展、工业网关的嵌入式开发者,尤其是手里有 RTL8367RB 但被 MDIO 时序卡住的人。关键词里的 STM32、MDIO、IIC、RTL8367RB 四个词,基本就是这套代码的全部战场。

2. 技术选型与工程结构:为什么用 GPIO 模拟而不是硬件外设

2.1 MDIO 与 IIC 的物理层复用关系

先把这个项目的核心矛盾讲清楚。RTL8367RB 的管理接口是 MDIO/MDC 两根线,而很多 STM32 板子为了省引脚,会把 MDC/MDIO 和 IIC 的 SCL/SDA 挂在同一组 GPIO 上,靠外部模拟开关或者干脆分时复用。这套源码的选型逻辑很直接:不用 STM32 的硬件 IIC 外设,也不用 ETH 外设自带的 MDIO,全部用 GPIO 位操作模拟。

原因有三个。第一,STM32 的 ETH MAC 自带 MDIO 接口,但它只认 PHY 的标准寄存器地址空间,RTL8367RB 的扩展寄存器(比如 VLAN 表、端口镜像配置)走的是 indirect access,硬件 MDIO 外设根本表达不了这种“先写地址寄存器、再读数据寄存器”的两段式访问。第二,硬件 IIC 外设的时序是固定的,MDIO 的帧格式(前导码 32 个 1、起始位 01、读写位、5 位 PHY 地址、5 位寄存器地址、2 位 turnaround)和 IIC 完全不是一回事,硬套外设只会把代码写成四不像。第三,GPIO 模拟的好处是时序可调,RTL8367RB 对 MDC 频率有上限要求(通常不超过 12.5MHz),模拟方式可以精确控制每个电平的保持时间,调试时改一个延时宏就能验证是不是时序问题。

常见做法是在工程里定义一个mdio_delay()宏,内部用__NOP()或者简单的循环计数,把 MDC 半周期控制在 200ns 到 400ns 之间。这个值不是拍脑袋定的,RTL8367RB 的数据手册里 MDC 最小高电平时间和最小低电平时间都有明确要求,低于下限芯片会丢帧。

2.2 目录结构与文件职责

这套源码的工程结构不复杂,但分层很清楚,照着这个结构放文件,移植到别的 STM32 型号上基本只改两个文件:

MDIO_STM32/ ├── Core/ │ ├── Inc/ │ │ ├── mdio.h # MDIO 帧格式定义、寄存器地址宏 │ │ ├── rtl8367rb.h # 交换芯片寄存器映射与 API 声明 │ │ └── board_gpio.h # GPIO 引脚定义与复用切换 │ └── Src/ │ ├── mdio.c # MDIO 位操作核心:起始、读写、turnaround │ ├── rtl8367rb.c # 芯片初始化、端口配置、VLAN 操作 │ └── board_gpio.c # MDC/MDIO 与 IIC 的引脚模式切换 ├── App/ │ └── main.c # 初始化流程与测试用例 └── Drivers/ └── stm32f1xx_hal_conf.h

mdio.c是最底层,只负责“把 48 位帧按位推出去、把 16 位数据读回来”,不关心对面挂的是 RTL8367RB 还是别的 PHY。rtl8367rb.c是芯片适配层,里面全是寄存器地址和位域操作。board_gpio.c处理复用切换——这一步很多人会漏掉,如果 MDC/MDIO 和 IIC 共引脚,切换时必须先把 IIC 外设关掉、把引脚重新配成推挽输出,否则 IIC 的从机状态机会干扰 MDIO 的电平。

提示:如果你的板子 MDIO 是独立引脚,board_gpio.c里的切换函数可以直接留空,但不要删掉,留着方便以后改板。

2.3 关键宏定义与寄存器映射

mdio.h里最值得看的是帧格式相关的宏。MDIO 的帧结构是固定的 48 位,前 32 位是前导码全 1,接着是起始位01,然后是操作码(读10、写01),再是 5 位 PHY 地址、5 位寄存器地址,最后 2 位 turnaround。读操作时 turnaround 是Z0(高阻后由从机拉低),写操作是10。

/* mdio.h - MDIO 帧格式与操作码定义 */ #define MDIO_PREAMBLE_LEN 32 /* 前导码 32 个 1 */ #define MDIO_START_BITS 0x01 /* 起始位 01,注意不是 IIC 的 START */ #define MDIO_OP_READ 0x02 /* 读操作码 10 */ #define MDIO_OP_WRITE 0x01 /* 写操作码 01 */ #define MDIO_PHY_ADDR_DEFAULT 0x00 /* RTL8367RB 默认 PHY 地址 */ #define MDIO_REG_MAX 0x1F /* 5 位寄存器地址,最大 31 */ /* RTL8367RB 常用寄存器(indirect access 的入口) */ #define RTL8367RB_REG_CHIP_ID 0x1300 /* 芯片 ID,读出来应该是 0x6367 */ #define RTL8367RB_REG_PORT_CTRL 0x1301 /* 端口控制 */ #define RTL8367RB_REG_VLAN_CTRL 0x1302 /* VLAN 控制 */

这里有个容易翻车的点:MDIO_START_BITS写成0x01表示先发 0 再发 1,但有些开发者会习惯性写成 IIC 的起始条件(SCL 高时 SDA 拉低),结果帧头就错了。MDIO 的起始位是在 MDC 低电平期间准备数据、高电平期间采样,和 IIC 的边沿触发逻辑完全不同。代码里mdio_write_bit()的实现必须严格按这个节拍来。

3. 核心代码拆解:一个 MDIO 读周期从起始到返回的完整链路

3.1 位操作原语:MDC 节拍与数据建立时间

所有 MDIO 操作最终都落到两个函数上:写一位和读一位。这两个函数的实现质量决定了整个链路能不能通。

/* mdio.c - 位操作原语 */ static void mdio_write_bit(uint8_t bit) { MDIO_MDC_LOW(); mdio_delay(); /* 数据在 MDC 低电平期间建立 */ if (bit) { MDIO_MDIO_HIGH(); } else { MDIO_MDIO_LOW(); } mdio_delay(); /* 保持时间,满足 RTL8367RB 的 setup 要求 */ MDIO_MDC_HIGH(); mdio_delay(); /* MDC 高电平期间从机采样 */ MDIO_MDC_LOW(); mdio_delay(); } static uint8_t mdio_read_bit(void) { uint8_t bit; MDIO_MDC_LOW(); mdio_delay(); MDIO_MDIO_INPUT(); /* 切换为输入,释放总线 */ mdio_delay(); MDIO_MDC_HIGH(); mdio_delay(); bit = MDIO_MDIO_READ() ? 1 : 0; /* MDC 高电平期间读取 */ MDIO_MDC_LOW(); mdio_delay(); return bit; }

逻辑说明:写位时,数据必须在 MDC 上升沿之前稳定,所以先拉低 MDC、设数据、再拉高 MDC。读位时,主机要先释放 MDIO 线(切成输入),否则会和从机的输出打架。参数上,mdio_delay()的时长决定了 MDC 频率,如果 RTL8367RB 读 ID 不稳定,第一个要调的就是这个延时——先往大了调,确认通了再逐步压缩。

3.2 读寄存器完整帧:前导码、turnaround 与数据拼接

单个读周期的帧是 48 位,其中前 32 位前导码、2 位起始、2 位操作码、5 位 PHY 地址、5 位寄存器地址、2 位 turnaround,最后 16 位数据。代码里用循环把前 32 位前导码推出去,然后逐段拼帧。

/* mdio.c - MDIO 读寄存器 */ uint16_t mdio_read_reg(uint8_t phy_addr, uint8_t reg_addr) { uint16_t data = 0; int i; /* 1. 前导码:32 个 1,让从机同步 */ for (i = 0; i < MDIO_PREAMBLE_LEN; i++) { mdio_write_bit(1); } /* 2. 起始位 01 */ mdio_write_bit(0); mdio_write_bit(1); /* 3. 操作码:读 = 10 */ mdio_write_bit(1); mdio_write_bit(0); /* 4. PHY 地址 5 位,高位先发 */ for (i = 4; i >= 0; i--) { mdio_write_bit((phy_addr >> i) & 0x01); } /* 5. 寄存器地址 5 位 */ for (i = 4; i >= 0; i--) { mdio_write_bit((reg_addr >> i) & 0x01); } /* 6. turnaround:主机释放,从机拉低 */ MDIO_MDIO_INPUT(); mdio_read_bit(); /* 第一个 turnaround 位,从机驱动 */ mdio_read_bit(); /* 第二个 turnaround 位 */ /* 7. 读 16 位数据,高位先出 */ for (i = 0; i < 16; i++) { data = (data << 1) | mdio_read_bit(); } return data; }

这段代码里最容易被忽略的是第 6 步。turnaround 阶段主机必须把 MDIO 切成输入,如果还保持推挽输出,从机拉低时会出现驱动冲突,轻则读回全 1,重则芯片进入异常状态。另外,PHY 地址和寄存器地址都是高位先发,循环里i从 4 递减到 0,写反了就会读到隔壁寄存器的值——这种 bug 最阴,因为读出来的数据看起来“像那么回事”,但配置就是不生效。

3.3 RTL8367RB 的 indirect access 封装

RTL8367RB 的扩展寄存器不能直接通过 MDIO 的 5 位地址访问,它用的是“先写地址、再读写数据”的间接访问机制。rtl8367rb.c里把这套流程封装成了两个函数。

/* rtl8367rb.c - 间接读寄存器 */ uint16_t rtl8367rb_indirect_read(uint16_t reg) { uint16_t val; /* 第一步:把目标寄存器地址写到地址寄存器 */ mdio_write_reg(MDIO_PHY_ADDR_DEFAULT, RTL8367RB_REG_ADDR, reg); /* 第二步:触发读操作 */ mdio_write_reg(MDIO_PHY_ADDR_DEFAULT, RTL8367RB_REG_CTRL, 0x0001); /* 第三步:等待操作完成,轮询状态位 */ do { val = mdio_read_reg(MDIO_PHY_ADDR_DEFAULT, RTL8367RB_REG_STATUS); } while ((val & 0x0001) == 0); /* 第四步:从数据寄存器取结果 */ return mdio_read_reg(MDIO_PHY_ADDR_DEFAULT, RTL8367RB_REG_DATA); }

逻辑说明:RTL8367RB_REG_ADDR、RTL8367RB_REG_CTRL、RTL8367RB_REG_STATUS、RTL8367RB_REG_DATA这四个是芯片固定的间接访问窗口寄存器,具体地址在rtl8367rb.h里定义。轮询那一步不能省,RTL8367RB 的间接操作需要几个 MDC 周期才能完成,如果写完 CTRL 立刻读 DATA,拿到的往往是上一次的旧值。参数上,reg是 16 位扩展寄存器地址,比如读芯片 ID 时传RTL8367RB_REG_CHIP_ID,正常应该返回0x6367。

3.4 初始化流程与端口配置示例

main.c里的初始化顺序是有讲究的:先切 GPIO 复用、再复位芯片、最后配端口。顺序错了会出现“配置写进去但端口不亮”的情况。

/* main.c - 初始化与测试 */ int main(void) { HAL_Init(); SystemClock_Config(); board_gpio_init(); /* 先把 MDC/MDIO 配成推挽输出 */ board_mdio_iic_switch(MDIO_MODE);/* 如果和 IIC 复用,切到 MDIO */ /* 读芯片 ID 验证链路 */ uint16_t chip_id = rtl8367rb_indirect_read(RTL8367RB_REG_CHIP_ID); if (chip_id != 0x6367) { /* 链路不通,检查接线和延时 */ while (1); } /* 配置端口 0-4 为自协商,端口 5 为固定 1000M 全双工 */ rtl8367rb_port_config(0, PORT_AUTO_NEG); rtl8367rb_port_config(5, PORT_FORCE_1000F); while (1) { /* 应用逻辑 */ } }

board_mdio_iic_switch()这个函数在复用场景下必须先调用,它内部会关掉 IIC 外设时钟、把引脚从开漏改成推挽。如果板子上 MDIO 是独立引脚,这个函数可以只留一个空实现,但调用点要保留,方便以后改板时不用动main.c。

4. 避坑与排查:MDIO 调不通时先看这五条

4.1 读回全 1 或全 0

现象:mdio_read_reg()返回0xFFFF或0x0000,芯片 ID 读不出来。原因通常是 turnaround 阶段没有把 MDIO 切成输入,主机和从机同时驱动总线,电平被拉死。解决:检查mdio_read_reg()第 6 步,确认MDIO_MDIO_INPUT()在第一个 turnaround 位之前调用,并且mdio_read_bit()内部没有再切回输出。

4.2 芯片 ID 读出来是 0x6367 但配置不生效

现象:链路验证通过,但写端口配置后端口状态没变化。原因多半是间接写操作少了“等待完成”的轮询,写完 CTRL 立刻去配下一个寄存器,前一个操作还没落地。解决:所有 indirect 操作后面都加状态位轮询,超时时间给 10ms 足够,超时后返回错误码而不是死等。

4.3 和 IIC 复用引脚时 IIC 设备失联

现象:切到 MDIO 后交换芯片正常,但切回 IIC 后 EEPROM 读不到。原因是切换函数只改了 GPIO 模式,没有重新初始化 IIC 外设的时钟和引脚复用寄存器。解决:board_mdio_iic_switch()里两个方向都要做完整处理,切回 IIC 时重新调用HAL_I2C_Init(),并且把引脚配回开漏加外部上拉的模式。

4.4 MDC 频率过高导致偶发读错

现象:常温下正常,温度一高或者换一批板子就偶发读错。原因是mdio_delay()用的是__NOP()循环,编译器优化等级一变,实际延时跟着变。解决:延时函数加volatile计数或者用定时器做基准,不要依赖 NOP 的指令周期;MDC 频率先按手册下限的一半来配,稳定后再往上调。

4.5 前导码不足 32 位

现象:某些批次的 RTL8367RB 能通,某些不通。原因是前导码循环被优化或者宏定义被改小,从机同步失败。解决:MDIO_PREAMBLE_LEN固定为 32,不要为了“省时间”改成 16 或 8,MDIO 协议里前导码的作用就是让从机锁定时钟相位,少一位都可能出问题。

5. 二次开发:把这套代码改成多芯片适配与自动化验证

这套源码拿来直接用没问题,但真正做产品时,板子上往往不止一颗交换芯片,或者同一份代码要兼容 RTL8367RB 和别的型号。我一般会做两件事:把芯片适配层抽象成函数指针表,再加一个上电自检流程。

先看抽象层的改法。rtl8367rb.c里的函数签名统一成mdio_chip_ops结构体:

/* mdio_chip.h - 芯片操作抽象 */ typedef struct { uint16_t chip_id; uint16_t (*read_reg)(uint16_t reg); int (*write_reg)(uint16_t reg, uint16_t val); int (*port_config)(uint8_t port, uint32_t mode); } mdio_chip_ops; /* 注册表示例 */ static const mdio_chip_ops rtl8367rb_ops = { .chip_id = 0x6367, .read_reg = rtl8367rb_indirect_read, .write_reg = rtl8367rb_indirect_write, .port_config = rtl8367rb_port_config, };

这样main.c里只持有const mdio_chip_ops *chip,初始化时读 ID 匹配到哪个就挂哪个 ops。加新芯片时只写一个新的xxx_ops文件,底层mdio.c一行不用动。参数上,chip_id用来做运行时探测,read_reg/write_reg的寄存器地址宽度按芯片实际来,RTL8367RB 是 16 位,有些芯片是 8 位,抽象层里用uint16_t兼容。

第二件事是上电自检。我在main.c里加了一个mdio_selftest(),流程是:读芯片 ID → 写一个测试寄存器再读回比对 → 遍历所有端口读链路状态。任何一步失败就通过串口打印错误码,而不是死循环。验证方法很土但有效:拿一块已知好的板子和一块可疑板子对比自检输出,错误码能直接定位到是链路层、寄存器访问层还是端口配置层的问题。

改造点改动文件验证方式
多芯片适配新增mdio_chip.h、各芯片 ops 文件换芯片后读 ID 和端口配置正常
上电自检main.c加mdio_selftest()串口输出各步骤 PASS/FAIL
延时可调mdio.c的mdio_delay()改为宏或变量不同优化等级下 MDC 频率一致

血泪经验是:改完抽象层后一定要把原来的单芯片流程完整跑一遍再提交,我有一次改完 ops 表忘了把rtl8367rb_indirect_write的返回值补上,编译不报错,但写配置全部静默失败,查了一下午才发现是函数指针返回了未初始化值。从那以后我每次动抽象层,都强制走一遍“读 ID → 写测试寄存器 → 读回比对 → 端口配置 → 读链路状态”这五步自检,少一步都不提交。希望这套代码和这些踩坑记录能帮到你,少走几天弯路。

本文还有配套的精品资源,点击获取

返回列表