数据包到达网卡后到用户态应用程序的全流程

一.DMA将数据写入ringbuffer

ringbuffer是与操作系统内核共享的内存区域

首先,网卡被启动时,驱动会先为网卡准备好"收货区":

  • 分配一批可用于接受数据包的内存缓冲区
  • 把这些内存映射成网卡能够访问的DMA(直接内存访问地址)地址
  • 建立一个RX Descriptor Ring(接收描述符环)
  • 把DMA地址写入每个RX描述符
  • 通知网卡这些描述符已经可用

然后,数据包到达网卡,网卡硬件完成信息解码,以太网帧接受,CPC校验,MAC地址过滤,RSS队列选择等

前导码 | SFD | 目的MAC | 源MAC | 类型/长度 | 数据和填充 | FCS

7字节 1字节 6字节 6字节 2字节 46~1500 4字节

如果开启了RSS,网卡会根据数据包的五元组(源IP、目标IP、源端口、目标端口、协议)计算哈希,然后选择某个RX队列 ,现代多队列网卡通常让不同 RX 队列对应不同 CPU,以便并行处理。后续屏蔽硬件中断可以只屏蔽某个RX队列的中断。

最后,网卡通过DMA写入内存

目的MAC | 源MAC | 类型/长度 | 数据和可能的填充

字段 通常会 DMA 到内存吗? 原因
前导码 Preamble 只用于 PHY/MAC 同步
SFD 只用于标记帧起点
目的 MAC Linux 需要处理二层协议
源 MAC Linux、网桥、抓包等需要
类型/长度 用来判断 IPv4、IPv6、ARP 等
VLAN Tag 取决于硬件卸载 可以保留,也可以剥离到描述符元数据
数据 Payload 这是主要数据内容
填充 Padding 通常是 通常无法与有效 Payload 完全区分
FCS/CRC 通常否 网卡校验后通常剥离
帧间间隔 IFG 它根本不属于以太网帧内容

二.网卡硬件中断通知CPU

DMA完成后数据已经位于内存中,但CPU并不知道某个RX描述符已经被网卡填写完毕, 然后网卡会通过硬件中断通知cpu,现代多队列网卡通常使用MSI-X,让每个RX/TX队列拥有相对独立的中断向量。中断具体由哪个CPU处理,取决于IRQ Affinity配置:

  • 中断向量(interrupt vector) :CPU使用的入口编号,用来索引中断处理函数。x86上通常是0~255。

  • IRQ号 :Linux内核为中断源分配的逻辑编号,可在/proc/interrupts中看到。

  • MSI-X Vector:网卡的一条MSI-X中断通道,Linux会为它建立IRQ,并最终映射到CPU中断向量。

MSI-X、IRQ和中断向量的关系 三者位于不同层次:

概念 所属层次 作用
MSI-X PCIe设备的中断通知机制 设备通过内存写消息触发中断
IRQ Linux内核的逻辑编号 标识并管理一个中断源
中断向量 CPU层面的入口编号 决定CPU进入哪个底层中断入口

整体关系可以简化为:

css 复制代码
网卡队列
   ↓
MSI-X Table Entry
   ↓
Linux IRQ
   ↓
目标CPU上的中断向量
   ↓
Linux底层中断入口
   ↓
网卡驱动中断处理函数

1. MSI-X是什么

MSI-X是PCI/PCIe设备使用的一种中断通知机制。

传统设备通过拉高中断引脚通知中断控制器,而MSI-X通过执行一次特殊的内存写操作通知CPU:

sql 复制代码
传统中断:

设备 → 中断引脚 → IO-APIC → CPU

MSI-X:

设备 → 写入MSI-X消息 → Local APIC → CPU

MSI-X中的"Memory"指的是这种中断表现为一次内存写事务,并不意味着设备真的把普通业务数据写入某块RAM。

MSI-X支持多个独立的Table Entry。每个Entry保存一组中断消息配置:

css 复制代码
MSI-X Table Entry
├── Message Address
├── Message Data
└── Vector Control

其中:

  • Message Address指定中断消息发送到哪个中断控制器及目标CPU。
  • Message Data中包含CPU需要接收的中断向量等信息。
  • Vector Control用于屏蔽或开启该Entry。

