第6篇:一个以太网帧的解剖课 —— 从 MAC 地址到 TCP 端口

发布时间:2026/6/30 10:34:57
第6篇:一个以太网帧的解剖课 —— 从 MAC 地址到 TCP 端口 第6篇一个以太网帧的解剖课 —— 从 MAC 地址到 TCP 端口一、用 C 语言的结构体来学协议大部分计算机网络教材是这样讲协议栈的先画一张七层 OSI 模型的图然后从上到下或者从下到上逐层介绍每一层都配一张协议头的示意图用不同颜色标注各个字段。这种讲法没问题但它有一个缺陷读完你知道了协议长什么样但不知道在代码里怎么用。今天我用另一种方式来讲——直接拿我们在winpkfilter_driver.cpp里定义的结构体过来对着代码讲。你读完这一篇不仅知道 IP 头的 TTL 字段在哪还知道怎么用指针强转把它从一堆十六进制里揪出来。这种方式可能不够学术但实用。我的信条是能跑通的代码比一张漂亮的示意图更有价值。二、以太网头一切开始的地方网线上传输的数据包最外层永远是 14 字节的以太网头。注意我说最外层——这 14 字节在最前面不是最外面。外面和前面不是同一个概念但在网络协议里它们恰好指向同一个方向。我没有定义一个专门的结构体给以太网头——因为太简单了直接算偏移就好Byte 0-5: 目标 MAC 地址DMAC——这个包要发给谁 Byte 6-11: 源 MAC 地址SMAC——这个包是谁发的 Byte 12-13: EtherType——里面装的什么协议IPv4? ARP? IPv6?MAC 地址是一个 48 位的硬件地址出厂时烧录在网卡 ROM 里。它理论上全球唯一但实际上可以被软件修改——这就是MAC 地址克隆和ARP 欺骗的基础。判断一个包是不是广播包只需要检查 DMAC 是不是ff:ff:ff:ff:ff:ff。在代码里一次memcmp搞定。EtherType 只有 2 字节但它决定了后面的所有解析逻辑EtherType协议你该怎么处理0x0800IPv4偏移 14 字节开始解析 IP 头0x0806ARP偏移 14 字节开始解析 ARP 载荷0x86DDIPv6偏移 14 字节开始解析 IPv6 头0x8100VLAN (802.1q)多了 4 字节 VLAN 标签真正的 EtherType 在偏移 16一个经常踩的坑0x86DD是 IPv6 的 EtherType。如果你只检查0x0800IPv6 流量会全部被当成未知协议跳过。这就是为什么我们的代码里有一行m_stat_ipv6_drop——IPv6 的包我们目前不处理但不是没看到。三、IP 头20 个字节里的全部信息跳过以太网头的 14 字节如果 EtherType 是0x0800接下来就是 IP 头。在我们的代码里是这样定义的winpkfilter_driver.cpptypedefstruct_IP_HEADER{UCHAR Ver_HLen;// 高4位版本号(4)低4位头长度(以4字节为单位)UCHAR TOS;// 服务类型现在叫 DSCPECNUSHORT Len;// IP 包总长含头USHORT ID;// 分片标识USHORT Flags_Frag;// 高3位标志(DF/MF)低13位分片偏移UCHAR TTL;// 存活跳数每过一路由器减1UCHAR Proto;// 上层协议6TCP17UDP1ICMPUSHORT Csum;// 头部校验和ULONG SrcAddr;// 源 IPULONG DstAddr;// 目标 IP}IP_HEADER;这个 20 字节的结构体承载了互联网最核心的路由逻辑。我挑几个有意思的字段讲讲。Ver_HLen一个字节干了两个人的活Ver_HLen是一个经典的双字段合并技巧。高 4 位是 VersionIPv4 就是 4低 4 位是 Internet Header Length以 4 字节为单位。如果低 4 位是 5说明头长 5 × 4 20 字节没有选项。如果是 6头长 24有 4 字节选项以此类推最大 15 × 4 60 字节。解析时通常这样写intversion(ip-Ver_HLen4)0x0F;// 高4位inthdr_len(ip-Ver_HLen0x0F)*4;// 低4位 × 4 字节数Flags_FragIP 分片的控制器高 3 位里最重要的是 bit 14Don’t Fragment, DF。如果 DF1这个包不能被分片路径上的路由器如果发现 MTU 不够会丢弃这个包并返回一个 ICMP “Fragmentation Needed”。这就是路径 MTU 发现Path MTU Discovery的底层机制。低 13 位是分片偏移以 8 字节为单位。如果一个大的 IP 包被切成三片第一片的偏移是 0第二片的偏移是第一片的长度/8以此类推。在我们的代码里我们做了一个假设数据包不分片。在get_target_worker()函数里我们根据 TCP/UDP 端口做哈希来分配 worker——但这只在第一个分片里有效因为只有第一个分片包含传输层头部。后续分片不包含端口信息哈希结果会不同可能被分配到不同的 worker。这是一个已知的 trade-off。在局域网环境里我们的主要运行场景IP 分片极其罕见——MTU 1500 对几乎所有应用都够用。如果真的遇到了我们的处理是后续分片可能被当作未知处理或者直接丢弃。Proto一字节决定命运6 TCP17 UDP1 ICMP。对于 TCP 包后面紧跟的是 TCP 头对于 UDP后面是 UDP 头对于 ICMP后面是 ICMP 头。这里有一个常被忽视的问题IP 头之后不一定直接就是传输层头。如果 IP 头有选项IHL 5选项字节夹在 IP 头和传输层头之间。所以正确的做法是inthdr_len(ip-Ver_HLen0x0F)*4;if(ip-ProtoIPPROTO_TCP){TCP_HEADER*tcp(TCP_HEADER*)((uint8_t*)iphdr_len);// ...}直接写(TCP_HEADER*)(ip 1)是错的因为在有 IP 选项的情况下ip 1跳过了sizeof(IP_HEADER)而不是实际的头长度。四、TCP 头连接的艺术跳过 IP 头之后如果 Proto6就到了 TCP 头typedefstruct_TCP_HEADER{USHORT SrcPort;// 源端口USHORT DstPort;// 目标端口ULONG SeqNum;// 序列号ULONG AckNum;// 确认号UCHAR HdrLen;// 高4位TCP头长度(以4字节为单位)UCHAR Flags;// SYN/ACK/FIN/RST/PSH/URG/ECE/CWRUSHORT WinSize;// 窗口大小USHORT Csum;// 校验和USHORT UrgPtr;// 紧急指针}TCP_HEADER;TCP 的标志位体系是互联网最精妙的设计之一八个 bit 各有分工SYN (0x02)我要建立连接这是我的起始序号ACK (0x10)我收到了你的数据我的确认号是有效的FIN (0x01)我没有更多数据要发了让我们结束吧RST (0x04)这个连接有问题立刻终止PSH (0x08)收到就立刻交给应用层别缓冲在透明代理里我们拦截的第一个包几乎总是 SYNFlags0x02。从这个包我们提取五元组SrcIP SrcPort DstIP DstPort Protocol查 NAT 表做路由决策。如果决定走代理我们就接管这个连接——不让真实的 SYN 到达目标服务器而是由我们跟目标服务器建立连接。HdrLen的高 4 位是 TCP 头长度以 4 字节为单位。解析方法跟 IP 头一样inttcp_hdr_len((tcp-HdrLen4)0x0F)*4;uint8_t*payload(uint8_t*)tcptcp_hdr_len;五、UDP 头穷亲戚只有 8 字节typedefstruct_UDP_HEADER{USHORT SrcPort;USHORT DstPort;USHORT Len;// UDP 数据报总长含头USHORT Csum;// 校验和}UDP_HEADER;就四样。没有序号没有确认号没有窗口没有标志位。UDP 的设计哲学是我尽量简单剩下的你看着办。UDP 的校验和在 IPv4 里是可选的可以填 0 表示我不算校验和但在 IPv6 里是强制的。这是一个许多程序在从 IPv4 迁移到 IPv6 时会踩的坑。六、ICMP 头网络的诊断信使typedefstruct_ICMP_HEADER{UCHAR Type;UCHAR Code;USHORT Csum;USHORT Id;USHORT Seq;}ICMP_HEADER;ICMP 最常见的用途是pingType8 Echo Request, Type0 Echo Reply。在我们的软路由里客户端偶尔会 ping 网关来测试连通性我们需要正确回应这些 ICMP Echo Request。如果不回应客户端会认为网关不可达。我们的处理很简单Type8 来的 Echo Request → 改成 Type0 Echo Reply → 交换 SrcIP 和 DstIP → 重新算校验和 → 发回去。七、ARP最会说谎的协议ARP 的结构体比其他协议都更有趣typedefstruct_ARP_HEADER{USHORT HwType;// 硬件类型1以太网USHORT ProtoType;// 协议类型0x0800IPv4UCHAR HwLen;// 硬件地址长度6UCHAR ProtoLen;// 协议地址长度4USHORT OpCode;// 操作1Request(问), 2Reply(答)UCHAR SrcMac[6];// 发送者 MACULONG SrcIp;// 发送者 IPUCHAR DstMac[6];// 目标 MACRequest 中为全零ULONG DstIp;// 目标 IP}ARP_HEADER;ARP 请求的处理很简单如果 SrcIp 在我们的子网内DstIp 是我们网关的 IP我们就构造一个 ARP Reply 回应它。这相当于有人在大厅里喊谁是 192.168.137.1“我们举手说我是这是我的身份证号。”在我们的代码中ARP 包被直接透传m_stat_arp_pass因为我们希望网关 IP 对局域网内的设备可见。如果拦截了 ARP 不回应设备会以为网关不存在。八、指针强转一个危险但高效的传统你可能会注意到我们的代码里大量使用了这种模式char*packet...;// 从驱动读到的原始数据IP_HEADER*ip(IP_HEADER*)(packet14);// 跳过以太网头TCP_HEADER*tcp(TCP_HEADER*)((uint8_t*)ipip_hdr_len);// 跳过IP头直接把原始字节流强转成结构体指针然后访问成员。这在 C 和 C 编程中属于技术上未定义行为、实际上大家都在用的灰色地带。严格别名规则Strict Aliasing Rule规定你不能用一个不兼容类型的指针去访问一块内存。char*和IP_HEADER*是不兼容类型所以技术上这是 UB。但所有的网络协议栈代码——包括 Linux 内核的、FreeBSD 的、Windows 自身的——都在用这个模式。为什么因为它是解析网络包最高效的方式一次指针运算 一次解引用没有拷贝、没有解析函数调用、没有额外开销。如果不用指针强转你就得手动逐字节解析——ip-SrcAddr (packet[26] 24) | (packet[27] 16) | ...——那代码不仅慢而且可读性为零。现实世界的 C 编程永远在标准说了什么和CPU 实际怎么工作之间取一个平衡。指针强转解析网络包就是这个平衡的经典案例。九、下一篇今天我们把以太网帧、IP 头、TCP/UDP/ICMP/ARP 头都过了一遍。带着这些知识下一篇我们来处理两个具体的局域网协议——ARP 和 DHCP——没有它们你的局域网根本不能即插即用。我们会看到 ARP 欺骗为什么这么容易、DHCP 的四步握手每一步在做什么、以及为什么在软路由里 DHCP 必须我们自己来写而不是交给 Windows。本文是《从0到1编写一个硬核软路由》系列的第六篇。上一篇第5篇真的读到一个包了 | 下一篇第7篇ARP的谎言与DHCP的魔法