Linux网络调优之内核网络栈发包收包认知

99%的焦虑都来自于虚度时间和没有好好做事,所以唯一的解决办法就是行动起来,认真做完事情,战胜焦虑,战胜那些心里空荡荡的时刻,而不是选择逃避。不要站在原地想象困难,行动永远是改变现状的最佳方式

写在前面


  • 陆续分享一些 Linux 网络调优的的技术博客
  • 讲网络调优,离不开讲 ebpf,对于网络优化,我计划分四个方向整理:
  • 1.内核网络栈的认知,以及一些常见的网络认知
  • 2.网络相关的内核参数优化以及监控指标认知
  • 3.BPF/eBPF 对于网络的性能监控与调优
  • 4.容器网络/软件定义网络SDN,云原生组网认知
  • 有其他小伙伴想了解的方向欢迎留言,本篇博客是第一部分,内核网络栈的认知
  • 理解不足小伙伴帮忙指正 :),生活加油

99%的焦虑都来自于虚度时间和没有好好做事,所以唯一的解决办法就是行动起来,认真做完事情,战胜焦虑,战胜那些心里空荡荡的时刻,而不是选择逃避。不要站在原地想象困难,行动永远是改变现状的最佳方式


内核网络栈的认知

在 Linux 系统中,网络数据包的接收发送是一个涉及硬件、驱动、内核协议栈的复杂协作过程。很多开发者可能只关注应用层的网络调用(如 socket 编程),却对内核底层如何 “迎接” 和 “处理”,发送数据包知之甚少。实际上涉及到一些调优,解决网络指标饱和或者异常的情况都需要对底层有一定的了解,同时在使用 ebpf 进行网络性能观测的时候,主要是通过一些内核态和用户的系统调用进行埋点,对数据进行汇总分析,所以对内核的认知是必不可少的。

内核收包机制认知

这里将从内核收包前的准备工作入手,一步步拆解数据包到达后的完整处理流程,理清 Linux 网络栈的核心逻辑。

一、收包前的准备:内核如何 “迎接” 数据包?

在网卡正式接收第一个数据包之前,Linux 内核需要完成一系列初始化和注册操作,为后续高效处理数据包做好铺垫。这 4 项准备工作缺一不可,直接决定了后续收包的效率和稳定性。这部分工作是在系统启动之后完成的。

1.创建软中断处理线程:ksoftirqd

内核首先会创建一个特殊的内核线程 ——ksoftirqd每个 CPU 核心对应一个实例(比如 CPU0 对应的线程是ksoftirqd/0)。创建时会为其绑定 “软中断处理” 的核心逻辑,它的核心职责是:

  • 接管 “耗时的数据包处理工作”(如协议解析、数据分发);
  • 避免硬中断长时间占用 CPU(硬中断优先级高,若处理耗时会阻塞其他任务)。
1
2
3
4
5
6
[root@liruilongs.github.io]-[~]$ ps -ef | grep   ksoftirqd | grep -v grep
root 11 2 0 8月03 ? 00:00:00 [ksoftirqd/0]
root 17 2 0 8月03 ? 00:00:00 [ksoftirqd/1]
root 22 2 0 8月03 ? 00:00:00 [ksoftirqd/2]
root 27 2 0 8月03 ? 00:00:00 [ksoftirqd/3]
[root@liruilongs.github.io]-[~]$

简单来说,ksoftirqd是内核专门为 “网络软中断” 配备的 “打工人”,后续数据包的核心处理都靠它。这里的软中断是什么,后面我们会讲。

2.协议栈注册:让内核 “认识” 所有协议

Linux 内核支持ARP、ICMP、IP、UDP、TCP等多种网络协议,但内核不会 “凭空识别” 这些协议,每个协议在初始化时,都需要主动将自己的数据包处理函数注册到内核的 协议链表 中。

举几个关键协议的注册例子:

  • UDP 协议:注册udp_rcv()函数,作为 UDP 数据包的 “入口处理器”;
  • TCP 协议(IPv4 场景):注册tcp_rcv_v4()函数,负责 TCP 数据包的接收逻辑;
  • IP 协议:注册ip_rcv()函数,处理 IP 头解析和协议分发(比如判断数据包是 UDP 还是 TCP)。

