土法炼钢兴趣小组的算法知识备份

Linux 网络栈全景:一个包从网卡到用户态的完整旅程

目录

你在 K8s 里抓包抓了半天,发现丢包发生在 netfilter 这一层。可是你连 netfilter 在整个收包路径里排第几都说不清楚。

这不怪你。Linux 网络栈横跨硬件中断、软中断、协议栈、netfilter、socket 五六个层次,代码分散在 net/core/net/ipv4/drivers/net/ 十几个目录里,光 net_rx_action 这一个函数就有几百行。很少有人能把一个包从网线到 read() 系统调用的完整路径讲清楚。

本文就干这件事:把一个包从物理网卡到用户态 read() 返回的完整路径走一遍。不画概念图糊弄,而是深入到具体的内核函数、sk_buff 的字段变化、softirq 的调度时机。最后用 bpftrace 在虚拟机里实测,打出每一跳的纳秒级时间线。

本文基于 Linux 6.x 内核源码。部分函数签名在不同版本间可能有差异。 实验环境:Ubuntu 22.04, kernel 6.5, QEMU/KVM 虚拟机


一、全景图:一个 RX 包的八层旅程

先看全貌。一个从网线进来的包,在 Linux 内核中要经过以下层次:

Linux 网络栈全景图

从下往上:

  1. NIC 硬件:网卡通过 DMA 把包写入 RX Ring Buffer,触发硬件中断(HW IRQ)
  2. 驱动 / NAPI:硬件中断处理函数关闭中断,调度 NAPI softirq
  3. NET_RX_SOFTIRQ:软中断上下文调用 napi_poll(),从 Ring Buffer 取包,构造 sk_buff
  4. GRO / TC / RPSnetif_receive_skb() 做 Generic Receive Offload 合并、流量控制、Receive Packet Steering
  5. IP 层ip_rcv() 做基本校验,ip_rcv_finish() 查路由表决定本地交付还是转发
  6. Netfilter:PREROUTING / INPUT / FORWARD 等 hook 点,iptables/nftables 在这里执行
  7. 传输层tcp_v4_rcv() / udp_rcv() / icmp_rcv() 把包放进对应 socket 的接收队列
  8. Socket / 用户态read() / recvmsg() 系统调用从 socket 接收队列取出数据,拷贝到用户空间

这八层,就是本文要逐一拆解的内容。

一个关键认知:中断上下文 vs 进程上下文

在开始之前,有一个认知必须先建立:一个包在内核中经历的代码,不是在同一个执行上下文里跑完的

这意味着:从网卡到 socket 接收队列,整个过程都是在 softirq 里完成的,不是在你的应用进程里。你的进程只负责最后一步——从队列里把数据拿走。理解这一点,对后面分析性能问题至关重要。


二、NIC 与驱动:从电信号到 sk_buff

硬件收包:DMA 与 Ring Buffer

现代网卡收包不走 CPU。网卡内部有一个 RX Ring Buffer(环形描述符数组),每个描述符指向一块预先分配好的 DMA 内存区域。当一个包到达时:

  1. 网卡硬件做 L2 校验(CRC check),如果开启了硬件 checksum offload,还会校验 L3/L4 校验和
  2. 网卡通过 DMA(Direct Memory Access) 把包数据写入 Ring Buffer 中下一个空闲描述符指向的内存
  3. 更新描述符状态为「已完成」
  4. 触发 MSI-X 中断(现代网卡基本都用 MSI-X,一个队列一个中断向量)

Ring Buffer 的大小直接影响抗突发流量的能力。查看和调整方法:

# 查看当前 Ring Buffer 大小
ethtool -g eth0

# 输出示例:
# Ring parameters for eth0:
# Pre-set maximums:
# RX:     4096
# TX:     4096
# Current hardware settings:
# RX:     256
# TX:     256

# 调大 RX Ring Buffer
ethtool -G eth0 rx 4096

一个常见的丢包场景:Ring Buffer 太小,突发流量来了,CPU 来不及在 softirq 里把包取走,Ring Buffer 被填满,新到的包直接被网卡丢弃。这种丢包在 ethtool -S eth0 | grep rx_droppedrx_no_buffer_count 里能看到。

RSS:多队列网卡的负载均衡

现代网卡通常有多个 RX 队列(比如 8 个或 16 个),每个队列有独立的 Ring Buffer 和中断向量。RSS(Receive Side Scaling) 是网卡硬件层面的负载均衡机制——网卡根据包头的五元组(src IP、dst IP、src port、dst port、protocol)计算一个 hash,然后用 indirection table 把包分配到不同的队列。

# 查看 RSS indirection table
ethtool -x eth0

# 查看各队列的中断统计
cat /proc/interrupts | grep eth0

RSS 的好处是:不同队列的中断可以分配到不同的 CPU 核心,实现硬件级别的并行收包。

NAPI:从中断驱动到轮询

