2UNIX网络编程(第3版)

TCP/IP协议族

协议基础

IPTCPUDP协议栈

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第2章 TCP/IP协议族

协议基础

一、导读

2.1 本章学习目标

第2章从第1章的宏观概览进入微观层面,系统深入地介绍TCP/IP协议族中各个核心协议的工作原理。如果说第1章让你知道了网络通信"是什么",那么本章将告诉你"怎么做"——数据是如何从你的程序一步步到达目的地的。

本章的核心学习目标包括:

  • 深入理解IP协议的工作机制:IP数据报的格式、寻址方式、分片与重组、路由选择的基本原理。
  • 掌握TCP的可靠传输机制:三次握手、四次挥手、序列号与确认号、超时重传、流量控制和拥塞控制。
  • 理解UDP的设计哲学:为什么在TCP已经足够强大的情况下还需要UDP?UDP适用于什么场景?
  • 了解ICMP和ARP的作用:这些辅助协议如何支撑网络通信的正常运行?
  • 掌握数据封装与解封装的完整过程:数据从应用层到物理介质的完整旅程。
  • 理解端口号和寻址机制:IP地址的分类、子网划分、NAT的基本概念。

2.2 为什么本章如此重要

很多程序员认为"网络编程只需要调用API就够了,不需要理解底层协议"。这种观点是极其危险的。Stevens在本书中反复强调:不理解协议的程序员,写出的代码可能在测试环境下正常工作,但在生产环境中出现各种诡异的问题。

举几个典型的例子:

  • 不理解TCP的字节流特性,就会在消息边界上犯错——接收端把两个消息拼在一起,或者把一个消息拆成两半。
  • 不理解TCP的TIME_WAIT状态,就会在服务器重启时遇到"Address already in use"的错误。
  • 不理解IP分片,就会发送超过MTU的UDP数据报导致丢包。
  • 不理解NAT,就会困惑为什么客户端能连接服务器,但服务器无法主动连接客户端。

理解协议不是为了成为网络专家,而是为了写出正确的、健壮的、高性能的网络程序。

2.3 核心问题引导

在阅读本章时,请带着以下问题思考:

TCP的三次握手为什么不能是两次?两次握手会有什么问题?

为什么TCP需要序列号和确认号?它们是如何协作保证可靠传输的?

UDP数据报的最大长度是多少?为什么不能无限大?如果超过MTU会怎样?

ARP是如何将IP地址映射到MAC地址的?ARP缓存的作用是什么?

当你在浏览器中输入 www.google.com 时,DNS解析、TCP连接、HTTP请求分别在协议栈的哪一层工作?

NAT解决了什么问题?它带来了哪些新的复杂性?


二、核心概念详解

2.1 IP协议:网络层的核心

2.1.1 IP数据报格式

IP(Internet Protocol)是TCP/IP协议族中最核心的协议,它负责将数据报从源主机路由到目的主机。每个IP数据报由头部和数据两部分组成。

IPv4数据报的头部结构:

0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|Version|  IHL  |Type of Service|          Total Length         |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|         Identification        |Flags|      Fragment Offset    |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  Time to Live |    Protocol   |         Header Checksum       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                       Source Address                          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                     Destination Address                       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    Options (if IHL > 5)                       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                         Data (payload)                        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