这个注册过程的作用很关键:当数据包到达时,内核只需查看数据包的 “协议类型字段”(如 IP 头中的protocol字段),就能快速找到对应的处理函数,无需遍历所有协议,极大提升了处理效率,类似于一个 Hash 路由一样。

3.网卡驱动初始化:打通硬件与内核的 “通道”

网卡是数据包进入系统的 “物理入口”,但它需要驱动程序才能与内核协作。内核会调用网卡对应的驱动初始化函数,完成 3 件核心事:

初始化 DMA(直接内存访问):配置网卡与内存的 DMA 映射,让网卡可以直接将数据包写入内存(无需 CPU 中转,减少 CPU 开销);下面为系统启动对应的内核日志

下面是内核日志中对应的分配逻辑,没有CPU 中转,意味着不需要 MMU 内存管理单元参与,分配的是实际的连续的物理内存,而不是虚拟内存,不存在超售问题,并且是锁定状态,不会发生页面换出,分配即占用。这里是内核态。

1
2
3
4
5
6
7
8
9
10
[root@liruilongs.github.io]-[~]$ sudo dmesg -T | grep DMA
[六 9月 13 19:15:28 2025] DMA [mem 0x0000000040000000-0x00000000ffffffff] # 内核划分DMA内存域(DMA zone),地址范围1GB-4GB,供网卡等DMA设备直接访问,对应“网卡驱动初始化→DMA映射配置”环节
[六 9月 13 19:15:28 2025] DMA32 empty # DMA32内存域为空,该内存域用于仅支持32位地址的老旧DMA设备,当前系统无此类设备,对网卡收包无影响
[六 9月 13 19:15:28 2025] DMA zone: 12288 pages used for memmap # DMA zone中12288个页(48MB)用于内存映射表(memmap),内核通过该表管理DMA内存使用状态,是DMA内存分配的基础
[六 9月 13 19:15:28 2025] DMA zone: 0 pages reserved # DMA zone无预留内存,所有内存可用于网卡等DMA设备的数据包缓存,保障收包时内存可用性
[六 9月 13 19:15:28 2025] DMA zone: 786432 pages, LIFO batch:63 # DMA zone共786432个页(3072MB=3GB)可用,LIFO batch=63表示内核批量分配内存时一次最多分配63个页,提升分配效率,为网卡提供充足DMA内存
[六 9月 13 19:15:28 2025] DMA: preallocated 1024 KiB GFP_KERNEL pool for atomic allocations # 预分配1024KB GFP_KERNEL内存池,供内核常规场景的DMA原子分配(非紧急数据包缓存),原子分配确保不能睡眠的场景(如硬中断)快速获内存
[六 9月 13 19:15:28 2025] DMA: preallocated 1024 KiB GFP_KERNEL|GFP_DMA pool for atomic allocations # 预分配1024KB DMA zone专属原子内存池,专门供网卡高并发收包时快速分配内存(如突发流量),避免数据包丢弃
[六 9月 13 19:15:28 2025] DMA: preallocated 1024 KiB GFP_KERNEL|GFP_DMA32 pool for atomic allocations # 预分配1024KB DMA32 zone原子内存池,因DMA32 zone为空,当前暂未使用,预留供老旧DMA设备使用
[root@liruilongs.github.io]-[~]$

注册 NAPI 的 poll 函数:将驱动实现的poll函数地址告诉内核,后续软中断处理时,内核会通过这个函数轮询从网卡读取数据包;

网卡收到数据包后,正常触发硬中断(IRQ);CPU 通知内核执行中断处理程序(ISR),读取数据包到内存,并唤醒上层协议栈,若数据包密集(如千兆网卡满速传输),会产生大量硬中断,频发的上下文切换会增加 CPU 开销,同时会使CPU 缓存失效。

NAPI (NET API)是为了在高吞吐量场景下减少硬中断次数,所以 NAPI通过 “硬中断触发 + 软中断轮询” 的混合模式平衡效率与实时性,而 poll 函数正是轮询阶段的核心执行者。poll 函数由网卡驱动实现,内核通过注册机制 “记住” 这个函数的地址,以便在软中断中调用。注册过程本质是将驱动的硬件操作逻辑接入内核的网络中断处理框架.