早期 Linux 网卡驱动是纯中断驱动的——每收一个包触发一个中断。在高速网络下(10Gbps+),每秒几百万个包,每个包一个中断,CPU 光处理中断就忙不过来了,这就是所谓的 中断风暴(interrupt storm)

NAPI(New API)是解决方案。核心思想:高负载时从中断模式切换到轮询模式

  1. 第一个包到达,触发硬件中断
  2. 中断处理函数 关闭该队列的中断,调用 napi_schedule() 把 NAPI poll 结构体挂到当前 CPU 的 softirq poll list 上
  3. 中断返回后,NET_RX_SOFTIRQ 被调度,调用 napi_poll() 一次性从 Ring Buffer 轮询取出多个包(budget 默认 64)
  4. 如果一次 poll 没取完(说明包还在持续到来),继续 poll
  5. 如果一次 poll 取完了所有包(Ring Buffer 空了),重新打开中断,回到中断驱动模式
/* 简化的 NAPI poll 逻辑 (drivers/net/virtio_net.c 等) */
static int my_driver_poll(struct napi_struct *napi, int budget)
{
    int work_done = 0;

    while (work_done < budget) {
        struct sk_buff *skb = fetch_from_ring_buffer(ring);
        if (!skb)
            break;

        /* 构造 sk_buff,设置协议等元数据 */
        skb->protocol = eth_type_trans(skb, netdev);

        /* 交给上层协议栈 */
        napi_gro_receive(napi, skb);
        work_done++;
    }

    if (work_done < budget) {
        /* Ring Buffer 已空,退出轮询模式 */
        napi_complete_done(napi, work_done);
        /* 重新打开中断 */
        enable_irq(ring->irq);
    }

    return work_done;
}

这里的 budget 参数控制一次 softirq 最多处理多少个包,默认 64。可以通过 sysctl 调整:

# 查看当前 NAPI budget
sysctl net.core.netdev_budget
# net.core.netdev_budget = 300

# 每次 softirq 最多处理的包数
sysctl net.core.netdev_budget_usecs
# net.core.netdev_budget_usecs = 2000

netdev_budget 是所有 NAPI 结构体的总预算(默认 300),netdev_budget_usecs 是单次 softirq 的时间上限(默认 2ms)。两个条件任一满足就退出 softirq。


三、sk_buff:内核网络栈的通用货币

一个包从 Ring Buffer 取出后,驱动要做的第一件事就是分配一个 sk_buff 结构体。这个结构体伴随着包走完内核中的整个旅程——从驱动层到 socket 层,每一层都通过修改 sk_buff 的指针和字段来「剥洋葱」。

核心字段:四个指针

sk_buff 里最关键的是四个指针,它们定义了包数据在内存中的位置:

struct sk_buff {
    /* ... 省略大量字段 ... */

    unsigned char *head;    /* 分配的内存块起始位置 */
    unsigned char *data;    /* 当前层协议头的起始位置 */
    unsigned char *tail;    /* 当前有效数据的结束位置 */
    unsigned char *end;     /* 分配的内存块结束位置 */

    /* 协议相关 */
    __be16 protocol;        /* L3 协议类型 (ETH_P_IP, ETH_P_IPV6, ...) */
    __u16 transport_header; /* L4 头相对 head 的偏移 */
    __u16 network_header;   /* L3 头相对 head 的偏移 */
    __u16 mac_header;       /* L2 头相对 head 的偏移 */

    /* socket 关联 */
    struct sock *sk;        /* 关联的 socket(可能为 NULL) */

    /* 设备信息 */
    struct net_device *dev; /* 收到/发出此包的网络设备 */

    /* 引用计数 */
    refcount_t users;       /* 引用计数 */
};

内存布局如下:

    head                data              tail              end
     |                   |                 |                 |
     v                   v                 v                 v
     +-------------------+--+---------+----+-----------------+
     |   headroom        |L3| payload |L4  |   tailroom      |
     +-------------------+--+---------+----+-----------------+
     |<-- skb_headroom ->|<- skb_len() -->|<- skb_tailroom ->|
     |<------------------ skb_end_offset() ----------------->|

每一层处理时,通过移动 data 指针来「剥掉」当前层的协议头:

/* IP 层处理时 */
skb_pull(skb, ip_hdrlen(skb));  /* data 指针后移,跳过 IP 头 */
/* 此时 data 指向 TCP/UDP 头 */

skb_clone vs skb_copy

当一个包需要被多个地方使用时(比如 tcpdump 要抓包,同时正常协议栈也要处理),内核不会复制整个包数据。

/* skb_clone 的本质 */
struct sk_buff *skb_clone(struct sk_buff *skb, gfp_t gfp)
{
    struct sk_buff *n = kmem_cache_alloc(skb_cache, gfp);
    /* 复制 sk_buff 元数据 */
    /* 共享底层数据,增加引用计数 */
    atomic_inc(&skb_shinfo(skb)->dataref);
    return n;
}

tcpdump 能以几乎零开销抓包,就是因为它用的是 skb_clone()——只要你不修改包数据,共享就是安全的。