网卡不会自行决定这些值。Linux在初始化设备和分配IRQ时计算相应配置,然后写入网卡的MSI-X Table。

2. 一个网卡队列如何对应MSI-X

以包含四个接收队列的网卡为例,驱动可能申请五个MSI-X Entry:

复制代码
MSI-X Entry 0 → RX/TX Queue 0
MSI-X Entry 1 → RX/TX Queue 1
MSI-X Entry 2 → RX/TX Queue 2
MSI-X Entry 3 → RX/TX Queue 3
MSI-X Entry 4 → 链路状态、错误等管理事件

当Queue 2需要通知CPU时,网卡使用Entry 2中配置的地址和数据发送MSI-X消息:

markdown 复制代码
RX Queue 2收到数据
        ↓
DMA写入数据缓冲区
        ↓
更新RX Descriptor
        ↓
读取MSI-X Entry 2的配置
        ↓
发出MSI-X写消息
        ↓
目标CPU收到中断

一个RX队列独占一个MSI-X Entry是常见配置,但不是强制要求。实际网卡可能:

  • RX和TX共用一个MSI-X Entry。
  • 多个队列共享一个MSI-X Entry。
  • RX队列和TX队列分别使用不同Entry。
  • 单独保留一个Entry处理管理事件。

3. MSI-X Entry与Linux IRQ

驱动向Linux申请MSI-X中断资源:

scss 复制代码
pci_alloc_irq_vectors(
    pdev,
    min_vectors,
    max_vectors,
    PCI_IRQ_MSIX
);

Linux为成功分配的MSI-X Entry创建对应的逻辑IRQ:

复制代码
MSI-X Entry 0 → Linux IRQ 120
MSI-X Entry 1 → Linux IRQ 121
MSI-X Entry 2 → Linux IRQ 122
MSI-X Entry 3 → Linux IRQ 123
MSI-X Entry 4 → Linux IRQ 124

驱动随后为这些IRQ注册处理函数:

perl 复制代码
request_irq(120, nic_queue_irq, 0, "eth0-q0", &queue0);
request_irq(121, nic_queue_irq, 0, "eth0-q1", &queue1);
request_irq(122, nic_queue_irq, 0, "eth0-q2", &queue2);
request_irq(123, nic_queue_irq, 0, "eth0-q3", &queue3);

request_irq(124, nic_admin_irq, 0, "eth0-admin", adapter);

不同队列可以使用相同的处理函数,但携带不同的上下文参数:

perl 复制代码
IRQ 120 ── nic_queue_irq(120, &queue0)
IRQ 121 ── nic_queue_irq(121, &queue1)
IRQ 122 ── nic_queue_irq(122, &queue2)
IRQ 123 ── nic_queue_irq(123, &queue3)

所以IRQ负责表达:

复制代码
这是Linux管理的哪一个中断源?

4. Linux IRQ与CPU中断向量

Linux还要把每个IRQ分配到一个目标CPU,并为它分配一个CPU中断向量。

假设分配结果如下:

复制代码
Linux IRQ 120 → CPU 0 → Interrupt Vector 0x51
Linux IRQ 121 → CPU 1 → Interrupt Vector 0x52
Linux IRQ 122 → CPU 2 → Interrupt Vector 0x53
Linux IRQ 123 → CPU 3 → Interrupt Vector 0x54

Linux根据这个结果生成MSI-X消息配置,并将其写入网卡:

复制代码
MSI-X Entry 0
├── 目标CPU:CPU 0
└── CPU Vector:0x51

MSI-X Entry 1
├── 目标CPU:CPU 1
└── CPU Vector:0x52

当网卡使用MSI-X Entry 0发出消息后:

perl 复制代码
网卡发出MSI-X消息
        ↓
消息被发送到CPU 0
        ↓
CPU 0收到Vector 0x51
        ↓
进入Vector 0x51对应的底层入口
        ↓
Linux定位到IRQ 120
        ↓
调用nic_queue_irq(120, &queue0)

5. 完整示例

假设数据包被RSS分配到RX Queue 1:

perl 复制代码
数据包到达网卡
        ↓
RSS计算结果选择RX Queue 1
        ↓
DMA写入Queue 1的接收缓冲区
        ↓
网卡更新RX Descriptor
        ↓
网卡使用MSI-X Entry 1发出中断消息
        ↓
