[TCP/IP网络编程] 套接字协议
创建套接字
socket 不是”应用层与传输层之间”的接口,而是”应用层与整个网络协议栈(传输层 + 网络层)之间”的接口。
1
2
3
4
5
6
7
8
9
10
11
12
┌──────────────────────────────┐
│ 应用层 你的程序 │
│ │
│ ↕ socket API │ ← 接口在这
│ │
├──────────────────────────────┤
│ 传输层 TCP / UDP ← socket 直接操作 │
├──────────────────────────────┤
│ 网络层 IPv4 / IPv6 ← socket 也直接指定 │
├──────────────────────────────┤
│ 链路层 以太网 / WiFi │
└──────────────────────────────┘
1
2
3
4
5
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
// domain: 协议族信息
// type: 套接字数据传输类型信息
// protocol: 计算机间通信使用的协议信息
协议族
- PF_INET: IPv4互联网协议族
- PF_INET6: IPv6互联网协议族
- …(不常用,省略)
首先梳理一下IPv4 /IPv6与TCP/UDP的关系:
1
2
3
4
5
6
7
8
9
┌─────────────────────────────────────┐
│ 应用层 HTTP FTP DNS ... │
├─────────────────────────────────────┤
│ 传输层 TCP UDP ← 你选的协议 │
├─────────────────────────────────────┤
│ 网络层 IP (IPv4 / IPv6) ← 寻址和路由 │
├─────────────────────────────────────┤
│ 链路层 以太网 WiFi ... │
└─────────────────────────────────────┘
- IP(IPv4/IPv6)负责”把数据包从一台主机送到另一台主机”——寻址 + 路由,是网络层。
- 寻址:给每台联网主机一个唯一地址,能标识”谁”。
- 路由:决定数据包从源到目的走哪条路,能”送到”。
- TCP/UDP 负责”在这两台主机之间,应用程序怎么传数据”——是传输层。
它们是上下层关系:TCP/UDP 跑在 IP 之上,IP 为 TCP/UDP 提供”送包”服务。
1
2
3
4
5
6
7
8
9
10
11
12
┌──────────────────────────────────────┐
│ IP 数据包 (IPv4) │
│ ┌──────────────┬───────────────────┐ │
│ │ IP 头部 │ 载荷(payload) │ │
│ │ 源IP/目的IP │ ┌───────────────┐ │ │
│ │ │ │ TCP/UDP 报文 │ │ │
│ │ │ │ ┌─────┬─────┐ │ │ │
│ │ │ │ │头部 │数据 │ │ │ │
│ │ │ │ └─────┴─────┘ │ │ │
│ │ │ └───────────────┘ │ │
│ └──────────────┴───────────────────┘ │
└──────────────────────────────────────┘
- IP 头部里的 “协议号”字段告诉接收方:”载荷是 TCP(6)还是 UDP(17)”。
- 所以 IP 是”信封”,TCP/UDP 报文是”信纸”,IP 负责投递。
- “IP 地址 + 端口号” = 一个通信端点,例如
192.168.1.10:8080。IP 定位主机,端口定位主机上的进程。
套接字类型
套接字类型指的是套接字的数据传输方式,由于协议族中存在多种数据传输方式,所以我们要显式的指定。
面向连接的套接字(SOCK_STREAM/TCP)
| 特性 | 缓冲区里的机制 |
|---|---|
| 数据不消失 | 数据留在发送缓冲,收到 ACK 才删,丢了就重传 |
| 按序传输 | 接收缓冲按序号重排,补齐后才按序交付 read |
| 无数据边界 | 缓冲区是纯字节流,不记录 write 次数,read 随意切 |
1、数据不会消失:
1
2
3
4
5
6
7
客户端 服务端
┌─────────┐ 网络 ┌─────────┐
│ 发送缓冲 │ ───────────────→ │ 接收缓冲 │
│(字节数组)│ │(字节数组)│
└─────────┘ └─────────┘
write() read()
写进去 从里面取
发送方write()的数据先进入发送缓冲:
1
发送缓冲: [ H e l l o ]
- 只要write成功返回,数据就已经进入了缓冲区,由协议栈负责送达。
- 发送方会保留数据直到收到对端ACK,才把这段缓冲清掉。
- 若网络丢包、对端没确认,协议栈会从缓冲里重新取出重新发送。
- 如果缓冲区满了,write会阻塞,直到有空间。
2、按序传输:
每个字节在流里都是有序号的,接收方按序重组:
1
2
3
发送: 字节 1 2 3 4 5
网络到达顺序可能: 1 2 4 3 5 (乱序)
接收缓冲按序号排好: [1 2 3 4 5] ← 交付给 read() 的一定是这个顺序
- 接收方收到乱序的段,会暂存并在缓冲区里按序号排列。
- 缺失的序号会等重传,补齐后才按序交给应用。
- 应用层的read读取的永远是有序字节流,看不到乱序。
3、无数据边界:
无论发送方write了几次,缓冲区内永远都是一串字节,没有明确的边界区分,所以对于读取端的read可以任意切分数据:
1
2
3
read() 可能返回: "Hello world" (一次全拿到)
也可能: "Hel" (一次只拿3个)
也可能: "Hello " (一次拿6个)
也就是说read返回多少,取决于缓冲区内当前有多少,buffer有多大,与write次数无关。
面向消息的套接字(SOCK_DGRAM/UDP)
| 特性 | 缓冲区里的机制 |
|---|---|
| 数据可能丢失/损毁 | 发送缓冲只用于排队发送,发完即删,无 ACK、无重传 |
| 不保证顺序 | 接收缓冲不排序,按到达顺序交付 recvfrom |
| 有数据边界 | 缓冲区是数据报队列,每个 sendto 是一个独立单元,边界保留 |
| 限制单次大小 | 每个数据报是独立传输单元,受 MTU / UDP 长度上限约束 |
1、有数据边界:
UDP套接字两端也有发送缓冲和接收缓冲,缓冲区内存的是一个个独立的完整数据报,每个数据报是一个独立单元,带自己的边界。
1
2
3
4
5
6
7
客户端 服务端
┌─────────┐ 网络 ┌─────────┐
│ 发送缓冲 │ ───────────────→ │ 接收缓冲 │
│(数据报队列)│ │(数据报队列)│
└─────────┘ └─────────┘
sendto() recvfrom()
放进一个数据报 取出一个数据报
也就说缓冲区是数据报队列,收发一一对应,边界不丢,不会出现粘包或拆包。
注意:如果
recvfrom给的 buffer 比数据报小,多出来的部分会被丢弃(不是留到下次读),这也体现了”一个数据报是一个不可分割的整体”。
2、数据可能丢失、可能损坏:
发送方 sendto() 把数据放进发送缓冲,然后立刻发出去,发完就从缓冲删掉:
1
2
3
4
发送缓冲: [ "Hello" ]
sendto()
↓ 发出后立即清除,不保留底本
发送缓冲: [ ]
- 没有ACK确认机制,UDP发了就不管了,不确认对端是否收到。
- 数据留在缓冲里只为排队发送,不是为了重传。
- 若网络丢包,数据就丢失了,协议栈不会重传。
对比 TCP:TCP 收到 ACK 才删缓冲、丢了重传;UDP 发完即删、丢了不管。“不消失”是 TCP 的承诺,UDP 没有这个承诺。
3、不保证传输顺序:
每个数据报独立传输,到达顺序可能和发送顺序不一致:
1
2
3
发送顺序: "A" "B" "C"
网络到达顺序可能: "C" "A" "B" (乱序)
接收缓冲交付给 recvfrom(): "C" "A" "B" ← 原样交付,不重排
- 接收方不会按序号重排,因为UDP没有TCP一样的序号机制。
- 数据报按照到达的先后顺序进入接收缓冲,
recvfrom()读到的就说到达顺序。 - 应用层看到的就是乱序的结果,要顺序得在应用层解决。
对比 TCP:TCP 接收缓冲按序号重排,保证有序交付;UDP 缓冲不做任何排序。“按序”是 TCP 的承诺,UDP 没有。
4、限制每次传输的数据大小
因为每个数据报要作为一个独立单元传输,它有大小上限:
- 一次
sendto()的数据不能超过一个数据报能承载的最大长度。 - 典型上限:受 MTU(最大传输单元,以太网约 1500 字节) 和 UDP 头部限制。
- 实际单个 UDP 数据报安全大小约 508 字节(避免 IP 分片),理论最大约 64KB(含头部)。
- 超过上限
sendto()会报错;数据太大还会在 IP 层被分片,任一分片丢失整个数据报就废了。
对比 TCP:TCP 是字节流,没有”一次写多大”的限制,write 多少都行,协议栈自动帮你切成合适大小的段发送。“限制单次大小”是数据报模型带来的必然结果。
协议的最终选择
socket()的第三个参数决定最终采用的协议。实际上传递前两个参数即可创建所需id套接字。所以大部分情况下向第三个参数传递0,除非同一协议族中存在多个数据传输方式相同的协议。因此,我们可以调用如下的socket创建套接字:
1
2
int tcp_socket = socket(PF_INET, SOCK_STREAM, 0); //tcp
int udp_socket = socket(PF_INET, SOCK_DGRAM, 0); //udp