TCP 报头全解:序号、确认应答与"可靠性是一个准数"
我的github:(https://github.com/xcx55/ubuntu-linux-project)
感谢各位大佬参观我的github!!
源笔记:TCP底层1/2(26-9-23)、分离的不同:内核和应用层(26-9-23)、TCP底层3(26-9-24)、TCP结构体-序号机制/可靠性本质(26-9-25)、TCP底层4(26-9-26)、缓冲区长这个样子/网络通信辅助图(26-9-26)
UDP 底层看完,轮到 TCP 了。同样是传输层,TCP 报头从 8 字节膨胀到 20 字节起------多出来的每一个字段,都是在为"可靠"付费。这一篇把 TCP 报头逐字段拆开,重点啃两件事:序号机制到底在解决什么 ,以及笔记里最锋利的一句话------可靠性,本质是一个准数。
一、先看报头怎么分离:内核和应用层的分法不一样
复习一下两种分离思路:
- 应用层协议 (自定义协议、HTTP 等):依据特殊字符 分离数据和报头(
\r\n、空行); - 内核传输层 :报头本质是结构体 ------依据传输而来的、提前得知的结构体格式 ,按字节切:报头是本层要使用的数据,有效载荷是要传给上层的数据。
TCP 报文按字节一刀切:前 20 字节是定长报头(+选项),剩下全是载荷。有没有取消对齐都能拿到,因为每个字段的位置是写死的。
二、为什么 TCP 没有"总长度"字段,UDP 却有?
UDP 报头里有一个 16 位 UDP 长度(报头+载荷总长度),TCP 却没有------为什么?
UDP 是数据报:发多少,传多少,收多少 ------长度字段有意义;
TCP 是面向字节流:内部自己敲定一次传多少 ,告诉接收方"这一批多少字节"没有意义------反正每一批都要整体交付,上层一次读取得到的到底完不完整,TCP 协议不保证,由上层自己调控。
带上总长度不是不可以,只是多了字段、会被优化掉------历史遗留问题。所以 TCP 的报文长度 = 20 报头 + n 载荷(UDP 是 8 + N)。
还有一个精妙的设计:TCP 的 20 字节不包含选项 。那选项长度怎么表达?------4 位"首部长度"字段,其数值 ×4 = 报头总长 (20+选项)。没有整体长度,只有头长度------剩下的都是载荷,算都算得出来。
三、可靠性的哲学:100% 可靠的协议根本不存在
先做一道逻辑题:A 给 B 发报文,B 要应答;但 B 怎么知道 A 收到了应答?A 要再确认......逻辑递归,无穷无尽。
结论:
- 互联网里没有 100% 可靠的协议------最新的那条报文,永远无法确认对方收到没有;
- 但历史报文的应答,是 100% 可靠的------你收到了对一个报文的确认,说明它一定到了。
所以 TCP 的策略是:保证数据报文的可靠性即可------让想要保证可靠性的报文,尽快"变为历史"(发出 → 得到应答 → 历史报文 → 确定送达)。
丢包为什么只能"规定",不能"根除"
丢包有两种情况:1、数据没传到对面;2、返回的应答丢了 。站在发送方视角,这两种情况的表现一模一样------都收不到应答。发送方永远无法区分是"数据丢"还是"应答丢"。
这么多人想不到别的办法吗?不是想不到,是逻辑上无解。所以 TCP 干脆换了个定义:
TCP 不是保证"数据一定被对方收到",而是保证"发送方对一个确定的结果有准数"------收到了应答就继续发下一个;没得到应答,就执行其他策略(重传)。
可靠性 = 准数。这是整套 TCP 机制的地基。
四、32 位序号:一个字段,四个用途
为了配合"准数",序号(seq)登场。它一个字段身兼数职:
- 去重 :应答丢了会触发重传,接收方会收到重复报文------32 位序号的核心用途之一就是去重;
- 按序排列 :双方并行收发效率高,但并行造成乱序------接收方按序号升序 排回去(这也解释了为什么序号要 32 位:序号必须够多);
- 捎带应答的基础 :TCP 双方地位对等(对称),发送和应答的字段是解耦的------任何一方都可能既发数据又捎带应答;
- 配合重传优化:见下面的确认序号。
每个字节都有编号
辅助图里的比方很传神:每一个字节,就是一个从 1 开始编号的"躺着的人"。
- 序号 = 当前报文数据第一个字节的编号;
- 算个账:报文载荷 3 字节、seq=2000 → 覆盖 2000、2001、2002(最后字节编号 2002)→ 下一个想要的字节编号是 2003------接收方等的就是它。
五、确认序号:一个天才约定
确认序号(ack)= 发出序号 + 1 ,含义是:我想要的下一个字节的序号,从这开始。
它的威力在批量发送时显现:
发送方发出: 1000 2000 3000 4000
收到应答: 1001 2001 4001
↑
3001 没来 → 3000 那个报文丢了
一个应答缺口,直接定位丢了哪一段 ------发送方不用全部重传,只补缺口。而且这是接收方和发送方共同遵守的约定,返回的确认序号本身就是一种"报文序号规定",有效减少发送方的重发次数。
六、16 位窗口大小:可靠性还有另一半------对面的内存
TCP 缓冲区长这个样子(发送缓冲区/接收缓冲区,都是字节编号的队列)。现在想一个问题:
接收方内存快满了,TCP 发来的数据好不容易到了,操作系统却不要了------是不是效率太低?你只能靠应答让对方反复重传,更慢。
所以设计了流量控制:
- 接收方衡量自己的接收能力 = 接收缓冲区的剩余空间大小;
- 怎么告诉发送方?应答报文里的 16 位窗口大小字段;
- 注意方向:它填写的是发送方视角的对方容量 ------我构建的报文都是发给对方的,所以我必须持续衡量对面 的接收容量,动态调整发送流量;
- 不能太慢,也不能太快------既保效率,也保准数。
笔记里的感悟值得原样保留:在我看来,可靠性不止体现在报文安全,更体现在对面主机内存安全这另一面! 流量控制,护的是收方的内存。
七、6 个标志位:给报文定型
20 字节报头里还有 6 位标志位,用 bit 位区分报文类型:
- ACK :应答报文(普通应答 / 捎带应答------捎带是常数级的包传递次数优化,传递报文本身属于 IO,能减少次数最好);
- SYN :建立连接(三次握手)------TCP 发数据前必须先建连接,因为创建连接是有成本的 :双方 OS 里都要维护
struct tcp_sock结构体,要为可靠性做大量载入和管理工作; - FIN:关闭连接(四次挥手)------断开往往是一方一厢情愿,另一方"不行,我还有数据";
- RST:和四元组相关,连接出问题时重置(后面细说);
- PSH :触发中断,在软件层立即唤醒 task_struct 调度运行------让接收进程马上来读缓冲区里已经连接好的数据;
- URG + 16 位紧急指针 :最少见的一对。URG 是开关,紧急指针本质是偏移量 ------"广义上,具有指向性的东西都可以叫指针"。它指向本次报文载荷中的紧急数据 (带外数据),可以不被按序、优先读取,接收它的函数也不同于 read。
紧急数据为什么存在?为了插队 ------比如"取消上传"这种控制命令,必须优先于普通缓冲区数据抵达对面。但笔记也补了句大实话:一般用得少,建立两个 fd 不就完了?
总结
- 报头分离:应用层靠特殊字符,内核靠结构体字节布局;TCP 报文 = 20 报头(不含选项)+ n 载荷;
- UDP 带总长因为它是数据报;TCP 不带因为字节流内部自己定,只有"首部长度×4";
- 没有 100% 可靠协议 ------最新报文永远无法确认;TCP 保证的是"历史报文可靠":可靠性 = 准数;
- 丢包无法区分数据丢还是应答丢 → TCP 定义改为"发送方得到确定结果";重传导致重复,seq 去重 ;并行导致乱序,seq 排序 ;序号是第一个字节的编号,确认序号 = 想要的下一个编号,一个缺口定位一段丢失;
- 16 位窗口 = 流量控制:填的是对面的接收能力,动态调整------可靠性还有"对面内存安全"这一半;
- 6 标志位:ACK/SYN/FIN/RST/PSH/URG+紧急指针(紧急数据 = 插队的带外数据)。
下一篇:把这些字段用起来------三次握手到底在验证什么、四次挥手为什么要四次,以及 accept/connect 为什么"不参与"握手。