协议基础
协议基础
协议(Protocol)是通信双方共同遵守的规则:数据如何组织、何时发送、收到后如何响应、出错后如何处理。没有相同的协议,两个程序即使连上同一个 Socket,也无法正确理解对方发送的数据。
网络分层与封装
网络通信通常按层设计。初学网络编程时,最常接触的是下面几层:
| 层次 | 常见协议或概念 | 主要职责 |
|---|---|---|
| 应用层 | HTTP、DNS、SMTP、自定义协议 | 定义业务数据的含义,例如“请求页面”“查询域名” |
| 传输层 | TCP、UDP | 在进程之间传输数据;使用端口区分不同应用 |
| 网络层 | IP | 让数据在不同网络之间到达目标主机;使用 IP 地址 |
| 链路层 | Ethernet、Wi-Fi | 在同一个局域链路上传输帧;使用 MAC 地址 |
发送数据时,每一层都会给上层数据附加自己的控制信息,这个过程叫封装:
1 | 应用数据 |
接收方按相反顺序拆除首部,这叫解封装。应用程序通常不需要手动构造 TCP、IP 或链路层首部;操作系统的协议栈会完成这些工作。
IP 地址、端口与 Socket
IP 地址用于定位主机,端口用于定位主机上的进程或服务。例如:
1 | 192.168.1.10:8080 |
端口是 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 | 客户端 服务端 |
SYN 用于发起连接,ACK 用于确认已收到对方的序列号。三次交互让双方确认对方可以接收数据,并同步各自的初始序列号。
对阻塞 TCP Socket 来说:
- 客户端的
connect()在握手成功后返回0; - 服务端内核完成握手后,连接进入等待队列;
- 服务端的
accept()取出连接并返回新的通信 Socket。
握手成功不代表应用层请求已经发送,更不代表业务操作成功;它只说明 TCP 连接已经建立。
四次挥手与半关闭
TCP 是全双工的,两个方向可以分别关闭。典型关闭过程如下:
1 | 主动关闭方 被动关闭方 |
收到 FIN 表示对端不会再发送数据;本端仍可能继续发送尚未完成的响应。因此关闭过程常画成四次交互,但 ACK 和 FIN 在某些情况下也可能合并发送。
调用 shutdown(fd, SHUT_WR) 会关闭本端发送方向并发送 FIN,但仍可以继续 recv() 读取对方响应。这种“半关闭”特别适合“请求全部发送完毕后,继续读取完整响应”的协议。
主动关闭方通常会进入 TIME_WAIT 一段时间。其目的包括:
- 确保最后一个 ACK 有机会重传;
- 避免网络中延迟到达的旧报文干扰后续复用同一四元组的新连接。
可靠传输、流量控制与拥塞控制
这三者容易混淆:
| 机制 | 解决的问题 | 直观理解 |
|---|---|---|
| 确认与重传 | 数据包丢失或损坏 | 没收到确认,就在超时后重发 |
| 流量控制 | 接收方处理不过来 | 接收方通过接收窗口告诉发送方“还能接收多少” |
| 拥塞控制 | 网络路径过于拥挤 | 发送方根据丢包、延迟等情况降低或调整发送速度 |
TCP 的“可靠”并不意味着业务一定成功。例如,对端程序收到请求后崩溃、数据库写入失败、进程处理超时,都属于应用层问题,仍需要应用协议定义响应、超时和重试策略。
字节流、消息边界与“粘包/拆包”
TCP 只保证字节序列可靠、有序,不知道你的业务消息从哪里开始、在哪里结束。所谓“粘包”和“拆包”并不是 TCP 出错,而是应用层没有正确处理字节流边界。
假设客户端执行:
1 | send(fd, "hello", 5, 0); |
服务端的 recv() 可能得到:
1 | 第一次:helloworld |
也可能得到:
1 | 第一次:hel |
还可能恰好是两次分别得到 hello 和 world。不能依赖任何一种情况。
应用协议必须定义消息边界,常见方式如下:
| 方式 | 示例 | 优点 | 注意事项 |
|---|---|---|---|
| 固定长度 | 每条消息固定 64 字节 | 解析简单 | 会浪费空间;不适合变长内容 |
| 分隔符 | 一行消息以 \n 结束 |
便于调试、适合文本协议 | 正文必须转义分隔符,或禁止出现分隔符 |
| 长度前缀 | [4 字节长度][正文] |
适合二进制和变长消息 | 必须验证长度上限,防止恶意超大长度 |
长度前缀协议示例
下面定义一个简单消息格式:
1 | 0 31 |
长度字段使用 32 位无符号整数和网络字节序。发送前用 htonl() 转换,接收后用 ntohl() 转回主机字节序。
send() 可能只发送部分数据,因此应循环发送:
1 |
|
接收方不能假定一次 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 | GET /index.html |
第一行是请求行,包含方法、目标和 HTTP 版本;接着是若干请求头;空行表示头部结束。若有请求体(如 JSON),空行后才是请求体。
一个 HTTP/1.1 响应示例:
1 | 200 OK |
第一行是状态行,后面是响应头、空行和可选的响应体。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依然代表业务请求失败。