关键字段解析:

  • Version(4位):IP版本号,IPv4为4,IPv6为6。
  • IHL(4位):IP头部长度,以4字节为单位。最小值为5(20字节),最大值为15(60字节)。
  • Total Length(16位):整个IP数据报的长度(头部+数据),最大65535字节。
  • Identification(16位):数据报标识符,用于分片和重组。同一数据报的所有分片使用相同的标识符。
  • Flags(3位):分片控制标志。DF(Don't Fragment)=1表示不允许分片;MF(More Fragments)=1表示后面还有分片。
  • Fragment Offset(13位):分片偏移量,以8字节为单位。指示该分片在原始数据报中的位置。
  • TTL(8位):生存时间,每经过一个路由器减1,减到0时数据报被丢弃。防止数据报在网络中无限循环。
  • Protocol(8位):上层协议类型。6=TCP,17=UDP,1=ICMP。
c
// IP头部结构(Linux <netinet/ip.h>)
struct iphdr {
#if __BYTE_ORDER == __LITTLE_ENDIAN
    unsigned int ihl:4;
    unsigned int version:4;
#elif __BYTE_ORDER == __BIG_ENDIAN
    unsigned int version:4;
    unsigned int ihl:4;
#endif
    uint8_t  tos;            // Type of service
    uint16_t tot_len;        // Total length
    uint16_t id;             // Identification
    uint16_t frag_off;       // Fragment offset + flags
    uint8_t  ttl;            // Time to live
    uint8_t  protocol;       // Protocol (TCP=6, UDP=17)
    uint16_t check;          // Header checksum
    uint32_t saddr;          // Source address
    uint32_t daddr;          // Destination address
};

2.1.2 IP寻址

IP地址是32位的标识符,用于唯一标识网络中的设备。通常用点分十进制表示法:192.168.1.100。

IP地址分为网络部分和主机部分。子网掩码(subnet mask)用于区分这两部分:

IP地址:     192.168.1.100    → 11000000.10101000.00000001.01100100
子网掩码:   255.255.255.0    → 11111111.11111111.11111111.00000000
网络地址:   192.168.1.0      → 11000000.10101000.00000001.00000000
广播地址:   192.168.1.255    → 11000000.10101000.00000001.11111111
主机范围:   192.168.1.1 ~ 192.168.1.254

CIDR(Classless Inter-Domain Routing)表示法:192.168.1.0/24 表示前24位是网络前缀。

特殊IP地址:

地址含义
0.0.0.0本网络(仅在初始化时使用)
127.0.0.0/8环回地址(loopback)
10.0.0.0/8私有地址(A类)
172.16.0.0/12私有地址(B类)
192.168.0.0/16私有地址(C类)
169.254.0.0/16链路本地地址(APIPA)
255.255.255.255受限广播地址
c
// 使用inet_pton和inet_ntop处理IP地址
#include <arpa/inet.h>

// 字符串 → 二进制
struct in_addr addr;
if (inet_pton(AF_INET, "192.168.1.100", &addr) != 1) {
    perror("inet_pton error");
}
// addr.s_addr 现在是网络字节序的32位IP地址

// 二进制 → 字符串
char buf[INET_ADDRSTRLEN];  // 16字节,足够存放 "xxx.xxx.xxx.xxx\0"
inet_ntop(AF_INET, &addr, buf, sizeof(buf));
printf("IP: %s\n", buf);  // 输出: IP: 192.168.1.100

常见误区:inet_addr() 是一个旧的函数,它将字符串转换为网络字节序的IP地址,但无法表示 255.255.255.255(因为返回 -1 表示错误,而 255.255.255.255 的二进制恰好也是 -1)。应该使用 inet_pton() 替代。

2.1.3 IP分片与重组

每个链路层技术都有自己的MTU(Maximum Transmission Unit,最大传输单元)。以太网的MTU是1500字节。当一个IP数据报大于链路的MTU时,路由器必须将其分片(fragment)成更小的片段。

c
// 分片示例
// 原始数据报:4000字节数据 + 20字节IP头 = 4020字节
// MTU = 1500字节
// 每个分片最多携带 1500 - 20 = 1480字节数据

// 分片1: 数据偏移=0,    MF=1, 数据=1480字节
// 分片2: 数据偏移=185,  MF=1, 数据=1480字节 (185 = 1480/8)
// 分片3: 数据偏移=370,  MF=0, 数据=1040字节 (370 = 2960/8)

// 注意:分片偏移以8字节为单位!
// 1480 / 8 = 185
// 2960 / 8 = 370

重组在目的主机上进行,而不是在路由器上。这意味着分片可能通过不同路径到达,目的主机需要根据Identification、Fragment Offset和MF标志将它们重新组装。

对网络编程的影响:

  • TCP:TCP在建立连接时通过MSS(Maximum Segment Size)协商最大段大小,通常避免IP分片。MSS = MTU - IP头(20) - TCP头(20) = 1460字节(以太网场景)。
  • UDP:UDP不协商MSS。如果UDP数据报加上IP头超过MTU,就会被分片。分片会增加丢包风险(任一分片丢失,整个数据报丢弃)。因此UDP数据报通常不应超过576字节(IPv4的最小重组缓冲区大小)或更保守地不超过512字节。
c
// 检测路径MTU:使用DF标志的ICMP ping
// ping -M do -s 1472 www.example.com
// 1472字节ICMP数据 + 8字节ICMP头 + 20字节IP头 = 1500字节(以太网MTU)
// 如果返回 "Message too long",说明路径MTU小于1500

// 在代码中设置DF标志(Linux)
int val = 1;
setsockopt(sockfd, IPPROTO_IP, IP_MTU_DISCOVER, &val, sizeof(val));
// 之后如果发送的数据报超过路径MTU,write/sendto会返回EMSGSIZE错误

2.2 TCP协议:可靠传输的基石

2.2.1 TCP报文段格式

0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          Source Port          |        Destination Port       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                        Sequence Number                        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    Acknowledgment Number                      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  Data |           |U|A|P|R|S|F|                               |
| Offset|  Reserved |R|C|S|S|Y|I|          Window               |
|       |           |G|K|H|T|N|N|                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|           Checksum            |         Urgent Pointer        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    Options (if data offset > 5)               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                         Data (payload)                        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

关键字段:

  • Source Port / Destination Port(各16位):标识发送方和接收方的应用进程。
  • Sequence Number(32位):本报文段数据的第一个字节的序号。
  • Acknowledgment Number(32位):期望收到的下一个字节的序号(累积确认)。
  • Data Offset(4位):TCP头部长度,以4字节为单位。最小5(20字节),最大15(60字节)。
  • Flags(6位):

- URG:紧急指针有效

- ACK:确认号有效(几乎所有报文段都设置此标志)

- PSH:请求接收方立即将数据交付应用层

- RST:重置连接(异常关闭)

- SYN:同步序号(建立连接)

- FIN:发送方完成数据发送(释放连接)

  • Window(16位):接收窗口大小,用于流量控制。

2.2.2 TCP连接管理:三次握手

TCP使用三次握手(three-way handshake)建立连接:

客户端                         服务器
      │                              │
      │  ① SYN (seq=x)              │
      │  ─────────────────────────→  │  服务器进入SYN_RCVD
      │                              │
      │  ② SYN+ACK (seq=y, ack=x+1) │
      │  ←─────────────────────────  │
      │  客户端进入ESTABLISHED        │
      │                              │
      │  ③ ACK (ack=y+1)            │
      │  ─────────────────────────→  │  服务器进入ESTABLISHED
      │                              │
      │  连接建立,可以传输数据        │

为什么需要三次而不是两次?

两次握手无法防止历史连接的建立。假设客户端发送的一个SYN在网络中滞留,超时后客户端重发SYN并成功建立连接、传输数据、关闭连接。如果滞留的旧SYN后来到达服务器,服务器会认为这是一个新的连接请求并回复SYN+ACK。如果只有两次握手,此时连接就建立了——但客户端知道这是一个过期的请求,会忽略它。服务器却以为连接已建立,等待数据,导致资源浪费。

三次握手中,客户端收到SYN+ACK后会检查确认号是否匹配。对于旧SYN的响应,确认号不匹配,客户端会发送RST拒绝连接。

c
// 使用tcpdump观察三次握手
// sudo tcpdump -i lo -nn 'tcp port 8080'
//
// 典型输出:
// 12:00:00.000000 IP 127.0.0.1.12345 > 127.0.0.1.8080:
//   Flags [S], seq 1234567890, win 65495, length 0
//   → SYN, seq=x
//
// 12:00:00.000100 IP 127.0.0.1.8080 > 127.0.0.1.12345:
//   Flags [S.], seq 987654321, ack 1234567891, win 65483, length 0
//   → SYN+ACK, seq=y, ack=x+1
//
// 12:00:00.000200 IP 127.0.0.1.12345 > 127.0.0.1.8080:
//   Flags [.], ack 987654322, win 65495, length 0
//   → ACK, ack=y+1

SYN队列与accept队列:

服务器端维护两个队列:

  • SYN队列(半连接队列):收到SYN后,连接进入SYN_RCVD状态,放入此队列。发送SYN+ACK后等待ACK。
  • accept队列(全连接队列):收到ACK后,连接进入ESTABLISHED状态,放入此队列。accept() 从此队列取出连接。

listen(sockfd, backlog) 的 backlog 参数控制accept队列的大小。SYN队列的大小通常由系统参数 tcp_max_syn_backlog 控制。

2.2.3 TCP连接释放:四次挥手

TCP使用四次挥手(four-way handshake)释放连接:

主动关闭方                     被动关闭方
      │                              │
      │  ① FIN (seq=u)              │
      │  ─────────────────────────→  │  被动方进入CLOSE_WAIT
      │                              │
      │  ② ACK (ack=u+1)            │
      │  ←─────────────────────────  │
      │  主动方进入FIN_WAIT_1         │
      │                              │
      │  ③ FIN (seq=w)              │
      │  ←─────────────────────────  │  被动方进入LAST_ACK
      │                              │
      │  ④ ACK (ack=w+1)            │
      │  ─────────────────────────→  │  被动方进入CLOSED
      │  主动方进入TIME_WAIT          │
      │  (等待2MSL后进入CLOSED)       │

为什么需要四次而不是三次? 因为TCP连接是全双工的。每个方向的关闭需要独立的FIN和ACK。当一方发送FIN时,只表示它不再发送数据,但仍然可以接收数据。另一方可能还有数据要发送,所以ACK和FIN通常不会合并。

TIME_WAIT状态:

主动关闭方在发送最后的ACK后进入TIME_WAIT状态,持续2MSL(Maximum Segment Lifetime,通常为60秒,所以TIME_WAIT持续120秒)。

TIME_WAIT的作用:

确保最后的ACK到达:如果ACK丢失,被动方会重发FIN,主动方可以在TIME_WAIT状态下重新发送ACK。

让网络中残留的数据报消失:防止旧连接的数据报被新连接误收。

c
// 查看系统中的TIME_WAIT连接
// ss -tan state time-wait
// netstat -tan | grep TIME_WAIT

// SO_REUSEADDR 可以让服务器在TIME_WAIT状态下绑定同一端口
int optval = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
// 注意:SO_REUSEADDR 只对 bind() 有效,不影响客户端

常见误区:

  • "TIME_WAIT是浪费资源的。"——TIME_WAIT是TCP协议正确工作的必要条件,不能简单地消除它。
  • "SO_REUSEADDR可以消除TIME_WAIT。"——SO_REUSEADDR只是允许在TIME_WAIT状态下重新绑定同一端口,TIME_WAIT状态本身仍然存在。

2.2.4 TCP可靠传输机制

TCP通过以下机制保证可靠传输:

序列号与确认号:

每个字节都有一个序列号。发送方为每个字节编号,接收方通过确认号告诉发送方"我已经成功收到了序号小于N的所有字节,期望下一个字节的序号是N"。这就是累积确认。

发送方发送:  seq=100, 数据="Hello" (5字节)
发送方发送:  seq=105, 数据="World" (5字节)

接收方确认:  ack=110  → 表示"我已收到序号<110的所有字节"
             即确认了 "Hello" 和 "World" 都收到了

超时重传:

发送方发送数据后启动定时器。如果在定时器到期前收到确认,则取消定时器;如果定时器到期仍未收到确认,则重传数据。

超时时间由RTO(Retransmission Timeout)决定。RTO基于RTT(Round-Trip Time)的动态估计,使用Jacobson/Karels算法:

RTT估计值:  SRTT (Smoothed RTT)
RTT偏差:    RTTVAR (RTT Variation)
RTO = SRTT + 4 × RTTVAR

快速重传:

如果发送方连续收到3个重复的ACK(即收到4个相同的ACK),说明某个报文段很可能丢失了。发送方立即重传,不必等待超时。

c
// 使用ss观察TCP连接的详细信息
// ss -ti dst :8080
//
// 输出示例:
// State    Recv-Q  Send-Q  Local Address:Port   Peer Address:Port
// ESTAB    0       0       127.0.0.1:12345      127.0.0.1:8080
//   cubic wmem:106496 cwnd:10 ssthresh:7 send 150.3Mbps
//   rtt:0.046ms mss:16384 rcv_rtt:0.05 rcv_space:87380

2.2.5 TCP流量控制与拥塞控制

流量控制:防止发送方发送过快导致接收方缓冲区溢出。通过TCP头部的Window字段实现——接收方在ACK中告知发送方自己的接收窗口大小(rwnd)。发送方的未确认数据量不能超过rwnd。

接收窗口 rwnd = 接收缓冲区大小 - 已使用缓冲区大小

发送方发送窗口 = min(rwnd, cwnd)
// cwnd = 拥塞窗口,由拥塞控制算法决定

拥塞控制:防止过多的数据注入网络,导致网络中的路由器或链路过载。TCP使用多种拥塞控制算法:

  • 慢启动(Slow Start):连接建立初期,cwnd从1个MSS开始,每收到一个ACK翻倍(指数增长),直到达到ssthresh。
  • 拥塞避免(Congestion Avoidance):cwnd超过ssthresh后,每个RTT增加1个MSS(线性增长)。
  • 快速重传(Fast Retransmit):收到3个重复ACK,立即重传。
  • 快速恢复(Fast Recovery):快速重传后,ssthresh = cwnd/2,cwnd = ssthresh + 3,然后进入拥塞避免。
c
// 查看Linux系统的TCP拥塞控制算法
// cat /proc/sys/net/ipv4/tcp_congestion_control
// 输出可能是: cubic, reno, bbr 等

// 设置拥塞控制算法(需要root权限)
// sysctl -w net.ipv4.tcp_congestion_control=bbr

// 在程序中查看连接的拥塞控制信息
// 使用 getsockopt 和 TCP_CONGESTION 选项
char cc[16];
socklen_t len = sizeof(cc);
getsockopt(sockfd, IPPROTO_TCP, TCP_CONGESTION, cc, &len);
printf("Congestion control: %s\n", cc);

2.3 UDP协议:轻量级传输

2.3.1 UDP报文格式

UDP的头部极其简单,只有8个字节:

0      7 8     15 16    23 24    31
+--------+--------+--------+--------+
|     Source Port   |   Dest Port    |
+--------+--------+--------+--------+
|     Length        |    Checksum    |
+--------+--------+--------+--------+
  • Source Port(16位):源端口号(可选,可以为0)
  • Destination Port(16位):目的端口号
  • Length(16位):UDP报文总长度(头部+数据),最小为8
  • Checksum(16位):校验和,覆盖头部和数据

UDP的最大数据报长度:理论最大值为65535 - 8(UDP头)= 65527字节。但实际受限于IP层的MTU。以太网MTU为1500字节,减去IP头20字节和UDP头8字节,推荐的最大UDP数据为1472字节。超过MTU会导致IP分片。

2.3.2 UDP的特点与适用场景

UDP不提供可靠性保证,但它的简洁性使其在特定场景中具有优势:

适用UDP的场景:

  • DNS查询:请求和响应都很短(通常<512字节),不需要连接建立的开销。
  • 实时音视频:对延迟敏感,可以容忍少量丢包。重传反而会增大延迟。
  • 在线游戏:状态更新频繁,旧数据比丢失更糟糕。
  • DHCP:在IP地址分配之前,不能使用TCP(TCP需要IP地址)。
  • SNMP:网络管理协议,简单查询。
  • NTP:时间同步,需要低延迟。

不适用UDP的场景:

  • 文件传输(需要完整的数据)
  • Web浏览(需要可靠的页面加载)
  • 电子邮件(需要可靠的邮件传递)
  • 任何不能容忍数据丢失的应用
c
// UDP的"connected"模式
// 虽然UDP是无连接的,但可以使用connect为UDP套接字指定对端
// 之后可以使用write/read代替sendto/recvfrom

int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(9999);
inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);

