跳到主要内容

7.4 NDISC 协议 (IPv6)

上一节我们聊完了 IPv4 的 ARP。平心而论,ARP 做到了它该做的:简单、直接、有效。但它也生来就带着缺陷——没有任何安全机制,谁都可以冒充网关(这就是我们常说的 ARP 欺骗)。

进入 IPv6 时代之后,这种「喊一声谁都可以答应」的机制就不行了。我们需要一套更严谨、功能更强大的协议。

这就是 RFC 4861 定义的 NDISC (Neighbor Discovery for IPv6)

这一节,我们将看到 IPv6 是如何用 NDISC 替代 ARP 的。你会发现,虽然核心任务还是「把 IP 变成 MAC」,但 NDISC 做得更多:它不仅负责地址解析,还负责路由器发现、前缀发现,以及重复地址检测(DAD)。


7.4.1 重复地址检测 (DAD)

在 IPv4 时代,如果局域网里有两台机器配了同一个 IP,后果通常是不可预测的——有时候双方能通,有时候都不通,看谁运气好。但在 IPv6 里,我们绝不允许这种模糊性存在。

DAD (Duplicate Address Detection) 是 IPv6 地址配置流程中的强制环节(IPv4 里只是个可选选项)。

它的逻辑很简单:当你要配置一个 IPv6 地址时,你不能直接用,得先问一句「这地址有人用了吗?」。如果没人回,你才能把它标记为永久地址。

具体流程是这样的:

  1. 生成链路本地地址:主机先生成一个以 FE80 开头的 Link-Local 地址。
  2. 标记为 Tentative:此时地址处于 IFA_F_TENTATIVE 状态,内核只能用它发送 NDISC 相关的消息,不能跑业务流量。
  3. 发起 DAD:调用 addrconf_dad_start()(位于 net/ipv6/addrconf.c)。
  4. 发送 NS 消息:发送一个特殊的 Neighbor Solicitation 消息。
    • Target Address:填你自己想用的那个 IP(即 tentative 地址)。
    • Source Address:填全 0(未指定地址 ::)。
  5. 等待:如果在规定时间内没收到回复,说明这地址确实没人用。
  6. 确认:状态从 IFA_F_TENTATIVE 变为 IFA_F_PERMANENT,地址正式生效。

这里有个细节:如果有人在 DAD 期间回复了 NA,说明地址冲突,DAD 失败,内核会报错并禁用该地址。

⚠️ 关于 Optimistic DAD 既然 DAD 要等,那网络岂不是要「卡」一下?确实。为了解决这个问题,RFC 4429 提出了 Optimistic DAD(内核配置 CONFIG_IPV6_OPTIMISTIC_DAD)。 开启后,内核不等 DAD 结束就先把地址标记为「乐观」状态(IFA_F_OPTIMISTIC),允许你先用着。如果后续发现冲突,再撤回。这是一种「先用后付」的策略,风险自担。


7.4.2 NDISC 的基础设施:nd_tbl

和 IPv4 用 arp_tbl 一样,IPv6 也有自己的邻居表,叫 nd_tbl

来看一下它的定义(net/ipv6/ndisc.c):

struct neigh_table nd_tbl = {
.family = AF_INET6,
.key_len = sizeof(struct in6_addr),
.hash = ndisc_hash,
.constructor = ndisc_constructor,
.pconstructor = pndisc_constructor,
.pdestructor = pndisc_destructor,
.proxy_redo = pndisc_redo,
.id = "ndisc_cache",
.parms = {
.tbl = &nd_tbl,
.base_reachable_time = ND_REACHABLE_TIME,
.retrans_time = ND_RETRANS_TIMER,
.gc_staletime = 60 * HZ,
.reachable_time = ND_REACHABLE_TIME,
.delay_probe_time = 5 * HZ,
.queue_len_bytes = 64*1024,
.ucast_probes = 3,
.mcast_probes = 3,
.anycast_delay = 1 * HZ,
.proxy_delay = (8 * HZ) / 10,
.proxy_qlen = 64,
},
.gc_interval = 30 * HZ,
.gc_thresh1 = 128,
.gc_thresh2 = 512,
.gc_thresh3 = 1024,
};

