PHASE 0 · 网络基础重建

0.3网络层

IP 地址、子网掩码、CIDR,与路由表

上一章结尾留了个钩子:ARP 只在同一网段有效,跨网段得先把包发给网关。可"同不同网段"到底怎么判断?网关又凭什么知道该把包往哪送?这两个问题,就是网络层要回答的——也是整个 Phase 0 分量最重的一章。一句话概括它的主题:跨网段,怎么走。

一句话定义

网络层(Network Layer):负责让数据包跨越多个网段,从源主机送达任意远端主机。它的地址是 IP,它的决策工具是路由表。

1IP 地址:网络上的位置

IP 地址(IP Address)是网络层的逻辑地址,标识"一台主机在哪个网络、网络里的哪个位置"。IPv4 地址是 32 位,写成 4 段十进制(点分十进制),每段 8 位、范围 0–255,例如 192.168.1.100

回忆 0.2 那组对照:MAC 是身份(跟着网卡),IP 是位置(跟着网络)。你笔记本插公司网线拿一个 IP,回家连 Wi-Fi 换一个 IP,但网卡的 MAC 自始至终没变。

关键来了:一个 IP 内部分成两段——网络部分(network) + 主机部分(host)。前半段标识"哪个网络",后半段标识"这个网络里的哪台机器"。可前后到底从哪一位切开?这就要靠子网掩码。先看一眼你自己的 IP,留意末尾那个 /24:

bash
ip addr show          # Linux,看 inet 192.168.x.x/24 这一行
ifconfig              # macOS

2子网掩码:位运算的本质

子网掩码(Subnet Mask)用一串连续的 1 标出 IP 的网络部分,剩下的 0 是主机部分。255.255.255.0 翻成二进制就是 24 个 1 接 8 个 0。把 IP 和掩码做按位与(bitwise AND)——1 的位置原样保留、0 的位置清零——得到的就是网络号(network address):

192.168.1.10011000000 10101000 00000001 01100100
AND 255.255.255.011111111 11111111 11111111 00000000
= 192.168.1.011000000 10101000 00000001 00000000
■ 网络部分(前 24 位)■ 主机部分(后 8 位)

于是,判断两台机器是否同网段的方法,精确到比特:它俩各自和掩码做 AND,网络号相等,就是同网段。 这正好补上了 0.2 那个悬而未决的问题——ARP 只在同网段有效,而"同不同"就是这么算出来的。

类比:这就是底层代码里的 bitmask

你写系统级代码时常做这种事:value & MASK,把关心的那几位抠出来、其余清零。子网掩码只是把这套位运算用在了 IP 地址上——没有任何新魔法。

用 Python 一行把它跑出来(刻意用逐字节 AND 暴露位运算,而不是藏进库里):

python
# 网络号 = IP 按位与 掩码
>>> [a & m for a, m in zip([192,168,1,100], [255,255,255,0])]
[192, 168, 1, 0]

# 同子网判定:两个 IP 的网络号是否相等
>>> net = lambda ip: [a & m for a, m in zip(ip, [255,255,255,0])]
>>> net([192,168,1,100]) == net([192,168,1,200])   # True,同一个 /24
>>> net([192,168,1,100]) == net([192,168,2,50])    # False,跨网段
实战里用标准库,但底下还是这个 AND

真写代码时你会直接用 ipaddress.ip_network('192.168.1.100/24', strict=False).network_address 拿到 192.168.1.0。库只是把上面那个按位与包了起来,本质一模一样。

3CIDR:数掩码里有几个 1

每次写 255.255.255.0 太啰嗦。它有 24 个 1,所以直接写成 /24——这就是 CIDR(Classless Inter-Domain Routing,无类域间路由)记法。/n 直接等于网络部分的位数;那么主机部分就是 32 − n 位,这个网段能容纳 2^(32−n) 个地址(其中两个有特殊用途:全 0 是网络号、全 1 是广播地址,所以可用主机数是 2^(32−n) − 2)。