// connect UDP套接字
connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));

// 之后可以直接使用write/read
write(sockfd, "Hello UDP", 9);
// 等价于 sendto(sockfd, "Hello UDP", 9, 0, ...)

// 好处:
// 1. 可以使用read/write,代码更简洁
// 2. 内核只交付来自connect指定地址的数据报
// 3. ICMP端口不可达错误会交付给应用(否则UDP会忽略)

2.4 辅助协议:ICMP与ARP

2.4.1 ICMP协议

ICMP(Internet Control Message Protocol)用于在IP主机、路由器之间传递控制信息和错误报告。它是IP协议的辅助协议,使用IP协议进行传输(protocol字段=1)。

常见的ICMP消息类型:

类型代码含义
00Echo Reply(ping响应)
30-15Destination Unreachable
31Host Unreachable
33Port Unreachable
34Fragmentation Needed(DF位设置时)
50Redirect
110Time Exceeded(TTL=0)
80Echo Request(ping请求)
c
// ICMP对网络编程的影响

// 1. UDP端口不可达 → ICMP Port Unreachable
// 当UDP数据报到达目的主机但端口没有进程监听时,
// 目的主机会返回ICMP Port Unreachable错误。
// 但UDP是无连接的,这个错误不会立即返回给发送方。
// 只有当下一次write/sendto时,才会收到ECONNREFUSED错误。