你会发现,这个结构和 arp_tbl 几乎一模一样。

  • gc_thresh1/2/3:垃圾回收的阈值,默认值也和 ARP 表一样(128, 512, 1024)。
  • parms:定义了超时、重试次数等行为参数。

NDISC 的实现基于 ICMPv6 消息。它定义了 5 种核心消息类型:

#define NDISC_ROUTER_SOLICITATION 133
#define NDISC_ROUTER_ADVERTISEMENT 134
#define NDISC_NEIGHBOUR_SOLICITATION 135
#define NDISC_NEIGHBOUR_ADVERTISEMENT 136
#define NDISC_REDIRECT 137

注意:ICMPv6 的 Type 值在 128-255 之间的都是「信息类消息」,0-127 是「错误类消息」。NDISC 的消息都是信息类的。

这 5 种消息里,RS/RA 和 Redirect 主要跟路由相关,我们留到第 8 章再聊。这一节我们只关心 NS (135)NA (136),也就是对应 ARP Request 和 Reply 的东西。

在内核里,NDISC 消息的接收路径是这样的:

  1. 网络包到达 icmpv6_rcv()
  2. icmpv6_rcv() 发现 Type 是上述 5 种之一,就丢给 ndisc_rcv() 处理。

至于 neigh_ops,NDISC 也有一套对应的逻辑:

  • ndisc_direct_ops:如果网卡不需要硬件头(比如 header_ops 是 NULL),直接发,调用 neigh_direct_output()
  • ndisc_generic_ops:如果 header_ops->cache 是 NULL。
  • ndisc_hh_ops:如果 header_ops->cache 存在,就缓存硬件头。

这套逻辑和 ARP 那边完全一致,只是换了个名字。


7.4.3 发送邻居请求 (NS)

好了,现在你的 IPv6 数据包要发出去,但不知道目标的 MAC 地址。这时候该怎么办?

和 IPv4 一样,流程发生在 ip6_finish_output2() 里:

