文章

[TCP/IP网络编程] 套接字协议

[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
本文由作者按照 CC BY 4.0 进行授权