// 2. TCP连接中的ICMP错误
// TCP收到ICMP错误后,会根据情况处理:
// - 如果连接已建立,收到"port unreachable"→ 返回ECONNREFUSED
// - 如果正在建立连接(SYN_SENT),收到"port unreachable"→ connect返回ECONNREFUSED

// 3. ping命令使用ICMP Echo
// ping www.example.com
// PING www.example.com (93.184.216.34) 56(84) bytes of data.
// 64 bytes from 93.184.216.34: icmp_seq=1 ttl=56 time=12.3 ms

2.4.2 ARP协议

ARP(Address Resolution Protocol)将IP地址映射到物理地址(MAC地址)。当一台主机需要向同一局域网内的另一台主机发送数据时,它需要知道目的MAC地址。

ARP的工作流程:

主机检查自己的ARP缓存,查找目的IP对应的MAC地址。

如果缓存中没有,主机广播ARP请求:"谁有IP地址X.X.X.X?请告诉Y.Y.Y.Y"。

拥有该IP地址的主机回复ARP响应:"X.X.X.X的MAC地址是XX:XX:XX:XX:XX:XX"。

发送方将映射关系存入ARP缓存(通常有超时时间,如120秒)。

bash
# 查看ARP缓存
arp -n
# 或
ip neigh show

