Files
TCP2UART/CH390_RX_PBUF泄漏修复复盘.md

7.4 KiB
Raw Permalink Blame History

CH390 / lwIP 固定次数 ping 失败问题修复复盘

1. 问题现象

在 TCP2UART 固件运行后,设备初期可以正常 ARP 和 ping,但连续 ping 一段时间后不再响应。

典型现象:

  • 设备 IP192.168.31.100
  • 设备 MAC02:00:00:00:00:01
  • 对端/网关 IP192.168.31.1
  • 对端/网关 MAC00:e0:4c:28:1e:60
  • 失败后设备仍持续发送 TCP SYN/RST 或 client timeout 相关流量,说明 TX、任务调度和应用层并未整体死机。
  • 失败后对端继续向设备 MAC 发送 ICMP/ARP,但设备不再回复。

关键抓包:

  • WiresharkLog/04290150.pcapng
    • seq=1884..1891 共 8 次 ping reply 正常。
    • 第 9 次 seq=1892 开始无 reply。
  • WiresharkLog/04290206.pcapng
    • 曾把 PBUF_POOL_SIZE / MEMP_NUM_TCPIP_MSG_INPKT 从 8 临时扩大到 16。
    • 成功 ping 从 8 次变为 seq=1900..1915 共 16 次。
    • 第 17 次 seq=1916 开始无 reply。

这个“成功次数随池大小移动”的现象证明:问题不是 CH390 随机丢包,也不是 PHY/TX 死掉,而是每次成功处理 ping 后都有某个 pbuf 引用没有释放,最终耗尽 PBUF_POOL

2. 排查过程中的重要结论

2.1 CH390 RX 读包路径曾存在风险,但不是最终根因

早期排查时发现 CH390 RX 路径与参考驱动存在若干不一致,已修正:

  • ch390_receive_packet() 按参考序列读取 RX ready:先读 MRCMDX dummy,再读 MRCMDX1
  • 校验 RX header 的 Head 字节必须为 CH390_PKT_RDY
  • CH390 RX SRAM 中的 rx_len 包含 Ethernet FCS,交给 lwIP 前需要减去 4 字节。
  • ch390_rx_reset() 显式写 MPTRCR_RST_RX 复位 RX memory pointer。

这些修正确保 CH390 RX FIFO 读包更接近参考实现,但无法解释“固定 8 次/16 次后失败”。

2.2 扩大 lwIP 池只能延迟问题

曾临时将如下配置从 8 提到 16

#define PBUF_POOL_SIZE           16
#define MEMP_NUM_PBUF            16
#define MEMP_NUM_TCPIP_MSG_INPKT 16

结果成功 ping 次数也从 8 变成 16。这说明扩大池子只是延迟耗尽,不能作为根修复。

最终已恢复为 8

#define PBUF_POOL_SIZE           8
#define MEMP_NUM_PBUF            8
#define MEMP_NUM_TCPIP_MSG_INPKT 8

2.3 tcpip_input() 异步队列不是最终根因

项目启用了 lwIP core locking。为避免每个 RX 包占用 MEMP_TCPIP_MSG_INPKT,配置已改为同步输入:

#define LWIP_TCPIP_CORE_LOCKING       1
#define LWIP_TCPIP_CORE_LOCKING_INPUT 1

这样 tcpip_input() 会在 core lock 下同步调用 ethernet_input(),不再通过 TCPIP_MSG_INPKT 邮箱异步排队。

但用户后续验证仍然固定 8 次后停止,且每次成功 ping 都已经有 reply,因此说明 RX 包确实已经进入 ICMP 处理路径,问题更可能是 reply 输出路径增加了 pbuf 引用但未释放。

3. 最终根因

最终根因位于:

Drivers/LwIP/src/netif/ethernet.c

ethernet_output() 实现:

q = pbuf_alloc(PBUF_RAW_TX, SIZEOF_ETH_HDR, PBUF_RAM);
if (q == NULL) {
  LINK_STATS_INC(link.memerr);
  LINK_STATS_INC(link.drop);
  return ERR_MEM;
}

pbuf_chain(q, p);

ethhdr = (struct eth_hdr *)q->payload;
SMEMCPY(&ethhdr->dest, dst, sizeof(struct eth_addr));
SMEMCPY(&ethhdr->src, src, sizeof(struct eth_addr));
ethhdr->type = lwip_htons(eth_type);

return netif->linkoutput(netif, q);

问题在 pbuf_chain(q, p)

lwIP 的 pbuf_chain() 会执行:

pbuf_ref(t);

也就是给被挂接的原始 pbuf p 引用计数加 1。

ICMP echo reply 路径会复用 RX pbuf

ethernetif_poll()
  -> tcpip_input()
  -> ethernet_input()
  -> ip4_input()
  -> icmp_input()
  -> ip4_output_if()
  -> etharp_output()
  -> ethernet_output()

