首部的本质是「把必要的信息写在明面上」,因为对端没有别的渠道知道。 TCP 的 20 个字节里,序号与确认号各占 4 字节——可靠性是有重量的。
2.4 讲完了四层各自管什么,2.5 用一次完整通信把它们串起来。 这一节先看首部,下一节看发送、接收。
一、为什么要有首部
因为「上下文」不能共享,只能每次带上:
| 首部要回答的 | 写在哪 | 不写会怎样 |
|---|---|---|
| 给哪个程序 | 端口号 | 不知道交给谁 |
| 第几个字节 | 序号 | 乱序了没法拼 |
| 收到没有 | 确认号 | 不知道要不要重传 |
| 还能装多少 | 窗口 | 发太快会撑爆对端 |
这四行解释了「为什么 TCP 头是 20 字节而不是 4 字节」:可靠性不是免费的,它要占字段。 UDP 只保留第一行(端口),所以只要 8 字节——它不打算回答后面三个问题。 第四行那个「窗口」是最容易被忽略的: 它是「流量控制」的全部机制——接收方通过窗口字段告诉发送方「我还能收多少」, 发送方按这个数调整发送速度。 没有它,快发送方会撑爆慢接收方。
二、TCP 头逐字段
九个字段,正好 20 字节:
| 字段 | 位(十六进制) | 本 Demo 的值 |
|---|---|---|
| 源端口 | 4 | 40000 |
| 目的端口 | 4 | 80 |
| 序号 | 8 | 1000 |
| 确认号 | 8 | 5000 |
| 数据偏移 + 保留 | 2 | 0x50 |
| 标志位 | 2 | 0x18 |
| 窗口 | 4 | 65535 |
| 校验和 | 4 | 0x0000 |
| 紧急指针 | 4 | 0 |
第二列的单位是「十六进制位数」,两位是一个字节: 所以序号占 8 位十六进制,等于 4 字节、32 位。 九个字段加起来 40 位十六进制,等于 20 字节。 第三、四行那两个 4 字节是「可靠性」的成本: 序号让你能拼回顺序,确认号让对端知道该重传哪一段。 两者合起来占了头部 40%,这就是「可靠传输要付的字节价」。 第五行那个 0x50 也有讲究:高 4 位是「数据偏移」,单位是 4 字节,5 乘 4 等于 20, 表示头部长度 20 字节。 所以 TCP 头最长可以到 60 字节(15 乘 4),多出来的 40 字节是选项(时间戳、窗口缩放等)。
三、标志位是八个开关
一个字节里挤八个开关,每个管一件事:
| 位 | 名字 | 含义 |
|---|---|---|
| 0x01 | FIN | 我要关了 |
| 0x02 | SYN | 我要建连 |
| 0x04 | RST | 立刻重置 |
| 0x08 | PSH | 别缓存,立刻上交 |
| 0x10 | ACK | 确认号有效 |
| 0x20 | URG | 紧急指针有效 |
第四行那个 PSH 是「实时性」的开关:默认 TCP 会攒一点数据再发(Nagle 算法),
加了 PSH 就表示「这段别攒,立刻交上去」。
所以「聊天软件按键立刻显示」靠的就是它。
这八个开关能组合:0x18 就是 ACK 加 PSH,
表示「我确认你的数据,同时请你立刻上交我这批」。
0x12 是 SYN 加 ACK,就是「握手第二步」那个包。
读标志位必须按位与,不能当整数比:flags & 0x02 判断是不是 SYN,
写成 flags == 0x02 就会漏掉 0x12 这种情况——这是解析网络包最常犯的错之一。
四、动手 Demo:把 TCP 头 20 个字节拆开
造一个 TCP 头,逐字段读出来。 注意「十六进制位数」这一列:两位就是一个字节。
import struct, _netsim as N
seg = N.tcp4h(40000, 80, seq=1000, ack=5000, flags=0x18)
sp, dp, sq, ak = struct.unpack('!HHII', seg[:12])
print('源端口 %d' % sp)
print('目的端口 %d' % dp)
print('序号 %d' % sq)
print('确认号 %d' % ak)
print('数据偏移 0x%02X' % seg[12])
print('标志位 0x%02X' % seg[13])
print('窗口 %d' % struct.unpack('!H', seg[14:16])[0])
print('校验和 0x%04X' % struct.unpack('!H', seg[16:18])[0])
print('紧急指针 %d' % struct.unpack('!H', seg[18:20])[0])
实跑输出:
--------------------------------------------------------------------
把 TCP 头 20 个字节拆开
--------------------------------------------------------------------
**数据包首部到底长什么样,拆一遍最清楚。**下面造一个 TCP 头,逐字段读出来。
字段 十六进制位数 本 Demo 的值
--------------------------------------------------
源端口 4 40000
目的端口 4 80
序号 8 1000
确认号 8 5000
数据偏移 + 保留 2 0x50
标志位 2 0x18
窗口 4 65535
校验和 4 0x0000
紧急指针 4 0
**第二列的单位是「十六进制位数」,两位是一个字节**:**所以序号占 8 位十六进制 = 4 字节 = 32 位。**
**九个字段加起来正好 40 位十六进制 = 20 字节。**
**校验和那一行是 0x0000,因为这里没算**:**TCP 校验和要把「伪首部」(含源与目的 IP)一起算进去**,
**本 Demo 只造了 TCP 头、没传 IP,所以留了 0。**
**这也说明「传输层校验」其实依赖网络层的信息**——**分层是逻辑上的,实现时层与层之间仍有耦合。**
**第六行那个 0x18 是两个标志位叠在一起**:**0x10 是 ACK、0x08 是 PSH**,
**所以这次既确认了对端、又要求立刻上交数据。**
**标志位是「一个字节里挤八个开关」**,**读的时候要按位与,不能当整数比。**
**序号与确认号都是 32 位,所以最大约 43 亿**:
**超过就回绕到 0**——**这就是为什么高速长连接的序号回绕是个真问题(PAWS 就是治它的)。**
九个字段读出来的值,和构造时传进去的完全一致:40000、80、1000、5000、0x18、65535。 这说明「手工造包」这条路是通的——协议不是什么神秘的东西,就是按位置填字节。 数据偏移是 0x50,高 4 位是 5:5 乘 4 等于 20,所以头部 20 字节。 读这个字段要先右移 4 位,再去掉高位的版本信息。 校验和是 0x0000,因为这里没算:TCP 校验和要把「伪首部」(含源与目的 IP)一起算进去, 本 Demo 只造了 TCP 头、没传 IP,所以留了 0。 这也说明「传输层校验」其实依赖网络层的信息——分层是逻辑上的,实现时层与层之间仍有耦合。
30 秒实验
- 把
flags改成 0x12,看输出变成什么(那是握手第二步的标志)。 - 把
seq改成 2 的 32 次方减 1,看会不会报错——想想序号回绕。 - 算一下:如果头部全填满选项(60 字节),数据偏移字段应该是多少?
常见误区
- 以为首部是「额外开销」。 它是「必要的信息」,没有它对方根本不知道该怎么处理这些字节。
- 以为标志位是「一个整数」。 它是八个独立开关,必须按位与判断,不能整体比较。
- 以为 TCP 头固定 20 字节。 最短 20,最长 60,多出来的是选项(时间戳、窗口缩放)。
自测三问(点开对答案)
1. TCP 头里哪两个字段占了 40% 的空间?为什么值得? —— **序号与确认号,各 4 字节。**序号能拼回顺序、确认号能指示重传,这就是可靠性的字节代价。 2. 0x18 这个标志位表示什么? —— **ACK(0x10)加 PSH(0x08)**:既确认对端的数据,又要求立刻上交我这批数据。 3. 为什么读标志位不能写成 `flags == 0x02`? —— **因为标志位可以组合**:0x12 也含 SYN,写等于就会漏掉。必须用按位与 `flags & 0x02`。下一节看这些首部是怎么被一层层加上去的。
- ☐ (本节完,下一节 2.5.2 发送数据包)