# 输出示例:
# Address          HWtype  HWaddress           Flags Mask  Iface
# 192.168.1.1      ether   aa:bb:cc:dd:ee:ff   C           eth0
# 192.168.1.100    ether   11:22:33:44:55:66   C           eth0

# 添加静态ARP条目
# arp -s 192.168.1.50 00:11:22:33:44:55

# 删除ARP条目
# arp -d 192.168.1.1

ARP对网络编程的影响:通常ARP对应用层是透明的。但了解ARP有助于理解以下现象:

  • 第一次ping某个IP可能比较慢(需要ARP解析),后续的ping更快(ARP缓存命中)。
  • 跨网段通信时,ARP解析的是下一跳路由器的MAC地址,而不是最终目的地的MAC地址。

2.5 数据封装的完整过程

让我们追踪一个HTTP请求从浏览器到服务器的完整旅程:

应用层:浏览器生成HTTP请求
  "GET /index.html HTTP/1.1\r\nHost: www.example.com\r\n\r\n"
      ↓
传输层:TCP添加头部
  [TCP头(20B) | HTTP请求数据]
  源端口: 49152, 目的端口: 80
  seq=1000, ack=0, flags=PSH|ACK, window=65535
      ↓
网络层:IP添加头部
  [IP头(20B) | TCP头(20B) | HTTP请求数据]
  源IP: 192.168.1.100, 目的IP: 93.184.216.34
  TTL=64, Protocol=6(TCP), Total Length=65
      ↓
