GPIO模拟MDC/MDIO驱动详解

该文章已生成可运行项目,

GPIO模拟MDC/MDIO驱动技术深度解析

在现代嵌入式网络设备开发中,一个看似不起眼的细节常常成为项目成败的关键:如何在没有专用MAC控制器的情况下,正确初始化并管理以太网PHY芯片?答案往往藏在一个简单却精巧的技术方案中—— 通过GPIO模拟MDC/MDIO时序

这不仅是一个“救急”手段,更是一种体现嵌入式系统灵活性与工程师智慧的经典实践。尤其在使用低成本MCU、FPGA或定制SoC时,硬件SMI(Station Management Interface)控制器可能并不存在,此时软件模拟就成为了打通物理层通信的唯一路径。


为什么需要GPIO模拟MDC/MDIO?

IEEE 802.3标准定义了MDC(Management Data Clock)和MDIO(Management Data Input/Output)两线接口,用于主机对PHY芯片的寄存器进行读写操作。它支持多达32个PHY设备挂载在同一总线上,每个PHY拥有32个16位控制/状态寄存器,如BMCR(控制)、BMSR(状态)、PHYID等。

典型的通信流程如下:
- 主机发送前导码唤醒所有PHY;
- 指定目标PHY地址与寄存器地址;
- 发起读或写操作;
- 在Turnaround阶段完成方向切换;
- 完成数据传输。

这套机制本应由硬件SMI模块自动处理。但现实是,许多主流MCU(如部分STM32系列、GD32、ESP32-S系列)并未集成该外设。即便是在一些RISC-V SoC或FPGA软核设计中,也常因资源限制而省略专用管理接口。

于是,“bit-banging”——即用软件精确控制GPIO高低电平变化来模拟协议波形——成为唯一可行的选择。


协议本质:不是SPI,也不是I²C

虽然MDC/MDIO看起来像其他串行总线,但它有自己独特的规则。

首先,它是 半双工双向数据线 结构。MDIO在大部分时间由主机驱动,但在读操作的“Turnaround”阶段必须释放,转为输入模式,由从设备(PHY)拉高或拉低输出数据。这意味着任何实现都必须支持运行时方向切换。

其次,其帧格式固定且包含多个关键字段:

字段 长度 内容
Preamble 32-bit 全‘1’,用于同步PHY
Start 2-bit 01 标识帧开始
Operation 2-bit 10 =写, 01 =读
PHY Addr 5-bit 0~31
Reg Addr 5-bit 0~31
Turnaround 2-bit Z0 (写)或 Zx (读,高阻态)
Data 16-bit 实际数据

其中最易出错的是Turnaround处理。例如,在读操作中,前两个bit应为高阻态(Z),随后PHY在下一个MDC上升沿开始驱动数据线。若主控未及时切换为输入模式,就会导致冲突或采样失败。

此外,整个过程对时序有一定要求。尽管标准允许MDC频率最高达2.5MHz(Clause 22),但软件模拟通常建议控制在500kHz~1MHz之间,以确保延时不被中断打乱。


软件模拟的核心挑战与突破点

要让一段代码真正可靠地工作,不能只看“能不能发出去”,而要看“是否能在各种环境下稳定运行”。

1. 精确延时 ≠ 死循环NOP

很多人初学时会写这样的延时函数:

void udelay(uint32_t us) {
    while (us--) for (volatile int i = 0; i < 10; i++);
}

这种做法严重依赖编译器优化和主频。一旦更换平台,时钟周期立刻失准。

更好的方式是结合系统滴答定时器或基于 SystemCoreClock 计算指令数。例如:

static void udelay(uint32_t us) {
    uint32_t n = us * (SystemCoreClock / 1000000U) / 8;
    while (n--) __NOP();
}

这里假设每条 __NOP() 耗时约8个周期,在100MHz下可实现微秒级精度。当然,若系统开启了高优先级中断(如DMA、Ethernet IRQ),仍需考虑关中断保护关键段。

2. GPIO方向切换必须无误

MDIO作为双向引脚,方向控制至关重要。典型错误出现在读操作中:Turnaround期间未能及时将GPIO设为输入,结果主控仍在尝试驱动低电平,而PHY也在输出,造成总线争抢。

正确的顺序应该是:

// 写完Reg Addr后
MDIO_DIR_OUTPUT();
MDIO_LOW();           // 输出Z0中的“0”
mdc_cycle();          // 第一个时钟边沿(不采样)
mdc_cycle();          // 第二个边沿到来前完成切换

MDIO_DIR_INPUT();     // 切换为输入,准备接收数据

注意:某些MCU的GPIO配置需要数个时钟周期生效,因此切换后最好加极短延时再进入采样阶段。

3. 上拉电阻不可忽视

由于MDIO通常是开漏结构,外部一般需接4.7kΩ上拉电阻。如果没有这个上拉,当PHY释放总线时,信号可能处于悬空状态,极易受干扰导致误读。

