前面我们拆解了 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 请求发送
当邻居处于 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 = 发送端 MAC
ar_sip = 发送端 IP
ar_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 show
NUD 状态变化追踪
# 追踪邻居状态变化——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 到协议栈的桥梁
下一篇:路由子系统深度拆解:FIB、策略路由与路由缓存
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
2026-04-20 · linux / networking
你调用 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 集成的完整实现。
2026-04-20 · linux / networking
UDP 简单?在内核中它一点都不简单。双哈希表 socket 查找、SO_REUSEPORT 多核分发、Early Demux 路由缓存、UDP GRO 聚合、reader_queue 无锁读、forward allocation 内存管理、UDP 封装(ESP/L2TP/VXLAN)——本文从 Linux 6.6 内核源码拆解 UDP 的每一个优化细节。
2026-04-20 · linux / networking
tcp_sendmsg 把用户数据拷到 sk_buff 就完事了?远没有。后面还有 Nagle 合并、TSQ 限流、cwnd/rwnd 双窗口门控、RACK-TLP 丢包检测、拥塞状态机五态跳转、sk_pacing_rate 软件限速。本文从 Linux 6.6 内核源码拆解 TCP 数据传输的完整路径——从 send() 到 ACK 处理——以及拥塞控制框架 tcp_congestion_ops 的可插拔架构。
2026-04-20 · linux / networking
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 状态机在内核中的真实转换路径。