第9章 网络层深入
叙事密度:中高 | 主语言:Python | Java/C++ 差异窗 | Vol 4·计算机网络
元数据卡
| 属性 | 内容 |
|---|---|
| 卷号 | Vol 4 — 计算机网络 |
| 章节 | 第9章:网络层深入 |
| 前置 | 第1章(网络分层模型)、第3章(TCP 基础) |
| 后置 | 第10章:TCP 拥塞控制 |
| 理论深度 | (4/5) |
| Python 相关度 | ≈70%;构造 IP 包、分片模拟、路由收敛模拟 |
| 核心概念 | IP 分片、CIDR、NAT、ICMP、距离向量、链路状态、BGP |
| 代码量 | ~150 行 |
你在哪
"再上一层的视角——驿道层。法术坐标寻址、驿道路由协议、传送门地址转换(NAT)——你正在看的是整张驿道的地图规划。信标塔之间怎么知道哪条驿道路径最短?"
前几章你一直在传送咒的教室里转悠——三次法力握手、滑动卷轴窗口、魔力流量控制——TCP 像一个称职的信使法师,承诺你的法术信函会按序到达。
但你有没有想过一个问题:信使法师怎么知道把法术信函送到哪座信标塔?
答案是网络层(IP 驿道层)。这一层负责两件事:
- 寻址——给每座信标塔一个全球唯一的「驿道门牌号」(IP 地址)
- 路由——决定从法师塔到信标塔,你的法术信函该走哪几座中转驿站
TCP 负责"到了没有,顺序对不对"。IP 负责"往哪儿走"。没有 IP,TCP 就是在空荡荡的驿道地图上写信——信永远到不了。
这一章,我们打开 IP 这颗法术水晶,一层层剥:从比特结构到分片重组,从子网划分到全球路由表。
本章分层
- 必读(IP 基础):IPv4 首部核心字段(TTL、Protocol、地址)、CIDR 子网划分、NAT 原理、ICMP 与 ping/traceroute
- 进阶(路由):距离向量 vs 链路状态(RIP vs OSPF 的直觉)
- 深水区:IP 分片与重组、BGP 路径向量与 AS_PATH
本章不会要求你掌握
- BGP 的具体选路策略(只需理解「互联网路由由自治系统策略决定」的直觉)
- OSPF LSA 的 11 种类型
- IPv6 扩展首部
你的任务
学完本章,你应当:
- 手绘 IPv4 首部,说出每个字段的位置、长度、意义
- 计算分片偏移,理解 MTU 和 DF 位的作用
- 用 CIDR 表示法写出任意子网范围和广播地址
- 解释 NAT 如何用一个公网 IP 养一百个设备
- 实现一个命令行版 ping/traceroute 的最小模型(ICMP 的应用)
- 口述距离向量和链路状态路由的本质区别
- 理解 BGP 的核心直觉:互联网路由不是最短路径算法,而是自治系统之间的商业策略决策
遭遇战 → 获得技能
第1战:IPv4 首部——网络层的身份证
问题: 你的法师观测镜向 93.184.216.34(example.com)发送了一道 HTTP 法术请求。这道请求到了 IP 驿道层,会变成一个什么样的法术数据包?
数据包的前 20 字节(不含选项)是 IPv4 首部。用 Python 的结构化视角看它:
# IPv4首部的最小模型——纯Python,无需任何库
import struct, socket
class IPv4Header:
"""将20字节的IPv4首部分解为命名字段"""
FMT = '!BBHHHBBH4s4s' # network byte order (big-endian)
def __init__(self, raw: bytes):
(ver_ihl, dscp_ecn, total_len, ident,
flags_frag, ttl, proto, checksum,
src, dst) = struct.unpack(self.FMT, raw[:20])
self.version = ver_ihl >> 4 # IPv4 = 4
self.ihl = ver_ihl & 0x0F # 首部长度(×4字节)
self.dscp = dscp_ecn >> 2 # 差分服务码点
self.ecn = dscp_ecn & 0x03 # 显式拥塞通知
self.total_length = total_len
self.id = ident
self.flags = flags_frag >> 13
self.fragment_offset = flags_frag & 0x1FFF
self.ttl = ttl
self.protocol = proto # 6=TCP, 17=UDP
self.checksum = checksum
self.src_addr = socket.inet_ntoa(src)
self.dst_addr = socket.inet_ntoa(dst)
def __repr__(self):
return (f"IPv4({self.src_addr}→{self.dst_addr} "
f"proto={self.protocol} len={self.total_length})")请消化每个字段的位置和意义。下面重点拆几个关键字段:
| 字段 | 长度 | 含义 | 你未来会遇到它的地方 |
|---|---|---|---|
| Version | 4bit | 永远=4 或 6 | 网卡收到的第一个判断 |
| IHL | 4bit | 首部长度(单位4字节,最小5=20B) | 包解析的起始偏移 |
| Total Length | 16bit | 整个IP包长度(含数据) | 超过MTU就需要分片 |
| Identification | 16bit | 分片所属的原始包ID | 分片重组靠它 |
| Flags | 3bit | DF=不分片, MF=还有分片 | MTU探测 |
| Fragment Offset | 13bit | 本分片在原始数据中的偏移(×8字节) | 重组时排序 |
| TTL | 8bit | 每跳减1,到0丢弃 | traceroute的核心 |
| Protocol | 8bit | 上层协议编号 | 6=TCP, 17=UDP, 1=ICMP |
| Header Checksum | 16bit | 每跳重算(验证首部完整性) | 路由器开销之一 |
Java 差异窗:Java 标准库不提供直接构造/解析 IPv4 首部的 API——你在
java.net里拿到的已经是传输层的数据流了。要用java.nio.ByteBuffer手握pack/unpack裸字节,或依赖pcap4j(JNI 包装 libpcap)。C 可以<netinet/ip.h>struct iphdr直接映射。Python 的struct比 C 的位域更可移植(C 位域的内存布局是 implementation-defined)。
第2战:IP 分片——当数据包大于 MTU
问题: 你的信标塔 MTU 是 1500 魔力字节(以太网最常见值)。应用层发了 4000 字节数据。经过 TCP 传送咒封装(20B TCP 首部)+ IP 封装(20B IP 首部)= 4040 字节。一个法术包塞不下。
IP 驿道层把这 4000 字节 TCP 数据切成三片:
原始 4040 字节 = 20(IP) + 20(TCP) + 4000(数据)
MTU = 1500 → 每片 IP 数据载荷 ≤ 1480 字节
片1: IP(20) + 数据[0:1480] Offset=0 MF=1
片2: IP(20) + 数据[1480:2960] Offset=185 MF=1
片3: IP(20) + 数据[2960:4000] Offset=370 MF=0关键规则:
- 分片偏移单位 = 8 字节。所以 1480/8 = 185,不是 1480。
- 除了最后一片,MF=1(More Fragments = 1)
- DF=1 时路由器不分片——收到超过 MTU 的包直接丢弃 + 回 ICMP 错误。这正是
traceroute和 PMTUD(Path MTU Discovery)的原理。 - 重组由接收端完成! 路由器只负责转发,不负责拼回去。
模拟分片:
def fragment(data: bytes, mtu: int, ip_header_len: int = 20):
"""将IP载荷按MTU分片,返回分片列表[(offset, mf, payload)]"""
max_payload = mtu - ip_header_len
assert max_payload >= 8, "MTU太小,连至少8字节载荷都放不下"
max_payload = (max_payload // 8) * 8 # 对齐到8的倍数
fragments = []
offset = 0
while offset < len(data):
size = min(max_payload, len(data) - offset)
mf = 1 if offset + size < len(data) else 0
fragments.append((offset // 8, mf, data[offset:offset + size]))
offset += size
return fragments
# 测试:以太网MTU=1500分片4000B数据
for off, mf, payload in fragment(b'x' * 4000, 1500):
print(f"OFF={off:4d} MF={mf} payload_len={len(payload)}")输出:
OFF= 0 MF=1 payload_len=1480
OFF= 185 MF=1 payload_len=1480
OFF= 370 MF=0 payload_len=1040Java 差异窗:Java
DatagramPacket的setSendBufferSize不会帮你分片——你得自己分。IPv6 禁止路由器分片(只有发送端可以做 Path MTU Discovery)。C 用setsockopt(fd, IPPROTO_IP, IP_MTU_DISCOVER, &val, ...)控制 DF 位。
理解关键:分片是网络层的功能,不是传输层的。TCP 在 MSS(= MTU - IP首部 - TCP首部)以内发送,恰恰是为了避免 IP 分片。因为分片丢一个就全丢——性能灾难。
第3战:子网掩码与 CIDR——让地址不再浪费
问题: 早期驿道用 A/B/C 类地址(classful):A 类 /8 ≈ 1600 万个驿道坐标给一个组织,B 类 /16 ≈ 6.5 万,C 类 /24 ≈ 254 个。一座法师塔需要 300 个坐标——给他 B 类浪费 6 万多个,给他 C 类不够。怎么办?
CIDR(无类别域间路由) 解决这个问题:不再固定 /8/16/24,而是任意前缀长度。
192.168.1.0/24 → 子网掩码 255.255.255.0 → 256个地址(254可用)
10.0.0.0/8 → 子网掩码 255.0.0.0 → 1600万个地址
203.0.113.0/28 → 子网掩码 255.255.255.240 → 16个地址(14可用)子网掩码 = 前缀全 1 + 主机位全 0:
/24 = 11111111.11111111.11111111.00000000 = 255.255.255.0
/28 = 11111111.11111111.11111111.11110000 = 255.255.255.240用 CIDR 算地址范围:
def cidr_range(cidr: str):
"""给定 '192.168.1.14/28',返回网络地址与广播地址"""
ip_str, prefix = cidr.split('/')
prefix = int(prefix)
# IP → 32位整数
ip_int = int.from_bytes(
bytes(int(x) for x in ip_str.split('.')), 'big')
# 掩码 = 前缀位全1
mask = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF
network = ip_int & mask
broadcast = network | (~mask & 0xFFFFFFFF)
return (socket.inet_ntoa(network.to_bytes(4, 'big')),
socket.inet_ntoa(broadcast.to_bytes(4, 'big')))
print(cidr_range('192.168.1.14/28'))
# → ('192.168.1.0', '192.168.1.15') 16个地址易错:网络地址 = 主机位全 0,广播地址 = 主机位全 1。这两个地址不能分配给设备。所以 /28 可用的只有 14 个地址。
CIDR 还允许路由聚合(route aggregation / supernetting):
200.0.0.0/22 = 200.0.0.0/24 + 200.0.1.0/24 + 200.0.2.0/24 + 200.0.3.0/24如果不聚合,路由器要为这四个 /24 维护四条路由条目。聚合后一条就够了。互联网的全球路由表从 1990 年代的几千条膨胀到今天超过 100 万条——没有 CIDR 聚合,路由器早炸了。
Java/C++ 差异窗:Java 的
InetAddress和Inet4Address没有直接给出子网范围的方法——你得用InetAddress.getAddress()拿byte[4]自己转 int。C 有inet_pton()(presentation to numeric)和inet_ntop()(反向),搭配struct sockaddr_in操作。Python 的ipaddress模块(标准库)把这个全包了:ipaddress.IPv4Network('192.168.1.0/28')一步到位。
第4战:NAT/NAPT——一个公网 IP 养活一家人
问题: IPv4 驿道坐标只有 43 亿个。全球信标塔早就超过这个数了。你家驿道信标塔只有一个公网坐标,但法术通讯石、法术书、魔力监视水晶、智能法灯都能上网。怎么办?
NAT(传送门地址转换) 的答案:在驿道信标塔上把内网坐标(如 192.168.1.x)翻译成一个公网坐标。
NAPT(网络地址端口转换) 更精妙——用端口号区分不同内网连接:
内网: 192.168.1.101:54321 → 路由器外网: 203.0.113.5:65000
内网: 192.168.1.102:54321 → 路由器外网: 203.0.113.5:65001核心数据结构——NAT 映射表(路由器内存里的一张哈希表):
class NATSession:
"""NAT 映射表的一条记录"""
def __init__(self, internal: tuple, external: tuple):
self.internal = internal # (ip, port)
self.external = external # (ip, port)
self.last_seen = time.time() # 超时回收
class NAPT:
def __init__(self, public_ip: str):
self.public_ip = public_ip
self.next_port = 49152
self._table: dict[tuple, NATSession] = {}
self._reversed: dict[tuple, NATSession] = {}
def translate_outbound(self, src_ip: str, src_port: int,
dst_ip: str, dst_port: int) -> tuple:
"""内→外:为 (内IP,内端口,外IP,外端口) 分配唯一的外网端口"""
key = (src_ip, src_port, dst_ip, dst_port)
if key in self._reversed:
session = self._reversed[key]
else:
ext_port = self.next_port
self.next_port += 1
session = NATSession((src_ip, src_port), (self.public_ip, ext_port))
self._table[(self.public_ip, ext_port)] = session
self._reversed[key] = session
session.last_seen = time.time()
return session.externalNAT 的副作用:外部不能主动连接内网设备——映射表里没有记录。这就是为什么你家的 NAS / 摄像头需要「打洞」或 UPnP / 端口转发。
IPv6 的优雅解决:128 位地址 ≈ 3.4×10³⁸ 个,理论上地球每粒沙子都能分一个唯一地址。IPv6 恢复了端到端透明的原始设计——不再需要 NAT。这也是为什么 IPv6 普及后 NAT 应该消失。但现实是……很多防火墙已经离不开了("NAT 就是防火墙"这种错误认知)。
Java 差异窗:Java 没有暴露 NAT 穿透的 API——那是操作系统的事情。NAT 穿透(STUN/TURN/ICE)在 Java 里要用
ice4j或全权交给 WebRTC。Python 可以用pystun3做 STUN 打洞测试。
第5战:ICMP——网络层的信使
问题: 你的法术信函发出去,中间驿道信标塔发现传送生命值(TTL)=0 了,怎么办?目标驿道坐标不存在,怎么通知发送方?
ICMP(驿道控制消息咒) 就是干这个的。它是驿道层的「带外控制通道」——不传应用法术数据,只传错误和诊断信息。
两个经典工具建立在 ICMP 之上:
ping —— 用 ICMP Echo Request/Reply
import socket, struct, time
def ping_once(host: str, timeout: float = 2.0) -> float | None:
"""发送 ICMP Echo Request 并等待 Reply,返回 RTT 毫秒"""
try:
dest = socket.gethostbyname(host)
except socket.gaierror:
return None
# ICMP Echo Request 需要 raw socket(需要 root)
# 构造 ICMP 首部: type(8=Echo) + code(0) + checksum + id + seq
icmp_id = 0x1234
seq = 1
payload = struct.pack('!d', time.time()) # 时间戳用于RTT
header = struct.pack('!BBHHH', 8, 0, 0, icmp_id, seq)
checksum = ip_checksum(header + payload) # 见下
header = struct.pack('!BBHHH', 8, 0, checksum, icmp_id, seq)
sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP)
sock.settimeout(timeout)
try:
sock.sendto(header + payload, (dest, 0))
received, addr = sock.recvfrom(1024)
# IP首部(20B) + ICMP首部(8B) = 28B
rtt = (time.time() - struct.unpack('!d', received[28:36])[0]) * 1000
return rtt
except socket.timeout:
return None
finally:
sock.close()为什么 ping 不用任何端口? 因为 ICMP 是网络层协议,不是传输层。它没有端口的概念。
telnet或nc基于 TCP,ping 基于 ICMP——两者是不同层的东西。Java 的InetAddress.isReachable()在 Linux 上默认用 TCP Echo(端口 7),不是 ICMP ping——所以它经常返回假阴性。
traceroute —— 用 TTL 逐步耗尽
def traceroute(host: str, max_hops: int = 30):
"""利用 TTL 逐步递增 + ICMP Time Exceeded 回显路由路径"""
dest = socket.gethostbyname(host)
for ttl in range(1, max_hops + 1):
# 发送端 socket(UDP 到高端口)
send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM,
socket.IPPROTO_UDP)
send_sock.setsockopt(socket.SOL_IP, socket.IP_TTL, ttl)
send_sock.settimeout(3)
# 接收端 socket(监听 ICMP 回显)
recv_sock = socket.socket(socket.AF_INET, socket.SOCK_RAW,
socket.IPPROTO_ICMP)
recv_sock.settimeout(3)
recv_sock.bind(('', 0))
send_sock.sendto(b'', (dest, 33434 + ttl)) # 经典高端口
try:
data, addr = recv_sock.recvfrom(512)
print(f"{ttl:2d} {addr[0]}")
except socket.timeout:
print(f"{ttl:2d} *")
finally:
send_sock.close()
recv_sock.close()traceroute 原理:发送 TTL=1 的 UDP 包到高端口 → 第一跳路由器 TTL 减到 0 → 丢弃 → 回 ICMP Time Exceeded(type=11) → 发送端记录该路由器 IP → TTL=2 → 到第二跳……直到目标端口不可达回 ICMP Port Unreachable(type=3 code=3)为止。
🔼 以上为 IP 基础(必读):IPv4 首部、CIDR 子网划分、NAT 原理、ICMP。你能用
ping和traceroute排障了,理解了 NAT 是如何解决地址短缺的。
以下为路由进阶(选读):距离向量、链路状态、BGP。这些内容让你理解互联网如何路由,但对日常排障来说非必须。
第6战:路由算法——路由器怎么知道往哪转发
问题: 上海驿道的信标塔收到一道去 93.184.216.34 的法术信函。它面前有 8 根法术光纤连到不同的驿道信标塔。它怎么选?
驿道路由咒语的核心是两个问题:
- 学——驿道信标塔之间如何交换可达信息
- 算——根据交换的信息计算最佳路径
距离向量(Distance Vector)——RIP
直觉:每个路由器只知道自己和邻居之间的距离。它定期把「我已知的最短路径表」告诉所有邻居。邻居更新自己的表——如果听到更短的路径就替换。
Bellman-Ford 方程:
d(x, y) = min{ c(x, v) + d(v, y) } 对所有邻居 vPython 模拟三个路由器的收敛:
class RouterDV:
def __init__(self, name: str):
self.name = name
self.table: dict[str, int] = {name: 0} # 目标→距离
self.neighbors: dict[str, int] = {} # 邻居→代价
def add_neighbor(self, other: str, cost: int):
self.neighbors[other] = cost
self.table[other] = cost
def advertise(self) -> dict[str, int]:
return dict(self.table)
def update(self, from_router: str, adv: dict[str, int]):
"""收到邻居广告后更新自己的路由表"""
cost_to_neighbor = self.neighbors.get(from_router)
if cost_to_neighbor is None:
return False
changed = False
for dest, dist in adv.items():
new_dist = cost_to_neighbor + dist
if dest not in self.table or new_dist < self.table[dest]:
self.table[dest] = new_dist
changed = True
return changed
# 模拟三个路由器收敛
A = RouterDV('A'); B = RouterDV('B'); C = RouterDV('C')
A.add_neighbor('B', 1)
B.add_neighbor('A', 1); B.add_neighbor('C', 2)
C.add_neighbor('B', 2)
for _ in range(3):
for router in [A, B, C]:
for neighbor_name, _ in router.neighbors.items():
neighbor = {'A': A, 'B': B, 'C': C}[neighbor_name]
router.update(neighbor_name, neighbor.advertise())
print("收敛后A的路由表:", A.table)
# → {'A': 0, 'B': 1, 'C': 3} (A→C = 1+2=3)RIP(路由信息协议):距离向量协议的工业实现。
- 距离 = 跳数(metric = hop count)
- 最大跳数 15(16 = 不可达)——限制了网络规模
- 每 30 秒广播一次路由表
- 经典问题:计数到无穷——一条链路断开后,路由器之间会「互相学习」错误路径,跳数缓慢增长到 16 才确认不可达。解决方案:毒性反转(poison reverse,告诉邻居「去 X 的代价=∞,别走我」)和触发更新。
链路状态(Link State)——OSPF
直觉:每个路由器都向全网洪泛(flooding)自己的连接状态(我连了谁,代价多少)。所有路由器收到全部信息后,各自用 Dijkstra 最短路径算法独立计算最短路径树。
这比距离向量好在哪里?
- 收敛更快(知道了全网拓扑,不用逐跳猜测)
- 不容易环路
- 缺点:每个路由器要维护全网的链路状态数据库,内存开销大
OSPF(开放最短路径优先) 的链路状态通告:
import heapq
class LinkStateRouter:
def __init__(self, name: str):
self.name = name
self.links: dict[str, int] = {} # 邻居→代价
def add_link(self, other: str, cost: int):
self.links[other] = cost
def dijkstra(self, routers: dict[str, 'LinkStateRouter'],
start: str) -> dict[str, tuple[int, str]]:
"""从 start 到所有节点的最短路径及下一跳"""
dist = {r: float('inf') for r in routers}
prev = {r: None for r in routers}
dist[start] = 0
pq = [(0, start)]
while pq:
d, node = heapq.heappop(pq)
if d > dist[node]:
continue
for neighbor, cost in routers[node].links.items():
nd = d + cost
if nd < dist[neighbor]:
dist[neighbor] = nd
prev[neighbor] = node
heapq.heappush(pq, (nd, neighbor))
# 计算下一跳(从 start 出发的第一个路由器)
next_hop = {}
for dest in routers:
if dest == start:
next_hop[dest] = start; continue
hop = dest
while prev[hop] and prev[hop] != start:
hop = prev[hop]
next_hop[dest] = hop
return {d: (int(dist[d]), nh) for d, nh in next_hop.items()}OSPF 的层次结构:区域(area)——把一个大网拆成多个区域,区域内的路由器只知道本区域的完整拓扑(缩短 Dijkstra 计算时间)。区域之间的通信靠区域边界路由器(ABR)。这让 OSPF 能管理数千台路由器的大网。
深水区:BGP 的核心直觉很简单——「互联网路由由自治系统之间的商业策略决定,不是最短路径算法」。以下关于 AS_PATH 和选路策略的细节作为加深理解,不要求掌握。
第7战:BGP——互联网的全球外交
问题: 你家的驿道信标塔用 RIP/OSPF 学习了驿站内的路径。但怎么知道去 93.184.216.34(美国东海岸某法术灵脉服务商的信标塔)的路?谁告诉你「走华夏驿道骨干→海底魔导光缆→Verizon 法阵网络」?
BGP(边界驿道协议)——驿道世界的「全球路由协议」。
BGP 和 OSPF/RIP 的根本区别:
| OSPF/RIP | BGP | |
|---|---|---|
| 范围 | 一个自治系统(AS)内部 | AS 之间 |
| 类型 | IGP(内部网关协议) | EGP(外部网关协议) |
| 协议 | 链路状态 / 距离向量 | 路径向量 |
| 选路依据 | 最短路径(代价/跳数) | 策略(商业、政治、法律) |
| 收敛 | 快(秒级) | 慢(分钟级) |
| 规模 | 数百 ~ 数千台路由器 | ~10 万个 AS,上百万路由条目 |
路径向量的核心:BGP 不是简单地说「我去 93.0.0.0/8 代价=10」,而是说「经由 AS_PATH = [AS174, AS15169, AS16509]」——记录了经过的自治系统列表。
AS_PATH 的作用:
- 防环——如果收到一条路由且 AS_PATH 已包含自己的 AS 号,丢弃(你已经在这条路上)
- 策略决策——运营商可以拒绝 AS_PATH 中包含某些 AS 的流量(比如中国电信不接受通过非互联点转发的路由)
BGP 的选路策略(简化版):
- 选 Local Preference 最高的(管理员设定的偏好)
- 选 AS_PATH 最短的
- 选 MED(多出口区分) 最低的
- 优先 eBGP(从隔壁 AS 学来的)而不是 iBGP(从本 AS 内部学来的)
- 选离 IGP 下一跳最近的路由器
不是算法,是外交:BGP 的选路不是最短路径算法,而是商业合同。A 运营商和 B 运营商签了对等(peering,免费交换流量)还是通过 C 中转(transit,按流量付费)?这决定了路由策略。2008 年巴基斯坦电信的 BGP 错误通告差点让 YouTube 全球下线——BGP 劫持不是因为路由算法有问题,而是因为 BGP 之间的信任模型太脆弱(没有全局验证机制)。
常见陷阱:从浏览器输入 IP 到数据包到达对方网卡
来,把这一章的知识串起来。你在浏览器输入 http://93.184.216.34:
- 浏览器 → HTTP GET 请求 → 交给 TCP(80 端口)
- TCP → 把 HTTP 数据包切成 segment(MSS=1460),每个加上 TCP 首部
- IP 层(你的操作系统内核)→ 加上 IPv4 首部(src=你的IP, dst=93.184.216.34, TTL=64, protocol=6)
- 分片判断:数据长度 ≤ MTU(1500) - 20(IP) = 1480 → 不用分片。如果分片了就按 8 字节对齐切。
- 路由表查询:目标 IP 不在本地子网 → 查路由表 → 下一跳 = 默认网关(192.168.1.1)
- 链路层 → ARP 查询网关的 MAC 地址 → 封装以太网帧 → 发送
- 网关路由器 → 收到帧,去掉以太网首部 → 看 IP 目标地址 → 查路由表(OSPF / BGP 学到的路由)→ 找到下一跳 ISP 路由器 → 重新封装帧 → 发送
- 每经过一跳 → TTL 减 1,IP 首部校验和重新计算(因为 TTL 变了)
- 穿越整个互联网(可能经过 10~20 跳路由器、多个 AS)→ 到达目标服务器
- 目标服务器 IP 层 → 校验首部 → 检查分片 → 完整后交给 TCP 层
这个过程的每一步,在这一章都有对应的知识。
通关挑战
挑战1:IP 分片重组器()
给定以下三个分片,写一个 reassemble() 函数把它们还原成原始 IP 数据报(注意偏移量要 *8):
Fragment 0: ID=999, Offset=0, MF=1, Payload=1400 bytes
Fragment 1: ID=999, Offset=175, MF=1, Payload=1400 bytes
Fragment 2: ID=999, Offset=350, MF=0, Payload=800 bytes提示:根据 offset 对分片排序,然后逐片拼接 payload。
挑战2:CIDR 子网计算器()
写一个函数,输入 CIDR 如 '10.20.30.0/26',输出:
- 子网掩码(点分十进制)
- 网络地址
- 广播地址
- 可用地址数
- 可用地址范围(第一个 ~ 最后一个)
提示:/26 → 64 个地址,62 个可用。
挑战3:简单路由模拟器()
创建 5 个路由器组成的网状拓扑,分别用距离向量(RIP 风格)和链路状态(OSPF 风格)算法计算最短路径。在一条链路断掉后,对比两种算法的收敛速度差异。
提示:距离向量可能遇到"计数到无穷"问题,你可以通过毒性反转缓解。
挑战4:手写 traceroute 最小版本()
用 Python 的 raw socket(在 Linux 上运行,需要 root)实现一个最小的 traceroute,用 UDP 到高端口慢慢增加 TTL,接收 ICMP Time Exceeded 消息。
提示:参考第5战中的 traceroute 代码框架。
验收标准
完成本章后,你应当可以:
- [ ] 手绘 IPv4 首部格式(20 字节内所有字段)
- [ ] 在纸上手算一个 4000 字节数据包经过 MTU=1500 链路的三个分片
- [ ] 给出一个 CIDR 如
172.16.0.0/20,迅速算出可用地址范围和数量 - [ ] 向非技术人员解释 NAT 的核心原理("就一个门牌号,你家十个人出门,靠不同的门铃编号区分")
- [ ] 说出 OSPF 和 BGP 的两个根本差异
- [ ] 解释为什么
ping不用端口号 - [ ] 想清楚 TCP 为什么用 MSS 避免 IP 分片
常见卡点
| 卡点 | 原因 | 解药 |
|---|---|---|
| 分片偏移为什么/8 | 13bit 最多 8191,而原始包最大 65535B;/8 后 13bit×8=65528≈64KB | 记住:偏移量存的值 × 8 = 实际字节偏移 |
| IPv4 校验和只保护首部为什么够 | IP 层不可靠设计——只保证首部正确到达路由器,数据校验交给上层(TCP/UDP) | 理解分层责任 |
| 子网掩码和 IP 一起写是什么意思 | 没有掩码,你无法从 IP 本身知道网络部分有多长 | CIDR 的 / 就是掩码的简写 |
| NAT 和防火墙是一样的吗 | 不是,但 NAT 破坏了端到端模型,确实有防火墙副作用 | 严格来说 NAT 是地址转换,防火墙是包过滤,两者不同 |
| BGP 不用最短路径? | BGP 选路优先看 AS_PATH 最短,但那是策略驱动——管理员可以忽略最短路径选商定好的 | BGP 不是算法,是政策 |
| traceroute 为什么用高端口 | 避免触发目标机器的防火墙/服务响应;高端口通常无服务 → ICMP Port Unreachable 收工 | 也有人在 TCP traceroute 用 SYN 包打 80 端口 |
现在不需要理解
- IPv6 扩展首部:IPv6 没有 IHL 和分片字段(分片首部是扩展首部的其中一种),但这不是你现在需要精通的——IPv6 这一章后面会专门讲。
- OSPF LSA 的 11 种类型:OSPF 的链路状态通告有 Type 1~11(Router LSA、Network LSA、Summary LSA……),初学者知道"洪泛链路状态 + Dijkstra"就够了。
- BGP 的 4 种消息类型:OPEN、UPDATE、KEEPALIVE、NOTIFICATION。知道 BGP 通过 TCP 179 端口可靠传输就够了。
- MPLS(多协议标签交换):在 IP 路由之上做的流量工程技术,不是网络层协议本身。
- VPC、子网设计、云网络架构:这是云平台层面的网络抽象,以本章为地基之后再看。
旅人笔记
网络层是互联网真正的"中流砥柱"。传输层为你保证了可靠连接,但如果没有网络层给你的数据包穿上"寄件人/收件人"的外衣,TCP 的可靠就是空中楼阁——信没人拆,何谈可靠?
这一章的路线图很清晰:
应用层数据
↓ TCP/UDP 封装(传输层)
↓ IPv4 首部封装(网络层) ← 分片、TTL、校验和
↓ 路由表查询(路由器做的事)
↓ 以太网帧封装(链路层)
↓ 物理层发送IP 协议的设计哲学是尽力而为(best effort)——不保证不丢包、不保证按序、不保证不重复。这听起来很弱,但正是这种简单让路由器可以极快地处理每个包(硬件线速转发),也把可靠性的重担交给了上层的 TCP。
CIDR 是互联网的"空间节省魔术"——让有限的地块精打细算。NAT 是"场地不够就用时间换空间"(把端口重用发挥到极致)。路由协议则是"群策群力"的分布式决策典范——没有中央控制器,每个路由器只知道自己和邻居的信息,却共同建起了全球可达的网络。
一句话消化本章:IP 解决"谁在哪"(寻址),路由解决"怎么到"(路径计算),ICMP 解决"到了没有/为什么没到"(诊断),NAT 解决"地址不够"(障眼法)。
→ 下一站预告
第10章:TCP 拥塞控制
你的法术信函成功地从上海驿道到了纽约驿道。但是在一条拥挤的驿道上,你的信函和无数别人的法术信函互相争抢驿道容量。如果所有人都抢,结果就是谁都传不了——驿道崩溃。
下一章,我们拆开 TCP 拥塞控制这个「互联网唯一的刹车系统」——AIMD、慢启动、快速恢复、CUBIC、BBR——以及 Bufferbloat 如何让网络变慢。
关键问题: 当所有人都在争抢带宽,TCP 是如何优雅地让路的?