diff --git a/CH390_RX_PBUF泄漏修复复盘.md b/CH390_RX_PBUF泄漏修复复盘.md new file mode 100644 index 0000000..ce05eb0 --- /dev/null +++ b/CH390_RX_PBUF泄漏修复复盘.md @@ -0,0 +1,245 @@ +# CH390 / lwIP 固定次数 ping 失败问题修复复盘 + +## 1. 问题现象 + +在 TCP2UART 固件运行后,设备初期可以正常 ARP 和 ping,但连续 ping 一段时间后不再响应。 + +典型现象: + +- 设备 IP:`192.168.31.100` +- 设备 MAC:`02:00:00:00:00:01` +- 对端/网关 IP:`192.168.31.1` +- 对端/网关 MAC:`00: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: + +```c +#define PBUF_POOL_SIZE 16 +#define MEMP_NUM_PBUF 16 +#define MEMP_NUM_TCPIP_MSG_INPKT 16 +``` + +结果成功 ping 次数也从 8 变成 16。这说明扩大池子只是延迟耗尽,不能作为根修复。 + +最终已恢复为 8: + +```c +#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`,配置已改为同步输入: + +```c +#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. 最终根因 + +最终根因位于: + +```text +Drivers/LwIP/src/netif/ethernet.c +``` + +原 `ethernet_output()` 实现: + +```c +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(ðhdr->dest, dst, sizeof(struct eth_addr)); +SMEMCPY(ðhdr->src, src, sizeof(struct eth_addr)); +ethhdr->type = lwip_htons(eth_type); + +return netif->linkoutput(netif, q); +``` + +问题在 `pbuf_chain(q, p)`。 + +lwIP 的 `pbuf_chain()` 会执行: + +```c +pbuf_ref(t); +``` + +也就是给被挂接的原始 pbuf `p` 引用计数加 1。 + +ICMP echo reply 路径会复用 RX pbuf: + +```text +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 的引用计数变化是: + +```text +RX pbuf 初始 ref = 1 +pbuf_chain(q, p) 后 ref = 2 +icmp_input() 末尾 pbuf_free(p) 后 ref = 1 +=> p 永远没有回到 0,PBUF_POOL 泄漏 1 个 +``` + +所以: + +- `PBUF_POOL_SIZE=8` 时,8 次 ping reply 后耗尽。 +- 临时扩大到 16 时,16 次 ping reply 后耗尽。 + +## 4. 修复方案 + +修复 `ethernet_output()`,在同步 `linkoutput()` 完成后释放临时 header pbuf 链: + +```c +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(ðhdr->dest, dst, sizeof(struct eth_addr)); + SMEMCPY(ðhdr->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 + +驱动层当前逻辑是正确的: + +```c +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 构建通过: + +```text +"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`