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地址60–5接收方网卡的唯一硬件地址交换机靠它做二层转发广播地址为ff:ff:ff:ff:ff:ff组播地址第一位为1如01:00:5e:xx:xx:xx源MAC地址66–11发送方网卡地址用于接收方回复时填写目的地址设备启动后由驱动读取EEPROM或自动生成不可随意修改否则触发交换机端口安全类型字段EtherType212–13标识载荷类型决定交给上层哪个协议处理0x0800IPv40x0806ARP0x86ddIPv6注意这是大端序抓包显示为08 00而非00 08提示很多初学者混淆“帧”和“包”。MAC头部包裹的是“帧Frame”而IP头部包裹的是“包Packet”。帧是物理链路层的概念包是网络层的概念。就像快递包裹帧外面贴的运单MAC头里面装的才是真正的货物IP包。当数据跨网段传输时MAC头会在每个路由器/三层交换机处被完全剥离并重写而IP头则一路跟随。为什么MAC头必须是14字节这不是拍脑袋定的。目的MAC6源MAC6类型214这个长度刚好满足以太网最小帧长64字节的约束加上4字节FCS校验和与46字节最小载荷。如果IP包太小比如ICMP echo request只有8字节就必须填充Padding到46字节否则网卡会直接丢弃——这是物理层的铁律跟软件无关。我曾经在一个电力监控终端上遇到“ping通但业务不通”的问题最后发现是厂商驱动填充字节写错了导致某些交换机FCS校验失败这种底层bug查起来要翻网卡手册逐bit比对。2.2 IP报头网络层的“导航地图”但绝不保证送达IP报头是整个互联网能互联互通的基石但它本身是个“尽力而为”的设计。我们以IPv4为例IPv6结构差异较大本文聚焦主流场景其固定部分20字节加上可选字段最多60字节。关键字段解析如下字段名长度bit位置字节偏移作用说明实操注意版本首部长度IHL8前4bit版本后4bitIHL0版本4IHL表示IP头长度单位4字节最小值5→20字节IHL5即标准无选项头若IHL6则头长24字节说明有4字节选项如记录路由服务类型TOS81旧版QoS字段现多用于DSCP前6bit和ECN后2bitLinux用ip route命令可设置TOS但多数家用路由器忽略此字段总长度162–3整个IP包长度头载荷单位字节最大65535若载荷过大如MTU1500需分片分片时除第一个片外其他片的MFMore Fragments标志置1标识符Identification164–5同一原始包的所有分片共享此ID用于重组某些NAT设备会篡改此字段导致分片重组失败标志Flags片偏移Fragment Offset16前3bit标志后13bit偏移6–7Flags中DFDon’t Fragment位为1时禁止分片偏移量单位为8字节TCP握手时SYN包通常设DF1避免分片导致连接失败UDP语音流常禁用DF以适应不同MTU生存时间TTL88每经过一个路由器减1为0则丢弃防环路traceroute正是利用TTL超时ICMP报文实现路径探测Linux默认64Windows默认128协议89指明上层协议6TCP17UDP1ICMP89OSPF抓包时Wireshark靠此字段决定如何解析载荷填错会导致协议识别失败首部校验和1610–11仅校验IP头不含载荷每跳都要重新计算校验和错误的包会被路由器静默丢弃是常见丢包原因源IP地址3212–15发送方IPNAT设备在此处做地址转换目的IP地址3216–19接收方IP路由器查路由表后根据此字段决定下一跳注意IP头中“首部长度IHL”和“总长度”是两个极易混淆的概念。IHL是IP头自身的长度以4字节为单位而总长度是整个IP包头数据的长度以字节为单位。举个例子一个标准20字节IP头1480字节TCP载荷IHL55×420总长度1500。如果加了12字节选项IHL88×432总长度1512。我在调试某款国产4G CPE时发现其IP头选项字段未对齐导致IHL计算错误Linux内核netfilter模块直接丢包这种bug在Wireshark里表现为“Malformed packet”但具体错在哪得看内核日志。2.3 UDP报头轻量级“快递单”快但不管签收UDP是典型的“发送即忘”协议头部仅8字节没有任何可靠性机制。它的存在价值在于极致的低开销和确定性延迟音视频流、DNS查询、NTP时间同步都依赖它。结构极其精简字段名长度字节位置相对于UDP头起始作用说明实操心得源端口号20–1发送方应用端口客户端通常为随机高端口端口号0为保留bind(0)由系统分配ephemeral port范围可通过/proc/sys/net/ipv4/ip_local_port_range查看目的端口号22–3接收方应用端口服务端固定如DNS53NTP123端口扫描本质就是发UDP包到目标端口看是否收到ICMP port unreachable响应长度24–5UDP头载荷总长度最小8纯头注意此长度不包含IP头Wireshark显示的“UDP length”即此字段值校验和26–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位置字节偏移作用说明实操避坑源端口目的端口各160–1, 2–3与UDP相同标识通信两端应用TCP连接四元组源IP、源端口、目的IP、目的端口唯一标识一个连接序列号Sequence Number324–7当前报文段数据的第一个字节的序号SYN和FIN各消耗1个序号初始序列号ISN由系统随机生成防序列号预测攻击三次握手中Client SYN的seqxServer SYN-ACK的seqyClient ACK的acky1确认号Acknowledgment Number328–11期望收到的下一个字节的序号仅ACK标志为1时有效ackseq1data_len即确认已成功接收从seq开始的data_len字节数据数据偏移Data Offset412高4位TCP头长度单位4字节最小5→20字节与IP头IHL同理但此处叫Data Offset若存在选项此值大于5保留位Reserved612低2位13高6位必须为0供未来扩展Wireshark中标记为...000000若出现非0值可能是畸形包或攻击控制标志Flags613低2位14高4位URG、ACK、PSH、RST、SYN、FIN每位独立控制RST1表示立即终止连接如端口无监听进程SYN1且ACK0为连接请求FIN1表示发起断开窗口大小Window Size1614–15接收方通告的剩余缓冲区大小字节用于流量控制实际窗口通告窗口×窗口扩大因子WSoptWSopt在SYN包中协商若窗口为0发送方必须停发并等待window update校验和Checksum1616–17强制校验覆盖TCP头载荷伪IP头同UDP计算开销大DPDK等高性能场景常由硬件卸载紧急指针Urgent Pointer1618–19指向紧急数据末尾仅URG1时有效现代应用极少使用telnet的中断键CtrlC曾用此机制注意TCP的“三次握手”和“四次挥手”不是魔法而是上述字段的精确配合。例如Client发SYN(seqx)后Server回SYN-ACK(seqy, ackx1)Client再发ACK(acky1)。这里ackx1是因为SYN标志位消耗了一个序号。同样挥手时FIN也消耗序号。我在教学生时常用一个比喻TCP就像两家公司签合同SYN是“我提议合作”ACK是“我同意你的提议”FIN是“我提议终止合作”而序列号就是合同页码确保每一页都不丢失、不重复、不错乱。2.5 ARP头部局域网里的“户籍查询系统”ARPAddress Resolution Protocol是IP网络能工作的最后一块拼图——它解决“我知道IP地址但怎么找到对应MAC地址”的问题。它工作在数据链路层但为网络层服务所以其帧结构特殊没有IP头直接封装在以太网帧中。ARP报文结构如下字段名长度字节位置相对于ARP头起始作用说明关键细节硬件类型Hardware Type20–11以太网Ethernet其他类型如6IEEE 802网络15帧中继协议类型Protocol Type22–30x0800IPv4与MAC头中EtherType字段值相同形成映射硬件地址长度HLEN14MAC地址长度6IPv6邻居发现NDP中为16IPv6地址协议地址长度PLEN15IPv4地址长度4固定值不可更改操作码Operation26–71ARP请求2ARP应答3RARP请求4RARP应答Wireshark中显示为who-has或tell发送方MAC地址68–13请求方或应答方的MACARP请求中此字段为发送方真实MACARP应答中为被查询方MAC发送方IP地址414–17请求方或应答方的IPARP请求中为请求方IPARP应答中为被查询方IP目标MAC地址618–23ARP请求中为全0未知ARP应答中为被查询方MAC这是关键请求时填0应答时才填真实MAC目标IP地址424–27请求方想查询的IP地址在ARP请求和应答中都相同标识查询目标提示ARP没有端口号概念因为它不走传输层。它的“协议类型”字段0x0800和MAC头的“类型字段”0x0800必须一致否则网卡驱动会丢弃。我曾在某款国产ARM工控机上遇到无法上网问题抓包发现ARP请求发出后无响应深入检查发现其网卡驱动将ARP报文的协议类型误设为0x0806ARP自身导致交换机认为这是非法帧而丢弃——这种底层驱动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字节对齐编译器在protocol1字节后插入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; }; // 完整的以太网帧含IPTCP 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而不是53DNS端口白白浪费几小时。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; // 1request, 2reply 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.1arp.sender_hw是请求方MACarp.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→ version4, ihl520字节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 0x0000DF0, MF0, offset0接1字节40→ip.ttl 64接1字节06→ip.protocol 6TCP接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_offset1040字节TCP头flags0x10ACK接1字节10→tcp.flags与上字节合并实际flags0x10即ACK实操心得Wireshark的“Packet Bytes”窗格显示的就是原始字节流左侧是偏移量Offset右侧是十六进制。你可以直接用鼠标选中某几个字节Wireshark会自动高亮对应协议字段。这是最快捷的“所见即所得”学习法。我带新人时第一课就是让他们用Wireshark抓包然后对照RFC文档手工计算每个字段的值直到能闭眼写出IP头的20字节布局。4.2 TCP三次握手图解用结构体字段说话三次握手是TCP最核心的交互我们用结构体字段来还原Step 1: Client → Server (SYN)eth: dstServer_MAC, srcClient_MAC, type0x0800 ip: srcClient_IP, dstServer_IP, protocol6, total_len60 (202020) tcp: src_port54321, dst_port80, seqx, ack0, data_offset10, flags0x02(SYN), window64240flags0x02→ SYN1, ACK0seqx→ 随机初始序列号ISNtotal_len60→ IP头20 TCP头20 无载荷20不对TCP头20字节IP头20字节共40字节。60是Wireshark显示的整个帧长含14字节MAC头4字节FCS60。IP头中total_len40。Step 2: Server → Client (SYN-ACK)eth: dstClient_MAC, srcServer_MAC, type0x0800 ip: srcServer_IP, dstClient_IP, protocol6, total_len60 tcp: src_port80, dst_port54321, seqy, ackx1, data_offset10, flags0x12(SYNACK), window65535flags0x12→ SYN1, ACK10x10 | 0x02ackx1→ 确认收到SYN消耗1序号Step 3: Client → Server (ACK)eth: dstServer_MAC, srcClient_MAC, type0x0800 ip: srcClient_IP, dstServer_IP, protocol6, total_len52 (202012? no, 2020040, but 52 includes TCP options) tcp: src_port54321, dst_port80, seqx1, acky1, data_offset12 (32字节TCP头含选项), flags0x10(ACK), window64240data_offset12→ TCP头长48字节12×4说明有8字节选项如MSS、SACKseqx1→ SYN消耗1序号现在发第一个数据包的序号acky1→ 确认收到SYN提示Wireshark中右键某个TCP包选择“Follow → TCP Stream”就能看到完整的三次握手交互。点击“Show and scroll in hex dump”即可看到每个包的原始字节与结构体字段一一对应。这是理解TCP状态机最直观的方式。4.3 ARP请求/应答图解局域网寻址的完整闭环在局域网内任何