资讯详情

LWIP TCP发送链路深度解析:从应用到网线的每一步

📅 2026/10/9 4:23:09 | 华诺云谱 👁 阅读
LWIP TCP发送链路深度解析:从应用到网线的每一步
最近在调一块基于 STM32H7 的网关板要把底下几十路 Modbus 仪表的数据打包成 TCP 报文上送到云端。应用层代码写得很顺调send函数也返回了正数但抓包工具里死活看不到数据出网口。折腾了一个下午最后发现是 ARP 没命中、报文在协议栈里排队等待地址解析而驱动侧 TX 描述符又刚好耗尽两个问题叠在一起把发送链路堵得死死的。这个问题让我意识到很多人对 LWIP 的使用停留在调用接口就能发出去的层面一旦涉及性能调优、异常排查、驱动移植就说不清数据到底在协议栈里经历了什么。所以这篇文章不聊 API 怎么用专门拆解一个 TCP 报文从应用层写数据开始到网线上出现比特流为止每一步封装、排队、递交、触发到底发生了什么。内容偏原理和机制但全程配合调试经验来讲正在做 LWIP 网关、嵌入式联网设备或者准备移植网卡驱动的朋友应该能从中得到一些书本上看不到的细节。1. 发送链路全景图应用数据到网线的每一站很多初学者第一次看 LWIP 源码会被绕晕原因是协议栈把发送过程拆成了很多小步骤每个步骤之间靠队列 回调 标志位衔接而不是像单片机裸机程序那样线性执行。在深入代码之前先把整个链路的地图拼出来后面所有讨论都围绕这张地图展开。1.1 两条调用路径RAW API 与 netconn APILWIP 的发送入口根据使用模式分成两条完全不同的路径这是理解后续所有机制的前提。RAW API也叫回调型 API应用层直接调用tcp_write()、tcp_output()协议栈和应用共享同一个上下文通常在裸机主循环里轮询跑或者在 RTOS 的某一个专用线程里跑。没有消息投递开销适合性能敏感、资源紧张的场景。netconn API / socket API应用层通过lwip_send()、send()等封装函数发送。中间隔着一个tcpip_thread应用线程把数据通过邮箱mbox投递给协议栈线程由协议栈线程统一执行 TCP/IP 处理。使用 socket API 的典型调用路径是这样的应用调用send()经过lwip_send-netconn_write_partly-tcp_write数据先放进 TCP 控制块的发送缓冲区然后标记有数据要发通过tcpip_mbox给tcpip_thread发一条消息。tcpip_thread收到消息后调用tcp_output()检查窗口和拥塞状态真正把数据从发送队列里发出去。RAW API 则没有中间商应用直接调tcp_write()把数据放入发送队列再调tcp_output()驱动发送。这里有一个很容易误解的点tcp_write()只是把数据排队并不代表已经发送。真正触发网卡发送动作的是tcp_output()。而tcp_output()是否真的向网卡递交数据又取决于 TCP 窗口、拥塞窗口、发送队列状态等多个条件。这就是为什么会出现send返回成功但抓不到包的现象——数据可能还在协议栈内部排队。1.2 为什么发送路径上到处是队列和回调LWIP 是事件驱动协议栈这是理解它一切设计的关键。TCP 是可靠传输协议报文发出去之后不能立刻扔掉必须保留副本等待对端 ACK如果超时没收到 ACK还要重传。所以 LWIP 的发送链路本质上是一条流水线加仓库的结构应用把数据写入TCP 发送缓冲区tcpcb-snd_buf管理剩余容量数据被封装成segment报文段挂到unsent队列等待发送tcp_output()把满足窗口条件的 segment 发出去挂到unacked队列等待确认收到 ACK 后从unacked队列移除释放对应 pbufIP 层没有队列的概念它是在 TCP 调用它的那一刻同步执行的填充 IP 头、查找路由、调用 ARP 模块求解 MAC 地址然后立即交给链路层。链路层的 netif 驱动同样同步执行遍历 pbuf 链表、填写 DMA 描述符、触发发送。所以整个发送过程实际上可以分为两段TCP 层有排队机制是异步的IP 层往下包括 IP、ARP、以太网驱动是同步的调用栈一次走到底。理解这个分界点排查问题时就能快速定位卡在哪一段。2. pbuf贯穿整个发送过程的缓冲区载体如果只记一个 LWIP 概念那必须是 pbuf。数据从应用层进来一直到驱动把比特流送上网线全程都跑在 pbuf 上。pbuf 不是简单的一块内存它是 LWIP 自己实现的内存管理器官专门解决协议栈各层都要往数据前面加头部这个核心矛盾。2.1 四种 pbuf 类型内存来源决定一切LWIP 定义了四种 pbuf 类型区别在于内存从哪里来、能不能扩容、能不能随意操作。类型内存来源特点典型场景PBUF_RAM堆内存mem_malloc连续内存长度固定可读可写可加头部TCP 发送缓冲区、tcp_write的数据存放PBUF_POOL内存池memp切成固定大小的块用链表串起来接收路径、Netconn API 的接收缓冲PBUF_ROM用户提供的内存只读不分配内存只引用外部数据payload 不可写应用直接把全局数组交给协议栈发送PBUF_REF用户提供的内存可写类似 ROM但允许写某些需要改写内容的引用场景发送路径上最常用的是PBUF_RAM。tcp_write()内部会调用pbuf_alloc(PBUF_RAM, length, PBUF_RAM)其中length除了应用数据长度还额外加上了从 TCP 头到以太网头的所有头部长度预留。我在调试时习惯在tcp_write入口看一眼 pbuf 分配大小很多发送异常其实是这里分配失败导致的后面会专门讲。PBUF_REF是个高级话题。RT-Thread 的lwIP组件里做零拷贝发送时就会用到这种类型应用把自己的 DMA 缓冲区直接交给协议栈协议栈不去拷贝数据只在外面包一层协议头。好处是省一次内存拷贝坏处是引用内容在发送完成之前不能被应用改写否则会发出错乱数据。所以PBUF_REF一般配合发送完成回调来使用回调里通知应用缓冲区可以复用了。2.2 pbuf_header封装动作的本质是移动头部指针协议栈封装数据本质上是在数据前面预留并填入协议头部。LWIP 用一个非常精妙的函数pbuf_header()完成这个过程。pbuf 结构体里有两个关键字段payload指向当前数据的起点len表示当前层看到的数据长度。应用数据刚进入协议栈时pbuf 的内存布局是[预留空间头部空间][实际载荷数据] ^ ^ payload payload len直接指向数据区pbuf_header(p, -header_len)做的事情是把p-payload指针向前移动header_len字节同时把p-len增加header_len。调用方随后往payload指向的位置填入协议头字段。注意参数是负数表示指针往低地址方向移动因为 MCU 内存地址是向上增长的头部在数据前面意味着更低的地址。一次完整的 TCP 发送pbuf 上的 payload 指针会这样变化// 应用数据在 pbuf 中payload 指向应用数据开头 // 1. TCP 层往前预留 TCP 头 pbuf_header(p, -(TCP_HLEN)); // 填 TCP 头源端口、目的端口、序号、确认号、窗口、校验和…… // 2. IP 层再往前预留 IP 头 pbuf_header(p, -(IP_HLEN)); // 填 IP 头版本、总长度、TTL、协议号、源 IP、目的 IP…… // 3. 以太网层再往前预留以太网头 pbuf_header(p, -(ETH_HDR_LEN)); // 填 MAC 地址、以太网类型 0x0800三层封装完成之后pbuf 的内存布局就是标准的以太网帧[以太网头][IP 头][TCP 头][应用数据] ^payload有趣的是TCP 层创建 segment 时头部空间的大小在一开始就通过pbuf_alloc的预留参数算好了。tcp_enqueue()分配 pbuf 时用的是PBUF_RAM类型第二个参数是数据总长第三个参数是PBUF_RAM类型的头部预留。LWIP 内部有一套头部预留长度表TCP 段预留PBUF_TRANSPORT大小包含传输层头 IP 头 链路层头IP 层看到这个 pbuf 时直接在预留位置填 IP 头不用再重新分配内存。这就是 pbuf 设计的高明之处——整个发送过程数据只拷贝一次剩下全靠指针移动。3. TCP 层封装分段、序号与发送队列管理TCP 是整条发送路径上逻辑最复杂的一层因为它承担了可靠性、流量控制、拥塞控制三大职责。LWIP 的tcp.c、tcp_out.c是阅读量最高的两个文件几乎所有发送问题最终都要回到这两个文件里找答案。3.1 tcp_write 到 tcp_enqueue按 MSS 切分报文应用调用tcp_write()时数据不一定是一个完整的 TCP 段。如果一次写入的数据量超过 MSS最大报文段长度TCP 层要把数据切成多个段每个段单独封装挂到发送队列上。tcp_write()的完整流程大致是这样检查连接状态如果连接未建立SYN_SENT/SYN_RCVD直接返回错误。检查发送缓冲余量snd_buf如果可写空间小于要写入的数据量返回ERR_MEM。这里说的内存不足更多时候是发送窗口余量不足而不是系统堆内存不足两个概念必须分开。按 MSS 把数据切块逐块调用tcp_enqueue()。LWIP 里tcp_enqueue()是真正干活的函数它负责分配 pbuf、构造tcp_seg结构体、把 segment 挂到发送队列尾部。更新snd_buf和snd_queuelen前者是剩余可发送字节数后者是队列中未发送的段数量。LWIP 对这两个值都有上限控制默认情况下如果队列里的段数量超过TCP_SND_QUEUELEN后续写入会被拒绝。tcp_seg是 TCP 层真正挂在队列上的节点它比 pbuf 多了一层信息struct tcp_seg { struct tcp_seg *next; // 队列下一个节点 u8_t flags; // 段标志如 TF_SEGMENTED struct pbuf *p; // 该段对应的 pbuf 链表 u16_t len; // 该段数据长度不含头部 };每个 TCP 连接有两个队列unsent可以发送、但还没发出去的段和unacked已经发出去、等待 ACK 的段。tcp_output()做的事就是从unsent队列头部取出满足条件的段调用tcp_seg发送函数把它从unsent挪到unacked。MSS 的计算在 LWIP 里有个细节值得注意tcp_eff_send_mss()会综合考虑对端通告的 MSS、本端定义的TCP_MSS以及路径 MTU如果启用了 IP 层 MTU 发现的话。对于 MTU 1500 的以太网扣除 40 字节的 IPTCP 头MSS 通常是 1460。如果网关要接 PPPoS点对点拨号MSS 还得跟着 PPPoE 的 MTU 下调。3.2 tcp_output 的触发条件窗口、定时器和 wnd 计算tcp_output()是 TCP 发送路径的发动机它有四种触发方式tcp_write()之后直接调用RAW API 模式tcpip_thread处理完TCPIP_MSG_API消息后的例行调用ACK 到达时发现窗口变大后触发定时器超时快速重传、RTO 重传值得注意的是tcp_output()并不是简单地把unsent队列里的段全部发出去它要遵守滑动窗口和拥塞窗口的双重限制。发送窗口snd_wnd是对端通告的接收能力拥塞窗口cwnd是本端根据网络拥塞情况调整的发送速率。真正可以发送的数据量是两者中的较小值。用大白话说就是多发可以但要控制在对方能收和网络能扛的范围内。如果unsent里有数据但窗口已经用完tcp_output()会什么都不做直接返回。这时候报文就安静地躺在队列里直到 ACK 到达、窗口打开的那一瞬间才会被发出去。这就是 TCP 层异步性的体现。小窗口综合征Silly Window Syndrome是 TCP 发送的一个经典问题对端每次只通告几个字节的窗口本端为了不打碎报文应当把多个小段累积成一个大的 TCP 段发送。LWIP 在tcp_output()里用snd_buf、snd_queuelen、TCP_SND_QUEUELEN三者的关系做了欠发保护保证太小太碎的数据不会频繁发出。这也是为什么有些应用代码里明明一次只写 6 个字节抓包却看到 TCP 段把好几个 6 字节攒在一起发。3.3 TCP 头部的字段填充seq、ack、flags 与校验和真正封装 TCP 报文头的代码在tcp_out.c的tcp_seg发送函数附近LWIP 用了一组宏来填充头字段。TCPH_SET(tcphdr, src, dest, seqno, ackno, wnd, flg, chksum);其中seqno是当前 TCP 控制块维护的发送序号tcb-snd_nxtackno是对端数据的确认号tcb-rcv_nxtwnd通告窗口是tcb-rcv_wnd。这些字段在连接握手阶段就已经建立起来每次发送时只需要顺序递增序号。校验和计算是 TCP 封装里开销最大的部分LWIP 用inet_chksum_pseudo()函数计算伪首部校验和校验范围覆盖 TCP 头和 TCP 数据并在计算时加入 IP 层的源地址、目的地址、协议号和长度。这里能优化空间很大很多网卡比如 W5500 这类硬件 TCP/IP 芯片会自己计算校验和软件只需要把校验和字段留空对于 STM32 的 MAC 控制器也有硬件校验和卸载功能。在网卡驱动里打开硬件校验和可以显著降低 CPU 开销。调试时我经常在tcp_seg发送前打印seqno、snd_nxt和抓包软件的序列号对比。对端回 ACK 时确认号总是比发送序号多 1如果有数据的话要加上数据长度一旦发现序号不连续说明有重传或者丢包问题往往不在 LWIP 本身而在物理链路或者网卡驱动。4. IP 层输出与 ARP 处理选路、找 MAC、填帧头TCP 层把数据封装好之后调用ip_output_if()进入 IP 层。IP 层的任务比 TCP 层简单直接填充 IP 头、选择出口网卡、解析下一跳 MAC 地址。但这层也有两个容易让新手翻车的地方多网卡路由选择以及 ARP 等待机制。4.1 ip_output 与 ip_route多网卡怎么选路由LWIP 的 IP 输出函数是ip_output()它内部先调用ip_route()选择合适的netif再调用ip_output_if()实际填充 IP 头并发送。ip_route()的路由逻辑是遍历所有 netif优先查找 IP 地址在同一子网的 netif。比如目的地址是192.168.1.100当前 netif 的地址是192.168.1.50/24直接命中。如果子网不匹配查找是否有默认网关netif-gw把数据交给默认路由表里匹配的 netif。都匹配不上返回NULLIP 层丢弃该数据。上层会收到ERR_RTE错误。多网卡设备比如同时有以太网和 4G 模块的网关一定注意ip_route()的查找顺序。LWIP 默认从第一个注册的 netif 开始匹配如果第一个 netif 的子网恰好覆盖了目的地址数据就从它出去哪怕你主观上想通过另一个网卡发送。我遇到过的一个真实案例设备上有线和 4G 两个接口有线网卡地址是192.168.1.x/244G 接口地址是运营商分配的10.x.x.x向局域网内设备发送数据时全部从有线口走了完全没经过 4G排查了半天才想起路由顺序这个细节。IP 头填充逻辑相对机械版本IPv4、首部长度520 字节、总长度字段、标识iphdr-id、TTL默认IP_DEFAULT_TTL通常是 64、协议号TCP 是 6、源地址和目的地址。IP 校验和作用在 20 字节的 IP 头上计算同样走inet_chksum。4.2 ARPIP 到 MAC 的翻译以及发送路径上的第一个卡点以太网发送必须有目的 MAC 地址。LWIP 通过 ARP 协议把 IP 地址解析成 MAC 地址这个过程在etharp.c里实现。当 IP 数据包到达以太网输出函数ethernet_output()时它会查询 ARP 缓存表。ARP 表在 LWIP 里是一个固定大小的数组默认配置是LWIP_ARP_QUEUEING配合 10 条表项。哈希表和重定向链管理着 IP 到 MAC 的映射以及老化时间。这里有个关键环节如果 ARP 表未命中LWIP 不是把数据包直接扔掉而是把 pbuf 挂在一个等待队列里然后发送一条 ARP 请求广播问网络上谁是 192.168.1.100请告诉我你的 MAC 地址。收到 ARP 应答后内核填充等待队列里数据包的以太网头然后把它继续沿发送链路往下送。这个暂存机制就是LWIP_ARP_QUEUEING宏的作用。但这个暂存队列的存在恰恰是很多发送莫名其妙慢了一拍的根源。我第一次 ping 一个陌生设备时第一个 TCP 连接迟迟建立不起来就是因为 TCP 层已经把 SYN 段交给了 IP 层IP 层在 ARP 缓存未命中时把数据挂了起来对端收到了 ARP 请求回复了 ARP 应答本地 ARP 缓存更新之后才把暂存的 SYS 段发出去。第一次连接通常要等一个 ARP 往返后面就快了。如果网络上设备根本没上线ARP 请求会反复发送LWIP 默认重试LWIP_ARP_MAXREQUEST次软驱和网络不可达的情况下这个等待过程会非常明显。排查时可以抓包看一下以太网层有没有 ARP 请求有没有 ARP 应答这两个问题的答案能快速区分问题在 ARP 层还是 TCP 层。4.3 以太网头部填充ethernet_output 的最后一笔ARP 解析成功后报文进入ethernet_output()。这个函数负责填充 14 字节的以太网头ethhdr-dest ethaddr; // 对端 MAC来自 ARP 缓存 ethhdr-src netif-hwaddr; // 本机 MAC ethhdr-type htons(ETHTYPE_IP); // 0x0800表示上层是 IP完成这一步后整个以太网帧已经成形14 字节以太网头 20 字节 IP 头 20 字节 TCP 头 应用数据。ethernet_output()最后调用netif-linkoutput(netif, pbuf)数据和 pbuf 的接力棒到此交到了网卡驱动手里。到这里封装的使命已经完成。接下来的问题不再是协议栈如何封装数据而是驱动如何把 pbuf 里的内容真正搬到网线上。5. netif 链路输出网卡驱动如何把 pbuf 搬上网线到这一步发送路径跨过了协议栈和驱动之间的边界。netif-linkoutput是一层驱动接口函数。不同网卡实现天差地别SPI 接口的以太网控制器要一字节一字节地写STM32 内置 MAC 则要操作 DMA 描述符。我拿最常见的 STM32 内置以太网EMAC来拆解这个过程其他网卡思路大同小异。5.1 从 pbuf 到 DMA 描述符Buffer Chain 的正确姿势STM32 的以太网驱动发送流程是首先拿到netif-linkoutput传来的 pbuf 链表。这里注意pbuf 可能不是一个连续的内存块比如PBUF_POOL类型就是链式的而 DMA 发送要求每个描述符对应一个连续缓冲。遍历 pbuf 链表把每个 pbuf 的payload地址和len长度填入一个或多个 TX DMA 描述符的 Buffer1 地址和长度字段。设置描述符控制字。STM32 的 DMA 描述符支持 Buffer Chain 模式描述符的TCHBuffer Chain标志表示这是链中一环最终描述符LS标志表示这是链的结尾。如果第一个 pbuf 的payload地址不满足 DMA 对齐要求就需要把数据拷贝到驱动自己的发送缓冲区这是以性能换兼容性的典型做法。设置好描述符后操作 DMA 的发送请求位如 ETH_DMAEST 寄存器的 TPL 位触发硬件发送。驱动里经常看到的low_level_output()函数实际上被 LWIP 定义为netif-linkoutput的实现。发送前一定要检查 TX 描述符的状态如果所有描述符都还在被 DMA 占用上一批数据还没发完函数会返回ERR_MEM。这个返回值和 TCP 层的ERR_MEM含义完全不同但经常被人混淆后面排查章节会专门展开。另外有一类高级驱动会做零拷贝发送直接用应用传入的 pbuf 的 payload 地址来填描述符而不是先拷贝到驱动缓冲。省了一次拷贝但要求 payload 必须满足 DMA 对齐、缓存一致性要求还要在发送完成中断里正确释放 pbuf。项目赶进度时不要轻易上零拷贝等基本功能稳定后再优化不迟。5.2 发送完成后的回执中断、清理与回调DMA 把数据发出去之后硬件会置位相应的中断标志驱动在中断处理函数里完成两件事回收 TX 描述符把描述符的控制字里的所有权标志OWN位交还给软件表示这个描述符可以复用了。释放 pbuf。这一步非常重要如果发送完成中断处理不当pbuf 不会被释放内存池会缓慢泄漏最终整个协议栈停止工作。LWIP 用netif-tx_complete()消息通知 TCP 层发送完成tcp_out.c会对重传队列做清理如果发送完成的是重传队列里的段并且没有更多数据要发就取消重传定时器。发送完成事件并不直接等同于对端 ACKTCP 层还要靠 ACK 来最终确认靠谱。调试时可以在发送完成中断里放一个计数器比如每累计 100 次发送通过串口打印剩余 TX 描述符数量。描述符数量持续下降但计数器在涨基本可以断定回收逻辑有 bug。5.3 缓存一致性与内存对齐DMA 发送最容易翻车的地方STM32 的 Cortex-M7 芯片H7 系列带有 D-CacheDMA 访问内存时会绕过 CPU 的 Cache直接读写物理内存这就带来了缓存一致性的问题。具体场景是CPU 往 pbuf 里写入了 TCP/IP 头部和数据但数据可能还躺在 D-Cache 里没来得及写回物理内存。DMA 直接把物理内存里的旧数据发出去结果对端收到的是残缺或者混乱的帧头。解决办法是发送前做一次 Cache 清SCB_CleanDCache_by_Addr(payload, len);同理如果接收方向 DMA 把数据写进了内存而 CPU 去读要先做 Cache 失效操作。M4 系列如 F407没有 D-Cache很多人默认没有这个问题但如果外扩 SDRAM 且开了写缓冲也可能踩到类似坑。驱动移植时看到诡异的数据错误先怀疑 Cache。DMA 描述符的地址还要注意对齐要求一般要求描述符首地址 4 字节对齐H7 的 EMAC 要求 32 位对齐很多芯片手册里特别强调。对齐没做好会导致 DMA 传输直接异常表现为发出去的数据长度对不上、忙时丢包。6. 发送路径上的经典坑与排查思路协议栈和驱动都讲完最实用的是怎么排查问题。我在嵌入式网络调试中总结了一套二分法定位思路先判断是协议栈没发出来还是驱动没发出去还是物理链路没收好。下面这些坑是我在多个项目里真实踩过的按排查顺序排。6.1 调了 send 但抓不到包从链路逐级二分定位遇到这种情况先回答三个问题send()/tcp_write()的返回值是什么如果是ERR_MEM数据根本没进协议栈发送队列如果是 0成功数据大概率还在 TCP 控制块的队列里排队。TCP 连接是否完全建立如果是SYN_SENT状态TCP 数据永远发不出来。目的设备的 ARP 是否已经解析成功我曾经遇到过一个最隐蔽的案例TCP 连接建立、send返回成功、应用层逻辑完全正常但物理层抓不到包。最后在主循环里反复打印tcp_active_pcbs的状态发现连接处于FIN_WAIT_1状态——服务器主动关闭了连接应用层数据在关闭之前发出去对端已经在挥手了。所以排查第一步不是看驱动而是看连接状态。LWIP 开启统计宏之后lwip_stats提供了很多计数器像是link.drop记录了链路层丢弃的包数量mem.err是内存分配失败次数。打开LWIP_STATS和LWIP_DEBUG这两组宏编译期加上LWIP_DEBUG并设置LWIP_DBG_TYPES_ON配合LWIP_DEBUG输出接口直接看各层日志是最快的定位手段。6.2 tcp_write 返回 ERR_MEM 的排查方向ERR_MEM是发送路径上最常见的错误但它的真正含义往往被误解。从tcp_enqueue()返回值来看常见诱因有三个task-snd_buf可写空间不足。应用写数据速度超过对端 ACK 返回速度发送缓冲区满了。解决方向是增大TCP_SND_BUF。如果对端接收窗口小单纯调大本地缓冲区可能没用要结合对端通告的窗口综合判断。TCP_SND_QUEUELEN限制。即使缓冲区还有空间但unsent队列里积压的段数量达到上限后续写入被拒。这个比值可以加大但注意每增加一个段会占用额外的 pbuf 池资源内存不足反而会出问题。pbuf 分配失败。PBUF_POOL或PBUF_RAM内存池耗尽常见于驱动发送完成中断没有正确释放 pbuf或者网上有大量重传占用了内存。排查ERR_MEM最有效的办法是打点统计三个值tcp_sndbuf()返回的剩余窗口、tcp_sndqueuelen()的队列长度、memp_stats[PBUF_POOL].used的 pbuf 使用量。三个数据同时监看基本能锁定是哪个环节堵住。我在网关上抓过数据应用层平均每 100ms 写 4KB 数据TCP 发送缓冲默认 16KB瞬间就被写满后来把TCP_SND_BUF调到 64KB配套加大PBUF_POOL缓冲数量问题解决。6.3 抓包验证Wireshark 只能看到链路层的数据前面所有排查手段最终都要靠抓包来验证。抓包工具看到的是完整的以太网帧所以它能确认的问题是网线上有没有数据、格式对不对而数据为什么没到网线则要往协议栈内部查。抓包时我习惯按顺序看三样东西以太网头源 MAC、目的 MAC、类型字段。类型如果是0x0806说明是 ARP 报文此时说明 IP 数据没到链路层或者卡在 ARP 解析上。IP 头源地址、目的地址、TTL、标识。TTL64 是 LWIP 默认值如果看到 TTL255说明这个包可能被别的设备转发过不是本机直接发的。TCP 头源端口、目的端口、序列号、window 值。window 值能反映本端通告的接收窗口是否正常如果持续为 0说明接收方向有问题发送方向不了。没有抓包工具的环境下可以在驱动low_level_output入口打印pbuf_total_len(pbuf)和pbuf-payload的首字节通过串口确认数据确实到达驱动层。打印首字节可以验证以太网头的第一个字节是目的 MAC 的第一个字节这个值和目的 IP 对应起来能区分是路由错误还是头填充错误。最后再分享一个我的调试习惯给板子加一个#define TXDUMP_DEBUG的编译开关在ethernet_output()和low_level_output()之间加一层 hook把待发送帧的前 64 字节直接 dump 到串口。这层 hook 位于协议栈和驱动的交界处既不会干扰协议栈内部的队列和管理又能直观看到即将发出的帧内容。这个习惯帮我定位过至少三个问题一个是 ARP 头填充错误导致整个局域网无法 ping 通一个是对端 MAC 地址字段写反还有一个是 IP 校验和错误导致对端直接丢弃报文。调试发送链路最省时间的不是猜而是把每一层的交接点打上日志让数据自己说它走到哪一步了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