跨网段把包交给网关,为什么目标IP不是网关?用隔离实验看清IP与MAC
学网络时,我经常看到一句话:同网段直接发送,跨网段交给网关。
如果真的把包交给网关,目标地址是不是就该填网关?网关又怎么知道我最终想访问谁?
这个疑问来自两个“目标”混在一起:IP包的最终目标,和当前这段链路上以太网帧的接收者。
这次不改家庭路由器。我们在 Ubuntu 的隔离网络里建一台主机 A、一台路由器 R、一台远端主机 B,抓到两处实际帧,再用一个断路对照确认网关的作用。
1. 先搭一张没有互联网的网络
环境为 WSL 的 Ubuntu-24.04,内核 5.15.167.4-microsoft-standard-WSL2,iproute2 6.1.0。
三个网络命名空间只用虚拟网线 veth 相连,没有物理上联,也没有 NAT。地址使用文档示例网段,不需要公网或家里的宽带账号。
| 节点 | 接口地址 | 默认网关 |
|---|---|---|
| A / eth0 | 192.0.2.2/24 | 192.0.2.1 |
| R / left | 192.0.2.1/24 | 本实验不需要 |
| R / right | 198.51.100.1/24 | 本实验不需要 |
| B / eth0 | 198.51.100.2/24 | 198.51.100.1 |
R 只在自己的命名空间里开启 IPv4 转发。B 的返回路由也必须配置好,否则 A 的请求能到达 B,回复却未必能回到 A。

Linux 网络命名空间隔离网络接口、路由等资源。这里的删路由操作只作用于新建的 A,不触及 WSL 宿主或 Windows 网络。network_namespaces 手册
2. 掩码怎样帮助判断“同网段”?
192.0.2.2/24 的 IPv4 掩码是 255.255.255.0。地址与掩码按位与,得到网络前缀:
192.0.2.2 & 255.255.255.0 = 192.0.2.0
192.0.2.1 & 255.255.255.0 = 192.0.2.0
198.51.100.2 & 255.255.255.0 = 198.51.100.0
所以在这张简单网络里,A 到 R 的左侧地址属于本地直连网络;B 则不在其中。
但真实系统不是每次只拿自己的掩码做一个“同/不同”的判断。配置地址后,系统会有相应的直连路由;发送时还要查询路由表,可能存在更具体的路由和其他策略。
掩码解释直连网络的范围,路由表决定这次发送的下一跳。 不要把入门口诀当成忽略全部路由配置的规则。
3. 先看系统怎么选,再看实际怎么发
实验脚本内部,a 保存新建的 A 命名空间名称。下面这些命令在命名空间存活期间执行,不是在实验退出后直接照抄变量名。
ip -n "$a" route get 192.0.2.1
ip -n "$a" route get 198.51.100.2
实际输出的关键字段是:
192.0.2.1 dev eth0 src 192.0.2.2
198.51.100.2 via 192.0.2.1 dev eth0 src 192.0.2.2
第一条没有额外的 via,属于直连选路;第二条明确指定经由 192.0.2.1。ip route get 查询的是内核对这个目标的路由解析结果,并不等于已经成功通信。ip-route 手册
接下来真正发送一次 ICMP Echo Request,并分别在 A 的 eth0 出口、R 的 right 出口观察。两处记录如下,MAC 是实验手动配置的本地管理地址,不是家里设备的地址。
| 字段 | A发出时 | R向B转发时 |
|---|---|---|
| 帧的源MAC | 02:00:00:00:00:10,A | 02:00:00:00:00:20,R右侧 |
| 帧的目标MAC | 02:00:00:00:00:11,R左侧 | 02:00:00:00:00:21,B |
| IP源地址 | 192.0.2.2 | 192.0.2.2 |
| IP目标地址 | 198.51.100.2 | 198.51.100.2 |
| TTL | 64 | 63 |
| ICMP序号 | 1 | 1 |
这次没有 NAT,所以 IP 源、目标地址都没有被转换。R 仍然会减小 TTL、更新 IPv4 头校验和;“IP目标不变”不等于“IP头所有字段都不变”。校验和更新属于协议机制说明,本次采集器没有把它列为验证字段。
4. 原来“交给网关”发生在这一层
A 的 IP 包里保留最终目标 B:
IP目标:198.51.100.2
但 A 当前只能在自己的链路上发送以太网帧。路由结果告诉它:下一跳是 R 的左侧接口。因此这一跳的帧以 R 左侧的 MAC 为目标。
R 收到后,根据包里的最终目标查询自己的路由,从 right 接口发送另一帧。这一段链路直接连着 B,帧的目标 MAC 就变成 B。
可以把两层理解为:
IP目标:最终要送到哪里?
当前帧目标:这一段链路先交给谁?
以太网上的 IPv4 通信通常通过 ARP 获取下一跳对应的 MAC。本次 A 的邻居表只出现了网关 192.0.2.1 -> 02:00:00:00:00:11,没有出现远端 B 的 MAC。ARP规范 RFC 826
注意证据边界:邻居表证明缓存了这个映射;采集器没有保存 ARP 请求/应答,不能说我们已经展示了完整 ARP 交换过程。IPv6 也不能照搬这部分,它使用邻居发现机制而非 ARP。
5. 删除默认路由,为什么局域网还通?
有了前面的认识,可以做一个有明确预测的实验:
只删 A 的默认路由,直连路由保留。A 还能访问同网段的 R,但不能再访问另一个子网的 B。
在 A 命名空间内部执行:
ip -n "$a" route del default
ip netns exec "$a" ping -c 1 -W 2 192.0.2.1
ip -n "$a" route get 198.51.100.2
ip netns exec "$a" ping -c 1 -W 2 198.51.100.2
实际结果:
| 操作 | 结果 |
|---|---|
| ping同网段R | 1发1收,成功 |
| 查询到B的路由 | Network is unreachable |
| ping B | connect: Network is unreachable |
这里不是“B收到了但没有回复”,也不是“已经发出然后超时”。A 当前就没有可用于这个目标的路由。
排障时可以先把失败位置分开:本次删路由得到的是“发送前就找不到路径”;DHCP错误网关实验则保留了路由,但下一跳地址不存在,邻居解析停在 INCOMPLETE。两者都可能让人觉得“网络不通”,却需要检查不同的配置。这里是对两组已有实验的比较,不是新增抓包结论。
恢复 A 的默认路由后,再 ping B,得到 1 发 1 收。这组成功、失败、恢复的对照,比单次 ping 更能说明默认路由在这张拓扑里的作用。

