第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。
// 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.254CIDR(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 | 受限广播地址 |
// 使用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)成更小的片段。
// 分片示例
// 原始数据报: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字节。
// 检测路径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拒绝连接。
// 使用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+1SYN队列与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。
让网络中残留的数据报消失:防止旧连接的数据报被新连接误收。
// 查看系统中的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),说明某个报文段很可能丢失了。发送方立即重传,不必等待超时。
// 使用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:873802.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,然后进入拥塞避免。
// 查看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浏览(需要可靠的页面加载)
- 电子邮件(需要可靠的邮件传递)
- 任何不能容忍数据丢失的应用
// 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消息类型:
| 类型 | 代码 | 含义 |
|---|---|---|
| 0 | 0 | Echo Reply(ping响应) |
| 3 | 0-15 | Destination Unreachable |
| 3 | 1 | Host Unreachable |
| 3 | 3 | Port Unreachable |
| 3 | 4 | Fragmentation Needed(DF位设置时) |
| 5 | 0 | Redirect |
| 11 | 0 | Time Exceeded(TTL=0) |
| 8 | 0 | Echo Request(ping请求) |
// 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 ms2.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秒)。
# 查看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.1ARP对网络编程的影响:通常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地址。跨网段通信需要路由器逐跳转发。
// 使用原始套接字观察封装过程(需要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
#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;
}编译和运行:
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 ms3.2 TCP连接状态监控工具
#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。