IPv4 转发选出了下一跳,但以太网仍然需要目的 MAC 地址。地址解析协议(ARP)向本地链路询问哪个网卡拥有某个 IPv4 地址,然后把答案缓存为邻居条目。这种映射是学来的而非永久配置,所以会老化,会在网卡迁移后刷新,也可能被同一广播域里的另一台主机伪造。

在本地链路上发问

假设主机要向直连网络中的 192.0.2.20 发送数据。它广播一条 ARP 请求,带上自己的 IP/MAC,并询问谁拥有目标 IP。属主用单播应答自己的硬件地址。发送方随后才能组装以太网帧。目的地不在本地链路时,主机解析所选网关;每一段以太网链路使用自己的下一跳映射,而不是远端主机的 MAC。

这个请求很小,而且只在本地发生。它不是 DNS 查询,也不会穿过路由器。请求尚未完成时,条目可能是 incomplete;近期流量确认可达时是 reachable;计时器到期后变成 stale;解析失败则可能进入 failed。Linux 用 ip neigh 展示这些状态,较旧的 arp(8) 只呈现更简单的地址到硬件映射。计时器和状态转换取决于内核与网卡策略,不能把“默认 60 秒”当成所有系统的承诺。

网卡是答案的一部分

同一个 IPv4 地址可以出现在不同链路,所以 ARP 记录必须和设备一起理解。ip neigh show dev eth0 只询问一张网卡;arp -an 也把接口列在每条映射旁边。虚拟机迁移或更换网卡后,邻居可能还缓存着旧 MAC。免费 ARP 通告能让邻居更快更新,但它仍是没有签名的声明,在敏感网络上应当监控而不能盲信。

静态邻居条目(ip neigh add ... nud permanent)可以固定已知网关或设备,但硬件真的更换时,它也会让合法变化失败,直到有人编辑条目。普通主机优先使用动态学习,把 permanent 留给有文档说明的边界。IPv6 不使用 ARP;Neighbor Discovery 通过 ICMPv6 完成类似工作,安全控制也不同。

坏掉的样子

incomplete 条目伴随反复连接超时,可能说明目标宕机、VLAN 错误或链路层过滤挡住了 ARP。STALE 单独出现只说明可达性需要重新确认,不能直接断定 MAC 错误;故障切换后缓存确实指向旧设备时,帧才可能被送错地方。重复 IP 会让多个应答互相竞争,表现为间歇性丢包。由于 ARP 没有密码学证明,其他主机可以谎称“这个地址归我”,把帧引向攻击者;但正确验证身份的 TLS 仍能保护应用内容,不会因为 ARP 被欺骗就自动交出明文凭据。

先读取表,再决定是否改变它。对照路由选中的设备、邻居的 MAC 与状态,并用抓包确认请求和应答。在共享或不可信网段使用交换机检查、DHCP snooping、端口控制或链路密码保护。不要在生产主机上遇到不确定故障就清空所有邻居:那会制造一阵广播,也可能删除原本正确的静态边界。把“看见一个 MAC”误当成“确认了属主”也会让排障走错方向。

记录观察时间、接口和路由来源同样重要,因为邻居表是不断变化的缓存,而不是永久资产清单。若同一地址在两张网卡上出现,必须保留设备维度再比较;简单按 IP 去重会把两个合法的链路事实压成一个错误答案。抓包时同时记录请求者和应答者的 MAC,才能判断是正常学习、迁移后的免费 ARP,还是两个主机在争夺同一地址。把这些证据和变更前后的状态一起保存,后续才可以重现一次短暂的 incomplete 或 stale。

排查顺序也应固定下来:先确认目的地址是否在本地前缀,再确认路由实际选择的接口,最后才检查该接口上的邻居状态。若路由把地址送到错误的网卡,继续重试 ARP 只会增加噪声;已有可用映射时,主机可以直接发送帧,不必重新发起 ARP,所以抓不到新的 ARP 应答本身不能证明故障。若地址解析已成功而业务帧仍丢失,再结合抓包检查交换机、VLAN 与过滤规则。把每一步的输入和输出写进事件记录,团队才能区分“解析失败”和“解析成功后链路丢帧”这两类完全不同的问题。

快速验证