这一点在PCB布局时就要考虑清楚,否则即使软件逻辑完美,也会出现偶发性通信失败。


参考实现:可移植的C语言驱动框架

以下是一个经过实际验证的轻量级实现模板,适用于绝大多数ARM Cortex-M平台(也可适配Linux GPIO sysfs或裸机环境)。

接口定义(gpio_mdio.h)

#ifndef GPIO_MDIO_H
#define GPIO_MDIO_H

#include <stdint.h>

void mdio_init(void);
uint16_t mdio_read(int phy_addr, int reg_addr);
void mdio_write(int phy_addr, int reg_addr, uint16_t value);

#endif

底层抽象与宏封装(gpio_mdio.c)

#include "gpio_mdio.h"

// --- 用户需根据具体平台修改 ---
#define MDC_GPIO      GPIOB
#define MDC_PIN       GPIO_PIN_10
#define MDIO_GPIO     GPIOB
#define MDIO_PIN      GPIO_PIN_11

// 使用HAL库示例,可替换为LL、寄存器直写等
#define MDC_HIGH()    HAL_GPIO_WritePin(MDC_GPIO, MDC_PIN, GPIO_PIN_SET)
#define MDC_LOW()     HAL_GPIO_WritePin(MDC_GPIO, MDC_PIN, GPIO_PIN_RESET)
#define MDIO_HIGH()   HAL_GPIO_WritePin(MDIO_GPIO, MDIO_PIN, GPIO_PIN_SET)
#define MDIO_LOW()    HAL_GPIO_WritePin(MDIO_GPIO, MDIO_PIN, GPIO_PIN_RESET)
#define MDIO_READ()   HAL_GPIO_ReadPin(MDIO_GPIO, MDIO_PIN)

static void mdio_set_output(void) {
    GPIO_InitTypeDef gpio = {0};
    gpio.Pin = MDIO_PIN;
    gpio.Mode = GPIO_MODE_OUTPUT_PP;
    gpio.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(MDIO_GPIO, &gpio);
}

static void mdio_set_input(void) {
    GPIO_InitTypeDef gpio = {0};
    gpio.Pin = MDIO_PIN;
    gpio.Mode = GPIO_MODE_INPUT;
    gpio.Pull = GPIO_NOPULL;
    HAL_GPIO_Init(MDIO_GPIO, &gpio);
}

// 微秒延时(依赖系统主频)
static void udelay(uint32_t us) {
    uint32_t n = us * (SystemCoreClock / 1000000U) / 8;
    while (n--) __NOP();
}

时钟周期生成

static void mdc_cycle(void) {
    MDC_HIGH();
    udelay(1);  // ~1μs周期 → 500kHz
    MDC_LOW();
    udelay(1);
}

注:可根据性能需求调整至0.5μs(对应1MHz),但超过2MHz建议使用硬件控制器。

写操作实现

void mdio_write(int phy_addr, int reg_addr, uint16_t value) {
    int i;

    // 1. Preamble: 32 bits of '1'
    mdio_set_output();
    for (i = 0; i < 32; i++) {
        MDIO_HIGH();
        mdc_cycle();
    }

    // 2. Start Code: 01
    MDIO_LOW(); mdc_cycle();
    MDIO_HIGH(); mdc_cycle();

    // 3. OP Code: 10 (Write)
    MDIO_HIGH(); mdc_cycle();
    MDIO_LOW(); mdc_cycle();

    // 4. PHY Address (5 bits, MSB first)
    for (i = 4; i >= 0; i--) {
        if (phy_addr & (1 << i)) MDIO_HIGH(); else MDIO_LOW();
        mdc_cycle();
    }

    // 5. Register Address
    for (i = 4; i >= 0; i--) {
        if (reg_addr & (1 << i)) MDIO_HIGH(); else MDIO_LOW();
        mdc_cycle();
    }

    // 6. Turnaround: Z0
    MDIO_LOW();  // 驱动低电平表示结束地址阶段
    mdc_cycle();
    mdc_cycle();  // 占用两个bit时间

    // 7. Data: 16 bits
    for (i = 15; i >= 0; i--) {
        if (value & (1 << i)) MDIO_HIGH(); else MDIO_LOW();
        mdc_cycle();
    }

    // 8. Idle
    MDC_HIGH(); udelay(1);
    MDC_LOW();
}

读操作实现(含方向切换)