链路层:以太网添加帧头和帧尾
  [帧头(14B) | IP头(20B) | TCP头(20B) | HTTP数据(25B) | FCS(4B)]
  源MAC: aa:bb:cc:dd:ee:ff
  目的MAC: 11:22:33:44:55:66 (网关的MAC)
  EtherType: 0x0800 (IPv4)
  总长度: 83字节
      ↓
物理层:转换为电信号/光信号发送到物理介质

注意:目的MAC是网关(路由器)的MAC地址,而不是最终服务器的MAC地址。因为ARP只能解析同一局域网内的MAC地址。跨网段通信需要路由器逐跳转发。

c
// 使用原始套接字观察封装过程(需要root权限)
#include <sys/socket.h>
#include <netinet/ip.h>
#include <netinet/tcp.h>
#include <net/ethernet.h>
#include <stdio.h>
#include <string.h>

int main(void) {
    // 创建原始套接字,接收所有IP数据报
    int sockfd = socket(AF_INET, SOCK_RAW, IPPROTO_TCP);
    if (sockfd < 0) {
        perror("socket");
        return 1;
    }

    char buf[65536];
    while (1) {
        ssize_t n = recvfrom(sockfd, buf, sizeof(buf), 0, NULL, NULL);
        if (n < 0) break;

        struct iphdr *ip = (struct iphdr *)buf;
        struct tcphdr *tcp = (struct tcphdr *)(buf + ip->ihl * 4);

        printf("IP: %d.%d.%d.%d → %d.%d.%d.%d\n",
               (ip->saddr >> 0) & 0xff, (ip->saddr >> 8) & 0xff,
               (ip->saddr >> 16) & 0xff, (ip->saddr >> 24) & 0xff,
               (ip->daddr >> 0) & 0xff, (ip->daddr >> 8) & 0xff,
               (ip->daddr >> 16) & 0xff, (ip->daddr >> 24) & 0xff);
        printf("TCP: port %u → %u, seq=%u, ack=%u\n",
               ntohs(tcp->source), ntohs(tcp->dest),
               ntohl(tcp->seq), ntohl(tcp->ack_seq));
    }

    close(sockfd);
    return 0;
}

