Linux 收包流程

在服务端开发中,我们往往只需要调用简单的 recv()read() 就可以自底向上获取网络数据。但在这些简单的 API 背后,隐藏着 Linux 极其精密复杂的网络子系统。


收包七步走

可以将整个收包链路宏观地归纳为以下核心阶段:

  1. 数据帧到达:数据帧从网络链路进入网卡物理介质。
  2. DMA 搬运数据:网卡利用 DMA 技术将网络包写入系统内存 (Ring Buffer)。
  3. 硬中断触发:网卡向 CPU 发送硬件中断信号,通知有新数据到达。
  4. 硬中断响应:CPU 暂停当前任务,执行硬中断函数,随后发出软中断请求。
  5. 软中断处理 (ksoftirqd):专门响应软中断的内核线程 ksoftirqd 开始工作,调用网卡驱动注册的 poll 轮询函数。
  6. 封装 sk_buff:网卡驱动从 Ring Buffer 摘下数据,将其封装为内核认识的 sk_buff 数据结构。
  7. 协议栈处理:经过网络层(IP)、传输层(TCP/UDP)解析,最终把数据追加到目标 Socket 的接收队列中,等待用户进程读取。

数据包的奇幻漂流

网卡接客与 DMA 搬运

当网络包通过光纤或双绞线到达网卡(NIC)时,网卡的 MAC 模块会对其进行初步过滤校验(比如检查 MAC 地址、FCS 校验等)。验证通过后,网卡并不会立刻打扰 CPU,而是通过 DMA (Direct Memory Access) 技术,直接将这些数据原封不动地写入到由系统预先分配好的内存空间 —— 即 Ring Buffer (环形缓冲区) 中。

这里 Ring Buffer 的内存实际上是由内核在驱动初始化时分配好的,网卡知道对应的物理地址,这就是真正的“零拷贝”(硬件层到内存层)。

硬中断 (Hard IRQ) 唤醒 CPU

网卡将数据放到 Ring Buffer 后,通过 PCIe 线上报一个硬件中断(Hard IRQ)通知 CPU 有活儿干了。

CPU 收到中断后,会打断当前进程上下文,转而进入中断上下文,去执行网卡驱动注册的硬中断处理函数(通常对应类似 ixgbe_msix_clean_rings 的函数)。 因为硬中断会屏蔽其他中断,为了保证系统响应速度,这部分代码必须极短、极快。内核网络栈采用的做法是:仅仅确认一下中断、清除硬件中断标志,然后触发一个软中断 (Soft IRQ)

  1. 硬中断的极速退出:触发软中断的核心动作非常轻量,本质上仅仅是将 NET_RX_SOFTIRQ 的标志位置位。完成置标后,硬中断处理函数立刻 return 结束,以最快速度恢复系统的中断挂起响应能力。
  2. 软中断的无缝衔接:在离开硬中断上下文的底层函数 irq_exit() 中,内核会探查是否有处于挂起状态(已标记)的软中断需要处理。一旦发现,就会在退出硬中断的尾声,直接切入并执行软中断逻辑。

源码追踪: 调用 __napi_schedule() 将当前的 NAPI (New API) 结构体挂载到当前 CPU 的 softnet_data.poll_list 上,随后触发 NET_RX_SOFTIRQ 软中断。

1
2
3
4
static inline void __napi_schedule(struct napi_struct *n) {
    list_add_tail(&n->poll_list, &sd->poll_list);
    __raise_softirq_irqoff(NET_RX_SOFTIRQ); // 仅仅是置标志位
}

ksoftirqd 与 NAPI 轮询接管 (Soft IRQ)

ksoftirqd/x 是什么角色? ksoftirqd/x 它是一个内核线程(每个 CPU 核心一个,x 为核心编号)。 在网络正常或低负载时,软中断往往会在刚才提到的硬中断退出阶段 (irq_exit()) 顺手被处理完。但若是遇到高并发大流量,软中断处理时间过长,为了防止系统一直处于中断上下文而“饿死”所有普通的用户进程,内核会主动中断顺手处理的行为,转而唤醒对应的后台内核线程 ksoftirqd/x,让它去接盘处理剩下的网络包。 这种设计的精妙之处在于:将不可抢占的中断上下文,转移到了受系统统一调度的内核线程上下文中处理,从而保证了 CPU 的公平分配。