CPU 1收到中断向量0x52
        ↓
CPU进入对应的底层中断入口
        ↓
Linux识别出IRQ 121
        ↓
调用nic_queue_irq(121, &queue1)
        ↓
屏蔽MSI-X Entry 1对应的队列中断
        ↓
调度Queue 1的NAPI
        ↓
NAPI批量处理RX Queue 1

可以把它浓缩为:

perl 复制代码
RX Queue 1
    ↓
MSI-X Entry 1
    ↓
MSI-X Message:目标CPU 1、Vector 0x52
    ↓
CPU 1的Interrupt Vector 0x52
    ↓
Linux IRQ 121
    ↓
nic_queue_irq(..., &queue1)
    ↓
Queue 1对应的NAPI

三、NAPI调度,软中断处理

网卡通过MSI-X触发硬件中断后驱动不会在硬中断上下文中处理所有数据包,而是调度对应的NAPI实例,将主要工作延后到NET_RX_SOFTIRQ软中断中完成。

网卡每秒可能受到成千上万的数据包,每个数据包都触发硬件中断,会形成中断风暴,cpu时间片会被中断占满,因为中断上下文是不能被打断调度的。 举个例子:10G网卡满速运行每秒收取1500万个包,触发1500万次中断,cpu直接瘫痪。

硬中断处理程序主要完成两件事情:

  1. 屏蔽当前RX队列的中断通知。
  2. 把该队列对应的NAPI加入当前CPU的轮询列表。

CPU的轮询列表是linux内核为每个cpu维护的内核数据结构,每个cpu都有一个softnet_data,softnet_data中包含一个poll_list

例如:

markdown 复制代码
CPU 0的softnet_data
└── poll_list
    ├── eth0 Queue 0的NAPI
    └── backlog NAPI

CPU 1的softnet_data
└── poll_list
    └── eth0 Queue 1的NAPI

什么时候进行轮询?

时机 执行者
硬中断退出时 当前CPU直接执行
重新启用Bottom Half时 当前进程上下文顺带执行
软中断积压或超过预算时 ksoftirqd/N内核线程执行
在进程上下文主动调用软中断处理接口时 当前CPU直接执行
后续其他硬中断退出时 当前CPU补充执行此前遗留的软中断

软中断poll处理流程:

当网卡中断调度NAPI后,对应NAPI被加入当前CPU的softnet_data.poll_list,同时设置NET_RX_SOFTIRQ待处理标志。

随后,在硬中断退出阶段,内核检查并执行待处理的NET_RX_SOFTIRQ。软中断通过net_rx_action()调用NAPI的poll()函数,批量处理RX Ring中的数据包。为了避免CPU长时间被软中断占用,NAPI Poll有单次处理配额,NET_RX_SOFTIRQ整体也有数据包数量和执行时间限制。如果达到数量或时间限制,而RX Ring中仍然有数据,NAPI会保持调度状态,内核重新触发NET_RX_SOFTIRQ,在后续轮次继续处理。如果软中断持续积压,后续工作可能由当前CPU对应的ksoftirqd/N内核线程处理。直到RX Ring被清空,驱动才完成NAPI并重新开启该队列的硬件中断。

可以简化成流程:

scss 复制代码
硬中断退出
    ↓
执行NET_RX_SOFTIRQ
    ↓
net_rx_action()调用NAPI Poll
    ↓
批量处理RX Ring
    ↓
达到包数或时间限制?
    ├── 是,Ring仍有数据
    │      ↓
    │   保持NAPI调度状态
    │      ↓
    │   重新触发NET_RX_SOFTIRQ
    │      ↓
    │   必要时由ksoftirqd/N继续处理
    │
    └── 否,Ring已经清空
           ↓
        完成NAPI
           ↓
        重新开启队列中断

四、GRO合并,协议栈处理

poll函数中,会从per-CPU缓存分配sk_buff(比全局内存分配器快),会预先尝试GRO合并再上送; sk_buff数据结构让协议栈每层处理,不是拷贝数据而是移动指针,本质:skb_pull = skb -> data += len

sk_buff结构体:

arduino 复制代码
struct sk_buff {
    struct sock *sk;         // 关联 socket
    struct net_device *dev;  // 来源网卡