三、实战案例

3.1 使用原始套接字实现简单ping

c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <sys/time.h>

#define PACKET_SIZE 64

unsigned short checksum(void *b, int len) {
    unsigned short *buf = b;
    unsigned int sum = 0;
    unsigned short result;

    for (sum = 0; len > 1; len -= 2)
        sum += *buf++;
    if (len == 1)
        sum += *(unsigned char *)buf;
    sum = (sum >> 16) + (sum & 0xffff);
    sum += (sum >> 16);
    result = ~sum;
    return result;
}

int main(int argc, char **argv) {
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <host>\n", argv[0]);
        exit(1);
    }

    int sockfd = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
    if (sockfd < 0) {
        perror("socket (need root?)");
        exit(1);
    }

    struct hostent *host = gethostbyname(argv[1]);
    if (!host) {
        fprintf(stderr, "Unknown host: %s\n", argv[1]);
        exit(1);
    }

    struct sockaddr_in dest_addr;
    memset(&dest_addr, 0, sizeof(dest_addr));
    dest_addr.sin_family = AF_INET;
    dest_addr.sin_addr = *(struct in_addr *)host->h_addr;

    char sendbuf[PACKET_SIZE];
    char recvbuf[65536];

    for (int i = 1; i <= 4; i++) {
        struct icmp *icmp = (struct icmp *)sendbuf;
        icmp->icmp_type = ICMP_ECHO;
        icmp->icmp_code = 0;
        icmp->icmp_id = getpid() & 0xffff;
        icmp->icmp_seq = i;
        memset(icmp->icmp_data, 0xab, PACKET_SIZE - 8);

        struct timeval tv;
        gettimeofday(&tv, NULL);
        memcpy(icmp->icmp_data, &tv, sizeof(tv));

        icmp->icmp_cksum = 0;
        icmp->icmp_cksum = checksum(sendbuf, PACKET_SIZE);

        struct timeval send_time;
        gettimeofday(&send_time, NULL);

        sendto(sockfd, sendbuf, PACKET_SIZE, 0,
               (struct sockaddr *)&dest_addr, sizeof(dest_addr));

        socklen_t addrlen = sizeof(dest_addr);
        ssize_t n = recvfrom(sockfd, recvbuf, sizeof(recvbuf), 0,
                             (struct sockaddr *)&dest_addr, &addrlen);

        if (n > 0) {
            struct timeval recv_time;
            gettimeofday(&recv_time, NULL);

            struct iphdr *ip = (struct iphdr *)recvbuf;
            int ip_hdr_len = ip->ihl * 4;
            struct icmp *reply = (struct icmp *)(recvbuf + ip_hdr_len);

            double rtt = (recv_time.tv_sec - send_time.tv_sec) * 1000.0 +
                         (recv_time.tv_usec - send_time.tv_usec) / 1000.0;

            printf("%d bytes from %s: icmp_seq=%d ttl=%d time=%.1f ms\n",
                   (int)n, inet_ntoa(dest_addr.sin_addr),
                   reply->icmp_seq, ip->ttl, rtt);
        }

        sleep(1);
    }

    close(sockfd);
    return 0;
}

编译和运行:

bash
gcc -o myping myping.c
sudo ./myping www.example.com
# 64 bytes from 93.184.216.34: icmp_seq=1 ttl=56 time=12.3 ms
# 64 bytes from 93.184.216.34: icmp_seq=2 ttl=56 time=11.8 ms
# 64 bytes from 93.184.216.34: icmp_seq=3 ttl=56 time=12.1 ms
# 64 bytes from 93.184.216.34: icmp_seq=4 ttl=56 time=11.9 ms

3.2 TCP连接状态监控工具

c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>