ksoftirqd 被调度执行时,此处软中断真正的处理入口为 net_rx_action()。它会去遍历刚刚硬中断放入 poll_list 的设备,并调用它们各自注册的 poll() 函数(即 NAPI 机制)。NAPI 机制的魅力在于:在网络高负载时,通过一次中断转为连续的轮询处理,以此来避免网络风暴带来的中断过载。

源码追踪

1
2
3
4
5
6
7
8
static __latent_entropy void net_rx_action(struct softirq_action *h) {
    // 遍历 poll_list 上的 napi 结构
    while (!list_empty(&list)) {
        struct napi_struct *n = list_first_entry(&list, struct napi_struct, poll_list);
        // 调用网卡驱动特定的 poll 函数 (如 ixgbe_poll)
        work = n->poll(n, weight);
    }
}

生成灵魂数据结构:sk_buff

在网卡驱动特定的 poll() 函数内部(比如 e1000 网卡的 e1000_clean_rx_irq),驱动会从 Ring Buffer 中读取之前 DMA 写入的数据帧。

此时,内核会为这段数据分配一个核心数据结构:sk_buff (Socket Buffer)。sk_buff 是 Linux 网络内核的“通用货币”,无论是在 MAC 层、IP 层还是 TCP 层,它的指针通过加减头部空间的方式在各个协议层中穿梭,避免了层与层之间数据的内存拷贝。

接下来,利用 napi_gro_receive() (通常伴随 GRO:Generic Receive Offload,合并多个零散包)或者直接 netif_receive_skb()sk_buff 喂给上层的网络协议栈。

协议栈的层层剥茧与 Socket 投递

数据包进入 netif_receive_skb() 后,内核会根据 MAC 头部的以太网类型(比如 ETH_P_IP)将包交给对应的网络层 (IP 层) 处理。

IP 层 (ip_rcv)

  • 验证 IP 头部、TTL 等。
  • 查询路由表,判断这个包是给本机的,还是需要转发的(Netfilter / iptables PREROUTING 链也是在此介入)。
  • 如果是送往本机,调用 ip_local_deliver(),最终剥离 IP 头,根据上层协议找到 UDP 或 TCP 处理函数。

传输层 (TCP: tcp_v4_rcv / UDP: udp_rcv): 以 TCP 为例:

  • TCP 协议栈会根据包里的 <源 IP,源端口,目的 IP,目的端口> 查找到对应的 socket 实例。
  • 解析 TCP 报头、确认序列号、滑动窗口等复杂逻辑。
  • 核心处理函数 tcp_rcv_established() 会把除去所有头部后的纯净 Payload 数据放到 socket 的接收队列 (Receive Queue) 中。

源码追踪:投递到 Socket 队列中并唤醒用户进程

1
2
3
4
5
// 将准备好的数据加入 sk_receive_queue
skb_queue_tail(&sk->sk_receive_queue, skb);

// 调用对应的回调唤醒处于阻塞态的用户进程 (如 epoll_wait / recv)
sk->sk_data_ready(sk);

最终:用户态的进程苏醒

sk_data_ready() 被调用时,如果进程之前因为调用 recv()epoll_wait() 处于睡眠状态,它现在就会被唤醒。此时进程调度器再次选中该用户进程,从内核空间将 socket 接收队列里的数据拷贝到用户态的 Buffer 中。至此,整个网络包的接收流程才算圆满结束!


总结思考

  1. DMA + CPU 缓存协同:规避了最外设到内存的 CPU 拷贝开销。
  2. NAPI(中断 + 轮询):用“硬中断”保证低延迟通知,用“软中断+轮询(poll)”处理高并发网络包,完美平衡了资源消耗与吞吐量。
  3. sk_buff 元数据控制:各层协议处理过程中,数据体本身的内存不会发生流动拷贝,仅仅是首部指针在变化。

十年码农内功:网络收包详细过程
linux6.9内核网络篇-收包源码分析 Linux内核网卡收包流程,以及cpu、设备关开中断情况

Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 StackJimmy 设计