sk_buff 的分配与回收

sk_buff 分配用的是 slab 缓存(skbuff_head_cache),包数据用 kmalloc 或 page allocator。频繁的分配回收在高 PPS(packets per second)场景下会成为瓶颈。这也是 XDP 和 AF_XDP 绕过 sk_buff 直接操作原始包数据能获得巨大性能提升的原因之一。

# 查看 skbuff slab 缓存的状态
cat /proc/slabinfo | grep skbuff
# 或者用 slabtop
slabtop -o | grep skbuff

四、softirq 收包:从 napi_poll 到 ip_rcv

NET_RX_SOFTIRQ 的触发

当驱动的中断处理函数调用 napi_schedule() 后,当前 CPU 的 softirq pending 位图中 NET_RX_SOFTIRQ 被置位。中断返回时,内核检查 pending 位图,发现有 softirq 需要处理,就调用 __do_softirq()

调用链:

硬件中断 → irq_handler (驱动注册的)
  → napi_schedule()     // 把 napi_struct 挂到 per-cpu poll_list
  → raise_softirq(NET_RX_SOFTIRQ)

中断返回 → do_softirq() → __do_softirq()
  → net_rx_action()     // NET_RX_SOFTIRQ 的处理函数
    → napi_poll()       // 调用驱动注册的 poll 回调
      → driver_poll()   // 从 Ring Buffer 取包,构造 sk_buff
        → napi_gro_receive()
          → netif_receive_skb()

netif_receive_skb():分发中心

netif_receive_skb() 是收包路径的关键枢纽。它做几件事:

  1. 送给 ptype_all 处理器:注册了 ETH_P_ALL 的协议处理器会收到每一个包。tcpdump 就是通过 AF_PACKET socket 注册了 ptype_all 来抓包的。
  2. GRO(Generic Receive Offload):如果开启了 GRO,会尝试把多个属于同一个 TCP 流的小包合并成一个大包再往上送。减少上层处理的包数量。
  3. RPS/RFS 处理:如果配置了 Receive Packet Steering,可能把包转发到其他 CPU 处理。
  4. 送给对应 L3 协议处理器:根据 skb->protocolETH_P_IPETH_P_IPV6 等),调用对应的处理函数。对于 IPv4,就是 ip_rcv()
/* 简化的 netif_receive_skb 核心逻辑 */
static int __netif_receive_skb_core(struct sk_buff *skb, ...)
{
    /* 1. 送给 tcpdump 等 raw socket */
    list_for_each_entry(ptype, &ptype_all, list) {
        deliver_skb(skb, ptype, orig_dev);
    }

    /* 2. 查找对应的 L3 协议处理器 */
    type = skb->protocol;  /* 如 ETH_P_IP (0x0800) */
    ptype = ptype_base[hash(type)];

    /* 3. 调用协议处理函数 */
    ret = deliver_skb(skb, ptype, orig_dev);
    /* 对于 IPv4, 这会调用 ip_rcv() */

    return ret;
}

ip_rcv():IP 层入口

ip_rcv() 是 IPv4 包进入网络层的入口:

int ip_rcv(struct sk_buff *skb, struct net_device *dev,
           struct packet_type *pt, struct net_device *orig_dev)
{
    struct iphdr *iph;

    /* 基本校验 */
    if (skb->pkt_type == PACKET_OTHERHOST)
        goto drop;  /* 不是发给我的,丢弃 */

    iph = ip_hdr(skb);

    /* 版本号必须是 4 */
    if (iph->version != 4)
        goto drop;

    /* 头部长度校验 */
    if (iph->ihl < 5)
        goto drop;

    /* IP checksum 校验(如果硬件没做的话) */
    if (unlikely(ip_fast_csum((u8 *)iph, iph->ihl)))
        goto drop;

    /* Netfilter PREROUTING hook */
    return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING,
                   net, NULL, skb, dev, NULL,
                   ip_rcv_finish);
}

注意最后一行:NF_HOOK 宏。这是 netfilter 的入口。包在进入 ip_rcv_finish() 之前,会先经过 netfilter 的 PREROUTING 链。如果 iptables 规则在 PREROUTING 里 DROP 了这个包,ip_rcv_finish() 根本不会被调用。

ip_rcv_finish():路由决策

过了 PREROUTING 之后,ip_rcv_finish() 查路由表,决定这个包的去向:

# 查看路由缓存命中情况
cat /proc/net/stat/rt_cache

# 查看 IP 转发是否开启
sysctl net.ipv4.ip_forward

五、Netfilter:iptables 到底在哪里执行

Netfilter 是 Linux 内核的包过滤框架,iptables/nftables 是它的用户态工具。理解 netfilter 的 hook 点在收包路径中的位置,对 K8s 网络调试至关重要——因为 kube-proxy 的 iptables 模式就是在这些 hook 点上挂规则。

