深蓝黑背景品牌封面:左侧大标题「ping 回车之后」,副标题「发包前系统先做了什么:ARP、路由与协议栈」;右侧原创装饰——一个终端窗口里显示 ping 命令,窗口下方依次展开三个步骤方块:查路由、问 ARP、封 ICMP,末端一枚数据包飞向目标主机;左上标注「经典题解析 · 网络」,右上标注「Day 10 · 10-08」 图 1:本文封面,点明「发包之前」的三步准备工作。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题面:输入 ping 加一个 IP,敲下回车,在第一个包真正发出之前,系统做了什么?

这道题和「浏览器输入网址后发生什么」是姊妹题,但考察面完全不同:那题走应用层全链路,这题沉到协议栈底层。分水岭答案只有一句:ping 一个 IP 不是直接发包,从回车到第一个包离网卡,协议栈要先过好几关。

核心结论:回车之后,系统依次完成六件事——构造 ICMP、查路由、解析 ARP、封装、发出、等回包。

第一步:解析目标,构造 ICMP Echo Request。

ping 的参数是 IP 就直接用(是域名还得先 DNS 解析)。协议栈构造一个 ICMP Echo Request 报文:类型字段为 8,带上标识符与序号——序号就是 ping 输出里 icmp_seq 的来源,用来把发出去的请求和回来的应答配成对。

第二步:查路由表,决定从哪块网卡出、下一跳是谁。

拿目标 IP 去匹配路由表,规则是最长前缀匹配:掩码越长的路由越优先。匹配结果分两种:目标与本机同网段,下一跳就是目标本身,包直接发到目标;跨网段,下一跳是默认网关,包先交给网关转发。这一步决定了源 IP(出口网卡的地址)与「链路层要找谁」。

第三步:ARP,把下一跳的 IP 翻译成 MAC 地址。

这是本题的灵魂。以太网只认 MAC 地址,IP 地址到了链路层用不上。系统先查 ARP 缓存,命中就直接用;没命中就广播一个 ARP 请求——「谁的 IP 是 X?请告诉我你的 MAC」,全网段都收得到,但只有 IP 为 X 的那台机器单播应答。应答回来后写入 ARP 缓存,后续包就不用再问了。

第四步:封装,三层套娃。

ICMP 报文外面套 IP 头(源 IP、目的 IP,协议号 1),IP 包外面再套以太网帧头(源 MAC、目的 MAC)。注意一个高频考点:帧的目的 MAC 不是最终目标的 MAC,而是下一跳的 MAC。跨网段时,目的 IP 是远端主机,目的 MAC 却是本局域网网关的——IP 地址端到端不变,MAC 地址每经过一跳重写一次。

第五步:网卡把帧发出去。

第六步:对端拆包,回 Echo Reply。

目标主机的协议栈层层拆包,ICMP 模块收到类型 8 的请求,回一个类型 0 的 Echo Reply,数据原样带回。回包走同样的路由与 ARP 流程返回来,ping 用序号配对,算出往返耗时打印出来。

写段代码,把路由与 ARP 的决策过程跑一遍。

下面模拟最长前缀匹配查路由,再按「同网段找目标、跨网段找网关」决定 ARP 解析谁。用 Node.js 22.15.0 直接运行(node route-arp.js)即可:

'use strict';

// 路由表:两条直连网段 + 一条默认路由
const routes = [
  { prefix: '10.0.0.0', mask: 8, nextHop: null, iface: 'eth0' },
  { prefix: '192.168.1.0', mask: 24, nextHop: null, iface: 'eth1' },
  { prefix: '0.0.0.0', mask: 0, nextHop: '192.168.1.1', iface: 'eth1' },
];
const arpCache = new Map([['192.168.1.1', 'aa:bb:cc:00:00:01']]); // 网关 MAC 已缓存

function ip2int(ip) {
  return ip.split('.').reduce((acc, o) => (acc * 256 + Number(o)) >>> 0, 0);
}

// 最长前缀匹配:掩码最长者优先
function lookup(dst) {
  const d = ip2int(dst);
  const hit = routes
    .filter((r) => r.mask === 0 || (d >>> (32 - r.mask)) === (ip2int(r.prefix) >>> (32 - r.mask)))
    .sort((a, b) => b.mask - a.mask)[0];
  return hit;
}

