一.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直接瘫痪。
硬中断处理程序主要完成两件事情:
- 屏蔽当前RX队列的中断通知。
- 把该队列对应的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,都在这一刻被唤醒。