
1. 项目概述与方案设计1.1 这个项目解决什么问题做嵌入式网络开发谁还没遇到过网线被碰掉的瞬间尤其是设备部署在机房角落、车间配电柜或者车载环境中网线松动、交换机重启、水晶头氧化都是家常便饭。对于跑着 LwIP 协议的 STM32 设备来说这个问题的痛点在于LwIP 默认根本不感知物理链路状态你一旦把网线拔掉上层 TCP 连接并不会立刻报错而是继续在“假死”状态里挣扎重传。我最初遇到这个问题是在一个用 STM32F407 做 TCP 采集网关的项目里设备负责把传感器数据定时上报给后台服务器。现场工人搬设备时不小心踢掉了网线过了五分钟才发现后台那边早就报了一堆“连接超时”。可我这边看代码TCP 客户端任务还在傻乎乎地重发数据包完全不知道链路已经断了。后来把网线插回去连接也不会自动恢复必须重启设备。这个体验让我下定决心必须在应用层把网线插拔的检测和自动恢复做掉这也是这篇文章的核心内容。1.2 为什么 LwIP 默认不会自动恢复要理解自动恢复的方案先得明白 LwIP 默认为什么这么“迟钝”。LwIP 对网卡状态的管理靠的是struct netif结构体里的标志位其中有一个NETIF_FLAG_LINK_UP用来指示物理链路是否在线。正常情况下这个标志位应该由驱动层维护链路断开时清掉链路恢复时置回。但问题在于LwIP 的驱动模型里把“链路状态”的检测甩给了底层实现。如果你用 CubeMX 生成的默认工程netif_set_up()和netif_set_link_up()只在初始化时被调用了一次之后没有任何代码去更新它。更麻烦的是 TCP 层的处理。TCP 协议本身是不关心物理链路的它只关心 ACK 有没有回来。网线拔掉后TCP 发送的数据包发不出去对端也不会回 ACKTCP 就会按照指数退避不断重传。这个重传过程在 LwIP 默认配置下能持续好几分钟除非应用层主动关闭连接否则 TCP 控制块会一直挂在tcp_active_pcbs链表里。也就是说默认情况下拔线后 TCP 是在“无效等待”而不是“快速失败”。所以要做网线插拔自动恢复本质上就两件事第一让设备感知物理链路的状态变化第二在链路状态变化时主动干预 LwIP 和业务层该断的断、该重连的重连。1.3 技术方案选型链路状态检测有几种常见思路我列个对比表格你就明白了方案实现难度实时性抗干扰能力适用场景定时轮询 PHY 寄存器低100ms ~ 500ms 可调好可加多次采样确认大多数产品首选PHY 中断 EXTI中毫秒级一般容易受干扰误触发对实时性要求高的场景只依赖 TCP 重传超时无极差分钟级差不推荐我最终选了定时轮询 PHY 寄存器这个方案原因很简单通用、靠谱、不依赖 PHY 型号的特殊中断配置。STM32 的 ETH 外设没有直接提供“链路状态寄存器”但 MDIO 接口可以很方便地读取 PHY 芯片的状态寄存器。最常见的做法是读 PHY 的 Basic Status Register地址 0x01其中 bit2 就是 Link Status。置 1 表示链路在线置 0 表示链路断开。恢复策略上我采用了“快速失败 主动重建”的思路。链路断开时不再让 TCP 傻等重传超时而是主动把活跃的 TCP 连接全部释放掉DHCP 也主动停止链路恢复时先等 PHY 自协商稳定再通知 LwIP 更新 link 状态重新启动 DHCP最后由应用层发起重连。这样整套逻辑是可控、可观测的比等 TCP 自己“想通”快得多。2. 硬件环境与 CubeMX 基础配置2.1 硬件平台与接线要点我用的平台是 STM32F407VET6 核心板外接 LAN8720A 模块这是目前 STM32 以太网开发里非常常见的组合。STM32F407 内置了 MAC 层只需要外接一颗 PHY 芯片通过 RMII 接口连接即可。LAN8720A 支持 RMII 和 MII 两种模式但实际工程基本都用 RMII因为它只需要 9 根信号线对 IO 的占用更少。RMII 接口的接线有几个关键点这里特别提一下REF_CLK 必须是 50MHz可以由 MCU 的 MCO1 引脚输出也可以由外部有源晶振提供具体接法要看板卡原理图。这个时钟如果不对PHY 根本跑不起来。MDIO/MDC 是管理接口用来读取 PHY 寄存器必须接对引脚否则读不到 PHY ID。CRS_DV 是载波检测和数据有效复用信号负责接收方向的指示。PHY 地址由 LAN8720A 的 PHYAD0 引脚电平决定常见的是 0x00 或 0x01一定要跟代码里 CubeMX 配置的 PHY Address 一致。我画了一个简化的引脚对照表不同板卡会有差异最终以你自己板子的原理图为准信号STM32F407 常见引脚说明RMII_REF_CLKPA1接收 PHY 提供的 50MHz REF_CLKRMII_MDIOPA2管理数据输入输出RMII_MDCPC1管理时钟RMII_CRS_DVPA7载波检测 / 数据有效RMII_RXD0PC4接收数据 bit0RMII_RXD1PC5接收数据 bit1RMII_TX_ENPB11发送使能RMII_TXD0PB12发送数据 bit0RMII_TXD1PB13发送数据 bit12.2 CubeMX 初始化配置步骤CubeMX 的配置过程并不复杂但每一步都要仔细。我按顺序列一下打开 STM32CubeMX选择你的芯片型号在 Pinout Configuration 页面找到 ETH将模式设置为 RMII。把 PHY Address 改成你的板卡实际地址。LAN8720A 模块大多数默认是 0x00但有的设计是 0x01这里错了后面读寄存器全是 FF。在 Middleware 中勾选 LwIP选择 RAW API。如果你打算用 FreeRTOS先在这里把 FreeRTOS 也打开LwIP 的中断和服务线程会自动挂在 FreeRTOS 下面。生成工程后打开 lwipopts.h把LWIP_NETIF_LINK_CALLBACK这个宏改为 1。这个宏默认是关闭的不打开的话调用netif_set_link_up/down就白调了。在 LwIP 的配置里把 DHCP 选项打开如果你的设备使用静态 IP则关闭 DHCP 并填好 IP 地址、子网掩码、网关。在 System Clock 配置里确保 ETH 的时钟配置正确。STM32F407 的以太网 MAC 需要 PLL48CLK一般由 PLL Q 输出这个在 CubeMX 里通常是自动配置的留意检查一下有没有黄色警告。CubeMX 生成的代码会包含MX_LWIP_Init()函数里面完成了 netif 的添加、IP 地址的设置和 DHCP 的启动。这个函数会在初始化阶段被调用后续我们写的链路检测逻辑要放在它之后。3. 核心代码实现链路检测与自动恢复3.1 PHY 寄存器读取与锁存位问题我前面提到要读 PHY 的 Basic Status Register地址是 0x01。在 HAL 库中读取 PHY 寄存器的接口是HAL_ETH_ReadPHYRegister。有一个非常经典的坑必须在这里说清楚PHY 的 Link Status 位是带锁存功能的。什么意思如果你在网线断开之后读了一次寄存器的 bit2读到的是 0但该位不会自动恢复成 1。即使网线已经插回去了如果你不重新读一次它可能还是 0。更准确地说这个锁存位在你读取之后会被更新为当前的真实状态。所以标准的读法应该是连续读两次以第二次为准。我封装了一个函数static uint8_t read_link_status(void) { uint32_t reg1 0; uint32_t reg2 0; /* 第一次读取用于清除锁存 */ HAL_ETH_ReadPHYRegister(heth, 0x01U, reg1); /* 第二次读取才是当前真实链路状态 */ HAL_ETH_ReadPHYRegister(heth, 0x01U, reg2); return (reg2 (1U 2)) ? 1U : 0U; }注意旧版 HAL 库中HAL_ETH_ReadPHYRegister的第三个参数是uint32_t*新版可能变成了uint16_t*编译报错时检查一下你的 HAL 版本接口定义。3.2 链路状态检测任务我用 FreeRTOS 创建了一个独立任务来做轮询检测周期大约是 200ms。为什么不放在主循环里因为主循环里还有 TCP 数据收发、业务逻辑处理轮询周期不稳定而且一个卡顿就可能错过链路变化独立任务更干净。检测任务代码#define PHY_BSR_REGISTER 0x01U #define PHY_LINK_STATUS_MASK (0x0004U) void LinkCheckTask(void *argument) { uint8_t last_state 0xFF; uint8_t current_state 0; uint8_t stable_state 0; for (;;) { current_state read_link_status(); /* 间隔短延时后再读一次确认状态稳定避免 PHY 自协商期间的抖动 */ osDelay(20); stable_state read_link_status(); if (stable_state ! current_state) { current_state stable_state; } if (current_state ! last_state) { if (current_state 1U) { /* 链路恢复 */ handle_link_up(); } else { /* 链路断开 */ handle_link_down(); } last_state current_state; } osDelay(180); } }这里有一个细节我在第一次读取后再延时 20ms 再读一次不是闲得没事干而是为了排除线缆接触不良导致的瞬间抖动。另外如果检测到状态变化我会调用handle_link_up()或handle_link_down()来处理具体的网络逻辑后面详细说。3.3 注册二层的 link callbackLwIP 支持在 netif 上注册一个链路状态回调函数当netif_set_link_up或netif_set_link_down被调用时会自动触发这个回调。开启宏LWIP_NETIF_LINK_CALLBACK之后我们就可以这样注册static void netif_link_callback(struct netif *netif) { if (netif_is_link_up(netif)) { /* 链路已恢复可以在这里做一些业务层通知 */ printf([LwIP] Link Up\r\n); } else { /* 链路已断开 */ printf([LwIP] Link Down\r\n); } } /* 在 MX_LWIP_Init() 之后调用 */ netif_set_link_callback(gnetif, netif_link_callback);这个回调最大的作用是给你一个统一的“链路事件”入口业务层可以在这里点亮指示灯、打印日志、发送通知给 RTOS 消息队列等。但注意这个回调是在调用netif_set_link_up/down的上下文里执行的如果是从链路检测任务调用的那它就运行在这个任务上下文中不要在回调里做阻塞操作。3.4 断开时的处理快速失败清理 TCP 连接这是整个自动恢复方案里最关键的环节。链路断开时最忌讳的就是让 TCP 连接继续挂着。默认情况下TCP 重传超时可能要几分钟对业务来说是不可接受的。而且就算链路恢复了这条 TCP 连接的对端可能早就把连接关掉了发数据只会触发 RST没有任何意义。我的做法是检测到链路断开后立即把 LwIP 中的所有活跃 TCP 连接主动终止让应用层进入一个“等待连接“的干净状态。代码实现如下#include lwip/tcp.h #include lwip/priv/tcp_priv.h static void abort_all_tcp_connections(void) { struct tcp_pcb *pcb NULL; struct tcp_pcb *next NULL; for (pcb tcp_active_pcbs; pcb ! NULL; pcb next) { next pcb-next; tcp_abort(pcb); } for (pcb tcp_tw_pcbs; pcb ! NULL; pcb next) { next pcb-next; tcp_abort(pcb); } }这里遍历了tcp_active_pcbs和tcp_tw_pcbs两个链表。LwIP 的 PCB 链表结构在不同版本里略有差异如果你的 LwIP 版本比较新可能还需要清理 TIME-WAIT 状态的连接。需要提醒的是直接操作 LwIP 内部链表不是最优雅的做法更推荐让每个 TCP 客户端任务自己保存自己的 PCB 指针在收到链路断开通知时自行关闭。但对于原型验证或者连接数不多的产品上面这段粗暴但有效能救急。调用时要把这个函数放进handle_link_down()里并且要注意上下文。如果你的工程开启了LWIP_TCPIP_CORE_LOCKING从用户任务操作 TCP 控制块之前要加锁更稳妥的方式是通过tcpip_callback把操作切换到 tcpip_thread 上下文中执行。我一般这样调用static void handle_link_down(void) { netif_set_link_down(gnetif); dhcp_stop(gnetif); tcpip_callback(abort_all_tcp_connections, NULL); }tcpip_callback会把这个函数放到 LwIP 主线程中去执行避免跨线程访问 TCP PCB 带来的崩溃风险实测下来稳定性好了很多。3.5 恢复时的处理更新链路状态重启 DHCP链路恢复后不能立刻发数据因为 PHY 还要进行自协商自协商一般需要 1~2 秒。我在链路检测任务里已经加了 20ms 的连续采样但这还不够最好在确认链路恢复后再延迟一小段时间确保 PHY 稳定在主状态再执行netif_set_link_up。恢复处理函数static void handle_link_up(void) { /* 确保 PHY 自协商完成后再通知 LwIP 链路恢复 */ osDelay(100); netif_set_link_up(gnetif); /* 如果之前使用 DHCP重启 DHCP 获取 IP */ dhcp_stop(gnetif); dhcp_start(gnetif); }为什么这里要dhcp_stop之后再来一次dhcp_start因为 DHCP 客户端的状态机在链路断开期间可能已经处于超时或者错误状态直接再次dhcp_start有时候不会触发一次全新的 DISCOVER 流程。先 stop 再 start能保证把 DHCP 状态机重置到初始状态重新走一遍“DISCOVER → OFFER → REQUEST → ACK”的完整过程这样拿到的 IP 和路由信息都是最新可用的。如果你的设备是静态 IP就不需要重启 DHCP直接netif_set_link_up就够了。但应用层的 TCP 客户端重连逻辑还是要做的我会在业务层定义一个link_restored标志TCP 客户端任务发现这个标志置位后主动关闭旧连接并重新连接服务器。3.6 应用层 TCP 重连状态机设备是 TCP 客户端时链路恢复后必须有主动重连机制否则什么都白搭。我一般在业务任务里维护一个简单的状态机typedef enum { APP_STATE_IDLE, APP_STATE_CONNECTING, APP_STATE_CONNECTED } app_state_t; static app_state_t app_state APP_STATE_IDLE; static struct tcp_pcb *app_pcb NULL; static volatile uint8_t app_need_reconnect 0; /* 链路恢复回调里置位 */ void app_notify_link_restored(void) { app_need_reconnect 1; } void app_tcp_task(void *argument) { for (;;) { if (app_need_reconnect) { app_need_reconnect 0; if (app_pcb ! NULL) { tcp_close(app_pcb); app_pcb NULL; } app_state APP_STATE_IDLE; } if (app_state APP_STATE_IDLE) { app_pcb tcp_new(); if (app_pcb ! NULL) { tcp_bind(app_pcb, IP_ADDR_ANY, 0); tcp_connect(app_pcb, server_addr, 8080, app_tcp_connected_cb); app_state APP_STATE_CONNECTING; } } osDelay(500); } }这段伪代码展示了重连的核心思路链路恢复后主动把旧的 PCB 关掉重新tcp_newtcp_connect。连续重试之间的间隔用osDelay(500)控制避免在链路还未稳定时疯狂重连。4. 实测效果与踩坑记录4.1 拔线插线实测表现我在调试板上实际跑了一遍串口日志非常干净ETH Link Down detected [LwIP] Link Down [DHCP] Stopped [TCP] Active connections aborted —— 此时拔掉网线等待 5 秒 —— ETH Link Up detected [LwIP] Link Up [DHCP] Restart DHCP [DHCP] DHCPS: got IP 192.168.1.100 [TCP] Reconnecting to 192.168.1.50:8080 [TCP] Connected从插回网线到最终 TCP 连接建立大约需要 2~3 秒其中大部分时间花在 PHY 自协商和 DHCP 流程上。对比之前默认行为插回网线后干等 TCP 重传超时足足要等 5 分钟以上这个优化效果是很直观的。4.2 常见问题速查表我把这个项目里遇到过的典型问题整理成了一张表现象可能原因解决方法插回网线后 Link 状态仍为 DownPHY BSR 寄存器 Lock 位未清除连续读取两次寄存器以第二次为准读 PHY 寄存器全部为 0xFFPHY Address 配置错误或 MDIO 线路问题核对硬件 PHYAD 引脚扫描 0x00~0x1F 地址调用 netif_set_link_up/down 没反应LWIP_NETIF_LINK_CALLBACK 宏没开启在 lwipopts.h 中把宏改为 1网线插回后 DHCP 不重新获取 IPDHCP 状态机卡死先 dhcp_stop 再 dhcp_start在中断上下文操作 LwIP 导致死机跨线程访问了 TCP PCB改用 tcpip_callback 切换到 tcpip_thread网线插拔后 PHY 协商不稳定RMII 50MHz 时钟不稳定用示波器检查 REF_CLK 波形确认 MCO 或晶振配置4.3 调试心得这个项目让我在嵌入式网络调试上攒了几个实打实的经验。第一拿到板子先把 PHY ID 读出来。不要急着跑 LwIP 联网先写一段代码遍历 MDIO 地址 0x00~0x1F把每个地址读到的 PHY ID 打印出来。很多“网络不通”的问题根源其实是 PHY 地址跟代码里配置的不一致或者是 MDIO 上拉电阻没焊导致读寄存器全是 0xFFFF。这一步能帮你把“PHY 是不是正常”这个变量提前排除掉。第二调试链路恢复时要加日志并保证日志不阻塞。我在链路检测任务里加了几条printf但在中断上下文或者高频任务里打日志会拖慢系统建议用一个独立的日志队列把日志事件投递到串口任务中异步输出。否则你可能看到的现象是链路恢复时主线程被 printf 卡住反而误报为系统死机。第三不要把netif_set_link_up当作“一切恢复”的信号。它只是告诉 LwIP 物理链路回来了TCP 连接不会自动重建DHCP 也不会自动重新获取 IP。真正让业务恢复的是你自己的应用层逻辑。设计恢复流程时永远要问自己一个问题“链路回来后我的业务层知道去重新干这件事吗”5. 进阶从轮询升级到 PHY 中断5.1 为什么轮询够用但中断更快轮询方案虽然简单可靠但 200ms 的检测周期在某些高实时性场景中还是不够。比如设备接收的是对延迟敏感的指令拔线后希望在几十毫秒内触发“链路异常”告警那 200ms 的延迟就大了。升级到 PHY 中断也不复杂。以 LAN8720A 为例它有一个 nINT 引脚可以通过配置中断寄存器让 PHY 在链路状态变化时主动拉低这个引脚。STM32 这边把 nINT 接到一个 EXTI 引脚上配置下降沿触发中断服务函数里只做一件事给链路检测任务发一个信号量或者置一个标志位然后退出。真正的 LwIP 处理仍然放到任务里做不在中断上下文里碰任何网络协议栈。这里要注意不同 PHY 的中断寄存器定义完全不一样。LAN8720A 需要配置它的 Mode Control/Status Register 和 Interrupt Mask Register具体位定义建议翻 datasheet。而且 nINT 是开漏输出硬件上必须接上拉电阻否则中断信号拉不出来。5.2 我个人的调试习惯最后分享一个我在多个网络项目里固定下来的小习惯上电后第一件事打印 PHY ID、Link Status、MAC 地址和 IP 地址。这四样东西都正常了再去调应用层逻辑。具体做法是在MX_LWIP_Init()之后加一段调试输出读取 PHY 的 ID 寄存器地址 0x02 和 0x03判断是不是 LAN8720A 的 ID 0x0007C0F1。如果读到的不是这个值十有八九是 PHY 地址配置错了。这个习惯帮我省下了大量盲目排障的时间尤其当多块板卡的 PHY 地址不统一的时候上电自检直接告诉你“这块板子的 PHY 地址是 1代码里写的是 0”问题一眼就定位了。做 STM32 网络项目网线插拔自动恢复不算什么高深技术但如果你不做现场维护人员就要一遍遍重启设备。把这套检测和恢复逻辑沉淀下来你的设备在真实环境里的存活率会有肉眼可见的提升。