Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 队列取数据、拷贝到用户态不能直接碰网卡
Linux 收包路径与中断/软中断分工 网卡 DMA → 硬中断 → NAPI 软中断轮询 → 协议栈 → socket 队列。 Linux 收包路径:从网卡到 socketNICDMA 写入 RX ring硬中断napi_scheduleNET_RX_SOFTIRQnet_rx_actionNAPI pollnapi_poll / 驱动 pollnetif_receive_skb协议分发ip_rcv路由 / 分片tcp_v4_rcv查 socketsk_receive_queue唤醒等待者上半部:只做最少的事关中断 / 记录 / 触发软中断下半部:NAPI 轮询 + 协议栈可被抢占 / 有预算socket 等待队列epoll / 阻塞唤醒关键点:硬中断不处理协议;NAPI 轮询有 budget;软中断可能被 ksoftirqd 接管;socket 收队列满会丢包观测:/proc/softirqs、/proc/net/softnet_stat、ss -tin、bpftrace tracepoint:net:*
读图:从左到右是时间顺序,也是 CPU 上下文切换的顺序。硬中断只做「登记 + 触发软中断」,真正解析报文在 NAPI poll 里;poll 有 budget,超预算就让出 CPU 给 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)。流程是:

  1. 驱动把缓冲区的物理地址写进描述符。
  2. 网卡收到帧,从描述符读出地址,把帧写进去,回写「完成」标志。
  3. 网卡触发中断,驱动读描述符,知道哪块缓冲区有数据。

这里有一个容易忽略的点:驱动给网卡的是物理地址(或 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 的核心循环做四件事:

  1. 读描述符,判断「done」位。
  2. 把缓冲区变成一个 sk_buff(napi_alloc_skb 或 build_skb)。
  3. 设置 skb->protocol、skb->dev、skb->data。
  4. 调用 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_rcvskb_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
列名称含义增长说明
1processed已处理的包数正常增长
2dropped因 backlog 满或内存不足丢弃收包能力不足
3time_squeeze预算耗尽但还有活单次软中断处理不完
4cpu_collision历史字段,恒为 0忽略
5received_rps被 RPS 转发到本 CPU 的包数看 RPS 是否生效
6flow_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 查找退化(哈希冲突)。

收包问题的排查顺序
  1. 网卡层:ethtool -S 看 rx_missed_errors、rx_no_buffer_count。增长说明 ring 小或 CPU 慢。
  2. 软中断层:/proc/softirqs 的 NET_RX 是否集中在少数 CPU;softnet_stat 的 dropped/time_squeeze。
  3. 协议栈层:perf top 看 ip_rcv、tcp_v4_rcv、nf_hook 的占比;规则多就查 netfilter。
  4. socket 层:ss -lnt 的 Recv-Q、ss -tin 的 retrans;队列满说明应用读得慢。
  5. 应用层: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 常是 1500 而 overlay 是 1450

veth 本身不封装,MTU 跟宿主机一致。VXLAN 要加 50 字节(外层 IP + UDP + VXLAN 首部),所以虚拟网络的 MTU 必须减到 1450,否则大包会在 underlay 被分片或丢弃。这个细节在 4.2.11 讲过,在容器网络里是最常见的「大包不通」原因。

4.8.16 netfilter:收包路径上的五个钩子

讲收包绕不开 netfilter,因为它在路径上有五个插入点。理解了这五个点,就能回答「iptables 的规则在哪一层生效」「为什么 tcpdump 能抓到被防火墙丢掉的包」。

钩子位置触发时机典型用途
PRE_ROUTINGip_rcv 之后、路由之前包刚进协议栈DNAT、raw 表的 NOTRACK
LOCAL_INip_local_deliver 里确认是发给本机的filter INPUT、SNAT 的复查
FORWARDip_forward 里确认要转发转发过滤
LOCAL_OUTip_output 之前本机发出的包filter OUTPUT、DNAT
POST_ROUTINGip_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)。

