网络数据从网卡到用户态:一场基于内存所有权转移的接力赛

网络数据从网卡到用户态:一场基于内存所有权转移的接力赛

核心本质(先看结论)

整个网络收包过程,本质上不是"数据的复制",而是"内存块所有权"在三个层级之间的交接:

  1. 硬件层:网卡通过 DMA 把数据塞进预先借好的物理内存(RX Ring)。
  2. 内核层 :驱动把这块物理内存"包装"成 sk_buff,然后通过链表指针转移,依次交给协议栈、Socket 接收队列。
  3. 用户层 :系统调用把内核内存的内容拷贝到用户态虚拟内存(这是唯一一次真正的数据拷贝)。

关键洞察 :在进入用户态之前,数据本身(那片物理内存页)始终没有移动过 ,移动的是指向它的指针(sk_buff它的逻辑归属权


第一章:内存的物理结构(必须先讲清楚)

1.1 物理内存的分区

在收包流程开始前,内核已经做好了三块内存准备:

内存区域 分配方式 大小 管理者 用途
RX Ring 描述符数组 dma_alloc_coherent(连续物理内存) 通常 256~4096 个条目 网卡驱动 + 硬件 存储"空白内存页的物理地址"
数据页(Data Pages) __get_free_pages(物理页框) 每个 4KB(或 MTU 对齐) 网卡驱动(预分配) 实际存放网络帧的物理内存
Socket 接收队列 struct sk_buff_head(链表头) 仅结构体本身 Socket 层 存储指向 sk_buff 的链表指针

关键概念 :RX Ring 描述符里存的不是数据,而是数据页的物理地址。网卡 DMA 引擎通过这个地址直接写内存。


第二章:数据流动的本质(分步骤拆解)

阶段一:硬件填充(数据进入物理内存)

核心动作

网卡 MAC 层接收到网络数据包后,将其暂存于网卡内部的 RX_FIFO 缓存中。随后,网卡的 DMA 引擎从 RX Ring 中读取当前指针指向的描述符(Descriptor),提取出预先分配好的物理地址,通过 PCIe 总线将数据包直接写入该地址指向的主机物理内存页中。

执行者

网卡 DMA 控制器(全程独立完成,零 CPU 参与

内存与状态变化

复制代码
1【主机物理内存】
2物理地址 0x1000 ~ 0x14FF:  [空白缓冲区]  →  [以太网帧原始数据]
3
4【RX Ring 描述符状态】
5Descriptor[head]:  
6  {addr: 0x1000, status: READY(0)}  →  {addr: 0x1000, status: DONE(1), pkt_len: 1500}

技术本质

  1. 数据流与控制流的解耦 :RX Ring 仅仅充当"提货单/入库单"的角色,记录"数据该搬到哪"以及"搬运状态"。数据包本身不经过 Ring Buffer,而是由 DMA 根据 Ring 中的物理地址,直接"空降"到主机内存。
  2. 硬件级零拷贝:数据从网卡到主机内存的跨越,完全由硬件(DMA 引擎)在物理层面完成,CPU 在此期间被彻底解放,无需执行任何内存拷贝指令。

阶段二:sk_buff 包装(内核给内存块加管理外壳)

动作 :CPU 响应硬中断,进入软中断(ksoftirqd),驱动调用 netdev_alloc_skb

sk_buff 结构体的核心字段(只列关键):

复制代码
struct sk_buff {
    unsigned char *head;      // 指向内存块起始(包含协议头预留空间)
    unsigned char *data;      // 指向实际数据起始(当前协议层视角)
    unsigned char *tail;      // 指向实际数据结束
    unsigned char *end;       // 指向内存块结束
    unsigned int len;         // 数据长度
    struct sk_buff *next;     // 链表指针(用于挂在队列里)
    struct sk_buff *prev;     // 链表指针
    struct sock *sk;          // 指向所属 Socket(最初为 NULL)
    // ... 
};

关键操作 :驱动并没有拷贝数据 ,而是把 RX Ring 描述符指向的物理内存页,直接赋值给 skb->headskb->data

复制代码
RX Ring 物理地址 0x1000  →  skb->head = 0x1000, skb->data = 0x1000 + 14(跳过 MAC 头)

此时数据所有权:从"硬件(DMA)"转移到"内核驱动"。RX Ring 该描述符被重置,回收再复用。

本质 :给这片物理内存贴上了"标签"(sk_buff 结构体),让内核能够通过结构体指针来操作它,而不需要记住原始物理地址。


阶段三:协议栈处理(指针偏移,数据不动)

动作sk_buff 进入 __netif_receive_skb,随后依次经过 IP 层(ip_rcv)和 TCP/UDP 层(tcp_v4_rcv / udp_rcv)。

数据本身没变,变的是 skb->data 指针

复制代码
原始帧:[MAC头(14)] [IP头(20)] [TCP头(20)] [Payload]
处理阶段 skb->data 指向 操作
驱动层 0x1000(帧起始) 刚生成
链路层(eth_type_trans 0x1000 + 14 跳过 MAC 头,标记协议类型
IP 层(ip_rcv 0x1000 + 14 + 20(IP 头后) 检查校验和、路由查找后偏移
TCP 层(tcp_v4_rcv 0x1000 + 14 + 20 + 20(TCP 头后) 处理序列号、窗口后偏移

本质sk_buff 是一把可移动的"标尺" ,通过移动 data 指针,同一块物理内存可以以不同的"协议层视图"被解析。这是 Linux 网络栈零拷贝处理的核心机制。


阶段四:入队(sk_buff 指针的链表转移)

动作 :传输层找到目标 Socket(struct sock),调用 __skb_queue_tail(&sk->sk_receive_queue, skb)

Socket 接收缓冲区的本质

复制代码
struct sock {
    struct sk_buff_head sk_receive_queue;  // 链表头
    // ...
};

sk_buff_head 就是一个双向链表的头节点:

复制代码
struct sk_buff_head {
    struct sk_buff *next;
    struct sk_buff *prev;
    __u32 qlen;  // 队列长度
};

入队操作

复制代码
之前:skb->next = NULL, skb->prev = NULL
入队后:sk_receive_queue->prev->next = skb; skb->prev = sk_receive_queue->prev;
       sk_receive_queue->prev = skb; skb->next = sk_receive_queue;
       sk_receive_queue->qlen++;
       skb->sk = sk;  // 标记所属 Socket

本质数据所在的物理内存页(0x1000)没移动 ,移动的是 sk_buff 这个结构体在链表中的位置。现在它从"游离态"变成了"Socket 接收队列的成员"。

此时数据所有权:从"内核协议栈"转移到"具体的 Socket"。用户进程可以通过系统调用访问它。


阶段五:唤醒与系统调用

动作 :入队后,内核检查 sk->sk_wq(等待队列),发现有进程在 epoll_wait 上等待,调用 wake_up_interruptible

进程被唤醒后 :调用 recvmsg(sockfd, msg, flags)

系统调用内部

复制代码
// 简化逻辑
SYSCALL_DEFINE3(recvmsg, ...) {
    // 从 sk_receive_queue 头部取出 skb
    skb = __skb_dequeue(&sk->sk_receive_queue);
    
    // 将 skb 指向的数据拷贝到用户态缓冲区
    skb_copy_datagram_msg(skb, 0, msg, skb->len);
    
    // 释放 skb
    kfree_skb(skb);
}

skb_copy_datagram_msg 的本质

复制代码
// 核心是调用 copy_to_user
copy_to_user(user_buf, skb->data, skb->len);

这一步触发 CPU 从内核态(Ring 0)向用户态(Ring 3)的数据拷贝 ,这是整个流程中唯一一次真正搬运数据内容的操作。

拷贝完成后

  • 用户态进程的虚拟地址空间里有了数据的副本。
  • 内核物理内存页(0x1000)被释放回内存池。

此时数据所有权:从"内核 Socket"转移到"用户进程"。


第三章:关键结构的作用与关系总览

为了彻底厘清,这里以表格形式列出所有"容器"及其真正的职能:

结构 类型 存什么 本质作用 数据是否在内部移动
RX Ring struct rx_desc[](数组) 物理地址 + 状态位 硬件和驱动的"交接单"------告诉网卡往哪写,告诉驱动从哪读 否(只存地址)
sk_buff 内核数据结构 指针(head/data/tail/end)+ 元数据(len, 协议偏移)+ 链表指针 数据的"控制面板":用指针而非内容来操作数据 否(只存指针)
Socket 接收队列 struct sk_buff_head(链表头) sk_buff 节点的 next/prev 指针 等待队列 :将属于同一 Socket 的 sk_buff 串联起来 否(只改链表指针)
用户态 Buffer char buf[BUFSIZ](虚拟内存) 拷贝后的纯数据 进程可以直接读写的最终载体 是(从内核拷贝过来)

第四章:为什么这样设计?(性能与抽象)

4.1 为什么用 sk_buff 而不直接传递物理地址?

  • 协议栈分层 :每一层只需要移动 data 指针,就能以不同视图解析同一块内存,避免层层拷贝。
  • 统一接口 :无论是 TCP、UDP 还是原始套接字,都通过 sk_buff 传递,代码复用。

4.2 为什么用环形队列(Ring Buffer)?

  • 硬件友好:环形结构天然适配 DMA 的"生产者-消费者"模式(网卡写,驱动读)。
  • 固定大小:预分配,避免运行时动态申请内存带来的延迟。

4.3 为什么要有 Socket 接收缓冲区?

  • 解耦:协议栈(软中断)和用户进程(系统调用)运行在不同上下文。缓冲区允许软中断全速收包,用户进程慢速消费,中间的排队由缓冲区吸收。
  • 背压(Backpressure):当缓冲区满时,TCP 会自动缩小窗口,通知对端慢点发,这是端到端流量控制的实现基础。

第五章:发送方向的逆向本质

理解了接收,发送就是接收的逆过程,但要注意权责变化

  1. 用户态 → 内核send 系统调用将用户数据拷贝进新分配的 sk_buff第一次拷贝)。
  2. 协议栈封装 :移动 skb->data 指针向前skb_push),给 TCP/IP 头腾位置,填入头部内容。
  3. 入队发送缓冲区sk_buff 挂入 sk->sk_write_queue
  4. Qdisc(流量控制)sk_buff 从发送缓冲区移到 Qdisc 队列,等待调度。
  5. TX Ring 映射 :驱动将 skb->data 的物理地址填入 TX Ring 描述符。
  6. DMA 发送:网卡根据 TX Ring 地址读取内存,发到网线。
  7. 发送完成中断 :释放 sk_buff

本质:发送方向是"先拷贝进内核,再通过指针交接交给硬件",和接收方向正好镜像。


第六章:常见性能问题与根因分析

现象 根因(从数据流角度) 解决方案
ifconfig 显示 RX dropped RX Ring 描述符被占满,新包到达时无可用的空白描述符 ethtool -G eth0 rx 4096 加大 Ring
软中断(si)占用单核 100% 所有中断都打在 CPU0,未启用 RSS 多队列 设置 IRQ Affinity,开启网卡多队列(RSS)
应用 recv 延迟高 Socket 接收队列中 sk_buff 积累过多,但 CPU 忙于软中断,未及时唤醒用户进程 调整 net.core.rmem_default,或使用 SO_BUSY_POLL
TCP 吞吐量上不去 发送缓冲区(sk_write_queue)满,send 阻塞,原因是 TX Ring 太小或 Qdisc 限速 调整 net.ipv4.tcp_wmem,检查 TC 规则

结语

理解网络数据流,关键不在于记住"先干什么再干什么",而在于理解内存所有权的转移路径

  1. 数据在物理内存页中永恒不变。
  2. 控制权通过 RX Ring 描述符sk_buff 指针Socket 链表指针 逐级转移。
  3. 最终通过一次系统调用拷贝,从内核空间"复制"到用户空间,完成所有权从内核到进程的移交。
相关推荐
名字还没想好☜1 小时前
Java 线上内存泄漏排查实战:jmap 导堆、MAT 找 GC Roots 与四类常见泄漏
java·开发语言·jvm·内存泄漏
码匠许师傅1 小时前
【C++ 面试真题】聊聊 C++ 的多继承与虚继承
开发语言·c++·面试
g10565591392 小时前
LAMP博客平台Wordpress实战
android·mysql·nginx·php
我不是疯子是傻子2 小时前
Qt CAN通信周期发送抖动?实测定时器精度校准与时间戳补偿方案
开发语言·数据库·qt
IT爱学堂3 小时前
尚硅谷 - 2025年3月Java+AI大模型应用开发
java·开发语言·人工智能
有点。3 小时前
C++认识数
开发语言·c++
许彰午4 小时前
# allbytaskid——一个接口扛六个DataStore
运维·服务器·php
W_326004 小时前
Python文件进阶:一维数据与 CSV 文件读写
开发语言·python
Tyler_TXZ4 小时前
C++C语言之——二叉树
c语言·开发语言·数据结构·c++·二叉树