协议基础

协议(Protocol)是通信双方共同遵守的规则:数据如何组织、何时发送、收到后如何响应、出错后如何处理。没有相同的协议,两个程序即使连上同一个 Socket,也无法正确理解对方发送的数据。

网络分层与封装

网络通信通常按层设计。初学网络编程时,最常接触的是下面几层:

层次 常见协议或概念 主要职责
应用层 HTTP、DNS、SMTP、自定义协议 定义业务数据的含义,例如“请求页面”“查询域名”
传输层 TCP、UDP 在进程之间传输数据;使用端口区分不同应用
网络层 IP 让数据在不同网络之间到达目标主机;使用 IP 地址
链路层 Ethernet、Wi-Fi 在同一个局域链路上传输帧;使用 MAC 地址

发送数据时,每一层都会给上层数据附加自己的控制信息,这个过程叫封装:

1
2
3
4
5
6
7
应用数据
↓ 加 TCP/UDP 首部
TCP 段 / UDP 数据报
↓ 加 IP 首部
IP 数据报
↓ 加以太网或 Wi‑Fi 首部
链路层帧

接收方按相反顺序拆除首部,这叫解封装。应用程序通常不需要手动构造 TCP、IP 或链路层首部;操作系统的协议栈会完成这些工作。

IP 地址、端口与 Socket

IP 地址用于定位主机,端口用于定位主机上的进程或服务。例如:

1
2
192.168.1.10:8080
└──── IP ────┘ └端口┘

端口是 16 位无符号整数,范围为 0 到 65535。常见分类:

范围 含义
0–1023 知名端口;在 Unix-like 系统中通常需要管理员权限绑定
1024–49151 注册端口,普通服务常使用此范围
49152–65535 动态/临时端口,客户端常由系统自动选择

对一条已建立的 TCP 连接,通常可用以下四元组唯一标识:

1
源 IP、源端口、目的 IP、目的端口

因此,同一台服务器可以同时为多个客户端提供 8080 端口的服务:各客户端的源 IP 或源端口不同。

Socket 是操作系统提供的通信端点。TCP 服务端常见流程是:

1
socket → setsockopt(可选)→ bind → listen → accept → recv/send → close

TCP 客户端常见流程是:

1
socket → connect → recv/send → close

UDP 接收端通常是:

1
socket → bind → recvfrom/sendto → close

TCP 协议

TCP(Transmission Control Protocol)是面向连接、可靠、有序的传输层协议。它提供的是字节流,而不是“一条一条的业务消息”。

TCP 的主要特性:

  • 面向连接:通信前通常先建立连接,通信结束后关闭连接。
  • 可靠传输:通过序列号、确认、超时重传等机制,尽力保证数据按序到达。
  • 有序传输:接收方按发送顺序向应用层交付字节。
  • 全双工:两端可以同时发送和接收数据。
  • 面向字节流:应用层消息边界不会被 TCP 保留。
  • 流量控制:根据接收方缓冲区能力控制发送速度。
  • 拥塞控制:根据网络拥塞情况调整发送速率。

TCP 常见于网页访问、数据库连接、文件传输、邮件传输和远程登录等需要完整、有序数据的场景。

三次握手

建立 TCP 连接通常需要三次握手:

1
2
3
4
客户端                                           服务端
| ---- SYN,seq = x --------------------------> |
| <--- SYN + ACK,seq = y,ack = x + 1 -------- |
| ---- ACK,ack = y + 1 ----------------------> |

SYN 用于发起连接,ACK 用于确认已收到对方的序列号。三次交互让双方确认对方可以接收数据,并同步各自的初始序列号。

对阻塞 TCP Socket 来说:

  • 客户端的 connect() 在握手成功后返回 0;
  • 服务端内核完成握手后,连接进入等待队列;
  • 服务端的 accept() 取出连接并返回新的通信 Socket。

握手成功不代表应用层请求已经发送,更不代表业务操作成功;它只说明 TCP 连接已经建立。

四次挥手与半关闭

TCP 是全双工的,两个方向可以分别关闭。典型关闭过程如下:

1
2
3
4
5
主动关闭方                                       被动关闭方
| ---- FIN ----------------------------------> |
| <--- ACK ---------------------------------- |
| <--- FIN ---------------------------------- |
| ---- ACK ---------------------------------> |

收到 FIN 表示对端不会再发送数据;本端仍可能继续发送尚未完成的响应。因此关闭过程常画成四次交互,但 ACK 和 FIN 在某些情况下也可能合并发送。

调用 shutdown(fd, SHUT_WR) 会关闭本端发送方向并发送 FIN,但仍可以继续 recv() 读取对方响应。这种“半关闭”特别适合“请求全部发送完毕后,继续读取完整响应”的协议。

主动关闭方通常会进入 TIME_WAIT 一段时间。其目的包括:

  • 确保最后一个 ACK 有机会重传;
  • 避免网络中延迟到达的旧报文干扰后续复用同一四元组的新连接。

