上一章结尾留了个钩子: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:
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):
于是,判断两台机器是否同网段的方法,精确到比特:它俩各自和掩码做 AND,网络号相等,就是同网段。 这正好补上了 0.2 那个悬而未决的问题——ARP 只在同网段有效,而"同不同"就是这么算出来的。
你写系统级代码时常做这种事:value & MASK,把关心的那几位抠出来、其余清零。子网掩码只是把这套位运算用在了 IP 地址上——没有任何新魔法。
用 Python 一行把它跑出来(刻意用逐字节 AND 暴露位运算,而不是藏进库里):
# 网络号 = 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,跨网段
真写代码时你会直接用 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 | 子网掩码 | 可用主机数 | 常见用途 |
|---|---|---|---|
| /8 | 255.0.0.0 | 16,777,214 | 大型私网,如 10.0.0.0/8 |
| /16 | 255.255.0.0 | 65,534 | 中型网络 |
| /24 | 255.255.255.0 | 254 | 家庭 / 小型办公(最常见) |
| /30 | 255.255.255.252 | 2 | 点对点链路 |
| /32 | 255.255.255.255 | 单个地址 | 主机路由 / 防火墙规则 |
4路由表:longest prefix match
现在回答"网关凭什么知道往哪送"。内核手里有一个数据包,看它的目的 IP,然后查路由表(routing table)——一组"目标网段 → 下一跳"的规则。这是上一节实验里主机 h1 的真实路由表:
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 → 交给网关。
你 ping 8.8.8.8 时,路由表把它匹配到 default,下一跳是网关,所以内核 ARP 的是网关的 MAC,而不是 8.8.8.8。default 路由是数据包"出网段"的唯一出口。
具体的 arm 优先匹配,最后那个 _ 通配兜底。default 路由就是路由表里的那个 _——前面所有更具体的网段都不匹配时,才轮到它。
5实验:在一台机器上打通两个子网
Network namespace(网络命名空间)让一台 Linux 里隔出多套独立的网络栈(各自的接口、IP、路由表),互不干扰,特别适合在本机模拟拓扑。我们用它造出两个真子网,再亲手加路由把它们连起来——你会发现,这就是一台路由器的最小内核。目标拓扑:
# 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
包从 h2 出发时 TTL 默认是 64,经过路由器 r 转发时被减了 1,到 h1 就成了 63。这一跳的痕迹,就是它真的穿过了路由器的铁证(TTL 是个防环字段,0.4 还会细讲)。穿过两台路由器就会是 62,以此类推。
把 ip_forward 设回 0(sudo ip netns exec r sysctl -w net.ipv4.ip_forward=0)再 ping,会 100% 丢包。因为内核里的每台机器默认不转发不是发给自己的包——必须被明确告知"我愿意当路由器"。这一个开关,就是"主机"和"路由器"的分界线。实测我两种情况都跑了:开 = 通,关 = 全丢。
netns 是 Linux 内核特性,macOS 没有;在 Mac 上想做这个实验,起一台 Linux 虚拟机或容器即可(OrbStack / Lima / Docker 都行)。做完清理:for ns in h1 h2 r; do sudo ip netns del $ns; done
你刚刚在命名空间里做的事——两个接口各属一个网段、一张路由表、外加打开 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,就是一台最小路由器。
动手练习
- 跑
ip addr show,记下你的 IP 和掩码长度(/n),手算你所在网段的网络号与广播地址。 - 用本章那段 Python 一行,验证你的网关 IP 和你的主机 IP 是否同网段(应该是)。
- 把整个 namespace 实验跑通,然后故意制造一次失败:删掉 h1 的默认路由(
sudo ip -n h1 route del default)再 ping,观察报错(应是Network is unreachable);想想这跟 ip_forward=0 那次失败,"失败的位置"有什么不同。 - 进阶:给路由器再接第三个网段 h3(
10.0.3.0/24),不改 h1/h2 的配置,看 h1 能不能 ping 通 h3,并解释为什么(提示:h1 的 default 把一切都丢给了 r,而 r 直连三个网段)。