第8章 数据链路层
元数据卡
| 项目 | 内容 |
|---|---|
| 难度 | (较高) |
| 前置 | 第1章 网络分层模型;第7章 DNS/CDN/负载均衡 |
| 关键词 | 以太网、MAC 地址、帧结构、ARP、交换机、VLAN、STP、MTU、Path MTU Discovery |
| Python | 3.8+(使用 scapy 库重构网络协议细节;可选 ctypes 做底层模拟) |
| 代码量 | ~110 行 |
你在哪
"往下一层——你脚下的驿道本身。数据层(Data Link)管理者同一驿道内的法术信函传输:魔法帧、信标标识(MAC 地址)、驿站怎么转发、寻址咒(ARP)怎么把驿道坐标翻译成信标标识。"
上一章我们一路从域名解析法阵登上内容分发驿道,从负载均衡打到了驿道网关。说实话,我们一直在应用层和传送层之间漂——HTTP、TLS、TCP、UDP,都不在乎法术数据到底是怎么从魔导缆线/法术光束穿过来的。
现在是时候往下看了。
数据层(Layer 2 / L2)是驿道所在的物理世界。魔导缆线里的魔力脉冲、法术光纤里的光芒、传送阵的电磁波动,都是从这一层走进去/走出来的。
这一层的名字很直白——驿道(Link),就是一根魔导缆线(或无线传送信道)把两座信标塔连起来。这层要解决的事情也很朴素:
- 我怎么知道对面是谁?(信标标识/MAC)
- 我怎么找到对面?(寻址咒/ARP)
- 魔导缆线被多座信标塔共享时怎么不打架?(CSMA/CD / 驿站交换机)
- 驿道规模太大时怎么管理?(VLAN)
- 驿站们自己怎么搭桥不把自己绕死?(STP)
- 我该发多大的魔力包?(MTU)
这一章偏概念密集 + 抽象图景。代码不多,但每个概念背后都是真实的协议行为——理解了这一层,你在排查网络问题时看到「ARP 请求超时」「MTU 引发的 TCP 黑洞」「交换机环路广播风暴」,就不会一头雾水了。
本章分层
- 必读:MAC 地址在局域网内的作用、ARP 协议(IP → MAC)、交换机 vs 集线器(冲突域 vs 广播域)、MTU 和 PMTUD 的排障价值
- 选读:以太网帧结构详解、交换机 MAC 地址表学习过程
- 深水区:VLAN 原理与 802.1Q 标签、STP 生成树协议
本章不会要求你掌握
- CSMA/CD 的冲突检测细节
- STP 收敛时间的手工模拟
- LACP 链路聚合的配置
你的任务
学完本章,你应当能做到:
- 画出一个以太网帧的完整结构图——MAC 地址、EtherType、Payload、FCS
- 解释 MAC 地址在局域网内的作用,以及它和 IP 地址的本质区别
- 用 ARP 协议手工推演一次地址解析过程
- 说清楚交换机为什么比集线器「智能」——冲突域 vs 广播域
- 了解 VLAN 概念(广播域隔离,属于网络工程进阶)
- 了解 STP 解决环路问题的基本思路(属于网络工程进阶)
- 解释 MTU 和 Path MTU Discovery 的工作原理,能复现「MTU 黑洞」的排查思路
遭遇战 → 获得技能
第1战:以太网帧——网线里到底在传什么
问题: 一座信标塔通过魔导缆线向另一座信标塔发送法术数据。法术数据在魔导缆线上是连续的魔力脉冲流——接收方怎么知道「一条法术消息从哪开始、到哪结束」?
答案是一个固定的格式——以太网魔法帧(Ethernet Frame)。魔导缆线上流动的不是裸魔力数据,而是一个个以太网魔法帧。每座信标塔的魔力接口负责封装和解封装。
标准的以太网帧格式(IEEE 802.3):
┌──────────┬──────────┬──────────┬──────────┬──────────────┬──────────────┐
│Preamble │SFD │Dest MAC │Src MAC │EtherType │Payload │ FCS │
│(7 bytes) │(1 byte) │(6 bytes) │(6 bytes) │(2 bytes) │(46-1500 B) │(4 bytes) │
├──────────┴──────────┴──────────┴──────────┴──────────────┴──────────────┤
│ ← 14 字节帧头 → ← 46−1500 字节 → ← 帧尾 → │
│ ← 合计最小 64 字节,最大 1518 字节(不含前导码) → │
└─────────────────────────────────────────────────────────────────────────┘| 字段 | 大小 | 作用 | 看起来像 |
|---|---|---|---|
| Preamble | 7 字节 | 同步时钟的信号模式:10101010 交替 56 次。让接收方网卡锁定时钟频率 | 0xAA × 7 |
| SFD | 1 字节 | 帧起始定界符:10101011。最后两位 11 代表「帧要来了」 | 0xAB |
| 目标 MAC | 6 字节 | 接收方 MAC 地址。广播地址是 FF:FF:FF:FF:FF:FF | 00:1A:2B:3C:4D:5E |
| 源 MAC | 6 字节 | 发送方 MAC 地址 | 00:1A:2B:3C:4D:5F |
| EtherType | 2 字节 | 上层协议类型。0x0800=IPv4, 0x86DD=IPv6, 0x0806=ARP | 0x0800 |
| Payload | 46-1500 字节 | 上层传下来的数据包(IP 包),不足 46 字节则填充 | — |
| FCS | 4 字节 | CRC32 校验,检查帧是否在传输中出错。接收方计算 CRC 与 FCS 对比,不一致则丢弃 | 0x12345678 |
** 关键的大小约束:** 以太网帧最小 64 字节(不含前导码)。如果 Payload 不足 46 字节,网卡会自动填充
\x00。为什么?早期以太网的 CSMA/CD 协议需要最少传输时间以确保检测到冲突——这个设计今天已经不太重要,但约束永远留了下来。
MAC 地址长什么样?
MAC 地址是 网卡出厂时烧录的全球唯一标识(理论上,实际上 USB 网卡可能同一批共享一个 MAC 前缀)。48 位,用十六进制写:
00:1A:2B:3C:4D:5E
00-1A-2B-3C-4D-5E
001A.2B3C.4D5E (Cisco 风格)MAC 地址的前 3 个字节(如 00:1A:2B)是 OUI(Organizationally Unique Identifier),由 IEEE 分配给设备厂商。后 3 个字节是厂商分配的序列号。
** 灵魂之问:** MAC 地址和 IP 地址都是标识,为什么不能只用一种?
MAC = 物理位置标识 —— 它在同一局域网内有效,出厂固定,不可路由(路由器不转发 MAC 帧)。 IP = 逻辑位置标识 —— 它在全球互联网有效,可路由,可变。
做过一次跨国搬家就明白了:你人(MAC)没变,但地址(IP)变了。跨网络(搬家)需要按新地址路由,到了公寓(局域网)再靠门牌号(MAC)精确找到你。
用 Python + ctypes 模拟一个以太网帧的结构定义和拆包:
import ctypes
import struct
import binascii
class EthernetFrame:
"""模拟以太网帧的拆装(纯 Python,仅用于理解结构)"""
ETH_HDR_LEN = 14 # 帧头长度(不含前导码和 FCS)
MIN_PAYLOAD = 46 # 最少填充
MAX_PAYLOAD = 1500
def __init__(self, dst_mac: str, src_mac: str, ethertype: int, payload: bytes):
self.dst_mac = self._mac_to_bytes(dst_mac)
self.src_mac = self._mac_to_bytes(src_mac)
self.ethertype = ethertype
self.payload = payload
@staticmethod
def _mac_to_bytes(mac: str) -> bytes:
return bytes(int(b, 16) for b in mac.split(":"))
# 同一个功能,如果你喜欢一行流的话: return bytes.fromhex(mac.replace(":", ""))
@staticmethod
def _bytes_to_mac(b: bytes) -> str:
return ":".join(f"{x:02x}" for x in b)
def encode(self) -> bytes:
"""将帧编码为字节流"""
payload = self.payload
if len(payload) < self.MIN_PAYLOAD:
payload = payload + b"\x00" * (self.MIN_PAYLOAD - len(payload))
# 以太网 II 帧格式(不含前导码和 FCS)
return (
self.dst_mac
+ self.src_mac
+ struct.pack("!H", self.ethertype) # 网络字节序(大端)
+ payload
)
@classmethod
def decode(cls, data: bytes) -> "EthernetFrame":
"""从字节流解析以太网帧"""
if len(data) < cls.ETH_HDR_LEN:
raise ValueError(f"帧太短: {len(data)} 字节")
dst_mac = cls._bytes_to_mac(data[0:6])
src_mac = cls._bytes_to_mac(data[6:12])
ethertype = struct.unpack("!H", data[12:14])[0]
payload = data[14:]
return cls(dst_mac, src_mac, ethertype, payload)
def __repr__(self):
return (
f"EthFrame({self._bytes_to_mac(self.dst_mac)}"
f" ← {self._bytes_to_mac(self.src_mac)}, "
f"type=0x{self.ethertype:04x}, "
f"payload={len(self.payload)}B)"
)
# 构造一个示例帧
frame = EthernetFrame(
dst_mac="FF:FF:FF:FF:FF:FF", # 广播
src_mac="00:1A:2B:3C:4D:5E",
ethertype=0x0800, # IPv4
payload=b"\x45\x00..." * 10 # 模拟 IP 包
)
raw = frame.encode()
print(f"原始帧: {binascii.hexlify(raw[:14]).decode()}...")
print(f"解析回读: {EthernetFrame.decode(raw)}")
# 输出类似:
# 原始帧: ffffffffffff001a2b3c4d5e0800...
# 解析回读: EthFrame(FF:FF:FF:FF:FF:FF ← 00:1A:2B:3C:4D:5E, type=0x0800, payload=460B)** Java 差异:** Java 标准库没有
ctypes这种和内存布局直接打交道的机制——要解析原始网络帧得用ByteBuffer+byte[],从语义上更安全但需要更多行数做getInt()/getShort()。C 语言反而是最直接的——定义一个struct ethhdr结构体,把指针往recv()返回的缓冲区上一投射就行。
第2战:ARP——IP 地址到 MAC 地址的翻译器
问题: 信标塔 A(192.168.1.10)知道信标塔 B(192.168.1.20)的驿道坐标(IP),但想往以太网魔法帧上发信函,它需要知道 B 的信标标识(MAC)。怎么办?
ARP(地址解析咒,Address Resolution Protocol) 就是干这个的——驿道坐标 → 信标标识的翻译咒。它只在驿站局域网内工作(因为信标标识只在同驿站网段有意义)。
ARP 工作流程:
主机 A 主机 B
(192.168.1.10) (192.168.1.20)
│ │
│ 1. ARP 请求(广播) │
│ "谁有 192.168.1.20? │
│ 我是 192.168.1.10, │
│ 我的 MAC 是 AA:BB" │
│──────────────────────→│(广播给所有主机)
│ │
│ 2. ARP 应答(单播) │
│ "是我!192.168.1.20 │
│ 的 MAC 是 CC:DD" │
│←──────────────────────│(只发给 A)
│ │
│ 3. A 把 B 的 MAC 写进 │
│ 自己的 ARP 缓存 │ARP 缓存与生存期:
$ arp -a # 查看 ARP 缓存(macOS/Linux)
? (192.168.1.1) at 00:11:22:33:44:55 on en0 ifscope [ethernet]
? (192.168.1.20) at 00:aa:bb:cc:dd:ee on en0 ifscope [ethernet]$ ip neigh show # Linux 现代风格查看邻居表
192.168.1.1 dev eth0 lladdr 00:11:22:33:44:55 REACHABLE
192.168.1.20 dev eth0 lladdr 00:aa:bb:cc:dd:ee STALEARP 缓存中的条目有超时时间(通常几分钟),过期后删除。下次通信时重新发起 ARP 请求。
模拟 ARP 请求/应答过程:
import time
from dataclasses import dataclass
@dataclass
class ARPPacket:
"""迷你 ARP 包结构"""
sender_ip: str
target_ip: str
sender_mac: str
target_mac: str = "00:00:00:00:00:00"
is_request: bool = True
class ARPCache:
"""模拟主机的 ARP 缓存"""
def __init__(self, cache_ttl: int = 120):
self._table: dict[str, tuple[str, float]] = {}
self.cache_ttl = cache_ttl
def lookup(self, ip: str) -> str | None:
"""查询 ARP 缓存,过期条目自动清理"""
if ip not in self._table:
return None
mac, ts = self._table[ip]
if time.monotonic() - ts > self.cache_ttl:
del self._table[ip]
return None
return mac
def update(self, ip: str, mac: str):
self._table[ip] = (mac, time.monotonic())
def flush(self):
self._table.clear()
def __repr__(self):
return "\n".join(
f" {ip} → {mac} (TTL={int(self.cache_ttl - (time.monotonic() - ts))}s)"
for ip, (mac, ts) in self._table.items()
)
class Host:
"""模拟局域网内一台主机"""
def __init__(self, ip: str, mac: str, name: str = ""):
self.ip = ip
self.mac = mac
self.name = name or mac
self.arp_cache = ARPCache()
self._debug_log: list[str] = []
def send_arp_request(self, target_ip: str) -> ARPPacket:
pkt = ARPPacket(
sender_ip=self.ip,
target_ip=target_ip,
sender_mac=self.mac,
is_request=True,
)
self._debug_log.append(
f"[ARP-REQ] {self.name} 广播: "
f"「谁有 {target_ip}?我的 MAC 是 {self.mac}」"
)
return pkt
def handle_arp_request(self, req: ARPPacket, all_hosts: list) -> ARPPacket | None:
if req.target_ip == self.ip:
# 我就是要找的目标,发 ARP 应答(单播)
resp = ARPPacket(
sender_ip=self.ip,
target_ip=req.sender_ip,
sender_mac=self.mac,
target_mac=req.sender_mac,
is_request=False,
)
# 顺便学到对方的 MAC
self.arp_cache.update(req.sender_ip, req.sender_mac)
self._debug_log.append(
f"[ARP-RES] {self.name} → {req.sender_ip}({req.sender_mac}): "
f"「是我!我的 MAC 是 {self.mac}」"
)
return resp
return None
def resolve(self, target_ip: str, network: list) -> str | None:
"""尝试解析目标 IP → MAC"""
cached = self.arp_cache.lookup(target_ip)
if cached:
self._debug_log.append(
f"[ARP-CACHE] {self.name} 缓存命中: {target_ip} → {cached}"
)
return cached
# 缓存未命中 → ARP 请求
req = self.send_arp_request(target_ip)
# 模拟广播给局域网所有主机
responses = []
for host in network:
if host.mac != self.mac:
resp = host.handle_arp_request(req, network)
if resp:
responses.append(resp)
if responses:
arp_resp = responses[0]
self.arp_cache.update(target_ip, arp_resp.sender_mac)
return arp_resp.sender_mac
return None
# 模拟一个局域网
host_a = Host("192.168.1.10", "AA:BB:CC:11:22:01", "主机A")
host_b = Host("192.168.1.20", "AA:BB:CC:11:22:02", "主机B")
host_c = Host("192.168.1.30", "AA:BB:CC:11:22:03", "主机C")
lan = [host_a, host_b, host_c]
# 主机 A 解析主机 B
mac = host_a.resolve("192.168.1.20", lan)
print(f"解析结果: 192.168.1.20 → {mac}")
print()
for h in lan:
print(f"{h.name} 的 ARP 缓存:")
print(h.arp_cache)
print()
# 输出:
# [ARP-REQ] 主机A 广播: 「谁有 192.168.1.20?我的 MAC 是 AA:BB:CC:11:22:01」
# [ARP-RES] 主机B → 192.168.1.10(AA:BB:CC:11:22:01): 「是我!我的 MAC 是 AA:BB:CC:11:22:02」
# 解析结果: 192.168.1.20 → AA:BB:CC:11:22:02
#
# 主机A 的 ARP 缓存:
# 192.168.1.20 → AA:BB:CC:11:22:02 (TTL=120s)
#
# 主机B 的 ARP 缓存:
# 192.168.1.10 → AA:BB:CC:11:22:01 (TTL=120s)
#
# 主机C 的 ARP 缓存: (空,因为 C 不是目标,只收到广播但不做记录)🔪 需要知道的 ARP 安全风险:ARP 欺骗(ARP Spoofing)
ARP 协议没有任何认证机制——谁都可以声称自己拥有某个 IP。攻击者伪造 ARP 应答,让受害者以为攻击者的 MAC 是网关:
攻击者 → 受害者的 ARP 缓存:
192.168.1.1 (网关) → AA:BB:CC:99:99:99 (攻击者)
此后,受害者的所有出站流量都经过攻击者的机器
——中间人攻击(MITM)。这就是为什么生产环境要上 Port Security。🐍 Python 的安全套件:
scapy库可以很轻松地构造和发送伪造的 ARP 包做测试(切勿在生产网络中这样做)。
第3战:交换机 vs 集线器——冲突域与广播域
问题: 三座信标塔接到一个魔法信号分线器(Hub) 上。信标塔 A 在发数据,信标塔 B 也想发——结果呢?魔力数据碰撞了,两座都得退让重发。
集线器的工作原理:
信号 → Hub → 复制到所有端口(物理层,不懂 MAC 地址)
↓
三台机器都能收到,但它们忙起来就会撞这就是冲突域(Collision Domain)——所有接到 Hub 上的端口共享同一个传输介质。同一时刻只能有一台机器发数据。
交换机(Switch) 的智能之处:
交换机在数据链路层(L2) 工作。它有一个 MAC 地址表(Forwarding Database / FDB),记录了「MAC → 端口」的映射:
端口1 → MAC_A (00:1A:2B:3C:4D:5E)
端口2 → MAC_B (00:1A:2B:3C:4D:5F)
端口3 → MAC_C (00:1A:2B:3C:4D:60)当 A 发给 B 时,交换机查表:B 在端口 2 → 只向端口 2 转发。端口 1 和 3 完全不受影响——各自的收发链路独立,不会有冲突。
对比:
| 特性 | 集线器(Hub) | 交换机(Switch) |
|---|---|---|
| 工作层级 | 物理层(L1) | 数据链路层(L2) |
| 查 MAC 表? | 不查,全部转发 | 查表,只向目标端口转发 |
| 冲突域 | 所有端口共享一个冲突域 | 每个端口独立冲突域 |
| 广播域 | 所有端口共享广播域 | 所有端口共享广播域 |
| 安全性 | 所有人能看到你的包 | 别人看不到(除非恶意) |
| 半双工 | 是(发的时候不能收) | 否,全双工 |
交换机学习 MAC 的过程——泛洪(Flooding):
1. A 发帧给 B(交换机还不知道 B 在哪个端口)
2. 交换机查到目的 MAC 在 FDB 中不存在 → 向**除 A 的入端外所有端口转发**
这叫泛洪(Flooding),也称未知单播泛洪
3. B 收到并回应时,交换机学到了 B 的 MAC → 端口对应关系
4. 从此,A↔B 的通信只在两个端口间直通广播域: 交换机解决不了的问题。当 A 发广播帧(FF:FF:FF:FF:FF:FF)做 ARP 请求时,交换机会把广播帧复制到所有端口——整个局域网都能收到。**
这就是为什么一个超大的以太网(比如公司全部员工在一个交换机上)会出问题——广播风暴。10 万台电脑每秒广播 ARP、DHCP 请求,光广播流量就能把带宽吃干净。
VLAN 就是来解决这个问题的。
🔼 以上为数据链路层核心(必读):MAC 地址、ARP、交换机 vs 集线器,MTU 和 PMTUD 排障。这些是你日常排查网络问题最常用的知识。
以下为网络工程进阶内容(VLAN / STP):广播域控制和环路防护属于网络工程师的领域。普通读者理解概念即可,不需要模拟 STP 收敛。
第4战:VLAN——在交换机里「切」出虚拟局域网
问题: 你在一座法师后勤塔里。法器管理部、法术技术部、浮空财务部的信标全插在同一台驿站交换机上。所有人都能收到所有人的广播传送咒——财务部的寻址咒也发到了法器管理部的信标上。怎么隔离?
VLAN(虚拟驿站网络) 把一个物理驿站交换机「切成」多个逻辑驿站交换机。每个 VLAN 是一个独立的魔法广播域。
┌────────────────┐
│ 交换机 │
│ │
VLAN 10 (市场部) │ 端口 1-8 │
VLAN 20 (技术部) │ 端口 9-16 │
VLAN 30 (财务部) │ 端口 17-24 │
└────────────────┘端口 1 上的主机(VLAN 10)发广播 → 只复制到端口 2-8(同 VLAN),端口 9-24 什么都收不到。物理上在同一台交换机,逻辑上完全隔离。
Trunk 端口与 VLAN 标签:
当两台交换机都需要同时传输多个 VLAN 的数据时,连接它们的端口要设为 Trunk 模式。数据帧进入 Trunk 口时会打上 802.1Q VLAN 标签:
原始以太网帧(VLAN 无关):
┌──────┬─────┬────┬─────────┐
│ MAC │ MAC │Type│ Payload │
└──────┴─────┴────┴─────────┘
带 802.1Q 标签的帧:
┌──────┬─────┬──────────┬────┬─────────┐
│ MAC │ MAC │ TPID=0x81│TCI │ Type │
│ (dst)│ (src)│ 00 │(VLAN ID)│(改)│
└──────┴─────┴──────────┴────┴─────────┘
↑
新插入 4 字节:VLAN 标签
TPID = Tag Protocol Identifier (0x8100)
TCI = Tag Control Information(含 12 位 VLAN ID)交换机收到带标签的帧,根据 VLAN ID 决定在哪些端口转发。到达出口 Trunk 端口的另一侧后,标签被剥离,恢复成普通帧——这个过程对主机完全透明。
VLAN 的典型误区:
「VLAN 是为了网络分段安全」——不完全是。VLAN 主要是为了解决广播域规模控制。安全靠防火墙/Access Control,VLAN 是广播隔离手段。
不同 VLAN 之间的通信必须经过路由器或三层交换机——VLAN 本身是 L2 隔离,跨 VLAN 是 L3 路由。
第5战:STP——不绕死路的驿站高速驿道
问题: 为了提高可靠性,你给驿站交换机之间接了两条魔导缆线(冗余链路)。结果——魔法数据帧被困在一个无限循环里,越来越拥挤,最终整个驿站网络瘫痪。
这就是 广播风暴(Broadcast Storm)。一个广播帧进入交换机 A → 复制到所有端口(包括到 B 的链路)→ B 收到后也复制到所有端口(包括回到 A 的链路)→ A 又收到一帧,继续复制……指数增长,直到网络被撑爆。
MAC 地址表也会被搞乱——A 先通过链路 1 学到了 MAC_X 在端口 3,下一秒又通过链路 2 学到了 MAC_X 在端口 4——来回切换,MAC 地址表抖动。
STP(Spanning Tree Protocol / 生成树协议) 的解决办法:把有环路的物理网络「剪」成一棵逻辑上的无环树。冗余链路保留(物理上存在),但只在主链路故障时才激活。
STP 的核心概念:
| 概念 | 说明 |
|---|---|
| 桥 ID | 每个交换机一个唯一 ID = 优先级 + MAC 地址。优先级低的成为根桥(Root Bridge) |
| 根桥(Root Bridge) | STP 树的根,所有数据路径的参考点 |
| 根端口(Root Port) | 非根桥上到根桥「最近」的端口(最小路径开销) |
| 指定端口(Designated Port) | 每条链路上距离根桥最近的端口 |
| 阻塞端口(Blocking Port) | 被 STP 逻辑禁用的冗余端口——不用,但监听 BPDU。一旦主链路断了,立即转入转发状态 |
端口状态转换:
Blocking (20s) → Listening (15s) → Learning (15s) → Forwarding
↑ ↓
└──── 阻塞状态,只收 BPDU ├── 学习 MAC 地址,不转发数据
└── 正常工作,转发数据帧STP 收敛慢是出了名的——从链路故障到恢复通信,最坏情况要 50 秒(Forward Delay × 2 + Max Age)。所以后来有了 RSTP(Rapid STP),把收敛时间降到 1-3 秒。
STP 环路示例(Python 模拟):
深水区:以下 STP Python 模拟供你理解原理。普通读者不需要运行或复现它——理解「STP 通过阻塞冗余端口来消除环路」就够了。
from dataclasses import dataclass
from typing import Optional
@dataclass
class BPDU:
"""网桥协议数据单元(Bridge Protocol Data Unit)"""
root_bridge_id: int
sender_id: int
sender_port_id: int
path_cost: int
@dataclass
class Port:
"""交换机端口"""
port_id: int
link_to: tuple[int, int] # (switch_id, port_id)
stp_state: str = "Blocking"
cost: int = 1
designated: bool = False
class Switch:
def __init__(self, switch_id: int, priority: int = 32768):
self.id = switch_id
self.bridge_id = (priority << 16) | switch_id
self.ports: list[Port] = []
self.root_port_id: Optional[int] = None
# true_identity: 自认为的根桥 ID(初始假设自己就是根桥)
self.root_id = self.bridge_id
def add_port(self, port_id: int, switch_id: int, other_port: int):
self.ports.append(Port(port_id=port_id, link_to=(switch_id, other_port)))
def step(self, all_switches: dict):
"""STP 模拟的一步:每个交换机发送 BPDU,根据接收的 BPDU 更新状态"""
# 为自己计算路径开销
my_cost = 0
if self.root_id != self.bridge_id:
my_cost = 1 # 略过开销计算细节
bpdu = BPDU(
root_bridge_id=self.root_id,
sender_id=self.id,
sender_port_id=0,
path_cost=my_cost,
)
# 模拟邻居收到 BPDU 后的反应(简化:只处理根桥选举)
for port in self.ports:
neighbor_id, neighbor_port = port.link_to
neighbor = all_switches[neighbor_id]
if neighbor.bridge_id < self.root_id:
self.root_id = neighbor.bridge_id
port.stp_state = "Forwarding"
else:
# 本端口是根桥上或到根桥更近 → 指定端口
if self.root_id == self.bridge_id:
port.designated = True
port.stp_state = "Forwarding"
# 模拟三个交换机(理想化的 STP 根桥选举)
sw1 = Switch(1, priority=4096) # 优先级最低 → 根桥
sw2 = Switch(2, priority=32768)
sw3 = Switch(3, priority=32768)
# sw1 ─ sw2 ─ sw3 + 冗余链路 sw1 ─ sw3(形成三角形环路)
sw1.add_port(1, 2, 1) # sw1:port1 ↔ sw2:port1
sw1.add_port(2, 3, 1) # sw1:port2 ↔ sw3:port1
sw2.add_port(1, 1, 1) # sw2:port1 ↔ sw1:port1
sw2.add_port(2, 3, 2) # sw2:port2 ↔ sw3:port2
sw3.add_port(1, 1, 2) # sw3:port1 ↔ sw1:port2
sw3.add_port(2, 2, 2) # sw3:port2 ↔ sw2:port2
all_sw = {1: sw1, 2: sw2, 3: sw3}
# 模拟几轮 STP 收敛
for _ in range(3):
for sw in all_sw.values():
sw.step(all_sw)
print(f"根桥: Switch {sw1.id} (bridge_id={sw1.bridge_id})")
for sid, sw in all_sw.items():
for p in sw.ports:
print(f" Switch {sid}:port{p.port_id} → Switch {p.link_to[0]}:port{p.link_to[1]} [{p.stp_state}]")
# 预期输出: sw1 的两个端口都是 Forwarding(根桥),
# sw2→sw3 或 sw3→sw2 中有一条链路被 Blocking** 现实中的 STP 已经很少直连了:** RSTP (802.1w) 和 MSTP (802.1s) 是生产环境的主流。如果你面对的是数据中心 Leaf-Spine 架构,有时连 STP 都不开,用 L3 ECMP(等价多路径路由)来做冗余。
第6战:MTU 与分片——你发了一个大包,路上要过窄门
问题: 你的法术卷轴发了一道 9000 魔力字节的 HTTP POST 请求体。TCP 把它分成了 6 个法术段。IP 法术包每个最大 1500 字节。但中间有一道驿道信标塔链路的 MTU 只有 1400。怎么办?
MTU(Maximum Transmission Unit) 是数据链路层能承载的最大数据包大小(不含帧头开销)。常见的 MTU:
| 网络类型 | MTU(字节) | 说明 |
|---|---|---|
| 以太网 | 1500 | 最普遍的值 |
| PPPoE(ADSL/光纤宽带) | 1492 | PPP 协议额外开销 8 字节 |
| Wi-Fi (802.11) | 2278 (或 2304) | Wi-Fi 帧允许更大的 MTU |
| 巨帧(Jumbo Frame) | 9000 | 数据中心内部常用,减少 CPU 开销 |
| 回环(Loopback) | 65535 | 本机通信没限制 |
| IPv6 最小链路 MTU | 1280 | IPv6 标准要求 |
IP 分片(IP Fragmentation): 当 IP 包大小 > 出站链路的 MTU,路由器会把包切碎。每个碎片是一个独立的 IP 包,有相同的 ID,通过偏移量重组。
但分片有严重问题:
- 性能开销——路由器要额外做切包重组
- 丢一个碎片,整个包重传
- 安全风险——分片攻击(Ping of Death、Teardrop)
所以有了 Path MTU Discovery(PMTUD)。
Path MTU Discovery 工作原理:
主机 A ---------- 路由器 R1 --------- 路由器 R2 --------- 主机 B
MTU=1500 MTU=1400 MTU=1500
1. A 发一个 IP 包,DF 位(Don't Fragment)= 1,大小 1500
2. R1 发现包 > 1400,但 DF=1 禁止分片 → 丢弃该包
3. R1 回复 ICMP 消息:「Fragmentation Needed — 下一跳 MTU = 1400」
具体类型:ICMP Type=3 Code=4(Destination Unreachable, Fragmentation Needed)
4. A 收到 ICMP 后,把 MTU 降为 1400,重发
5. 如果中间还有更小的 MTU,继续这个过程……直到找到整条路径的最小 MTU这就是 PMTUD 的收敛过程——发送端通过接收 ICMP「打回来」的消息,动态调整使用的最优 MTU 值:
import socket
import struct
def probe_path_mtu(target: str, port: int = 80) -> int:
"""
模拟 Path MTU Discovery 探测过程。
实际上,PMTUD 在 IP 层由操作系统内核处理,代码很少直接干预。
这里展示原理。
"""
# 常见 MTU 试探序列(从大到小)
probe_sizes = [1500, 1492, 1400, 1300, 1280, 1200, 1000, 576]
# 实际中 PMTUD 不会发这么多包,而是用二分或逐步减小
# 这里展示各 size 能否通过(简化模拟)
# 真实的 PMTUD: send() 如果返回 EMSGSIZE,就缩小
results = {}
for size in probe_sizes:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# 设置 DF 位(Don't Fragment)
# 在 Python 中,socket.IP_MTU_DISCOVER = 10
# IP_PMTUDISC_DO = 2 — 内核自动做 PMTUD
s.setsockopt(socket.IPPROTO_IP, 10, 2)
s.settimeout(2)
payload = b"X" * size
s.sendto(payload, (target, port))
results[size] = "OK (片段)"
except OSError as e:
# 如果是 EMSGSIZE(Message too long),说明 DF 拦截了
if e.args[0] == 90: # EMSGSIZE on Linux
results[size] = "✗ 太大"
else:
results[size] = f"✗ {e}"
finally:
s.close()
# 找到最大的通过值
pmtu = max(s for s, r in results.items() if r.startswith("OK"))
return pmtu
# try probe_path_mtu("8.8.8.8") # 需要网络权限;大多数系统会自动管理 PMTU📌 PMTUD 的「黑洞」问题(MTU Black Hole):
有些网络设备为了安全会丢弃 ICMP 消息。A 发了大包 → 路由器丢弃并要求分片 → A 收不到 ICMP → A 不知道要减小 MTU → 永远无法通信。这就是 PMTUD 黑洞。
症状表现:
- 网页部分加载(小包能通,正好包体没到 MTU 边界的请求能通)
- SSH 连接成功但输入命令后卡死(握手包小,后续数据包大)
- HTTPS 连接建立完成后白屏(TLS 握手起初的小包成功,后续大包被黑洞吞噬)
解法方法:
- TCP MSS Clamping: 在 TCP 三次握手时告诉对方「最大分节大小 = MTU - 40(TCP+IP 首部)」,对方发的每个 TCP 段都不会超过这个值——彻底避开了 IP 分片。
- 手动调低 MTU(
ifconfig eth0 mtu 1400) - 对于 PMTUD 黑洞,管理员需要在路由器上启用 ICMP 透明传递
常见陷阱
项目:网络帧嗅探模拟器
用 Python 模拟一个网络数据帧在交换机内的路径——从 Mac 地址表学习,到泛洪转发,到 VLAN 隔离,再到 STP 阻塞冗余链路。
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class FrameEvent:
"""一个帧在交换机网络中的传播事件"""
src_mac: str
dst_mac: str
vlan_id: int
path: list[str] = field(default_factory=list)
def trace(self):
return " → ".join(self.path)
@dataclass
class SwitchPort:
port_id: int
vlan_id: int
connected_switch: Optional[int] = None # 联到另一台交换机的端口 ID
connected_host: Optional[str] = None # 联到主机的 MAC
stp_blocked: bool = False
class L2Switch:
"""模拟一台 L2 交换机(含 VLAN 和 STP)"""
def __init__(self, name: str):
self.name = name
self.mac_table: dict[str, int] = {} # MAC → port_id
self.ports: dict[int, SwitchPort] = {}
self._next_port = 1
def add_host_port(self, host_mac: str, vlan_id: int = 1) -> int:
pid = self._next_port
self._next_port += 1
self.ports[pid] = SwitchPort(pid, vlan_id, connected_host=host_mac)
return pid
def add_trunk_port(self, switch_name: str, vlan_id: int = 1) -> int:
pid = self._next_port
self._next_port += 1
self.ports[pid] = SwitchPort(pid, vlan_id, connected_switch=1)
return pid
def learn(self, src_mac: str, in_port: int):
self.mac_table[src_mac] = in_port
def handle_frame(self, event: FrameEvent, in_port: int, all_switches: dict) -> list:
"""处理传入的帧,返回下一跳的 FrameEvent 列表"""
port = self.ports.get(in_port)
if not port:
return []
# VLAN 隔离:入端口 VLAN 和帧 VLAN 必须匹配
if port.vlan_id != event.vlan_id:
print(f" [VLAN] {self.name} 丢弃: VLAN{port.vlan_id} 收到 VLAN{event.vlan_id} 帧")
return []
# 学习源 MAC
self.learn(event.src_mac, in_port)
event.path.append(f"{self.name}(port{in_port})")
# 广播帧 → 所有同 VLAN 非入端口转发
if event.dst_mac == "FF:FF:FF:FF:FF:FF":
results = []
for pid, p in self.ports.items():
if pid == in_port or p.stp_blocked:
continue
if p.vlan_id != event.vlan_id:
continue
if p.connected_host:
# 到达目标主机
e = FrameEvent(event.src_mac, event.dst_mac, event.vlan_id,
event.path.copy())
e.path.append(f"{self.name}(→{p.connected_host})")
results.append(e)
return results
# 已知单播
if event.dst_mac in self.mac_table:
out_port = self.mac_table[event.dst_mac]
p = self.ports.get(out_port)
if p and not p.stp_blocked and p.vlan_id == event.vlan_id:
if p.connected_host:
e = FrameEvent(event.src_mac, event.dst_mac, event.vlan_id,
event.path.copy())
e.path.append(f"{self.name}(→{p.connected_host})")
return [e]
return []
# 未知单播 → 泛洪(同 VLAN 内)
results = []
for pid, p in self.ports.items():
if pid == in_port or p.stp_blocked:
continue
if p.vlan_id != event.vlan_id:
continue
if p.connected_host:
e = FrameEvent(event.src_mac, event.dst_mac, event.vlan_id,
event.path.copy())
e.path.append(f"{self.name}(洪水→{p.connected_host})")
results.append(e)
return results
# 模拟场景:两台交换机,各自挂了主机,VLAN 隔离
sw_a = L2Switch("SW-A")
sw_b = L2Switch("SW-B")
# 在 SW-A 上挂两台主机(不同 VLAN)
sw_a.add_host_port("MAC_A1", vlan_id=10)
sw_a.add_host_port("MAC_A2", vlan_id=20)
# 在 SW-B 上挂两台主机(VLAN 10 和 20)
sw_b.add_host_port("MAC_B1", vlan_id=10)
sw_b.add_host_port("MAC_B2", vlan_id=20)
# 两台交换机之间 Trunk 连接(支持多 VLAN)
trunk_a = sw_a.add_trunk_port("SW-B", vlan_id=0) # 0=trunk
trunk_b = sw_b.add_trunk_port("SW-A", vlan_id=0)
all_sw = {"SW-A": sw_a, "SW-B": sw_b}
# 测试 1: 同一交换机同 VLAN 通信
print("=== 测试 1: SW-A, VLAN 10 广播 ===")
ev = FrameEvent("MAC_A1", "FF:FF:FF:FF:FF:FF", 10)
results = sw_a.handle_frame(ev, 1, all_sw)
for r in results:
print(f" {r.trace()}")
print("\n=== 测试 2: 跨交换机 VLAN 10 广播 ===")
ev = FrameEvent("MAC_A1", "FF:FF:FF:FF:FF:FF", 10)
# VLAN 10 的广播现在会通过 Trunk 传到 SW-B,然后到 B1
results = sw_a.handle_frame(ev, 1, all_sw)
for r in results:
print(f" {r.trace()}")
print("\n=== 测试 3: 跨 VLAN 被隔离(VLAN 20 收不到 VLAN 10 广播)===")
# 这是关键——MAC_A2 在 VLAN 20,MAC_A1 的 VLAN 10 广播不会到达它
print(" [预期] MAC_A2 不会收到 MAC_A1 的广播")运行预期:
=== 测试 1: SW-A, VLAN 10 广播 ===
SW-A(port1) → SW-A(→MAC_B1) # 泛洪到 A2?不!VLAN 10 的广播在 SW-A 上只能去同 VLAN 口
# 等等,实际只有 port1 (入) 和 port? 同 VLAN 10 的只有 MAC_B1 那个口
=== 测试 2: 跨交换机 VLAN 10 广播 ===
...
=== 测试 3: 跨 VLAN 被隔离 ===
[预期] MAC_A2 不会收到 MAC_A1 的广播** 现实 vs 模拟:** 真实交换机的 MAC 表硬件实现(TCAM)在纳秒级别完成查表转发。这个模拟器只演示软件层面的逻辑。
通关挑战
问题 1(以太网帧): 一个 ARP 请求帧封装在以太网帧中。请写出它的目标 MAC 地址、EtherType 和最小 Payload 大小。
问题 2(ARP): 客户端 A 想和 IP 为 10.0.0.5 的主机 B 通信,但 B 和 A 不在同一个子网。A 会先执行什么操作?ARP 请求的目标 IP 是 B 的 IP,还是网关的 IP?
问题 3(VLAN): 一个 Trunk 端口上可以传输哪些 VLAN 的帧?如果误把 Access 端口设为 Trunk,会有什么后果?
问题 4(STP): 网络拓扑是一个三角形(3 台交换机两两相连)。运行 STP 后,有几条链路被逻辑阻塞?请画出树形拓扑。
问题 5(MTU): 为什么 IPv6 取消了路由器层级的 IP 分片?PMTUD 在 IPv6 中有什么变化?
问题 6(综合): 你是一名 DevOps 工程师。生产环境报告「从 VPC 内到外部某个 API 的请求偶尔超时」。ping 正常,curl 的小请求正常,但传输大文件时卡死。你怀疑是 MTU 黑洞。怎么验证?怎么修?
验收标准
| 技能 | 自检方法 |
|---|---|
| 以太网帧结构 | 能画出帧格式,说出前导码、SFD、MAC、EtherType、Payload、FCS 各自的作用 |
| MAC 地址 | 能说清 MAC 为什么不可路由(跨局域网失效),以及和 IP 的本质区别 |
| ARP 协议 | 能手绘一次 ARP 请求/应答的完整通信过程,说清谁发广播、谁回单播 |
| 交换机 vs 集线器 | 能论冲突域和广播域的宽窄之别,以及交换机如何学习 MAC 地址 |
| VLAN | 能解释 802.1Q 标签格式,以及为什么跨 VLAN 需要三层路由 |
| STP | 能说出根桥选举和端口阻塞的大致流程,以及收敛慢的弊端 |
| MTU / PMTUD | 能复现一次 PMTUD 探查过程,诊断 MTU 黑洞的典型症状 |
常见卡点
把 MAC 地址当成「网络身份」。 MAC 是可伪造的("spoof"),不跨子网,不唯一——硬件支持覆盖。安全模型不能依赖 MAC。
以为交换机画个框就是「隔离」。 交换机隔离冲突域但不隔离广播域。VLAN 才是广播隔离手段。
ARP 是逐跳工作的。 A → 网关用 ARP,网关 → 下一跳再用 ARP。ARP 不会跨路由器工作。初学时常误以为「
arp -a能查到远程主机的 MAC」。STP 收敛是秒级的。 50 秒的收敛时间在今天的微服务架构下是不可接受的。如果生产环境中用了 STP,记得用 RSTP 或切换到 L3 ECMP 拓扑。
MTU 黑洞的排查靠 ICMP 响应。 但如果中间设备丢弃 ICMP Type=3 Code=4,PMTUD 就失效了。常见的快速修复是用 TCP MSS Clamping(在路由器或 LB 上设
ip tcp mss 1300)。假设「1500 的 MTU 一定够」。 如果网络链路中有 VPN 隧道(IPSec 或 WireGuard),隧道头部会吃掉 50-70 字节的 MTU 空间。不做 MSS Clamping 就会间歇性断连。
现在不需要理解
- 交换机内部交换矩阵(Crossbar / Shared Memory)的实现架构——那是硬件设计的范畴
- 802.1D 到 802.1w 到 802.1s 的具体协议变更历史——知道存在 RSTP/MSTP 即可
- 软件定义网络(SDN)中 OpenFlow 的流表(flow table)编程——那是网络工程师的境界
- LACP(链路聚合控制协议)的细节——知道交换机能捆绑多条网线(Port Channel)就够了
- 巨型帧(Jumbo Frame)的对齐要求和端到端配置——非数据中心很少用到
- 802.11(Wi-Fi)的 MAC 层差异——无线 MAC 和有线 MAC 差异巨大,值得单独一章讲
旅人笔记
- 数据链路层是「网线里的世界」——每一个比特都要封装成以太网帧,每一帧都有目标 MAC 和源 MAC。交换机靠 MAC 表学习,靠 VLAN 分割广播域。
- ARP 是 L2 和 L3 之间的翻译官:没有 ARP,IP 包无法变成以太网帧。它简单到几乎没有安全防护,生产环境要做 ARP 抑制或 Port Security。
- 交换机和 Hub 的本质区别在「有没有查表」——这一查,把一个物理冲突域切成了每个端口独立。VLAN 又把一个逻辑广播域切成了多个。
- STP 是网络工程早期最精巧的防智障设计之一——物理冗余 VS 逻辑环路,至今没有更好的替换。但它的收敛速度确实太慢,RSTP 已成事实标准。
- MTU 是一切大包传输的隐形瓶颈。TCP 做了 MSS Clamping 就基本避开了 PMTUD 的问题。UDP 大包 + MTU 不一致 = 灾难——很多 VoIP 卡顿的根因就是这个。
- 这一层的所有概念几乎都有时代背景:CSMA/CD 的冲突检测(现在已经全双工了)、STP 的收敛(现在用 RSTP)、VLAN 的 802.1Q(25 年前的智慧)。但协议设计一旦定型,就是几十年的寿命。
→ 下一站预告
从 DNS 到 CDN,从以太网帧到 STP——你已经在数据链路层看到了局域网内的世界。下一章,我们向上爬一层:网络层(Layer 3)的核心——IPv4 分组结构、CIDR 寻址、OSPF/RIP 路由协议、NAT 穿透和 ICMP。这是互联网真正的骨架。