可靠传输、流量控制与拥塞控制

这三者容易混淆:

机制 解决的问题 直观理解
确认与重传 数据包丢失或损坏 没收到确认,就在超时后重发
流量控制 接收方处理不过来 接收方通过接收窗口告诉发送方“还能接收多少”
拥塞控制 网络路径过于拥挤 发送方根据丢包、延迟等情况降低或调整发送速度

TCP 的“可靠”并不意味着业务一定成功。例如,对端程序收到请求后崩溃、数据库写入失败、进程处理超时,都属于应用层问题,仍需要应用协议定义响应、超时和重试策略。

字节流、消息边界与“粘包/拆包”

TCP 只保证字节序列可靠、有序,不知道你的业务消息从哪里开始、在哪里结束。所谓“粘包”和“拆包”并不是 TCP 出错,而是应用层没有正确处理字节流边界。

假设客户端执行:

1
2
send(fd, "hello", 5, 0);
send(fd, "world", 5, 0);

服务端的 recv() 可能得到:

1
第一次:helloworld

也可能得到:

1
2
第一次:hel
第二次:loworld

还可能恰好是两次分别得到 hello 和 world。不能依赖任何一种情况。

应用协议必须定义消息边界,常见方式如下:

方式 示例 优点 注意事项
固定长度 每条消息固定 64 字节 解析简单 会浪费空间;不适合变长内容
分隔符 一行消息以 \n 结束 便于调试、适合文本协议 正文必须转义分隔符,或禁止出现分隔符
长度前缀 [4 字节长度][正文] 适合二进制和变长消息 必须验证长度上限,防止恶意超大长度

长度前缀协议示例

下面定义一个简单消息格式:

1
2
3
4
5
6
0                   31
+-------------------+
| 正文长度(4 字节) |
+-------------------+
| 正文(变长) |
+-------------------+

长度字段使用 32 位无符号整数和网络字节序。发送前用 htonl() 转换,接收后用 ntohl() 转回主机字节序。

send() 可能只发送部分数据,因此应循环发送:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <arpa/inet.h>
#include <cerrno>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <string>
#include <sys/socket.h>

bool sendAll(int fd, const void* data, std::size_t length) {
const auto* current = static_cast<const char*>(data);
std::size_t sent = 0;

while (sent < length) {
const ssize_t n = send(fd, current + sent, length - sent, 0);
if (n > 0) {
sent += static_cast<std::size_t>(n);
} else if (n == -1 && errno == EINTR) {
continue; // 被信号中断,重试
} else {
return false;
}
}
return true;
}

bool sendPacket(int fd, const std::string& body) {
if (body.size() > std::numeric_limits<std::uint32_t>::max()) {
return false; // 不能放入 4 字节长度字段
}

const std::uint32_t network_length =
htonl(static_cast<std::uint32_t>(body.size()));

return sendAll(fd, &network_length, sizeof(network_length)) &&
sendAll(fd, body.data(), body.size());
}

接收方不能假定一次 recv() 就能读满 4 字节长度或完整正文。它需要维护接收缓冲区:数据不足时继续读取,数据足够时按协议解析,并保留下一条消息的剩余字节。解析长度字段后必须先检查它是否超过业务允许的最大值,例如 1 MiB,不能直接按网络中提供的长度分配内存。

UDP 协议

UDP(User Datagram Protocol)是无连接、尽力而为、面向数据报的传输层协议。

UDP 的主要特性:

  • 无连接:发送数据前不需要 TCP 三次握手。
  • 保留数据报边界:一次成功的 sendto() 对应接收端的一次数据报。
  • 不保证可靠、顺序或不重复:数据报可能丢失、重复或乱序到达。
  • 首部开销较小:UDP 首部固定为 8 字节。
  • 支持一对一、一对多、多对一和多对多通信;可用于广播或多播(受网络环境和权限限制)。

UDP 适合 DNS 查询、实时语音视频、在线游戏状态同步、局域网发现等场景。这里的“适合实时”不等于 UDP 天然更快:它只是减少了连接和可靠传输的开销;应用若自行加入重传、排序和拥塞控制,复杂度会相应增加。

UDP 数据报边界

UDP 保留发送边界:发送端的一次 sendto() 产生一份数据报,接收端一次 recvfrom() 最多读取一份数据报。

但接收缓冲区过小时,超出缓冲区的部分会被丢弃。因此,接收方必须根据协议的最大包长分配足够缓冲区。UDP 的 recvfrom() 返回 0 表示收到了一个合法的空数据报,不表示连接关闭。

UDP 数据报过大时可能触发 IP 分片;任一分片丢失通常会导致整个数据报无法使用。实际网络编程中应让单个 UDP 业务消息保持适中,并根据目标网络的 MTU 设计包长。

TCP 与 UDP 的对比

