TCP/IP 学习总结与避坑指南
- 约 25 分钟读完 - 全文 9988 字TCP/IP 学习总结与避坑指南
最近华为项目交付碰到各种头大的网络问题,现在整理下TCP/IP相关知识。参考书籍:W. Richard Stevens《TCP/IP 详解 卷1:协议》、W. Richard Stevens《Unix 网络编程 卷1:套接字联网 API》
目标读者:刚接触网络编程,或者学了很多概念但总觉得「协议栈串不起来」「代码一跑就掉坑」的同学。
TCP/IP 不是一门「背完就能会」的技术。它由一整套分层协议组成,每一层只关心自己的事情,又通过严格定义的头部把信息交给下一层。理解这套分层思维,是看懂网络报文、写出稳定网络程序、排查线上连接问题的第一步。
这篇文章不会把所有 RFC 都讲一遍(那是 1000 多页书做的事),而是帮你建立主干知识框架:
- TCP/IP 协议栈为什么要分层?数据是怎么一层层包起来的?
- IP、TCP、UDP、ARP、ICMP、DNS 这些协议各自解决什么问题?
- Unix Socket 编程的核心 API 与调用顺序是什么?
- 写网络程序最容易踩哪些坑,怎么避开?
- 遇到连接失败、卡顿、端口耗尽这些问题,该按什么思路排查?
读完之后,你应该能看懂 Wireshark 里的大部分常见报文,也能独立写出稳定的 TCP 客户端/服务端,并具备基础的线上网络问题排查能力。
一、TCP/IP 协议栈:为什么必须分层?
网络通信的复杂度非常高:不同的物理介质、不同的操作系统、不同的应用程序要在全球范围内交换数据。如果不分层,每一台设备都要同时处理「电信号怎么传」「数据怎么路由」「传输可不可靠」「应用数据什么意思」所有问题。
分层的目的就是关注点分离:每一层只解决本层的问题,向上层提供服务,把复杂问题拆成可管理的小块。
1.1 OSI 七层模型 vs TCP/IP 四层模型
教学中常用 OSI 七层模型,但互联网实际运行的是 TCP/IP 四层模型。
graph LR
subgraph OSI七层模型
A7[7 应用层] --> A6[6 表示层]
A6 --> A5[5 会话层]
A5 --> A4[4 传输层]
A4 --> A3[3 网络层]
A3 --> A2[2 数据链路层]
A2 --> A1[1 物理层]
end
subgraph TCP/IP四层模型
B4[应用层<br/>HTTP/FTP/DNS/SMTP] --> B3[传输层<br/>TCP/UDP]
B3 --> B2[网络层<br/>IP/ICMP/IGMP]
B2 --> B1[网络接口层<br/>以太网/WiFi/PPP]
end
A7 -.对应.-> B4
A6 -.对应.-> B4
A5 -.对应.-> B4
A4 -.对应.-> B3
A3 -.对应.-> B2
A2 -.对应.-> B1
A1 -.对应.-> B1
| TCP/IP 层 | 核心协议 | 解决的问题 |
|---|---|---|
| 应用层 | HTTP、DNS、FTP、SMTP、SSH | 应用程序之间「说什么」 |
| 传输层 | TCP、UDP | 进程到进程的通信,可靠或高效传输 |
| 网络层 | IP、ICMP、IGMP、路由协议 | 主机到主机的寻址与路由 |
| 网络接口层 | 以太网、WiFi、ARP、PPP | 相邻设备之间的物理/逻辑传输 |
新手容易混淆的点:TCP/IP 里的「应用层」实际上对应了 OSI 的应用层、表示层、会话层三层。不要死磕七层,实际工程里四层模型更常用。
1.2 数据封装与解封装
每一层都会给上一层的数据加一个「头部」,然后交给下一层。接收方则逐层去掉头部,把数据交给上层。
flowchart TB
subgraph 发送方
A[应用数据] -->|"应用层封装"| B["应用层头部 + 应用数据"]
B -->|"传输层封装"| C["TCP/UDP 头部 + 应用层数据"]
C -->|"网络层封装"| D["IP 头部 + TCP 数据段"]
D -->|"链路层封装"| E["以太网头部 + IP 数据报 + 以太网尾部"]
end
E -->|"比特流在物理介质传输"| F
subgraph 接收方
F["以太网帧"] -->|"链路层解封装"| G["IP 数据报"]
G -->|"网络层解封装"| H["TCP/UDP 数据段"]
H -->|"传输层解封装"| I["应用层数据"]
I -->|"应用层处理"| J[交给对应进程]
end
关键概念:
- MTU(Maximum Transmission Unit):链路层帧能承载的最大数据量,以太网常见值是 1500 字节。
- MSS(Maximum Segment Size):TCP 一次能发送的最大数据段,通常 = MTU - IP 头 - TCP 头,以太网常见 1460 字节。
- 分片(Fragmentation):当 IP 数据报超过链路 MTU 时,IP 层会把它切成多个片。TCP 一般会通过 MSS 避免分片,UDP 则容易触发分片。
避坑技巧:
- 不要随意修改 MTU,VPN/隧道场景特别容易出现「大数据包被静默丢弃」的问题。
- UDP 应用如果要传输大报文,建议在应用层自己拆包,不要让 IP 层分片。
二、链路层与 ARP:同一局域网怎么找到对方?
链路层负责相邻节点之间的数据传输。在以太网里,设备用 MAC 地址标识,交换机根据 MAC 地址转发帧。
2.1 以太网帧结构
+----------------+----------------+----------------+----------------+----------------+
| 目的 MAC 6B | 源 MAC 6B | 类型 2B | 数据 46~1500B | FCS 4B |
+----------------+----------------+----------------+----------------+----------------+- MAC 地址:48 位,全球唯一(理论上),用于局域网内定位网卡。
- 类型字段:例如
0x0800表示上层是 IP,0x0806表示 ARP。 - FCS(Frame Check Sequence):帧校验序列,检测传输过程中的比特错误。
2.2 ARP:IP 地址 → MAC 地址
ARP(Address Resolution Protocol)解决的是「知道对方 IP,但不知道对方 MAC」的问题。
sequenceDiagram
participant A as 主机 A<br/>192.168.1.10
participant B as 主机 B<br/>192.168.1.20
A->>B: ARP 请求:谁的 IP 是 192.168.1.20?请告诉我你的 MAC
Note over A,B: 广播帧,局域网内所有主机收到
B->>A: ARP 应答:我是 192.168.1.20,MAC 是 xx:xx:xx:xx:xx:xx
Note over A,B: 单播帧,只有 A 收到
ARP 表会缓存一段时间:arp -a 可以查看本机 ARP 缓存。
避坑技巧:
- ARP 只在同一二层网络(同一广播域)有效。跨网段通信时,主机把报文发给网关,由网关继续转发。
- 网关配错是新手常见问题:如果默认网关不在同一子网,ARP 请求会失败,数据根本发不出去。
- ARP 欺骗/中间人攻击在局域网内存在,敏感环境要开启动态 ARP 检测(DAI)或静态绑定。
三、IP 协议:主机到主机的寻址与路由
IP(Internet Protocol)是网络层的核心,负责把数据从源主机送到目的主机。它不保证可靠、不保证有序,这些由 TCP 或应用层自己处理。
3.1 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 | Padding |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+关键字段含义:
- Version:IPv4 填 4,IPv6 填 6。
- IHL(Header Length):头部长度,单位是 4 字节。没有选项时最小是 5,即 20 字节。
- Total Length:整个 IP 数据报的长度,最大 65535 字节。
- Identification / Flags / Fragment Offset:用于分片和重组。
- TTL(Time to Live):每经过一个路由器减 1,到 0 被丢弃,防止报文在网络里无限循环。
- Protocol:上层协议类型。6 = TCP,17 = UDP,1 = ICMP,等等。
- Source/Destination Address:源 IP 和目的 IP。
3.2 IP 地址、子网掩码与 CIDR
IP 地址 = 网络号 + 主机号。子网掩码用来划分这两部分。
IP 地址: 192.168.1.100 → 11000000.10101000.00000001.01100100
子网掩码: 255.255.255.0 → 11111111.11111111.11111111.00000000
网络号: 192.168.1.0 → 前 24 位
主机号: 0.0.0.100 → 后 8 位CIDR 表示法:192.168.1.0/24,斜杠后面的数字表示网络号位数。
判断两台主机是否在同一子网:用各自的 IP 和子网掩码做按位与,得到网络号,网络号相同就在同一子网。
3.3 路由基础
当目的 IP 不在本子网时,主机会把报文发给默认网关(Default Gateway)。路由器根据路由表决定下一跳。
flowchart LR
A[主机 A<br/>192.168.1.10/24] -->|"目的 IP 不在同一子网"| R[路由器<br/>192.168.1.1]
R -->|"查路由表"| B[主机 B<br/>10.0.0.5/24]
常用查看命令:
# Linux/macOS
ip route show # 查看路由表
route -n # 旧命令
# Windows
route print避坑技巧:
- 配置静态 IP 时,最容易漏掉或配错的就是子网掩码和默认网关。只配 IP 不配上两者,通常只能 ping 通同网段。
/24和/16的区别不只是数字大小,它决定了网络规模和可用主机数。/24有 254 个可用主机地址,/16有 65534 个。- 路由表顺序很重要:更具体的子网(前缀更长)优先于默认路由。
四、ICMP:IP 的「诊断助手」
ICMP(Internet Control Message Protocol)本身不传输用户数据,而是用于传递控制消息和诊断信息。
4.1 Ping 原理
Ping 使用 ICMP Echo Request 和 Echo Reply。
sequenceDiagram
participant A as 主机 A
participant B as 主机 B
A->>B: ICMP Echo Request
Note over A,B: 携带标识符和序列号
B->>A: ICMP Echo Reply
Note over A,B: A 根据往返时间计算延迟
Ping 不通不一定是对端关机,可能是:
- 防火墙拦截了 ICMP
- 路由不可达
- 对端 ARP 表异常
- 本机默认网关配错
4.2 Traceroute 原理
Traceroute 利用 TTL 递增或端口不可达来追踪路径。
flowchart LR
A[主机] -->|"TTL=1"| R1[路由器 1]
R1 -->|"TTL 超时,返回 ICMP Time Exceeded"| A
A -->|"TTL=2"| R2[路由器 2]
R2 -->|"TTL 超时,返回 ICMP Time Exceeded"| A
A -->|"TTL=3"| B[目标主机]
B -->|"端口不可达 / Echo Reply"| A
避坑技巧:
- 有些路由器配置了「不回应 ICMP Time Exceeded」,Traceroute 会显示
* * *,不代表网络不通。 - 防火墙可能会阻断 Traceroute 的 UDP 探测端口,换
-I(ICMP)或-T(TCP)模式可能成功。
五、UDP:简单、高效、不可靠
UDP(User Datagram Protocol)提供无连接、不可靠、面向数据报的传输服务。
5.1 UDP 头部
0 7 8 15 16 23 24 31
+--------+--------+--------+--------+
| Source Port | Dest Port |
+--------+--------+--------+--------+
| Length | Checksum |
+--------+--------+--------+--------+
| data octets ... |
+-------------------------------------+- Source Port / Dest Port:各 16 位,标识发送和接收进程。
- Length:UDP 头部 + 数据的总长度。
- Checksum:可选(IPv4 中),覆盖头部、数据和伪头部。
5.2 UDP 的特点与适用场景
| 特点 | 说明 |
|---|---|
| 无连接 | 不需要建立连接,直接发 |
| 不可靠 | 不保证到达、不保证顺序、不重传 |
| 低开销 | 头部只有 8 字节 |
| 面向数据报 | 发一次收一次,保留边界 |
适合用 UDP 的场景:DNS 查询、视频直播、在线游戏、VoIP、NTP、QUIC 底层。
避坑技巧:
- UDP 没有流量控制,发送方太快会导致接收方丢包。实时音视频要做抖动缓冲(jitter buffer)和前向纠错(FEC)。
- UDP 报文超过 MTU 会被 IP 层分片,分片丢失会导致整个 UDP 报文作废。大数据包要在应用层分片。
- 不要认为 UDP 一定比 TCP 快。在丢包率高的网络里,应用层自己实现可靠传输可能比 TCP 更复杂且效果更差。
六、TCP:可靠传输的基石
TCP(Transmission Control Protocol)是互联网最常用的传输协议。它提供面向连接、可靠、按序、字节流的服务。
6.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 | Padding |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+关键字段:
- Sequence Number(序号):标识本报文段发送的数据在字节流中的位置。
- Acknowledgment Number(确认号):期望收到的下一个字节的序号。
- ACK:确认号有效。
- SYN:发起连接。
- FIN:结束连接。
- RST:重置连接,通常表示异常。
- Window(窗口大小):接收方当前可用缓存大小,用于流量控制。
6.2 三次握手:建立连接
sequenceDiagram
participant C as 客户端
participant S as 服务端
C->>S: SYN, seq=x
Note over C,S: 客户端请求建立连接
S->>C: SYN, seq=y, ACK=x+1
Note over C,S: 服务端同意,也请求建立反向连接
C->>S: ACK=y+1
Note over C,S: 客户端确认,连接建立
为什么是三次?
- 两次握手无法确认客户端的接收能力,也不能防止历史重复连接请求造成资源浪费。
- 三次是「双方都能发送和接收」的最小验证次数。
关键状态:
- 客户端:SYN_SENT → ESTABLISHED
- 服务端:LISTEN → SYN_RCVD → ESTABLISHED
6.3 四次挥手:关闭连接
sequenceDiagram
participant C as 客户端
participant S as 服务端
C->>S: FIN, seq=u
Note over C,S: 客户端没有数据要发了
S->>C: ACK=u+1
Note over C,S: 服务端确认,但可能还有数据要发
S->>C: FIN, seq=w
Note over C,S: 服务端也没有数据要发了
C->>S: ACK=w+1
Note over C,S: 客户端确认
为什么一般是四次?
TCP 是全双工的,两个方向要分别关闭。一方发完 FIN 只表示「我这方向没数据了」,另一方可能还有数据要发。
关键状态与时间等待:
- 主动关闭方最后进入 TIME_WAIT,等待 2MSL(Maximum Segment Lifetime,通常 1-4 分钟)后才彻底关闭。
- TIME_WAIT 的作用:
- 确保最后一个 ACK 被对方收到,如果丢失可以重传。
- 让网络中延迟的报文自然消失,避免影响下一个使用相同四元组的新连接。
6.4 TCP 状态机
stateDiagram-v2
[*] --> CLOSED
CLOSED --> SYN_SENT : 主动打开/send SYN
CLOSED --> LISTEN : 被动打开
SYN_SENT --> ESTABLISHED : 收到 SYN+ACK
LISTEN --> SYN_RCVD : 收到 SYN
SYN_RCVD --> ESTABLISHED : 收到 ACK
ESTABLISHED --> FIN_WAIT_1 : 主动关闭/send FIN
ESTABLISHED --> CLOSE_WAIT : 收到 FIN
FIN_WAIT_1 --> FIN_WAIT_2 : 收到 ACK
FIN_WAIT_1 --> TIME_WAIT : 收到 FIN+ACK
FIN_WAIT_2 --> TIME_WAIT : 收到 FIN
CLOSE_WAIT --> LAST_ACK : 发送 FIN
LAST_ACK --> CLOSED : 收到 ACK
TIME_WAIT --> CLOSED : 2MSL 超时
6.5 可靠传输机制
TCP 通过以下机制保证可靠性:
flowchart LR
A[序号与确认机制] --> B[超时重传]
C[滑动窗口] --> D[流量控制]
E[拥塞窗口] --> F[拥塞控制]
B --> G[可靠传输]
D --> G
F --> G
序号与确认:每发一个字节都有序号,接收方用 ACK 确认连续收到的最大序号。
超时重传:发送方启动定时器,超时未收到 ACK 就重传。
滑动窗口:允许发送方在没收到 ACK 的情况下连续发送多个段,提高吞吐量。
流量控制:接收方通过 Window 字段告诉发送方自己还能接收多少数据,防止接收方缓存溢出。
拥塞控制:发送方根据网络拥塞程度调整发送速率,主要算法包括慢启动、拥塞避免、快重传、快恢复。
6.6 拥塞控制四个阶段
flowchart TB
A[慢启动<br/>cwnd 指数增长] -->|"cwnd >= ssthresh"| B[拥塞避免<br/>cwnd 线性增长]
B -->|"收到 3 个重复 ACK"| C[快重传<br/>立即重传]
C --> D[快恢复<br/>cwnd=ssthresh]
D --> B
B -->|"超时"| E[超时重传<br/>cwnd=1]
E --> A
- 慢启动(Slow Start):初始窗口小,每收到一个 ACK 窗口近似翻倍,直到达到慢启动阈值(ssthresh)。
- 拥塞避免(Congestion Avoidance):超过阈值后,窗口线性增长。
- 快重传(Fast Retransmit):收到 3 个重复 ACK 就立刻重传,不等超时。
- 快恢复(Fast Recovery):快重传后不把窗口降到 1,而是降到 ssthresh,再线性增长。
6.7 Nagle 算法与延迟 ACK
- Nagle 算法:减少小报文数量。如果上一次发送的数据还没被确认,且当前数据量小于 MSS,就把小数据攒起来一起发。
- 延迟 ACK:接收方不立即对每个收到的段发 ACK,而是等 40ms 左右,看是否有数据要一起回,减少 ACK 数量。
避坑技巧:
- Nagle + 延迟 ACK 会造成明显延迟:发送方等小 ACK,接收方等大数据一起回,形成「写 - 等 - 写 - 等」的 ping-pong 效应。
- 交互式应用(如游戏、SSH、RPC)通常要关闭 Nagle:
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on))。 - 批量传输场景(文件下载)可以保留 Nagle,减少小包数量。
七、DNS 与 DHCP:网络配置的基础设施
7.1 DNS:域名 → IP
DNS(Domain Name System)是互联网的「电话簿」,把人类可读的域名解析成 IP 地址。
flowchart LR
A[客户端] -->|"查询 example.com"| B[本地 DNS 缓存]
B -->|"未命中"| C[递归 DNS 服务器]
C -->|"迭代查询"| D[根 DNS 服务器]
D --> E[.com 顶级域服务器]
E --> F[example.com 权威服务器]
F -->|"返回 IP"| C
C -->|"返回 IP"| A
DNS 记录类型:
| 类型 | 用途 |
|---|---|
| A | 域名 → IPv4 地址 |
| AAAA | 域名 → IPv6 地址 |
| CNAME | 域名别名 |
| MX | 邮件交换服务器 |
| NS | 权威 DNS 服务器 |
| TXT | 文本记录,常用于验证和 SPF |
避坑技巧:
- DNS 有缓存,修改记录后全球生效可能要几分钟到 48 小时(受 TTL 控制)。
- 注意 DNS 解析失败和连接超时的区别:先
nslookup/dig确认域名能否解析,再 ping/telnet 确认网络层是否通。 gethostbyname不是线程安全的,现代程序应使用getaddrinfo。
7.2 DHCP:自动获取网络配置
DHCP(Dynamic Host Configuration Protocol)让主机自动获取 IP 地址、子网掩码、网关、DNS 服务器。
sequenceDiagram
participant H as 主机
participant D as DHCP 服务器
H->>D: DHCP Discover(广播)
D->>H: DHCP Offer
H->>D: DHCP Request
D->>H: DHCP ACK
八、Unix 网络编程核心:Socket API
Socket 是 Unix 系统提供的网络编程接口,屏蔽了底层协议细节,让程序像读写文件一样通信。
8.1 TCP 服务端典型调用流程
flowchart TB
A[socket] --> B[bind]
B --> C[listen]
C --> D[accept]
D --> E[read/write]
E --> F[close]
8.2 TCP 客户端典型调用流程
flowchart TB
A[socket] --> B[connect]
B --> C[read/write]
C --> D[close]
8.3 核心 API 说明
| API | 作用 |
|---|---|
socket() |
创建套接字,指定协议族(IPv4/IPv6)和类型(流式/数据报) |
bind() |
把套接字绑定到本地地址和端口 |
listen() |
把套接字置为监听状态,指定全连接队列长度 |
accept() |
从全连接队列取出一个已完成握手的连接 |
connect() |
客户端发起连接 |
read()/write() 或 recv()/send() |
收发数据 |
close() |
关闭连接 |
shutdown() |
半关闭:只关闭读或写 |
8.4 最简 TCP 服务端示例
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#define PORT 8080
#define BACKLOG 128
int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket");
exit(1);
}
int reuse = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(PORT);
if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
exit(1);
}
if (listen(listen_fd, BACKLOG) < 0) {
perror("listen");
exit(1);
}
printf("Server listening on port %d\n", PORT);
while (1) {
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
int conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &len);
if (conn_fd < 0) {
perror("accept");
continue;
}
char buf[1024];
ssize_t n = read(conn_fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("Received: %s\n", buf);
write(conn_fd, "OK\n", 3);
}
close(conn_fd);
}
close(listen_fd);
return 0;
}8.5 最简 TCP 客户端示例
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#define SERVER "127.0.0.1"
#define PORT 8080
int main() {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
perror("socket");
exit(1);
}
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
inet_pton(AF_INET, SERVER, &addr.sin_addr);
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("connect");
exit(1);
}
write(fd, "hello\n", 6);
char buf[1024];
ssize_t n = read(fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("Server reply: %s", buf);
}
close(fd);
return 0;
}九、Unix 网络编程常见陷阱与避坑技巧
9.1 必须设置 SO_REUSEADDR
int reuse = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));原因:服务端主动关闭连接后会进入 TIME_WAIT,该连接的本地端口短时间内不能复用。如果不设置 SO_REUSEADDR,重启服务时可能报 Address already in use。
注意:SO_REUSEADDR 允许复用 TIME_WAIT 的端口,但不会允许两个进程同时绑定到完全相同的 IP:端口(Linux 上 SO_REUSEPORT 才能做到)。
9.2 正确处理 SIGPIPE
向一个已经收到 RST 的 socket 写数据会触发 SIGPIPE 信号,默认行为是终止进程。
signal(SIGPIPE, SIG_IGN); // 忽略信号
更优雅的做法是在写之前检查连接状态,或者使用 MSG_NOSIGNAL 标志:
send(fd, buf, len, MSG_NOSIGNAL);9.3 处理部分读写
TCP 是字节流,read 和 write 不保证一次读完或写完。
// 可靠的写:循环写直到写完
ssize_t write_all(int fd, const void *buf, size_t len) {
const char *p = buf;
size_t remaining = len;
while (remaining > 0) {
ssize_t n = write(fd, p, remaining);
if (n < 0) {
if (errno == EINTR) continue;
return -1;
}
p += n;
remaining -= n;
}
return len;
}9.4 处理 EINTR
慢系统调用(如 accept、read、write)被信号中断后会返回 -1 并设置 errno = EINTR。 robust 的程序应该重试:
while ((n = read(fd, buf, len)) < 0 && errno == EINTR)
;9.5 不要用 strlen 计算二进制数据长度
send(fd, buf, strlen(buf), 0) 只适合文本。二进制数据里可能有 \0,要用实际长度。
9.6 使用 getaddrinfo 替代 gethostbyname
gethostbyname 只支持 IPv4,不是线程安全的,已经被废弃。现代程序应使用 getaddrinfo:
struct addrinfo hints, *res;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC; // 支持 IPv4 和 IPv6
hints.ai_socktype = SOCK_STREAM;
int err = getaddrinfo("example.com", "80", &hints, &res);
if (err != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(err));
exit(1);
}
int fd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
connect(fd, res->ai_addr, res->ai_addrlen);
freeaddrinfo(res); // 记得释放
9.7 区分 close 与 shutdown
close(fd):关闭文件描述符,引用计数减到 0 时释放资源,双向关闭。shutdown(fd, SHUT_WR):只关闭写方向,读仍然可以接收数据,常用于通知对端「我发完了」。
9.8 阻塞 vs 非阻塞 IO
| 模式 | 特点 | 适用场景 |
|---|---|---|
| 阻塞 | 调用没完成就挂起 | 简单程序、每个连接一个线程/进程 |
| 非阻塞 | 调用立即返回,没数据返回 EAGAIN/EWOULDBLOCK | 高并发、IO 多路复用 |
9.9 IO 多路复用
单线程服务大量连接时,要用 IO 多路复用同时监视多个 fd 的就绪状态。
flowchart LR
A[主线程] --> B[select/poll/epoll]
B -->|"哪些 fd 可读/可写"| C[处理可读 fd]
B --> D[处理可写 fd]
| 机制 | 特点 |
|---|---|
| select | 有 fd 数量限制(通常 1024),每次调用要遍历全部 fd |
| poll | 用数组描述 fd,没有数量硬限制,但仍需遍历 |
| epoll(Linux) | 事件驱动,只关注活跃的 fd,支持边缘触发和水平触发 |
| kqueue(BSD/macOS) | 类似 epoll,事件驱动 |
epoll 水平触发(LT)vs 边缘触发(ET):
- LT:只要 fd 还有数据可读,epoll 就会一直通知。
- ET:只有 fd 状态变化时通知一次,需要一次性把数据读完,通常配合非阻塞 IO。
十、典型 Unix 网络障碍问题与排查思路
10.1 排查流程总览
flowchart TD
A[网络问题] --> B[确认是 DNS/路由/端口/应用哪一层]
B --> C{能解析域名吗?}
C -->|否| D[检查 DNS / hosts / 防火墙]
C -->|是| E{能 ping 通 IP 吗?}
E -->|否| F[检查路由 / ACL / 安全组]
E -->|是| G{端口通吗?}
G -->|否| H[检查服务是否监听 / 防火墙 / 安全组]
G -->|是| I[检查应用日志 / 抓包 / 连接状态]
10.2 Connection refused
原因:目标 IP 可达,但目标端口没有进程在监听,或者防火墙明确拒绝。
排查:
# 服务端检查是否在监听
ss -tlnp | grep 8080
netstat -tlnp | grep 8080
# 客户端测试端口是否开放
telnet server_ip 8080
nc -vz server_ip 808010.3 Connection timed out
原因:报文到达目的地过程中被丢弃,没有收到任何 RST 或响应,常见于防火墙静默丢弃、路由不可达、NAT 映射问题。
排查:
# 看路径到哪里断了
traceroute server_ip
mtr server_ip
# 抓包看 SYN 有没有出去、有没有回来
tcpdump -i any port 8080 -nn10.4 Too many TIME_WAIT / 端口耗尽
现象:高并发短连接场景下,客户端连接失败,报错 Cannot assign requested address。
原因:大量主动关闭的连接堆积在 TIME_WAIT,占用了本地端口。
解决思路:
- 尽量使用长连接 / 连接池。
- 服务端设置
SO_REUSEADDR。 - Linux 可调整
net.ipv4.ip_local_port_range扩大临时端口范围。 - 开启
net.ipv4.tcp_tw_reuse(谨慎,仅对客户端出站连接有效,不要开tcp_tw_recycle,已在内核 4.12 后移除)。
10.5 CLOSE_WAIT 堆积
现象:服务端 ss -tan | grep CLOSE_WAIT 看到很多连接。
原因:对端已经发了 FIN,但本端应用没有调用 close(),通常是因为:
- 应用逻辑忘记关闭 socket。
- 应用在处理完数据前阻塞在某处,无法执行 close。
- 异常分支没有释放连接资源。
解决:检查代码里所有异常路径是否都调用了 close,确保每个 accept 出来的 fd 都有对应的关闭点。
10.6 SYN Flood / 半连接队列溢出
现象:服务偶尔出现连接建立慢或大量 SYN_SENT/SYN_RECV。
原理:Linux 内核有两个队列:
- 半连接队列(syn queue):存放收到 SYN 但还没完成三次握手的连接。
- 全连接队列(accept queue):存放已完成三次握手但还没被
accept取走的连接。
flowchart LR
A[客户端 SYN] -->|"进入半连接队列"| B[SYN_RCVD]
B -->|"收到 ACK"| C[ESTABLISHED]
C -->|"进入全连接队列"| D[等待 accept]
D -->|"accept 取走"| E[交给业务处理]
排查:
ss -s # 查看连接统计
ss -lnt # 看全连接队列长度和溢出次数
netstat -s | grep overflow解决:
- 调大
net.ipv4.tcp_max_syn_backlog。 - 开启 SYN Cookies:
net.ipv4.tcp_syncookies = 1。 - 增大
listen(fd, backlog)的 backlog,同时配合somaxconn调整。 - 提升应用
accept速度。
10.7 高并发下 accept 慢或全连接队列溢出
现象:连接完成三次握手,但客户端一直等待服务端响应。
原因:accept 处理太慢,全连接队列满了,新的完成连接被丢弃。
解决:
- 用多线程/多进程分担 accept。
- 使用 IO 多路复用 + 事件驱动架构。
- 调整
net.core.somaxconn和listen()的 backlog。
10.8 MTU/MSS 导致的大包不通
现象:小数据包能通,大数据包不通;或某些网站/服务能打开,某些打不开。
原因:路径中某段链路 MTU 较小,大包被丢弃,且中间设备不发送 ICMP “Fragmentation Needed”,导致无法自动降包大小。这就是著名的 PMTUD(Path MTU Discovery)黑洞。
解决:
- 设置
net.ipv4.tcp_mtu_probing = 1启用 TCP MTU 探测。 - 在 VPN/隧道两端统一调低 MTU/MSS。
- 中间网络设备不要丢弃 ICMP “Fragmentation Needed”。
10.9 应用层协议粘包/拆包
现象:TCP 客户端发了两条消息,服务端一次 read 读到了两条消息的内容。
原因:TCP 是字节流,没有消息边界。应用层需要自己定义消息边界。
常见解决方案:
- 固定长度:每个消息固定 N 字节。
- 分隔符:如
\n、自定义分隔符。 - 长度前缀:消息头 4 字节表示消息体长度。
// 长度前缀示例:先读 4 字节长度,再读对应长度的 body
uint32_t len;
read_all(fd, &len, 4);
len = ntohl(len);
read_all(fd, body, len);十一、学习路线与推荐资源
11.1 学习顺序建议
flowchart LR
A[网络分层模型] --> B[IP/子网/路由]
B --> C[ARP/ICMP]
C --> D[TCP 握手挥手与状态机]
D --> E[滑动窗口与拥塞控制]
E --> F[Socket API 编程]
F --> G[IO 多路复用]
G --> H[Wireshark 抓包分析]
H --> I[线上问题排查]
11.2 推荐实践
- 用 Wireshark 抓一次完整的三次握手和四次挥手,亲眼看看每个字段的值。
- 写一个 echo 服务器,先用阻塞 IO,再用 select/epoll 改写。
- 故意制造问题:把客户端改成快速创建大量短连接,观察 TIME_WAIT;把服务端 accept 故意睡几秒,观察全连接队列溢出。
- 用
ss、tcpdump、traceroute、nc、iperf这些工具做实验。
11.3 推荐参考书
- 《TCP/IP 详解 卷1:协议》(W. Richard Stevens)——协议细节最权威。
- 《Unix 网络编程 卷1:套接字联网 API》(W. Richard Stevens)——Socket 编程圣经。
- 《Wireshark 网络分析就这么简单》——抓包实战。
- 《Linux 高性能服务器编程》(游双)——中文入门友好。
十二、总结
TCP/IP 协议栈并不神秘,它的核心思想就是分层与解耦:
- 链路层负责「相邻设备怎么传」。
- 网络层负责「怎么找到对方主机」。
- 传输层负责「怎么把数据交给正确的进程,并决定可靠还是高效」。
- 应用层负责「这些数据代表什么业务含义」。
Unix 网络编程的核心是理解 Socket API 的调用顺序、IO 模型、以及 TCP 连接的生命周期。写出稳定的网络程序,不在于记住所有参数,而在于理解:
- 连接是什么时候建立的,什么时候关闭的。
- 数据是怎么流动的,会不会被截断、会不会粘包。
- 异常情况下系统会怎么表现,程序能不能正确处理。
希望这份总结能帮你把《TCP/IP 详解》里的知识点串成一张网,少踩一些网络编程里的经典大坑。