前面我们拆解了 IP
层的路由查找与转发——ip_route_output_flow()
找到了下一跳 IP 地址和出接口。但 IP 层只管 L3
寻址,实际发帧需要 L2 目的 MAC 地址。谁来做这个翻译?
答案是邻居子系统(neighbour subsystem)——一个比 ARP 更通用的框架。ARP 只是 IPv4 在以太网上的一种邻居发现协议;IPv6 用 NDP(Neighbor Discovery Protocol),InfiniBand 用自己的地址解析。邻居子系统把”根据 L3 地址查找 L2 地址”这个动作抽象为统一的接口,不同协议注册不同的解析回调。
在发包路径中,当 ip_finish_output2()
需要发出一个帧时:
ip_finish_output2(net, sk, skb)
├── neigh = ip_neigh_for_gw(rt, skb, &is_v6gw)
│ └── 从路由缓存取或创建邻居对象
└── neigh_output(neigh, skb, skip_cache)
├── NUD_CONNECTED + 缓存有效?
│ └── neigh_hh_output(hh, skb) 快路径:直接拷贝缓存的 L2 头
└── 否则 → neigh->output(neigh, skb)
└── neigh_resolve_output() → 发 ARP 请求,skb 排队等待
本文拆解邻居子系统的三个核心问题:邻居缓存的数据结构与查找、NUD 状态机的完整转换、以及 ARP/NDP 协议的内核实现。
一、struct neighbour:邻居缓存条目
每个已知的”下一跳”在内核中对应一个
struct neighbour
对象(include/net/neighbour.h:137):
struct neighbour {
struct neighbour __rcu *next; /* 哈希链表指针 */
struct neigh_table *tbl; /* 所属表(arp_tbl / nd_tbl) */
struct neigh_parms *parms; /* 协议参数(超时、重试次数等) */
/* 时间戳 */
unsigned long confirmed; /* 最后确认时间(上层反馈可达) */
unsigned long updated; /* 最后更新时间 */
unsigned long used; /* 最后使用时间 */
/* NUD 状态机 */
u8 nud_state; /* 当前状态(NUD_REACHABLE 等) */
atomic_t probes; /* 已发送的探测次数 */
struct timer_list timer; /* 状态超时定时器 */
/* 未解析队列 */
struct sk_buff_head arp_queue; /* 等待地址解析的 skb 队列 */
unsigned int arp_queue_len_bytes;
/* 硬件地址 */
seqlock_t ha_lock;
unsigned char ha[]; /* L2 地址(变长,以太网 6 字节) */
/* L2 头缓存 */
struct hh_cache hh; /* 完整的 L2 头缓存(含 EtherType) */
/* 发送分发 */
int (*output)(struct neighbour *, struct sk_buff *);
const struct neigh_ops *ops; /* 协议操作集 */
/* 设备引用 */
struct net_device *dev;
/* 查找键(变长) */
u8 primary_key[]; /* IPv4: 4 字节 IP;IPv6: 16 字节 */
};关键设计要点:
变长对象。ha[] 和
primary_key[] 都是变长的——以太网
ha 是 6 字节,IPv4 primary_key 是
4 字节,IPv6 是 16
字节。neigh_table->entry_size
在表初始化时计算好总大小。
L2 头缓存(hh_cache)。当邻居进入
NUD_CONNECTED 状态后,hh_cache
存储完整的以太网头(14 字节:目的 MAC + 源 MAC +
EtherType)。后续发包直接 memcpy
这个缓存,不需要每次构造——这是邻居子系统最重要的性能优化。
output 函数指针。根据 NUD 状态动态切换:
-
NUD_CONNECTED:output = neigh_connected_output(快路径)
-
其他状态:output = neigh_resolve_output(需要解析)
二、neigh_table:邻居缓存表
表结构
struct neigh_table(neighbour.h:200)是邻居缓存的全局管理器。IPv4
的 ARP 表是
arp_tbl(include/net/arp.h:11),IPv6
的 NDP 表是
nd_tbl(include/net/ndisc.h:79):
struct neigh_table {
int family; /* AF_INET / AF_INET6 */
unsigned int entry_size; /* sizeof(neighbour) + key_len + ha_len */
unsigned int key_len; /* IPv4: 4, IPv6: 16 */
/* 协议回调 */
__u32 (*hash)(...); /* 哈希函数 */
bool (*key_eq)(...); /* 键比较 */
int (*constructor)(...); /* 新建条目初始化——ARP: arp_constructor */
/* GC 参数 */
int gc_thresh1; /* 软下限——低于此不触发 GC */
int gc_thresh2; /* 软上限——开始周期性 GC */
int gc_thresh3; /* 硬上限——强制立即 GC */
/* 哈希表 */
struct neigh_hash_table __rcu *nht; /* RCU 保护的哈希表 */
/* 统计 */
struct neigh_statistics __percpu *stats;
/* Proxy ARP */
struct pneigh_entry **phash_buckets;
struct sk_buff_head proxy_queue;
struct timer_list proxy_timer;
};哈希查找
neigh_hash_table(neighbour.h:192)使用开链哈希:
struct neigh_hash_table {
struct neighbour __rcu **hash_buckets;
unsigned int hash_shift;
__u32 hash_rnd[4]; /* 4 个随机种子 */
};查找路径(___neigh_lookup_noref(),neighbour.h:293):
hash_val = hash(pkey, dev, hash_rnd) >> (32 - hash_shift);
for (n = rcu_dereference(nht->hash_buckets[hash_val]);
n != NULL;
n = rcu_dereference(n->next)) {
if (n->dev == dev && key_eq(n, pkey))
return n;
}哈希函数同时考虑 L3 地址和网络设备——同一个 IP 在不同接口上可能对应不同的 MAC。
IPv4 的快速查找入口是
__ipv4_neigh_lookup()(include/net/arp.h:22),对环回和点对点接口特殊处理(key
强制为 INADDR_ANY)。
三、NUD 状态机:邻居可达性检测
NUD(Neighbor Unreachability
Detection)是邻居子系统的核心——它用状态机跟踪每个邻居的可达性。状态定义在
include/uapi/linux/neighbour.h:62:
┌──────────────┐
│ NUD_PERMANENT│ ← ip neigh add ... nud permanent
│ NUD_NOARP │ ← 无需解析(环回/点对点)
└──────────────┘
(静态,不参与状态机)
┌─────────────────────────────────────────────────────┐
│ NUD 动态状态机 │
│ │
│ 首次发包 │
│ ──────→ NUD_INCOMPLETE │
│ │ 发 ARP Request/NS │
│ │ skb 排入 arp_queue │
│ │ │
│ ├── 收到 Reply → NUD_REACHABLE │
│ │ │ 启动 reachable_time 定时器│
│ │ │ │
│ │ └── 超时 → NUD_STALE │
│ │ │ │
│ │ ├── 有新发包 │
│ │ │ → NUD_DELAY│
│ │ │ │ │
│ │ │ └── delay_probe_time 超时│
│ │ │ → NUD_PROBE│
│ │ │ │ │
│ │ │ 确认可达 ←┘│
│ │ │ → NUD_REACHABLE│
│ │ │ │
│ └── 重试耗尽 → NUD_FAILED ←── 重试耗尽 │
│ │ 丢弃 arp_queue 中的包 │
│ │ 后续发包到此邻居直接丢弃 │
└──────────────────────────┴────────────────────────────┘
状态组合
#define NUD_IN_TIMER (NUD_INCOMPLETE | NUD_REACHABLE | NUD_DELAY | NUD_PROBE)
#define NUD_VALID (NUD_PERMANENT | NUD_NOARP | NUD_REACHABLE |
NUD_PROBE | NUD_STALE | NUD_DELAY)
#define NUD_CONNECTED (NUD_PERMANENT | NUD_NOARP | NUD_REACHABLE)NUD_CONNECTED
是快路径的门票——只有在这三个状态下,neigh_output()
才会走缓存的 L2 头路径。
状态转换触发
| 转换 | 触发条件 | 内核函数 |
|---|---|---|
| → NUD_INCOMPLETE | 首次发包,无缓存 | neigh_event_send() |
| INCOMPLETE → REACHABLE | 收到 ARP Reply/NA | neigh_update() |
| REACHABLE → STALE | reachable_time 超时 |
neigh_timer_handler() |
| STALE → DELAY | 有新 skb 要发 | neigh_event_send() |
| DELAY → PROBE | delay_probe_time 超时 |
neigh_timer_handler() |
| PROBE → REACHABLE | 收到上层确认(TCP ACK) | neigh_confirm() |
| PROBE → FAILED | 重试次数耗尽 | neigh_timer_handler() |
| * → REACHABLE | TCP ACK 确认可达 | neigh_confirm() |
neigh_confirm()
的特殊作用:TCP 收到 ACK 时调用
dst_confirm_neigh(),间接调用
neigh_confirm() 更新 confirmed
时间戳。这意味着活跃的 TCP
连接会持续刷新邻居的可达性——不需要额外发 ARP 探测。
四、ARP 协议实现
ARP 请求发送
当邻居处于 NUD_INCOMPLETE 或
NUD_PROBE
状态时,neigh_resolve_output() 调用 ARP
发送逻辑:
neigh_resolve_output(neigh, skb)
├── neigh_event_send(neigh, skb)
│ └── nud_state 不在 NUD_CONNECTED + NUD_DELAY?
│ └── __neigh_event_send(neigh, skb)
│ ├── NUD_NONE → NUD_INCOMPLETE
│ │ └── neigh->ops->solicit(neigh, skb)
│ │ → arp_solicit(neigh, skb)
│ │ └── arp_send(ARPOP_REQUEST, ETH_P_ARP,
│ │ target_ip, dev, source_ip,
│ │ NULL, dev->dev_addr, NULL)
│ │ → arp_create() 构造 ARP 包
│ │ → arp_xmit() 发出
│ └── skb 入 arp_queue 等待解析
└── 如果已解析 → 填充 L2 头并发出
arp_send()(include/net/arp.h:62)构造标准
ARP 请求包:
ar_hrd= 1(以太网)ar_pro= 0x0800(IPv4)ar_op= ARPOP_REQUEST(1)ar_sha= 发送端 MACar_sip= 发送端 IPar_tha= 00:00:00:00:00:00(未知)ar_tip= 目标 IP
目的 MAC 使用广播地址
ff:ff:ff:ff:ff:ff。
ARP 响应接收
对端回复 ARP Reply 后的处理链:
网卡收包 → netif_receive_skb()
↓ ETH_P_ARP
arp_rcv(skb, dev, pt, orig_dev)
↓ NF_INET_ARP_IN(Netfilter ARP 钩子)
arp_process(net, sk, skb)
├── 解析 ARP 头:ar_op, ar_sha, ar_sip, ar_tha, ar_tip
│
├── ARPOP_REQUEST?
│ ├── ar_tip 是本机 IP?
│ │ └── 发 ARPOP_REPLY(源 IP 对应的 MAC)
│ ├── Proxy ARP 开启?
│ │ └── pneigh_lookup() → 代理响应
│ └── 更新发送端的邻居缓存
│ → neigh_update(n, sha, NUD_STALE, ...)
│
└── ARPOP_REPLY?
└── neigh = neigh_lookup(&arp_tbl, &sip, dev)
├── 找到 → neigh_update(n, sha, NUD_REACHABLE, ...)
│ ├── 更新 ha[](MAC 地址)
│ ├── 状态 → NUD_REACHABLE
│ ├── 刷新 hh_cache(L2 头缓存)
│ └── 发出 arp_queue 中排队的 skb
└── 未找到 → 丢弃
Gratuitous ARP
Gratuitous ARP(免费 ARP)是源 IP = 目的 IP 的 ARP 请求——用于宣告自己的 MAC 地址变更或检测 IP 冲突。内核在 IP 地址配置时发送:
# 手动触发 Gratuitous ARP
arping -U -I eth0 10.0.0.1
arping -A -I eth0 10.0.0.1 # ARP Reply 形式五、NDP:IPv6 邻居发现
IPv6 用 ICMPv6 的 Neighbor Solicitation(NS,类型 135)和
Neighbor Advertisement(NA,类型 136)替代 ARP。内核实现在
nd_tbl(include/net/ndisc.h:79)中。
NS/NA 函数
/* include/net/ndisc.h:448-465 */
void ndisc_send_ns(dev, solicit, daddr, saddr, match); /* 发送 NS */
void ndisc_send_na(dev, daddr, target, router, solicited, override, inc_opt); /* 发送 NA */
enum skb_drop_reason ndisc_rcv(struct sk_buff *skb); /* 接收处理 */与 ARP 的关键区别
| 特性 | ARP(IPv4) | NDP(IPv6) |
|---|---|---|
| 协议层 | L2(独立 EtherType 0x0806) | L3(ICMPv6,EtherType 0x86DD) |
| 地址解析 | 广播 ARP Request | 组播 NS(Solicited-Node 组播组) |
| 无状态地址检测 | Gratuitous ARP | DAD(Duplicate Address Detection) |
| 路由器发现 | 无(依赖 DHCP) | RS/RA(Router Solicitation/Advertisement) |
| Redirect | ICMP Redirect | ICMPv6 Redirect |
| Proxy | Proxy ARP | Proxy NDP |
NDP 使用 Solicited-Node
组播地址(ff02::1:ffXX:XXXX,基于目标 IP 后 24
位)代替广播,大幅减少了网络上不相关主机的中断。
NDP 定时器
/* include/net/ndisc.h:50-51 */
#define ND_REACHABLE_TIME (30*HZ) /* 30 秒 */
#define ND_RETRANS_TIMER HZ /* 1 秒 */六、发送路径的快路径与慢路径
neigh_output:决策入口
neigh_output()(neighbour.h:529)是发包路径中邻居子系统的入口:
static inline int neigh_output(struct neighbour *n,
struct sk_buff *skb,
bool skip_cache)
{
const struct hh_cache *hh = &n->hh;
if (!skip_cache && (n->nud_state & NUD_CONNECTED) && hh->hh_len)
return neigh_hh_output(hh, skb); /* 快路径 */
else
return n->output(n, skb); /* 慢路径 */
}快路径:neigh_hh_output
当邻居在 NUD_CONNECTED 状态且
hh_cache 有效时,neigh_hh_output()
直接用 memcpy 把缓存的 L2 头(以太网头 14
字节)拷贝到 skb 前面:
static inline int neigh_hh_output(const struct hh_cache *hh,
struct sk_buff *skb)
{
unsigned int hh_alen = 0;
unsigned int seq;
unsigned int hh_len;
do {
seq = read_seqbegin(&hh->hh_lock);
hh_len = READ_ONCE(hh->hh_len);
if (likely(hh_len <= HH_DATA_ALIGN(hh_len)))
memcpy(skb->data - HH_DATA_OFF(hh_len),
hh->hh_data, HH_DATA_ALIGN(hh_len));
} while (read_seqretry(&hh->hh_lock, seq));
skb_push(skb, hh_len);
return dev_queue_xmit(skb);
}这个 seqlock 保护的 memcpy
是整个发包路径中最频繁的操作之一——在正常传输中,绝大多数包走这条路径。无锁竞争,无函数调用,一个
memcpy + dev_queue_xmit() 就完成了
L2 封装。
慢路径:neigh_resolve_output
如果邻居不在 NUD_CONNECTED
状态(新建、超时、探测中),走
neigh_resolve_output():
neigh_resolve_output(neigh, skb)
├── __neigh_event_send(neigh, skb)
│ ├── NUD_INCOMPLETE:skb 入 arp_queue,等 ARP 回复
│ ├── NUD_STALE → NUD_DELAY:设定延迟探测定时器
│ └── 已解析:继续
├── neigh_hh_init(neigh, dst)
│ └── 如果 hh_cache 无效 → 初始化
├── dev_hard_header(skb, dev, ntohs(skb->protocol),
│ neigh->ha, NULL, skb->len)
│ └── 根据 ha[] 构造 L2 头
└── dev_queue_xmit(skb)
arp_queue 的长度上限由
neigh_parms->QUEUE_LEN_BYTES 控制(默认
~64KB)。超过限制时,最老的 skb 被丢弃。
七、Proxy ARP
Proxy ARP 让一台主机代替另一台主机回复 ARP 请求——常用于路由器连接不同子网但不做 NAT 的场景。
内核实现
arp_process() 收到 ARP Request
├── ar_tip 不是本机 IP
├── dev->flags & IFF_PROMISC 或
│ pneigh_lookup(&arp_tbl, net, &tip, dev, 0) 找到代理条目
└── pneigh_enqueue(&arp_tbl, parms, skb)
└── skb 入 proxy_queue
└── proxy_timer 触发后
└── arp_tbl.proxy_redo(skb)
└── 发 ARP Reply(用本机 MAC 回复)
pneigh_enqueue() 有一个
proxy_delay
延迟——避免代理响应先于真正的目标主机到达。
# 启用接口级 Proxy ARP
echo 1 > /proc/sys/net/ipv4/conf/eth0/proxy_arp
# 添加精确代理条目
ip neigh add proxy 10.0.0.100 dev eth0八、垃圾回收
邻居缓存条目会持续增长——每个访问过的 IP 都留下一条。GC 通过三级阈值控制:
entries < gc_thresh1 (默认 128):不触发 GC
gc_thresh1 ≤ entries < gc_thresh2 (默认 512):周期性 GC(清理过期条目)
gc_thresh2 ≤ entries < gc_thresh3 (默认 1024):激进 GC
entries ≥ gc_thresh3:强制立即清理,新建条目可能失败
neigh_table->gc_work 是周期性 GC
的工作队列。neigh_forced_gc() 在条目数逼近
gc_thresh3 时强制执行。
大规模网络调优
默认阈值对大型数据中心或多租户网络过小——一个 /16 子网有 65534 个可能的邻居:
# 查看当前邻居表大小
ip neigh show | wc -l
# 调大 GC 阈值
sysctl -w net.ipv4.neigh.default.gc_thresh1=4096
sysctl -w net.ipv4.neigh.default.gc_thresh2=8192
sysctl -w net.ipv4.neigh.default.gc_thresh3=16384
# 对特定接口调参
sysctl -w net.ipv4.neigh.eth0.gc_stale_time=120九、可观测性实战
邻居表统计
# 查看 ARP 表统计——缓存命中率、GC 次数、解析失败
cat /proc/net/stat/arp_cache
# entries allocs destroys hash_grows lookups hits ...
# res_failed rcv_probes_mcast rcv_probes_ucast ...
# periodic_gc_runs forced_gc_runs unres_discards table_fulls
# 查看当前邻居表
ip -s neigh showNUD 状态变化追踪
# 追踪邻居状态变化——neigh_update 是状态转换的核心入口
bpftrace -e '
kprobe:neigh_update {
$neigh = (struct neighbour *)arg0;
$new_state = arg2;
$dev = $neigh->dev;
printf("%s dev=%s old_state=0x%x new_state=0x%x\n",
comm,
$dev->name,
$neigh->nud_state,
$new_state);
}'ARP 解析延迟
# 测量 ARP 解析耗时——从 INCOMPLETE 到 REACHABLE
bpftrace -e '
kprobe:neigh_resolve_output {
$neigh = (struct neighbour *)arg0;
if ($neigh->nud_state == 1) { /* NUD_INCOMPLETE */
@resolve_start[$neigh] = nsecs;
}
}
kprobe:neigh_update {
$neigh = (struct neighbour *)arg0;
$new_state = arg2;
if ($new_state == 2 && @resolve_start[$neigh]) { /* NUD_REACHABLE */
@resolve_ms = hist((nsecs - @resolve_start[$neigh]) / 1000000);
delete(@resolve_start[$neigh]);
}
}'GC 压力监控
# 追踪强制 GC——频繁触发说明阈值太小
bpftrace -e '
kprobe:neigh_forced_gc {
printf("forced_gc triggered! time=%lu\n", nsecs);
@forced_gc = count();
}
interval:s:60 { print(@forced_gc); clear(@forced_gc); }'邻居表满检测
# 监控 "neighbour table overflow" 内核日志
bpftrace -e '
kprobe:__neigh_create {
@create_attempts = count();
}
kretprobe:__neigh_create /retval < 0/ {
printf("neigh_create FAILED err=%d\n", retval);
@create_failures = count();
}'十、关键参数速查
| 参数 | 默认值 | 内核对应 | 调优建议 |
|---|---|---|---|
neigh.default.gc_thresh1 |
128 | neigh_table->gc_thresh1 |
大网络增大到 4096+ |
neigh.default.gc_thresh2 |
512 | neigh_table->gc_thresh2 |
大网络增大到 8192+ |
neigh.default.gc_thresh3 |
1024 | neigh_table->gc_thresh3 |
大网络增大到 16384+ |
neigh.default.gc_stale_time |
60 | STALE 条目存活时间(秒) | 稳定网络可增大 |
neigh.default.base_reachable_time_ms |
30000 | NUD_REACHABLE 持续时间 | 稳定网络可增大 |
neigh.default.delay_first_probe_time |
5 | NUD_DELAY → NUD_PROBE 延迟(秒) | 通常不需调整 |
neigh.default.retrans_time_ms |
1000 | 探测重传间隔(毫秒) | 通常不需调整 |
neigh.default.ucast_solicit |
3 | 单播探测次数 | 不稳定网络可增大 |
neigh.default.mcast_solicit |
3 | 组播探测次数 | 不稳定网络可增大 |
neigh.default.unres_qlen_bytes |
65536 | 未解析队列字节上限 | 突发大包场景可增大 |
conf.{iface}.proxy_arp |
0 | Proxy ARP 开关 | 按需开启 |
conf.{iface}.arp_announce |
0 | ARP 源 IP 选择策略 | 多 IP 主机建议设为 2 |
conf.{iface}.arp_filter |
0 | ARP 响应过滤 | 多网卡建议开启 |
参考文献
- Linux
内核源码,
include/net/neighbour.h,6.8(struct neighbour 定义于第 137 行,struct neigh_table 于第 200 行) - Linux
内核源码,
include/net/arp.h,6.8(arp_tbl 于第 11 行,arp_send 于第 62 行) - Linux
内核源码,
include/net/ndisc.h,6.8(nd_tbl 于第 79 行,ndisc_send_ns 于第 452 行) - Linux
内核源码,
include/uapi/linux/neighbour.h,6.8(NUD 状态常量于第 62 行) - RFC 826, “An Ethernet Address Resolution Protocol”, D. Plummer, 1982
- RFC 4861, “Neighbor Discovery for IP version 6 (IPv6)”, T. Narten et al., 2007
- Linux
内核文档,
Documentation/networking/ip-sysctl.rst
上一篇:Socket 层内核实现:从 VFS 到协议栈的桥梁
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【Linux 网络子系统深度拆解】Socket 层内核实现:从 VFS 到协议栈的桥梁
你调用 socket(AF_INET, SOCK_STREAM, 0) 创建一个 TCP 连接,底层发生了什么?内核分配了两个核心对象——VFS 层的 struct socket 和协议层的 struct sock,通过 proto_ops 和 proto 两张分发表,把文件系统语义的 read/write 翻译成协议语义的 tcp_sendmsg/tcp_recvmsg。本文从 Linux 6.6 内核源码拆解 socket 创建、双层分发、SO_REUSEPORT 多核分发、epoll 集成的完整实现。
【Linux 网络子系统深度拆解】UDP 内核实现与 socket lookup 优化
UDP 简单?在内核中它一点都不简单。双哈希表 socket 查找、SO_REUSEPORT 多核分发、Early Demux 路由缓存、UDP GRO 聚合、reader_queue 无锁读、forward allocation 内存管理、UDP 封装(ESP/L2TP/VXLAN)——本文从 Linux 6.6 内核源码拆解 UDP 的每一个优化细节。
【Linux 网络子系统深度拆解】TCP 内核实现(下):数据传输与拥塞控制
tcp_sendmsg 把用户数据拷到 sk_buff 就完事了?远没有。后面还有 Nagle 合并、TSQ 限流、cwnd/rwnd 双窗口门控、RACK-TLP 丢包检测、拥塞状态机五态跳转、sk_pacing_rate 软件限速。本文从 Linux 6.6 内核源码拆解 TCP 数据传输的完整路径——从 send() 到 ACK 处理——以及拥塞控制框架 tcp_congestion_ops 的可插拔架构。
【Linux 网络子系统深度拆解】TCP 内核实现(上):连接管理与状态机
TCP 连接在内核中不只是一个状态机——它是一组精心设计的数据结构和队列。本文从 Linux 6.6 内核源码出发,拆解 TCP 连接建立的 SYN Queue / Accept Queue 二级队列模型、request_sock 半连接对象、tcp_sock 全连接对象、SYN Cookie 无状态防御、TCP Fast Open 零 RTT 机制、inet_timewait_sock 轻量级 TIME_WAIT 实现,以及完整的 TCP 状态机在内核中的真实转换路径。