4.8 内核收包路径
一个帧从网线到达,到应用 read 拿到字节,中间经过哪些函数?为什么硬中断只做「登记」而不解析协议?NAPI 的 budget 用完了会怎样,ksoftirqd 什么时候接手?sk_buff 在哪个函数里分配、在哪个函数里释放?/proc/net/softnet_stat 的三列十六进制数到底在说什么?
画出从 napi_poll 到 tcp_v4_rcv 的完整调用链;用 softnet_stat 的 drop 与 time_squeeze 判断收包瓶颈;用 bpftrace 统计每个函数的调用次数与延迟;解释 RPS/RFS/GRO 各自优化了哪一段。
前七章讲的是「协议应该怎么工作」,从这一章开始讲「Linux 实际上怎么实现」。两者之间的差距,正是性能问题的来源:协议说「收到包就处理」,实现却要把处理推迟到软中断;协议说「按序交付」,实现却要维护乱序队列和 backlog。
收包路径是整篇最短、最硬的一段。它涉及的函数不多,但每一个都有明确的职责边界,而且都可能是瓶颈。这一章会沿着时间顺序走一遍,把「谁在什么上下文里做什么」标清楚。看完之后,你再看 perf top 里的 net_rx_action、tcp_v4_rcv,就知道它们在整条链路的哪个位置。
读法建议:把 rx-path 图放在旁边,对照正文里的函数名。所有观测命令都在本机可跑;需要网卡的部分用 lo 或 veth 代替,结论一致。
4.8.1 收包全景:三个上下文
收包不是「一个函数从头做到尾」,而是分成三个上下文,每个上下文的约束不同:
| 上下文 | 谁在跑 | 能做什么 | 不能做什么 |
|---|---|---|---|
| 硬中断 | 网卡中断处理程序 | 关中断、登记 NAPI、触发软中断 | 不能睡眠、不能解析协议、不能耗时 |
| 软中断 | ksoftirqd 或中断返回路径 | NAPI 轮询、协议栈处理、入队 | 不能睡眠(但可被抢占) |
| 进程上下文 | 应用 read/recv | 从 socket 队列取数据、拷贝到用户态 | 不能直接碰网卡 |
ksoftirqd。排查收包问题要同时看 /proc/softirqs 的 NET_RX、softnet_stat 的 drop,以及 socket 的 Recv-Q。读这张图要抓住「两次交接」。第一次交接在硬中断到软中断:硬中断只调用 napi_schedule 把设备挂到 per-CPU 的 poll_list 上,然后立刻退出。第二次交接在协议栈到 socket:tcp_v4_rcv 把数据放进 sk_receive_queue,然后唤醒等待的进程,剩下的拷贝工作由进程自己做。
为什么要这么分?因为硬中断会屏蔽同优先级中断,如果在里面解析 TCP,一个包的处理时间会直接变成中断延迟,网卡队列很快溢出。把重活推给软中断,硬中断就能在微秒级返回,网卡继续收包。
4.8.2 网卡初始化与 RX ring
在收包之前,驱动要先准备好「放包的地方」。这个地方叫 RX ring(接收环),是一圈固定数量的描述符,每个描述符指向一块 DMA 缓冲区。
驱动初始化(probe 阶段)
├─ pci_alloc_consistent / dma_alloc_coherent # 分配描述符环
├─ alloc_pages / build_skb # 为每个描述符分配缓冲区
├─ 写 RDLEN(环长度)、RDH(头)、RDT(尾)寄存器
├─ request_irq / request_threaded_irq # 注册中断处理
└─ netif_napi_add(adapter->netdev, &adapter->napi, igb_poll, 64)
netif_napi_add 的最后一个参数是权重(weight),它决定了这个 NAPI 实例一次最多处理多少个包。64 是常见值,netdev_budget(默认 300)是全局预算。
环的大小很关键。描述符数量固定,网卡把收到的帧依次写进「下一个空位」,驱动处理完再把位置还给网卡。如果驱动处理速度跟不上,环满,后续的帧被网卡直接丢弃——这个丢弃发生在硬件层,softnet_stat 和 ss 都看不到,只能靠 ethtool -S 的 rx_missed_errors / rx_no_buffer_count。
# 看 RX ring 的当前值与上限
ethtool -g eth0
# 调大 RX ring(立即生效,可能短暂丢包)
sudo ethtool -G eth0 rx 4096
# 看硬件层丢弃
ethtool -S eth0 | grep -Ei 'miss|no_buffer|drop|discard'
rx_missed_errors 增长是「ring 太小或 CPU 太慢」的强信号。它和 softnet_stat 的 drop 不是一回事:前者是网卡没地方写,后者是内核拿到了但没处理完。
4.8.3 DMA:网卡怎么写内存
网卡不经过 CPU 直接把帧写进内存,这叫 DMA(Direct Memory Access)。流程是:
- 驱动把缓冲区的物理地址写进描述符。
- 网卡收到帧,从描述符读出地址,把帧写进去,回写「完成」标志。
- 网卡触发中断,驱动读描述符,知道哪块缓冲区有数据。
这里有一个容易忽略的点:驱动给网卡的是物理地址(或 IOMMU 映射后的地址),不是虚拟地址。所以 sk_buff 的数据区必须在 DMA 可访问的内存里,不能是 vmalloc 分配的。这也是为什么高性能驱动用 build_skb 把「页」直接变成 skb,避免拷贝。
DMA 带来的可见性问题由屏障保证:驱动在把描述符交给网卡前要 wmb(),在读取网卡写回的状态前要 rmb()。这些细节在驱动代码里,排障时不需要碰,但看到「偶发丢包且 CPU 不高」时,要想到可能是 DMA/ring 层面的问题,而不是协议栈。
4.8.4 硬中断:只做三件事
网卡收到帧后触发中断,进入驱动的中断处理函数(以 igb 为例,走的是 NAPI 模式):
/* drivers/net/ethernet/intel/igb/igb_main.c(简化) */
static irqreturn_t igb_msix_ring(int irq, void *data)
{
struct igb_q_vector *q_vector = data;
/* 1. 写 EITR 寄存器,暂时关闭这个队列的中断(中断合并) */
igb_write_itr(q_vector);
/* 2. 把 napi 挂到当前 CPU 的 poll_list,并触发 NET_RX_SOFTIRQ */
napi_schedule(&q_vector->napi);
/* 3. 返回,硬中断结束 */
return IRQ_HANDLED;
}
三件事:关中断(或调中断合并)、napi_schedule、返回。真正的收包在软中断里。
napi_schedule 最终调用 ____napi_schedule,把 napi_struct 加到 sd->poll_list(per-CPU 的 softnet_data),然后 raise_softirq_irqoff(NET_RX_SOFTIRQ)。如果当前不在中断上下文,还会唤醒 ksoftirqd。
硬中断的耗时可以直接测量。/proc/interrupts 只给计数,要测延迟得用 ftrace 或 bpftrace:
# 看每个队列的中断次数与绑定的 CPU
grep -E 'eth0|ens|enp' /proc/interrupts | head
# 用 bpftrace 统计硬中断处理函数的耗时分布
sudo bpftrace -e 'kprobe:igb_msix_ring { @start[tid] = nsecs; }
kretprobe:igb_msix_ring /@start[tid]/ {
@ns = hist(nsecs - @start[tid]); delete(@start[tid]); }'
如果硬中断耗时经常超过几微秒,说明中断合并没开或者中断太频繁——这是 perf top 里 si(softirq)和 hi(hardirq)占比高的常见原因。
4.8.5 NAPI 与 net_rx_action:有预算的轮询
软中断 NET_RX_SOFTIRQ 的处理函数是 net_rx_action。它的逻辑是一个带预算的循环:
/* net/core/dev.c(简化) */
static __latent_entropy void net_rx_action(struct softirq_action *h)
{
struct softnet_data *sd = this_cpu_ptr(&softnet_data);
unsigned long time_limit = jiffies + usecs_to_jiffies(netdev_budget_usecs);
int budget = READ_ONCE(netdev_budget); /* 默认 300 */
while (!list_empty(&sd->poll_list)) {
struct napi_struct *n = list_first_entry(...);
int work = n->poll(n, budget); /* 驱动 poll,返回处理数 */
budget -= work;
if (work < n->weight) /* 这一轮收完了 */
napi_complete_done(n, work);
if (budget <= 0 || time_after(jiffies, time_limit)) {
sd->time_squeeze++; /* 预算耗尽,记一笔 */
break;
}
}
if (budget > 0)
__raise_softirq_irqoff(NET_RX_SOFTIRQ); /* 还有活,下一轮继续 */
}
三个参数决定这一轮能跑多久:netdev_budget(总包数,默认 300)、netdev_budget_usecs(总时间,默认 2000 µs)、每个 NAPI 的 weight。任何一个耗尽,循环就退出,剩下的留给下一次软中断或者 ksoftirqd。
time_squeeze 是收包路径最重要的一个计数器:它表示「预算用完了但还有活没干」。如果它持续增长,说明单次软中断处理不完,需要更多 CPU 或者更小的中断频率。
# 看预算参数
sysctl net.core.netdev_budget net.core.netdev_budget_usecs net.core.dev_weight
# 调整(高 PPS 场景常用)
sysctl -w net.core.netdev_budget=600
4.8.6 驱动 poll:从描述符到 skb
napi_poll(net/core/dev.c)是包装层,负责统计和调度,真正干活的是驱动的 poll,比如 igb_poll:
/* drivers/net/ethernet/intel/igb/igb_main.c(简化) */
static int igb_poll(struct napi_struct *napi, int budget)
{
struct igb_q_vector *q_vector = container_of(napi, ...);
int cleaned = 0;
cleaned = igb_clean_tx_irq(q_vector, budget); /* 顺便回收 TX */
if (cleaned < budget) {
int work = igb_clean_rx_irq(q_vector, budget); /* 收包主体 */
napi_complete_done(napi, work);
return work;
}
return budget;
}
igb_clean_rx_irq 的核心循环做四件事:
- 读描述符,判断「done」位。
- 把缓冲区变成一个
sk_buff(napi_alloc_skb或build_skb)。 - 设置
skb->protocol、skb->dev、skb->data。 - 调用
napi_gro_receive(&q_vector->napi, skb)。
napi_gro_receive 先尝试 GRO 合并:如果这个包和上一个包属于同一条流、可以合并,就把它们合成一个大的 skb,减少上层处理次数。合并不了就走 napi_skb_finish → netif_receive_skb。
# 看 GRO 是否开启,以及合并统计
ethtool -k eth0 | grep -E 'generic-receive-offload|large-receive-offload'
nstat -az | grep -E 'Gro|GRO'
4.8.7 netif_receive_skb:协议分发
netif_receive_skb 是「驱动」和「协议栈」的分界线。它做三件事:
/* net/core/dev.c(简化) */
static int __netif_receive_skb_core(struct sk_buff **pskb, bool pfmemalloc,
struct packet_type **ppt_prev)
{
struct sk_buff *skb = *pskb;
struct packet_type *ptype, *pt_prev = NULL;
/* 1. 给抓包程序一份副本(tcpdump 就是在这里拿到的) */
list_for_each_entry_rcu(ptype, &ptype_all, list)
deliver_skb(skb, ptype, ...);
/* 2. 按 skb->protocol 找对应的协议处理函数 */
list_for_each_entry_rcu(ptype, &ptype_base[ntohs(type) & PTYPE_HASH_MASK], list) {
if (ptype->type == type && ...)
ret = deliver_skb(skb, ptype, ...); /* 通常调用 ip_rcv */
}
/* 3. 没有匹配的协议,交给 raw socket 或丢弃 */
...
}
这里有两个关键点。第一,tcpdump 的包是在这一步之前复制的,所以它能看到「即将被丢弃的包」。第二,协议分发靠 skb->protocol 和一张哈希表,ip_packet_type 注册在 0x0800 上,arp_packet_type 注册在 0x0806 上。
deliver_skb 会在调用协议函数前把 skb->dev 设好,并把 skb->data 指向网络层首部(因为链路层首部已经被 eth_type_trans 剥掉)。这个「指针前移」是理解 skb 生命周期的关键。
4.8.8 从 ip_rcv 到 tcp_v4_rcv
进入网络层后,调用链是这样的:
ip_rcv
→ ip_rcv_core # 校验长度、版本、校验和;丢弃坏包
→ NF_HOOK(NF_INET_PRE_ROUTING, ip_rcv_finish) # netfilter 的第一个钩子
→ ip_rcv_finish
→ ip_rcv_finish_core # 查路由:ip_route_input_noref
→ dst_input
├─ 本机:ip_local_deliver
│ → ip_local_deliver_finish
│ → ip_protocol_deliver_rcu
│ → tcp_v4_rcv / udp_rcv / icmp_rcv
└─ 转发:ip_forward → ip_output → dev_queue_xmit
ip_rcv 只做「合法性检查」,路由决策在 ip_rcv_finish_core 里做。这一步决定了包是「上交本机」还是「转发出去」——同一台机器既可以是主机也可以是路由器,分叉就在这里。
ip_local_deliver_finish 按 IP 首部的协议号查 inet_protos 哈希表,找到 tcp_v4_rcv 并调用。到这里,链路层和网络层的首部都已经被处理完,skb->data 指向 TCP 首部。
tcp_v4_rcv 是 TCP 的入口,它的工作顺序是:
/* net/ipv4/tcp_ipv4.c(简化) */
int tcp_v4_rcv(struct sk_buff *skb)
{
/* 1. 基本检查:长度、校验和(若未卸载) */
if (skb->pkt_type == PACKET_OTHERHOST) goto discard_it;
/* 2. 查 socket:先查 established 哈希,再查 listener 哈希 */
sk = __inet_lookup_skb(&tcp_hashinfo, skb, __tcp_hdrlen(th), th->source,
th->dest, &refcounted, &dif);
if (!sk) goto no_tcp_socket; /* 回 RST */
/* 3. 状态机处理 */
ret = tcp_v4_do_rcv(sk, skb); /* → tcp_rcv_established / tcp_rcv_state_process */
...
}
tcp_rcv_established 的快路径会检查「是否是预期序号、是否有数据」,如果是就直接调用 tcp_data_queue 入队,然后 sk->sk_data_ready(sk) 唤醒等待者。这个唤醒是收包路径的终点,之后的工作都在进程上下文里。
4.8.9 sk_buff 的生命周期
sk_buff(简称 skb)是内核里「一个包」的载体。它的结构可以简化成五个指针:
head data tail end
│ │ │ │
┌─────────────────┼───────────────────┼─────────────┼───────────┐
│ headroom │ 各层首部 + 数据 │ 未使用空间 │ shared_info│
│ (给首部预留) │ │ │ ( frags )│
└─────────────────┼───────────────────┼─────────────┼───────────┘
│◀── skb->data 可前移(push)或后移(pull)──▶│
封装时:skb_push 把 data 前移,写入首部(不需要拷贝数据)
解封装:skb_pull 把 data 后移,跳过首部
这就是为什么协议栈能「零拷贝」地加/减首部
读这张图要抓住「headroom」的作用。每层加首部时不需要重新分配内存,只要把 data 指针往前挪,把首部写进预留空间。skb_push/skb_pull 只改指针,不搬数据——这是 Linux 网络栈高效的关键设计之一。
skb 的生命周期:
| 阶段 | 函数 | 说明 |
|---|---|---|
| 分配 | napi_alloc_skb / build_skb | 从 per-CPU 缓存分配,比 alloc_skb 快 |
| 填充 | 驱动 DMA 写入 | 数据在 data 指向的区域 |
| 上送 | napi_gro_receive → netif_receive_skb | 可能被 GRO 合并 |
| 分发 | deliver_skb → ip_rcv | skb_pull 剥链路层首部 |
| 入队 | tcp_data_queue | 挂到 sk_receive_queue 或 ofo_queue |
| 交付 | skb_copy_datagram_iter | 拷贝到用户态 |
| 释放 | kfree_skb → skb_release_all | 减引用计数,归零才真正释放 |
skb_clone 是另一个关键操作:它复制 skb 结构(约 200 字节),但共享数据区,只增加 skb_shared_info 的引用计数。tcpdump 抓包、多协议分发都用它。克隆的 skb 不能修改数据,否则会影响其他持有者。
# 看 skb 的分配与释放(需要 bpftrace)
sudo bpftrace -e 'kprobe:__alloc_skb { @alloc = count(); }
kprobe:kfree_skb { @free = count(); }
interval:s:5 { print(@alloc); print(@free); clear(@alloc); clear(@free); }'
如果 alloc 持续大于 free,说明有 skb 泄漏——通常是驱动或某个模块的引用计数没减。
4.8.10 softnet_stat:三列十六进制数
/proc/net/softnet_stat 每个 CPU 一行,字段是十六进制。它是判断「收包瓶颈」最直接的证据。
cat /proc/net/softnet_stat
# 用 python 转成十进制,按 CPU 打印关键列
python3 - <<'PY'
for cpu, line in enumerate(open('/proc/net/softnet_stat')):
f = [int(x, 16) for x in line.split()]
print(f"cpu{cpu}: processed={f[0]} dropped={f[1]} "
f"time_squeeze={f[2]} rps={f[4]} flow_limit={f[5]}")
PY
| 列 | 名称 | 含义 | 增长说明 |
|---|---|---|---|
| 1 | processed | 已处理的包数 | 正常增长 |
| 2 | dropped | 因 backlog 满或内存不足丢弃 | 收包能力不足 |
| 3 | time_squeeze | 预算耗尽但还有活 | 单次软中断处理不完 |
| 4 | cpu_collision | 历史字段,恒为 0 | 忽略 |
| 5 | received_rps | 被 RPS 转发到本 CPU 的包数 | 看 RPS 是否生效 |
| 6 | flow_limit_count | 单流超过 RFS 流上限的次数 | 单流过大,RFS 失效 |
高版本内核在后面还追加了 backlog 长度、ksoftirqd 唤醒次数等列,列数会随版本变化,以 Documentation/networking/softnet_stat.rst 为准,不要硬编码列号。
三列的读法:
dropped增长:包到了内核但没排上队。常见于netdev_max_backlog太小(虚拟设备走 backlog 时)、内存不足、或者 CPU 完全跑满。time_squeeze增长:能处理,但一次处理不完。说明单核能力到顶,需要 RPS/多队列/更多核。flow_limit_count增长:某一条流太大,RFS 的流表放不下,哈希退化。
# backlog 上限(虚拟设备和 RPS 会用到)
sysctl net.core.netdev_max_backlog
# 每个 CPU 的软中断统计
grep -E 'NET_RX|NET_TX' /proc/softirqs
# ksoftirqd 占用的 CPU
ps -eo pid,comm,pcpu | grep ksoftirqd
4.8.11 RPS、RFS 与多队列
单核收包能力有上限(大约 100–200 万 PPS)。要突破,得让多个 CPU 一起收。三种机制解决三个不同的问题:
| 机制 | 解决什么 | 层次 | 开启方式 |
|---|---|---|---|
| RSS | 网卡多队列,硬件哈希分流 | 硬件 | ethtool -L eth0 combined 8 |
| RPS | 单队列网卡把软中断分到多核 | 软件 | /sys/class/net/eth0/queues/rx-0/rps_cpus |
| RFS | 让处理包的 CPU 与跑应用的 CPU 一致 | 软件 | rps_flow_cnt + rps_sock_flow_entries |
| XPS | 发送方向选择 CPU | 软件 | /sys/class/net/eth0/queues/tx-0/xps_cpus |
RSS 是硬件的:网卡按四元组哈希,把不同的流分到不同队列,每个队列一个中断,绑到不同 CPU。它的问题是哈希固定,同一时刻热点流可能都落到一个队列。
RPS 是软件的补丁:单队列网卡也能把包分到多个 CPU。它靠 rps_cpus 位图指定「哪些 CPU 可以处理这个队列的包」,用 skb_get_hash 做流哈希。代价是多一次 IPI(处理器间中断),因为硬中断在 CPU0,软中断要跑到 CPU1。
RFS 进一步优化:它记录「哪个进程在哪个 CPU 上读这个 socket」,让收包 CPU 尽量和读 socket 的 CPU 一致,提高缓存命中率。
# 看网卡队列数
ethtool -l eth0
# 开启 RPS:让 CPU0-3 都能处理 rx-0 的包(位掩码 0xf)
echo f | sudo tee /sys/class/net/eth0/queues/rx-0/rps_cpus
# 开启 RFS:全局流表 + 每队列流表
sysctl -w net.core.rps_sock_flow_entries=32768
echo 4096 | sudo tee /sys/class/net/eth0/queues/rx-0/rps_flow_cnt
RPS 和 RFS 只对单队列或队列数少于 CPU 数的场景有意义。如果网卡已经有 16 个队列、CPU 也是 16 核,RSS 已经把包分开了,再开 RPS 反而多一次 IPI。
4.8.12 GRO:在进入协议栈前合并
GRO(Generic Receive Offload)把「同一流、连续序号」的多个小包合并成一个大包,减少协议栈的处理次数。它发生在 napi_gro_receive 里,也就是协议栈之前。
合并的收益很直接:一个 64 KB 的 skb 走一遍协议栈,比 44 个 1500 字节的 skb 走 44 遍便宜得多。代价是延迟:GRO 会等一小会儿看有没有后续包,这会给实时流量增加微秒到毫秒级的抖动。
# 看 GRO/LRO 状态
ethtool -k eth0 | grep -E 'generic-receive-offload|large-receive-offload'
# 关闭 GRO(低延迟场景,比如交易系统)
sudo ethtool -K eth0 gro off
注意 GRO 和 LRO 的区别:GRO 是软件合并,保留完整的 TCP 语义,可以在转发路径上使用;LRO 是网卡硬件合并,会破坏 TCP 首部信息,不能用于转发,现代网卡基本不用了。
4.8.13 观测实验:把路径跑一遍
把上面的函数串起来,用 bpftrace 数一遍调用次数,就能确认路径真的这么走:
# 统计 5 秒内各关键函数的调用次数
sudo bpftrace -e '
kprobe:napi_poll { @napi_poll = count(); }
kprobe:netif_receive_skb { @netif_receive = count(); }
kprobe:ip_rcv { @ip_rcv = count(); }
kprobe:tcp_v4_rcv { @tcp_v4_rcv = count(); }
interval:s:5 { exit(); }'
预期比例是:netif_receive_skb ≥ ip_rcv ≥ tcp_v4_rcv(因为还有 ARP、UDP、ICMP),napi_poll 的次数远小于包数(一次 poll 处理一批)。
# 看一次收包在协议栈里花了多少时间
sudo bpftrace -e '
kprobe:netif_receive_skb { @start[tid] = nsecs; }
kretprobe:netif_receive_skb /@start[tid]/ {
@us = hist((nsecs - @start[tid]) / 1000); delete(@start[tid]); }'
如果分布的长尾很重(比如 P99 超过 100 µs),通常是 netfilter 规则太多、或者 socket 查找退化(哈希冲突)。
- 网卡层:
ethtool -S看rx_missed_errors、rx_no_buffer_count。增长说明 ring 小或 CPU 慢。 - 软中断层:
/proc/softirqs的 NET_RX 是否集中在少数 CPU;softnet_stat的dropped/time_squeeze。 - 协议栈层:
perf top看ip_rcv、tcp_v4_rcv、nf_hook的占比;规则多就查 netfilter。 - socket 层:
ss -lnt的Recv-Q、ss -tin的retrans;队列满说明应用读得慢。 - 应用层:
strace -c看read/epoll_wait的次数与耗时。
4.8.14 中断合并、busy-poll 与 DPDK 的边界
硬中断的代价不只是处理函数本身,还有中断上下文切换。每个包一次中断,在 100 万 PPS 时就是每秒 100 万次中断,CPU 光进出中断就忙不过来。三种手段可以降低这个成本,它们分别对应三种不同的取舍。
中断合并(interrupt coalescing) 让网卡攒一批包再发一次中断:
# 看当前合并参数
ethtool -c eth0
# 收到 32 个包或等 64 微秒就触发一次中断,并开启自适应
sudo ethtool -C eth0 rx-usecs 64 rx-frames 32 adaptive-rx on
rx-usecs 是「最多等多久」,rx-frames 是「攒够多少个」。两个值越大,中断越少、吞吐越高,但延迟越大。交互式服务通常取 rx-usecs 20–50,批处理可以取 100 以上。adaptive-rx 让网卡根据流量自动调整,是大多数场景的合理默认。
busy-poll 让应用主动轮询网卡,而不是等中断:
# 全局允许的忙等时间(微秒),0 表示关闭
sysctl net.core.busy_poll net.core.busy_read
# 应用侧:setsockopt(fd, SOL_SOCKET, SO_BUSY_POLL, &usec, sizeof usec);
忙等把「睡眠-唤醒」的延迟换成了 CPU 占用。它适合「延迟极敏感、CPU 有富余」的场景(比如高频交易),不适合普通 Web 服务——后者 CPU 本来就是瓶颈。
DPDK 走得更远:它把网卡完全从内核手里接管(VFIO/UIO),驱动在用户态,收包靠纯轮询,零中断、零拷贝、零系统调用。代价是整块网卡不能再给内核用,要自己实现协议栈(或者只做转发)。它是「用 CPU 换延迟和吞吐」的极致形态。
| 方案 | 中断 | 拷贝 | CPU 占用 | 适用 |
|---|---|---|---|---|
| 默认 NAPI | 有 | 1 次(到用户态) | 中 | 通用 |
| 中断合并 | 减少 | 1 次 | 中低 | 高吞吐、可容忍延迟 |
| busy-poll | 少 | 1 次 | 高 | 低延迟、CPU 富余 |
| DPDK | 无 | 0 次 | 极高(专用核) | 转发、NFV |
这张表说明一件事:没有「最快」的方案,只有「把成本放到哪里」的选择。内核路径的成本在中断和拷贝,DPDK 把它转成了 CPU 占用和可维护性。
4.8.15 backlog 与虚拟设备:veth 的收包路径
真实网卡走 NAPI,但虚拟设备(veth、bridge、tun)走的可能是另一条路:netif_rx → per-CPU backlog 队列 → process_backlog。
veth_xmit
→ dev_forward_skb
→ netif_rx # 把 skb 挂到 sd->input_pkt_queue
→ ____napi_schedule(sd->backlog) # backlog 也是一个 napi_struct
→ net_rx_action
→ process_backlog # 从 input_pkt_queue 取,交给 netif_receive_skb
process_backlog 的 weight 是 64,队列上限是 netdev_max_backlog(默认 1000)。这意味着虚拟设备的收包能力受这个队列限制——容器密度高的机器上,softnet_stat 的 dropped 增长往往就是它太小。
# 看 backlog 上限与当前排队情况
sysctl net.core.netdev_max_backlog
# 每个 CPU 的 backlog 长度(较新内核的 softnet_stat 列)
cat /proc/net/softnet_stat
# 调大(容器/overlay 场景常用 16384 或更高)
sysctl -w net.core.netdev_max_backlog=16384
为什么 veth 要走 backlog 而不是直接调用 netif_receive_skb?因为发送方可能在任意进程上下文里,直接调用接收路径会打破「收包在软中断里完成」的假设(比如持有不该持有的锁)。backlog 相当于一个「软中断信箱」,把跨上下文的问题变成排队问题。
bridge 转发也类似:br_handle_frame 在软中断里跑,转发时调用 dev_queue_xmit,如果目的设备是 veth,又回到 netif_rx。一个容器到另一个容器的包,可能在同一台机器上走两遍 backlog——这是容器网络开销的一部分,4.12 会量化。
veth 本身不封装,MTU 跟宿主机一致。VXLAN 要加 50 字节(外层 IP + UDP + VXLAN 首部),所以虚拟网络的 MTU 必须减到 1450,否则大包会在 underlay 被分片或丢弃。这个细节在 4.2.11 讲过,在容器网络里是最常见的「大包不通」原因。
4.8.16 netfilter:收包路径上的五个钩子
讲收包绕不开 netfilter,因为它在路径上有五个插入点。理解了这五个点,就能回答「iptables 的规则在哪一层生效」「为什么 tcpdump 能抓到被防火墙丢掉的包」。
| 钩子 | 位置 | 触发时机 | 典型用途 |
|---|---|---|---|
PRE_ROUTING | ip_rcv 之后、路由之前 | 包刚进协议栈 | DNAT、raw 表的 NOTRACK |
LOCAL_IN | ip_local_deliver 里 | 确认是发给本机的 | filter INPUT、SNAT 的复查 |
FORWARD | ip_forward 里 | 确认要转发 | 转发过滤 |
LOCAL_OUT | ip_output 之前 | 本机发出的包 | filter OUTPUT、DNAT |
POST_ROUTING | ip_finish_output 之前 | 即将交给设备 | SNAT、MASQUERADE |
调用方式都是 NF_HOOK 宏:先检查这个钩子上有没有注册规则,没有就直接调用 okfn(比如 ip_rcv_finish),有就进 nf_hook_slow 逐条匹配。「没有规则时零开销」是这个设计的巧妙之处,也是为什么干净的机器不需要担心 netfilter 的性能。
/* net/ipv4/ip_input.c(简化) */
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, net, NULL, skb, skb->dev,
NULL, ip_rcv_finish);
PRE_ROUTING 有一个特殊之处:conntrack 和 defrag 也挂在这里,优先级比 filter 表更靠前(数值更小)。所以一个包先被 conntrack 记录,再做 DNAT,最后才轮到用户规则。这解释了「为什么 conntrack 表会比 filter 规则先看到包」。
# 看每个钩子上挂了哪些链与优先级
sudo nft list ruleset | grep -E 'hook|priority'
# 看 conntrack 的当前条目与上限
sudo conntrack -C
sysctl net.netfilter.nf_conntrack_max
# 看 netfilter 是否在丢包(内核日志)
dmesg | grep -i 'nf_' | tail -5
一个和收包性能直接相关的数字是规则数量。nf_hook_slow 是线性遍历,1000 条规则的链意味着每个包最多 1000 次比较。生产环境常用 nft 的 meta mark、ipset/nft set 把「大量 IP 的黑名单」变成一次哈希查找,把 O(n) 降到 O(1)。
- 「iptables 和 nftables 是两套内核机制」。它们共用 netfilter 钩子,只是用户态工具和规则表示不同。
iptables-nft会把规则翻译成 nftables 的表示。 - 「tcpdump 抓不到被防火墙丢的包」。抓得到。tcpdump 的 AF_PACKET 钩子在
netif_receive_skb之前,而 netfilter 在ip_rcv之后,所以被PRE_ROUTING丢弃的包在抓包里仍然可见。这也是「抓包有包但服务收不到」的常见原因。
4.8.17 sk_buff 的字段、头空间与生命周期
4.8.9 讲的是 skb 在路径上的流转,这一节把结构拆开:哪些字段在哪个阶段被写、头空间为什么能避免拷贝、克隆与分片的引用计数怎么算。
struct sk_buff 字段很多,按用途分成五组:
| 组 | 字段 | 类型 | 含义 | 谁改 |
|---|---|---|---|---|
| 链表 | next / prev | struct sk_buff * | 队列前后指针 | skb_queue_tail |
| 归属 | dev / sk | net_device * / sock * | 收发的设备与 socket | 驱动 / 协议栈 |
| 数据 | head / data / tail / end | unsigned char * | 缓冲区起止与当前数据区 | skb_reserve/put/pull/push |
| 长度 | len / data_len / truesize | unsigned int | 线性+分片总长 / 分片长 / 实际内存 | skb_put、分片、skb_set_owner_r |
| 元数据 | protocol / mark / priority / pkt_type | __be16 / u32 / u32 / u8 | 协议、fwmark、优先级、包类型 | 各层 |
| 校验 | ip_summed / csum_start / csum_offset | 位域 / u16 | 校验和状态与位置 | 驱动 / 协议栈 |
| 控制 | cb[48] | char[] | 每层的私有控制块 | TCP 用 TCP_SKB_CB |
| 分片 | skb_shared_info | 尾部结构 | frags、frag_list、GSO 信息 | 驱动 / GRO / GSO |
head/data/tail/end 是理解零拷贝的关键:
head data tail end
│ │ │ │
▼ ▼ ▼ ▼
┌─────────┬─────────┬───────────────┬───────────────────┐
│ headroom│ 已用头部 │ payload │ tailroom │
└─────────┴─────────┴───────────────┴───────────────────┘
← skb_push skb_put →
skb_reserve(skb, n)把data/tail同时后移 n,预留头部空间,之后skb_push加首部不用重新分配。- 每层加首部就是
skb_push+ 填字段:TCP 加 20 字节、IP 再加 20 字节、以太网再加 14 字节。指针向前移动,数据不搬。 skb_pull在接收方向剥首部,data后移;len减小,data_len不变。
skb_shared_info 挂在 end 处,记录分片与 GSO:
| 字段 | 含义 |
|---|---|
nr_frags / frags[] | 页片段数组,每项是 (page, offset, size) |
frag_list | 分片 skb 链(IP 分片、GSO 用) |
gso_size / gso_segs / gso_type | GSO 参数:每段大小、段数、类型 |
dataref | 数据区引用计数,克隆时共享 |
tx_flags | 发送标志(如 SKBTX_DEV_ZEROCOPY) |
克隆不复制数据:skb_clone() 只复制 struct sk_buff,head/data 指针相同,skb_shared_info.dataref 加 1。skb_copy() 才复制整个数据区。TCP 重传时常用克隆保留原始 skb;tcpdump 抓包也用克隆,所以抓包本身不改变转发路径。
生命周期可以压成一张表:
| 阶段 | 函数 | 发生什么 |
|---|---|---|
| 分配 | __alloc_skb | 从 skbuff_head_cache 取结构,kmalloc_reserve 取数据区 |
| 预留 | skb_reserve | 为各层首部留空间 |
| 填充 | skb_put | 追加载荷,tail 后移 |
| 加头 | skb_push | data 前移,填首部 |
| 克隆 | skb_clone / pskb_copy | 共享或复制数据区,dataref 变化 |
| 入队 | __skb_queue_tail | 挂到 sk_receive_queue 或 qdisc |
| 释放 | kfree_skb_reason | users 减到 0 后释放数据区与结构 |
释放路径的计数最容易被忽略:kfree_skb_reason() 的 reason 会进 drop monitor,truesize 会从 socket 的 sk_rmem_alloc 里扣除。如果 truesize 记错,接收缓冲会永远「不释放」,表现为 ss -m 的 mem 只增不减。这是驱动或自写模块常见的 bug。
一个 100 字节的 UDP 包,sk_buff 结构本身约 240 字节,数据区按 SKB_DATA_ALIGN 对齐到 512 或 1024 字节,再加 skb_shared_info 约 320 字节。truesize 通常是最小 768 字节起。所以 SO_RCVBUF=1 MB 只能排约 1300 个 100 字节的包,不是 1 万个。
4.8.18 conntrack:tuple、状态机与表容量
4.8.16 讲了钩子怎么排、nf_hook_slow 怎么遍历;这一节把 conntrack 的表结构、状态机与容量讲清。NAT 是 conntrack 的一个消费者,具体规则与端口转发放到 4.8.23,这里只交代它改了哪些字段。
每个钩子上可以挂多个「钩子函数」,按优先级排序。IPv4 的优先级常量(include/uapi/linux/netfilter_ipv4.h):
| 常量 | 值 | 谁用 |
|---|---|---|
NF_IP_PRI_FIRST | INT_MIN | 最前 |
NF_IP_PRI_RAW | -300 | raw 表 |
NF_IP_PRI_CONNTRACK | -200 | conntrack |
NF_IP_PRI_MANGLE | -150 | mangle 表 |
NF_IP_PRI_NAT_DST | -100 | DNAT |
NF_IP_PRI_FILTER | 0 | filter 表 |
NF_IP_PRI_SECURITY | 50 | SELinux |
NF_IP_PRI_NAT_SRC | 100 | SNAT |
NF_IP_PRI_LAST | INT_MAX | 最后 |
这张表解释了两个现象:
- DNAT 必须在路由之前(-100 < 0),否则路由已经按原目的地址选好了出口。
- SNAT 必须在路由之后(100 > 0),因为它要等出口确定后才能选源地址。
nf_hook_slow() 是遍历钩子的函数:
/* net/netfilter/core.c,简化 */
int nf_hook_slow(struct sk_buff *skb, struct nf_hook_state *state,
const struct nf_hook_entries *e, unsigned int s)
{
unsigned int verdict;
int ret;
for (; s < e->num_hook_entries; s++) {
verdict = nf_hook_entry_hookfn(&e->hooks[s], skb, state);
switch (verdict & NF_VERDICT_MASK) {
case NF_ACCEPT:
break;
case NF_DROP:
kfree_skb_reason(skb, SKB_DROP_REASON_NETFILTER_DROP);
ret = NF_DROP_GETERR(verdict);
if (ret == 0)
ret = -EPERM;
return ret;
case NF_QUEUE:
...
case NF_STOLEN:
return NF_DROP_GETERR(verdict);
...
}
}
return 1;
}
NF_DROP 会直接 kfree_skb_reason,原因码 SKB_DROP_REASON_NETFILTER_DROP——用 4.8.14 的 bpftrace 脚本能直接看到哪条规则在丢。
conntrack 挂在 NF_IP_PRI_CONNTRACK(-200),在 DNAT 之前。它为每个连接维护两个方向的 tuple:
/* include/net/netfilter/nf_conntrack_tuple.h,简化 */
struct nf_conntrack_tuple {
struct nf_conntrack_man src; /* 源地址/端口/三层协议 */
struct {
union nf_inet_addr u3; /* 目的地址 */
union {
__be16 all;
struct { __be16 port; } tcp;
struct { __be16 port; } udp;
struct { __be16 id; } icmp;
} u;
u_int8_t protonum; /* 协议号 */
u_int8_t dir; /* 方向 */
} dst;
};
一条流在表里有两条 tuple(原始方向 + 回复方向),哈希后挂进 nf_conntrack_hash。tuple 只是键,真正的连接对象是 struct nf_conn:
| 字段 | 类型 | 含义 | 谁维护 |
|---|---|---|---|
ct_general | struct nf_conntrack | 引用计数与销毁回调 | nf_conntrack_get/put |
tuplehash[2] | struct nf_conntrack_tuple_hash[2] | 原始/回复两个方向的键与哈希节点 | hash_conntrack |
status | unsigned long | IPS_CONFIRMED、IPS_SEEN_REPLY、IPS_ASSURED、IPS_SRC_NAT_DONE 等位 | 状态机与 NAT |
timeout | unsigned long | 过期时间(jiffies),由协议 tracker 续期 | GC 与收包路径 |
mark | u_int32_t | connmark,可跨包保留 | CONNMARK target |
proto | union | TCP/UDP/ICMP 的私有状态(序号、窗口、ICMP id) | 协议 tracker |
ext | struct nf_ct_ext * | 扩展区:NAT、helper、acct、timeout | 各扩展模块 |
首包进入 nf_conntrack_in()(net/netfilter/nf_conntrack_core.c):
nf_conntrack_in
→ resolve_normal_ct
→ nf_ct_get_tuple # 从 skb 提取五元组
→ __nf_conntrack_find_get # 两轮哈希查找:先原始方向,再回复方向
→ init_conntrack # 未命中则分配 nf_conn,状态 NEW
→ nf_conntrack_handle_packet # 交给 tcp/udp/icmp tracker 更新状态
NEW 条目在 __nf_conntrack_confirm() 之前不进入哈希表。确认点通常在 LOCAL_IN 或 POST_ROUTING 钩子(nf_conntrack_confirm 包装),确认成功才插入并置 IPS_CONFIRMED。两个 CPU 同时确认同一条流时,输的一方收到 insert_failed,包被丢弃——这不是表满,但计数长得很像。
状态机:
| 状态 | 含义 | 何时进入 |
|---|---|---|
NEW | 只看到第一个方向的包 | 首包 |
ESTABLISHED | 两个方向都看到过 | 收到回复 |
RELATED | 与已有连接相关(FTP data、ICMP error) | helper 判定 |
INVALID | 不属于任何已知连接 | 校验失败、状态异常 |
UNTRACKED | raw 表 NOTRACK | 显式跳过 |
NAT 的本质是在 conntrack 里记录「原 tuple → 新 tuple」的映射,然后在后续包上做反向翻译:
- DNAT 改目的地址/端口,回复方向自动改源;
- SNAT 改源地址/端口,回复方向自动改目的;
MASQUERADE是 SNAT 的特例,源地址取出口网卡的地址,适合动态 IP。
conntrack 表的容量、驱逐与丢包排查见 4.8.24;这里只记一个判据:nf_conntrack_count 接近 nf_conntrack_max 时,新连接会被丢,ss 与 nstat 可能毫无异常,只有 conntrack -S 的 insert_failed/drop 在涨。这是 NAT 网关和 Kubernetes Service 最常见的「神秘丢包」。
nftables 与 iptables 都走同一套 netfilter 钩子,但规则表达、内核执行路径和「表/链」的组织方式不同;两者的逐项对照、sets/maps 与原子下发放在 4.8.22。NAT 的完整规则、hairpin 与端口转发放在 4.8.23;conntrack 的容量计算与驱逐细节放在 4.8.24。
conntrack -C看总数,和nf_conntrack_max比。conntrack -S看insert_failed、drop、early_drop是否增长。conntrack -L -p tcp --dport 443 | head看条目是否异常堆积(同一五元组多条)。dmesg | grep conntrack找表满或 helper 报错。- 确认是否需要
nf_conntrack_helper=0(现代内核默认关闭自动 helper,FTP/SIP 要显式配置)。
4.8.19 程序一:用接收缓冲制造 UDP 丢包
收包路径的最后一环是「把 skb 放进 socket 的接收队列」。队列满了,内核在 sock_queue_rcv_skb_reason() 里丢包,计数进 UdpRcvbufErrors。这段程序把这一环单独放大:接收方只开 4 KB 缓冲、先不读,发送方连发一万个包,最后统计收到多少。
/* rcvbuf_drop.c:小接收缓冲 + 突发发送,观察 UDP 丢包 */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>
#define NPKT 10000
#define PSIZE 512
static int make_rx(unsigned short *port)
{
int fd = socket(AF_INET, SOCK_DGRAM, 0);
int buf = 4096;
struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = 0 };
socklen_t len = sizeof a;
if (fd < 0)
return -1;
if (setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &buf, sizeof buf) < 0)
perror("setsockopt(SO_RCVBUF)");
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (bind(fd, (struct sockaddr *)&a, sizeof a) < 0 ||
getsockname(fd, (struct sockaddr *)&a, &len) < 0) {
perror("bind");
close(fd);
return -1;
}
*port = ntohs(a.sin_port);
return fd;
}
int main(void)
{
unsigned short port = 0;
int rfd = make_rx(&port);
int sfd, actual = 0;
socklen_t alen = sizeof actual;
if (rfd < 0)
return 1;
getsockopt(rfd, SOL_SOCKET, SO_RCVBUF, &actual, &alen);
printf("接收缓冲 SO_RCVBUF=%d(内核实际 %d),目标端口 %u\n", 4096, actual, port);
sfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sfd < 0) {
perror("socket");
close(rfd);
return 1;
}
struct sockaddr_in dst = { .sin_family = AF_INET, .sin_port = htons(port) };
dst.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
char pkt[PSIZE];
memset(pkt, 'x', sizeof pkt);
int sent = 0;
for (int i = 0; i < NPKT; i++) {
ssize_t n = sendto(sfd, pkt, sizeof pkt, MSG_DONTWAIT,
(struct sockaddr *)&dst, sizeof dst);
if (n == (ssize_t)sizeof pkt)
sent++;
else if (errno == EAGAIN)
usleep(1000); /* 发送缓冲满,稍等再发 */
else
break;
}
printf("sendto 成功 %d 个(目标 %d)\n", sent, NPKT);
/* 发送完毕再读,读的这段时间里队列早已溢出 */
int recvd = 0;
char buf[2048];
for (;;) {
ssize_t n = recv(rfd, buf, sizeof buf, MSG_DONTWAIT);
if (n < 0)
break;
recvd++;
}
printf("recv 收到 %d 个,丢失约 %d 个(%.1f%%)\n",
recvd, sent - recvd, sent > 0 ? 100.0 * (sent - recvd) / sent : 0.0);
close(sfd);
close(rfd);
return 0;
}
编译运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 rcvbuf_drop.c -o rcvbuf_drop
./rcvbuf_drop
# 同时观察内核计数
nstat -az | grep -E 'UdpRcvbufErrors|UdpInErrors'
典型输出(Linux 回环):
接收缓冲 SO_RCVBUF=4096(内核实际 8192),目标端口 54321
sendto 成功 10000 个(目标 10000)
recv 收到 12 个,丢失约 9988 个(99.9%)
三个可验证的点:
SO_RCVBUF设 4096,内核返回 8192——Linux 会把值翻倍用于开销记账。- 收到 12 个而不是 0:
truesize决定每个包占多少,4 KB 缓冲大约能放十几个 512 字节的包。 UdpRcvbufErrors增加约 9988,与程序统计一致。如果两者差距很大,说明丢在别处(校验和、路由、netfilter)。
把 SO_RCVBUF 调到 1 MB 再跑,收到的比例会显著上升,但仍然不可能 100%:回环的发送速度远高于接收方读的速度,队列迟早溢出。这就是「UDP 没有流控」的直接后果。
4.8.20 程序二:解析 /proc/net/softnet_stat 的十六进制列
softnet_stat 每行对应一个 CPU,字段全是十六进制且没有表头。这个脚本把列名、增量、丢包原因一起打出来,适合放进巡检脚本。
#!/usr/bin/env python3
"""softnet.py:按 CPU 打印 softnet_stat 的增量与丢包率(Linux)"""
import sys
import time
COLS = [
"processed", # 处理的 skb 数
"dropped", # 因 backlog 满丢弃
"time_squeeze", # NAPI 预算用完仍未处理完
"cpu_collision", # 发送时的 qdisc 锁冲突
"received_rps", # 被 RPS 转发的次数
"flow_limit", # RFS 流表超限
]
def read_softnet():
rows = []
with open("/proc/net/softnet_stat", encoding="ascii") as f:
for line in f:
parts = line.split()
if len(parts) < 6:
continue
rows.append([int(x, 16) for x in parts])
return rows
def main():
interval = float(sys.argv[1]) if len(sys.argv) > 1 else 1.0
prev = read_softnet()
time.sleep(interval)
cur = read_softnet()
print(f"{'cpu':>3} " + " ".join(f"{c:>12}" for c in COLS) + f"{'drop%':>8}")
for cpu, (a, b) in enumerate(zip(prev, cur)):
delta = [y - x for x, y in zip(a[:6], b[:6])]
total = delta[0] + delta[1]
pct = (100.0 * delta[1] / total) if total else 0.0
print(f"{cpu:>3} " + " ".join(f"{v:>12d}" for v in delta) + f"{pct:>7.2f}%")
# 附带的 UDP 接收错误,帮助区分「backlog 丢」与「socket 缓冲丢」
try:
with open("/proc/net/snmp", encoding="ascii") as f:
for line in f:
if line.startswith("Udp:"):
fields = line.split()
if len(fields) > 5:
print("Udp RcvbufErrors =", fields[5])
except OSError:
pass
if __name__ == "__main__":
main()
运行:
chmod +x softnet.py
sudo ./softnet.py 1
读法:
| 列 | 持续增长说明 | 去哪里查 |
|---|---|---|
dropped | backlog 满,包在进入协议栈前被丢 | net.core.netdev_max_backlog、RPS 配置 |
time_squeeze | NAPI 预算用完,还有包没处理 | net.core.netdev_budget、netdev_budget_usecs |
cpu_collision | 发送路径 qdisc 锁竞争 | 多队列、XPS、tc 配置 |
received_rps | RPS 在工作 | 正常,但过高说明单队列瓶颈 |
flow_limit | RFS 流表满 | net.core.rps_flow_cnt、rps_sock_flow_entries |
softnet_stat 是十六进制,直接 cat 看到的是 0000001a 这样的值;用 awk '{print strtonum("0x"$2)}' 也能转,但 strtonum 不是所有 awk 都有,Python 更稳。
4.8.21 实验:中断、软中断与丢包点
实验一把「中断落点、软中断预算、backlog」串起来看,实验二用 bpftrace 直接定位丢包函数。
实验一:看中断与软中断的分布。
# 每个队列的中断号、次数、绑定的 CPU
grep -E 'eth|ens|virtio|mlx' /proc/interrupts | head
# 队列数
ethtool -l eth0
# 每个队列的 ring 与丢包
ethtool -S eth0 | grep -E 'rx_queue_.*_drops|rx_dropped|rx_missed'
# 每秒的软中断 CPU 占比(%soft 列)
mpstat -P ALL 1 3
# softnet 的 backlog 与丢包
cat /proc/net/softnet_stat
判据:
- 某个 CPU 的
%soft接近 100%,其他 CPU 空闲 → 中断集中,查irqbalance与/proc/irq/N/smp_affinity。 rx_queue_N_drops增长 → ring 满,查ethtool -g与napi_poll的预算。softnet_stat的dropped增长 → backlog 满,调netdev_max_backlog或开 RPS。time_squeeze增长 → 单次 NAPI 处理不完,调netdev_budget。
实验二:bpftrace 定位丢包函数。
sudo bpftrace -e '
kprobe:net_rx_action { @rx = count(); }
kprobe:napi_poll { @poll[comm] = count(); }
kprobe:kfree_skb_reason { @drop[args->reason] = count(); }
tracepoint:net:netif_receive_skb { @len = hist(args->len); }
interval:s:5 { print(@rx); print(@poll); print(@drop); print(@len); clear(@rx); clear(@poll); clear(@drop); clear(@len); exit(); }'
@drop的 key 是enum skb_drop_reason的数值,配合bpftrace -lv 'enum skb_drop_reason'或内核头文件查名字。@len的直方图能看出包大小分布:大量 64 字节小包说明是 ACK 风暴或扫描;大量 1500 字节说明是批量传输。@poll按进程名分组意义不大(NAPI 在软中断里,comm可能是swapper或ksoftirqd),但能看出是否有ksoftirqd接手。
RPS 的配置(单队列网卡把软中断分到多核):
# 把 rx-0 的软中断分到 CPU0-3(位掩码 0xf,十六进制)
echo f | sudo tee /sys/class/net/eth0/queues/rx-0/rps_cpus
# 开启 RFS:全局流表 + 每队列流表
sudo sysctl -w net.core.rps_sock_flow_entries=32768
echo 4096 | sudo tee /sys/class/net/eth0/queues/rx-0/rps_flow_cnt
RPS 只在单队列网卡上有意义;多队列网卡已经由硬件把包分到不同队列,再开 RPS 只会多一次 IPI。rps_cpus 是十六进制位掩码,f 表示 CPU0–3,ff 表示 CPU0–7——写成十进制 15 也能被解析成 0xf,但 10 会被当成 0x10(CPU4),这是最常见的配置错误。
4.8.22 nftables 与 iptables:同一钩子,两种规则模型
iptables 的表/链是硬编码的;nftables 把它们变成可创建的对象(表可命名,链可指定 type/hook/priority,规则由表达式组成)。两者共用 netfilter 钩子,包路径不变;变的是规则怎么组织、下发与匹配。
| iptables 表 | 内置链 | nftables 的对应写法 |
|---|---|---|
filter | INPUT / FORWARD / OUTPUT | type filter hook input/forward/output priority filter |
mangle | 五个链 | type filter hook ... priority mangle(改字段) |
nat | PREROUTING / INPUT / OUTPUT / POSTROUTING | type nat hook prerouting/input/output/postrouting priority dstnat/srcnat |
raw | PREROUTING / OUTPUT | type filter hook ... priority raw |
security | INPUT / FORWARD / OUTPUT | type filter hook ... priority security |
priority 可用名字或数字,数字与 4.8.16 的 NF_IP_PRI_* 一致;nft 还有 route 链类型,用于 TPROXY 这类需要重路由的场景。
# 同一件事:放行已建立连接与 22/80/443,丢弃黑名单
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -p tcp -m multiport --dports 22,80,443 -j ACCEPT
iptables -A INPUT -s 203.0.113.0/24 -j DROP
nft add table ip filter
nft 'add chain ip filter input { type filter hook input priority filter; policy accept; }'
nft add rule ip filter input ct state established,related accept
nft add rule ip filter input tcp dport { 22, 80, 443 } accept
nft add rule ip filter input ip saddr 203.0.113.0/24 drop
三个差别:
- counter 是表达式:
nft add rule ... counter挂计数器,nft list ruleset -a能看到每条规则的packets/bytes与 handle。 - 集合是语言的一部分。
tcp dport { 22, 80, 443 }是匿名集合,内核用nft_lookup求值;大量地址用命名集合,匹配是哈希/区间查找,不是逐条线性比较。 - 下发是事务。
nft -f ruleset.nft整体提交,要么全生效要么全回滚;iptables 一条一条setsockopt,中途失败会留下半套规则。
sets 与 maps 是 nftables 相对 iptables 最实用的升级:
nft add set ip filter blackhole '{ type ipv4_addr; flags interval; }'
nft add element ip filter blackhole { 203.0.113.0/24, 198.51.100.7 }
nft add rule ip filter input ip saddr @blackhole drop
nft add map ip nat dnat_map '{ type inet_service : verdict; }'
nft add element ip nat dnat_map { 8080 : dnat to 10.0.0.2:80, 8443 : dnat to 10.0.0.3:443 }
nft add rule ip nat prerouting tcp dport vmap @dnat_map
vmap 的值是 verdict(accept、drop、dnat to ...),所以一张 map 可以同时做「端口 → 后端」和「端口 → 丢弃」。iptables 要实现同样的事,通常得靠 ipset + 多条 -m set 规则。
内核执行路径不同:
| 实现 | 入口 | 匹配方式 | 规则存储 |
|---|---|---|---|
| iptables-legacy | ipt_do_table() | xt_match->match() 逐个模块调用 | ipt_entry 连续内存块 |
| iptables-nft | nft_do_chain()(经 nft_compat) | 把老 match/target 包成 nft 表达式 | nftables 对象 |
| nftables | nft_do_chain() | expr->ops->eval() 逐表达式求值 | nft_rule + 表达式链 |
nft_do_chain() 是一个「表达式流水线」:nft_rule_for_each_expr() 按顺序调 expr->ops->eval(),结果放在 nft 寄存器里;nft_cmp 比较、nft_lookup 查集合、nft_payload 取首部字段、nft_counter 计数、nft_bitwise 做掩码。没有「match 模块」与「target 模块」的区别,这是 nftables 规则更短的原因。
iptables -L看不到 nft 规则:iptables-nft看到的是 nft 后端里的 compat 规则;iptables-legacy则是完全独立的一套,可能同时生效。先用iptables -V确认后端。- 两套工具同时改同一个钩子:规则按优先级排序,iptables 与 nft 的规则可能交错执行;调试时用
nft list ruleset看全量。 nft flush ruleset会清掉 iptables-nft 的规则:它清的是整个规则集;生产机上先nft list ruleset > backup.nft。
4.8.23 NAT 与端口转发:DNAT、SNAT 与 hairpin
NAT 是在 conntrack 里登记映射:首包改字段,后续包按登记表翻译。这解释了「为什么只有第一个包走 NAT 规则」与「为什么抓包看到两个不同的目的地址」。
| 类型 | 钩子 | 改哪个字段 | 典型用途 |
|---|---|---|---|
| SNAT | POSTROUTING(也可在 INPUT) | 源 IP/端口 | 内网访问外网、固定出口 |
| MASQUERADE | POSTROUTING | 源 IP/端口,源地址取出口网卡地址 | 动态公网 IP、容器出网 |
| DNAT | PREROUTING / OUTPUT | 目的 IP/端口 | 端口转发、Service ClusterIP |
| REDIRECT | PREROUTING / OUTPUT | 目的地址改为本机,端口可改 | 透明代理、80 → 8080 |
| TPROXY | mangle PREROUTING | 不改首部,打 mark 交给本地代理 | 透明代理(需策略路由) |
一次 DNAT 的完整路径:
client 10.0.0.3:54321 → VIP 10.0.0.1:8080
PREROUTING:conntrack(-200) 建 NEW 条目 → DNAT(-100) 目的改成 10.0.0.2:80
路由:按新目的 10.0.0.2 查表,forward 到 eth1
FORWARD:过滤;POSTROUTING:可选 SNAT(hairpin 必须)
→ 10.0.0.2:80
reply 10.0.0.2:80 → 10.0.0.3:54321
PREROUTING:conntrack 命中回复方向,按映射把源改回 10.0.0.1:8080
首包走规则,后续包走 conntrack。nf_nat_inet_fn() 用 HOOK2MANIP() 把钩子映射成改源(POSTROUTING)或改目的(其他 NAT 钩子);IP_CT_NEW 时逐条跑 NAT 规则,没命中就 nf_nat_alloc_null_binding() 登记空绑定,之后 nf_nat_packet() 只按 ct->status 里的 NAT 位改字段。「NAT 规则只对首包生效」的根源就在这里。
nf_nat_packet() 改 L3/L4 字段并重算校验和;TCP/UDP 校验和不能增量更新时标记 CHECKSUM_PARTIAL 交给网卡。抓包看到校验和错误,先确认是不是网卡卸载。
端口转发(nftables):
nft add table ip nat
nft 'add chain ip nat prerouting { type nat hook prerouting priority dstnat; }'
nft add rule ip nat prerouting iifname "eth0" tcp dport 8080 counter dnat to 10.0.0.2:80
nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; }'
nft add rule ip nat postrouting oifname "eth0" masquerade
# iptables 等价:-t nat -A PREROUTING -i eth0 -p tcp --dport 8080 -j DNAT --to-destination 10.0.0.2:80
前置条件不是可选的:
sysctl -w net.ipv4.ip_forward=1 # 不转发,DNAT 后的包在 FORWARD 被丢
sysctl -w net.ipv4.conf.eth0.rp_filter=2 # 宽松反向过滤,避免非对称路由被丢
sysctl -w net.ipv4.conf.all.route_localnet=1 # 仅 DNAT 到 127.0.0.1 时需要
hairpin(发夹):Pod 访问自己的 Service,或内网客户端访问「同一台负载均衡后面的自己」。请求被 DNAT 到后端,但后端直接按源地址回客户端,就绕过了负载均衡,conntrack 看不到回复。修复是在 POSTROUTING 对「内网 → 内网」再 SNAT 一次:
iptables -t nat -A POSTROUTING -s 10.244.0.0/16 -d 10.244.0.0/16 -j MASQUERADE
nft add rule ip nat postrouting ip saddr 10.244.0.0/16 ip daddr 10.244.0.0/16 masquerade
tcp 6 431999 ESTABLISHED src=10.0.0.3 dst=10.0.0.1 sport=54321 dport=8080 \
src=10.0.0.2 dst=10.0.0.3 sport=80 dport=54321 [ASSURED] mark=0 use=1
- DNAT 写在 POSTROUTING:路由已经按原目的地址做完,改完也不会重新选路;DNAT 必须在 PREROUTING/OUTPUT,必要时配合
ip_route_me_harder。 - 忘了
ip_forward=1:conntrack 里有 DNAT 条目,但包在 FORWARD 被丢,表现为「规则命中、后端收不到」。 - hairpin 不做 SNAT:后端直接回客户端,回复绕过负载均衡,conntrack 状态对不上,连接超时。
raw表NOTRACK与 NAT 同用:没有 conntrack 就没有 NAT 映射,DNAT 规则不会生效。- 用 tcpdump 的校验和判断 NAT 改错:网卡校验和卸载会让抓包显示
bad checksum,关掉 offload 再抓,或看skb->ip_summed。
4.8.24 conntrack 表溢出:容量、驱逐与排查
conntrack 容量在启动时按内存算出,之后可调。表满时新连接被丢,但 ss、nstat 可能完全正常,这是它最难排查的地方。
Linux 6.8 的默认值(nf_conntrack_init_start()):
buckets = (RAM / 16384) / sizeof(struct hlist_head) # 64 位下除以 8
内存 > 4 GB:buckets = 262144;> 1 GB:65536;下限 1024
max = buckets * 1
| 内存 | 默认 buckets | 默认 max | 约可同时跟踪 |
|---|---|---|---|
| 2 GB | 16384 | 16384 | 1.6 万条 |
| 4 GB | 65536 | 65536 | 6.5 万条 |
| ≥ 8 GB | 262144 | 262144 | 26 万条 |
每条 nf_conn 约 300–500 字节(含 NAT/helper 扩展),26 万条约 80–130 MB;把 max 调到 100 万,内存与 GC 扫描时间都会上去。max 不是越大越好,要看「建连速率 × 超时时间」是否真的需要。
表满分两层处理:
- 分配时:
nf_conntrack_alloc()发现count > max,调用early_drop()在哈希桶里找可驱逐条目(通常是UNREPLIED或超时近的);找不到就返回NULL,resolve_normal_ct()返回-ENOMEM,nf_conntrack_in()把包标成NF_DROP,drop计数加一。 - GC 后台:
gc_worker周期扫描,超过max * 95%时提前驱逐(early_drop)并清理超时条目。nf_conntrack_max95就是这条 95% 线。
early_drop() 从哈希值对应的桶开始,最多扫描 NF_CT_EVICTION_RANGE 个相邻桶,找到可驱逐条目就计数并返回;找不到才真正丢包。
统计口径(conntrack -S):
| 字段 | 含义 | 持续增长说明 |
|---|---|---|
entries | 当前条目数 | 与 nf_conntrack_count 一致 |
insert_failed | 确认时插入哈希失败 | 同一流被多 CPU 同时确认,或表满 |
drop | 分配/跟踪失败被丢的包 | 表满、内存不足 |
early_drop | 提前驱逐的条目数 | 接近 95% 线,正在腾空间 |
invalid | 不属于任何连接、校验失败 | 扫描、畸形包、状态异常 |
超时时间决定条目占多久(秒):
| 参数 | 默认 | 说明 |
|---|---|---|
nf_conntrack_tcp_timeout_established | 432000 | 5 天;长连接多时是表大小的主因 |
nf_conntrack_tcp_timeout_time_wait | 120 | TIME_WAIT 条目 |
nf_conntrack_tcp_timeout_close | 10 | CLOSE 状态 |
nf_conntrack_tcp_timeout_syn_recv | 60 | 半开连接 |
nf_conntrack_udp_timeout | 30 | 单包 UDP |
nf_conntrack_udp_timeout_stream | 120 | 有回复的 UDP 流 |
nf_conntrack_icmp_timeout | 30 | ICMP |
Kubernetes Service 是 conntrack 的重度用户:每个 ClusterIP 连接都有一条 DNAT 映射,kube-proxy 的 iptables/IPVS 规则只负责首包,后续包靠 conntrack。节点上 nf_conntrack_count 接近 max 时,新连接随机失败,ss 看不到 SYN 队列异常,只有 conntrack -S 的 drop/insert_failed 在涨。排查顺序:
sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
conntrack -S
conntrack -L -p tcp --dport 443 2>/dev/null | head
conntrack -L 2>/dev/null | awk '{print $4}' | sort | uniq -c | sort -rn | head
sysctl -w net.netfilter.nf_conntrack_max=1048576
sysctl -w net.netfilter.nf_conntrack_buckets=262144
nf_conntrack_buckets 可写,但扩容会重建哈希表并 synchronize_net(),有短暂抖动;生产上更稳妥的是在启动参数/模块参数里设定,或在维护窗口调。
- 表满只看
ss:ss统计的是 socket,conntrack 表满时连接可能根本没建起来;要看conntrack -S与dmesg。 - 把
nf_conntrack_count当 tuple 数:一个连接一条nf_conn、两条 tuple;count 是连接数,不是哈希节点数。 - 只调 max 不调 buckets:buckets 不变时,max 越大平均链长越长,查找变慢;两者要一起看。
- 把
insert_failed当表满:并发确认同一条流也会insert_failed,此时entries可能远低于 max;要结合drop/early_drop判断。 - helper 默认关闭:现代内核
nf_conntrack_helper=0,FTP/SIP 等需要显式-m helper或nf_conntrack_helper配置,否则 RELATED 连接建不起来。
4.8.25 实验:用 nftables DNAT + conntrack + tcpdump 看一次转发
在一台 Linux 测试机上用两个 netns 复现「VIP:8080 → 后端:80」,验证三件事:DNAT 只改首包、conntrack 记录双向 tuple、tcpdump 在不同位置看到不同地址。
#!/usr/bin/env bash
set -euo pipefail
SRV=dnat-srv; CLI=dnat-cli
trap 'ip netns del $SRV 2>/dev/null; ip netns del $CLI 2>/dev/null; ip link del br-dnat 2>/dev/null' EXIT
ip netns add "$SRV"; ip netns add "$CLI"
ip link add br-dnat type bridge; ip addr add 10.88.0.1/24 dev br-dnat; ip link set br-dnat up
for pair in "$SRV:2" "$CLI:3"; do
ns=${pair%%:*}; last=${pair##*:}
ip link add "veth-$ns" type veth peer name eth0 netns "$ns"
ip link set "veth-$ns" master br-dnat; ip link set "veth-$ns" up
ip netns exec "$ns" ip addr add "10.88.0.$last/24" dev eth0
ip netns exec "$ns" ip link set eth0 up
ip netns exec "$ns" ip route add default via 10.88.0.1
done
sysctl -qw net.ipv4.ip_forward=1
nft add table ip nat
nft 'add chain ip nat prerouting { type nat hook prerouting priority dstnat; }'
nft add rule ip nat prerouting tcp dport 8080 counter dnat to 10.88.0.2:80
ip netns exec "$SRV" python3 -m http.server 80 --bind 10.88.0.2 >/dev/null 2>&1 & SRV_PID=$!
tcpdump -i br-dnat -nn -c 4 'tcp port 8080 or tcp port 80' >/tmp/dnat.txt 2>&1 & TCP_PID=$!
sleep 1
ip netns exec "$CLI" curl -sS --max-time 3 http://10.88.0.1:8080/ >/dev/null || true
sleep 0.5; kill "$TCP_PID" "$SRV_PID" 2>/dev/null || true
conntrack -L -p tcp --dport 8080 2>/dev/null | head -3
conntrack -S | head -8
cat /tmp/dnat.txt
nft list ruleset | grep -A2 'dport 8080'
预期输出:
tcp 6 431999 ESTABLISHED src=10.88.0.3 dst=10.88.0.1 sport=4xxxx dport=8080 \
src=10.88.0.2 dst=10.88.0.3 sport=80 dport=4xxxx [ASSURED] mark=0 use=1
counter packets 1 bytes 60 dnat to 10.88.0.2:80
读法:第一组 tuple 是客户端看到的(目的 VIP:8080),第二组是后端看到的(源后端:80);回复方向不需要单独规则。counter packets 1 说明只有首包命中 DNAT,后续包走 conntrack 的 ESTABLISHED 路径。把 ip_forward 改为 0 再跑,计数仍增加但 curl 超时,条目停在 NEW——这就是「NAT 配了但没开转发」。
本实验需要 Linux 的 nf_tables、nf_conntrack、bridge 与 conntrack-tools;写作环境为 macOS,命令未在本机实测,语法以 nftables 1.0 / Linux 6.8 为准。
4.8.26 本章陷阱清单
- 把
softnet_stat当十进制:所有列都是十六进制,0000001a是 26 不是 1a。 rps_cpus写十进制:它是位掩码且按十六进制解析,10是 CPU4 不是 CPU1。- 把 backlog 当 socket 接收缓冲:
netdev_max_backlog是软中断前的队列,SO_RCVBUF是 socket 队列,丢包计数不同。 - 认为
dropped一定是网卡丢的:softnet_stat的dropped是 backlog 丢,网卡 ring 丢看ethtool -S。 - 把 GRO 当 LRO:GRO 是软件合并、可配置、保留包边界信息;LRO 是网卡合并,可能破坏转发与时间戳。
- 调大 NAPI budget 就以为能提高吞吐:budget 太大会让单 CPU 长时间占软中断,其他队列饿死;要配合多队列/RPS。
- 只看
/proc/interrupts的总数:要按队列看,某队列为 0 说明没收到包或中断没绑对。 - 认为
irqbalance一定最优:它按中断数均衡,不理解队列与 NUMA;高 PPS 场景通常手工绑核。 - 把
sk_buff.truesize当len:接收缓冲按 truesize 记账,SO_RCVBUF能放的包远少于「字节数/包长」。 - DNAT 规则挂在 POSTROUTING:DNAT 必须在路由前(PREROUTING/OUTPUT),否则路由已按旧目的地址选好出口。
- conntrack 表满却只看
ss:表满时新连接被丢,TCP 计数可能毫无变化,要看conntrack -S与dmesg。 - 混用
iptables与nft查看规则:两者可能操作不同的后端,iptables -L看不到 nft 规则。 iptables-nft与iptables-legacy并存:iptables -V先确认后端;两套规则独立,排障时iptables-save与nft list ruleset都要看。- hairpin 只做 DNAT 不做 SNAT:回复绕过负载均衡,conntrack 对不上,表现为「后端收到了但客户端超时」。
- 只调
nf_conntrack_max不调 buckets:平均哈希链变长,查找与 GC 变慢;按内存一起算,扩容选维护窗口。
4.8.27 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 画出收包的三个上下文 | 能说出硬中断只做哪三件事 | 4.8.1、4.8.4 |
| 写出完整调用链 | napi_poll → netif_receive_skb → ip_rcv → tcp_v4_rcv | 4.8.6–4.8.8 |
| 解释 budget 与 time_squeeze | 知道预算耗尽后发生什么 | 4.8.5、4.8.10 |
| 说清 skb 的 headroom 与 clone | 知道为什么加首部不用拷贝 | 4.8.9 |
读 softnet_stat 六列 | 能区分 dropped 与 time_squeeze | 4.8.10 |
| 说清 RPS/RFS/GRO 各优化哪段 | 知道什么时候不该开 | 4.8.11、4.8.12 |
说出 skb 的数据区与 truesize | 知道接收缓冲按什么记账 | 4.8.17 |
| 排出 netfilter 五个钩子的优先级 | 能解释 DNAT 为何在路由前 | 4.8.18 |
| 用接收缓冲复现 UDP 丢包 | 能对上 UdpRcvbufErrors | 4.8.19 |
| 解析 softnet_stat 的十六进制列 | 能区分 backlog 丢与 ring 丢 | 4.8.20 |
| 说清 nftables 与 iptables 的对应关系 | 能解释 sets/maps 与事务下发 | 4.8.22 |
| 画出 DNAT/SNAT 的钩子与字段变化 | 能解释 hairpin 为何要 SNAT | 4.8.23 |
| 算出 conntrack 的容量并排查表满 | 能读 conntrack -S 的 drop/early_drop | 4.8.24 |
Q1 网卡收到一个帧,到应用能 read 到,中间经历了哪几个上下文?各自做了什么?
Q2 net_rx_action 的 budget 用完了,剩下的包会怎样?会不会丢?
Q3 softnet_stat 里 dropped 增长但 time_squeeze 不增长,最可能是什么原因?
Q4 skb_clone 和 skb_copy 的区别是什么?tcpdump 用哪个?
Q5 一台机器 16 核、网卡 16 队列,还需要开 RPS 吗?为什么?
Q6 ethtool -S 的 rx_missed_errors 增长,和 softnet_stat 的 dropped 增长,含义有什么不同?
答案
A1 三个:硬中断(igb_msix_ring 调 napi_schedule,登记并触发软中断)、软中断(net_rx_action → 驱动 poll → netif_receive_skb → ip_rcv → tcp_v4_rcv → 入队并唤醒)、进程上下文(应用 read 从 sk_receive_queue 拷贝数据)。
A2 不会丢。net_rx_action 退出前会重新 __raise_softirq_irqoff(NET_RX_SOFTIRQ),下一轮软中断继续处理;如果软中断持续积压,会由 ksoftirqd 接手,把负载交给调度器。只有当 backlog 队列满(netdev_max_backlog)时才会丢,计入 dropped。
A3 dropped 增长说明包在进入 backlog 队列时被丢,通常是 netdev_max_backlog 太小、内存不足,或者 RPS 转发到的 CPU 处理不过来。time_squeeze 不增长说明不是「预算不够」,而是「队列/内存不够」。
A4 skb_clone 只复制 skb 结构(约 200 字节),共享数据区,增加引用计数;skb_copy 连数据一起复制,得到完全独立的副本。tcpdump 用 clone(它只需要读,不需要改),所以抓包对性能的影响主要是复制结构 + 一次 AF_PACKET 排队,而不是复制整个包。
A5 不需要。16 队列的 RSS 已经把不同的流分到 16 个队列、16 个中断、16 个 CPU 上,RPS 再转发只会多一次 IPI,反而降低性能。RPS 是「队列数少于 CPU 数」时的补丁。
A6 rx_missed_errors 是网卡硬件层的丢弃:RX ring 满了,网卡没地方写,包根本没进内核。dropped 是内核软件层的丢弃:包进了内核但 backlog 队列满或内存不足。前者要调 ring 或加 CPU,后者要调 backlog 或减少上层处理时间。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| N(深入理解 Linux 网络) | 第 2 章 2.2(数据如何从网卡到协议栈) | 4.8.1–4.8.8 | 完整:DMA、硬中断、NAPI、协议分发 |
| N(深入理解 Linux 网络) | 第 2 章 2.3(本章总结) | 4.8.13 | 改写为观测实验 |
| N(深入理解 Linux 网络) | 第 2 章 2.2(网卡到协议栈,PDF p27–49) | 4.8.17 | 补充:skb 与 ring 的交互 |
| N(深入理解 Linux 网络) | 第 4 章 4.4(发送路径与 RingBuffer,PDF p108–130) | 4.8.17 | 对照:发送方向的 skb |
| W(谢希仁) | 第 4 章(网络层与路由、NAT,PDF p126–221) | 4.8.18、4.8.23 | 背景:钩子、转发与 NAT |
| N(深入理解 Linux 网络) | 第 10 章 10.5(外部通信与 iptables/NAT,PDF p326–334) | 4.8.23 | 完整:SNAT/DNAT 与抓包证据 |
| Linux 6.8 源码 | net/netfilter/{core,nf_conntrack_core,nf_nat_core,nf_tables_core}.c | 4.8.22–4.8.24 | 完整:钩子、conntrack、NAT、nft |
| K(Kerrisk) | 第 58 章 58.5(IP 地址与路由,PDF p968–981) | 4.8.18 | 背景:netfilter 的位置 |
| R(奔跑吧 Linux 内核) | 第 5 章 5.1–5.2(中断管理、软中断与 tasklet) | 4.8.4、4.8.5 | 完整:硬中断/软中断的分层与 ksoftirqd |
| K(Kerrisk) | 第 63 章 63.2(I/O 多路复用与就绪语义) | 4.8.8、4.8.10 | 背景:socket 就绪的判定 |
下一站:收包讲完了,发包是它的镜像,但多了 qdisc、TSO 和 TX ring。进入 4.9 内核发包路径,从
tcp_sendmsg一路走到ndo_start_xmit。