RFC 9293学习:TCP 协议完整学习笔记(基于 RFC 9293)
本文从零开始,用最通俗的语言解释TCP协议的每一个核心概念。
原文:RFC 9293,2022年8月,废弃了1981年的RFC 793。
第一章:TCP 是什么?为什么需要它?
1.1 一句话理解 TCP
想象你在寄快递:
- IP协议 相当于快递公司:负责把包裹从A运到B,但不保证一定到,也不保证顺序。
- TCP协议 相当于"可靠快递协议":在IP上面加了一层保证,确保包裹:
- 一定到达(丢了重发)
- 按顺序到达(乱序重排)
- 没有损坏(校验和验证)
- 不超过对方能接收的量(流量控制)
- 不把网络塞满(拥塞控制)
1.2 TCP 的核心特性
RFC 9293 Section 2.2 总结如下:
| 特性 | 说明 |
|---|---|
| 可靠传输 | 通过序列号检测丢包,通过重传修复 |
| 有序传输 | 字节流保证顺序,不会乱 |
| 字节流服务 | 对应用透明,不关心消息边界 |
| 面向连接 | 通信前必须先建立连接(三次握手) |
| 全双工 | 两个方向独立传输数据 |
| 端口复用 | 用端口号区分同一主机上的不同应用 |
| 单播 | 一对一通信(不支持广播/多播作为目标) |
第二章:TCP 头部格式——每个字段的含义
2.1 头部图示
0 1 2 3 (字节序号)
0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7|0 1 2 3 4 5 6 7
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 源端口号 (16位) | 目标端口号 (16位) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 序列号 Sequence Number (32位) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 确认号 Acknowledgment Number (32位) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 数据 | |C|E|U|A|P|R|S|F| |
| 偏移 | 保留 |W|C|R|C|S|S|Y|I| 窗口大小 (16位) |
| | |R|E|G|K|H|T|N|N| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 校验和 (16位) | 紧急指针 (16位) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 选项 Options (可选) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 应用数据 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
2.2 各字段逐一讲解
源端口 / 目标端口(各16位)
端口就是门牌号。IP地址定位到一台机器,端口定位到机器上的具体程序。
比如:Web服务器通常在80端口,HTTPS在443端口。
序列号 Sequence Number(32位)
每个字节都有编号,就像书的页码。序列号是"这段数据第一个字节的页码"。
特殊情况:SYN包里的序列号是初始序列号(ISN),第一个真正的数据字节是 ISN+1ISN + 1ISN+1。
确认号 Acknowledgment Number(32位)
“我已经收到了这个编号之前的所有数据,现在期待收到编号为X的字节。”
只有ACK标志位为1时这个字段才有效。
例:确认号=301 意思是"300及之前的字节我都收到了,请发第301字节"。
数据偏移 Data Offset(4位)
告诉接收方"数据从哪里开始"。单位是32位字(4字节)。
最小值=5(即20字节,无选项时的标准头部大小)。
最大值=15(即60字节,选项最多40字节)。
控制位 / 标志位(8个)
| 标志 | 全称 | 作用 |
|---|---|---|
| CWR | Congestion Window Reduced | 拥塞窗口已缩小(配合ECN使用) |
| ECE | ECN-Echo | 通知对方网络出现拥塞 |
| URG | Urgent | 紧急指针字段有效 |
| ACK | Acknowledgment | 确认号字段有效 |
| PSH | Push | 请立即将数据推送给应用层,不要缓冲 |
| RST | Reset | 强制断开连接(出错时使用) |
| SYN | Synchronize | 同步序列号,用于建立连接 |
| FIN | Finish | 发送方没有更多数据了,请求关闭连接 |
窗口大小 Window(16位)
接收方告诉发送方:“我现在还能接收这么多字节的数据”。
这是流量控制的核心机制,防止发送方把接收方淹没。重要:必须把它当作无符号整数处理(MUST-1),否则大窗口会被误认为负数。
建议在连接记录中用32位字段保存实际窗口值(使用窗口扩展选项时需要)。
校验和 Checksum(16位)
TCP的自我检测机制,检查数据在传输过程中有没有损坏。计算方法:对整个TCP头部+数据+伪头部(包含IP地址信息)做反码求和。
伪头部(IPv4版):
+--------+--------+--------+--------+ | 源IP地址 (32位) | +--------+--------+--------+--------+ | 目标IP地址 (32位) | +--------+--------+--------+--------+ | 零 |协议号=6| TCP总长度 | +--------+--------+--------+--------+为什么要加IP地址?防止被路由到错误目标的数据包被误接受。
强制要求:发送方必须生成校验和(MUST-2),接收方必须验证(MUST-3)。
紧急指针 Urgent Pointer(16位)
只在URG标志位为1时有效。
值含义:相对于当前序列号的偏移量,指向紧急数据最后一个字节的下一个位置。
现代应用程序不应该使用这个机制(SHLD-13),因为各实现行为不一致。
2.3 完整的 C++ 头部结构示例
#include <cstdint>
#include <cstring>
#include <arpa/inet.h> // htons, ntohs
#include <iostream>
// TCP 头部结构(20字节固定部分,不含选项)
// 注意:网络字节序是大端(big-endian)
struct TcpHeader {
uint16_t src_port; // 源端口
uint16_t dst_port; // 目标端口
uint32_t seq_num; // 序列号
uint32_t ack_num; // 确认号(ACK标志为1时有效)
uint8_t data_offset; // 高4位=数据偏移(单位:32bit字), 低4位=保留(置0)
uint8_t flags; // 控制标志位(见下面的枚举)
uint16_t window; // 窗口大小(接收方的可用缓冲区字节数)
uint16_t checksum; // 校验和
uint16_t urgent_ptr; // 紧急指针(URG=1时有效)
// 之后是可变长度的选项字段(0~40字节)
};
// 标志位枚举(对应flags字段的各个位)
enum TcpFlags : uint8_t {
TCP_FIN = 0x01, // bit 0: 结束
TCP_SYN = 0x02, // bit 1: 同步
TCP_RST = 0x04, // bit 2: 重置
TCP_PSH = 0x08, // bit 3: 推送
TCP_ACK = 0x10, // bit 4: 确认
TCP_URG = 0x20, // bit 5: 紧急
TCP_ECE = 0x40, // bit 6: ECN回显
TCP_CWR = 0x80 // bit 7: 拥塞窗口缩小
};
// 辅助函数:解析头部并打印各字段(主机字节序显示)
void print_tcp_header(const TcpHeader& hdr) {
// 从网络字节序转为主机字节序再显示
uint16_t sport = ntohs(hdr.src_port);
uint16_t dport = ntohs(hdr.dst_port);
uint32_t seq = ntohl(hdr.seq_num);
uint32_t ack = ntohl(hdr.ack_num);
uint8_t doff = (hdr.data_offset >> 4) & 0x0F; // 高4位
uint8_t flags = hdr.flags;
uint16_t win = ntohs(hdr.window);
std::cout << "=== TCP Header ===\n";
std::cout << " 源端口: " << sport << "\n";
std::cout << " 目标端口: " << dport << "\n";
std::cout << " 序列号: " << seq << "\n";
std::cout << " 确认号: " << ack << "\n";
std::cout << " 数据偏移: " << (int)doff << " x 4 = "
<< (int)doff * 4 << " 字节\n";
std::cout << " 标志位: "
<< ((flags & TCP_FIN) ? "FIN " : "")
<< ((flags & TCP_SYN) ? "SYN " : "")
<< ((flags & TCP_RST) ? "RST " : "")
<< ((flags & TCP_PSH) ? "PSH " : "")
<< ((flags & TCP_ACK) ? "ACK " : "")
<< ((flags & TCP_URG) ? "URG " : "")
<< ((flags & TCP_ECE) ? "ECE " : "")
<< ((flags & TCP_CWR) ? "CWR " : "")
<< "\n";
std::cout << " 窗口大小: " << win << " 字节\n";
std::cout << " 校验和: 0x" << std::hex << ntohs(hdr.checksum)
<< std::dec << "\n";
}
int main() {
// 模拟一个 SYN 包头部
TcpHeader syn_pkt;
std::memset(&syn_pkt, 0, sizeof(syn_pkt));
syn_pkt.src_port = htons(54321); // 客户端随机端口
syn_pkt.dst_port = htons(80); // 目标:HTTP
syn_pkt.seq_num = htonl(100); // 初始序列号=100
syn_pkt.ack_num = htonl(0); // SYN包无效确认号
syn_pkt.data_offset = (5 << 4); // 数据偏移=5(无选项,20字节头部)
syn_pkt.flags = TCP_SYN; // 只有SYN标志
syn_pkt.window = htons(65535); // 最大窗口(未使用窗口扩展时)
syn_pkt.checksum = 0; // 实际发送前由内核计算
syn_pkt.urgent_ptr = 0;
print_tcp_header(syn_pkt);
return 0;
}
第三章:序列号与确认号——TCP 可靠性的基础
3.1 序列号的作用
每一个字节都有一个唯一的序列号,就像给每张纸编页码:
字节数据: H e l l o W o r l d
序列号: 101 102 103 104 105 106 107 108 109 110 111
- 确认是累积的:ACK=106 意味着"101~105我全收到了,等106"
- 重传通过序列号识别:如果105没收到,ACK一直停在105,发送方重传
序列号空间范围:000 到 232−12^{32} - 1232−1(约42亿),所有运算取模 2322^{32}232。
3.2 关键状态变量(TCB中存储)
发送方的变量:
1⏟已确认∣2⏟已发未确认∣3⏟可发但未发∣4⏟还不能发 \underbrace{1}_{\text{已确认}} \quad | \underbrace{2}_{\text{已发未确认}} | \underbrace{3}_{\text{可发但未发}} | \underbrace{4}_{\text{还不能发}} 已确认
1∣已发未确认
2∣可发但未发
3∣还不能发
4
SND.UNA SND.NXT SND.UNA + SND.WND
| | |
----+-------+---------------+--------------+------>
已确认 | 已发未确认 | 可发未发 | 不可发
(区域1) | (区域2) | (区域3) | (区域4)
| 变量 | 全名 | 含义 |
|---|---|---|
| SND.UNA | send unacknowledged | 最老的未被确认的序列号(发送窗口左边界) |
| SND.NXT | send next | 下一个要发送的序列号 |
| SND.WND | send window | 发送窗口大小(由接收方广告) |
| SND.UP | send urgent pointer | 发送方的紧急指针 |
| SND.WL1 | - | 上次更新窗口时用的段序列号(防止用旧段更新窗口) |
| SND.WL2 | - | 上次更新窗口时用的确认号 |
| ISS | initial send sequence | 初始发送序列号 |
接收方的变量:
RCV.NXT RCV.NXT + RCV.WND
| |
----+-------+--------------+------>
已确认 | 期望接收区域 | 不接受
(区域1) | (区域2) | (区域3)
| 变量 | 全名 | 含义 |
|---|---|---|
| RCV.NXT | receive next | 期望收到的下一个序列号(接收窗口左边界) |
| RCV.WND | receive window | 接收窗口大小(告诉发送方我能收多少) |
| RCV.UP | receive urgent pointer | 接收方紧急指针 |
| IRS | initial receive sequence | 初始接收序列号 |
3.3 段的可接受性判断
一个到来的数据段,接收方如何判断是否接受?
接受条件(有数据时):RCV.NXT≤SEG.SEQ<RCV.NXT+RCV.WND \text{接受条件(有数据时):} \quad RCV.NXT \leq SEG.SEQ < RCV.NXT + RCV.WND 接受条件(有数据时):RCV.NXT≤SEG.SEQ<RCV.NXT+RCV.WND
或者(段的末尾落在窗口内也接受):
RCV.NXT≤SEG.SEQ+SEG.LEN−1<RCV.NXT+RCV.WND RCV.NXT \leq SEG.SEQ + SEG.LEN - 1 < RCV.NXT + RCV.WND RCV.NXT≤SEG.SEQ+SEG.LEN−1<RCV.NXT+RCV.WND
四种特殊情况的完整表格:
| 段长度 | 接收窗口 | 接受条件 |
|---|---|---|
| 0 | 0 | SEG.SEQ=RCV.NXTSEG.SEQ = RCV.NXTSEG.SEQ=RCV.NXT |
| 0 | >0> 0>0 | RCV.NXT≤SEG.SEQ<RCV.NXT+RCV.WNDRCV.NXT \leq SEG.SEQ < RCV.NXT + RCV.WNDRCV.NXT≤SEG.SEQ<RCV.NXT+RCV.WND |
| >0> 0>0 | 0 | 不接受(窗口为零时不接受数据) |
| >0> 0>0 | >0> 0>0 | 上面两个条件满足其一即可 |
3.4 初始序列号(ISN)的选择
为什么不能从0开始?
如果每次都从0开始,上一次连接遗留在网络中的旧数据包可能被新连接误认为有效数据。
RFC 9293 推荐的公式:
ISN=M+F(localip,localport,remoteip,remoteport,secretkey) ISN = M + F(\text{localip}, \text{localport}, \text{remoteip}, \text{remoteport}, \text{secretkey}) ISN=M+F(localip,localport,remoteip,remoteport,secretkey)
其中:
- MMM 是一个每约4微秒递增一次的时钟计数器(绕回周期约4.55小时,远大于MSL=2分钟)
- F()F()F() 是一个伪随机函数(PRF),通常用密码学哈希实现
- secretkey 是只有本机知道的秘密,防止攻击者预测ISN
F()F()F() 的作用:即使攻击者知道连接的四元组(两端IP+端口),也无法预测ISN,从而无法伪造RST攻击。
3.5 C++ 演示:序列号比较(模 2322^{32}232)
#include <cstdint>
#include <iostream>
// TCP 序列号比较:考虑 2^32 回绕
// 由于序列号是模 2^32 的无符号整数,不能直接用 < 比较
// 判断 a < b(模 2^32 意义下)
// 方法:如果 (b - a) 在 (0, 2^31) 范围内,则 a < b
bool seq_less(uint32_t a, uint32_t b) {
// 利用无符号减法自动取模
return (int32_t)(b - a) > 0;
}
// 判断 a <= b
bool seq_leq(uint32_t a, uint32_t b) {
return (int32_t)(b - a) >= 0;
}
// 判断 a 是否在 [left, right] 窗口内(闭区间,模 2^32)
bool in_window(uint32_t a, uint32_t left, uint32_t right) {
// left <= a <= right(模 2^32)
return seq_leq(left, a) && seq_leq(a, right);
}
// 判断一个ACK是否"可接受"(有效确认了新数据)
// 条件:SND.UNA < SEG.ACK <= SND.NXT
bool is_acceptable_ack(uint32_t seg_ack, uint32_t snd_una, uint32_t snd_nxt) {
// SEG.ACK 必须在 (SND.UNA, SND.NXT] 范围内
return seq_less(snd_una, seg_ack) && seq_leq(seg_ack, snd_nxt);
}
int main() {
// 测试1:正常情况
uint32_t snd_una = 100, snd_nxt = 200;
uint32_t seg_ack = 150;
std::cout << "ACK=150 是否有效: "
<< (is_acceptable_ack(seg_ack, snd_una, snd_nxt) ? "是" : "否")
<< "\n"; // 是
// 测试2:回绕情况(序列号接近 2^32 边界)
// 假设 SND.UNA 在 2^32-50 附近,SND.NXT 回绕到了 50
uint32_t una_wrap = 0xFFFFFFD0u; // 2^32 - 48
uint32_t nxt_wrap = 50; // 已经回绕
uint32_t ack_wrap = 30; // 在回绕后的范围内
std::cout << "回绕情况 ACK=30 是否有效: "
<< (is_acceptable_ack(ack_wrap, una_wrap, nxt_wrap) ? "是" : "否")
<< "\n"; // 是
// 测试3:旧的 ACK(重复确认)
uint32_t dup_ack = 50; // 等于 SND.UNA,不是 > SND.UNA
std::cout << "重复 ACK=50 是否有效: "
<< (is_acceptable_ack(dup_ack, 50, 200) ? "是" : "否")
<< "\n"; // 否(不满足 SND.UNA < ACK 的严格小于)
return 0;
}
第四章:连接建立——三次握手(3WHS)
4.1 为什么需要三次握手?
核心问题: 双方都需要知道对方的初始序列号,并且得到确认。
一问一答是两次,但两次不够:因为最后一个ACK也可能丢失,服务器不知道客户端是否收到了SYN-ACK。三次握手解决了这个问题。
4.2 正常三次握手流程
TCP 客户端 A TCP 服务器 B
CLOSED LISTEN
| |
|--- SYN, SEQ=100 -------------------> |
| "我的初始序号是100" SYN-RECEIVED
| |
SYN-SENT <--- SYN+ACK, SEQ=300, ACK=101 --|
| "我的初始序号是300, |
| 收到你的了,等101" |
| |
ESTABLISHED |
|--- ACK, SEQ=101, ACK=301 -----------> |
| "收到你的了,等301" ESTABLISHED
| |
|=== DATA 正常传输 ====================== |
步骤分解:
- A -> B:SYN(seq=100):A说"我要建立连接,我的起始序号是100"
- B -> A:SYN+ACK(seq=300, ack=101):B说"好的,我的起始序号是300,你的100我收到了,等101"
- A -> B:ACK(seq=101, ack=301):A说"你的300我收到了,等301,连接建立"
注意:SYN本身占用一个序列号,所以ACK的确认号是 ISN+1ISN + 1ISN+1。
4.3 同时发起连接(Simultaneous Open)
两端同时主动发起连接,TCP必须支持(MUST-10):
TCP 端A TCP 端B
CLOSED CLOSED
| |
|--- SYN, SEQ=100 -------> ... |
| |--- SYN, SEQ=300 ---> ...
| <--- SYN, SEQ=300 ---------------|
| <SEQ=100><SYN> 到达B --> SYN-RECEIVED |
SYN-RECEIVED |
|--- SYN+ACK, SEQ=100, ACK=301 ------> ... |
| <--- SYN+ACK, SEQ=300, ACK=101 --------|
ESTABLISHED ESTABLISHED
路径:CLOSED → SYN-SENT → SYN-RECEIVED → ESTABLISHED(两端相同)
4.4 旧SYN包引起的问题及RST恢复
旧的重复SYN到达时,通过RST恢复:
A (客户端) B (服务器)
| |
|=== (旧的SYN=90 在网络中漂流) =============>|
|--- 新 SYN, SEQ=100 -----------------> ... |
| |
| <--- SYN+ACK, SEQ=300, ACK=91 ----| ← 响应了旧SYN
| |
| A发现 ACK=91 与 ISS=100 不符 |
|--- RST, SEQ=91 -----------------------> 回到LISTEN
| |
| (原来的新SYN终于到达) |
|--- SEQ=100 SYN --------------------> |
| <--- SYN+ACK, SEQ=400, ACK=101 ----|
|--- ACK, SEQ=101, ACK=401 -----------> |
ESTABLISHED ESTABLISHED
4.5 半开连接(Half-Open Connection)
A重启了,但B不知道,以为连接还在:
A (重启后) B (以为连接还开着)
CLOSED ESTABLISHED(send 300, rcv 100)
| |
|--- SYN, SEQ=400 -----------------------> |
| B处于synchronized状态, |
| SEQ=400不在期望窗口内 |
| <--- ACK, SEQ=300, ACK=100 -------|
| |
| A没有对应连接,发RST |
|--- RST, SEQ=100 -----------------------> |
| B中止!|
| CLOSED
|--- 继续尝试建立连接 ------------------> |
第五章:TCP 状态机
5.1 所有状态列表
| 状态 | 含义 |
|---|---|
| CLOSED | 无连接(虚构状态,表示没有TCB) |
| LISTEN | 被动等待远端的连接请求 |
| SYN-SENT | 已发SYN,等对方的SYN-ACK |
| SYN-RECEIVED | 已收并发了SYN,等最终的ACK |
| ESTABLISHED | 连接建立,正常数据传输阶段 |
| FIN-WAIT-1 | 已发FIN,等对方ACK |
| FIN-WAIT-2 | 己方FIN已被确认,等对方的FIN |
| CLOSE-WAIT | 收到了对方的FIN,等本地应用调用Close |
| CLOSING | 双方同时关闭,等对方的ACK |
| LAST-ACK | 已发己方FIN,等最后一个ACK |
| TIME-WAIT | 等待2倍MSL(最大段生存时间),确保旧包消散 |
5.2 状态转移图
5.3 状态转换的触发事件
触发TCP状态转换的三类事件:
事件类型
|
+-- 用户调用 (User Calls)
| OPEN / SEND / RECEIVE / CLOSE / ABORT / STATUS
|
+-- 到达的段 (Arriving Segments)
| 含有 SYN / ACK / RST / FIN 标志的包
|
+-- 超时 (Timeouts)
USER TIMEOUT(用户超时)
RETRANSMISSION TIMEOUT(重传超时)
TIME-WAIT TIMEOUT(2MSL超时)
第六章:连接关闭——四次挥手
6.1 为什么需要四次?
TCP是全双工的(两个方向独立)。关闭也是"半关闭":
- 一方发FIN,表示"我不再发数据了"
- 但另一方可以继续发数据,直到它也发FIN
所以完整关闭需要两个方向各一个FIN+ACK,共4次交互。
6.2 正常关闭序列
A(主动关闭方) B(被动关闭方)
ESTABLISHED ESTABLISHED
| |
|--- FIN+ACK, SEQ=100, ACK=300 --------> |
| CLOSE-WAIT
FIN-WAIT-1 |
| |
| <--- ACK, SEQ=300, ACK=101 -------|
FIN-WAIT-2 |
| |
| (B的应用程序调用CLOSE) |
| <--- FIN+ACK, SEQ=300, ACK=101 --|
| LAST-ACK
TIME-WAIT |
|--- ACK, SEQ=101, ACK=301 ------------> |
| CLOSED
| (等待 2×MSL = 4分钟) |
CLOSED
6.3 同时关闭(Simultaneous Close)
A B
ESTABLISHED ESTABLISHED
| |
|--- FIN+ACK, SEQ=100, ACK=300 -------->... |
| ...FIN+ACK, SEQ=300, ACK=100 <------ |
CLOSING CLOSING
| |
|--- ACK, SEQ=101, ACK=301 ------------> |
| <--- ACK, SEQ=301, ACK=101 --------|
TIME-WAIT TIME-WAIT
| (2MSL) | (2MSL)
CLOSED CLOSED
6.4 TIME-WAIT 状态的作用
TIME-WAIT 必须持续 2×MSL2 \times MSL2×MSL(MSLMSLMSL = 最大段生存时间 = 2分钟,所以是4分钟):
目的1:确保最后一个ACK能到达B(如果ACK丢失,B会重发FIN,A在TIME-WAIT还能响应)
目的2:防止旧连接的延迟包影响新连接
特殊情况:如果有Timestamp选项,可以用改进算法减少TIME-WAIT时间(SHLD-4)。
6.5 RST 重置连接
RST 用于立即强制关闭连接,不等待对方确认。
三种触发RST的情况:
- 连接不存在(CLOSED状态):收到任何非RST包,回RST
- 非同步状态(LISTEN/SYN-SENT/SYN-RECEIVED):收到不合法的ACK,回RST
- 同步状态(ESTABLISHED等):收到不合法包,回ACK(不回RST!用于确认窗口)
第七章:分段与 MSS(最大段大小)
7.1 为什么要控制段的大小?
太大的问题:超过路径上某个链路的MTU(最大传输单元),会被分片或丢弃。
太小的问题:每段都有20字节TCP头 + 20字节IP头 = 40字节开销,数据太少则效率低。
7.2 MSS 选项(Maximum Segment Size)
在SYN包中协商,告诉对方"你最多可以给我发这么大的段":
格式:Kind=2, Length=4, MSS值(2字节)
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
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 2 | 4 | MSS 值 (16位) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
默认值:
- IPv4:536字节(576−40576 - 40576−40,576是IPv4最小重组大小,40是IPv4+TCP头)
- IPv6:1220字节(1280−601280 - 601280−60)
实际有效发送MSS公式:
Eff.snd.MSS=min(SendMSS+20, MMS_S)−TCPhdrsize−IPoptionsize Eff.snd.MSS = \min(SendMSS + 20,\ MMS\_S) - TCPhdrsize - IPoptionsize Eff.snd.MSS=min(SendMSS+20, MMS_S)−TCPhdrsize−IPoptionsize
其中: - SendMSSSendMSSSendMSS:从对端收到的MSS选项值
- MMS_SMMS\_SMMS_S:IP层允许的最大传输单元
- TCPhdrsizeTCPhdrsizeTCPhdrsize:TCP头部大小(含选项,至少20字节)
- IPoptionsizeIPoptionsizeIPoptionsize:IP选项/扩展头大小
7.3 路径 MTU 发现(PMTUD)
TCP自己并不知道路径上每条链路的MTU,PMTUD帮助动态发现:
客户端 路由器R1(MTU=1500) 路由器R2(MTU=576) 服务器
| | | |
|--- 大包(1400字) -->| | |
| |--- 太大了 -------->| |
| | X(丢弃) |
| |<-- ICMP "需要分片" | |
|<-- ICMP 报告 ------| | |
| | |
| (调小MSS,重发) | |
|--- 小包(556字) --->|--- 可以通过 ------>|--- 到达 -------->|
7.4 Nagle 算法——避免小包泛滥
问题背景:SSH等交互应用,每次只发1个字符,但每个字符都有40字节TCP+IP头,效率极低("糊涂窗口综合症"的一种)。
Nagle算法规则:
如果 有未确认的数据在路上 (SND.NXT > SND.UNA):
则 缓冲新数据,直到:
(a) 可以发一个满尺寸段(>= Eff.snd.MSS),或
(b) 所有之前的数据都被确认了
简单说:要么发大包,要么等确认。
注意:应用程序可以通过 TCP_NODELAY socket选项关闭Nagle算法(MUST-17),比如需要低延迟的游戏或实时交互。
第八章:数据传输与可靠性机制
8.1 重传超时(RTO)
数据发出后,如果在RTO时间内没有收到ACK,就重传。
RTO必须动态计算,因为网络RTT(往返时延)变化很大。
RFC 9293 要求使用 RFC 6298 的算法(MUST-18):
SRTT=(1−α)×SRTT+α×RTT_sample(α=1/8) SRTT = (1 - \alpha) \times SRTT + \alpha \times RTT\_sample \quad (\alpha = 1/8) SRTT=(1−α)×SRTT+α×RTT_sample(α=1/8)
RTTVAR=(1−β)×RTTVAR+β×∣SRTT−RTT_sample∣(β=1/4) RTTVAR = (1 - \beta) \times RTTVAR + \beta \times |SRTT - RTT\_sample| \quad (\beta = 1/4) RTTVAR=(1−β)×RTTVAR+β×∣SRTT−RTT_sample∣(β=1/4)
RTO=SRTT+4×RTTVAR RTO = SRTT + 4 \times RTTVAR RTO=SRTT+4×RTTVAR
其中:
- SRTTSRTTSRTT:平滑化的RTT估计(指数移动平均)
- RTTVARRTTVARRTTVAR:RTT的方差估计(衡量抖动)
- 重传时RTO翻倍(指数退避)
Karn算法:重传后不用重传包的ACK来更新RTT样本(因为不知道ACK对应哪次发送)。
8.2 拥塞控制
TCP必须(MUST-19)实现基本拥塞控制算法:
慢启动(Slow Start):
连接初始时,拥塞窗口(cwnd)很小(通常1~10个MSS)
每收到一个ACK,cwnd += 1 个MSS
→ 每个RTT,cwnd翻倍(指数增长)
→ 直到 cwnd >= ssthresh(慢启动阈值)
拥塞避免(Congestion Avoidance):
当 cwnd >= ssthresh 时:
每收到一个ACK,cwnd += MSS²/cwnd
→ 每个RTT,cwnd += 1 个MSS(线性增长)
快速重传(Fast Retransmit):
收到3个重复ACK → 不等RTO超时,立即重传丢失的段
→ ssthresh = max(FlightSize/2, 2*MSS)
→ cwnd = ssthresh(快速恢复)
图示:
cwnd
^
| /(慢启动,指数)
| /
ssthresh / \___(拥塞避免,线性)
| / \____
| / \___(检测到丢包,cwnd骤降)
| /
+--+--+--+--+--+--+--+--+-> 时间(RTT)
| |
连接开始 首次丢包
8.3 流量控制——滑动窗口
接收方控制:
接收方在每个ACK中告诉发送方当前窗口大小(RCV.WND),发送方不能超过这个量。
“糊涂窗口综合症”(Silly Window Syndrome, SWS):
当接收方缓冲区快满时,可能广告一个很小的窗口(比如1字节),导致发送方只能发微小的段,效率极低。
解决方案——双端都要配合:
接收方:只有在可以广告足够大的窗口时才更新窗口:
ΔW=RCV.BUFF−RCV.USER−RCV.WND≥min(12×RCV.BUFF, Eff.snd.MSS) \Delta W = RCV.BUFF - RCV.USER - RCV.WND \geq \min\left(\frac{1}{2} \times RCV.BUFF,\ Eff.snd.MSS\right) ΔW=RCV.BUFF−RCV.USER−RCV.WND≥min(21×RCV.BUFF, Eff.snd.MSS)
发送方:只有满足以下条件之一才发送:
- 可以发一个满尺寸段:min(D,U)≥Eff.snd.MSS\min(D, U) \geq Eff.snd.MSSmin(D,U)≥Eff.snd.MSS
- Push了且所有队列数据都能发
- 可以发至少 12\frac{1}{2}21 最大窗口的数据
- 超时强制发送(override timeout,0.1~1秒)
8.4 零窗口探测(Zero-Window Probing, ZWP)
当接收方广告窗口=0时,发送方必须定期发送探测包(MUST-36),防止死锁:
发送方 接收方
|--- 数据 -------------------> |
| <--- ACK, WIN=0 ------------| ← 接收缓冲满了,窗口关闭
| |
| (等待 RTO 时间) | ← 接收方处理数据,释放了缓冲区
| |
|--- 探测包(1字节数据)-------> | ← 但窗口更新的ACK可能丢了!
| <--- ACK, WIN=4096 ---------| ← 接收方在探测回应中带上新窗口
| |
|--- 正常数据传输继续 ---------->|
第一个探测应在RTO后发送(SHLD-29),后续探测指数退避(SHLD-30)。
8.5 Keep-Alive(TCP保活)
当连接长时间空闲时,发送探测包检测对端是否还活着。
默认关闭(MUST-25),应用程序可以开启(MUST-24)。
默认间隔:至少2小时(MUST-28)。
注意:不能因为某次探测没有响应就认为连接断了(MUST-29),因为ACK本身不保证可靠传输。
8.6 延迟ACK(Delayed ACK)
为提高效率,接收方不必每收到一个段就立即发ACK,可以稍微等一下,把ACK和数据一起发,或者把多个段的ACK合并成一个。
限制:
- 延迟不能超过 500ms(MUST-40)
- 至少每收到2个满尺寸段就要发一次ACK(SHLD-19)
第九章:TCP 选项
9.1 必须支持的三个选项
| Kind | 长度 | 名称 | 作用 |
|---|---|---|---|
| 0 | 1字节 | EOL(End of Option List) | 选项列表结束标志 |
| 1 | 1字节 | NOP(No Operation) | 填充,用于对齐后续选项到字边界 |
| 2 | 4字节 | MSS(Maximum Segment Size) | 协商最大段大小,仅在SYN中出现 |
9.2 常用但非必须的选项
| 选项 | RFC | 作用 |
|---|---|---|
| SACK(选择确认) | RFC 2018 | 精确告知收到了哪些不连续的段,减少不必要重传 |
| Timestamp | RFC 7323 | 精确RTT测量 + PAWS(防序列号回绕) |
| Window Scale | RFC 7323 | 将窗口大小扩展到最大约1GB(16位×最大14位移位) |
9.3 选项格式
单字节选项(Kind 0 和 Kind 1):
+--------+
| Kind |
+--------+
多字节选项(Kind 2 及其他):
+--------+--------+--------+--------+...
| Kind | Length | Data... |
+--------+--------+--------+--------+...
↑ Length包含Kind和Length本身
重要规则(MUST-6):实现必须忽略不认识的选项(不能报错),以保证前向兼容。
第十章:TCP 安全性
10.1 TCP 本身没有加密
TCP头部是明文传输的,路径上的任何节点都能看到:
- 源端口、目标端口
- 序列号、确认号
- 所有标志位
- 数据内容
10.2 主要安全威胁
| 攻击类型 | 原理 | 防御措施 |
|---|---|---|
| SYN洪泛 | 发送大量SYN包耗尽服务器TCB资源 | SYN Cookie |
| RST注入 | 伪造RST包强制断开连接 | RFC 5961:RST只接受SEQ=RCV.NXT的 |
| 序列号预测 | 猜测ISN,伪造数据 | RFC 6528:ISN包含密码学PRF |
| ICMP攻击 | 用ICMP错误包干扰TCP连接 | RFC 5927:严格验证ICMP包中的TCP信息 |
10.3 加密保护方案
完整保护(包括TCP头部):
→ IPsec (网络层加密)
仅数据完整性+认证:
→ TCP-AO (TCP Authentication Option, RFC 5925)
→ 替代已废弃的TCP-MD5
仅数据内容保护:
→ TLS (在TCP之上的应用层加密)
→ tcpcrypt (实验性TCP扩展)
第十一章:完整的 C++ TCP 状态机演示
#include <iostream>
#include <string>
#include <cstdint>
#include <functional>
// ============================================================
// TCP 状态枚举
// ============================================================
enum class TcpState {
CLOSED,
LISTEN,
SYN_SENT,
SYN_RECEIVED,
ESTABLISHED,
FIN_WAIT_1,
FIN_WAIT_2,
CLOSE_WAIT,
CLOSING,
LAST_ACK,
TIME_WAIT
};
// 将状态转为字符串(方便打印)
const char* state_name(TcpState s) {
switch (s) {
case TcpState::CLOSED: return "CLOSED";
case TcpState::LISTEN: return "LISTEN";
case TcpState::SYN_SENT: return "SYN-SENT";
case TcpState::SYN_RECEIVED: return "SYN-RECEIVED";
case TcpState::ESTABLISHED: return "ESTABLISHED";
case TcpState::FIN_WAIT_1: return "FIN-WAIT-1";
case TcpState::FIN_WAIT_2: return "FIN-WAIT-2";
case TcpState::CLOSE_WAIT: return "CLOSE-WAIT";
case TcpState::CLOSING: return "CLOSING";
case TcpState::LAST_ACK: return "LAST-ACK";
case TcpState::TIME_WAIT: return "TIME-WAIT";
default: return "UNKNOWN";
}
}
// ============================================================
// 简化的 TCP 端点(仅演示状态机)
// ============================================================
struct TcpEndpoint {
TcpState state = TcpState::CLOSED;
std::string name; // 端点名称,用于打印
uint32_t iss = 0; // 初始发送序列号
uint32_t snd_nxt = 0; // 下一个发送序列号
uint32_t snd_una = 0; // 最老的未确认序列号
uint32_t rcv_nxt = 0; // 期望收到的下一个序列号
explicit TcpEndpoint(const std::string& n, uint32_t initial_seq)
: name(n), iss(initial_seq), snd_nxt(initial_seq), snd_una(initial_seq)
{}
// 打印当前状态
void print_state() const {
std::cout << " [" << name << "] 状态 -> " << state_name(state) << "\n";
}
// ---- 用户操作 ----
// 被动打开(服务器调用,进入LISTEN)
void passive_open() {
if (state == TcpState::CLOSED) {
state = TcpState::LISTEN;
std::cout << name << ": 被动OPEN, 进入LISTEN\n";
print_state();
}
}
// 主动打开(客户端调用,发送SYN)
// 返回要发送的SYN包信息
uint32_t active_open() {
if (state == TcpState::CLOSED) {
state = TcpState::SYN_SENT;
// ISN已在构造时设置
snd_nxt = iss + 1; // SYN占一个序号
std::cout << name << ": 主动OPEN, 发送SYN(SEQ=" << iss << "), 进入SYN-SENT\n";
print_state();
return iss; // 返回ISN,供对端使用
}
return 0;
}
// 调用CLOSE(应用程序请求关闭)
void close_call() {
if (state == TcpState::ESTABLISHED) {
state = TcpState::FIN_WAIT_1;
std::cout << name << ": 调用CLOSE, 发送FIN(SEQ=" << snd_nxt << "), 进入FIN-WAIT-1\n";
snd_nxt++; // FIN占一个序号
print_state();
} else if (state == TcpState::CLOSE_WAIT) {
state = TcpState::LAST_ACK;
std::cout << name << ": 调用CLOSE(从CLOSE-WAIT), 发送FIN, 进入LAST-ACK\n";
snd_nxt++;
print_state();
}
}
// ---- 接收到的段处理 ----
// 收到 SYN(服务器端处理)
void receive_syn(uint32_t seg_seq, uint32_t server_iss) {
if (state == TcpState::LISTEN) {
rcv_nxt = seg_seq + 1; // 期望收 SYN 后的第一个数据
iss = server_iss;
snd_nxt = iss + 1;
snd_una = iss;
state = TcpState::SYN_RECEIVED;
std::cout << name << ": 收到SYN(SEQ=" << seg_seq << "), "
<< "发送SYN+ACK(SEQ=" << iss << ",ACK=" << rcv_nxt << "), "
<< "进入SYN-RECEIVED\n";
print_state();
}
}
// 收到 SYN+ACK(客户端处理)
void receive_syn_ack(uint32_t seg_seq, uint32_t seg_ack) {
if (state == TcpState::SYN_SENT) {
// 检查ACK是否合法:SND.UNA < SEG.ACK <= SND.NXT
if (seg_ack == snd_nxt) { // 简化:ack应该等于 ISS+1
rcv_nxt = seg_seq + 1;
snd_una = seg_ack;
state = TcpState::ESTABLISHED;
std::cout << name << ": 收到SYN+ACK(SEQ=" << seg_seq
<< ",ACK=" << seg_ack << "), "
<< "发送ACK(ACK=" << rcv_nxt << "), 进入ESTABLISHED\n";
print_state();
}
}
}
// 收到对SYN的ACK(服务器端:完成三次握手)
void receive_ack_of_syn(uint32_t seg_ack) {
if (state == TcpState::SYN_RECEIVED) {
if (seg_ack == snd_nxt) {
snd_una = seg_ack;
state = TcpState::ESTABLISHED;
std::cout << name << ": 收到ACK(ACK=" << seg_ack
<< "), 三次握手完成, 进入ESTABLISHED\n";
print_state();
}
}
}
// 收到 FIN
void receive_fin(uint32_t seg_seq) {
if (state == TcpState::ESTABLISHED) {
rcv_nxt = seg_seq + 1; // FIN占一个序号
state = TcpState::CLOSE_WAIT;
std::cout << name << ": 收到FIN(SEQ=" << seg_seq << "), "
<< "发送ACK(ACK=" << rcv_nxt << "), 进入CLOSE-WAIT\n";
print_state();
} else if (state == TcpState::FIN_WAIT_1) {
rcv_nxt = seg_seq + 1;
state = TcpState::CLOSING;
std::cout << name << ": 收到FIN(同时关闭), 进入CLOSING\n";
print_state();
} else if (state == TcpState::FIN_WAIT_2) {
rcv_nxt = seg_seq + 1;
state = TcpState::TIME_WAIT;
std::cout << name << ": 收到FIN, 发送ACK, 进入TIME-WAIT(等2MSL)\n";
print_state();
}
}
// 收到 ACK(通用)
void receive_ack(uint32_t seg_ack) {
if (state == TcpState::FIN_WAIT_1 && seg_ack == snd_nxt) {
snd_una = seg_ack;
state = TcpState::FIN_WAIT_2;
std::cout << name << ": FIN被确认, 进入FIN-WAIT-2\n";
print_state();
} else if (state == TcpState::CLOSING && seg_ack == snd_nxt) {
state = TcpState::TIME_WAIT;
std::cout << name << ": 收到ACK, 进入TIME-WAIT\n";
print_state();
} else if (state == TcpState::LAST_ACK && seg_ack == snd_nxt) {
state = TcpState::CLOSED;
std::cout << name << ": 最后的ACK收到, 进入CLOSED, 连接终止\n";
print_state();
}
}
// 2MSL超时(模拟)
void time_wait_timeout() {
if (state == TcpState::TIME_WAIT) {
state = TcpState::CLOSED;
std::cout << name << ": 2MSL超时, 进入CLOSED\n";
print_state();
}
}
};
// ============================================================
// 演示:完整的三次握手 + 正常关闭
// ============================================================
int main() {
std::cout << "========== 三次握手演示 ==========\n";
TcpEndpoint client("客户端", 100); // 客户端ISN=100
TcpEndpoint server("服务器", 300); // 服务器ISN=300
// 服务器先进入LISTEN
server.passive_open();
std::cout << "\n--- 握手第1步:客户端发SYN ---\n";
uint32_t client_isn = client.active_open();
std::cout << "\n--- 握手第2步:服务器回SYN+ACK ---\n";
server.receive_syn(client_isn, 300);
std::cout << "\n--- 握手第3步:客户端发ACK ---\n";
// 客户端收到服务器的SYN+ACK:SEQ=300, ACK=101
client.receive_syn_ack(300, 101); // SEQ=300(服务器ISN), ACK=101(client_isn+1)
// 服务器收到最终的ACK
server.receive_ack_of_syn(301); // ACK=server_ISN+1=301
std::cout << "\n========== 数据传输(略) ==========\n";
std::cout << " 两端都处于 ESTABLISHED,正常发送数据...\n";
std::cout << "\n========== 四次挥手演示 ==========\n";
std::cout << "\n--- 挥手第1步:客户端发FIN ---\n";
client.close_call(); // 客户端主动关闭
std::cout << "\n--- 挥手第2步:服务器回ACK,服务器还有数据可发 ---\n";
server.receive_fin(101); // 服务器收到FIN(SEQ=101)
std::cout << "\n--- 挥手第3+4步:服务器发FIN,客户端回ACK ---\n";
// 服务器应用程序也调用CLOSE
server.close_call(); // 服务器发FIN,进入LAST-ACK
// 客户端收到服务器的FIN(SEQ=301,因为SYN占了300,FIN占了301)
client.receive_fin(301); // 客户端进入TIME-WAIT
// 服务器收到最后的ACK
server.receive_ack(302); // 服务器进入CLOSED
// 客户端等待2MSL后关闭
std::cout << "\n--- 客户端等待2MSL(约4分钟),然后关闭 ---\n";
client.time_wait_timeout();
std::cout << "\n========== 连接完全关闭 ==========\n";
return 0;
}
第十二章:重要的 MUST/SHOULD 要求汇总
RFC 9293 对各功能打了标签(MUST = 必须实现,SHOULD = 强烈建议,MAY = 可选)。
以下是最关键的几条:
| 编号 | 要求 | 级别 |
|---|---|---|
| MUST-1 | 窗口必须作为无符号数处理 | MUST |
| MUST-2 | 发送方必须计算校验和 | MUST |
| MUST-3 | 接收方必须验证校验和 | MUST |
| MUST-8 | 使用时钟驱动的ISN生成器 | MUST |
| MUST-9 | ISN的PRF分量不能从外部计算 | MUST |
| MUST-10 | 支持同时打开 | MUST |
| MUST-13 | TIME-WAIT持续2MSL | MUST |
| MUST-14 | 实现发送和接收MSS选项 | MUST |
| MUST-17 | 应用程序可以关闭Nagle算法 | MUST |
| MUST-18 | 实现Karn算法(RTO计算) | MUST |
| MUST-19 | 实现慢启动、拥塞避免、指数退避 | MUST |
| MUST-36 | 支持零窗口探测 | MUST |
| MUST-40 | 延迟ACK不超过500ms | MUST |
| SHLD-7 | 实现Nagle算法 | SHOULD |
| SHLD-8 | 实现ECN(显式拥塞通知) | SHOULD |
| SHLD-18 | 实现延迟ACK | SHOULD |
| MAY-5 | 可以实现Keep-Alive | MAY |
附录:关键术语中英对照
| 英文术语 | 中文 | 简释 |
|---|---|---|
| TCB | 传输控制块 | 保存一条TCP连接所有状态的数据结构 |
| ISN/ISS/IRS | 初始序列号 | 连接建立时选择的起始序号 |
| MSL | 最大段生存时间 | 一个TCP段在网络中存活的最长时间(2分钟) |
| MSS | 最大段大小 | 单个TCP段可携带的最大数据量 |
| MTU | 最大传输单元 | 链路层一次能传输的最大字节数 |
| RTT | 往返时延 | 一个包从发出到收到ACK的时间 |
| RTO | 重传超时 | 等待ACK的最长时间,超时则重传 |
| cwnd | 拥塞窗口 | 拥塞控制算法维护的发送限制 |
| SRTT | 平滑RTT | RTT的指数移动平均值 |
| SWS | 糊涂窗口综合症 | 由于小窗口导致的效率极低的稳定状态 |
| PMTUD | 路径MTU发现 | 动态发现路径上最小MTU的机制 |
| PAWS | 防序列号回绕 | 用Timestamp选项防止高速连接序列号回绕 |
| SACK | 选择确认 | 精确报告收到了哪些不连续的数据 |
| ECN | 显式拥塞通知 | 路由器在包头打标记通知拥塞,避免丢包 |
| ZWP | 零窗口探测 | 窗口为0时定期发送探测,防止死锁 |
更多推荐

所有评论(0)