第一条命令用于 macOS,只读展示本机当前认识的 ARP 表。先把下面的 Python 保存为 arp_fixture_check.py,再运行固定夹具解析器,验证两张网卡、完整映射、解析中的 incomplete,以及 permanent/stale/failed NUD 状态;它不会触碰内核邻居表,也不会修改任何生产配置。

/usr/sbin/arp -an
python3 arp_fixture_check.py
import ipaddress
import json
import re

# Synthetic macOS arp -an and Linux ip -j -4 neigh show output.
ARP_LINES = """? (192.0.2.10) at a:b:c:d:e:f on en0 ifscope [ethernet]
? (192.0.2.10) at 02:00:00:00:00:20 on en1 ifscope [ethernet]
? (192.0.2.11) at (incomplete) on en0 ifscope [ethernet]
"""
NEIGH_JSON = """[
  {"dst":"192.0.2.10","dev":"eth0","lladdr":"02:00:00:00:00:10","state":["PERMANENT"]},
  {"dst":"192.0.2.10","dev":"eth1","lladdr":"02:00:00:00:00:20","state":["STALE"]},
  {"dst":"192.0.2.11","dev":"eth0","state":["INCOMPLETE"]},
  {"dst":"192.0.2.12","dev":"eth0","state":["FAILED"]}
]"""

def parse_arp(text):
    result = {}
    pattern = r"\S+ \(([0-9.]+)\) at (\(incomplete\)|[0-9a-fA-F:]+) on (\S+)(?: .*)?"
    for line in text.splitlines():
        if not line.strip():
            continue
        match = re.fullmatch(pattern, line)
        if not match:
            raise ValueError("not a supported macOS ARP row")
        ip, mac, interface = match.groups()
        ip = str(ipaddress.IPv4Address(ip))
        if mac == "(incomplete)":
            mac = None
        else:
            parts = mac.split(":")
            if len(parts) != 6 or any(not re.fullmatch(r"[0-9a-fA-F]{1,2}", p) for p in parts):
                raise ValueError("invalid Ethernet address")
            mac = ":".join(f"{int(part, 16):02x}" for part in parts)
        result[(ip, interface)] = {"mac": mac}
    return result

def parse_neigh(text):
    result = {}
    for entry in json.loads(text):
        key = (str(ipaddress.IPv4Address(entry["dst"])), entry["dev"])
        result[key] = {"mac": entry.get("lladdr"), "state": entry["state"]}
    return result

if __name__ == "__main__":
    arp = parse_arp(ARP_LINES)
    neigh = parse_neigh(NEIGH_JSON)
    assert len(arp) == 3 and len(neigh) == 4, "interface identity was lost"
    assert arp[("192.0.2.10", "en0")]["mac"] == "0a:0b:0c:0d:0e:0f"
    assert arp[("192.0.2.10", "en1")]["mac"] == "02:00:00:00:00:20"
    assert arp[("192.0.2.11", "en0")]["mac"] is None
    assert neigh[("192.0.2.10", "eth0")]["state"] == ["PERMANENT"]
    assert neigh[("192.0.2.10", "eth1")]["state"] == ["STALE"]
    assert neigh[("192.0.2.11", "eth0")] == {"mac": None, "state": ["INCOMPLETE"]}
    assert neigh[("192.0.2.12", "eth0")] == {"mac": None, "state": ["FAILED"]}
    assert parse_arp("") == parse_neigh("[]") == {}
    try:
        parse_arp("? (192.0.2.1) at aa:bb on en0")
    except ValueError:
        pass
    else:
        raise AssertionError("malformed MAC accepted")
    print("PASS: interface keys, MAC normalization, unresolved states, and empty tables")

在 macOS 上,第一条命令只读当前 ARP 表;固定夹具输出 PASS: interface keys, MAC normalization, unresolved states, and empty tables。夹具使用 (IP, interface) 作为键,保留同一地址在不同网卡上的两个事实,并拒绝格式不完整的 MAC。这只是固定数据的解析练习,不是 ARP 报文实验,也不是适配所有平台输出的完整解析器。实时表的内容取决于本机拓扑,不能替代抓包和路由检查。若同一地址在两张网卡上出现,应保留设备维度再比较;简单按 IP 去重会把两个合法链路事实压成一个错误答案。排查事件时,在迁移前后各做一次只读快照,并把路由、接口、MAC、状态和抓包时间戳放在同一记录里,这样才能区分解析失败与解析成功后链路丢帧。

延伸阅读

来源