前几篇我们拆解了 收包路径 和 发包路径
的全景。你会发现,两条路径在 IP 层交汇——收包时
ip_rcv() 决定这个包是本地投递还是转发,发包时
ip_queue_xmit() 查路由决定从哪个接口出去。IP
层是内核网络栈的中枢路由器。
但如果你用 ip route show table all
看到几千条路由规则,你能回答这些问题吗?
- FIB 路由表用的是什么数据结构?查找一条路由的时间复杂度是多少?
ip rule策略路由是怎么选表的?性能开销有多大?- IP 分片重组的定时器什么时候触发?超时的分片怎么处理?
- PMTU 发现失败时,内核是怎么回退到分片的?
- Netfilter 的五个钩子点分别在 IP 处理的哪个位置被调用?
本文全部从内核源码出发回答这些问题。
一、ip_rcv():IP 层入口
所有 IPv4 包在 L2 协议分发后进入
ip_rcv()(include/net/ip.h:161):
int ip_rcv(struct sk_buff *skb, struct net_device *dev,
struct packet_type *pt, struct net_device *orig_dev);ip_rcv()
做的事情很少但很关键——基本的合法性校验:
// net/ipv4/ip_input.c — ip_rcv() 简化
int ip_rcv(struct sk_buff *skb, ...)
{
struct iphdr *iph;
// 1. 只处理发给本机的包(pkt_type == PACKET_HOST 或 PACKET_BROADCAST 等)
// PACKET_OTHERHOST 的包在这里被丢弃
if (skb->pkt_type == PACKET_OTHERHOST)
goto drop;
// 2. 确保 IP 头可读(可能需要 pskb_may_pull)
if (!pskb_may_pull(skb, sizeof(struct iphdr)))
goto inhdr_error;
iph = ip_hdr(skb);
// 3. 版本号必须是 4
if (iph->version != 4)
goto inhdr_error;
// 4. 头长度至少 20 字节(ihl >= 5)
if (iph->ihl < 5)
goto inhdr_error;
// 5. 校验和验证
if (unlikely(ip_fast_csum((u8 *)iph, iph->ihl)))
goto csum_error;
// 6. 总长度合法性
if (skb->len < ntohs(iph->tot_len))
goto drop;
// 统计计数
__IP_INC_STATS(net, IPSTATS_MIB_INPKTS);
// 7. 进入 Netfilter PRE_ROUTING 钩子
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING,
net, NULL, skb, dev, NULL,
ip_rcv_finish);
}校验失败时递增
IPSTATS_MIB_INHDRERRORS——这个计数器可以在
/proc/net/netstat 中看到。
1.1 NF_HOOK 宏的语义
NF_HOOK 是 Netfilter
的核心宏(include/linux/netfilter.h):
static inline int
NF_HOOK(uint8_t pf, unsigned int hook, struct net *net, struct sock *sk,
struct sk_buff *skb, struct net_device *in, struct net_device *out,
int (*okfn)(struct net *, struct sock *, struct sk_buff *))
{
int ret = nf_hook(pf, hook, net, sk, skb, in, out, okfn);
if (ret == 1)
ret = okfn(net, sk, skb); // 所有钩子都放行 → 调用 okfn
return ret;
}返回值语义:
| 返回值 | 含义 |
|---|---|
NF_ACCEPT (1) |
放行,继续调用 okfn(如
ip_rcv_finish) |
NF_DROP (0) |
丢弃,kfree_skb() |
NF_QUEUE (3) |
排入用户态队列(NFQUEUE) |
NF_STOLEN (4) |
Netfilter 接管了这个包,不再继续 |
这意味着 ip_rcv() 最后的
NF_HOOK 调用实际上是:“先让 iptables/nftables
的 PREROUTING 链检查这个包,如果放行就调用
ip_rcv_finish()”。
二、ip_rcv_finish():路由决策
ip_rcv_finish() 是 IP
收包路径的核心——它决定这个包的命运:本地投递还是转发。
// net/ipv4/ip_input.c — ip_rcv_finish() 简化
static int ip_rcv_finish(struct net *net, struct sock *sk, struct sk_buff *skb)
{
struct iphdr *iph = ip_hdr(skb);
// 1. Early Demux:已建立连接的包跳过路由查找
if (net->ipv4.sysctl_ip_early_demux && !skb_dst(skb)) {
// TCP: tcp_v4_early_demux()
// UDP: udp_v4_early_demux()
// 如果找到匹配的 socket,直接复用 socket 上缓存的 dst_entry
}
// 2. 如果 Early Demux 没命中,做完整路由查找
if (!skb_dst(skb)) {
int err = ip_route_input_noref(skb, iph->daddr, iph->saddr,
iph->tos, skb->dev);
if (err)
goto drop;
}
// 3. IP 选项处理
if (iph->ihl > 5) {
// 处理 IP 选项(源路由、Record Route 等)
if (ip_rcv_options(skb, net))
goto drop;
}
// 4. 根据路由结果跳转
// dst->input 被设置为:
// - ip_local_deliver() — 本地投递
// - ip_forward() — 转发
// - ip_error() — 错误(如目的不可达)
return dst_input(skb); // 等价于 skb_dst(skb)->input(skb)
}2.1 Early Demux 的性能意义
Early Demux(早期解复用)是一个重要的优化。对于已建立的
TCP 连接,socket
上已经缓存了路由结果(dst_entry)。tcp_v4_early_demux()
通过四元组查找
socket,如果命中就直接复用缓存的路由——跳过整个 FIB
查找。
在连接数较多的服务器上,Early Demux 可以节省 30-50% 的 IP 层处理时间。但在纯路由/转发场景下(包不是发给本机的),它只是浪费 CPU 做无用的 socket 查找。所以转发网关通常关闭它:
# 在纯转发/路由器设备上关闭 Early Demux
sysctl -w net.ipv4.tcp_early_demux=0
sysctl -w net.ipv4.udp_early_demux=02.2 ip_route_input_noref():完整路由查找
当 Early Demux 未命中时,内核调用
ip_route_input_noref()(include/net/route.h:201):
int ip_route_input_noref(struct sk_buff *skb, __be32 dst, __be32 src,
u8 tos, struct net_device *devin);这个函数内部的关键步骤:
- 构建流键(
struct flowi4):目的 IP、源 IP、TOS、入接口 - 策略路由选表:遍历
ip rule规则链,选择匹配的 FIB 表 - FIB 查找:在选中的 FIB 表中做最长前缀匹配
- 构建 rtable:将
fib_result转化为struct rtable,设置dst->input和dst->output
路由类型决定了 dst->input 的值:
fib_result.type |
dst->input |
含义 |
|---|---|---|
RTN_LOCAL |
ip_local_deliver() |
目的是本机地址 |
RTN_UNICAST |
ip_forward() |
需要转发 |
RTN_BROADCAST |
ip_local_deliver() |
广播包 |
RTN_MULTICAST |
ip_mr_input() |
组播 |
RTN_UNREACHABLE |
ip_error() |
不可达,回 ICMP |
RTN_BLACKHOLE |
dst_discard() |
静默丢弃 |
三、FIB 路由表:LC-trie 实现
FIB(Forwarding Information Base)是内核路由表的底层数据结构。理解它的实现对理解路由查找性能至关重要。
3.1 FIB 表组织
内核支持多个 FIB
表(include/net/ip_fib.h):
struct fib_table {
struct hlist_node tb_hlist; // 表链
u32 tb_id; // 表 ID(如 254=main, 255=local)
int tb_num_default;
struct rcu_head rcu;
unsigned long *tb_data; // 指向 trie 根节点
unsigned long __data[]; // 内联数据
};默认有三张表:
| 表 ID | 名称 | 用途 |
|---|---|---|
| 255 | local |
本机地址、广播地址(自动维护,不可手动修改) |
| 254 | main |
普通路由(ip route add
默认写入这张表) |
| 253 | default |
默认路由(通常为空) |
策略路由可以创建编号 1-252 的自定义表。
3.2 LC-trie 数据结构
FIB 表内部使用 LC-trie(Level-Compressed trie)实现最长前缀匹配。这是 Robert Olsson 在 2004 年为 Linux 2.6.13 引入的算法,替代了之前的哈希表实现。
LC-trie 的核心思想:
普通 trie(前缀树):
每个 IP 地址的 32 位逐 bit 构建树
问题:树高最大 32 层,稀疏节点浪费内存
LC-trie 优化:
1. Path Compression:跳过只有单子节点的路径
10.0.0.0/8 和 10.1.0.0/16 之间的中间节点被压缩
2. Level Compression:多 bit 步进
每个节点可以同时检查 N 位(而非 1 位)
减少树高,加速查找
查找复杂度:
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 最长前缀匹配 | O(log n) 摊销 | n 为前缀数,实际 10-15 次内存访问 |
| 插入/删除 | O(log n) | 可能触发子树重组 |
| 全表扫描 | O(n) | ip route show 遍历 |
对于 100 万条路由(如 BGP 全表),LC-trie 通常只需 8-12 次内存访问完成一次查找。这比之前的哈希表实现(最坏 O(32) 次哈希)有显著改善。
3.3 fib_lookup() 调用链
fib_lookup() 是 FIB
查找的入口(include/net/ip_fib.h:311):
static inline int fib_lookup(struct net *net, const struct flowi4 *flp,
struct fib_result *res, unsigned int flags)
{
struct fib_table *tb;
// 快速路径:没有策略路由时直接查 local + main 表
if (!net->ipv4.fib_has_custom_rules) {
// 先查 local 表(本机地址最优先)
tb = rcu_dereference(net->ipv4.fib_local);
if (tb && !fib_table_lookup(tb, flp, res, flags | FIB_LOOKUP_NOREF))
return 0;
// 再查 main 表
tb = rcu_dereference(net->ipv4.fib_main);
if (tb && !fib_table_lookup(tb, flp, res, flags | FIB_LOOKUP_NOREF))
return 0;
return -ENETUNREACH;
}
// 慢速路径:策略路由 — 遍历 ip rule 规则链
return __fib_lookup(net, flp, res, flags);
}性能关键点:当没有自定义策略路由时(fib_has_custom_rules == false),内核跳过规则链遍历,直接查
local 和 main 两张表。这是大多数服务器的路径——只有两次 trie
查找。
3.4 fib_result 结构
查找成功后,结果存入
fib_result(include/net/ip_fib.h:169):
struct fib_result {
__be32 prefix; // 匹配的前缀
unsigned char prefixlen; // 前缀长度
unsigned char nh_sel; // 选中的下一跳索引(ECMP)
unsigned char type; // 路由类型(RTN_LOCAL/RTN_UNICAST/...)
unsigned char scope; // 范围(RT_SCOPE_HOST/LINK/UNIVERSE)
u32 tclassid; // TC 分类 ID
dscp_t dscp; // DSCP 值
struct fib_nh_common *nhc; // 下一跳公共信息
struct fib_info *fi; // 路由信息
struct fib_table *table; // 匹配的 FIB 表
};3.5 ECMP 多路径路由
当一条路由有多个下一跳时(ip route add ... nexthop via A nexthop via B),fib_info
中包含多个
fib_nh(include/net/ip_fib.h:105):
struct fib_nh {
struct fib_nh_common nh_common; // 通用字段:dev, gw, flags
struct hlist_node nh_hash;
struct fib_info *nh_parent;
__be32 nh_saddr; // 首选源地址
int nh_saddr_genid;
};ECMP 选择算法基于流哈希(源 IP + 目的 IP + 源端口 + 目的端口 + 协议号),同一条流始终选同一个下一跳——避免乱序。
四、策略路由:ip rule 选表机制
4.1 策略路由规则链
策略路由的核心是 ip rule
规则链。每条规则定义”在什么条件下查哪张 FIB
表”。默认规则:
$ ip rule show
0: from all lookup local # 优先级 0,查 local 表
32766: from all lookup main # 优先级 32766,查 main 表
32767: from all lookup default # 优先级 32767,查 default 表4.2 规则匹配字段
fib_rule
结构(include/net/fib_rules.h:20)支持丰富的匹配条件:
struct fib_rule {
// 匹配条件
int iifindex; // 入接口
int oifindex; // 出接口
u32 mark; // fwmark
u32 mark_mask; // fwmark 掩码
u32 flags;
u32 table; // 目标 FIB 表
u8 action; // 动作(FR_ACT_TO_TBL/FR_ACT_UNREACHABLE/...)
u8 l3mdev;
u8 proto;
u8 ip_proto; // TCP/UDP/...
char iifname[IFNAMSIZ];
char oifname[IFNAMSIZ];
struct fib_kuid_range uid_range;
struct fib_rule_port_range sport_range;
struct fib_rule_port_range dport_range;
// ...
};可以基于源 IP、目的 IP、入接口、fwmark、协议、端口范围、UID 等条件匹配。
4.3 策略路由的性能开销
__fib_lookup()
遍历规则链是线性的——每条规则逐一匹配。如果你有
100 条 ip rule,每个包都要检查最多 100
条规则才能找到匹配的 FIB 表。
性能影响:
无策略路由(默认): 2 次 trie 查找
10 条 ip rule: ~10 次规则匹配 + 若干次 trie 查找
100 条 ip rule: ~100 次规则匹配 + 若干次 trie 查找
在高 PPS 转发场景下,大量 ip rule
会成为性能瓶颈。优化方案:
- 减少规则数:用 fwmark 合并规则(在
iptables/nftables 中打标,一条
ip rule fwmark覆盖多种场景) - 调整规则优先级:把最频繁命中的规则放在最前面
- 使用
VRF:
ip link add vrf-blue type vrf table 100——VRF 设备在路由查找前就确定了表,跳过规则链
五、本地投递路径:ip_local_deliver()
当路由决策为 RTN_LOCAL
时,dst->input 指向
ip_local_deliver()(include/net/ip.h:165)。
5.1 分片重组
ip_local_deliver()
的第一件事是检查是否需要分片重组:
// net/ipv4/ip_input.c — ip_local_deliver() 简化
int ip_local_deliver(struct sk_buff *skb)
{
struct iphdr *iph = ip_hdr(skb);
// MF (More Fragments) 标志或 fragment offset 非零 → 这是一个分片
if (ip_is_fragment(iph)) {
// 送入分片重组队列
if (ip_defrag(net, skb, IP_DEFRAG_LOCAL_DELIVER))
return 0; // 还没收齐,等待更多分片
// ip_defrag 返回 0 = 重组完成,skb 现在是完整包
}
// 进入 Netfilter LOCAL_IN 钩子
return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN,
net, NULL, skb, skb->dev, NULL,
ip_local_deliver_finish);
}5.2 ip_local_deliver_finish():协议分发
通过 Netfilter LOCAL_IN
后,ip_local_deliver_finish()
做最终的协议分发:
static int ip_local_deliver_finish(struct net *net, struct sock *sk,
struct sk_buff *skb)
{
skb_clear_delivery_time(skb);
__skb_pull(skb, skb_network_header_len(skb)); // 移动指针跳过 IP 头
rcu_read_lock();
ip_protocol_deliver_rcu(net, skb, ip_hdr(skb)->protocol);
rcu_read_unlock();
return 0;
}ip_protocol_deliver_rcu() 根据 IP 头的
protocol 字段查找注册的协议处理函数:
protocol 值 |
处理函数 | 含义 |
|---|---|---|
| 6 (IPPROTO_TCP) | tcp_v4_rcv() |
TCP 协议 |
| 17 (IPPROTO_UDP) | udp_rcv() |
UDP 协议 |
| 1 (IPPROTO_ICMP) | icmp_rcv() |
ICMP 协议 |
| 47 (IPPROTO_GRE) | gre_rcv() |
GRE 隧道 |
| 4 (IPPROTO_IPIP) | ipip_rcv() |
IPIP 隧道 |
如果找不到注册的协议处理函数,递增
IPSTATS_MIB_INUNKNOWNPROTOS 并发送 ICMP
“协议不可达”。
六、转发路径:ip_forward()
当路由决策为 RTN_UNICAST
且包不是发给本机时,进入
ip_forward()(include/net/ip.h:749)。
6.1 ip_forward() 核心逻辑
// net/ipv4/ip_forward.c — ip_forward() 简化
int ip_forward(struct sk_buff *skb)
{
struct iphdr *iph = ip_hdr(skb);
struct rtable *rt = skb_rtable(skb);
// 1. TTL 检查
if (iph->ttl <= 1) {
// TTL 耗尽,发送 ICMP Time Exceeded
icmp_send(skb, ICMP_TIME_EXCEEDED, ICMP_EXC_TTL, 0);
__IP_INC_STATS(net, IPSTATS_MIB_INHDRERRORS);
goto drop;
}
// 2. MTU 检查
if (ip_exceeds_mtu(skb, mtu)) {
if (iph->frag_off & htons(IP_DF)) {
// DF 标志 + 包太大 → ICMP "Fragmentation Needed"
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_FRAG_NEEDED,
htonl(mtu));
__IP_INC_STATS(net, IPSTATS_MIB_FRAGFAILS);
goto drop;
}
}
// 3. 严格源路由检查(rp_filter)
if (net->ipv4.sysctl_ip_forward &&
IN_DEV_RPFILTER(in_dev)) {
// 反向路径过滤:检查源 IP 的路由是否指向入接口
}
// 4. TTL 减一
ip_decrease_ttl(iph);
// 5. Netfilter FORWARD 钩子
return NF_HOOK(NFPROTO_IPV4, NF_INET_FORWARD,
net, NULL, skb, skb->dev, rt->dst.dev,
ip_forward_finish);
}6.2 ip_forward_finish() → ip_output()
static int ip_forward_finish(struct net *net, struct sock *sk, struct sk_buff *skb)
{
struct iphdr *iph = ip_hdr(skb);
// 处理 IP options(如 Record Route、Timestamp)
if (unlikely(iph->ihl > 5))
ip_forward_options(skb);
__IP_INC_STATS(net, IPSTATS_MIB_OUTFORWDATAGRAMS);
// 进入发送路径
return dst_output(net, sk, skb);
// dst->output 指向 ip_output()
}6.3 转发路径的性能瓶颈
对于纯转发场景(路由器/网关),IP 层的关键瓶颈是:
| 阶段 | 开销 | 优化手段 |
|---|---|---|
| FIB 查找 | 8-12 次内存访问 | 关闭 Early Demux 避免无用 socket 查找 |
| Netfilter 钩子 | 与规则数线性 | 减少规则、用 nftables 替代 iptables |
| TTL 修改 + 校验和 | 固定 | 无(必须做) |
| 策略路由 | 与规则数线性 | 减少 ip rule、用 VRF |
| conntrack | 哈希表查找 | 转发流量可跳过 conntrack(-j NOTRACK) |
高 PPS 转发场景的极限优化通常是完全绕过 IP
层——用 XDP 在驱动层直接转发,避免
ip_rcv→ip_forward→ip_output
的全部开销(详见 21-xdp)。
七、IP 分片与重组
7.1 为什么会分片
当 IP 包大小超过出接口的 MTU 时,如果 DF(Don’t
Fragment)标志未设置,内核会对包进行分片。分片发生在
ip_finish_output() 中(发送路径)。
7.2 发送端分片:ip_do_fragment()
ip_do_fragment()(include/net/ip.h:170)处理发送端分片:
int ip_do_fragment(struct net *net, struct sock *sk, struct sk_buff *skb,
int (*output)(struct net *, struct sock *, struct sk_buff *));分片过程使用 ip_frag_state
状态机(include/net/ip.h:194):
struct ip_frag_state {
bool DF; // Don't Fragment 标志
unsigned int hlen; // IP 头长度
unsigned int ll_rs; // 链路层预留空间
unsigned int mtu; // 出接口 MTU
unsigned int left; // 剩余数据长度
int offset; // 当前分片偏移
int ptr; // 当前分片数据指针
__be16 not_last_frag; // MF 标志
};分片后每个片段都是独立的 sk_buff,各有自己的
IP 头,fragment offset 字段标记在原始包中的位置。
7.3 接收端重组:ip_defrag()
收包端在 ip_local_deliver() 中调用
ip_defrag()(include/net/ip.h:735):
int ip_defrag(struct net *net, struct sk_buff *skb, u32 user);重组使用 inet_frag_queue
结构(include/net/inet_frag.h):
struct inet_frag_queue {
spinlock_t lock; // 队列锁
struct timer_list timer; // 重组超时定时器
struct hlist_node hash; // 哈希表节点
refcount_t refcnt;
struct rb_root rb_fragments; // 红黑树组织分片
struct sk_buff *fragments_tail;
ktime_t stamp; // 第一个分片到达时间
int len; // 已收到的总长度
int meat; // 已收到的数据长度
u8 flags;
// ...
};重组流程:
1. 收到分片 → 用 (源IP, 目的IP, ID, 协议号) 做哈希查找
2. 找到现有队列 → 将分片插入红黑树(按 offset 排序)
3. 没有现有队列 → 创建新队列,启动重组定时器
4. 检查是否所有分片到齐(收到最后一片 + 数据连续)
5. 到齐 → 重组为完整 sk_buff,返回给调用者
6. 超时 → 丢弃所有分片,发送 ICMP "Fragment Reassembly Time Exceeded"
7.4 重组超时与资源限制
# 分片重组超时(秒)
sysctl net.ipv4.ipfrag_time # 默认 30 秒
# 分片队列内存上限
sysctl net.ipv4.ipfrag_high_thresh # 默认 4MB
sysctl net.ipv4.ipfrag_low_thresh # 默认 3MB
# 当总内存超过 high_thresh,开始丢弃最老的队列,直到降到 low_thresh统计计数器在 /proc/net/snmp 中:
$ cat /proc/net/snmp | grep -A 1 Ip:
Ip: ... ReasmTimeout ReasmReqds ReasmOKs ReasmFails FragOKs FragFails FragCreates
Ip: ... 0 1234 1230 4 5678 0 11356| 计数器 | 含义 |
|---|---|
ReasmReqds |
收到的分片总数 |
ReasmOKs |
成功重组的包数 |
ReasmFails |
重组失败的包数(超时或内存不足) |
ReasmTimeout |
因超时丢弃的重组队列数 |
FragOKs |
成功分片的包数 |
FragFails |
分片失败的包数(DF 标志 + 包太大) |
FragCreates |
创建的分片数 |
7.5 分片的安全风险
IP 分片长期以来是安全攻击的目标:
- Tiny Fragment Attack:构造极小的分片,让第一个分片的 TCP 头不完整,绕过状态less防火墙的端口检查
- Overlapping Fragment Attack:构造偏移重叠的分片,重组后覆盖已检查的头部字段
- Fragment Flood:大量发送不完整的分片,耗尽重组队列内存
现代内核的应对(net/ipv4/ip_fragment.c):
- 拒绝偏移重叠的分片
- 用红黑树替代链表,加速分片插入
ipfrag_high_thresh限制总内存- 超时机制回收僵尸队列
八、PMTU 发现与 FNHE 缓存
8.1 Path MTU Discovery
当发送的包因 DF 标志太大而被中间路由器丢弃时,路由器回复 ICMP “Fragmentation Needed” 并携带下一跳 MTU。内核收到这个 ICMP 后,更新该目的地的 PMTU 缓存。
8.2 FNHE(FIB Nexthop Exception)
Linux 3.6
删除了全局路由缓存(rt_cache),改用
per-nexthop 的异常表(FNHE)来缓存 PMTU
等路径特定信息(include/net/ip_fib.h:59):
struct fib_nh_exception {
struct fib_nh_exception __rcu *fnhe_next;
int fnhe_genid;
__be32 fnhe_daddr; // 目的 IP
u32 fnhe_pmtu; // 缓存的 PMTU
bool fnhe_mtu_locked; // 手动锁定的 MTU
__be32 fnhe_gw; // 重定向的网关
unsigned long fnhe_expires; // 过期时间
struct rtable __rcu *fnhe_rth_input; // 缓存的入方向 rtable
struct rtable __rcu *fnhe_rth_output; // 缓存的出方向 rtable
unsigned long fnhe_stamp; // 更新时间戳
};FNHE 的生命周期:
1. 收到 ICMP "Frag Needed" → 创建/更新 FNHE,设置 fnhe_pmtu
2. 后续发往该目的的包 → 路由查找命中 FNHE → 使用缓存的 PMTU
3. FNHE 过期(默认 10 分钟) → 回退到接口 MTU,重新发现
4. 如果 PMTU 持续减小 → FNHE 持续更新
8.3 dst_entry 与路由缓存
dst_entry(include/net/dst.h:26)是路由查找结果的抽象:
struct dst_entry {
struct net_device *dev; // 出接口
struct dst_ops *ops; // 操作函数表
unsigned long _metrics; // 路径度量(MTU 等)
unsigned long expires; // 过期时间
int (*input)(struct sk_buff *); // 收包处理函数
int (*output)(struct net *, struct sock *, struct sk_buff *); // 发包处理函数
unsigned short flags;
short obsolete; // 是否过期
rcuref_t __rcuref; // RCU 引用计数
// ...
};dst_entry 的 input 和
output 函数指针是 IP
层处理的核心分发机制——路由查找的结果被编码为函数指针,后续处理只需调用
dst_input(skb) 或
dst_output(net, sk, skb) 即可。
九、Netfilter 五钩子详解
IP 层在五个位置调用 Netfilter 钩子。以下是每个钩子的精确调用位置和用途:
9.1 钩子位置总览
// include/uapi/linux/netfilter_ipv4.h
#define NF_IP_PRE_ROUTING 0 // ip_rcv() → ip_rcv_finish()
#define NF_IP_LOCAL_IN 1 // ip_local_deliver() → ip_local_deliver_finish()
#define NF_IP_FORWARD 2 // ip_forward() → ip_forward_finish()
#define NF_IP_LOCAL_OUT 3 // ip_local_out() → dst_output()
#define NF_IP_POST_ROUTING 4 // ip_output() → ip_finish_output()9.2 数据流与钩子的对应关系
本地收包:
PRE_ROUTING → 路由决策 → LOCAL_IN → 协议栈
转发:
PRE_ROUTING → 路由决策 → FORWARD → POST_ROUTING → 出接口
本地发送:
LOCAL_OUT → 路由决策 → POST_ROUTING → 出接口
9.3 conntrack 的影响
连接跟踪(nf_conntrack)在
PRE_ROUTING 和 LOCAL_OUT
处注册。对于转发流量,每个包都要经历两次 conntrack
查找(PRE_ROUTING 入 + POST_ROUTING 确认)。
在高 PPS 转发场景下,conntrack 的开销可能占 IP 层处理的 30-50%。如果不需要 NAT 和状态防火墙,可以跳过:
# 对转发流量跳过 conntrack(使用 nftables)
nft add rule inet filter prerouting iif eth0 oif eth1 notrack
# 或使用 iptables -j NOTRACK(raw 表)
iptables -t raw -A PREROUTING -i eth0 -o eth1 -j NOTRACK十、IP 层统计与可观测性
10.1 /proc/net/snmp 与 /proc/net/netstat
# SNMP 标准计数器
cat /proc/net/snmp | grep Ip:
# 扩展计数器
cat /proc/net/netstat | grep IpExt:关键计数器与内核对应关系:
| 计数器 | 内核常量 | 递增位置 |
|---|---|---|
InReceives |
IPSTATS_MIB_INPKTS |
ip_rcv() 入口 |
InHdrErrors |
IPSTATS_MIB_INHDRERRORS |
ip_rcv() 校验失败 |
InAddrErrors |
IPSTATS_MIB_INADDRERRORS |
路由查找失败(目的不可达) |
InDelivers |
IPSTATS_MIB_INDELIVERS |
ip_local_deliver_finish() |
ForwDatagrams |
IPSTATS_MIB_OUTFORWDATAGRAMS |
ip_forward_finish() |
OutRequests |
IPSTATS_MIB_OUTREQUESTS |
ip_local_out() |
OutDiscards |
IPSTATS_MIB_OUTDISCARDS |
发送路径丢弃 |
InNoRoutes |
IPSTATS_MIB_INNOROUTES |
路由查找无匹配 |
InDiscards |
IPSTATS_MIB_INDISCARDS |
内存不足等原因丢弃 |
10.2 bpftrace 追踪路由决策
# 追踪 ip_route_input_noref 的延迟和结果
bpftrace -e '
kprobe:ip_route_input_noref {
@start[tid] = nsecs;
}
kretprobe:ip_route_input_noref /@start[tid]/ {
$lat = (nsecs - @start[tid]) / 1000;
@route_lookup_us = hist($lat);
@route_result = lhist(retval, -10, 10, 1);
delete(@start[tid]);
}
'10.3 追踪转发路径
# 追踪 ip_forward 的调用频率和 TTL
bpftrace -e '
kprobe:ip_forward {
$skb = (struct sk_buff *)arg0;
$iph = (struct iphdr *)($skb->head + $skb->network_header);
@forward_ttl = lhist($iph->ttl, 0, 256, 8);
@forward_count = count();
}
interval:s:1 {
printf("--- %d forwards/sec ---\n", @forward_count);
clear(@forward_count);
}
'10.4 追踪分片重组
# 追踪 ip_defrag 调用和重组结果
bpftrace -e '
kprobe:ip_defrag {
@defrag_calls = count();
}
kretprobe:ip_defrag {
@defrag_result = lhist(retval, -10, 10, 1);
// 返回 0 = 重组完成,-EINPROGRESS = 等待更多分片
}
interval:s:5 {
printf("=== ip_defrag stats ===\n");
print(@defrag_calls);
print(@defrag_result);
clear(@defrag_calls);
clear(@defrag_result);
}
'10.5 perf 定位 IP 层热点
# 采集 IP 层相关函数的 CPU 热点
perf top -g -e cycles:k -s comm --filter='ip_rcv or ip_forward or \
ip_local_deliver or fib_table_lookup or ip_output'
# 对比优化前后的 FIB 查找开销
perf stat -e cycles,instructions,cache-misses -a \
-p $(pgrep -f "ip_forward") -- sleep 10十一、关键参数与调优
11.1 路由相关 sysctl
| 参数 | 默认值 | 作用 | 调优建议 |
|---|---|---|---|
net.ipv4.ip_forward |
0 | 启用 IP 转发 | 路由器/网关设为 1 |
net.ipv4.tcp_early_demux |
1 | TCP Early Demux | 纯转发设备关闭 |
net.ipv4.udp_early_demux |
1 | UDP Early Demux | 纯转发设备关闭 |
net.ipv4.conf.all.rp_filter |
0 | 反向路径过滤 | 1=严格,2=宽松 |
net.ipv4.conf.all.accept_redirects |
1 | 接受 ICMP 重定向 | 安全环境设为 0 |
net.ipv4.conf.all.send_redirects |
1 | 发送 ICMP 重定向 | 非路由器设为 0 |
net.ipv4.ip_forward_use_pmtu |
0 | 转发时用 PMTU | 特殊场景可开启 |
net.ipv4.ip_no_pmtu_disc |
0 | 禁用 PMTU 发现 | 不建议修改 |
11.2 分片相关 sysctl
| 参数 | 默认值 | 作用 |
|---|---|---|
net.ipv4.ipfrag_time |
30 | 重组超时(秒) |
net.ipv4.ipfrag_high_thresh |
4194304 | 重组队列内存上限(字节) |
net.ipv4.ipfrag_low_thresh |
3145728 | 重组队列内存下限(字节) |
net.ipv4.ipfrag_max_dist |
64 | 最大乱序分片距离 |
11.3 高 PPS 转发场景调优清单
# 1. 开启转发
sysctl -w net.ipv4.ip_forward=1
# 2. 关闭 Early Demux(转发流量不需要)
sysctl -w net.ipv4.tcp_early_demux=0
sysctl -w net.ipv4.udp_early_demux=0
# 3. 关闭不需要的 Netfilter 模块
# 如果不需要 NAT,卸载 conntrack
modprobe -r nf_conntrack
# 4. 减少策略路由规则
# 用 VRF 替代大量 ip rule
# 5. 调整 rp_filter(宽松模式比严格模式快)
sysctl -w net.ipv4.conf.all.rp_filter=2
# 6. 确保 RSS/RPS 均匀分布(减少单 CPU 瓶颈)
ethtool -L eth0 combined $(nproc)十二、总结
IP 层是内核网络栈中逻辑最复杂的一层——路由查找、策略选表、Netfilter 钩子、分片重组、PMTU 缓存,每个子系统都有自己的数据结构和性能特征:
- FIB 用 LC-trie:O(log n) 前缀匹配,10 万条路由也只需十几次内存访问
- 策略路由是线性遍历:
ip rule越多越慢,高 PPS 场景用 VRF 替代 - 五个 Netfilter 钩子:PRE_ROUTING → LOCAL_IN / FORWARD → POST_ROUTING / LOCAL_OUT,conntrack 在高 PPS 转发时可能成为瓶颈
- 分片重组用红黑树:超时 30 秒、内存限制 4MB,注意分片攻击防护
- PMTU 用 FNHE 缓存:per-nexthop 异常表替代了旧的全局路由缓存
- Early Demux 是双刃剑:加速已建立连接,但在转发场景是无用开销
下一篇我们深入 TCP 连接管理,拆解 SYN 队列、Accept 队列和 TCP 状态机的内核实现。
参考文献
- Linux 内核源码,
net/ipv4/ip_input.c,6.6 LTS / 6.8 - Linux 内核源码,
net/ipv4/ip_forward.c,6.6 LTS - Linux 内核源码,
net/ipv4/fib_trie.c,6.6 LTS(LC-trie 实现) - Linux
内核源码,
include/net/ip.h,6.8(ip_rcv声明于第 161 行,ip_forward声明于第 749 行,ip_defrag声明于第 735 行) - Linux
内核源码,
include/net/ip_fib.h,6.8(fib_result定义于第 169 行,fib_nh定义于第 105 行) - Linux
内核源码,
include/net/route.h,6.8(rtable定义于第 60 行) - Robert Olsson, “Trash-based Routing”, 2004, USENIX Annual Technical Conference
- Linux
内核文档,
Documentation/networking/ip-sysctl.rst
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【Linux 网络子系统深度拆解】路由子系统深度拆解:FIB、策略路由与路由缓存
IP 包到了 ip_rcv_finish(),下一跳怎么选?本文深入拆解 Linux 路由子系统的完整实现:fib_table 的 LC-trie 数据结构、fib_info/fib_nh 的内存布局、fib_rules 策略路由链、ECMP 多路径哈希选路、nexthop 对象 API、FNHE 异常缓存(路由缓存的替代品)、dst_entry 与 rtable 的关系、IPv6 fib6 差异,以及 bpftrace 追踪路由决策的实战方法。
Linux 网络子系统深度拆解
从 sk_buff 到 XDP,从收包路径到 TC 框架——系统拆解 Linux 内核网络子系统的每一个核心模块。基于 Linux 6.6 LTS 源码,配合 bpftrace/perf 实测追踪。
【Kubernetes 网络深度系列】Linux 网络栈全景:一个包从网卡到用户态的完整旅程
从 NIC 驱动到用户态 read(),一个网络包在 Linux 内核中到底经历了什么?本文拆解 sk_buff、NAPI、softirq、netfilter 的完整收包路径,并用 bpftrace 实测追踪每一跳的延迟。
【Kubernetes 网络深度系列】路由与隧道:Linux 怎么决定一个包往哪走
Linux 路由表、FIB 查找、策略路由,以及 VXLAN/Geneve/WireGuard 隧道技术深度拆解