netfilter 的两个常见误解
  1. 「iptables 和 nftables 是两套内核机制」。它们共用 netfilter 钩子,只是用户态工具和规则表示不同。iptables-nft 会把规则翻译成 nftables 的表示。
  2. 「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 / prevstruct sk_buff *队列前后指针skb_queue_tail
归属dev / sknet_device * / sock *收发的设备与 socket驱动 / 协议栈
数据head / data / tail / endunsigned char *缓冲区起止与当前数据区skb_reserve/put/pull/push
长度len / data_len / truesizeunsigned 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_typeGSO 参数:每段大小、段数、类型
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_pushdata 前移,填首部
克隆skb_clone / pskb_copy共享或复制数据区,dataref 变化
入队__skb_queue_tail挂到 sk_receive_queue 或 qdisc
释放kfree_skb_reasonusers 减到 0 后释放数据区与结构

释放路径的计数最容易被忽略:kfree_skb_reason() 的 reason 会进 drop monitor,truesize 会从 socket 的 sk_rmem_alloc 里扣除。如果 truesize 记错,接收缓冲会永远「不释放」,表现为 ss -m 的 mem 只增不减。这是驱动或自写模块常见的 bug。

`truesize` 为什么比 `len` 大

一个 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_FIRSTINT_MIN最前
NF_IP_PRI_RAW-300raw 表
NF_IP_PRI_CONNTRACK-200conntrack
NF_IP_PRI_MANGLE-150mangle 表
NF_IP_PRI_NAT_DST-100DNAT
NF_IP_PRI_FILTER0filter 表
NF_IP_PRI_SECURITY50SELinux
NF_IP_PRI_NAT_SRC100SNAT
NF_IP_PRI_LASTINT_MAX最后

这张表解释了两个现象:

  1. DNAT 必须在路由之前(-100 < 0),否则路由已经按原目的地址选好了出口。
  2. 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_generalstruct nf_conntrack引用计数与销毁回调nf_conntrack_get/put
tuplehash[2]struct nf_conntrack_tuple_hash[2]原始/回复两个方向的键与哈希节点hash_conntrack
statusunsigned longIPS_CONFIRMED、IPS_SEEN_REPLY、IPS_ASSURED、IPS_SRC_NAT_DONE 等位状态机与 NAT
timeoutunsigned long过期时间(jiffies),由协议 tracker 续期GC 与收包路径
marku_int32_tconnmark,可跨包保留CONNMARK target
protounionTCP/UDP/ICMP 的私有状态(序号、窗口、ICMP id)协议 tracker
extstruct 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不属于任何已知连接校验失败、状态异常
UNTRACKEDraw 表 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 排障五步
  1. conntrack -C 看总数,和 nf_conntrack_max 比。
  2. conntrack -S 看 insert_failed、drop、early_drop 是否增长。
  3. conntrack -L -p tcp --dport 443 | head 看条目是否异常堆积(同一五元组多条)。
  4. dmesg | grep conntrack 找表满或 helper 报错。
  5. 确认是否需要 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

读法:

列持续增长说明去哪里查
droppedbacklog 满,包在进入协议栈前被丢net.core.netdev_max_backlog、RPS 配置
time_squeezeNAPI 预算用完,还有包没处理net.core.netdev_budget、netdev_budget_usecs
cpu_collision发送路径 qdisc 锁竞争多队列、XPS、tc 配置
received_rpsRPS 在工作正常,但过高说明单队列瓶颈
flow_limitRFS 流表满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 的对应写法
filterINPUT / FORWARD / OUTPUTtype filter hook input/forward/output priority filter
mangle五个链type filter hook ... priority mangle(改字段)
natPREROUTING / INPUT / OUTPUT / POSTROUTINGtype nat hook prerouting/input/output/postrouting priority dstnat/srcnat
rawPREROUTING / OUTPUTtype filter hook ... priority raw
securityINPUT / FORWARD / OUTPUTtype 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

