摘要
一块 STM32H743 控制板在重新上电或下载固件后可以正常连接上位机,但连续运行数小时后会突然停止发起 HTTP 请求。串口仍能看到 FreeRTOS 心跳,COM3 也未被其他进程占用,因此问题一度看起来像"网络服务断了"。
通过网卡原始抓包、串口日志和 ST-Link 在线内存检查,最终发现故障时 CPU 和主调度仍在运行,但 LwIP 的 tcpip_thread 卡在以太网发送等待中;ETH HAL 的 TX 描述符指针与 BuffersInUse 计数已经不一致。发送驱动在 HAL_ETH_Transmit_IT() 返回忙时无限重试,使调用者一直持有 LwIP 核心锁。修复为串行化 TX 描述符提交与回收,并限制忙状态重试时间。重新构建、下载和 Flash 校验后,板端恢复周期性请求,15 秒抓包中观察到 42 个 HTTP 请求/响应,全部成功。
故障现象
设备正常启动后能连接 192.168.137.177:8000,按周期请求上位机。经过数小时运行后,上位机不再看到新请求。用户确认 COM3 没有被其他程序占用;串口仍持续输出 RTOS 心跳。这说明 MCU 没有整体停机,但还不能据此断定网络任务正常。
故障时的串口心跳节选:
[rtos] heartbeat=11880 tick=11881000 ... app_running=0 generation=4
[rtos] heartbeat=11881 tick=11882000 ... app_running=0 generation=4
...
[rtos] heartbeat=11899 tick=11900000 ... app_running=0 generation=4
同一故障窗口的以太网采集文件只有 524 字节,基本只有 pcapng 文件头,没有板端 IP 或 ARP 报文:故障窗口空抓包。因此当时板子确实没有把新的网络帧送到物理网卡。需要注意,串口心跳是另一个任务的活动证明,不能证明 tcpip_thread 仍在处理网络工作。
先用抓包确定故障停在哪一层
更早的一段持续抓包在 16:10:22 捕获到板子向 192.168.137.177:8000 发起 TCP SYN;上位机返回 SYN-ACK,并在约 1、2、4 秒后重传,但板端没有 ACK,握手没有完成:早期握手失败抓包。这与"上位机没有监听"不同:服务端已经收到 SYN 并作出响应,但后续数据链路没有继续。
抓包回答了两个重要问题:
-
有时板端仍尝试建立连接,但无法完成 TCP 握手。
-
到最终故障窗口时,连新的 SYN、ARP 等板端报文都看不到。
故障因此不能只归结为 HTTP JSON 格式或 COM 口问题。HTTP 请求尚未发出之前,网络接口的发送/处理路径已经停止前进。
在线调试把范围缩到 ETH TX 描述符
故障现场通过 ST-Link 在线检查 FreeRTOS 任务、队列和 ETH HAL 状态。关键观察如下:
• RTOS 心跳任务继续运行;问题集中在网络处理链路。
• tcpip_thread 阻塞在 TxPktSemaphore 等待路径,并持有 LwIP 核心互斥锁。
• HttpWave 等待 LwIP 核心互斥锁,TCP/IP 邮箱已满(6/6)。
• ETH HAL 状态显示 CurTxDesc=2,对应描述符的 PacketAddress2 仍非空,地址为 0x3000814c;DMA 的 OWN 位已经清零,但 BuffersInUse=0,HAL 同时返回 BUSY。
最后一组状态是根因定位的关键。HAL 在准备 TX 描述符时,不只检查 DMA OWN 位,也检查 PacketAddressidx 是否为空:
/* Drivers/STM32H7xx_HAL_Driver/Src/stm32h7xx_hal_eth.c */
if ((READ_BIT(dmatxdesc->DESC3, ETH_DMATXNDESCWBF_OWN) ==
ETH_DMATXNDESCWBF_OWN)
|| (dmatxdesclist->PacketAddress[descidx] != NULL))
{
return HAL_ETH_ERROR_BUSY;
}
而 HAL_ETH_ReleaseTxPacket() 的主循环受 BuffersInUse 控制:
uint32_t numOfBuf = dmatxdesclist->BuffersInUse;
uint32_t idx = dmatxdesclist->releaseIndex;
while ((numOfBuf != 0U) && (pktTxStatus != 0U))
{
/* 检查完成状态,释放 pbuf,再清空 PacketAddress 并更新计数 */
}
当计数已经变成 0,而对应 PacketAddress 仍非空时,正常回收循环不会再检查该项;下一次发送又会因为非空指针返回 BUSY。它形成了"回收看不到、发送不能复用"的死锁状态。HAL 的 HAL_ETH_TxFreeCallback() 在本工程中已经实现并调用 pbuf_free(),现场没有证据指向单纯的 pbuf 引用泄漏。
TX 描述符提交和回收分别发生在网络发送路径与以太网输入任务。此前两处都可能调用 HAL 的 TX 管理函数,却没有共用互斥保护。结合在线快照中的指针/计数不一致,可以判断 HAL 的共享描述符账本可能被并发访问破坏。这里的"并发破坏"是由内存状态和调用路径推得的根因,不是单靠 pcap 能直接证明的结论。
原代码为什么会把局部异常放大成全网断连
修复前的发送循环在 BUSY 后等待信号量、回收描述符,再无限重试。下面保留关键控制流,省略了非 BUSY 错误分支:
do
{
if (HAL_ETH_Transmit_IT(&heth, &tx_config) == HAL_OK)
{
errval = ERR_OK;
}
else
{
if (HAL_ETH_GetError(&heth) & HAL_ETH_ERROR_BUSY)
{
osSemaphoreAcquire(TxPktSemaphore, ETHIF_TX_TIMEOUT);
HAL_ETH_ReleaseTxPacket(&heth);
errval = ERR_BUF;
}
else
{
/* 其他错误:释放 pbuf 引用并返回 ERR_IF */
errval = ERR_IF;
}
}
} while (errval == ERR_BUF);
这段代码有两个风险:
-
TX 状态损坏时,等待超时也不会退出,循环会永久重试。
-
low_level_output() 在 LwIP 核心上下文中执行;它一直不返回,就会持续占用核心锁。其他 HTTP/TCP 操作无法进入核心,TCP/IP 邮箱逐渐填满,最终板子连新的 SYN 和 ARP 都发不出来。
因此外部看到的是"几小时后所有连接突然消失",而内部起点是一个 TX 描述符无法复用的状态。
修复:串行访问,并为重试设置上限
在 ethernetif.c 中新增 TxPktMutex,由发送路径和以太网输入任务共同持有。发送前先回收已完成帧,再提交新帧;接收任务也必须在同一互斥锁内回收 TX 描述符:
/* low_level_output() */
(void)HAL_ETH_ReleaseTxPacket(&heth);
tx_status = HAL_ETH_Transmit_IT(&heth, &tx_config);
(void)osMutexRelease(TxPktMutex);
/* ethernetif_input() */
if ((TxPktMutex != NULL) &&
(osMutexAcquire(TxPktMutex, osWaitForever) == osOK))
{
(void)HAL_ETH_ReleaseTxPacket(&heth);
(void)osMutexRelease(TxPktMutex);
}
同时,BUSY 等待时间设为 100 ms,最多尝试 3 次。失败时释放之前为 DMA 持有而增加的 pbuf 引用,并将错误返回给 LwIP:
#define ETHIF_TX_TIMEOUT (100U)
#define ETHIF_TX_RETRY_COUNT (3U)
for (attempt = 0U; attempt < ETHIF_TX_RETRY_COUNT; attempt++)
{
/* 加锁、回收完成项、提交 TX,然后释放锁 */
if (tx_status == HAL_OK)
{
return ERR_OK;
}
if (tx_busy == 0U)
{
errval = ERR_IF;
break;
}
if ((attempt + 1U) < ETHIF_TX_RETRY_COUNT)
{
(void)osSemaphoreAcquire(TxPktSemaphore, ETHIF_TX_TIMEOUT);
}
}
/* HAL 未接管本次失败发送的引用。 */
pbuf_free(p);
return errval;
互斥锁保护的是 HAL 的共享描述符状态,不会在等待发送完成期间一直持有;重试有明确上限,因此异常情况下 LwIP 核心锁也能释放。