网卡把数据写入内存,内核通过 NAPI 取出数据,再经过链路层、IP 层、TCP/UDP 层逐层解封装,最后放入 Socket 接收缓冲区并唤醒应用。
一、完整流程
网线、光纤或无线信号
→ 网卡
→ DMA
→ RX Ring Buffer
→ 硬件中断
→ NAPI + 软中断
→ 网卡驱动取包
→ sk_buff
→ 网络接口层
→ IP层
→ TCP/UDP层
→ Socket接收缓冲区
→ recv()
→ 用户程序
这正是文章给出的 Linux 收包主线。
1. 网卡接收到数据帧
网卡收到的不是一段单纯的应用数据,而是一个完整的数据帧:
| 以太网头 | IP头 | TCP头 | 应用数据 | FCS |
网卡首先进行一些硬件处理,例如:
判断目标 MAC 是否应该接收
校验以太网帧
进行校验和卸载
根据 RSS 选择接收队列
将数据写入对应缓冲区
驱动初始化网卡时,已经创建了 RX Ring。Ring 中的描述符大致记录:
这个接收缓冲区在哪块内存
这个缓冲区能够存放多少数据
当前是否已经被网卡使用
2. 通过 DMA 写入内存
网卡通常不会让 CPU 逐字节复制数据,而是通过 DMA 把数据直接写入驱动预先准备的内存:
网卡
→ DMA
→ 内存中的接收缓冲区
写完以后,网卡更新 RX Ring 中的描述符,例如标记:
这个位置已经收到一个长度为 1500 字节的帧
因此,RX Ring 本身主要是描述符组成的环形队列,真正的数据通常位于描述符指向的内存页或缓冲区中。文章将其概括为"网卡通过 DMA 把网络包写入 Ring Buffer"。
3. 网卡触发硬件中断
数据已经进入内存,但操作系统还不知道,于是网卡触发硬件中断:
网卡 → CPU:有新的数据到了
CPU 暂停当前工作,找到该网卡注册的中断处理函数。
硬中断处理函数不会完整解析 TCP/IP,因为硬中断上下文应尽快结束。它主要完成:
确认中断
→ 暂时屏蔽或抑制该队列的中断
→ 调用 napi_schedule()
→ 安排后续轮询处理
NAPI 是 Linux 网络栈使用的事件处理机制。设备先通过中断通知主机,主机再调度 NAPI,通过驱动的 poll 方法批量处理数据。
4. NAPI 和软中断批量取包
如果每收到一个包都产生一次完整中断,高流量时 CPU 会频繁进入硬中断,效率很低。
NAPI 采用:
第一次有包:中断通知
后续一批包:主动轮询
典型过程是:
硬中断
→ 调度 NET_RX_SOFTIRQ
→ 执行 NAPI poll
→ 从 RX Ring 批量读取多个数据包
NAPI 的 poll 方法有一个 budget,限制一次最多处理多少个接收包:
还有包,并且达到 budget
→ 以后继续 poll,不立即恢复中断
所有包处理完毕
→ napi_complete_done()
→ 重新启用网卡中断
这种方式把多次硬中断合并成一次通知加批量处理。官方文档指出,NAPI 处理通常运行在软中断上下文,并且中断一般保持屏蔽到轮询完成。
文章把软中断处理统一描述成由 ksoftirqd 完成,这是便于理解的简化。实际软中断也可能在硬中断返回过程中直接执行;只有处理时间过长或任务积压时,才更可能交给 ksoftirqd。
5. 驱动构造 sk_buff
驱动从 RX Ring 取出描述符后,会把收到的数据交给 Linux 网络栈,并使用:
struct sk_buff
也就是 skb 来描述它。
sk_buff 是 Linux 表示网络包的主要结构,但结构体本身主要保存元数据,真正的数据位于关联的内存缓冲区中。
概念上:
sk_buff
├── 数据起始位置
├── 数据长度
├── 接收网卡
├── 协议类型
├── 校验和状态
└── 指向实际数据的指针
驱动可能调用类似:
napi_gro_receive()
→ netif_receive_skb()
把 skb 交给上层网络协议栈。
这里还可能执行 GRO,将同一 TCP 流的多个小包暂时合并成一个较大的 skb,减少协议栈处理次数。
6. 网络接口层解析以太网帧
进入网络接口层后,内核检查:
帧是否合法
目标 MAC 是否匹配
上层协议是什么
以太网头中的 EtherType 可以表示:
0x0800 → IPv4
0x86DD → IPv6
0x0806 → ARP
确定是 IPv4 后,逻辑上剥离以太网头:
接收前:
| MAC头 | IP头 | TCP头 | 数据 |
剥离后:
| IP头 | TCP头 | 数据 |
↑
skb->data
所谓"剥离"通常不需要复制全部数据,而是向后调整 skb->data 指针。文章正是用这个指针移动解释各层之间如何避免重复复制。
7. IP 层判断包往哪里走
IPv4 数据会进入类似下面的路径:
ip_rcv()
→ Netfilter PRE_ROUTING
→ 路由判断
IP 层检查:
IP 版本和首部长度
IP 头校验和
数据包长度
目标 IP
分片是否需要重组
上层是 TCP、UDP 还是 ICMP
随后查询路由,判断数据包的归宿:
目标是本机
→ 交给本机传输层
目标不是本机并允许转发
→ 进入 FORWARD 路径
没有路由或不允许转发
→ 丢弃
本机数据通常经过 PRE_ROUTING 和 LOCAL_IN;转发数据则经过 PRE_ROUTING、FORWARD 和 POST_ROUTING
8. TCP 根据四元组寻找 Socket
如果 IP 头中的协议字段是 TCP,内核把数据交给 TCP:
ip_local_deliver()
→ tcp_v4_rcv()
对于已经建立的 TCP 连接,内核使用四元组查找 Socket:
源 IP
源端口
目标 IP
目标端口
例如:
客户端:10.0.0.10:53000
服务器:10.0.0.20:8080
对应的连接是:
10.0.0.10:53000
→ 10.0.0.20:8080
TCP 接下来会:
校验 TCP 报文
检查序列号
丢弃重复数据
暂存乱序数据
重组连续字节流
处理 ACK
更新接收窗口
必要时发送 ACK
将数据加入 Socket 接收队列
文章将这一步概括为:传输层根据四元组找到对应 Socket,然后把数据放入 Socket 接收缓冲区
9. 如果收到的是 TCP 握手包
如果收到的是一个新的 SYN,而不是已有连接的数据:
客户端 → 服务器:SYN
内核会根据目标 IP 和目标端口找到监听 Socket:
0.0.0.0:8080
然后:
创建半连接状态
→ 返回 SYN + ACK
→ 收到最终 ACK
→ 建立完整连接
→ 放入 accept 队列
应用调用:
accept(listen_fd, ...);
就能取得这个已连接 Socket。后续数据包再通过四元组查找对应的连接 Socket
10. 数据进入 Socket 接收缓冲区
TCP 将整理好的数据加入:
Socket Receive Buffer
此时数据已经从:
以太网帧
→ IP 数据包
→ TCP 报文段
→ TCP 字节流
如果应用正在阻塞等待:
recv(fd, buf, size, 0);
内核会唤醒这个进程。应用再次获得 CPU 后,将数据从 Socket 接收缓冲区复制到用户空间:
内核 Socket 接收缓冲区
→ 用户程序 buffer
然后 recv() 返回实际读取的字节数。这是文章所说的接收路径中的一次主要数据复制