从网卡收到数据到 epoll 被唤醒:把 Linux 网络接收链路真正串起来


🎉博主首页: 有趣的中国人

🎉专栏首页: 操作系统原理

🎉其它专栏: C++初阶 | C++进阶 | 初阶数据结构


文章目录

  • [从网卡收到数据到 epoll 被唤醒:把 Linux 网络接收链路真正串起来](#从网卡收到数据到 epoll 被唤醒:把 Linux 网络接收链路真正串起来)
    • 一、先从最底下看:数据是怎么进来的
      • [1. 网卡最先接触到的,其实不是"应用层数据"](#1. 网卡最先接触到的,其实不是“应用层数据”)
      • [2. 为什么必须有 DMA](#2. 为什么必须有 DMA)
      • [3. DMA 把数据写到哪里了:Ring Buffer](#3. DMA 把数据写到哪里了:Ring Buffer)
    • [二、CPU 什么时候接手:中断、软中断和 sk_buff](#二、CPU 什么时候接手:中断、软中断和 sk_buff)
      • [1. 数据写完以后,网卡怎么通知 CPU](#1. 数据写完以后,网卡怎么通知 CPU)
      • [2. 从 Ring Buffer 到 sk_buff,这一步是谁做的](#2. 从 Ring Buffer 到 sk_buff,这一步是谁做的)
      • [3. 一个包不是立刻进 socket,而是先过协议栈](#3. 一个包不是立刻进 socket,而是先过协议栈)
    • [三、从协议栈到 socket:真正和编程接口接上了](#三、从协议栈到 socket:真正和编程接口接上了)
      • [1. IP 层和 TCP 层分别在干什么](#1. IP 层和 TCP 层分别在干什么)
      • [2. socket 接收队列里放的到底是什么](#2. socket 接收队列里放的到底是什么)
      • [3. 到了 socket 队列,还不是应用层](#3. 到了 socket 队列,还不是应用层)
      • [4. socket() 创建的到底是什么](#4. socket() 创建的到底是什么)
      • [5. TCP 和 UDP 是怎么区分的](#5. TCP 和 UDP 是怎么区分的)
    • [四、listen、accept 和 epoll:从"有包"到"有事件"](#四、listen、accept 和 epoll:从“有包”到“有事件”)
      • [1. listen 之后,socket 的角色就变了](#1. listen 之后,socket 的角色就变了)
      • [2. accept 干了什么](#2. accept 干了什么)
      • [3. epoll 监听普通 fd 时,到底在等什么](#3. epoll 监听普通 fd 时,到底在等什么)
      • [4. epoll 监听 listenfd 时,又在等什么](#4. epoll 监听 listenfd 时,又在等什么)
      • [5. 为什么 listenfd 可读了,accept 还可能返回 EAGAIN](#5. 为什么 listenfd 可读了,accept 还可能返回 EAGAIN)
    • 五、把整条链路收一下
    • 六、最后补一个结构总图

从网卡收到数据到 epoll 被唤醒:把 Linux 网络接收链路真正串起来

在学 Linux 网络编程的时候,最容易出现一种情况:每个点都听过,但连不成一条线。

比如说,知道网卡收包、知道 DMA、知道中断、知道 sk_buff、知道 socket、知道 listen/accept、也知道 epoll 很快,但如果真的问一句:

一个 TCP 连接上的数据到来之后,到底经历了什么,最后为什么会让 epoll_wait() 返回?

很多时候就会开始断层。

这篇文章就不把这些知识点拆开讲,而是沿着 "数据进入主机之后的真实流动路径" ,从网卡一路讲到 epoll,再把 listenfd、全连接队列、新连接 socket 这些东西一起串起来。中间会穿插一些内核里的关键结构和代码,尽量把这条链路讲完整。


一、先从最底下看:数据是怎么进来的

1. 网卡最先接触到的,其实不是"应用层数据"

网络数据进入主机时,最先碰到的是网卡。但网卡一开始接收到的,并不是"HTTP 请求"或者"TCP 数据"这种概念上的东西,而是物理层的电信号或光信号。

网卡先做的事情,是把这些信号转换成比特流,然后按链路层格式去解析。对以太网来说,最先处理的是以太网帧。

可以简单理解成这样:

text 复制代码
网线/光纤
   ↓
网卡(物理层)
   ↓
比特流
   ↓
链路层解析以太网帧

网卡会先看几个关键东西:

  • 目标 MAC 是不是自己
  • 帧是否合法
  • CRC 校验是否通过

如果目标 MAC 不是本机,或者帧本身有问题,很多时候在这一层就直接丢弃了,根本不会进入后面的协议栈。

所以,收包的起点是网卡,但真正进入内核协议栈之前,已经先经过了一轮链路层筛选。


2. 为什么必须有 DMA

如果没有 DMA,那么网卡收到一个包之后,CPU 就得自己亲手把数据从设备搬到内存里。这个思路在理论上当然没问题,但在高并发场景下会非常低效。

因为 CPU 本来就要做很多事情:

  • 跑协议栈
  • 处理调度
  • 执行业务逻辑
  • 用户态/内核态切换

如果连搬数据这种体力活都得它自己干,那系统吞吐很快就会掉下来。

所以实际系统里,网卡收包之后,通常会通过 DMA(Direct Memory Access) 直接把数据写进内存。CPU 不是完全不参与,而是主要负责"安排"和"收尾",真正的数据搬运交给 DMA。

可以把这个过程理解成:

text 复制代码
没有 DMA:
网卡 → CPU → 内存

有 DMA:
网卡 → DMA → 内存

这一步非常关键,因为后面很多高性能网络优化,都是建立在"CPU 不做无意义搬运"这个基础上的。


3. DMA 把数据写到哪里了:Ring Buffer

DMA 写入的目的地,不是随便一块内存,而是驱动层提前准备好的接收缓冲区。这个缓冲区通常组织成环形,所以大家经常叫它 Ring Buffer

可以画成这样:

text 复制代码
+-------+    +-------+    +-------+
| slot0 | -> | slot1 | -> | slot2 |
+-------+    +-------+    +-------+
    ^                             |
    |_____________________________|

每个槽位都可以存一个包,网卡收到数据后,就通过 DMA 把包写到某个 slot 中。

这里一定要区分清楚:

  • Ring Buffer:驱动层/网卡层面的接收缓冲
  • socket 接收队列:协议栈处理后,交给 socket 的接收队列

这两个不是一回事。很多人学到后面会混淆,结果把驱动层和 socket 层揉在一起理解,后面就容易乱。

所以在这个阶段,数据只是:

网卡通过 DMA 写进了驱动层的接收缓冲区,还没有进入 socket。


二、CPU 什么时候接手:中断、软中断和 sk_buff

1. 数据写完以后,网卡怎么通知 CPU

DMA 把数据写进 Ring Buffer 后,网卡要告诉 CPU:"我这边有新包到了,你该处理了。"

这个通知机制就是 中断

中断的本质可以理解为:硬件主动打断 CPU 当前执行的流程,让 CPU 去响应某个事件。比如键盘敲一下、磁盘读完了、网卡收到了新包,这些都可以触发中断。

网卡收包之后的大致流程可以抽象成:

text 复制代码
网卡收到数据
   ↓
DMA 写入 Ring Buffer
   ↓
触发硬件中断
   ↓
CPU 保存现场
   ↓
进入中断处理流程

不过网络子系统里有个很重要的设计:

硬中断通常不做太重的活。

因为硬中断优先级很高,如果在这个阶段做大量复杂逻辑,会影响整个系统。所以 Linux 通常会在硬中断里只做少量工作,例如确认中断来源、做一些必要标记,然后把真正的收包处理延后到软中断去完成。

也就是说,网络收包的"真正重活",多数是在 软中断 里做的。


2. 从 Ring Buffer 到 sk_buff,这一步是谁做的

当数据已经在 Ring Buffer 中了,接下来内核要把它转成协议栈统一能处理的对象,也就是 sk_buff

这一点很关键:

从 Ring Buffer 取数据并封装成 sk_buff,是 CPU 在软中断上下文中完成的。

不是 DMA 自动帮你变成 sk_buff,也不是 socket 自己来拿,而是内核网络接收路径中的代码去做这件事。

Linux 网络里最核心的数据结构之一就是:

c 复制代码
struct sk_buff 
{
    union 
    {
        struct 
        {
            struct sk_buff *next;
            struct sk_buff *prev;
            union 
            {
                struct net_device *dev;
            };
        };
    };
    unsigned int len;
    unsigned char *data;
    unsigned int truesize;
    ...
};

当然真实结构比这个复杂得多,这里只看几个核心点:

  • next/prev:说明它可以挂到链表/队列上
  • data:指向真正的数据区域
  • len:数据长度
  • dev:来自哪个网卡设备

你可以把 sk_buff 理解成:

Linux 内核中对"一个网络包"的统一封装对象。

后面无论经过 IP 层、TCP 层,还是最终挂到 socket 的接收队列里,核心流转对象基本都是它。


3. 一个包不是立刻进 socket,而是先过协议栈

sk_buff 被创建出来以后,并不会立刻进入 socket。中间还要经过协议栈逐层处理。

整个过程大致是:

text 复制代码
Ring Buffer
   ↓
驱动层取包
   ↓
创建 sk_buff
   ↓
IP 层处理
   ↓
TCP 层处理
   ↓
找到对应 socket
   ↓
进入 socket 接收队列

这里最容易搞错的点,就是把"创建 sk_buff"和"进入 socket 队列"当成同一步。实际上不是。它先是一个在协议栈中流动的包对象,走完 IP 层、TCP 层之后,才会被放到具体 socket 上。


三、从协议栈到 socket:真正和编程接口接上了

1. IP 层和 TCP 层分别在干什么

sk_buff 创建出来之后,先走 IP 层。IP 层要检查很多事情,比如:

  • 目标 IP 是否是本机
  • IP 头是否合法
  • 校验和是否正确
  • 是否涉及分片/重组

过了 IP 层以后,再进入 TCP 层。TCP 层要干的事情更多,因为 TCP 是有状态、有可靠性保证的协议。典型工作包括:

  • 根据四元组找到对应连接
  • 检查序列号
  • 处理乱序和重组
  • 更新确认号、窗口等状态

对于 TCP 来说,最关键的一步是:

根据四元组找到对应的 sock

所谓四元组就是:

text 复制代码
						源 IP
						目标 IP
						源端口
						目标端口

只有定位到对应连接之后,这个包才知道应该被交给哪个 socket。


2. socket 接收队列里放的到底是什么

socket 接收队列里,放的不是"应用层消息对象",也不是某种已经被上层解析好的东西,放的仍然是 sk_buff

Linux 内核中,socket 的核心协议结构是 struct sock。里面就有接收队列和发送队列。

可以简化看一下:

c 复制代码
struct sock {
    struct sk_buff_head sk_receive_queue;
    struct sk_buff_head sk_write_queue;
    ...
};

这里:

  • sk_receive_queue:接收队列
  • sk_write_queue:发送队列

struct sk_buff_head 本质上就是一个管理 sk_buff 链表的队列头。

也就是说:

text 复制代码
socket 接收队列
   ↓
[skb] -> [skb] -> [skb] -> ...

所以更准确的说法是:

TCP 层处理完成后,会把 sk_buff 挂到对应 sock 的接收队列上。

这一步做完,socket 对应用层来说就"可读"了。


3. 到了 socket 队列,还不是应用层

这也是一个很常见的误区。很多人会说,"数据已经到 socket 里了,那是不是就已经到应用层了?"

不是。

socket 接收队列仍然是内核态的数据结构,数据只是先暂存在内核里。只有当应用程序调用 read()recv() 这些系统调用时,内核才会把数据从 socket 接收队列拷贝到用户空间缓冲区。

这个过程大致可以画成这样:

text 复制代码
网卡
 ↓
Ring Buffer
 ↓
sk_buff
 ↓
IP / TCP
 ↓
socket 接收队列(内核)
 ↓
read / recv
 ↓
用户空间 buffer
 ↓
应用层看到数据

所以,socket 接收队列是内核和应用层之间的重要缓冲层,但它本身不属于应用层。


4. socket() 创建的到底是什么

用户写:

c 复制代码
int fd = socket(AF_INET, SOCK_STREAM, 0);

这一步并不会建立连接,也不会收发数据,它只是创建一套内核对象,并返回一个文件描述符。

Linux 中 socket 也是"文件"体系的一部分,所以它背后会关联 struct file。而 file 里会通过 private_data 指向真正的 struct socket

结构关系可以简单画成这样:

text 复制代码
						fd
						 ↓
						struct file
						 ↓
						private_data
						 ↓
						struct socket
						 ↓
						struct sock

内核里对应关系很经典:

c 复制代码
struct socket 
{
    socket_state state;
    short type;
    struct file *file;
    struct sock *sk;
    const struct proto_ops *ops;
};

几个关键点:

  • file:说明 socket 也挂在文件描述符体系里
  • sk:真正协议层面的核心对象
  • ops:不同 socket 的操作函数表

所以 socket() 系统调用干的事,本质上可以概括成一句话:

创建文件描述符,创建 socket 对象,并把它和更底层的 sock 结构关联起来。


5. TCP 和 UDP 是怎么区分的

在用户态,通常是通过:

c 复制代码
socket(AF_INET, SOCK_STREAM, 0); // TCP
socket(AF_INET, SOCK_DGRAM, 0);  // UDP

来区分。

在内核里,struct socket 并不直接区分 TCP/UDP,而是通过它关联的 sock 以及操作函数表来区分。Linux 内核用的是一种典型的 C 语言"继承 + 多态"风格。

比如通用层有 struct sock,而 TCP、UDP 会在这个基础上扩展:

c 复制代码
struct tcp_sock
{
    struct inet_connection_sock inet_conn;
    ...
};

struct udp_sock 
{
    struct inet_sock inet;
    ...
};

再往里追,会发现它们底层都包含 struct sock 这一层。

这就是为什么 struct socket 里虽然只有一个 struct sock *sk,但它最终可以指向 TCP 相关对象,也可以指向 UDP 相关对象。上层拿的是"基类指针",真正运行时再分派到不同协议实现上。

如果用面向对象的话来说,这确实有点像多态,只不过 Linux 是用 C 的方式做出来的。


四、listen、accept 和 epoll:从"有包"到"有事件"

1. listen 之后,socket 的角色就变了

服务端最开始调用 socket() 创建出来的,只是一个普通 socket。真正让它变成"监听 socket"的,是 listen()

一旦 listen() 成功,这个 socket 的职责就不再是正常收发业务数据,而是变成了:

接收新连接。

对于 TCP 来说,监听 socket 会关联连接队列。最常说的就是:

  • 半连接队列
  • 全连接队列

客户端第一次发 SYN 过来之后,服务端先进入半连接状态;三次握手完成之后,这个连接才会进入全连接队列,等待应用层 accept()

在内核里,全连接队列是 TCP 监听相关结构的一部分。可以看一个典型成员:

c 复制代码
struct inet_connection_sock 
{
    struct request_sock_queue icsk_accept_queue;
    ...
};

这里的 icsk_accept_queue,就可以理解成我们平时讲的 accept 队列,也就是全连接队列。

所以,不是所有 socket 都有这个队列,只有监听 socket 才会维护它。


2. accept 干了什么

当应用层调用:

c 复制代码
int connfd = accept(listenfd, NULL, NULL);

本质上做的是:

  1. listenfd 对应的全连接队列里取出一个已经完成三次握手的连接
  2. 为这个连接创建一个新的通信 socket
  3. 返回新的文件描述符 connfd

所以服务端通常会有两类 fd:

  • listenfd:负责监听新连接
  • connfd:负责和某一个客户端实际通信

这两者职责完全不同:

text 复制代码
listenfd:等连接
connfd  :传数据

这也是为什么监听 socket 和普通通信 socket 虽然底层都属于 socket,但它们的"可读"含义并不一样。


3. epoll 监听普通 fd 时,到底在等什么

当我们把一个普通 TCP 通信 fd 加到 epoll 里时,epoll_wait() 等的不是某个"应用层消息对象",也不是专门等某个固定的 sk_buff,它等的是:

这个 fd 是否处于就绪状态。

对于可读事件来说,最核心的判定条件就是:

socket 接收队列是否非空。

也就是说,当 TCP 层把 sk_buff 放入 sk_receive_queue 后,这个 socket 就变成可读了,内核会通过回调把这个就绪事件挂到 epoll 的就绪链表中,最终 epoll_wait() 就会返回。

可以把这一段理解成:

text 复制代码
						数据到来
						 ↓
						TCP 层处理完成
						 ↓
						skb 进入 socket 接收队列
						 ↓
						socket 可读
						 ↓
						epoll_wait 返回

所以更准确地说:

epoll 等的不是 sk_buff 本身,而是 socket 的就绪状态;而这个就绪状态,常常由接收队列里是否有 sk_buff 决定。


4. epoll 监听 listenfd 时,又在等什么

监听 listenfd 的情况和普通通信 socket 不一样。

普通 fd 可读,通常代表"有数据了";但 listenfd 可读,不代表来了业务数据,而是代表:

全连接队列里有新的已建立连接了。

也就是说,监听 socket 的可读事件,本质上对应的是 accept queue 非空。

可以画成这样:

text 复制代码
						客户端三次握手完成
						   ↓
						连接进入全连接队列
						   ↓
						listenfd 变为可读
						   ↓
						epoll_wait 返回
						   ↓
						应用层 accept()

所以:

  • 普通 connfd 的可读:接收队列里有数据
  • listenfd 的可读:全连接队列里有连接

这是很多人第一次学 epoll 时最容易混掉的点。


5. 为什么 listenfd 可读了,accept 还可能返回 EAGAIN

这个问题其实很经典,尤其是在非阻塞和 ET 模式下经常遇到。

根本原因在于:

epoll 只负责告诉你"这个 fd 发生过就绪",但不保证你真正去处理的时候,条件还一定成立。

比如有两种常见情况。

第一种,多线程/多进程同时抢同一个 listenfd

一个新连接到来之后,内核把等待者都唤醒了,但最终只有一个线程抢到了这个连接,其他线程再去 accept() 时,全连接队列已经空了。这时非阻塞 accept() 就会返回 EAGAIN

第二种,ET 模式下没有一次性把队列读空。

ET 只在状态变化时通知一次,所以 listenfd 一旦变为可读,就应该循环 accept(),直到返回 EAGAIN 为止,否则后面的连接可能还在队列里,但你却收不到新的通知。

典型写法就是:

c 复制代码
while (true) 
{
    int connfd = accept(listenfd, NULL, NULL);
    if (connfd < 0) 
    {
        if (errno == EAGAIN || errno == EWOULDBLOCK)
            break;
        else
            perror("accept");
    }
}

这个循环不是"多余的",而是在 ET + 非阻塞场景下的标准处理方式。


五、把整条链路收一下

到这里,其实整条主线已经很清楚了。

对于一个普通 TCP 包来说,它的大致路径是:

text 复制代码
						网卡收到数据
						   ↓
						DMA 写入 Ring Buffer
						   ↓
						硬中断通知 CPU
						   ↓
						软中断开始真正处理
						   ↓
						驱动层从 Ring Buffer 取包并创建 sk_buff
						   ↓
						进入 IP 层
						   ↓
						进入 TCP 层
						   ↓
						根据四元组找到对应 sock
						   ↓
						skb 放入 socket 接收队列
						   ↓
						socket 变为可读
						   ↓
						epoll_wait 返回
						   ↓
						应用层 recv/read 取数据

而对于服务端的新连接来说,则是另一条相关但不同的链路:

text 复制代码
						socket()
						   ↓
						bind()
						   ↓
						listen()
						   ↓
						监听 socket 维护连接队列
						   ↓
						客户端三次握手完成
						   ↓
						连接进入全连接队列
						   ↓
						listenfd 可读
						   ↓
						epoll_wait 返回
						   ↓
						accept()
						   ↓
						创建新的 connfd
						   ↓
						后续用 connfd 和客户端通信

所以最后真正需要记住的,不是某个孤立定义,而是下面这几句话:

  • Ring Buffer 属于驱动层,不是 socket 层
  • 协议栈中流动的核心对象是 sk_buff
  • socket 接收队列里放的也是 sk_buff
  • socket 接收队列仍然属于内核态,不是应用层
  • 普通 fd 可读,通常表示接收队列里有数据
  • listenfd 可读,表示全连接队列里有连接
  • accept 取出的不是 listenfd 自己,而是一个新的通信 socket

如果把这些点真的串起来了,那么后面再看 Reactor、Muduo、LT/ET、惊群、NAPI,理解成本会低很多。因为你脑子里已经有一条完整的主线了,而不是一堆分散的概念。


六、最后补一个结构总图

文章最后,用一张总图把对象关系和数据流再收一遍:

text 复制代码
                ┌─────────────────────┐
                │      用户程序        │
                │  read / recv / epoll│
                └─────────┬───────────┘
                          │
                          ▼
                ┌─────────────────────┐
                │    fd / struct file  │
                └─────────┬───────────┘
                          │ private_data
                          ▼
                ┌─────────────────────┐
                │    struct socket    │
                │   file / sk / ops   │
                └─────────┬───────────┘
                          │
                          ▼
                ┌─────────────────────┐
                │     struct sock     │
                │   sk_receive_queue  │
                │   sk_write_queue    │
                └─────────┬───────────┘
                          │
          ┌───────────────┴────────────────┐
          │                                │
          ▼                                ▼
┌─────────────────────┐          ┌─────────────────────┐
│   普通通信 socket    │          │    listen socket     │
│  接收队列里放 skb     │          │   全连接队列等连接     │
└─────────────────────┘          └─────────────────────┘

数据流:
网卡 → DMA → Ring Buffer → sk_buff → IP → TCP → socket 队列 → epoll → 应用层

说到底,Linux 网络编程难的从来不是某一个函数,也不是某一个结构体,而是能不能把这些零散的知识点真正串成一条线。只要这条主线理顺了, Reactor、Muduo、LT/ET、惊群 这些内容,其实都只是继续往下展开而已。

相关推荐
坤坤藤椒牛肉面2 小时前
linux驱动1
linux·运维·服务器
摸鱼仙人~2 小时前
LLM量化技术全景对比:AWQ、GPTQ、GGUF与FP8/INT8/INT4的抉择指南
运维·服务器
zoujiahui_20182 小时前
ubuntu使用中的问题
linux·ubuntu·github
默|笙2 小时前
【Linux】线程互斥与同步_线程互斥
linux
被考核重击2 小时前
计算机网络核心知识点笔记
网络·笔记·计算机网络
wanhengidc2 小时前
服务器 网络信息安全
运维·服务器·网络
CHANG_THE_WORLD2 小时前
PDF解析器代码详解:从文件结构到交叉引用表解析
网络·pdf
wanhengidc2 小时前
裸金属服务器都有什么作用
运维·服务器
Harvy_没救了2 小时前
MySQL主从架构深度解析:原理、优化与实践指南
运维·mysql·架构
CHANG_THE_WORLD2 小时前
演示宽度数组解析
linux·服务器·前端