uint16_t mdio_read(int phy_addr, int reg_addr) {
    uint16_t data = 0;
    int i;

    mdio_set_output();

    // 1. Preamble
    for (i = 0; i < 32; i++) {
        MDIO_HIGH();
        mdc_cycle();
    }

    // 2. Start + OP: 01 01 (Read)
    MDIO_LOW(); mdc_cycle();
    MDIO_HIGH(); mdc_cycle();

    MDIO_LOW(); mdc_cycle();
    MDIO_HIGH(); mdc_cycle();

    // 3. PHY & Reg Address
    for (i = 4; i >= 0; i--) {
        if (phy_addr & (1 << i)) MDIO_HIGH(); else MDIO_LOW();
        mdc_cycle();
    }
    for (i = 4; i >= 0; i--) {
        if (reg_addr & (1 << i)) MDIO_HIGH(); else MDIO_LOW();
        mdc_cycle();
    }

    // 4. Turnaround: Zx -> 切换为输入
    mdio_set_input();
    mdc_cycle();  // 第一时钟(忽略)
    mdc_cycle();  // 第二时钟前完成切换

    // 5. 读取16位数据(在MDC上升沿采样)
    for (i = 15; i >= 0; i--) {
        MDC_HIGH();
        udelay(0.5);
        if (MDIO_READ()) data |= (1 << i);
        MDC_LOW();
        udelay(0.5);
    }

    // 6. 空闲恢复
    MDC_HIGH(); udelay(1);
    MDC_LOW();

    return data;
}

这段代码已在STM32F4、GD32F303、ESP32-S3等平台上验证可用,配合DP83848、LAN8720A、RTL8201等常见PHY均可正常通信。


实战场景:从Bootloader到工业网关

场景一:RTOS启动前PHY初始化

在嵌入式系统中,TCP/IP协议栈往往运行于操作系统之上。但在系统刚上电时,PHY尚未复位,链路无法建立。这时就需要在 main() 早期调用 mdio_init() 完成基础配置。

void ethernet_phy_init(void) {
    mdio_init();

    // 读取PHY ID确认型号
    uint16_t id1 = mdio_read(0, 2);
    uint16_t id2 = mdio_read(0, 3);

    if ((id1 == 0x2000) && (id2 == 0x5C90)) {
        printf("Found DP83848\n");
    } else {
        ERROR("Unknown PHY!");
        return;
    }

    // 软件复位
    mdio_write(0, 0, 0x8000);
    while (mdio_read(0, 0) & 0x8000) {
        udelay(1000);
    }

    // 启动自协商
    mdio_write(0, 0, 0x1200);  // 自动协商使能 + 重启

    // 等待链路建立
    while (!(mdio_read(0, 1) & 0x0004)) {
        HAL_Delay(100);
    }

    printf("Link UP!\n");
}

这类代码常用于Bootloader阶段,确保内核加载前网络物理层已就绪。

场景二:多PHY管理系统

某些工业网关或交换机模块需要连接多个PHY(如双网口、SFP+电口共存)。由于每个PHY有自己的地址(PHYAD[0:4]引脚配置),可通过同一组MDC/MDIO总线分别访问。

for (int phy = 0; phy < 2; phy++) {
    uint16_t bmsr = mdio_read(phy, 1);
    if (bmsr & 0x0004) {
        printf("PHY %d: Link Up\n", phy);
    }
}

只要硬件上正确分配地址(如PHY0=0,PHY1=1),软件无需改动即可扩展。

场景三:故障诊断工具

当遇到“网口灯不亮”、“ping不通”等问题时,可以直接使用该驱动独立测试PHY通信是否正常,排除MAC侧问题。

> mdio read 0 1
BMSR = 0x786D  # 表示支持10/100,自协商完成,链路已建立

配合逻辑分析仪抓取MDC/MDIO波形,可以直观看到前导码、地址、Turnaround等关键节点,极大提升调试效率。


设计建议与最佳实践

项目 建议
时钟频率 控制在500kHz~1MHz,避免过快导致采样失败
GPIO速度 设置为Low Speed,防止边沿振铃
上拉电阻 外部添加4.7kΩ上拉,增强稳定性
中断屏蔽 在bit-bang过程中禁用高优先级中断
错误处理 添加超时重试机制,如连续读到0xFFFF视为异常
调试支持 定义 DEBUG_MDIO 宏打印发送序列
可移植性 将GPIO操作抽象为宏,便于跨平台迁移

特别提醒:不要试图在FreeRTOS任务中频繁调用 mdio_read() 。每次读写耗时可达数百微秒,容易阻塞调度。建议封装为一次性初始化函数,或放入低优先级任务执行。


结语:软件补足硬件的哲学延续

尽管越来越多的现代SoC集成了完整的SMI控制器,甚至支持Clause 45扩展协议,但在特定场景下,GPIO模拟依然是不可或缺的技术选项。

它不仅是解决“没有硬件”的权宜之计,更体现了嵌入式开发中一种核心理念: 用软件的灵活性去弥补硬件的局限性

掌握这项技术,意味着你不仅能读懂PHY datasheet,还能亲手“对话”物理层芯片;意味着你在面对奇怪的网络问题时,多了一种深入底层排查的能力;也意味着你可以从容应对那些“理论上可行,但缺个控制器”的产品原型设计。

在这个追求高度集成的时代,回归基础、理解时序、动手编写bit-bang代码,反而成了一种稀缺而珍贵的工程素养。

本文章已经生成可运行项目

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值