void print_tcp_state(int sockfd) {
    struct tcp_info info;
    socklen_t len = sizeof(info);

    if (getsockopt(sockfd, IPPROTO_TCP, TCP_INFO, &info, &len) < 0) {
        perror("getsockopt TCP_INFO");
        return;
    }

    const char *states[] = {
        "ESTABLISHED", "SYN_SENT", "SYN_RECV",
        "FIN_WAIT1", "FIN_WAIT2", "TIME_WAIT",
        "CLOSE", "CLOSE_WAIT", "LAST_ACK",
        "LISTEN", "CLOSING"
    };

    printf("TCP Connection Info:\n");
    printf("  State:        %s\n", states[info.tcpi_state]);
    printf("  Unacked:      %u\n", info.tcpi_unacked);
    printf("  SACKed:       %u\n", info.tcpi_sacked);
    printf("  Lost:         %u\n", info.tcpi_lost);
    printf("  Retrans:      %u\n", info.tcpi_retrans);
    printf("  RTT:          %u us\n", info.tcpi_rtt);
    printf("  RTT var:      %u us\n", info.tcpi_rttvar);
    printf("  Send cwnd:    %u\n", info.tcpi_snd_cwnd);
    printf("  Send mss:     %u\n", info.tcpi_snd_mss);
    printf("  Total retrans: %u\n", info.tcpi_total_retrans);
}

int main(int argc, char **argv) {
    if (argc != 3) {
        fprintf(stderr, "Usage: %s <host> <port>\n", argv[0]);
        exit(1);
    }

    int sockfd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in servaddr;
    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(atoi(argv[2]));
    inet_pton(AF_INET, argv[1], &servaddr.sin_addr);

    if (connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        perror("connect");
        exit(1);
    }

    printf("Connected to %s:%s\n", argv[1], argv[2]);
    print_tcp_state(sockfd);

    close(sockfd);
    return 0;
}

四、本章小结

本章深入讲解了TCP/IP协议族的核心协议,核心要点回顾:

IP协议:负责数据报的寻址和路由。IP数据报的头部包含源/目的IP地址、TTL、协议类型等关键信息。IP分片在路由器上发生,重组在目的主机上进行。

TCP协议:提供可靠的字节流服务。三次握手建立连接,四次挥手释放连接。序列号/确认号、超时重传、快速重传保证可靠传输。流量控制(滑动窗口)和拥塞控制(慢启动/拥塞避免)保证网络稳定。TIME_WAIT状态持续2MSL,确保连接正确关闭。

UDP协议:提供无连接的数据报服务。头部仅8字节,开销小。保留消息边界。适用于对延迟敏感、可容忍丢包的场景。

ICMP:传递控制信息和错误报告。ping使用ICMP Echo。ICMP错误会影响TCP/UDP的行为。

ARP:将IP地址映射到MAC地址。同一局域网内通过ARP解析MAC地址,跨网段通信需要路由器逐跳转发。

数据封装:数据从应用层到物理层逐层添加头部。理解封装过程有助于调试网络问题和优化性能。


五、思考与练习

思考题

如果TCP的三次握手中第二次(SYN+ACK)丢失了,会发生什么?客户端和服务器分别处于什么状态?最终如何解决?

为什么TCP的TIME_WAIT需要等待2MSL而不是1MSL?如果将TIME_WAIT设置为0会有什么问题?

一个UDP数据报大小为2000字节,通过以太网发送(MTU=1500)。需要分成几个IP分片?如果其中一个分片丢失,会发生什么?

在NAT环境下,为什么外部主机无法主动连接到内网主机?NAT穿透(NAT traversal)的基本原理是什么?

TCP的拥塞窗口(cwnd)和接收窗口(rwnd)如何共同决定发送方的发送速率?

编程练习

实现traceroute:利用IP的TTL字段,从TTL=1开始递增,每跳发送UDP数据报到一个高端口。每跳的ICMP Time Exceeded响应会暴露路由器的IP地址。直到收到ICMP Port Unreachable(到达目的主机)。

TCP连接信息查看器:连接到任意TCP服务器,使用 getsockopt 和 TCP_INFO 获取连接的详细信息(RTT、拥塞窗口、重传统计等),并格式化输出。

UDP MTU探测器:从小到大逐步增加UDP数据报的大小,使用DF标志发送,直到收到"Message too long"错误,从而确定路径MTU。

2/10TCP/
0%