// 解析下一跳的 MAC:缓存命中直接用,否则广播 ARP 请求
function resolveMac(ip) {
  if (arpCache.has(ip)) return { mac: arpCache.get(ip), how: 'ARP 缓存命中' };
  const mac = 'ee:ff:00:11:22:33'; // 模拟单播应答学来的 MAC
  arpCache.set(ip, mac);
  return { mac, how: `广播 ARP 请求「谁的 IP 是 ${ip}」,单播应答后写入缓存` };
}

for (const dst of ['10.0.0.5', '8.8.8.8']) {
  const r = lookup(dst);
  const hop = r.nextHop ?? dst; // 同网段下一跳即目标,跨网段为网关
  const { mac, how } = resolveMac(hop);
  console.log(`ping ${dst} → 出口 ${r.iface},下一跳 ${hop},帧目的 MAC ${mac}(${how})`);
}

实测输出:

ping 10.0.0.5 → 出口 eth0,下一跳 10.0.0.5,帧目的 MAC ee:ff:00:11:22:33(广播 ARP 请求「谁的 IP 是 10.0.0.5」,单播应答后写入缓存)
ping 8.8.8.8 → 出口 eth1,下一跳 192.168.1.1,帧目的 MAC aa:bb:cc:00:00:01(ARP 缓存命中)

同网段的 10.0.0.5 直接 ARP 目标本身;跨网段的 8.8.8.8,帧的目的 MAC 是网关的——IP 层盯着 8.8.8.8,链路层只把它送到网关门口。

时序流程图:ping 一个 IP 从回车到第一个包发出经过六步——①解析目标:把参数当 IP 处理(若是域名还要先 DNS 解析),构造 ICMP Echo Request;②查路由表:用目标 IP 匹配路由,决定从哪块网卡出、下一跳是谁——同网段下一跳就是目标本身,跨网段则是默认网关;③ARP 解析:以太网只认 MAC 地址,先查 ARP 缓存,没有就广播「谁的 IP 是 X,请告诉我你的 MAC」,对方单播应答后写入缓存;④封装:依次套上 ICMP(类型 8、序号)、IP(源/目的 IP)、以太网帧头(源/目的 MAC),注意目的 MAC 是下一跳的 MAC,跨网段时是网关的 MAC 而不是最终目标的;⑤网卡发出;⑥对端协议栈层层拆包,ICMP 模块回一个 Echo Reply,原路返回,ping 打印往返耗时。底部指出两个高频追问:为什么 ping 的是 IP 还要 ARP——因为二层只认 MAC;跨网段时目的 IP 与目的 MAC 不一致——IP 端到端不变,MAC 每跳重写 图 2:从回车到发出的六步链路与两个高频追问。

高频追问先备好。

「ping 的是 IP,为什么还要 ARP?」因为二层只认 MAC,IP 是三层逻辑地址,帧没有 MAC 出不了网卡。「跨网段时目的 IP 和目的 MAC 不一致,矛盾吗?」不矛盾——目的 IP 端到端不变,指导每一跳的路由;目的 MAC 每跳重写,只负责当前这一段链路。「ping 不通怎么排?」按这六步反查:本机路由表对不对、ARP 学没学到、网关通不通、对端防火墙是不是丢了 ICMP。

易错点清单。

一,张嘴就是「ICMP 走 TCP/UDP 的端口」——ICMP 是网络层协议,没有端口概念。二,认为 ping 同网段也要过网关,没过路由这一步。三,把 ARP 说成「查询 DNS」。四,答不出序号的用途,说不清 ping 怎么把请求和应答配成对。

回到面试现场,建议的答题节奏:先一句「不是直接发包,协议栈要过六关」立住框架,再按六步顺序展开,重点停在第三步 ARP 与第四步「目的 MAC 是下一跳」这两个区分度最高的点上,最后主动给出 ping 不通时的逆向排查路径。框架、细节、实战三层都有了,这道题就是满分答卷。

你排查过最离奇的一次 ping 不通是什么原因?ARP、路由还是防火墙?欢迎分享。