五个 Hook 点

                            PREROUTING
                                |
                          routing decision
                           /          \
                     INPUT            FORWARD
                       |                 |
                  local process     POSTROUTING
                       |                 |
                     OUTPUT            [out]
                       |
                  POSTROUTING
                       |
                     [out]

对于一个发给本机的包,经过的 hook 点是:

PREROUTING → INPUT → 本地进程

对于一个需要转发的包:

PREROUTING → FORWARD → POSTROUTING

对于一个本机发出的包:

OUTPUT → POSTROUTING

Netfilter 在 K8s 中的角色

在 K8s 中,kube-proxy 的 iptables 模式会在这些 hook 点上挂大量规则:

当 Service 数量达到几千上万时,iptables 规则可能有几万条。每个包都要线性匹配这些规则,性能急剧下降。这也是为什么 K8s 后来引入了 IPVS 模式和 eBPF(Cilium)来替代 iptables。

关于 eBPF 如何绕过 netfilter 提升性能,详见 eBPF 系列

用 iptables 的 trace 功能观察包的路径

Netfilter 有一个极其有用但鲜为人知的 trace 功能,可以打印出一个包经过了哪些表和链:

# 开启 trace(在 raw 表的 PREROUTING 链上标记要追踪的包)
iptables -t raw -A PREROUTING -p icmp -j TRACE
iptables -t raw -A OUTPUT -p icmp -j TRACE

# 加载 nf_log_ipv4 模块
modprobe nf_log_ipv4

# 然后 ping 一下,看 dmesg
ping -c 1 10.0.0.1
dmesg | grep TRACE

# 输出类似:
# TRACE: raw:PREROUTING:policy:2 IN=eth0 ...
# TRACE: mangle:PREROUTING:policy:1 IN=eth0 ...
# TRACE: nat:PREROUTING:policy:1 IN=eth0 ...
# TRACE: mangle:INPUT:policy:1 IN=eth0 ...
# TRACE: filter:INPUT:policy:1 IN=eth0 ...
# TRACE: nat:INPUT:policy:1 IN=eth0 ...

这个输出清晰地展示了包经过的每一个表和链。调试 K8s 网络问题时,这比盲猜有用一百倍。

# 用完记得清理
iptables -t raw -D PREROUTING -p icmp -j TRACE
iptables -t raw -D OUTPUT -p icmp -j TRACE

六、传输层:tcp_v4_rcv 与 socket 的相遇

从 IP 层到传输层

ip_local_deliver() 在经过 netfilter 的 INPUT 链之后,调用 ip_local_deliver_finish(),根据 IP 头中的 protocol 字段(6=TCP, 17=UDP, 1=ICMP)找到对应的传输层处理函数:

/* net/ipv4/ip_input.c */
static int ip_local_deliver_finish(struct net *net, struct sock *sk,
                                   struct sk_buff *skb)
{
    int protocol = ip_hdr(skb)->protocol;

    /* 剥掉 IP 头 */
    skb_pull(skb, ip_hdrlen(skb));

    /* 根据协议号找处理函数 */
    ipprot = rcu_dereference(inet_protos[protocol]);
    /* TCP: tcp_v4_rcv, UDP: udp_rcv, ICMP: icmp_rcv */
    ret = ipprot->handler(skb);

    return ret;
}

tcp_v4_rcv():TCP 的入口