"无类"是相对早年的 A/B/C 类来说的:那时 IP 被硬切成 8/16/24 位三档,浪费严重;CIDR 打破类的边界,允许按任意长度切分,地址利用率高得多。常见前缀速查:

CIDR子网掩码可用主机数常见用途
/8255.0.0.016,777,214大型私网,如 10.0.0.0/8
/16255.255.0.065,534中型网络
/24255.255.255.0254家庭 / 小型办公(最常见)
/30255.255.255.2522点对点链路
/32255.255.255.255单个地址主机路由 / 防火墙规则

4路由表:longest prefix match

现在回答"网关凭什么知道往哪送"。内核手里有一个数据包,看它的目的 IP,然后查路由表(routing table)——一组"目标网段 → 下一跳"的规则。这是上一节实验里主机 h1 的真实路由表:

$ sudo ip -n h1 route
default via 10.0.1.1 dev v-h1
10.0.1.0/24 dev v-h1 proto kernel scope link src 10.0.1.2

逐条读:

  • 10.0.1.0/24 dev v-h1 scope link ——"目的地落在 10.0.1.0/24 这个网段,直接从 v-h1 发出"。scope link 表示同网段、直连可达,内核会用 ARP 找对方 MAC(0.2 那一套)。
  • default via 10.0.1.1 ——default 就是 0.0.0.0/0,匹配任何地址;via 10.0.1.1 表示把包交给下一跳 10.0.1.1(网关)。

当多条规则同时匹配时,选前缀最长(最具体)的那条——这就是 longest prefix match(最长前缀匹配):

  • 发往 10.0.1.50:既匹配 /24 又匹配 default(/0),/24 更长 → 直接本地发出;
  • 发往 8.8.8.8:只匹配 default → 交给网关。
这就是上一章练习第 3 题的答案

你 ping 8.8.8.8 时,路由表把它匹配到 default,下一跳是网关,所以内核 ARP 的是网关的 MAC,而不是 8.8.8.8。default 路由是数据包"出网段"的唯一出口。

类比:longest prefix match 像 Rust 的 match

具体的 arm 优先匹配,最后那个 _ 通配兜底。default 路由就是路由表里的那个 _——前面所有更具体的网段都不匹配时,才轮到它。

5实验:在一台机器上打通两个子网

Network namespace(网络命名空间)让一台 Linux 里隔出多套独立的网络栈(各自的接口、IP、路由表),互不干扰,特别适合在本机模拟拓扑。我们用它造出两个真子网,再亲手加路由把它们连起来——你会发现,这就是一台路由器的最小内核。目标拓扑:

h110.0.1.2/24子网 1
r(路由器)10.0.1.1 · 10.0.2.1一脚一个网段
h210.0.2.2/24子网 2
bash
# 1. 三个命名空间:两台主机 h1/h2 + 一台路由器 r
sudo ip netns add h1
sudo ip netns add h2
sudo ip netns add r

# 2. 两对 veth,把两端分别塞进对应命名空间
sudo ip link add v-h1 type veth peer name v-r1
sudo ip link set v-h1 netns h1
sudo ip link set v-r1 netns r
sudo ip link add v-h2 type veth peer name v-r2
sudo ip link set v-h2 netns h2
sudo ip link set v-r2 netns r

# 3. 配 IP:h1 在 10.0.1.0/24,h2 在 10.0.2.0/24,路由器一脚一个网段
sudo ip -n h1 addr add 10.0.1.2/24 dev v-h1
sudo ip -n r  addr add 10.0.1.1/24 dev v-r1
sudo ip -n h2 addr add 10.0.2.2/24 dev v-h2
sudo ip -n r  addr add 10.0.2.1/24 dev v-r2

# 4. 所有接口 up(含每个命名空间自己的 lo)
for ns in h1 h2 r; do sudo ip -n $ns link set lo up; done
sudo ip -n h1 link set v-h1 up
sudo ip -n r  link set v-r1 up
sudo ip -n h2 link set v-h2 up
sudo ip -n r  link set v-r2 up