icmp_input() 末尾本身会 pbuf_free(p),这部分是正确的。但在原实现中,ethernet_output() 通过 pbuf_chain(q, p)p 额外加了一次引用,却没有在 linkoutput() 返回后释放临时 header pbuf q

因此每次 ping 的引用计数变化是:

RX pbuf 初始 ref = 1
pbuf_chain(q, p) 后 ref = 2
icmp_input() 末尾 pbuf_free(p) 后 ref = 1
=> p 永远没有回到 0PBUF_POOL 泄漏 1 个

所以:

  • PBUF_POOL_SIZE=8 时,8 次 ping reply 后耗尽。
  • 临时扩大到 16 时,16 次 ping reply 后耗尽。

4. 修复方案

修复 ethernet_output(),在同步 linkoutput() 完成后释放临时 header pbuf 链:

err_t ethernet_output(struct netif *netif,
                      struct pbuf *p,
                      const struct eth_addr *src,
                      const struct eth_addr *dst,
                      u16_t eth_type)
{
  struct pbuf *q;
  struct eth_hdr *ethhdr;
  err_t err;

  LWIP_ASSERT("netif != NULL", netif != NULL);
  LWIP_ASSERT("p != NULL", p != NULL);
  LWIP_ASSERT("src != NULL", src != NULL);
  LWIP_ASSERT("dst != NULL", dst != NULL);

  q = pbuf_alloc(PBUF_RAW_TX, SIZEOF_ETH_HDR, PBUF_RAM);
  if (q == NULL) {
    LINK_STATS_INC(link.memerr);
    LINK_STATS_INC(link.drop);
    return ERR_MEM;
  }

  pbuf_chain(q, p);

  ethhdr = (struct eth_hdr *)q->payload;
  SMEMCPY(&ethhdr->dest, dst, sizeof(struct eth_addr));
  SMEMCPY(&ethhdr->src, src, sizeof(struct eth_addr));
  ethhdr->type = lwip_htons(eth_type);

  err = netif->linkoutput(netif, q);
  pbuf_free(q);
  return err;
}

为什么这里可以释放 q

  • 本项目 low_level_output() 是同步发送。
  • 它会立即遍历 pbuf 链,把数据复制到 s_tx_buffer
  • 随后调用 ch390_runtime_send_packet() 把连续 buffer 发给 CH390。
  • low_level_output() 返回后不再持有 pbuf 指针。

因此 ethernet_output()linkoutput() 返回后释放 q 是正确的。

pbuf_free(q) 会同时:

  • 释放临时 Ethernet header pbuf q
  • 解除 pbuf_chain() 对原始 RX pbuf p 增加的引用;
  • 之后 icmp_input() 末尾的 pbuf_free(p) 可以真正把 RX pbuf 归还 PBUF_POOL

5. 不要做的错误修复

5.1 不要在 netif->input() 成功后手动释放 pbuf

驱动层当前逻辑是正确的:

input_err = ch390_netif.input(p, &ch390_netif);
if (input_err != ERR_OK) {
    pbuf_free(p);
}

netif->input() 返回 ERR_OK 时,pbuf ownership 已经交给 lwIP。此时驱动不能再 pbuf_free(p),否则会造成 double-free 或 use-after-free。

5.2 不要只扩大 PBUF_POOL_SIZE

扩大池子只会让失败次数从 8 变 16、32……不会修复泄漏。

5.3 不要继续优先怀疑 CH390 PHY/TX

抓包中失败后设备仍持续发送 TCP SYN/RST,说明 TX 和任务仍活着。固定次数失败更符合 pbuf 引用泄漏。

6. 验证结果

修复后 Keil 构建通过:

"TCP2UART\TCP2UART.axf" - 0 Error(s), 0 Warning(s).
Program Size: Code=93376 RO-data=2768 RW-data=456 ZI-data=56032

用户烧录验证后确认问题已修复。

7. 后续排查建议

如后续再次出现固定次数网络停止,优先检查:

  1. 是否存在 pbuf_chain() / pbuf_ref() 后没有配对 pbuf_free() 的路径。
  2. 是否有 ARP pending queue 长时间持有 pbuf。
  3. 是否有 TCP recvmbox / 应用桥接队列背压长期持有 pbuf。
  4. 是否有人在 netif->input() 成功后错误释放 pbuf,导致内存破坏。

推荐排查点:

  • Drivers/LwIP/src/netif/ethernet.c
  • Drivers/LwIP/src/core/ipv4/icmp.c
  • Drivers/LwIP/src/core/ipv4/etharp.c
  • Drivers/LwIP/src/core/pbuf.c
  • Drivers/LwIP/src/netif/ethernetif.c