以 TCP 为例,tcp_v4_rcv() 大致做这些事:

  1. 查找 socket:根据四元组(src IP:port, dst IP:port)在 hash table 中查找对应的 struct sock
  2. SYN 包:如果是 SYN 包且找到了 LISTEN 状态的 socket,进入三次握手流程
  3. 数据包:如果找到了 ESTABLISHED 状态的 socket,把 sk_buff 放入 socket 的接收队列(sk_receive_queuesk_backlog
  4. 唤醒等待的进程:如果有进程在 read() / epoll_wait() 上阻塞等待,唤醒它
/* 极度简化的 tcp_v4_rcv */
int tcp_v4_rcv(struct sk_buff *skb)
{
    struct tcphdr *th = tcp_hdr(skb);

    /* 查找对应的 socket */
    sk = __inet_lookup_skb(&tcp_hashinfo, skb,
                           th->source, th->dest);
    if (!sk)
        goto no_tcp_socket;  /* 没找到,发 RST */

    /* 如果 socket 被用户进程锁住了(正在 read),放入 backlog */
    if (sock_owned_by_user(sk)) {
        sk_add_backlog(sk, skb);
    } else {
        /* 直接处理:放入 receive queue,唤醒等待进程 */
        tcp_rcv_established(sk, skb);
        /* 内部会调用 sk->sk_data_ready() 唤醒 epoll */
    }

    return 0;
}

这里有一个很重要的细节:backlog 队列。如果用户进程正在持有 socket 锁(比如正在 read() 中间),softirq 不能直接操作 socket 的接收队列(会有锁竞争)。这时候 softirq 把包放到 backlog 队列,等用户进程释放锁时再处理 backlog。

socket 接收队列到用户态

当用户进程调用 read()recvmsg() 时:

  1. 进入内核,调用 sock_recvmsg()tcp_recvmsg()
  2. sk_receive_queue 中取出 sk_buff
  3. skb_copy_datagram_msg() 把数据从内核空间拷贝到用户空间
  4. 释放 sk_buff
  5. 返回用户态
# 查看 socket 接收队列的积压情况
ss -tnp | head
# Recv-Q 列就是接收队列中未被应用读取的字节数
# 如果 Recv-Q 持续增长,说明应用消费速度跟不上

七、中断亲和性与收包调优

到这里,我们已经把完整的收包路径走了一遍。但在生产环境中,仅仅理解路径是不够的——你还需要知道如何调优。收包调优的核心就是:让包的处理负载均匀分布在多个 CPU 核心上

IRQ 亲和性:smp_affinity

每个硬件中断都可以绑定到特定的 CPU 核心。这是最基础的负载均衡手段:

# 查看 eth0 各队列的中断号
cat /proc/interrupts | grep eth0
#  28:  1234567  0  0  0  IR-PCI-MSI  eth0-TxRx-0
#  29:  0  2345678  0  0  IR-PCI-MSI  eth0-TxRx-1
#  30:  0  0  3456789  0  IR-PCI-MSI  eth0-TxRx-2
#  31:  0  0  0  4567890  IR-PCI-MSI  eth0-TxRx-3

# 查看中断 28 的 CPU 亲和性
cat /proc/irq/28/smp_affinity
# 00000001  (只有 CPU 0)

# 把中断 28 绑定到 CPU 2
echo 4 > /proc/irq/28/smp_affinity  # 4 = 二进制 100 = CPU 2

# 或者用 smp_affinity_list(更直观)
echo 2 > /proc/irq/28/smp_affinity_list

irqbalance:自动中断均衡

手动设置中断亲和性很繁琐。irqbalance 是一个守护进程,会自动根据负载把中断分配到不同的 CPU 核心。

但在高性能场景下,irqbalance 可能不够好——它的调整频率较低(默认 10 秒一次),而且不了解你的应用架构。很多高性能场景会关闭 irqbalance,手动绑定。

# 查看 irqbalance 状态
systemctl status irqbalance

# 高性能场景:关闭 irqbalance,手动绑定
systemctl stop irqbalance
systemctl disable irqbalance

RPS:软件层面的 RSS

如果你的网卡只有一个 RX 队列(虚拟机里很常见),或者网卡的 RSS 队列数少于 CPU 核心数,怎么办?

RPS(Receive Packet Steering) 是内核在软件层面实现的收包负载均衡。它在 netif_receive_skb() 阶段,根据包头 hash 把包转发到其他 CPU 的 backlog 队列处理。

# 查看 eth0 第 0 个 RX 队列的 RPS 配置
cat /sys/class/net/eth0/queues/rx-0/rps_cpus
# 00000000  (未启用)

# 启用 RPS,让所有 CPU 参与处理(假设 8 核)
echo ff > /sys/class/net/eth0/queues/rx-0/rps_cpus

# 同时需要增大 backlog 队列大小
sysctl -w net.core.netdev_max_backlog=10000

RPS 的代价是:跨 CPU 转发包会产生 IPI(Inter-Processor Interrupt),有一定开销。但对于单队列网卡来说,这比所有包都在一个 CPU 上处理要好得多。

RFS:让包在「对的」CPU 上处理

RPS 把包随机分散到各个 CPU,但有一个问题:处理包的 CPU 可能不是运行应用进程的 CPU。这意味着数据要跨 CPU 传递,cache miss 严重。

RFS(Receive Flow Steering) 解决这个问题:它记录每个网络流(五元组 hash)最近是被哪个 CPU 上的进程消费的,然后把属于同一个流的包引导到那个 CPU 处理。

# 启用 RFS(设置全局流表大小)
echo 32768 > /proc/sys/net/core/rps_sock_flow_entries

# 设置每个 RX 队列的流表大小
echo 4096 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt

XPS:发包侧的队列选择

XPS(Transmit Packet Steering) 是发包方向的优化。它控制每个 CPU 核心优先使用哪个 TX 队列发包,减少多个 CPU 竞争同一个 TX 队列的锁。

# 把 TX 队列 0 绑定到 CPU 0
echo 1 > /sys/class/net/eth0/queues/tx-0/xps_cpus

# 把 TX 队列 1 绑定到 CPU 1
echo 2 > /sys/class/net/eth0/queues/tx-1/xps_cpus

总结对比

机制 层面 方向 原理 适用场景
RSS 硬件 RX 网卡按五元组 hash 分配到不同 RX 队列 多队列物理网卡
smp_affinity 内核 RX 把中断绑定到指定 CPU 手动调优
irqbalance 用户态 RX 自动均衡中断分配 通用场景
RPS 内核 RX 软件层面按 hash 分散到多 CPU 单队列/少队列网卡
RFS 内核 RX 把包引导到消费进程所在的 CPU 减少 cache miss
XPS 内核 TX 让 CPU 优先用绑定的 TX 队列 减少 TX 队列锁竞争

八、实验:用 bpftrace 追踪一个 ICMP 包的完整旅程

理论讲完了,该上手验证了。我们用 bpftrace 追踪一个 ICMP echo request 包(ping)从进入内核到送达 socket 的完整路径,打印每一跳的时间戳。

关于 eBPF 和 bpftrace 的基础知识,参见 eBPF 系列。 关于 Network Namespace 的基础知识,参见 网络命名空间详解

准备工作

# 安装 bpftrace(Ubuntu 22.04+)
apt-get install -y bpftrace

# 确认内核支持 kprobe
cat /proc/kallsyms | grep -c ip_rcv
# 应该输出 1 或更多

bpftrace 脚本:追踪 ICMP 收包路径

下面这个脚本追踪 ICMP 包经过的关键内核函数,打印纳秒级时间线:

#!/usr/bin/env bpftrace

/*
 * icmp_rx_trace.bt - 追踪 ICMP 包的完整 RX 路径
 *
 * 用法:
 *   bpftrace icmp_rx_trace.bt &
 *   ping -c 3 <target_ip>
 */

BEGIN
{
    printf("%-18s %-30s %-6s %s\n",
           "TIMESTAMP_ns", "FUNCTION", "CPU", "INFO");
    printf("---\n");
}

/* 1. 驱动层 NAPI poll 入口 */
kprobe:napi_gro_receive
{
    $skb = (struct sk_buff *)arg1;
    /* 只追踪 ICMP (IP protocol 1) */
    if ($skb->protocol == 0x0008) {  /* ETH_P_IP in network byte order */
        @start[tid] = nsecs;
        printf("%-18llu %-30s %-6d %s\n",
               nsecs, "napi_gro_receive", cpu, "skb entered GRO");
    }
}

/* 2. netif_receive_skb:分发中心 */
kprobe:netif_receive_skb
{
    $skb = (struct sk_buff *)arg0;
    if ($skb->protocol == 0x0008) {
        printf("%-18llu %-30s %-6d %s\n",
               nsecs, "netif_receive_skb", cpu, "dispatching to L3");
    }
}

/* 3. ip_rcv:IP 层入口 */
kprobe:ip_rcv
{
    $skb = (struct sk_buff *)arg0;
    printf("%-18llu %-30s %-6d %s\n",
           nsecs, "ip_rcv", cpu, "IP validation + PREROUTING");
}

/* 4. ip_rcv_finish:路由决策 */
kprobe:ip_rcv_finish
{
    printf("%-18llu %-30s %-6d %s\n",
           nsecs, "ip_rcv_finish", cpu, "route lookup done");
}

/* 5. ip_local_deliver:送往本地传输层 */
kprobe:ip_local_deliver
{
    printf("%-18llu %-30s %-6d %s\n",
           nsecs, "ip_local_deliver", cpu, "-> INPUT chain");
}

/* 6. icmp_rcv:ICMP 处理 */
kprobe:icmp_rcv
{
    $elapsed = @start[tid] > 0 ? nsecs - @start[tid] : 0;
    printf("%-18llu %-30s %-6d total=%llu ns\n",
           nsecs, "icmp_rcv", cpu, $elapsed);
}

END
{
    clear(@start);
}

运行实验

# 终端 1:运行 bpftrace
bpftrace icmp_rx_trace.bt

# 终端 2:发送 ICMP 包
ping -c 3 127.0.0.1

预期输出类似:

TIMESTAMP_ns       FUNCTION                       CPU    INFO
---
8234567890123456   napi_gro_receive               0      skb entered GRO
8234567890124100   netif_receive_skb              0      dispatching to L3
8234567890124800   ip_rcv                         0      IP validation + PREROUTING
8234567890125200   ip_rcv_finish                  0      route lookup done
8234567890125600   ip_local_deliver               0      -> INPUT chain
8234567890126100   icmp_rcv                       0      total=2644 ns

在一台典型的 VM 上,一个 ICMP 包从 napi_gro_receiveicmp_rcv 大约 2-5 微秒。在物理机上可以更快。如果你看到超过 10 微秒,可能是 netfilter 规则太多或者有其他 softirq 在竞争。

进阶:用 perf probe 追踪发包路径

除了 bpftrace,perf probe 也是追踪内核网络路径的利器。它可以在任意内核函数上动态设置追踪点:

# 在 ip_rcv 上设置追踪点
perf probe --add ip_rcv

# 在 tcp_v4_rcv 上设置追踪点
perf probe --add tcp_v4_rcv

# 在 netif_receive_skb 上设置追踪点
perf probe --add netif_receive_skb

# 录制追踪数据(同时在另一个终端制造流量)
perf record -e probe:ip_rcv -e probe:tcp_v4_rcv \
            -e probe:netif_receive_skb -a sleep 5

# 查看结果
perf script

# 清理
perf probe --del ip_rcv
perf probe --del tcp_v4_rcv
perf probe --del netif_receive_skb

perf probe 的好处是开销极低,适合在生产环境使用。缺点是输出没有 bpftrace 那么灵活。

完整的发包 + 收包时间线

如果你想同时追踪发包路径(TX),可以扩展 bpftrace 脚本:

#!/usr/bin/env bpftrace

/*
 * net_timeline.bt - 同时追踪 TX 和 RX 路径
 */

/* TX 路径关键函数 */
kprobe:dev_queue_xmit     { printf("%-18llu TX  dev_queue_xmit      cpu=%-3d\n", nsecs, cpu); }
kprobe:sch_direct_xmit    { printf("%-18llu TX  sch_direct_xmit     cpu=%-3d\n", nsecs, cpu); }
kprobe:dev_hard_start_xmit { printf("%-18llu TX  dev_hard_start_xmit cpu=%-3d\n", nsecs, cpu); }

/* RX 路径关键函数 */
kprobe:netif_receive_skb   { printf("%-18llu RX  netif_receive_skb   cpu=%-3d\n", nsecs, cpu); }
kprobe:ip_rcv              { printf("%-18llu RX  ip_rcv              cpu=%-3d\n", nsecs, cpu); }
kprobe:tcp_v4_rcv          { printf("%-18llu RX  tcp_v4_rcv          cpu=%-3d\n", nsecs, cpu); }

/* 使用 tracepoint 也可以 */
tracepoint:net:net_dev_xmit
{
    printf("%-18llu TP  net_dev_xmit        cpu=%-3d dev=%s len=%d\n",
           nsecs, cpu, args->name, args->len);
}

tracepoint:net:netif_receive_skb
{
    printf("%-18llu TP  netif_receive_skb   cpu=%-3d len=%d\n",
           nsecs, cpu, args->len);
}

这个脚本同时使用了 kprobe 和 tracepoint。tracepoint 是内核源码里预定义的追踪点,比 kprobe 更稳定(不会因为函数改名而失效),但数量有限。kprobe 可以挂在任意函数上,灵活性更强。


九、常见问题与调试实战

问题一:softirq 占用率过高

# 查看 softirq 统计
cat /proc/softirqs
#                    CPU0       CPU1       CPU2       CPU3
# NET_RX:       123456789      12345       1234        123

# 如果某个 CPU 的 NET_RX 远高于其他 CPU,说明收包集中在一个核上
# 检查 RSS 是否生效、RPS 是否配置

如果 softirq 处理时间过长,内核会把 softirq 工作转交给 ksoftirqd 内核线程(每个 CPU 一个)。用 top 看到 ksoftirqd/0 CPU 占用率很高,基本就是网络 softirq 忙不过来了。

问题二:Ring Buffer 溢出丢包

# 查看网卡统计
ethtool -S eth0 | grep -E 'rx_dropped|rx_missed|rx_no_buffer'

# 查看内核网络统计
cat /proc/net/softnet_stat
# 每行对应一个 CPU,各列含义:
# col1: 已处理的包数
# col2: 因 backlog 满而丢弃的包数
# col3: time_squeeze 次数(softirq 用完预算被强制退出)

time_squeeze(第三列)不为零说明 softirq 一直在忙,但被强制限时退出了。这时候可以考虑: - 增大 net.core.netdev_budget - 增大 net.core.netdev_budget_usecs - 启用 RPS 分散负载

问题三:用 ss 诊断 socket 队列积压

# 查看 TCP socket 的接收和发送队列
ss -tnpi

# 各字段含义:
# Recv-Q: 接收队列中等待应用读取的字节数
# Send-Q: 发送队列中等待对端确认的字节数

# 如果 Recv-Q 持续非零且增长,说明:
# 1. 应用 read() 速度跟不上
# 2. 可能需要优化应用的事件循环
# 3. 或者 epoll 的用法有问题

问题四:确认中断是否均匀分布

# 实时监控中断分布(每秒刷新)
watch -n 1 'cat /proc/interrupts | grep eth0'

# 如果只有一个 CPU 在涨,其他都是 0:
# 1. 检查 RSS 是否启用
# 2. 检查 irqbalance 是否运行
# 3. 检查 smp_affinity 设置

十、从 sk_buff 到 XDP:为什么需要绕过协议栈

走完整个收包路径后,你应该能感受到一个现实:这条路径很长

一个包要经过硬件中断 → softirq → NAPI poll → GRO → netif_receive_skb → ip_rcv → netfilter → tcp_v4_rcv → socket 队列 → 用户态 read()。每一步都有 CPU 开销,每一步都涉及 sk_buff 的分配、修改、释放。

在 10Gbps 线速下(大约 14.88 Mpps 对于最小包),每个包只有大约 67 纳秒的处理预算。走完整个协议栈是不够用的。

这就是为什么需要 XDP(eXpress Data Path)和 AF_XDP:

方案 绕过层次 性能 保留内核功能
标准协议栈 基线 全部保留
XDP netfilter 之前处理 可选择性送入协议栈
AF_XDP 绕过整个协议栈 很高 仅保留驱动层
DPDK 绕过内核驱动 极高 无,纯用户态

在 K8s 场景中,Cilium 使用 eBPF/XDP 来替代 kube-proxy 的 iptables 规则,在 netfilter 之前就完成 Service 的负载均衡,性能提升显著。


十一、总结:一张图回顾完整路径

让我们最后把完整的 RX 路径用函数调用链串起来:

[NIC Hardware]
  DMA write → RX Ring Buffer → MSI-X IRQ

[Hard IRQ Context]
  irq_handler()
    → napi_schedule()
    → raise_softirq(NET_RX_SOFTIRQ)

[Soft IRQ Context - NET_RX_SOFTIRQ]
  net_rx_action()
    → napi_poll()
      → driver_poll()          // 从 Ring Buffer 取包
        → alloc_skb()          // 分配 sk_buff
        → eth_type_trans()     // 设置 skb->protocol
        → napi_gro_receive()   // GRO 尝试合并
          → netif_receive_skb()
            → ptype_all 处理 (tcpdump)
            → RPS 转发 (如果配置了)
            → deliver_skb()    // 根据 protocol 分发
              → ip_rcv()
                → NF_HOOK(PREROUTING)
                → ip_rcv_finish()
                  → route lookup
                  → ip_local_deliver()
                    → NF_HOOK(INPUT)
                    → ip_local_deliver_finish()
                      → tcp_v4_rcv() / udp_rcv() / icmp_rcv()
                        → sk_lookup()
                        → sock_queue_rcv_skb()
                        → sk->sk_data_ready()  // 唤醒 epoll

[Process Context]
  read() / recvmsg() / epoll_wait()
    → sock_recvmsg()
      → tcp_recvmsg()
        → skb_copy_datagram_msg()  // 拷贝到用户空间
        → kfree_skb()              // 释放 sk_buff
    → return to userspace

这条路径中的每一个函数,你都可以用 bpftrace 或 perf probe 挂追踪点。当你在 K8s 中遇到网络问题时,知道包在哪一层被丢弃或者卡住,就知道该往哪个方向去查。

本文覆盖的是 Linux 网络栈的「纵向」路径。在后续文章中,我们会深入每一层的「横向」细节:

掌握了这条完整路径,你就有了一张地图。后面的文章都是在这张地图上标注细节。


附录 A:快速参考命令

# === 网卡和驱动 ===
ethtool -i eth0                          # 驱动信息
ethtool -g eth0                          # Ring Buffer 大小
ethtool -l eth0                          # 队列数量
ethtool -x eth0                          # RSS indirection table
ethtool -S eth0                          # 网卡统计(含各种丢包计数)
ethtool -k eth0                          # offload 特性开关

# === 中断 ===
cat /proc/interrupts | grep eth0         # 中断计数
cat /proc/irq/<N>/smp_affinity           # 中断亲和性
cat /proc/irq/<N>/effective_affinity     # 实际生效的亲和性

# === softirq ===
cat /proc/softirqs                       # softirq 计数
cat /proc/net/softnet_stat               # 收包统计(丢包、time_squeeze)

# === RPS/RFS/XPS ===
cat /sys/class/net/eth0/queues/rx-0/rps_cpus
cat /sys/class/net/eth0/queues/rx-0/rps_flow_cnt
cat /sys/class/net/eth0/queues/tx-0/xps_cpus

# === socket ===
ss -tnpi                                 # TCP socket 详情
ss -unlp                                 # UDP listening sockets
cat /proc/net/sockstat                   # socket 统计

# === netfilter ===
iptables -t raw -A PREROUTING -j TRACE   # 开启 TRACE
conntrack -L                             # 连接追踪表
conntrack -C                             # 连接追踪条目数

# === bpftrace ===
bpftrace -l 'kprobe:*ip_rcv*'            # 列出可追踪的函数
bpftrace -l 'tracepoint:net:*'           # 列出 net 相关 tracepoint

附录 B:关键内核源码文件索引

文件 内容
net/core/dev.c netif_receive_skb()net_rx_action()、RPS/RFS 逻辑
net/core/skbuff.c sk_buff 的分配、释放、clone、copy
net/ipv4/ip_input.c ip_rcv()ip_rcv_finish()ip_local_deliver()
net/ipv4/tcp_ipv4.c tcp_v4_rcv()
net/ipv4/udp.c udp_rcv()
net/ipv4/icmp.c icmp_rcv()
net/netfilter/core.c nf_hook_slow()、netfilter 框架核心
net/ipv4/netfilter/ iptables 各表的实现
include/linux/skbuff.h sk_buff 结构体定义
include/linux/netdevice.h net_device、NAPI 结构体定义
kernel/softirq.c softirq 调度、__do_softirq()

附录 C:推荐阅读


By .