# 5. 两台主机各加默认路由,指向路由器在自己这侧的 IP
sudo ip -n h1 route add default via 10.0.1.1
sudo ip -n h2 route add default via 10.0.2.1

# 6. 打开路由器的 IP 转发(默认是关的!)
sudo ip netns exec r sysctl -w net.ipv4.ip_forward=1

# 7. 见证奇迹:h1 跨子网 ping h2
sudo ip netns exec h1 ping -c 3 10.0.2.2

最后一步的真实输出(我在一台 Linux 上跑出来的):

终端输出
64 bytes from 10.0.2.2: icmp_seq=1 ttl=63 time=0.316 ms
64 bytes from 10.0.2.2: icmp_seq=2 ttl=63 time=0.042 ms
64 bytes from 10.0.2.2: icmp_seq=3 ttl=63 time=0.042 ms
看那个 ttl=63,不是 64

包从 h2 出发时 TTL 默认是 64,经过路由器 r 转发时被减了 1,到 h1 就成了 63。这一跳的痕迹,就是它真的穿过了路由器的铁证(TTL 是个防环字段,0.4 还会细讲)。穿过两台路由器就会是 62,以此类推。

把第 6 步关掉试试

把 ip_forward 设回 0(sudo ip netns exec r sysctl -w net.ipv4.ip_forward=0)再 ping,会 100% 丢包。因为内核里的每台机器默认不转发不是发给自己的包——必须被明确告知"我愿意当路由器"。这一个开关,就是"主机"和"路由器"的分界线。实测我两种情况都跑了:开 = 通,关 = 全丢。

macOS 备忘 & 收尾

netns 是 Linux 内核特性,macOS 没有;在 Mac 上想做这个实验,起一台 Linux 虚拟机或容器即可(OrbStack / Lima / Docker 都行)。做完清理:for ns in h1 h2 r; do sudo ip netns del $ns; done

路由器关联 router-link

你刚刚在命名空间里做的事——两个接口各属一个网段、一张路由表、外加打开 ip_forward——就是一台路由器的最小内核。Phase 3 第一章会把这套东西从命名空间搬到真实双网口,配上约 30 行配置变成能用的路由器。而在 OpenWRT 里,你在 /etc/config/network 写的每个 interface,netifd 都会翻译成这样的 ip addr + ip route 命令;那条从 WAN 口学来的 default 路由,就是你家路由器通向外网的总出口。

本章小结

  • IP 是网络层的逻辑地址(位置);一个 IP 内部分为网络部分 + 主机部分。
  • 子网掩码用按位与划出网络号;两个 IP 网络号相等 = 同网段——补上了 0.2 留下的问题。
  • CIDR/n = 网络位数;网段容量 2^(32−n)
  • 路由表按 longest prefix match 选路,default(/0)兜底;ping 外网 → 匹配 default → 发给网关(这就是 0.2 练习的答案)。
  • 一台机器配好两个网段 + 路由 + ip_forward,就是一台最小路由器。

动手练习

  1. ip addr show,记下你的 IP 和掩码长度(/n),手算你所在网段的网络号与广播地址。
  2. 用本章那段 Python 一行,验证你的网关 IP 和你的主机 IP 是否同网段(应该是)。
  3. 把整个 namespace 实验跑通,然后故意制造一次失败:删掉 h1 的默认路由(sudo ip -n h1 route del default)再 ping,观察报错(应是 Network is unreachable);想想这跟 ip_forward=0 那次失败,"失败的位置"有什么不同。
  4. 进阶:给路由器再接第三个网段 h3(10.0.3.0/24),不改 h1/h2 的配置,看 h1 能不能 ping 通 h3,并解释为什么(提示:h1 的 default 把一切都丢给了 r,而 r 直连三个网段)。