三个差别:

  1. counter 是表达式:nft add rule ... counter 挂计数器,nft list ruleset -a 能看到每条规则的 packets/bytes 与 handle。
  2. 集合是语言的一部分。tcp dport { 22, 80, 443 } 是匿名集合,内核用 nft_lookup 求值;大量地址用命名集合,匹配是哈希/区间查找,不是逐条线性比较。
  3. 下发是事务。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-legacyipt_do_table()xt_match->match() 逐个模块调用ipt_entry 连续内存块
iptables-nftnft_do_chain()(经 nft_compat)把老 match/target 包成 nft 表达式nftables 对象
nftablesnft_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 规则更短的原因。

nftables 与 iptables 混用的三个坑
  1. iptables -L 看不到 nft 规则:iptables-nft 看到的是 nft 后端里的 compat 规则;iptables-legacy 则是完全独立的一套,可能同时生效。先用 iptables -V 确认后端。
  2. 两套工具同时改同一个钩子:规则按优先级排序,iptables 与 nft 的规则可能交错执行;调试时用 nft list ruleset 看全量。
  3. nft flush ruleset 会清掉 iptables-nft 的规则:它清的是整个规则集;生产机上先 nft list ruleset > backup.nft。

4.8.23 NAT 与端口转发:DNAT、SNAT 与 hairpin

NAT 是在 conntrack 里登记映射:首包改字段,后续包按登记表翻译。这解释了「为什么只有第一个包走 NAT 规则」与「为什么抓包看到两个不同的目的地址」。

类型钩子改哪个字段典型用途
SNATPOSTROUTING(也可在 INPUT)源 IP/端口内网访问外网、固定出口
MASQUERADEPOSTROUTING源 IP/端口,源地址取出口网卡地址动态公网 IP、容器出网
DNATPREROUTING / OUTPUT目的 IP/端口端口转发、Service ClusterIP
REDIRECTPREROUTING / OUTPUT目的地址改为本机,端口可改透明代理、80 → 8080
TPROXYmangle 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
NAT 的五个边界
  1. DNAT 写在 POSTROUTING:路由已经按原目的地址做完,改完也不会重新选路;DNAT 必须在 PREROUTING/OUTPUT,必要时配合 ip_route_me_harder。
  2. 忘了 ip_forward=1:conntrack 里有 DNAT 条目,但包在 FORWARD 被丢,表现为「规则命中、后端收不到」。
  3. hairpin 不做 SNAT:后端直接回客户端,回复绕过负载均衡,conntrack 状态对不上,连接超时。
  4. raw 表 NOTRACK 与 NAT 同用:没有 conntrack 就没有 NAT 映射,DNAT 规则不会生效。
  5. 用 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 GB16384163841.6 万条
4 GB65536655366.5 万条
≥ 8 GB26214426214426 万条

每条 nf_conn 约 300–500 字节(含 NAT/helper 扩展),26 万条约 80–130 MB;把 max 调到 100 万,内存与 GC 扫描时间都会上去。max 不是越大越好,要看「建连速率 × 超时时间」是否真的需要。

表满分两层处理:

  1. 分配时:nf_conntrack_alloc() 发现 count > max,调用 early_drop() 在哈希桶里找可驱逐条目(通常是 UNREPLIED 或超时近的);找不到就返回 NULL,resolve_normal_ct() 返回 -ENOMEM,nf_conntrack_in() 把包标成 NF_DROP,drop 计数加一。
  2. 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_established4320005 天;长连接多时是表大小的主因
nf_conntrack_tcp_timeout_time_wait120TIME_WAIT 条目
nf_conntrack_tcp_timeout_close10CLOSE 状态
nf_conntrack_tcp_timeout_syn_recv60半开连接
nf_conntrack_udp_timeout30单包 UDP
nf_conntrack_udp_timeout_stream120有回复的 UDP 流
nf_conntrack_icmp_timeout30ICMP

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(),有短暂抖动;生产上更稳妥的是在启动参数/模块参数里设定,或在维护窗口调。