图片是实验日志节选的排版,不是终端窗口截图。它展示的虚拟链路延迟不用于判断家庭宽带速度。
6. 验证程序为什么这样设计?
我们没有在家里的 LAN 上制造故障,而是把故障限制在三个新建命名空间里。命名空间名称带随机标识,脚本只清理本次创建的资源,不删除别人的环境。
脚本做三层验证:
- 选路验证:比较直连目标和跨网段目标的
ip route get。 - 帧字段验证:对两处出站 ICMP 请求做断言,检查 IP目标不变、MAC目标改变、TTL减1。
- 因果对照:删默认路由,确认直连可达而远端失败;恢复后再次确认可达。
Ubuntu 里没有预装 tcpdump,所以这次使用 Python 标准库的 AF_PACKET 原始套接字,只监听隔离实验接口。采集器限定出站 IPv4 ICMP Echo Request 和目标 B,保存首个符合条件的帧字段。
它不是通用抓包工具:只适合这个没有 VLAN、没有隧道的实验,不能拿去解析任意网络流量。需要广泛协议分析时应使用成熟工具,而不是不断扩展这个小程序。
为了避免“刚启动采集器就发送,结果没抓到”的时序问题,采集器在绑定接口后写就绪标记,主脚本看到两处标记才发送请求。采集完成等待进程退出,异常时同样执行清理。
完整源码、依赖准备和运行命令放在文末附录。正文中的命名空间变量由编排脚本创建,不需要在真实网卡上手动制造故障。
7. 把口诀补全
“同网段直接发,跨网段交给网关”可以帮助入门,但最好继续追问:
- 这次匹配的是哪条路由?是否还有更具体的路由?
- IP最终目标是谁?当前链路的下一跳是谁?
- 反方向有没有路由?中间设备有没有转发和过滤规则?
这篇实验说明:把包交给网关,不需要把IP最终目标改成网关;下一跳与最终目标属于两个层次。
我们验证的是没有 NAT 的两个子网之间的 IPv4 转发,不是所有网络配置的验收,也不证明家庭光猫、OpenWrt 或外网入口已经配置正确。
附录:完整源码与复现
以下是完整文件,不需要先下载仓库。环境限定为 Ubuntu 24.04 amd64(本次为 WSL2),需要 Bash、iproute2、ping、Python 3、sysctl、timeout 和 dpkg-deb;不能把 Windows PowerShell 当成 Bash。实验需要 root 和创建网络命名空间的权限,普通受限容器可能无法运行。不要在生产路由器上运行。
在 Linux 中建立任意工作目录,例如:
mkdir -p ~/network-lab-next-hop/scripts
cd ~/network-lab-next-hop
将下面每个代码块按标题文件名放入 scripts 目录,使用 UTF-8 和 LF 换行。脚本按自身位置寻找文件,不依赖我的 D 盘路径。本篇不需要 dnsmasq、BusyBox、nftables 或 conntrack,只使用系统已有工具与 Python 标准库。
文件:scripts/verify-network-next-hop.sh
#!/usr/bin/env bash
set -euo pipefail
base=$(cd -- "$(dirname -- "$0")/.." && pwd)
out="$base/data/verification/network-next-hop-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$out"
exec > >(tee "$out/stdout.txt") 2>&1
for tool in ip ping python3; do command -v "$tool" >/dev/null; done
test "$(id -u)" = 0
token=$(cat /proc/sys/kernel/random/uuid)
a="bl-${token:0:8}-a"
r="bl-${token:0:8}-r"
b="bl-${token:0:8}-b"
created=()
pids=()
cleanup() {
for pid in "${pids[@]}"; do kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true; done
for ns in "${created[@]}"; do ip netns del "$ns"; done
}
trap cleanup EXIT
for ns in "$a" "$r" "$b"; do ip netns add "$ns"; created+=("$ns"); ip -n "$ns" link set lo up; done
# All veth endpoints live in new namespaces, never on the host network.
ip -n "$a" link add eth0 type veth peer name left netns "$r"
ip -n "$r" link add right type veth peer name eth0 netns "$b"
ip -n "$a" link set eth0 address 02:00:00:00:00:10 up
ip -n "$r" link set left address 02:00:00:00:00:11 up
ip -n "$r" link set right address 02:00:00:00:00:20 up
ip -n "$b" link set eth0 address 02:00:00:00:00:21 up
ip -n "$a" addr add 192.0.2.2/24 dev eth0
ip -n "$r" addr add 192.0.2.1/24 dev left
ip -n "$r" addr add 198.51.100.1/24 dev right
ip -n "$b" addr add 198.51.100.2/24 dev eth0
ip -n "$a" route add default via 192.0.2.1
ip -n "$b" route add default via 198.51.100.1
ip netns exec "$r" sysctl -qw net.ipv4.ip_forward=1
echo '=== Versions ==='
uname -r
ip -V
echo '=== Same-subnet route ==='
ip -n "$a" route get 192.0.2.1
ip netns exec "$a" ping -c 1 -W 2 192.0.2.1
echo '=== Remote-subnet route ==='
ip -n "$a" route get 198.51.100.2
ip netns exec "$a" python3 "$base/scripts/capture-lab-icmp.py" eth0 "$out/a-frame.json" "$out/a.ready" &
pids+=("$!")
ip netns exec "$r" python3 "$base/scripts/capture-lab-icmp.py" right "$out/r-frame.json" "$out/r.ready" &
pids+=("$!")
for attempt in $(seq 1 100); do
if test -f "$out/a.ready" && test -f "$out/r.ready"; then break; fi
sleep 0.05
done
test -f "$out/a.ready"
test -f "$out/r.ready"
ip netns exec "$a" ping -c 1 -W 2 198.51.100.2
for pid in "${pids[@]}"; do wait "$pid"; done
pids=()
echo '=== A neighbor table ==='
ip -n "$a" neigh show
echo '=== Remove only A lab default route ==='
ip -n "$a" route del default
ip netns exec "$a" ping -c 1 -W 2 192.0.2.1
if ip -n "$a" route get 198.51.100.2; then echo 'Unexpected remote route'; exit 1; fi
if ip netns exec "$a" ping -c 1 -W 2 198.51.100.2; then echo 'Unexpected remote reachability'; exit 1; fi
ip -n "$a" route add default via 192.0.2.1
ip netns exec "$a" ping -c 1 -W 2 198.51.100.2
python3 - "$out" <<'PY'
import json
import sys
from pathlib import Path
p = Path(sys.argv[1])
a = json.loads((p / 'a-frame.json').read_text())
r = json.loads((p / 'r-frame.json').read_text())
assert a['ethernet_destination'] == '02:00:00:00:00:11'
assert r['ethernet_destination'] == '02:00:00:00:00:21'
assert a['ip_source'] == r['ip_source'] == '192.0.2.2'
assert a['ip_destination'] == r['ip_destination'] == '198.51.100.2'
assert a['icmp_sequence'] == r['icmp_sequence']
assert a['ttl'] == r['ttl'] + 1
(p / 'result.json').write_text(json.dumps({'passed': True, 'a_frame': a, 'router_frame': r,
'scope': 'isolated Linux namespaces, no NAT, no host routing changes'}, indent=2))
print('ASSERTIONS PASSED: IP destination unchanged, next-hop MAC changed, TTL decreased by one')
PY
echo "Evidence: $out"
文件:scripts/capture-lab-icmp.py
import json
import socket
import struct
import sys
from pathlib import Path
interface, output, ready = sys.argv[1:]
with socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(3)) as sock:
sock.bind((interface, 0))
sock.settimeout(10)
Path(ready).touch()
while True:
frame, metadata = sock.recvfrom(65535)
if metadata[2] != socket.PACKET_OUTGOING or len(frame) < 42:
continue
if frame[12:14] != b'\x08\x00' or frame[23] != 1:
continue
header_length = (frame[14] & 15) * 4
if frame[14 + header_length] != 8:
continue
destination = socket.inet_ntoa(frame[30:34])
if destination != '198.51.100.2':
continue
result = {
'interface': interface,
'ethernet_destination': ':'.join(f'{x:02x}' for x in frame[:6]),
'ethernet_source': ':'.join(f'{x:02x}' for x in frame[6:12]),
'ip_source': socket.inet_ntoa(frame[26:30]),
'ip_destination': destination,
'ttl': frame[22],
'icmp_sequence': struct.unpack('!H', frame[14 + header_length + 6:14 + header_length + 8])[0],
}
Path(output).write_text(json.dumps(result, indent=2), encoding='utf-8')
print(json.dumps(result))
break
运行与结果判断
在上述工作目录执行:
sudo bash scripts/verify-network-next-hop.sh
输出保存在工作目录的 data/verification 下,以运行时间命名。正常完成会显示断言通过;还应检查本次目录的 result.json 中 passed 为 true。预设的失败对照属于实验内容,不意味着脚本整体失败;依赖缺失、权限不足或断言失败则不能算通过。不要使用 python -O 或 PYTHONOPTIMIZE 禁用断言。
脚本只修改自己新建的随机名称网络命名空间,正常退出及普通错误退出会执行清理。断电、强制终止或 kill -9 不能保证清理;可用 ip netns list 检查,只处理确认属于本次实验的资源,不要批量删除其他命名空间。输出中的地址来自隔离实验,不需要填写家庭账号、真实设备地址或认证信息。
1406



被折叠的 条评论
为什么被折叠?



