第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 | 协议 | 你该怎么处理 |
|---|---|---|
0x0800 |
IPv4 | 偏移 14 字节开始解析 IP 头 |
0x0806 |
ARP | 偏移 14 字节开始解析 ARP 载荷 |
0x86DD |
IPv6 | 偏移 14 字节开始解析 IPv6 头 |
0x8100 |
VLAN (802.1q) | 多了 4 字节 VLAN 标签,真正的 EtherType 在偏移 16 |
一个经常踩的坑:0x86DD 是 IPv6 的 EtherType。如果你只检查 0x0800,IPv6 流量会全部被当成"未知协议"跳过。这就是为什么我们的代码里有一行 m_stat_ipv6_drop++------IPv6 的包我们目前不处理,但不是没看到。
三、IP 头:20 个字节里的全部信息
跳过以太网头的 14 字节,如果 EtherType 是 0x0800,接下来就是 IP 头。在我们的代码里是这样定义的(winpkfilter_driver.cpp):
cpp
typedef struct _IP_HEADER {
UCHAR Ver_HLen; // 高4位=版本号(4),低4位=头长度(以4字节为单位)
UCHAR TOS; // 服务类型,现在叫 DSCP+ECN
USHORT Len; // IP 包总长(含头)
USHORT ID; // 分片标识
USHORT Flags_Frag; // 高3位=标志(DF/MF),低13位=分片偏移
UCHAR TTL; // 存活跳数,每过一路由器减1
UCHAR Proto; // 上层协议:6=TCP,17=UDP,1=ICMP
USHORT Csum; // 头部校验和
ULONG SrcAddr; // 源 IP
ULONG DstAddr; // 目标 IP
} IP_HEADER;
这个 20 字节的结构体,承载了互联网最核心的路由逻辑。我挑几个有意思的字段讲讲。
Ver_HLen:一个字节干了两个人的活
Ver_HLen 是一个经典的双字段合并技巧。高 4 位是 Version(IPv4 就是 4),低 4 位是 Internet Header Length(以 4 字节为单位)。如果低 4 位是 5,说明头长 = 5 × 4 = 20 字节(没有选项)。如果是 6,头长 = 24(有 4 字节选项),以此类推,最大 15 × 4 = 60 字节。
解析时通常这样写:
cpp
int version = (ip->Ver_HLen >> 4) & 0x0F; // 高4位
int hdr_len = (ip->Ver_HLen & 0x0F) * 4; // 低4位 × 4 = 字节数
Flags_Frag:IP 分片的控制器
高 3 位里最重要的是 bit 14(Don't Fragment, DF)。如果 DF=1,这个包不能被分片,路径上的路由器如果发现 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 = TCP,17 = UDP,1 = ICMP。对于 TCP 包,后面紧跟的是 TCP 头;对于 UDP,后面是 UDP 头;对于 ICMP,后面是 ICMP 头。
这里有一个常被忽视的问题:IP 头之后不一定直接就是传输层头。 如果 IP 头有选项(IHL > 5),选项字节夹在 IP 头和传输层头之间。所以正确的做法是:
cpp
int hdr_len = (ip->Ver_HLen & 0x0F) * 4;
if (ip->Proto == IPPROTO_TCP) {
TCP_HEADER* tcp = (TCP_HEADER*)((uint8_t*)ip + hdr_len);
// ...
}
直接写 (TCP_HEADER*)(ip + 1) 是错的,因为在有 IP 选项的情况下,ip + 1 跳过了 sizeof(IP_HEADER) 而不是实际的头长度。
四、TCP 头:连接的艺术
跳过 IP 头之后,如果 Proto=6,就到了 TCP 头:
cpp
typedef struct _TCP_HEADER {
USHORT SrcPort; // 源端口
USHORT DstPort; // 目标端口
ULONG SeqNum; // 序列号
ULONG AckNum; // 确认号
UCHAR HdrLen; // 高4位=TCP头长度(以4字节为单位)
UCHAR Flags; // SYN/ACK/FIN/RST/PSH/URG/ECE/CWR
USHORT WinSize; // 窗口大小
USHORT Csum; // 校验和
USHORT UrgPtr; // 紧急指针
} TCP_HEADER;
TCP 的标志位体系是互联网最精妙的设计之一,八个 bit 各有分工:
- SYN (0x02):我要建立连接,这是我的起始序号
- ACK (0x10):我收到了你的数据,我的确认号是有效的
- FIN (0x01):我没有更多数据要发了,让我们结束吧
- RST (0x04):这个连接有问题,立刻终止
- PSH (0x08):收到就立刻交给应用层,别缓冲
在透明代理里,我们拦截的第一个包几乎总是 SYN(Flags=0x02)。从这个包我们提取五元组(SrcIP + SrcPort + DstIP + DstPort + Protocol),查 NAT 表,做路由决策。如果决定走代理,我们就"接管"这个连接------不让真实的 SYN 到达目标服务器,而是由我们跟目标服务器建立连接。
HdrLen 的高 4 位是 TCP 头长度(以 4 字节为单位)。解析方法跟 IP 头一样:
cpp
int tcp_hdr_len = ((tcp->HdrLen >> 4) & 0x0F) * 4;
uint8_t* payload = (uint8_t*)tcp + tcp_hdr_len;
五、UDP 头:穷亲戚只有 8 字节
cpp
typedef struct _UDP_HEADER {
USHORT SrcPort;
USHORT DstPort;
USHORT Len; // UDP 数据报总长(含头)
USHORT Csum; // 校验和
} UDP_HEADER;
就四样。没有序号,没有确认号,没有窗口,没有标志位。UDP 的设计哲学是"我尽量简单,剩下的你看着办"。
UDP 的校验和在 IPv4 里是可选的(可以填 0 表示"我不算校验和"),但在 IPv6 里是强制的。这是一个许多程序在从 IPv4 迁移到 IPv6 时会踩的坑。
六、ICMP 头:网络的诊断信使
cpp
typedef struct _ICMP_HEADER {
UCHAR Type;
UCHAR Code;
USHORT Csum;
USHORT Id;
USHORT Seq;
} ICMP_HEADER;
ICMP 最常见的用途是 ping(Type=8 Echo Request, Type=0 Echo Reply)。在我们的软路由里,客户端偶尔会 ping 网关来测试连通性,我们需要正确回应这些 ICMP Echo Request。如果不回应,客户端会认为网关不可达。
我们的处理很简单:Type=8 来的 Echo Request → 改成 Type=0 Echo Reply → 交换 SrcIP 和 DstIP → 重新算校验和 → 发回去。
七、ARP:最会说谎的协议
ARP 的结构体比其他协议都更有趣:
cpp
typedef struct _ARP_HEADER {
USHORT HwType; // 硬件类型:1=以太网
USHORT ProtoType; // 协议类型:0x0800=IPv4
UCHAR HwLen; // 硬件地址长度:6
UCHAR ProtoLen; // 协议地址长度:4
USHORT OpCode; // 操作:1=Request(问), 2=Reply(答)
UCHAR SrcMac[6]; // 发送者 MAC
ULONG SrcIp; // 发送者 IP
UCHAR DstMac[6]; // 目标 MAC(Request 中为全零)
ULONG DstIp; // 目标 IP
} ARP_HEADER;
ARP 请求的处理很简单:如果 SrcIp 在我们的子网内,DstIp 是我们网关的 IP,我们就构造一个 ARP Reply 回应它。这相当于有人在大厅里喊"谁是 192.168.137.1?",我们举手说"我是!这是我的身份证号。"
在我们的代码中,ARP 包被直接透传(m_stat_arp_pass++),因为我们希望网关 IP 对局域网内的设备"可见"。如果拦截了 ARP 不回应,设备会以为网关不存在。
八、指针强转:一个危险但高效的传统
你可能会注意到,我们的代码里大量使用了这种模式:
cpp
char* packet = ...; // 从驱动读到的原始数据
IP_HEADER* ip = (IP_HEADER*)(packet + 14); // 跳过以太网头
TCP_HEADER* tcp = (TCP_HEADER*)((uint8_t*)ip + ip_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的魔法