conntrack 容量的五个坑
  1. 表满只看 ss:ss 统计的是 socket,conntrack 表满时连接可能根本没建起来;要看 conntrack -S 与 dmesg。
  2. 把 nf_conntrack_count 当 tuple 数:一个连接一条 nf_conn、两条 tuple;count 是连接数,不是哈希节点数。
  3. 只调 max 不调 buckets:buckets 不变时,max 越大平均链长越长,查找变慢;两者要一起看。
  4. 把 insert_failed 当表满:并发确认同一条流也会 insert_failed,此时 entries 可能远低于 max;要结合 drop/early_drop 判断。
  5. 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 本章陷阱清单

15 条高频错误
  1. 把 softnet_stat 当十进制:所有列都是十六进制,0000001a 是 26 不是 1a。
  2. rps_cpus 写十进制:它是位掩码且按十六进制解析,10 是 CPU4 不是 CPU1。
  3. 把 backlog 当 socket 接收缓冲:netdev_max_backlog 是软中断前的队列,SO_RCVBUF 是 socket 队列,丢包计数不同。
  4. 认为 dropped 一定是网卡丢的:softnet_stat 的 dropped 是 backlog 丢,网卡 ring 丢看 ethtool -S。
  5. 把 GRO 当 LRO:GRO 是软件合并、可配置、保留包边界信息;LRO 是网卡合并,可能破坏转发与时间戳。
  6. 调大 NAPI budget 就以为能提高吞吐:budget 太大会让单 CPU 长时间占软中断,其他队列饿死;要配合多队列/RPS。
  7. 只看 /proc/interrupts 的总数:要按队列看,某队列为 0 说明没收到包或中断没绑对。
  8. 认为 irqbalance 一定最优:它按中断数均衡,不理解队列与 NUMA;高 PPS 场景通常手工绑核。
  9. 把 sk_buff.truesize 当 len:接收缓冲按 truesize 记账,SO_RCVBUF 能放的包远少于「字节数/包长」。
  10. DNAT 规则挂在 POSTROUTING:DNAT 必须在路由前(PREROUTING/OUTPUT),否则路由已按旧目的地址选好出口。
  11. conntrack 表满却只看 ss:表满时新连接被丢,TCP 计数可能毫无变化,要看 conntrack -S 与 dmesg。
  12. 混用 iptables 与 nft 查看规则:两者可能操作不同的后端,iptables -L 看不到 nft 规则。
  13. iptables-nft 与 iptables-legacy 并存:iptables -V 先确认后端;两套规则独立,排障时 iptables-save 与 nft list ruleset 都要看。
  14. hairpin 只做 DNAT 不做 SNAT:回复绕过负载均衡,conntrack 对不上,表现为「后端收到了但客户端超时」。
  15. 只调 nf_conntrack_max 不调 buckets:平均哈希链变长,查找与 GC 变慢;按内存一起算,扩容选维护窗口。

4.8.27 本章出口检查

检查项达标线没达标去哪
画出收包的三个上下文能说出硬中断只做哪三件事4.8.1、4.8.4
写出完整调用链napi_poll → netif_receive_skb → ip_rcv → tcp_v4_rcv4.8.6–4.8.8
解释 budget 与 time_squeeze知道预算耗尽后发生什么4.8.5、4.8.10
说清 skb 的 headroom 与 clone知道为什么加首部不用拷贝4.8.9
读 softnet_stat 六列能区分 dropped 与 time_squeeze4.8.10
说清 RPS/RFS/GRO 各优化哪段知道什么时候不该开4.8.11、4.8.12
说出 skb 的数据区与 truesize知道接收缓冲按什么记账4.8.17
排出 netfilter 五个钩子的优先级能解释 DNAT 为何在路由前4.8.18
用接收缓冲复现 UDP 丢包能对上 UdpRcvbufErrors4.8.19
解析 softnet_stat 的十六进制列能区分 backlog 丢与 ring 丢4.8.20
说清 nftables 与 iptables 的对应关系能解释 sets/maps 与事务下发4.8.22
画出 DNAT/SNAT 的钩子与字段变化能解释 hairpin 为何要 SNAT4.8.23
算出 conntrack 的容量并排查表满能读 conntrack -S 的 drop/early_drop4.8.24
即时自测 · 4.8

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}.c4.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。