    // [ARCH] 协议栈每层移动这些指针
    unsigned char *head;     // 缓冲区起始
    unsigned char *data;     // 当前层数据起始
    unsigned char *tail;     // 数据结束
    unsigned char *end;      // 缓冲区结束

    // [ARCH] L2/L3/L4 头的位置偏移
    __u16 transport_header;  // L4 头
    __u16 network_header;    // L3 头
    __u16 mac_header;        // L2 头

    __be16 protocol;         // 以太网协议
    unsigned int len;        // 数据总长度
};

GRO是收包端的合并优化,如果连续收到的几个小包属于同一个tcp流,并且是连续的,GRO会将其合并成一个sk_buff,将多个协议栈处理优化成一个。 接下来进入协议栈处理:

__netif_receive_skb_core函数做了两件事

  • 把包递给ptype_all注册的所有处理器(tcptump就是在这里抓包的,早于防火墙的iptables过滤,所以能抓到iptables丢掉的包)
  • 根据包的protocol字段分发到对应的L3处理器(IPV4包走ip_rcv) 然后进入IP层: ip_rcv先做基础校验(ip版本对不对,头部检验和对不对),校验通过后,到达关键点,Netfilter的PRE_ROUTING hook,iptables的PREROUTIN规则就是在这里生效的,conntrack连接追踪也是在这里建立。

Netfilter在IP层插入了两个拦截点:PRE_ROUTING在路由查找之前,所有进入的包都会过这一关;ip_route_input_noref查询路由表,找到匹配的路由条目(这里有个重要优化------路由缓存),路由查找决定这个包是发给本机的还是需要转发的;路由决策后,本地包走ip_local_deliver,到达第二个拦截点LOCAL_IN------这就是iptables INPUT链的位置,你平时写的防火墙规则大部分在这里生效。每个hook点都意味着链表遍历和函数调用,规则越多越慢,这也是XDP和eBPF出现的原因------在Netfilter之前就做决策。

这里有个重要优化------路由缓存。第一个包需要走完整的路由表查找,但后续同一个流的包会命中缓存,查找时间从微秒级降到纳秒级。对于服务器收包来说,绝大多数包都是发给本机的,走ip_local_deliver继续上送。

到了传输层,tcp_v4_rcv是TCP收包的入口。> 第一步是根据源IP、目的IP、源端口、目的端口这四元组做哈希查找,找到对应的socket,找不到就发RST拒绝;找到了就看socket状态,如果是ESTABLISHED状态,就走快速路径tcp_rcv_established,绝大多数数据传输的包都走这条快速路径。tcp_rcv_established的快速路径有两个条件:TCP头里没有额外选项,并且序列号和预期的下一个序列号完全一致。满足这两个条件,意味着这是一个正常的、按序到达的数据包,可以跳过乱序处理、重传检测等所有复杂逻辑,直接把数据放入接收队列------这个优化让TCP在正常传输时的开销降到最低。看代码,快速路径命中后,先用skb_pull剥掉TCP头,然后tcp_queue_rcv把sk_buff挂到socket的接收队列上,紧接着tcp_data_ready唤醒所有在等待这个socket数据的进程------不管你是用阻塞的read,还是epoll,都在这一刻被唤醒。

相关推荐
程序猿阿越15 分钟前
kubelet源码阅读
后端·kubernetes·源码阅读
烂蜻蜓1 小时前
Flask入门教程(九):模板渲染——用Jinja2构建动态页面
后端·python·flask
QQ_21696290961 小时前
【源码编号:project93375】SpringBoot汽车维修管理信息系统:客户车辆、维修预约、工单派发、配件结算全流程实战
java·spring boot·后端·汽车·springboot·需求分析
gis开发之家2 小时前
Spring Boot 4 深度解析,JdbcTemplate 实战——轻量级数据库操作方案
java·数据库·spring boot·后端
2601_962063974 小时前
Spring Boot拦截器(Interceptor)详解
java·spring boot·后端
明月_清风4 小时前
GitHub Actions 从入门到实战:一文搞懂 CI/CD 自动化
后端·ci/cd·github
foggyprojects5 小时前
AI 问数也要做行级权限:让 Codex 给销售查询加上权限
后端
千里码aicood6 小时前
flask基于数字人的储粮知识问答原型系统研究与实现
后端·python·flask