static int ip6_finish_output2(struct sk_buff *skb)
{
struct dst_entry *dst = skb_dst(skb);
struct net_device *dev = dst->dev;
struct neighbour *neigh;
struct in6_addr *nexthop;
int ret;
. . .

// 1. 找下一跳
nexthop = rt6_nexthop((struct rt6_info *)dst, &ipv6_hdr(skb)->daddr);

// 2. 查邻居表
neigh = __ipv6_neigh_lookup_noref(dst->dev, nexthop);

// 3. 如果没找到,创建一个
if (unlikely(!neigh))
neigh = __neigh_create(&nd_tbl, nexthop, dst->dev, false);

// 4. 发送(如果状态不对,会触发解析)
if (!IS_ERR(neigh)) {
ret = dst_neigh_output(dst, neigh, skb);
. . .

如果邻居状态不是 NUD_REACHABLE,内核会触发 neigh_probe(),进而调用 neigh->ops->solicit。 对于 NDISC,这个回调就是 ndisc_solicit()

来看一下它的实现(net/ipv6/ndisc.c):

static void ndisc_solicit(struct neighbour *neigh, struct sk_buff *skb)
{
struct in6_addr *saddr = NULL;
struct in6_addr mcaddr;
struct net_device *dev = neigh->dev;
struct in6_addr *target = (struct in6_addr *)&neigh->primary_key;
int probes = atomic_read(&neigh->probes);

// 如果有现成的源地址,就用它
if (skb && ipv6_chk_addr(dev_net(dev), &ipv6_hdr(skb)->saddr, dev, 1))
saddr = &ipv6_hdr(skb)->saddr;

// 决定是单播探测还是组播探测
if ((probes -= neigh->parms->ucast_probes) < 0) {
// 单播探测:目标就是对方的 IP
if (!(neigh->nud_state & NUD_VALID)) {
ND_PRINTK(1, dbg, "%s: trying to ucast probe in NUD_INVALID: %pI6\n",
__func__, target);
}
ndisc_send_ns(dev, neigh, target, target, saddr);
} else if ((probes -= neigh->parms->app_probes) < 0) {
// 用户态 ARP 守护进程相关(CONFIG_ARPD)
#ifdef CONFIG_ARPD
neigh_app_ns(neigh);
#endif
} else {
// 组播探测:目标是对应的 Solicited Node 组播地址
addrconf_addr_solict_mult(target, &mcaddr);
ndisc_send_ns(dev, NULL, target, &mcaddr, saddr);
}
}

这段代码的核心逻辑是:

  1. 先试着单播 NS(如果对方之前有记录,只是状态 Stale 了,直接问它本人最快)。
  2. 如果单播次数用尽,或者根本没有记录,就组播 NS。

最终都是调用 ndisc_send_ns() 来构造并发送 ICMPv6 包。

NDISC 消息的结构体 nd_msg 很简单:

struct nd_msg {
struct icmp6hdr icmph;
struct in6_addr target;
__u8 opt[0];
};
  • 对于 NSicmph.icmp6_type 设为 NDISC_NEIGHBOUR_SOLICITATION
  • 对于 NAicmph.icmp6_type 设为 NDISC_NEIGHBOUR_ADVERTISEMENT

NA 消息有个大坑:它带有一堆标志位,定义在 icmp6hdr 的联合体里:

struct icmpv6_nd_advt {
#if defined(__LITTLE_ENDIAN_BITFIELD)
__u32 reserved:5,
override:1, // 强制更新缓存
solicited:1, // 这是一个响应
router:1, // 发送者是路由器
reserved2:24;
#endif
} u_nd_advt;

这三个 flag 至关重要:

  • Router:发送方是个路由器,收到方要更新默认路由器列表。
  • Solicited:置 1 表示「我是应你的请求而来的」,不是主动广播。对方收到后通常会把状态设为 REACHABLE
  • Override:置 1 表示「不管你缓存里存的是什么,以我这个为准」,强制更新 L2 地址。

看一眼 ndisc_send_ns() 的核心逻辑:

void ndisc_send_ns(struct net_device *dev, struct neighbour *neigh,
const struct in6_addr *solicit,
const struct in6_addr *daddr, const struct in6_addr *saddr)
{
struct sk_buff *skb;
struct in6_addr addr_buf;
int inc_opt = dev->addr_len;
int optlen = 0;
struct nd_msg *msg;

// 如果没指定源地址,找个 Link-Local 地址凑合
if (saddr == NULL) {
if (ipv6_get_lladdr(dev, &addr_buf,
(IFA_F_TENTATIVE|IFA_F_OPTIMISTIC)))
return;
saddr = &addr_buf;
}

// 如果源地址是全0(DAD的情况),就不带 Source Link-Layer Option
if (ipv6_addr_any(saddr))
inc_opt = 0;
if (inc_opt)
optlen += ndisc_opt_addr_space(dev);

skb = ndisc_alloc_skb(dev, sizeof(*msg) + optlen);
if (!skb)
return;

// 构造 ICMPv6 头部
msg = (struct nd_msg *)skb_put(skb, sizeof(*msg));
*msg = (struct nd_msg) {
.icmph = {
.icmp6_type = NDISC_NEIGHBOUR_SOLICITATION,
},
.target = *solicit,
};

// 填充 Source Link-Layer Address Option
if (inc_opt)
ndisc_fill_addr_option(skb, ND_OPT_SOURCE_LL_ADDR,
dev->dev_addr);

ndisc_send_skb(skb, daddr, saddr);
}

对应的,发送 NA 的是 ndisc_send_na()

static void ndisc_send_na(struct net_device *dev, struct neighbour *neigh,
const struct in6_addr *daddr,
const struct in6_addr *solicited_addr,
bool router, bool solicited, bool override, bool inc_opt)
{
...
msg = (struct nd_msg *)skb_put(skb, sizeof(*msg));
*msg = (struct nd_msg) {
.icmph = {
.icmp6_type = NDISC_NEIGHBOUR_ADVERTISEMENT,
.icmp6_router = router,
.icmp6_solicited = solicited,
.icmp6_override = override,
},
.target = *solicited_addr,
};

// 填充 Target Link-Layer Address Option(告诉对方我的 MAC)
if (inc_opt)
ndisc_fill_addr_option(skb, ND_OPT_TARGET_LL_ADDR,
dev->dev_addr);
...
}

7.4.4 接收并处理 NS 和 NA

现在换成接收视角。数据包进来,ndisc_rcv() 统筹全局。

它做的第一件事非常关键——安全检查

int ndisc_rcv(struct sk_buff *skb)
{
struct nd_msg *msg;

if (skb_linearize(skb))
return 0;

msg = (struct nd_msg *)skb_transport_header(skb);
__skb_push(skb, skb->data - skb_transport_header(skb));

// 检查 1:Hop Limit 必须是 255
if (ipv6_hdr(skb)->hop_limit != 255) {
ND_PRINTK(2, warn, "NDISC: invalid hop-limit: %d\n",
ipv6_hdr(skb)->hop_limit);
return 0;
}

// 检查 2:ICMPv6 Code 必须是 0
if (msg->icmph.icmp6_code != 0) {
ND_PRINTK(2, warn, "NDISC: invalid ICMPv6 code: %d\n",
msg->icmph.icmp6_code);
return 0;
}
...
}

为什么 Hop Limit 必须是 255? 因为路由器转发包时会把 Hop Limit 减 1。如果收到的包 Hop Limit 是 255,说明它来自同一链路,没经过任何路由器转发。这有效地防止了远程攻击者伪造 NDISC 消息来攻击你的局域网。这是 RFC 4861 强制要求的。

随后是一个大 switch,分发给不同的处理函数:

switch (msg->icmph.icmp6_type) {
case NDISC_NEIGHBOUR_SOLICITATION:
ndisc_recv_ns(skb);
break;
case NDISC_NEIGHBOUR_ADVERTISEMENT:
ndisc_recv_na(skb);
break;
...
}

处理 NS (ndisc_recv_ns)

这是整个邻居子系统最复杂的方法之一,因为它要处理三种情况:

  1. 目标是我自己的地址:对方在问「这是谁的 IP?」,我要回复 NA。
  2. DAD 冲突检测:对方说「我在做 DAD,目标是 IP X」,而我也在用 IP X。说明撞车了,必须处理。
  3. Proxy NDP (代理):对方问别人,但我配置了代理,我要替别人回答。

我们分段来看(net/ipv6/ndisc.c):

第一步:判断是 DAD 还是普通查询

static void ndisc_recv_ns(struct sk_buff *skb)
{
...
// 如果源地址是全0 (::),说明这是 DAD 探测
int dad = ipv6_addr_any(saddr);

// 各种合法性检查(包长、目标是否组播、DAD 包的目的地是否正确等)
if (skb->len < sizeof(struct nd_msg)) { ... return; }
if (ipv6_addr_is_multicast(&msg->target)) { ... return; } // 目标不能是组播
if (dad && !ipv6_addr_is_solict_mult(daddr)) { ... return; } // DAD 必须发到 Solicited 组播
...
}

第二步:检查目标地址是否在我身上

ifp = ipv6_get_ifaddr(dev_net(dev), &msg->target, dev, 1);
if (ifp) {
// 情况 A:撞车了!
if (ifp->flags & (IFA_F_TENTATIVE|IFA_F_OPTIMISTIC)) {
if (dad) {
// 我也在做 DAD,有人发了同样的包 -> DAD 失败
addrconf_dad_failure(ifp);
return;
} else {
// 对方在正常请求,但我还在 Tentative 状态
// 如果不是 Optimistic,就忽略
if (!(ifp->flags & IFA_F_OPTIMISTIC))
goto out;
}
}
// 目标在我身上,准备回复
idev = ifp->idev;
} else {
// 目标不在我身上,检查是否需要 Proxy
...
}

第三步:回复 NA

if (dad) {
// 针对普通 NS 的 DAD 回复(目标地址填自己,Flag 设 Router)
ndisc_send_na(dev, NULL, &in6addr_linklocal_allnodes, &msg->target,
!!is_router, false, (ifp != NULL), true);
goto out;
}

...

// 普通回复:顺便更新一下邻居表(把发 NS 的人记下来)
neigh = __neigh_lookup(&nd_tbl, saddr, dev, !inc || lladdr || !dev->addr_len);
if (neigh) {
// 状态设为 STALE(因为我不确定对方真的在不在,只是看到了包)
neigh_update(neigh, lladdr, NUD_STALE,
NEIGH_UPDATE_F_WEAK_OVERRIDE|
NEIGH_UPDATE_F_OVERRIDE);
...
// 发送 NA 回复
ndisc_send_na(dev, neigh, saddr, &msg->target,
!!is_router,
true, (ifp != NULL && inc), inc);
...
}

处理 NA (ndisc_recv_na)

当我们发出 NS 后,会收到对方的 NA。来看看内核怎么处理(net/ipv6/ndisc.c):

第一步:合法性检查

static void ndisc_recv_na(struct sk_buff *skb)
{
...
// 目标地址不能是组播
if (ipv6_addr_is_multicast(&msg->target)) { ... return; }

// 如果是 Solicited NA,目的地址不能是组播(Solicited NA 必须单播)
if (ipv6_addr_is_multicast(daddr) &&
msg->icmph.icmp6_solicited) { ... return; }

// 检查是否有人抢了我的 IP
ifp = ipv6_get_ifaddr(dev_net(dev), &msg->target, dev, 1);
if (ifp) {
if (skb->pkt_type != PACKET_LOOPBACK
&& (ifp->flags & IFA_F_TENTATIVE)) {
// 收到别人对我 Tentative 地址的 NA -> DAD 失败
addrconf_dad_failure(ifp);
return;
}
...
if (skb->pkt_type != PACKET_LOOPBACK)
ND_PRINTK(1, warn,
"NA: someone advertises our address %pI6 on %s!\n",
&ifp->addr, ifp->idev->dev->name);
...
}

第二步:更新邻居表

neigh = neigh_lookup(&nd_tbl, &msg->target, dev);
if (neigh) {
...
// 根据 NA 的 Flag 决定如何更新
// 1. Solicited=1 -> 状态设为 REACHABLE (邻居在线)
// 2. Solicited=0 -> 状态设为 STALE (可能是 Gratuitous ARP 类似的主动通告)
// 3. Override=1 -> 强制更新 MAC
neigh_update(neigh, lladdr,
msg->icmph.icmp6_solicited ? NUD_REACHABLE : NUD_STALE,
NEIGH_UPDATE_F_WEAK_OVERRIDE|
(msg->icmph.icmp6_override ? NEIGH_UPDATE_F_OVERRIDE : 0)|
NEIGH_UPDATE_F_OVERRIDE_ISROUTER|
(msg->icmph.icmp6_router ? NEIGH_UPDATE_F_ISROUTER : 0));
...
}
}

这一段逻辑和 ARP 的 arp_process 非常像,只是多了 RouterOverride flag 的处理细节。


7.4.5 小结

这一节我们拆解了 IPv6 的 NDISC 协议。

相比 ARP,它无疑更复杂:

  • 消息更丰富:不仅有 NS/NA,还有 RS/RA/Redirect。
  • 安全性更强:强制 Hop Limit 255 检查,防止远程攻击。
  • 语义更精确:通过 Solicited/Override/Router 标志位,精确控制邻居状态机的行为(NUD_REACHABLE vs NUD_STALE)。
  • 功能更完善:内置了 DAD 机制,从根源上解决了 IP 地址冲突问题。

但如果你剥开这些复杂的字段和 Option,你会发现它的核心行为和 ARP 并没有本质区别:发请求、收响应、记缓存

理解了 ARP,再看 NDISC,你会发现你只是在看同一个故事的「加强重制版」。下一章,我们将带着这些邻居发现的知识,进入 IPv6 子系统的更深处——看看自动配置和路由是如何利用这些基础的。