配置硬件参数:设置网卡的 MAC 地址、双工模式(全双工 / 半双工)、传输速率(如 1Gbps)等基础参数。

1
2
3
4
5
6
7
8
9
10
11
[root@liruilongs.github.io]-[~]$ ifconfig   enp3s0
enp3s0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 10.12.191.125 netmask 255.255.240.0 broadcast 10.12.191.255
inet6 fe80::f816:3eff:fe76:29e7 prefixlen 64 scopeid 0x20<link>
ether fa:16:3e:76:29:e7 txqueuelen 1000 (Ethernet)
RX packets 8800 bytes 12120818 (11.5 MiB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 1431 bytes 130773 (127.7 KiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

[root@liruilongs.github.io]-[~]$

这一步相当于 “打通网卡与内核的通信通道”,让硬件具备接收数据包的能力。

4.启动网卡:配置队列与中断

驱动初始化完成后,内核会将网卡从 “down” 状态切换到 “up” 状态(即启动网卡),同时完成两项关键配置:

分配 RX/TX 队列:通常为每个 CPU 核心分配独立的接收队列(RX 队列)和发送队列(TX 队列)(即 RSS 队列技术),实现数据包的负载均衡,避免单队列成为性能瓶颈;

查看通道信息,网卡的 “通道” 是硬件层面的数据处理队列,用于将网络收发任务分配到不同 CPU 核心,实现并行处理以提升吞吐量。

1
2
3
4
5
6
7
8
9
10
11
12
13
[root@liruilongs.github.io]-[~]$ ethtool -l  enp3s0
Channel parameters for enp3s0:
Pre-set maximums: # 硬件支持的最大队列数(驱动或固件限制)
RX: n/a # 接收队列最大数:不支持独立RX队列(n/a = not applicable)
TX: n/a # 发送队列最大数:不支持独立TX队列
Other: n/a # 其他队列(如管理队列)不支持
Combined: 1 # 组合队列(同时处理RX和TX)最大数:1

Current hardware settings: # 当前生效的队列配置
RX: n/a # 当前未启用独立RX队列
TX: n/a # 当前未启用独立TX队列
Other: n/a # 当前未启用其他队列
Combined: 1 # 当前启用1个组合队列

上面的配置可以看出张网卡(虚拟化环境中的网卡, KVM 的 virtio-net)不支持独立的 RX/TX 队列,而是使用 “组合队列”(Combined)同时处理接收和发送数据,当前配置了 1 个组合队列

通过 sys 伪文件系统查看网卡的 “通道” 信息,可以看到 “通道” 的数量为 1,即 1 个 RX 队列和 1 个 TX 队列。

1
2
3
[root@developer ~]# ls /sys/class/net/enp3s0/queues/
rx-0 tx-0
[root@developer ~]#

确认网卡类型

1
2
3
[root@liruilongs.github.io]-[~]$ ethtool -i  enp3s0 | grep vir
driver: virtio_net
[root@liruilongs.github.io]-[~]$

注册硬中断处理函数:将网卡的 “数据包到达中断” 与内核中的中断处理函数绑定,当网卡收到数据时,能触发 CPU 响应硬中断,网卡中断的触发流程

  • 硬件层面:网卡收到数据包后,通过 PCIe 总线向 CPU 发送中断请求(IRQ)信号;
  • CPU 层面:CPU 响应中断,暂停当前任务,跳转到内核预设的中断处理入口;
  • 内核层面:内核通过中断向量号(IRQ 号)找到对应的处理函数(ISR)并执行。

实际上就是将网卡的硬件中断信号与内核中的中断服务程序(ISR)建立关联,让内核知道 “哪个 IRQ 号对应哪个网卡的哪个事件(如数据包到达)”

至此,收包前的准备工作全部完成,内核开启网卡的硬中断,进入 “等待数据包到达” 的状态。

二、数据包到达后:内核如何 “处理” 数据包?

当外部数据包通过网线到达网卡时,Linux 内核会启动一套 “流水线式” 的处理流程,从硬件接收到底层协议解析,再到应用层分发,每一步都分工明确。

深入理解Linux网络: 修炼底层内功,掌握高性能原理 (张彦飞)

第一步:网卡 DMA 写入内存,触发硬中断

网卡接收到物理层的以太网帧后,不会直接通知 CPU “帮忙”,而是通过之前配置的 DMA 通道,直接将数据包写入内存中 RX 队列对应的 RingBuffer(环形缓冲区) ,这个过程完全不需要 CPU 参与,极大减少了 CPU 的负担。

这里有一个误区

Q: 那就是这个 RingBuffer 到底是在网卡上,还是在内核态?到底是物理内存,还是虚拟内存,我们知道如果是内核态,那么使用只能是内核分配的虚拟内存,不是物理内存,但是没有CPU参与,即不走MMU,那么只能是物理内存。

A:网卡接收数据后,先存入自身硬件缓冲区(临时存储);网卡的 DMA 控制器根据内核提前告知的 “RingBuffer 物理地址”,直接将数据从网卡硬件缓冲区写入物理内存(即 RingBuffer);DMA 传输完成后,触发硬中断,CPU 介入;内核代码通过 “RingBuffer 的内核虚拟地址” 读取物理内存中的数据,交给协议栈处理

ethtool -g 用于查看网卡的硬件环形缓冲区(Ring Buffer)参数

1
2
3
4
5
6
7
8
9
10
11
[root@liruilongs.github.io]-[~]$ ethtool -g  enp3s0
Ring parameters for enp3s0:
Pre-set maximums: # 硬件支持的最大缓冲区数量(驱动/固件限制)
RX: 2048 # 接收环形缓冲区最大可配置数量:2048个
RX Mini: n/a # 小数据包接收缓冲区:不支持(n/a = not applicable)
RX Jumbo: n/a # 巨型帧接收缓冲区:不支持
TX: 2048 # 发送环形缓冲区最大可配置数量:2048个

Current hardware settings: # 当前生效的缓冲区配置
RX: 2048 # 当前接收缓冲区数量:2048个
TX: 2048 # 当前发送缓冲区数量:2048个

该网卡支持独立的接收(RX)发送(TX)环形缓冲区;当前接收和发送缓冲区均配置为 2048 个(已达硬件最大限制);并且不支持 小数据包,巨型帧 的专用缓冲区(仅用通用缓冲区处理所有包)

单个缓冲区的大小通常与网卡的 MTU(最大传输单元)匹配,计算公式为:单缓冲区大小 ≈ MTU + 协议头部开销(约 18-42 字节)

1
2
[root@liruilongs.github.io]-[~]$ ip link show enp3s0 | grep mtu
2: enp3s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc tbf state UP mode DEFAULT group default qlen 1000

MTU1500 ,所以总 DMA 物理内存估算,计算该网卡的 DMA 内存总占用:

  • 接收缓冲区:2048 个 × 1538 字节 ≈ 3.07 MB;
  • 发送缓冲区:2048 个 × 1538 字节 ≈ 3.07 MB;

总计:约 6.14 MB(实际可能略大,因驱动会预留少量管理内存)。

RingBuffer 满的时候,新来的数据包将被去弃。使⽤ iconfig 命令查看⽹卡的时候,可以看到⾥⾯有个overruns

1
2
3
4
[root@liruilongs.github.io]-[~]$ ifconfig enp3s0 | grep over
RX errors 0 dropped 0 overruns 0 frame 0
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
[root@liruilongs.github.io]-[~]$

表⽰因为环形队列满被丢弃的包数。如果发现有丢包,可能需要通过ethtool命令来加⼤环形队列的长度。

也可以通过 ethtool -S enp3s0rx_queue_0_drops 指标来查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[root@liruilongs.github.io]-[~]$ ethtool -S enp3s0 
NIC statistics:
rx_queue_0_packets: 49137
rx_queue_0_bytes: 69848865
rx_queue_0_drops: 0
rx_queue_0_xdp_packets: 0
rx_queue_0_xdp_tx: 0
rx_queue_0_xdp_redirects: 0
rx_queue_0_xdp_drops: 0
rx_queue_0_kicks: 0
tx_queue_0_packets: 13662
tx_queue_0_bytes: 935165
tx_queue_0_xdp_tx: 0
tx_queue_0_xdp_tx_drops: 0
tx_queue_0_kicks: 0
[root@liruilongs.github.io]-[~]$

通常很少有修改的需求,队列不是越长越好,过长会增加数据延迟(数据包在队列中等待时间变长),通常调整为 512~2048(根据业务场景:高吞吐量场景可稍大,低延迟场景需偏小)

如果是小数据包,数据包写入完成后,网卡会向 CPU 发送一个硬中断请求(IRQ),相当于告诉 CPU:“有新数据包到了,快处理!”

如果是大数据包,大流量,修改队列长度 2048 时,可能要积累 670 个包才触发一次 NAPI 轮询;若队列长度增至 5242,可能要积累 2048 个包才处理,即位于第一个缓存区的数据包和第2048个缓存区的数据包在同一时间被处理,对第一个缓存区的数据就造成了延迟

这里积累的数据触发机制通过 NAPI 权重(napi weight)来控制,本质是 单次轮询最多处理的数据包上限。当队列中积累的数据包数量达到或接近这个值时,NAPI 会触发处理。 默认是 64

1
2
3
[root@developer ~]# cat /sys/module/virtio_net/parameters/napi_weight
64
[root@developer ~]#

需要说明的是 napi_weight 是 “上限”,不是 “触发阈值”。NAPI 轮询的触发阈值(即积累多少个数据包才会触发一次轮询处理)控制还有一个影响参数,即 内核全局预算(netdev_budget)netdev_budget 限制一次软中断中所有 NAPI 实例处理的数据包总数,即所有的网卡,避免单个软中断占用 CPU 过久。 默认值为 300

1
2
3
[root@developer ~]# sysctl net.core.netdev_budget
net.core.netdev_budget = 300
[root@developer ~]#

权重是针对单个队列的,而预算 netdev_budget限制了一次软中断处理周期内所有NAPI实例总共能处理的数据包数量,防止软中断过长时间占用CPU

第二步:CPU 响应硬中断,触发软中断

CPU 收到硬中断请求后,会暂停当前正在执行的任务,切换到内核态,执行之前注册的硬中断处理函数。但这里有个关键设计:硬中断处理函数非常 “轻量化”,只做两件小事:

  • 告知网卡 “我已收到中断通知”,避免网卡重复触发中断;
  • 向内核发起网络接收软中断请求(NET_RX_SOFTIRQ),将后续的 “数据包解析、协议处理” 等耗时操作,交给软中断处理。

为什么硬中断处理要 “轻量化”?因为硬中断的优先级最高,若处理耗时会阻塞其他高优先级任务(如系统调用、其他硬件中断),影响系统响应性。

/proc/softirqs 文件输出展示了系统中软中断(Software Interrupt)在各个 CPU 核心上的触发次数

1
2
3
4
5
6
7
8
9
10
11
12
13
[root@developer ~]# cat /proc/softirqs 
CPU0 CPU1 CPU2 CPU3
HI: 0 5 1 0
TIMER: 19258 14213 16893 24588
NET_TX: 0 0 2 2
NET_RX: 3559 1626 1311 2046
BLOCK: 15548 0 0 0
IRQ_POLL: 0 0 0 0
TASKLET: 1681 225 164 1710
SCHED: 23419 20707 23057 31402
HRTIMER: 0 0 0 0
RCU: 33341 32536 31459 34107
[root@developer ~]#

NET_TX 和 NET_RX 分别是 “发送” 和 “接收” 对应在每个CPU核心的软中断,分别对应 “发送” 和 “接收” 数据包的处理。

第三步:ksoftirqd 处理软中断,读取数据包

ksoftirqd 线程会周期性检查内核的软中断请求队列,当发现 NET_RX_SOFTIRQ 软中断时,会立即开始工作:

  • 关闭硬中断:避免处理软中断时被新的硬中断打断(防止数据竞争,保证数据一致性);
  • 调用 poll 函数读包:通过驱动注册的poll函数,从 RX 队列的 RingBuffer 中读取数据包,并将其封装成内核统一的sk_buff结构体(sk_buff是内核中描述数据包的 “标准容器”,包含数据包的所有信息,如协议头、数据内容、长度等);
  • 开启硬中断:数据包读取完成后,重新开启硬中断,允许接收新的数据包。

软中断处理时,单次调用 NAPIpoll 函数,循环处理数据包(数量不超过 weightnetdev_budget 的最小值),若本次读了阈值内的包(packets_read=64),即使 RX_Ring 还有数据,也会退出循环,避免单次 poll 占用 CPU 过久。

通过 proc/interrupts 可以看到 虚拟网卡(virtio3)的 数据请求队列中断(req.0 表示第 0 个数据队列,负责网卡的数据包接收 / 发送)

1
2
[root@developer ~]# cat /proc/interrupts | grep req
81: 16363 0 0 0 ITS-MSI 2097153 Edge virtio3-req.0

对应的中断亲和性,即中断发生在那个CPU,可以通过中断号查看,smp_affinity(中断亲和性)控制 “该中断允许在哪些 CPU 核心上运行”,值为十六进制:

1
2
3
[root@developer ~]# cat /proc/irq/81/smp_affinity
f
[root@developer ~]#

十六进制 f = 二进制 1111,对应系统的 4 个 CPU 核心(CPU0~CPU3)。

这一步完成了 “从硬件缓冲区到内核缓冲区” 的数据包转移,为后续的协议解析做好准备。

通过 top 命令可以看到 每个CPU 核心的 软中断(si)/硬中断(hi)的使用率

1
2
3
4
5
6
7
8
top - 16:03:56 up  3:37,  1 user,  load average: 0.00, 0.00, 0.00
Tasks: 228 total, 1 running, 227 sleeping, 0 stopped, 0 zombie
%Cpu0 : 0.0 us, 0.0 sy, 0.0 ni,100.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu1 : 0.0 us, 0.0 sy, 0.0 ni,100.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu2 : 0.3 us, 0.3 sy, 0.0 ni, 99.3 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu3 : 0.3 us, 0.0 sy, 0.0 ni, 99.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 6657.9 total, 5398.1 free, 734.6 used, 700.4 buff/cache
MiB Swap: 4096.0 total, 4096.0 free, 0.0 used. 5923.4 avail Mem

第四步:协议栈解析,分发到应用层

poll函数将sk_buff交给内核协议栈后,协议栈会按 “从下到上” 的顺序逐层解析数据包,就像 “拆快递” 一样,一层一层揭开数据包的 “包装”:

  1. IP 层处理:调用ip_rcv()函数解析 IP 头,检查 IP 地址、校验和等信息,然后根据 IP 头中的protocol字段,判断数据包的上层协议(如 UDP 对应 17,TCP 对应 6);
  2. 传输层处理
    • 若为 UDP 包:调用udp_rcv()函数解析 UDP 头,检查端口号,然后通过 socket 将数据分发给对应的应用进程;
    • 若为 TCP 包:调用tcp_rcv_v4()函数解析 TCP 头,处理 TCP 连接状态(如三次握手、重传、流量控制),最后将数据通过 socket 交给应用进程,这里会进行一次数据拷贝。

至此,一个数据包从 “到达网卡” 到 “被应用进程接收” 的完整流程就结束了,需要注意的是在收包过程中,一共进行两次的数据拷贝:

  • 网卡写入 DMA 的数据会通过驱动注册的poll函数,从 RX 队列的 RingBuffer 中读取数据包,并将其封装成内核统一的sk_buff结构体这是一次
  • 内核的数据包要交付给用户态进程时,数据会从内核的 socket 接收缓冲区拷贝到用户空间,这是第二次拷贝

内核发包机制认知

网络发包的核心逻辑是 用户进程发起请求,内核协议栈分层处理,最终通过网卡硬件发送数据

深入理解Linux网络: 修炼底层内功,掌握高性能原理

一、发包前的准备工作:发送队列 RingBuffer 的构建

发包同样是一个涉及用户态、内核态多层协作的复杂过程,在实际的发包之前,内核会在网卡启动之后也做一些发包相关的准备,主要是前面我们讲到的分配传输队列 RingBuifer的过程。

不同的网卡驱动实现不同,会分配两个环形数组

  • 一个用于内核使用(分配虚拟内存)
  • 一个用于网卡硬件使用,分配的是连续的物理内存(DMA)

在后面的发包过程中会进行对应的地址映射,这两个环形数组中相同位置的指针都将指向同⼀个skb(数据包的内核结构体),内核往对应 skb 地址写数据,网卡硬件就能共同访问同样的数据,负责发送。

二、用户进程发起请求,内核协议栈分层处理

1.用户进程:发起发送请求

用户进程通过 sendto系统调用(或 send/write)发起网络发送,下面的是一个Python 的Demo,从 Python 标准库的 socket 模块封装,最终触发操作系统的 send 系统调用

1
2
3
4
5
6
7
8
9
10
11
12
13
import socket
# 创建TCP socket(内核创建对应的socket对象)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 建立连接(内核完善socket对象的源/目标地址信息)
sock.connect(("110.242.69.21", 80))
# 准备发送数据
request_data = ()
# 调用send系统调用(内核查找socket对象并构造msghdr结构体)
bytes_sent = sock.send(request_data.encode())
# 接收响应
response = sock.recv(4096)
# 关闭连接(内核销毁socket对象)
sock.close()

此时进程从用户态切换到内核态,内核会执行两项核心操作:

  • 查找对应的 socket 对象(标识 TCP/UDP 连接的源 IP、目标 IP、源端口、目标端口);
  • 构造 msghdr 结构体(封装待发送消息的地址信息、数据长度等元数据)。

之后会进入内核网络协议栈分层处理

2.协议栈分层处理(内核态):从传输层到网络设备

内核态的协议栈处理遵循 TCP/IP 分层模型,从传输层到网络层、邻居子系统、网络设备子系统,逐步完成数据封装与转发。

传输层(TCP 处理)

首次内存拷贝与浅拷贝,内核调用 tcp_sendmsg 函数处理传输层逻辑,核心操作包括:

  • 申请skb(Socket Buffer,内核中存储网络数据的缓冲区),并将用户态数据拷贝到skb;
  • 进行滑动窗口管理(控制数据发送速率,避免对端接收缓冲区溢出);
  • 设置 TCP 头部(如源端口、目标端口、序列号、确认号等,保证 TCP 可靠传输)

这一步会涉及两次关键的内存拷贝操作:

  1. 第一次拷贝(必需,深拷贝):内核先申请 skb(Socket Buffer,内核中存储网络数据的专用缓冲区),再将用户态传递的数据包拷贝到 skb 中。该拷贝的开销随数据量增大而显著增加;
  2. 第二次拷贝(必需,浅拷贝):为保证 TCP 可靠传输(当对端未返回 ACK 时需重发数据),内核会克隆 skb 的 “描述符”(生成新的 skb 副本),但数据本身复用原 skb 的内存(仅拷贝元信息,开销极低)。

网络层(IP 处理)

内核调用 ip_queue_xmit 函数处理网络层逻辑,核心操作包括:

  • 路由查找:根据目标 IP 地址查询路由表,确定数据发送的下一跳地址
  • 网络过滤:经过 netfilter 框架(如 iptables 规则),判断是否允许数据发送;

MTU 检查与分片:若数据包大小超过网络设备的 MTU(最大传输单元,默认 1500 字节),则触发 IP 分片,申请多个新 skb,将原 skb 中的数据深拷贝到新 skb 中(第三次拷贝,可选)。

结合传输层处理,内核发包的内存拷贝可总结为 “两次必需 + 一次可选”

  • 必需拷贝 1:用户态数据 → 内核态 skb(深拷贝);
  • 必需拷贝 2:传输层 skb → 网络层 skb(浅拷贝,仅描述符);
  • 可选拷贝 3:IP 分片时的深拷贝(仅当数据包超过 MTU 时触发)。

邻居子系统

获取目标 MAC 地址,内核再次调用 ip_queue_xmit(逻辑分支不同),通过 ARP 协议获取目标设备的 MAC 地址:

  • 缓存命中:若本地 ARP 缓存中存在 “目标 IP → MAC 地址” 的映射,直接使用该 MAC 地址;
  • 缓存未命中:发送 ARP 请求包(询问目标 IP 对应的 MAC 地址),待收到 ARP 响应后继续传输。

网络设备子系统

这里网络设备子系统主要用于队列选择和触发软中断

队列选择:内核调用 dev_queue_xmit,根据网卡的多队列配置(若支持),将 skb 放入对应的发送队列(用于负载均衡),谈后调用qdise_run 发送数据,如果当前任然持有CPU时间片,那么会 while循环不断地从队列中取出 skb 并进⾏发送(qdisc_restar 调用)。注意,这个时侯其实都占⽤的是⽤户进程的内核态时间。只有当 quota⽤尽或者其他进程需要CPU的时候才触发 NET_TX_SOFTIRQ类型软中断**(由 ksoftirqd 内核线程处理)。

在这期间,内核会调用网卡驱动程序 dev_hard_start_xmit通过网卡发送数据。

所以为什么说 90% 以上的网络发包开销集中在 “内核态处理阶段”(协议栈解析、内存拷贝),从这里可以看到仅当内核态进程时间片用尽、需由 ksoftirqd 线程继续处理发送队列时,才会触发 NET_TX 软中断,统计到 si(软中断 CPU 时间) 中。

这也是在服务器上查看/proc/softirgs,⼀般 NET_RX 都要⽐ NET_TX ⼤得多的原因之一。对于接收来说,都要经过 NET_RX 软中断,⽽对于发送来说,只有内核态CPU配额⽤尽才让软中断上。

3.软中断与硬中断:内核与硬件的异步协作

到这里以后发送数据消耗的CPU就都显⽰在软中断的CPU使用率里面,不会消耗⽤户进程的内核态时间。

软中断处理ksoftirqd 内核线程(每个 CPU 核心对应一个)检测到 NET_TX 软中断后,调用 net_tx_action 函数,再通过 qdisc_run 调度发送队列(如 FIFO、优先级调度),将 skb 提交给网卡驱动 dev_hard_start_xmit 调用;

硬中断处理:网卡完成数据发送后,触发硬中断(如 igb_msix_ring 函数,因网卡驱动而异),通知内核释放 skb 内存、清理 RingBuffer,为下一次发送做准备。这里的硬中断会触发 NET_RX_SOFTIRQ 软中断,即网卡的 “发送完成通知” 与 “接收数据” 触发的硬中断,最终都会调用 NET_RX_SOFTIRQ(而非 NET_TX_SOFTIRQ),导致 “发送完成” 的开销被统计到 NET_RX 中;这也是上面看到 NET_RX 的 CPU 使用率要比 NET_TX 大的原因。

三、网卡驱动的数据包处理

下面我们看下网卡驱动如何发送数据包,在上面的网络设备子系统中dev_hard_start_xmit调用驱动,驱动衔接硬件

  1. 入口:内核调用驱动,通过dev_hard_start_xmit函数,调用驱动注册的ndo_start_xmit回调(如igb网卡的igb_xmit_frame),完成“内核到驱动”的交接。
  2. 驱动绑定与DMA映射(igb网卡为Demo) igb_xmit_frame先将skb分配到对应发送队列,再通过igb_xmit_frame_ringskb绑定到RingBuffer(环形缓冲区)的igb_tx_buffer;随后igb_tx_map通过dma_map_single,将skb虚拟地址转成硬件可访问的物理地址,写入硬件描述符。
  3. 触发硬件发送 驱动更新网卡寄存器(如E1000_TDT),通知硬件读取描述符中的物理地址,通过DMA直接读内存数据并发送,无需CPU参与,整个流程核心是“DMA映射”和“RingBuffer衔接”,实现软件到硬件的高效数据传递。

博文部分内容参考

© 文中涉及参考链接内容版权归原作者所有,如有侵权请告知 :)


https://blog.csdn.net/wll1228/article/details/121311707

《深入理解Linux网络: 修炼底层内功,掌握高性能原理 (张彦飞)》


© 2018-至今 liruilonger@gmail.com, 保持署名-非商用-相同方式共享(CC BY-NC-SA 4.0)

发布于

2025-10-06

更新于

2025-10-22

许可协议

评论
Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×