1. 这不是教科书里的“背诵清单”,而是网络协议栈的“解剖现场”
你打开Wireshark抓到一个包,看到那一长串十六进制数据,第一反应是不是:这堆0和1里,到底哪几位是源IP?哪个字节在告诉路由器“请发给192.168.1.100”?TCP的SYN标志位藏在哪?为什么ARP请求里没有IP地址却能拿到MAC?——这些不是抽象概念,而是真实在网络设备内存里一字节一字节排布的结构体。我干了十多年底层网络开发和协议分析,从嵌入式网关固件调试,到数据中心交换机流表逆向,再到教学实验室带学生手写以太网帧封装,最常被问到的问题就是:“老师,能不能把头部字段画出来,标清楚每个bit是干啥的?”今天这篇,不讲OSI七层模型的漂亮分层图,也不列RFC文档编号,就用一张张手绘级结构图+C语言结构体定义+真实抓包十六进制对照,带你把MAC头、IP头、UDP头、TCP头、ARP头全部“剥开看肉”。核心关键词全在标题里:MAC头部、IP报头、UDP报头、TCP报头、ARP头部、图解、结构体刨析——每一个都是你在调试丢包、分析延迟、排查连接重置、甚至写DPDK用户态协议栈时,必须亲手摸过的“物理存在”。适合三类人:刚学计算机网络想搞懂“数据怎么真正跑起来”的学生;做IoT设备通信、工业网关开发需要手动构造报文的工程师;还有天天看Wireshark但总卡在“这个flag到底是第几个bit”的运维同学。下面所有内容,都来自我调试某款国产千兆工业交换机时的真实日志和内存dump,连字段偏移量都是实测校准过的。
2. 协议头部设计逻辑:为什么是这个顺序?为什么占这么多字节?
2.1 一切始于以太网:MAC头部是网络世界的“门牌号”
先说结论:MAC头部是整个网络通信的物理锚点,它不关心上层是什么协议,只负责把一帧数据准确投递到同一局域网内的下一台设备。它的结构看似简单,但每个字段的存在都有硬性物理约束。我们来看标准以太网II帧(Ethernet II)的MAC头部:
| 字段名 | 长度(字节) | 位置(偏移) | 作用说明 | 实操注意 |
|---|---|---|---|---|
| 目的MAC地址 | 6 | 0–5 | 接收方网卡的唯一硬件地址,交换机靠它做二层转发 | 广播地址为ff:ff:ff:ff:ff:ff,组播地址第一位为1(如01:00:5e:xx:xx:xx) |
| 源MAC地址 | 6 | 6–11 | 发送方网卡地址,用于接收方回复时填写目的地址 | 设备启动后由驱动读取EEPROM或自动生成,不可随意修改否则触发交换机端口安全 |
| 类型字段(EtherType) | 2 | 12–13 | 标识载荷类型,决定交给上层哪个协议处理 | 0x0800=IPv4,0x0806=ARP,0x86dd=IPv6;注意这是大端序,抓包显示为08 00而非00 08 |
提示:很多初学者混淆“帧”和“包”。MAC头部包裹的是“帧(Frame)”,而IP头部包裹的是“包(Packet)”。帧是物理链路层的概念,包是网络层的概念。就像快递包裹(帧)外面贴的运单(MAC头),里面装的才是真正的货物(IP包)。当数据跨网段传输时,MAC头会在每个路由器/三层交换机处被完全剥离并重写,而IP头则一路跟随。
为什么MAC头必须是14字节?这不是拍脑袋定的。目的MAC(6)+源MAC(6)+类型(2)=14,这个长度刚好满足以太网最小帧长64字节的约束(加上4字节FCS校验和与46字节最小载荷)。如果IP包太小(比如ICMP echo request只有8字节),就必须填充(Padding)到46字节,否则网卡会直接丢弃——这是物理层的铁律,跟软件无关。我曾经在一个电力监控终端上遇到“ping通但业务不通”的问题,最后发现是厂商驱动填充字节写错了,导致某些交换机FCS校验失败,这种底层bug查起来要翻网卡手册逐bit比对。
2.2 IP报头:网络层的“导航地图”,但绝不保证送达
IP报头是整个互联网能互联互通的基石,但它本身是个“尽力而为”的设计。我们以IPv4为例(IPv6结构差异较大,本文聚焦主流场景),其固定部分20字节,加上可选字段最多60字节。关键字段解析如下:
| 字段名 | 长度(bit) | 位置(字节偏移) | 作用说明 | 实操注意 |
|---|---|---|---|---|
| 版本+首部长度(IHL) | 8(前4bit版本,后4bitIHL) | 0 | 版本=4,IHL表示IP头长度(单位:4字节),最小值5→20字节 | IHL=5即标准无选项头;若IHL=6,则头长24字节,说明有4字节选项(如记录路由) |
| 服务类型(TOS) | 8 | 1 | 旧版QoS字段,现多用于DSCP(前6bit)和ECN(后2bit) | Linux用ip route命令可设置TOS,但多数家用路由器忽略此字段 |
| 总长度 | 16 | 2–3 | 整个IP包长度(头+载荷),单位字节,最大65535 | 若载荷过大(如MTU=1500),需分片;分片时除第一个片外,其他片的MF(More Fragments)标志置1 |
| 标识符(Identification) | 16 | 4–5 | 同一原始包的所有分片共享此ID,用于重组 | 某些NAT设备会篡改此字段,导致分片重组失败 |
| 标志(Flags)+片偏移(Fragment Offset) | 16(前3bit标志,后13bit偏移) | 6–7 | Flags中DF(Don’t Fragment)位为1时禁止分片;偏移量单位为8字节 | TCP握手时SYN包通常设DF=1,避免分片导致连接失败;UDP语音流常禁用DF以适应不同MTU |
| 生存时间(TTL) | 8 | 8 | 每经过一个路由器减1,为0则丢弃,防环路 | traceroute正是利用TTL超时ICMP报文实现路径探测;Linux默认64,Windows默认128 |
| 协议 | 8 | 9 | 指明上层协议,6=TCP,17=UDP,1=ICMP,89=OSPF | 抓包时Wireshark靠此字段决定如何解析载荷,填错会导致协议识别失败 |
| 首部校验和 | 16 | 10–11 | 仅校验IP头(不含载荷),每跳都要重新计算 | 校验和错误的包会被路由器静默丢弃,是常见丢包原因 |
| 源IP地址 | 32 | 12–15 | 发送方IP | NAT设备在此处做地址转换 |
| 目的IP地址 | 32 | 16–19 | 接收方IP | 路由器查路由表后,根据此字段决定下一跳 |
注意:IP头中“首部长度(IHL)”和“总长度”是两个极易混淆的概念。IHL是IP头自身的长度(以4字节为单位),而总长度是整个IP包(头+数据)的长度(以字节为单位)。举个例子:一个标准20字节IP头+1480字节TCP载荷,IHL=5(5×4=20),总长度=1500。如果加了12字节选项,IHL=8(8×4=32),总长度=1512。我在调试某款国产4G CPE时,发现其IP头选项字段未对齐,导致IHL计算错误,Linux内核netfilter模块直接丢包,这种bug在Wireshark里表现为“Malformed packet”,但具体错在哪得看内核日志。
2.3 UDP报头:轻量级“快递单”,快但不管签收
UDP是典型的“发送即忘”协议,头部仅8字节,没有任何可靠性机制。它的存在价值在于极致的低开销和确定性延迟,音视频流、DNS查询、NTP时间同步都依赖它。结构极其精简:
| 字段名 | 长度(字节) | 位置(相对于UDP头起始) | 作用说明 | 实操心得 |
|---|---|---|---|---|
| 源端口号 | 2 | 0–1 | 发送方应用端口,客户端通常为随机高端口 | 端口号0为保留,bind(0)由系统分配;ephemeral port范围可通过/proc/sys/net/ipv4/ip_local_port_range查看 |
| 目的端口号 | 2 | 2–3 | 接收方应用端口,服务端固定(如DNS=53,NTP=123) | 端口扫描本质就是发UDP包到目标端口,看是否收到ICMP port unreachable响应 |
| 长度 | 2 | 4–5 | UDP头+载荷总长度,最小8(纯头) | 注意:此长度不包含IP头,Wireshark显示的“UDP length”即此字段值 |
| 校验和 | 2 | 6–7 | 可选字段,校验UDP头+载荷+伪IP头(源IP、目的IP、协议、UDP长度) | Linux默认开启校验和,但某些嵌入式设备为省CPU关闭;校验和为0表示禁用,此时接收方不校验 |
提示:UDP校验和的“伪IP头”是关键难点。它不是真实IP头,而是从IP头中提取的4字节源IP、4字节目的IP、1字节协议(17)、2字节UDP长度,拼成12字节参与校验。这意味着即使IP头本身正确,只要源IP或目的IP在传输中被NAT修改,而UDP校验和没重算,接收方就会丢包。我曾在一个工业PLC通信项目中遇到UDP包100%丢弃,最终发现是中间防火墙做了源地址转换但没更新UDP校验和——这种问题在纯TCP环境很少见,因为TCP校验和强制启用且更复杂。
2.4 TCP报头:网络世界的“合同文本”,复杂但可靠
TCP头部是协议栈中最复杂的之一,固定20字节,加上选项最多60字节。它用状态机、序列号、确认号、窗口、标志位等一整套机制,把不可靠的IP网络变成了可靠的字节流通道。核心字段深度解析:
| 字段名 | 长度(bit) | 位置(字节偏移) | 作用说明 | 实操避坑 |
|---|---|---|---|---|
| 源端口+目的端口 | 各16 | 0–1, 2–3 | 与UDP相同,标识通信两端应用 | TCP连接四元组(源IP、源端口、目的IP、目的端口)唯一标识一个连接 |
| 序列号(Sequence Number) | 32 | 4–7 | 当前报文段数据的第一个字节的序号;SYN和FIN各消耗1个序号 | 初始序列号(ISN)由系统随机生成,防序列号预测攻击;三次握手中,Client SYN的seq=x,Server SYN-ACK的seq=y,Client ACK的ack=y+1 |
| 确认号(Acknowledgment Number) | 32 | 8–11 | 期望收到的下一个字节的序号;仅ACK标志为1时有效 | ack=seq+1+data_len,即确认已成功接收从seq开始的data_len字节数据 |
| 数据偏移(Data Offset) | 4 | 12(高4位) | TCP头长度(单位:4字节),最小5→20字节 | 与IP头IHL同理,但此处叫Data Offset;若存在选项,此值大于5 |
| 保留位(Reserved) | 6 | 12(低2位)+13(高6位) | 必须为0,供未来扩展 | Wireshark中标记为...000000,若出现非0值,可能是畸形包或攻击 |
| 控制标志(Flags) | 6 | 13(低2位)+14(高4位) | URG、ACK、PSH、RST、SYN、FIN,每位独立控制 | RST=1表示立即终止连接(如端口无监听进程);SYN=1且ACK=0为连接请求;FIN=1表示发起断开 |
| 窗口大小(Window Size) | 16 | 14–15 | 接收方通告的剩余缓冲区大小(字节),用于流量控制 | 实际窗口=通告窗口×窗口扩大因子(WSopt),WSopt在SYN包中协商;若窗口为0,发送方必须停发并等待window update |
| 校验和(Checksum) | 16 | 16–17 | 强制校验,覆盖TCP头+载荷+伪IP头(同UDP) | 计算开销大,DPDK等高性能场景常由硬件卸载 |
| 紧急指针(Urgent Pointer) | 16 | 18–19 | 指向紧急数据末尾,仅URG=1时有效 | 现代应用极少使用,telnet的中断键(Ctrl+C)曾用此机制 |
注意:TCP的“三次握手”和“四次挥手”不是魔法,而是上述字段的精确配合。例如,Client发SYN(seq=x)后,Server回SYN-ACK(seq=y, ack=x+1),Client再发ACK(ack=y+1)。这里ack=x+1是因为SYN标志位消耗了一个序号。同样,挥手时FIN也消耗序号。我在教学生时常用一个比喻:TCP就像两家公司签合同,SYN是“我提议合作”,ACK是“我同意你的提议”,FIN是“我提议终止合作”,而序列号就是合同页码,确保每一页都不丢失、不重复、不错乱。
2.5 ARP头部:局域网里的“户籍查询系统”
ARP(Address Resolution Protocol)是IP网络能工作的最后一块拼图——它解决“我知道IP地址,但怎么找到对应MAC地址?”的问题。它工作在数据链路层,但为网络层服务,所以其帧结构特殊:没有IP头,直接封装在以太网帧中。ARP报文结构如下:
| 字段名 | 长度(字节) | 位置(相对于ARP头起始) | 作用说明 | 关键细节 |
|---|---|---|---|---|
| 硬件类型(Hardware Type) | 2 | 0–1 | 1=以太网(Ethernet) | 其他类型如6=IEEE 802网络,15=帧中继 |
| 协议类型(Protocol Type) | 2 | 2–3 | 0x0800=IPv4 | 与MAC头中EtherType字段值相同,形成映射 |
| 硬件地址长度(HLEN) | 1 | 4 | MAC地址长度=6 | IPv6邻居发现(NDP)中为16(IPv6地址) |
| 协议地址长度(PLEN) | 1 | 5 | IPv4地址长度=4 | 固定值,不可更改 |
| 操作码(Operation) | 2 | 6–7 | 1=ARP请求,2=ARP应答,3=RARP请求,4=RARP应答 | Wireshark中显示为who-has或tell |
| 发送方MAC地址 | 6 | 8–13 | 请求方或应答方的MAC | ARP请求中此字段为发送方真实MAC,ARP应答中为被查询方MAC |
| 发送方IP地址 | 4 | 14–17 | 请求方或应答方的IP | ARP请求中为请求方IP,ARP应答中为被查询方IP |
| 目标MAC地址 | 6 | 18–23 | ARP请求中为全0(未知),ARP应答中为被查询方MAC | 这是关键!请求时填0,应答时才填真实MAC |
| 目标IP地址 | 4 | 24–27 | 请求方想查询的IP地址 | 在ARP请求和应答中都相同,标识查询目标 |
提示:ARP没有端口号概念,因为它不走传输层。它的“协议类型”字段(0x0800)和MAC头的“类型字段”(0x0800)必须一致,否则网卡驱动会丢弃。我曾在某款国产ARM工控机上遇到无法上网问题,抓包发现ARP请求发出后无响应,深入检查发现其网卡驱动将ARP报文的协议类型误设为0x0806(ARP自身),导致交换机认为这是非法帧而丢弃——这种底层驱动bug,只能靠对比标准RFC 826和芯片手册逐字节排查。
3. 结构体定义与内存布局:C语言视角下的协议真相
3.1 为什么必须用#pragma pack(1)?字节对齐的血泪教训
在嵌入式或内核开发中,我们常把网络报文当作一块连续内存来解析,这就要求C结构体的内存布局必须与协议规定的字节顺序完全一致。而C编译器默认按平台字长(如x86_64为8字节)对齐结构体成员,这会导致插入填充字节(padding),破坏协议格式。例如,一个朴素的IP头结构体:
// 错误示范:未考虑对齐,编译器会自动填充 struct ip_header_bad { uint8_t version_ihl; // 1字节 uint8_t tos; // 1字节 uint16_t total_len; // 2字节 uint16_t id; // 2字节 uint16_t flags_offset; // 2字节 uint8_t ttl; // 1字节 uint8_t protocol; // 1字节 uint16_t checksum; // 2字节 uint32_t src_ip; // 4字节 uint32_t dst_ip; // 4字节 }; // 编译器可能在ttl后插入3字节padding,使结构体总长≠20实测在GCC x86_64下,sizeof(struct ip_header_bad)可能为24字节,因为uint32_t src_ip要求4字节对齐,编译器在protocol(1字节)后插入3字节填充。这会导致src_ip字段实际偏移不是12,而是16,与协议规定严重不符。
正确做法是强制1字节对齐:
#pragma pack(1) struct ip_header { #ifdef __BIG_ENDIAN_BITFIELD uint8_t version:4, ihl:4; #else uint8_t ihl:4, version:4; #endif uint8_t tos; uint16_t total_len; uint16_t id; #ifdef __BIG_ENDIAN_BITFIELD uint16_t flags:3, frag_offset:13; #else uint16_t frag_offset:13, flags:3; #endif uint8_t ttl; uint8_t protocol; uint16_t checksum; uint32_t src_ip; uint32_t dst_ip; }; #pragma pack()注意:
#pragma pack(1)是GCC/Clang/MSVC通用指令,告诉编译器结构体成员按1字节边界对齐,不插入任何padding。但位域(bit-field)的顺序在大小端机器上不同,因此用#ifdef __BIG_ENDIAN_BITFIELD区分。Linux内核中实际使用__be32、__be16等类型,并通过ntohl()、htons()系列函数处理字节序转换,比直接位域更安全。我在移植一个LWIP协议栈到国产RISC-V芯片时,就因忘记#pragma pack(1),导致IP头解析错位,花了两天时间用逻辑分析仪比对内存dump才定位。
3.2 完整协议栈结构体嵌套:从以太网帧到TCP载荷
真实的网络报文是层层封装的,结构体也应体现这种嵌套关系。以下是一个典型IPv4/TCP报文的完整结构体定义(简化版,仅含固定头部):
#pragma pack(1) // 以太网帧头 struct eth_header { uint8_t dst_mac[6]; uint8_t src_mac[6]; uint16_t type; // 0x0800 for IPv4 }; // IPv4头 struct ip_header { #ifdef __BIG_ENDIAN_BITFIELD uint8_t version:4, ihl:4; #else uint8_t ihl:4, version:4; #endif uint8_t tos; uint16_t total_len; uint16_t id; #ifdef __BIG_ENDIAN_BITFIELD uint16_t flags:3, frag_offset:13; #else uint16_t frag_offset:13, flags:3; #endif uint8_t ttl; uint8_t protocol; // 6 for TCP uint16_t checksum; uint32_t src_ip; uint32_t dst_ip; }; // TCP头 struct tcp_header { uint16_t src_port; uint16_t dst_port; uint32_t seq; uint32_t ack; #ifdef __BIG_ENDIAN_BITFIELD uint8_t data_offset:4, reserved:3, ns:1; uint8_t flags; #else uint8_t reserved:3, data_offset:4, ns:1; uint8_t flags; #endif uint16_t window; uint16_t checksum; uint16_t urg_ptr; }; // 完整的以太网帧(含IP+TCP) struct eth_ip_tcp_frame { struct eth_header eth; struct ip_header ip; struct tcp_header tcp; uint8_t payload[]; // 柔性数组,指向TCP载荷 }; #pragma pack()使用时,假设有一块内存uint8_t *pkt指向抓包得到的原始数据:
struct eth_ip_tcp_frame *frame = (struct eth_ip_tcp_frame *)pkt; printf("Dest MAC: %02x:%02x:%02x:%02x:%02x:%02x\n", frame->eth.dst_mac[0], frame->eth.dst_mac[1], ...); printf("IP Total Len: %d\n", ntohs(frame->ip.total_len)); printf("TCP Seq: %u\n", ntohl(frame->tcp.seq)); printf("Payload len: %d\n", ntohs(frame->ip.total_len) - (frame->ip.ihl * 4) - 20); // IP头长 - TCP头长提示:
ntohs()(network to host short)和ntohl()(network to host long)是必须的!因为网络字节序是大端(Big-Endian),而x86/x64是小端(Little-Endian)。直接读frame->ip.total_len会得到错误值。我见过太多新手在打印端口号时忘了ntohs(),结果看到0x3500(即13312)而不是53(DNS端口),白白浪费几小时。
3.3 ARP结构体:没有IP头的“裸奔”协议
ARP报文不经过IP层,直接封装在以太网帧中,因此其结构体定义需与以太网头紧邻:
#pragma pack(1) struct arp_header { uint16_t hw_type; // 1 for Ethernet uint16_t proto_type; // 0x0800 for IPv4 uint8_t hw_len; // 6 for MAC uint8_t proto_len; // 4 for IPv4 uint16_t opcode; // 1=request, 2=reply uint8_t sender_hw[6]; // 源MAC uint8_t sender_proto[4]; // 源IP uint8_t target_hw[6]; // 目标MAC(请求时为0) uint8_t target_proto[4]; // 目标IP }; #pragma pack() // 完整ARP帧 struct eth_arp_frame { struct eth_header eth; struct arp_header arp; };解析ARP请求时:
arp.opcode == htons(1)arp.target_proto是你要查询的IP(如192.168.1.1)arp.sender_hw是请求方MAC,arp.sender_proto是请求方IP
解析ARP应答时:
arp.opcode == htons(2)arp.target_hw已被填入目标MAC,这就是你要的答案!
注意:ARP应答帧的以太网目的MAC是请求方的MAC(单播),而ARP请求帧的目的MAC是
ff:ff:ff:ff:ff:ff(广播)。在代码中,你需要先解析eth.dst_mac来判断是请求还是应答,再决定如何处理arp字段。我在写一个ARP欺骗检测工具时,就因没检查eth.dst_mac是否为广播地址,导致把所有ARP应答都误判为攻击。
4. 图解实战:Wireshark抓包与结构体字段一一对应
4.1 抓一个标准HTTP GET请求,逐层“剥洋葱”
我们用curl http://httpbin.org/get发起一个简单请求,在Wireshark中过滤http,找到对应的TCP流。选取其中第一个完整的TCP数据包(SYN之后的HTTP GET包),展开其协议树:
Frame 123: 66 bytes on wire (528 bits), 66 bytes captured (528 bits) Ethernet II, Src: IntelCor_XX:XX:XX (aa:bb:cc:dd:ee:ff), Dst: Netgear_XX:XX:XX (00:11:22:33:44:55) Internet Protocol Version 4, Src: 192.168.1.100, Dst: 34.123.45.67 Transmission Control Protocol, Src Port: 54321, Dst Port: 80, Seq: 1, Ack: 1, Len: 12 Hypertext Transfer Protocol现在,我们把这66字节原始数据(十六进制)拿出来,对照结构体解析:
001122334455 aabbccddeeff 0800 4500 003c 1234 0000 4006 ... ^---------^ ^---------^ ^--^ ^----^ ^----^ ^----^ ^----^ Dst MAC Src MAC Type IP头 IP头 IP头 IP头- 前6字节
001122334455→eth.dst_mac[6] - 接6字节
aabbccddeeff→eth.src_mac[6] - 接2字节
0800→eth.type = 0x0800(大端,即十进制2048) - 接1字节
45→ip.version_ihl = 0x45→ version=4, ihl=5(20字节IP头) - 接1字节
00→ip.tos = 0 - 接2字节
003c→ip.total_len = 0x003c = 60(十进制),即整个IP包60字节 - 接2字节
1234→ip.id = 0x1234 - 接2字节
0000→ip.flags_offset = 0x0000(DF=0, MF=0, offset=0) - 接1字节
40→ip.ttl = 64 - 接1字节
06→ip.protocol = 6(TCP) - 接2字节
xxxx→ip.checksum(此处略过,需计算) - 接4字节
c0a80164→ip.src_ip = 0xc0a80164 = 192.168.1.100 - 接4字节
227b2d43→ip.dst_ip = 0x227b2d43 = 34.123.45.67 - 接2字节
d431→tcp.src_port = 0xd431 = 54321(需ntohs()→54321) - 接2字节
0050→tcp.dst_port = 0x0050 = 80 - 接4字节
...→tcp.seq - 接4字节
...→tcp.ack - 接1字节
a0→tcp.data_offset_reserved_flags→ data_offset=10(40字节TCP头),flags=0x10(ACK) - 接1字节
10→tcp.flags(与上字节合并,实际flags=0x10,即ACK)
实操心得:Wireshark的“Packet Bytes”窗格显示的就是原始字节流,左侧是偏移量(Offset),右侧是十六进制。你可以直接用鼠标选中某几个字节,Wireshark会自动高亮对应协议字段。这是最快捷的“所见即所得”学习法。我带新人时,第一课就是让他们用Wireshark抓包,然后对照RFC文档,手工计算每个字段的值,直到能闭眼写出IP头的20字节布局。
4.2 TCP三次握手图解:用结构体字段说话
三次握手是TCP最核心的交互,我们用结构体字段来还原:
Step 1: Client → Server (SYN)
eth: dst=Server_MAC, src=Client_MAC, type=0x0800 ip: src=Client_IP, dst=Server_IP, protocol=6, total_len=60 (20+20+20) tcp: src_port=54321, dst_port=80, seq=x, ack=0, data_offset=10, flags=0x02(SYN), window=64240flags=0x02→ SYN=1, ACK=0seq=x→ 随机初始序列号(ISN)total_len=60→ IP头20 + TCP头20 + 无载荷20?不对!TCP头20字节,IP头20字节,共40字节。60是Wireshark显示的整个帧长,含14字节MAC头+4字节FCS=60。IP头中total_len=40。
Step 2: Server → Client (SYN-ACK)
eth: dst=Client_MAC, src=Server_MAC, type=0x0800 ip: src=Server_IP, dst=Client_IP, protocol=6, total_len=60 tcp: src_port=80, dst_port=54321, seq=y, ack=x+1, data_offset=10, flags=0x12(SYN+ACK), window=65535flags=0x12→ SYN=1, ACK=1(0x10 | 0x02)ack=x+1→ 确认收到SYN,消耗1序号
Step 3: Client → Server (ACK)
eth: dst=Server_MAC, src=Client_MAC, type=0x0800 ip: src=Client_IP, dst=Server_IP, protocol=6, total_len=52 (20+20+12? no, 20+20+0=40, but 52 includes TCP options) tcp: src_port=54321, dst_port=80, seq=x+1, ack=y+1, data_offset=12 (32字节TCP头,含选项), flags=0x10(ACK), window=64240data_offset=12→ TCP头长48字节(12×4),说明有8字节选项(如MSS、SACK)seq=x+1→ SYN消耗1序号,现在发第一个数据包的序号ack=y+1→ 确认收到SYN
提示:Wireshark中右键某个TCP包,选择“Follow → TCP Stream”,就能看到完整的三次握手交互。点击“Show and scroll in hex dump”,即可看到每个包的原始字节,与结构体字段一一对应。这是理解TCP状态机最直观的方式。
4.3 ARP请求/应答图解:局域网寻址的完整闭环
在局域网内,任何