在 Windows 上跑 lwIP(二):TCP 客户端实测与 Wireshark 逐包分析

0 阅读7分钟

在 Windows 上跑 lwIP(二):TCP 客户端实测与 Wireshark 逐包分析

上一篇我们把 lwIP 在 Windows 上搭了起来,能 ping 通网关。ping 只证明 ICMP 能走通,真正的目标是 TCP。本篇让 lwIP 作为 TCP 客户端连接公网服务器。

第一部分:事前了解

测试程序:socket_examples 是什么??

contrib/apps/socket_examples/socket_examples.c 是官方贡献的 socket API 测试集,在 contrib/examples/example_app/lwipcfg.h 里打开:

#define LWIP_SOCKET_EXAMPLES_APP      1

它会依次跑三个测试,每个通过打印一行 finished successfully,最后打印 all tests done, thread ending

测试验证什么
sockex_nonblocking_connect非阻塞 connect:先连真实目标(select 等可写)成功,再连一个死主机验证返回 EINPROGRESS、最终报错
sockex_testrecv阻塞 connect + 分段发送 HTTP 请求 + 带超时的 recv
sockex_testtwoselects两个 socket 各发一个字节,另起四个线程并发 select 等事件(预期超时)

这三个测试正好覆盖了 socket 编程的三种典型姿势,而且全程走 BSD socket API——和 Linux 网络编程是同一套接口,这也是 lwIP 适合学习的原因之一。

第二部分:网络配置调整

2.1 改动清单:指到一个"活"的公网目标

原版默认目标是个伪造的链路本地 IPv6 地址(FE80::12:34FF:FE56:78AB)——那台机器不存在,就算存在,链路本地地址也出不了局域网。本篇要练的是 lwIP → 路由器 NAT → 公网的完整链路,目标必须换。全程改动就两处,都在 contrib/apps/socket_examples/socket_examples.c 里。本节先把改法完整给出来(可以直接照抄),为什么这么改,见 2.2、2.3 节。

改动 1:目标地址换成 Cloudflare 的 1.1.1.1:80,写成 IPv4-mapped 形式

/* IPv4-mapped form of 1.1.1.1 (Cloudflare): the router has no web UI on :80,
 * so use a public host to also exercise NAT. lwIP maps this to plain IPv4. */
#define SOCK_TARGET_HOST6  "::ffff:1.1.1.1"

(注释原文是英文,意思是:目标选 Cloudflare 的 1.1.1.1:80——路由器自己的 80 端口没有 Web 服务,用公网地址正好把路由器 NAT 也纳入链路,更接近真实嵌入式设备上网。)

改动 2:非阻塞测试里"连不通的地址",改成局域网里一台死主机

原来是:

  addr.sin6_addr.un.u8_addr[0]++; /* this should result in an invalid address */

改为(一段注释 + 四行赋值,注释也是这次改动加的):

  /* 上游的做法是 addr.sin6_addr.un.u8_addr[0]++ 来得到一个无效地址,
     但我们的目标是 IPv4-mapped(::ffff:1.1.1.1):首字节 +1 会得到一个
     不可路由的原生 IPv6 地址,错误码变成 ENETUNREACH 而非 EINPROGRESS。
     改为指向局域网里一台死主机:ARP 解析失败,
     正好产生这个测试想要的连接错误。 */
  addr.sin6_addr.un.u8_addr[12] = 192;
  addr.sin6_addr.un.u8_addr[13] = 168;
  addr.sin6_addr.un.u8_addr[14] = 0;
  addr.sin6_addr.un.u8_addr[15] = 222; /* 必须是局域网里一台真实不存在的死主机 */

改的这几行代码,会把 ::ffff:1.1.1.1 原地变成 ::ffff:192.168.0.222,mapped 结构原封不动,IPv6 分支照常能解析。

选 192.168.0.222 是因为事先 ping 过、确认网段里没有这台机器——你照抄之前要在自己网段里同样确认一台不存在的地址。

2.2 原理:为什么目标要写成 ::ffff: 这种形式

简单来说:这是我为了同时兼容IPv6和IPv4所做出的改变

关键在于测试代码有IPv6和IPv4两条编译分支,所以实际走 IPv6 分支时——用 sockaddr_in6ip6addr_aton() 解析目标。但我的网络是纯 IPv4 的,真给一个原生 IPv6 地址根本发不出去。

::ffff:1.1.1.1 这种 IPv4-mapped 地址正好卡在两边中间:IPv6 分支的代码能正常解析它,lwIP 内部又会识别 mapped 形式、自动转回纯 IPv4 路径发包。一个定义,同时满足编译分支和真实网络。

2.3 原理:为什么死主机能修好非阻塞测试

