Skip to content

第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 驿道层)。这一层负责两件事:

  1. 寻址——给每座信标塔一个全球唯一的「驿道门牌号」(IP 地址)
  2. 路由——决定从法师塔到信标塔,你的法术信函该走哪几座中转驿站

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 扩展首部

你的任务

学完本章,你应当:

  1. 手绘 IPv4 首部,说出每个字段的位置、长度、意义
  2. 计算分片偏移,理解 MTU 和 DF 位的作用
  3. 用 CIDR 表示法写出任意子网范围和广播地址
  4. 解释 NAT 如何用一个公网 IP 养一百个设备
  5. 实现一个命令行版 ping/traceroute 的最小模型(ICMP 的应用)
  6. 口述距离向量和链路状态路由的本质区别
  7. 理解 BGP 的核心直觉:互联网路由不是最短路径算法,而是自治系统之间的商业策略决策

遭遇战 → 获得技能

第1战:IPv4 首部——网络层的身份证

问题: 你的法师观测镜向 93.184.216.34(example.com)发送了一道 HTTP 法术请求。这道请求到了 IP 驿道层,会变成一个什么样的法术数据包?

数据包的前 20 字节(不含选项)是 IPv4 首部。用 Python 的结构化视角看它:

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})")

请消化每个字段的位置和意义。下面重点拆几个关键字段:

字段长度含义你未来会遇到它的地方
Version4bit永远=4 或 6网卡收到的第一个判断
IHL4bit首部长度(单位4字节,最小5=20B)包解析的起始偏移
Total Length16bit整个IP包长度(含数据)超过MTU就需要分片
Identification16bit分片所属的原始包ID分片重组靠它
Flags3bitDF=不分片, MF=还有分片MTU探测
Fragment Offset13bit本分片在原始数据中的偏移(×8字节)重组时排序
TTL8bit每跳减1,到0丢弃traceroute的核心
Protocol8bit上层协议编号6=TCP, 17=UDP, 1=ICMP
Header Checksum16bit每跳重算(验证首部完整性)路由器开销之一

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)的原理。
  • 重组由接收端完成! 路由器只负责转发,不负责拼回去。

模拟分片:

python
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=1040

Java 差异窗:Java DatagramPacketsetSendBufferSize 不会帮你分片——你得自己分。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 算地址范围:

python
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 的 InetAddressInet4Address 没有直接给出子网范围的方法——你得用 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 映射表(路由器内存里的一张哈希表):

python
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.external

NAT 的副作用:外部不能主动连接内网设备——映射表里没有记录。这就是为什么你家的 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

python
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 是网络层协议,不是传输层。它没有端口的概念。telnetnc 基于 TCP,ping 基于 ICMP——两者是不同层的东西。Java 的 InetAddress.isReachable() 在 Linux 上默认用 TCP Echo(端口 7),不是 ICMP ping——所以它经常返回假阴性。

traceroute —— 用 TTL 逐步耗尽

python
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。你能用 pingtraceroute 排障了,理解了 NAT 是如何解决地址短缺的。


以下为路由进阶(选读):距离向量、链路状态、BGP。这些内容让你理解互联网如何路由,但对日常排障来说非必须。


第6战:路由算法——路由器怎么知道往哪转发

问题: 上海驿道的信标塔收到一道去 93.184.216.34 的法术信函。它面前有 8 根法术光纤连到不同的驿道信标塔。它怎么选?

驿道路由咒语的核心是两个问题:

  1. ——驿道信标塔之间如何交换可达信息
  2. ——根据交换的信息计算最佳路径

距离向量(Distance Vector)——RIP

直觉:每个路由器只知道自己和邻居之间的距离。它定期把「我已知的最短路径表」告诉所有邻居。邻居更新自己的表——如果听到更短的路径就替换。

Bellman-Ford 方程

d(x, y) = min{ c(x, v) + d(v, y) }  对所有邻居 v

Python 模拟三个路由器的收敛:

python
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 的代价=∞,别走我」)和触发更新

直觉:每个路由器都向全网洪泛(flooding)自己的连接状态(我连了谁,代价多少)。所有路由器收到全部信息后,各自用 Dijkstra 最短路径算法独立计算最短路径树。

这比距离向量好在哪里?

  • 收敛更快(知道了全网拓扑,不用逐跳猜测)
  • 不容易环路
  • 缺点:每个路由器要维护全网的链路状态数据库,内存开销大

OSPF(开放最短路径优先) 的链路状态通告:

python
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/RIPBGP
范围一个自治系统(AS)内部AS 之间
类型IGP(内部网关协议)EGP(外部网关协议)
协议链路状态 / 距离向量路径向量
选路依据最短路径(代价/跳数)策略(商业、政治、法律)
收敛快(秒级)慢(分钟级)
规模数百 ~ 数千台路由器~10 万个 AS,上百万路由条目

路径向量的核心:BGP 不是简单地说「我去 93.0.0.0/8 代价=10」,而是说「经由 AS_PATH = [AS174, AS15169, AS16509]」——记录了经过的自治系统列表。

AS_PATH 的作用:

  1. 防环——如果收到一条路由且 AS_PATH 已包含自己的 AS 号,丢弃(你已经在这条路上)
  2. 策略决策——运营商可以拒绝 AS_PATH 中包含某些 AS 的流量(比如中国电信不接受通过非互联点转发的路由)

BGP 的选路策略(简化版)

  1. Local Preference 最高的(管理员设定的偏好)
  2. AS_PATH 最短
  3. MED(多出口区分) 最低的
  4. 优先 eBGP(从隔壁 AS 学来的)而不是 iBGP(从本 AS 内部学来的)
  5. 选离 IGP 下一跳最近的路由器

不是算法,是外交:BGP 的选路不是最短路径算法,而是商业合同。A 运营商和 B 运营商签了对等(peering,免费交换流量)还是通过 C 中转(transit,按流量付费)?这决定了路由策略。2008 年巴基斯坦电信的 BGP 错误通告差点让 YouTube 全球下线——BGP 劫持不是因为路由算法有问题,而是因为 BGP 之间的信任模型太脆弱(没有全局验证机制)。


常见陷阱:从浏览器输入 IP 到数据包到达对方网卡

来,把这一章的知识串起来。你在浏览器输入 http://93.184.216.34

  1. 浏览器 → HTTP GET 请求 → 交给 TCP(80 端口)
  2. TCP → 把 HTTP 数据包切成 segment(MSS=1460),每个加上 TCP 首部
  3. IP 层(你的操作系统内核)→ 加上 IPv4 首部(src=你的IP, dst=93.184.216.34, TTL=64, protocol=6)
  4. 分片判断:数据长度 ≤ MTU(1500) - 20(IP) = 1480 → 不用分片。如果分片了就按 8 字节对齐切。
  5. 路由表查询:目标 IP 不在本地子网 → 查路由表 → 下一跳 = 默认网关(192.168.1.1)
  6. 链路层 → ARP 查询网关的 MAC 地址 → 封装以太网帧 → 发送
  7. 网关路由器 → 收到帧,去掉以太网首部 → 看 IP 目标地址 → 查路由表(OSPF / BGP 学到的路由)→ 找到下一跳 ISP 路由器 → 重新封装帧 → 发送
  8. 每经过一跳 → TTL 减 1,IP 首部校验和重新计算(因为 TTL 变了)
  9. 穿越整个互联网(可能经过 10~20 跳路由器、多个 AS)→ 到达目标服务器
  10. 目标服务器 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 分片

常见卡点

卡点原因解药
分片偏移为什么/813bit 最多 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 是如何优雅地让路的?

Built with VitePress | Software Systems Atlas