对比项 TCP UDP
连接方式 面向连接 无连接
数据形式 字节流 数据报
消息边界 不保留,应用层必须定义 保留一份数据报的边界
可靠性 可靠、有序、不重复地交付字节流 不保证送达、顺序或不重复
流量/拥塞控制 内置 协议本身不提供
接收缓冲区不足 后续字节仍留在 TCP 流中等待读取 当前数据报可能被截断,剩余部分丢失
典型场景 网页、文件、数据库、远程登录 DNS、实时媒体、游戏状态、局域网发现

选择协议时,应从业务需求出发:需要可靠完整的数据时通常选 TCP;更在意实时性、允许少量丢失并能在应用层处理时可考虑 UDP。

HTTP 与 HTTPS

HTTP(Hypertext Transfer Protocol)是应用层的请求—响应协议。客户端发出请求,服务器返回响应。HTTP/1.1 和 HTTP/2 通常运行在 TCP 上;HTTP/3 运行在 QUIC 上,而 QUIC 基于 UDP。因此,“HTTP 一定基于 TCP”的说法对 HTTP/3 不成立。

HTTPS 不是另一套完全不同的 HTTP 语义,而是 HTTP + TLS:TLS 为通信提供加密、完整性保护和服务器身份认证。HTTPS 默认端口通常为 443,HTTP 默认端口通常为 80;端口只是惯例,不是协议本身的硬性要求。

HTTP 请求与响应

一个 HTTP/1.1 请求示例:

1
2
3
4
GET /index.html HTTP/1.1
Host: example.com
Accept: text/html

第一行是请求行,包含方法、目标和 HTTP 版本;接着是若干请求头;空行表示头部结束。若有请求体(如 JSON),空行后才是请求体。

一个 HTTP/1.1 响应示例:

1
2
3
4
5
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 13

Hello, world!

第一行是状态行,后面是响应头、空行和可选的响应体。HTTP/1.1 响应体可借助 Content-Length 确定长度;也可能使用 Transfer-Encoding: chunked 分块传输,因此不能简单地把一次 recv() 当作完整 HTTP 响应。

常见 HTTP 方法

方法 常见语义 是否通常幂等
GET 获取资源,不应修改资源状态 是
POST 提交数据或创建子资源 否
PUT 用请求体整体创建或替换指定资源 是
PATCH 对资源进行部分修改 不一定
DELETE 删除指定资源 是
HEAD 与 GET 类似,但只返回响应头 是

“幂等”指同一个请求执行一次和多次,预期资源状态相同。它不表示响应内容、日志记录或统计信息一定完全相同。

常见 HTTP 状态码

状态码 含义
200 OK 请求成功
201 Created 已创建资源
204 No Content 请求成功,但没有响应体
301 / 302 重定向
400 Bad Request 请求格式错误
401 Unauthorized 缺少或无效认证信息
403 Forbidden 服务器理解请求,但拒绝授权
404 Not Found 资源不存在
405 Method Not Allowed 资源不支持该方法
500 Internal Server Error 服务器内部错误
502 Bad Gateway 网关或代理从上游得到无效响应
503 Service Unavailable 服务暂时不可用

DNS 简介

DNS(Domain Name System)将域名映射为 IP 地址。例如,浏览器访问 www.example.com 前,通常需要先查询其 A 记录(IPv4)或 AAAA 记录(IPv6)。

DNS 通常使用 UDP 53 端口,以减少一次普通查询的开销;响应过大、区域传送或某些场景会使用 TCP。应用程序中应优先使用 getaddrinfo() 进行解析,而不是自己拼接 DNS 报文。

设计自定义应用层协议时的检查清单

  • 定义帧边界:固定长度、分隔符或长度前缀三选一,不能依赖 recv() 次数。
  • 定义字节序:跨网络传输的多字节整数统一使用网络字节序。
  • 定义字段:至少明确版本、消息类型、长度和正文格式;复杂协议可增加魔数、请求 ID、校验字段等。
  • 限制长度:对长度字段设置上限,验证数据是否足够再解析,避免内存耗尽和越界访问。
  • 定义错误处理:格式错误、未知消息类型、超时、重复请求、连接中断时各自如何处理。
  • 定义超时与重试:不要无限等待;重试前要考虑操作是否幂等,避免重复写入或重复扣费。
  • 保持兼容性:协议需要演进时通过版本号或可选字段实现向前/向后兼容。
  • 不要把 C++ 结构体直接发送到网络:其中可能有内存对齐、填充字节、字节序和不同编译器布局差异;应逐字段序列化。

常见误区

  • TCP 可靠,不代表业务处理一定成功;应用层仍需要响应码、超时和错误处理。
  • TCP 没有“一个 send() 对应一个 recv()”的保证;必须自行定义消息边界。
  • UDP 不保证比 TCP 更快;最终延迟取决于网络、拥塞和应用实现。
  • recv() == 0 对 TCP 表示对端正常关闭发送方向;对 UDP 则可能只是收到空数据报。
  • send() 成功返回不表示对端应用已经处理数据,只表示数据已交给本机内核继续发送。
  • HTTP 状态码是应用层语义,TCP 连接成功、HTTP 返回 500 依然代表业务请求失败。