很多学网络的朋友拿到TCP报文段首部格式,第一反应是找一张字段表开始背:源端口16位、序号32位、确认号32位……背完合上书又问,抓包为什么还是看不懂?我自己的经验是,不要背字段,把TCP报文段的首部当成一张按位布局的二进制地图。你只要知道每个字段占几位、为什么占这么多位,看到一串十六进制也能当场推出来:这是握手包还是数据包,窗口多大,协商的MSS是多少。这篇就按我实际抓包分析时的思路,把TCP首部格式从头到尾拆一遍。适合刚学计算机网络基础的学生,也适合日常跟网络抓包打交道的运维和开发。读完你会得到两个东西:一个可以对照Wireshark逐字段核对的阅读方法,一套遇到异常连接时快速看首部字段定位问题的习惯。
1. 先建立整体认知:为什么TCP首部从20字节起步
1.1 最小20字节是怎么凑出来的
TCP首部不是拍脑袋定的长度。TCP要独立于IP工作,它必须自己携带足够信息来描述通信双方、字节流位置、控制标志和流量控制窗口。把这些信息排下来,最低限度就是:端口4字节、序号4字节、确认号4字节、数据偏移和标志位2字节、窗口2字节、校验和2字节、紧急指针2字节。加起来正好20字节,也就是十六进制的0x14。这是所有TCP报文段的基准长度,任何TCP包都不会小于这个值。
为什么这些字段一个都不能少?你可以把TCP首部想象成快递面单上的必填项:源端口和目的端口是发件人和收件人电话,序号和确认号是包裹编号和签收备注,窗口是仓库容量寄存说明,校验和是货品总价核验,标志位是这票货是正常送达还是退回重发。少了任何一项,快递都送不到手上,TCP的可靠性、连接管理或流量控制机制就缺了一条腿。UDP不用这么复杂,所以它的首部只有8字节,但UDP也因此不负责可靠传输、不管理连接、不做流量控制——你拿8字节和20字节对比着看,就能理解TCP牺牲带宽和头部开销,换来的是什么能力。
这里有个很实际的问题:既然TCP首部可长可短,协议栈怎么知道当前这个包的首部到底占多长?靠的是第13字节的高4位,也就是“数据偏移”字段。数据偏移的单位是4字节,如果值是5,就表示首部5×4=20字节;如果值是15,就表示首部15×4=60字节,这是上限。后面我会专门展开这个字段,先记住结论:TCP首部长度介于20到60字节之间,变化来自选项字段。
1.2 头部长度为什么要用“数据偏移”表达
“数据偏移”这个叫法容易让人困惑,因为它其实是在说“首部长度”:TCP首部后面紧跟数据,这个字段告诉协议栈,数据从哪里开始。为什么不直接叫“长度”?因为TCP头长度不固定,IP头长度也不固定,IP首部里同样有一个IHL字段。协议设计者把“数据从第几个字开始”和“首部占几个字”看成同一个信息,用偏移量来表达长度,这样接收方解析时逻辑最简单:先跳过IP头,再跳过TCP头,剩下的全是应用数据。
关键的槽点是:数据偏移只占4位,最大是15,但TCP最小首部是20字节。如果单位是1字节,4位最多表示15字节,根本不够用。所以设计单位必须放大,最终选择了4字节。为什么是4字节而不是2字节或8字节?因为整个TCP/IP协议栈从设计之初就强调32位对齐。IP头、TCP头都希望落在4字节边界上,CPU读写内存时按32位访问最舒服,协议栈在做内存拷贝和字段解析时也能省不少指令。4字节为单位还有一个好处:20到60字节的长度区间,用4位二进制就能完整表达,不浪费位宽。
从抓包的角度看,数据偏移字段是解析TCP头时第一个要看的。Wireshark里显示为“Header Length: 32 bytes”,其实就是把数据偏移值乘了4。我在实际排障中见过有人把20字节的普通ACK包读成32字节,原因就是没注意数据偏移字段的值不是字节数,而是“4字节块数”。20字节时值是5,32字节时值是8,这两个数字很容易混淆,后面常见误区里我会再提一次。
2. 逐位拆开固定字段:端口、序号与确认号
2.1 端口字段:16位容量与连接标识
TCP首部最前面两个字段是源端口和目的端口,各占16位。端口号范围0到65535,这个上限完全由二进制位数决定:2的16次方。设计者选16位,在协议诞生年代是一个很自然的选择,一个整数刚好两个字节,不占空间,而且几万个端口在当时的网络规模下完全够用。0到1023是知名端口,比如80、443、22,通常需要特权才能监听;1024到49151是注册端口;49152到65535是动态/临时端口,客户端发起连接时一般从这里选一个源端口。
端口字段和IP地址一起,构成了连接标识的关键部分。一个TCP连接通常用四元组描述:源IP、源端口、目的IP、目的端口;严谨一点再加上协议编号,就是五元组。这里有个非常常见的误解:以为服务器监听80端口就只能处理一个连接。不对。服务器的监听端口80是固定的,但每个连接还有客户端那一侧的源IP和源端口参与区分。所以一台服务器同时挂几万、几十万个80端口连接是完全正常的。
反过来,客户端机器做压力测试时经常遇到“端口不够用”的问题。一个客户端对外访问,源IP是固定的,可用的临时端口区间有限,比如Linux默认的net.ipv4.ip_local_port_range通常是32768到60999,也就两万多个端口。如果发起大量短连接并且不处理TIME_WAIT状态,端口可能被迅速占满,表现就是“Cannot assign requested address”。这不是TCP首部字段不够,而是连接四元组的组合空间被临时端口区间限制住了。理解了端口16位和四元组的关系,这类问题一眼就能定位。
2.2 序号与确认号:字节流协议的两根坐标轴
TCP最核心的设计,是把应用层数据看成一条连续的字节流,而不是一堆独立的报文。这个视角直接决定了序号字段的语义。序号占32位,表示“本报文段第一个数据字节在发送字节流中的编号”。初始序号不是0也不是1,而是一个随机值,叫ISN。为什么随机?如果不随机,攻击者可以通过预测序号来伪造TCP包、往连接里注入数据。现代操作系统生成ISN时会结合时间和加密算法,让外界无法猜测。
序号有一个特别容易忽略的规则:SYN和FIN各消耗一个序号。这个规则从抓包里看最明显。客户端发SYN包,假设seq=1000,这个包没有携带任何应用数据,但服务器返回的ACK包的确认号必须是1001,而不是1000。因为SYN本身占了一个字节的“逻辑位置”,服务器期望客户端下一个发送的字节编号是1001。FIN包同理。很多人第一次抓包时看到带FIN的包比不带数据的包还多占一个序号,会一脸疑惑,其实这是TCP专门设计的,目的就是让连接建立和释放过程也能参与可靠确认。
确认号字段也是32位,它的语义是“期望收到对端下一个字节的序号”。注意,不是“最后收到的字节序号”。如果B返回ack=2000,意思是我已经收到1999以及之前所有字节,现在请你从2000开始发。这种机制叫累计确认:接收方不需要给每个包单独发确认,只要把期望的下一字节序号报出来,发送方就知道之前的数据全部到达了。一旦某一个包丢了,接收方收到后面的乱序包时,确认号会一直停在缺失位置,发送方靠超时重传或者收到多个重复ACK来感知丢包,触发快速重传。
给一个具体的例子:A发送seq=2000,payload是10字节;B收到后返回ack=2010。如果B把ack写成2000或者2009,发送方就没法判断正确边界。所以抓包时看TCP信息栏里的Seq和Ack,一定要结合相对序号理解:Wireshark为了照顾人眼,通常显示“relative sequence number”,把ISN归零,这样第一个SYN包显示Seq=0,后续包显示Seq=1、11之类的相对值。实际线上排查跨设备抓包时,一定要关掉相对序号看绝对序号,否则两边各算各的,根本对不上。
2.3 数据偏移字段:4位如何表达20到60字节
数据偏移字段排在第13字节的高4位,它的值是首部长度除以4。最小值是5,对应20字节;最大值是15,对应60字节。前面说过,为什么不是以字节为单位?因为位数不够且要考虑32位对齐。这里再补充一个实用角度:协议栈解析时,看到数据偏移值是5,就知道后面没有选项,直接进入数据;看到值是6或7,就知道有4到8字节的选项需要解析。选项字段最多40字节,加上固定20字节,刚好60字节封顶,这个设计把整个首部长度压进了一个4位字段里,非常精巧。
抓包验证也很直观。一个普通的TCP ACK包,通常没有选项或者只有时间戳选项,Header Length显示“20 bytes”或“32 bytes”。而SYN包因为要协商MSS、窗口缩放、SACK、时间戳,Header Length经常是“44 bytes”或“60 bytes”。数据偏移字段的值和首部字节数之间的关系我是这样记的:看到Wireshark里Header Length是44字节,就立刻除以4得到11,数据偏移字段的二进制就是1011。自己写协议解析器时,从原始字节流里读这个值,再乘4,拿到真实长度,千万别直接拿这个值当字节数用。
3. 连接的控制中枢:标志位、窗口与校验和
3.1 标志位:从6位经典标记到9位扩展
标志位是TCP首部里最“热闹”的地方。经典教材会告诉你保留6位、标志位6位:URG、ACK、PSH、RST、SYN、FIN。但你在Wireshark里看到的实际报文,标志位往往不止6个,还会有NS、CWR、ECE。这是后来RFC扩展的结果:原本的保留位被拿出来一部分用于显式拥塞通知。所以严格来说,现在的TCP头标志区是:NS、CWR、ECE、URG、ACK、PSH、RST、SYN、FIN,共9位,保留位只剩3位。读老代码或者老文档看到“保留6位”不要奇怪,那是当时的历史版本。
这些标志位各自管什么,我直接列一张表:
| 标志位 | 置位含义 |
|---|---|
| URG | 紧急指针字段有效,表示有紧急数据需要优先处理 |
| ACK | 确认号字段有效,除SYN外几乎所有包都要置位 |
| PSH | 接收方应尽快把数据交给应用层,不要等在缓冲区里 |
| RST | 异常重置连接,常见于端口未监听、协议异常 |
| SYN | 同步序号,用于建立连接 |
| FIN | 发送方数据发送完毕,请求释放连接 |
| CWR | 发送端已降低发送速率,回应ECE通知 |
| ECE | 接收端反馈网络出现拥塞,通知对端降速 |
| NS | ECN机制里的随机数校验位,普通排障很少关心 |
三次握手在标志位上的表现是:客户端发SYN,服务器回SYN+ACK,客户端再回ACK。四次挥手则是A发FIN,B回ACK,B再发FIN,A回ACK。RST最特殊,它不是正常流程,而是随时可能出现的“中断开关”:你访问一个没人监听的端口,对方内核直接回RST;中间设备发现连接非法,也可能注入RST。抓包时看到RST不要慌,先看带没带ACK,再看序号对不对,这是后面排障一节的重点。
PSH标志值得一提,因为它经常被误解。老教材说“PSH表示立即上送应用层”,实际现代协议栈几乎不依赖PSH来决定交付时机,因为TCP默认就会按序把数据交给应用。PSH更多是发送端提示“这段数据请别攒着”,常见于交互式协议里,比如SSH。你抓SSH流量会看到大量PSH+ACK包,但HTTP大文件下载里PSH很少。这个标志对普通应用的影响没有教科书渲染的那么大。
3.2 窗口字段:16位上限与窗口缩放
窗口字段占16位,最大65535,含义是“接收方当前还能接收的字节数”,即通告窗口。它属于流量控制:接收方根据自己缓冲区剩余空间,告诉发送方“你最多再发这么多,多了装不下”。发送方收到这个值后,把当前未确认的数据量控制在窗口范围内,就不会把接收方缓冲区撑爆。
很多人在这一步犯迷糊:窗口字段和拥塞控制有什么关系?其实TCP首部里根本没有拥塞窗口字段,拥塞窗口是发送方自己维护的状态变量,不随报文传输。通告窗口是接收方告诉发送方“别超过我的容量”,解决的是两端处理速度不匹配;拥塞窗口是发送方探测网络能承载多少数据,解决的是链路中间节点的承载能力。两个窗口取较小值,才决定实际发送速度。
问题来了:65535字节的窗口上限,在1981年很够用,但在今天的网络环境下远远不够。拿10Gbps链路举例,RTT如果是20毫秒,带宽时延积大约是10Gbps×0.02s=25MB。窗口只有64KB时,即使链路再宽,发送方也只能每200毫秒发64KB数据,吞吐被死死摁在不到3Mbps。所以TCP后来增加了窗口缩放选项:用一个shift count(0到14)把16位窗口值左移,实际窗口=窗口字段值×2^shift。比如窗口字段是64240,缩放因子是7,实际通告窗口就是64240×128≈8.22MB。Wireshark显示的“Calculated window size”就是做了这个换算之后的值。
抓包时一定要关注窗口缩放。两端在握手SYN包中互相通告自己的缩放因子,如果有一方不支持,另一方就默认不用缩放,窗口就退回64KB上限。我曾经在某个跨平台系统的对接里遇过吞吐上不去的问题,两边带宽都很足,抓包一看,一端SYN包里没有窗口缩放选项,而另一端通告的窗口高达数MB,协商失败后实际窗口按64KB处理,吞吐自然起不来。这类问题,只看Window字段不看缩放因子,是永远找不到原因的。
3.3 校验和:别忽略那个“伪首部”
校验和字段16位,作用是检测TCP首部和数据在传输过程中有没有损坏。但TCP校验和覆盖的范围比你想的大得多,它不只是计算TCP首部,而是计算三块内容:12字节的伪首部、TCP首部、TCP数据。伪首部由源IP、目的IP、保留0字节、协议编号(IPv4下是6)、TCP报文段总长度组成,最关键的是:伪首部只参与计算,不会真的随包发送。
为什么要把IP层的信息拉进来算校验和?因为TCP校验和不仅要保护自己这段内容,还要防止IP层把包投错地方。如果源IP、目的IP或者协议号在传输中被改错了,接收方的TCP校验和就会对不上,从而丢弃这个包。这相当于让TCP给IP层也做了一层完整性兜底。IPv6下的伪首部更长,包含更多地址位和下一个头字段,原理完全一样。Linux内核里计算校验和时会通过一个辅助函数把伪首部拼接进来,从协议栈实现角度,这个设计增加了代码复杂度,但换来了跨层的完整性保护。
计算过程很简单:发送方先把校验和字段置0,然后把整个覆盖范围按16位一组划分,不足16位的尾部补0,对每一组做反码求和,最后把和取反,写入校验和字段。接收方收到后,对同样的范围再做一次同样计算,结果应该是全1(0xFFFF),否则说明包在传输中损坏,直接丢弃。这个算法在CPU上做起来极快,现代网卡还支持硬件计算卸载。我在看协议栈代码时最喜欢提醒别人:计算范围里那个伪首部,是TCP校验和容易写错的地方,自己手写解析器只按TCP段算校验和,算出来永远是错。
4. 选项字段:可变部分才是现代TCP真正厉害的地方
4.1 常见选项速览:MSS、窗口缩放、SACK与时间戳
固定20字节只能保证TCP“能工作”,要跑出高性能,全靠选项字段。选项位于固定首部之后、数据之前,总长最多40字节,每个选项由“类型+长度+值”组成,某些填充选项只有一个字节。我把抓包时最常见的几个选项整理成一张表:
| 选项类型 | 名称 | 长度 | 作用 |
|---|---|---|---|
| 1 | NOP | 1字节 | 空填充,用于32位对齐 |
| 2 | MSS | 4字节 | 协商最大报文段长度 |
| 3 | Window Scale | 3字节 | 通告窗口缩放因子,范围0到14 |
| 4 | SACK-Permitted | 2字节 | 表示本端支持选择性确认 |
| 5 | SACK | 可变(8+8n字节) | 报告收到的不连续数据段 |
| 8 | Timestamp | 10字节 | 测量RTT并防止序号回绕 |
NOP是最不起眼却最常用的选项,它的作用纯粹是占位。因为TCP要求选项整体按4字节边界对齐,如果前面几个选项加起来的长度不是4的倍数,就用NOP把尾巴补齐。比如MSS选项4字节、SACK-Permitted选项2字节,这俩加起来6字节,后面通常就跟一个NOP再加时间戳10字节,凑成16字节。Wireshark里显示为一串“NOP, NOP”的包,就是对齐用的,不用在意。
MSS选项每次握手都会出现,它表示本端能接受的最大TCP数据段长度。标准以太网环境下MSS通常协商为1460,这个数字怎么来的:以太网MTU是1500字节,减去IPv4首部20字节,再减TCP首部20字节,剩余数据最多就是1460。如果中间链路MTU是1400,MSS就会变成相应较小的值。SYN包里的MSS是“提议值”,接收方会在自己的MSS和对方的MSS里取较小者作为这条连接的实际发送上限。所以抓包看到两端MSS不一致很正常,实际生效的是小值。
4.2 选项协商如何影响大流量传输
窗口缩放、SACK和时间戳这三个选项,直接决定了一条TCP连接能不能在长肥网络(高带宽、高延迟)上跑出吞吐。先看时间戳:它有两个值,TSval和TSecr,发送方在包里放当前时间,接收方原样回显,发送方就能算出精确的RTT。同时,时间戳还能防止序号回绕带来的歧义——32位序号在高速链路上很快绕一圈,比如10Gbps下大约每3.4秒就回绕一次,如果没有时间戳,接收方很难区分旧包和新包。
SACK的作用是解决“一个包丢了,一堆后续包跟着遭殃”的问题。传统累计确认下,如果接收方收到100到199和300到399的数据,但丢了200到299这一段,它只能反复确认“下一个期望是200”,发送方要等到超时才知道到底该重传哪些。开启了SACK之后,接收方可以明确告诉发送方“我收到了100-199和300-399,缺口在200-299”,发送方只重传缺口部分,效率提升非常明显。所以在抓包时看到一个SYN包里带SACK-Permitted,基本可以判断这条连接对丢包有不错的恢复能力。
窗口缩放和这三个选项必须都在三次握手阶段协商完成,这解释了为什么抓包时SYN包总是带着一堆选项。如果任一方没有在SYN包里通告某个能力,后续就不允许再使用。这也是TCP“端到端协商”思想的一个体现:双方能力不同、路径条件不同,必须在连接建立时就把约束谈好,之后数据传得再快也不能突破协商结果。我建议每个做网络性能排查的人都养成一个习惯:先看握手阶段的SYN包选项,就能提前判断这条连接的上限。如果SYN里MSS是1460、窗口缩放是7、SACK和时间戳齐全,这条连接的底子就是好的;如果某个选项缺失,后面出现吞吐异常、乱序重传高企就有了解释方向。
5. 实操验证:用抓包把首部格式“解剖”一遍
5.1 抓包准备与过滤表达式
纸上谈兵讲再多,不如自己抓一个包看一眼。准备一台能联网的机器,安装Wireshark,或者用命令行工具tcpdump也可以。抓本机回环流量最简单,因为不需要过滤网卡混杂模式,权限也容易满足。先开着终端抓包,再在浏览器里访问一个普通网站,就能看到完整的TCP三次握手。
最常用的tcpdump命令是这样:
sudo tcpdump -i eth0 -nn 'tcp port 443' -c 10如果想把原始报文保存下来放到Wireshark里慢慢看,加一个-w参数:
sudo tcpdump -i eth0 -nn -w /tmp/tcp.pcap 'tcp port 443'在Wireshark里打开文件后,过滤表达式按需求写。只想看TCP包,就输入tcp;想只看SYN包,输入tcp.flags.syn == 1;只看出站方向的SYN包,再叠加一个条件:tcp.flags.syn == 1 && tcp.flags.ack == 0。这个组合过滤很关键,因为SYN+ACK包同时满足syn=1和ack=1,如果你只看syn==1,会把服务端的响应也混进来。正确的是用“syn=1且ack=0”表达“主动发起连接的包”,用“syn=1且ack=1”表达“被动确认并同步的包”。
抓包时要注意一个细节:在Wireshark里看到的TCP信息栏是一行摘要,默认显示相对序号、相对确认号,还会自动算出窗口大小。这些信息非常方便,但如果你想练解读首部的硬功夫,建议右键选择“Protocol Preferences”里关掉相对序号,或者直接看原始字节流。Wireshark底下有Hex pane,把光标点到TCP头部,就能逐字节看到十六进制和对应的字段高亮。这一块的联动,是搞懂首部格式的最佳练习场。
5.2 一个SYN包的逐字段对照
我拿一个典型的出站SYN包作为例子,把它展开成一张表格。为了描述方便,我们假设客户端用端口52000访问服务器的443端口,源IP和目的IP用文档专用的保留地址段,不影响理解。
| 字段 | 抓包显示值 | 解读 |
|---|---|---|
| Source Port | 52000 | 客户端临时分配的源端口 |
| Destination Port | 443 | HTTPS服务端口 |
| Sequence Number | 相对值0 | 一个随机ISN的起始位置 |
| Header Length | 44 bytes | 数据偏移字段值是11 |
| Flags | 0x002 | 只有SYN置位,没有ACK |
| Window | 64240 | 接收缓冲区通告值 |
| Checksum | 0x8f5e | 发送方计算后的校验值 |
| [MSS] | 1460 | 本端建议的最大段长度 |
| [Window Scale] | 7 | 实际窗口左移7位 |
| [SACK Permitted] | True | 支持选择性确认 |
| [Timestamp] | TSval=..., TSecr=0 | 首次握手没有回显值 |
为什么Header Length是44字节?固定首部20字节,加上MSS选项4字节、Window Scale选项3字节、SACK-Permitted选项2字节、时间戳选项10字节,几个数字加起来:4+3+2+10=19字节,不是4的倍数,于是中间插入若干NOP填充,凑成20字节的选项区,最后固定20+20=44字节。你从原始十六进制里看数据偏移字段的值,应该是11,也就是二进制1011。
再看Flags的值0x002是怎么来的。TCP标志字段按位展开,从高位到低位依次是NS、CWR、ECE、URG、ACK、PSH、RST、SYN、FIN。SYN位处在倒数第二位,对应的二进制权值是0x02,ACK位权值是0x10。所以一个纯SYN包的flags显示为0x002,一个SYN+ACK包显示为0x012,一个纯ACK包显示为0x010。记住这个位权表,你在十六进制里看到一个0x012,立刻能反应出这是握手的第二个包,不需要把鼠标悬停在Wireshark的Flags字段上。
5.3 从首部字段快速定位握手异常
实际排障中,抓包不是看热闹,而是要在几秒内判断连接卡在哪一步。我总结了一套看首部的顺序:先看Flags,确认这是哪个阶段的包;再看Seq和Ack,确认双方序号是否对得上;然后看窗口和选项,判断性能上限;最后才看IP层有没有丢包。
最常见的场景是“只看到出站SYN,没看到SYN+ACK”。这有两种可能:要么SYN包根本没到对方,要么对方回了但回包被防火墙丢掉。这时候看SYN包的重传情况:TCP会重传SYN,第一次约1秒后重试,指数退避。如果多次重传都石沉大海,多半是路径上有设备静默丢包,或者目的端口根本没监听。可以用tcpdump在服务器侧同时抓包,一眼就能判断是入向丢还是出向丢。这个“两端同时抓包对照”的操作,比在客户端单侧猜半天高效得多。
再一种场景是握手完成了,但连接立刻被RST干掉。这时要看RST包的序号和确认号。如果RST.ACK=1,并且确认号是对方期望的正确值,说明是服务器主动拒绝——端口未监听、应用层拒绝或安全策略未通过;如果RST包的ACK标志为0,且序号很诡异,很可能是中间设备伪造注入的reset,用来干扰连接。这两种情况处理思路完全不同:前者查应用和防火墙策略,后者要排查链路设备是否有流清洗策略。判断依据都在首部那几位标志里,熟悉了就不用瞎猜。
6. 常见问题与排障经验实录
6.1 首部字段速查表
把TCP首部所有字段汇总成一张速查表,排查时可以直接对照。注意这里列出的字节位置是从TCP头起始处计算的,不是从IP头起始处。
| 字节范围 | 字段 | 位数 | 作用与常见值 |
|---|---|---|---|
| 0-1 | 源端口 | 16 | 客户端临时端口,常见32768-60999 |
| 2-3 | 目的端口 | 16 | 服务监听端口,如80、443 |
| 4-7 | 序号 | 32 | 第一个数据字节的流位置,SYN/FIN各占一个序号 |
| 8-11 | 确认号 | 32 | 期望收到的下一个字节序号,累计确认 |
| 12 | 数据偏移 | 4 | 首部长度/4,最小5,最大15 |
| 12 | 保留位 | 3 | 目前保留未用,通常为0 |
| 12-13 | 标志位 | 9 | NS、CWR、ECE、URG、ACK、PSH、RST、SYN、FIN |
| 14-15 | 窗口 | 16 | 接收方通告窗口,实际值需结合缩放因子 |
| 16-17 | 校验和 | 16 | 覆盖伪首部+TCP首部+数据 |
| 18-19 | 紧急指针 | 16 | URG=1时指向紧急数据末尾 |
| 20-59 | 选项 | 0-40 | 常见MSS、窗口缩放、SACK、时间戳等 |
这张表建议打印出来放在手边,比任何时候去翻RFC都快。我自己早年做协议接入时,用的就是一张类似的纸质表,每看一个十六进制字节就按表查一次,半个月后所有字段位置就烂熟于心了。初学者不用追求一次背下来,先能看懂Wireshark里的字段和这张表的对应关系,就已经赢了一半。
6.2 最容易踩的5个误区
误区一:确认号表示“已经收到的最后一个字节号”。错,确认号是“期望收到的下一个字节号”。如果我收到0到199字节,确认为200,而不是199。这个区别在抓包时非常明显,尤其是配合SYN占一个序号来看,很多人第一次看到SYN包后回应的ack是x+1,会以为服务器多确认了一个字节,其实那就是规则本身。
误区二:数据偏移字段的值就是首部字节数。错,它是4字节块数。值5对应20字节,值11对应44字节。因为20字节的首部恰好对应值5,导致不少人误以为这个字段就是字节数,直到遇到带选项的包才露出马脚。自己解析包时,一定先用data_offset*4得到真实长度,再看数据从哪开始。
误区三:TCP校验和只算TCP首部。错,它要算伪首部、TCP首部和数据三部分。伪首部里的源IP和目的IP来自IP层,协议号固定为6。这也是为什么你不能把TCP段单独拎出来就算校验和,必须结合IP头信息。自己做抓包工具时最容易踩的就是这个坑。
误区四:窗口字段就是实际接收能力。错,还要看窗口缩放因子。窗口缩放协商在握手阶段完成,如果对端没有通告缩放因子,窗口上限就是65535字节,即使本地缓冲区再大也没用。抓包看到的“Calculated window size”才是协商后的真实值。
误区五:只有数据包才消耗序号。错,SYN包和FIN包各占一个序号。很多初学者看三次握手的第二个包,不理解为什么ack是seq+1,而不是seq,就是因为没把SYN看成“占了一个字节”的逻辑实体。四次挥手时FIN同理,最后一个ACK的确认号也要在FIN序号上加1。
6.3 实际排障中的一条经验线
排障经验这东西,讲一堆不如给一条可复用的操作线。我自己处理TCP连接问题时的习惯是:抓包后先不看IP,直接看TCP首部。第一眼看Flags,判断当前抓到的包是握手的第几个、挥手状态、还是异常RST;第二眼看相对序号和确认号,把双向的序号线画出来,确认没有序号跳变;第三眼看MSS和窗口缩放选项,评估这条连接的性能上限;最后才结合IP层确认丢包和RTT。
举个例子,我曾经调一个跨平台系统的对接问题,现象是文件传输总是中断,从应用层日志看不出原因。抓包一看,三次握手正常,数据发送阶段接收窗口一路上涨,然后突然变成0,窗口字段更新为0,发送方就停下来了。再往后,接收方重新发了窗口更新包,但发送方没反应,最终超时重传。这一串现象,问题其实不在TCP首部本身,而是接收方的应用层没有及时消费数据,缓冲区满了,触发了TCP的流量控制。但如果不是先看懂窗口字段和标志位,我根本没法把“窗口变成0”和“应用层读太慢”这件事关联起来。
还有一次是服务器回包一切正常,但客户端始终报连接超时。两端同时抓包后,发现服务器发出的SYN+ACK包在某个中间节点被改了MSS值,这属于中间设备篡改TCP选项的典型案例。如果只看客户端侧抓包,只会看到“发了很多SYN一直没响应”,永远找不到根因。后来我们在服务器侧也抓了一份,一对标志位和序号,很快锁定了路径上的设备。所以我想强调的排障经验就是:TCP首部格式不是考试用的背诵题,而是一套现场勘查工具。遇到网络问题,先把首部字段按顺序读一遍,大多数问题的方向就清楚了。
最后再分享一个小技巧:抓包时保留完整报文长度,不要用-s 0截断;看首部格式一定要开“Relative sequence numbers”之外的绝对序号视角;分析结束后记得保存为pcapng格式并附上过滤条件。把读首部变成习惯,你再看网络问题,就不再是玄学,而是一行行可以验证的二进制事实。