先看上游原版为什么"应该能"工作:u8_addr[0]++ 的注释自己写的是 "should result in an invalid address"——"应该能"得到一个无效地址,话说得很留余地。它的隐含假设是"目标是原生 IPv6 地址":默认的 FE80::... 首字节 +1 后落到组播段,连不通、但错误码符合预期。

我的改动 1 把这个假设打破了:::ffff:1.1.1.1 的首字节是 0x00,+1 后变成 0100::...——一个原生 IPv6 地址,而我的网卡没有 IPv6 路由,connect 当场失败,错误码是 ENETUNREACH,可测试断言的是 EINPROGRESS,断言直接炸。

死主机修复的本质,是把失败点从路由层后移到 ARP 层:IPv4 路由没问题,卡在 ARP 解析——192.168.0.222 没人应答,SYN 永远发不出去,连接永远"正在进行中"。这正是 EINPROGRESS 的本义。之后 ARP 放弃、错误通过 select 的 errset 返回,又正好对上测试后半段的断言。

2.4 跑通

你能依次看到以下输出:

sockex_nonblocking_connect finished successfully
sockex_testrecv finished successfully
sockex_testtwoselects finished successfully
all tests done, thread ending

这是我的测试通过结果:

socket_examples_output.png

第三部分:抓包分析

3.1 怎么找到那几条 TCP 流

详细操作可以跟随网上教程学

网卡上流量很杂,关键是过滤。我的过滤路径分两步,先粗后细:

  1. 显示过滤器先缩小范围tcp && ip.addr == 1.1.1.1——只看 lwIP 和测试目标之间的 TCP 包,其余流量全部滤掉(前提是抓包时选对了网卡:就是 lwIP 用的那块有线网卡,Npcap 抓包和 lwIP 收包看到的是同一条链路)
  2. 再用 TCP Stream 锁定单条连接:在过滤结果里找到目标流的任意一个包,右键 → Follow → TCP Stream,Wireshark 会自动把过滤器改成 tcp.stream eq N——一条完整连接就单独拎出来了(3.2 节的截图就是这么来的)

3.2 逐包分析:一条完整的 TCP 连接

sockex_testtwoselects 的一条流为例(Follow TCP Stream 后 tcp.stream eq 20;客户端 192.168.0.200 是 lwIP,服务器 1.1.1.1):

tcp_twoselects_stream20.png

                       【 ① 三次握手 】
No.   方向              标志         说明
403   lwIP → 服务器     SYN          Seq=0 Win=20480 MSS=1024
406   服务器 → lwIP     SYN, ACK     Seq=0 Ack=1 Win=65535 MSS=1300
407   lwIP → 服务器     ACK          握手完成,连接建立
─────────────────────────────────────────────────────────────
                       【 ② 数据传输 】
440   lwIP → 服务器     PSH, ACK     Len=1:发出一个字节 "G"
442   服务器 → lwIP     ACK          确认收到这 1 字节
...   (约 2.2 秒静默,双方都没有说话——测试在等 select 超时)
─────────────────────────────────────────────────────────────
                       【 ③ 连接关闭(挥手) 】
455   lwIP → 服务器     FIN, ACK     lwIP 主动关闭
457   服务器 → lwIP     FIN, ACK     服务器把"确认你的 FIN"和自己的 FIN 合进一个包
459   lwIP → 服务器     ACK          连接关闭

为什么挥手只有三个包?

这是一个常见的问题

教科书说四次挥手:FIN → ACK → FIN → ACK。实际抓到的是三个:

  1. lwIP 发 FIN(455)
  2. 服务器回 FIN, ACK(457)——一个包顶俩
  3. lwIP 回 ACK(459)

原因是合并确认:服务器收到 FIN 时自己也已经没有数据要发(它从头到尾只确认过一个字节),于是把"对你 FIN 的 ACK"和自己的 FIN 合并进同一个包发出(piggyback),四次挥手就此变成三个包。这是规范允许的标准行为,不是异常。能不能凑齐四次,取决于对端实现和时序:对端收到 FIN 时若还有话要说,就会先 ACK、说完再 FIN,四次一个不少。

当然,Wireshark 的妙用远不止于此——本篇只用到了一小部分(显示过滤器 + Follow TCP Stream),更多玩法值得慢慢探索。

总结

  • socket API 层三个测试全过:阻塞/非阻塞 connect、分段 send、select 多路复用
  • Wireshark 验证了完整链路:握手标准、数据双向可达、关闭行为符合规范

下一篇预告

example_app 跑得再顺,那也是别人的应用——真实项目里没人会把产品代码写进示例程序里。下一篇《在 Windows 上跑 lwIP(三):把 lwIP 移植进自己的工程》,按 lwIP 官方 BUILDING 文档的标准用法,把 lwIP 当库链接进自己的 CMake 工程。


环境:Windows 11 / MSYS2 UCRT64 gcc 16.1.0 / lwIP 2.2.2 / Npcap SDK 1.16