彻底解剖 TCP 数据流动:从 `write()` 到网卡,再从网卡到 `read()` 的全链路解析

文章目录

  • [彻底解剖 TCP 数据流动:从 `write()` 到网卡,再从网卡到 `read()` 的全链路解析](#彻底解剖 TCP 数据流动:从 write() 到网卡,再从网卡到 read() 的全链路解析)
  • [二、发送方向:从 `write()` 到客户端网卡](#二、发送方向:从 write() 到客户端网卡)
    • [第 1 步:根据文件描述符找到 TCP 套接字](#第 1 步:根据文件描述符找到 TCP 套接字)
    • [第 2 步:数据从用户空间复制到内核](#第 2 步:数据从用户空间复制到内核)
    • [第 3 步:`write()` 返回究竟代表什么](#第 3 步:write() 返回究竟代表什么)
    • [第 4 步:`write()` 不一定一次写完](#第 4 步:write() 不一定一次写完)
    • [第 5 步:TCP 为数据建立可靠传输状态](#第 5 步:TCP 为数据建立可靠传输状态)
    • [第 6 步:TCP 分段](#第 6 步:TCP 分段)
    • [第 7 步:进入 IP 层](#第 7 步:进入 IP 层)
    • [第 8 步:进入链路层和邻居子系统](#第 8 步:进入链路层和邻居子系统)
    • [第 9 步:进入 qdisc 和网卡驱动](#第 9 步:进入 qdisc 和网卡驱动)
    • [第 10 步:DMA 与网卡发送](#第 10 步:DMA 与网卡发送)
  • 三、网络传输:数据如何到达服务端
  • [四、接收方向:从服务端网卡到 `read()`](#四、接收方向:从服务端网卡到 read())
    • [第 11 步:服务端网卡收到数据](#第 11 步:服务端网卡收到数据)
    • [第 12 步:RX Ring 与 DMA](#第 12 步:RX Ring 与 DMA)
    • [第 13 步:硬中断与 NAPI](#第 13 步:硬中断与 NAPI)
    • [第 14 步:链路层处理](#第 14 步:链路层处理)
    • [第 15 步:IP 层处理](#第 15 步:IP 层处理)
    • [第 16 步:TCP 根据连接信息查找对应套接字](#第 16 步:TCP 根据连接信息查找对应套接字)
    • [第 17 步:TCP 校验和序列号处理](#第 17 步:TCP 校验和序列号处理)
    • [第 18 步:数据进入套接字接收队列](#第 18 步:数据进入套接字接收队列)
    • [第 19 步:接收端发送 ACK](#第 19 步:接收端发送 ACK)
  • [五、`read()` 如何从 TCP 接收队列取数据](#五、read() 如何从 TCP 接收队列取数据)
  • [六、为什么 `read()` 一次不一定读到完整消息](#六、为什么 read() 一次不一定读到完整消息)
  • 七、所谓"粘包"和"拆包"到底是什么
  • 八、服务端回显时,数据如何反向流动
  • [九、监听套接字 `lfd` 到底保存什么](#九、监听套接字 lfd 到底保存什么)
  • [十、TCP 建立连接时涉及哪些队列](#十、TCP 建立连接时涉及哪些队列)
    • [第一次握手:客户端发送 SYN](#第一次握手:客户端发送 SYN)
    • [第二次握手:服务端发送 SYN+ACK](#第二次握手:服务端发送 SYN+ACK)
    • [第三次握手:客户端返回 ACK](#第三次握手:客户端返回 ACK)
  • [十一、`accept()` 到底做了什么](#十一、accept() 到底做了什么)
  • 十二、监听套接字是否完全不能接收业务数据
  • 十三、三个阻塞函数的真正阻塞条件
    • [1. `read()`](#1. read())
    • [2. `write()`](#2. write())
    • [3. `accept()`](#3. accept())
  • [十四、TCP 流量控制为什么会让发送端停下来](#十四、TCP 流量控制为什么会让发送端停下来)
  • [十五、`read()` 返回 0 到底表示什么](#十五、read() 返回 0 到底表示什么)
  • 十六、完整数据流全景
  • 十七、不同对象在整个过程中分别负责什么
  • 十八、几个必须彻底纠正的错误认识
    • [错误一:`write()` 返回说明对方已经收到](#错误一:write() 返回说明对方已经收到)
    • [错误二:数据交给网卡后,TCP 就可以立即删除发送数据](#错误二:数据交给网卡后,TCP 就可以立即删除发送数据)
    • [错误三:网卡通过 DMA 直接把数据写进 `cfd` 的读缓冲区](#错误三:网卡通过 DMA 直接把数据写进 cfd 的读缓冲区)
    • [错误四:内核根据四元组找到用户空间的 `cfd`](#错误四:内核根据四元组找到用户空间的 cfd)
    • [错误五:一次 `write()` 对应一次 `read()`](#错误五:一次 write() 对应一次 read())
    • [错误六:`lfd` 的读缓冲区里保存 SYN 包](#错误六:lfd 的读缓冲区里保存 SYN 包)
    • 错误七:每收到一个包都会产生一次完整硬中断处理
    • [错误八:网卡 Ring Buffer 中直接保存完整数据包](#错误八:网卡 Ring Buffer 中直接保存完整数据包)
  • 十九、最终结论

彻底解剖 TCP 数据流动:从 write() 到网卡,再从网卡到 read() 的全链路解析

在 TCP 网络编程中,我们通常只看到几个简单的系统调用:

cpp 复制代码
write(fd, data, size);
read(fd, buffer, size);

但在这两个函数之间,数据实际上经历了:

  • 用户态与内核态之间的数据拷贝
  • TCP 发送队列与接收队列
  • TCP 分段、重传和流量控制
  • IP 路由与链路层封装
  • 网卡驱动和 DMA
  • 硬中断、NAPI 与软中断
  • TCP 四元组查找
  • 套接字唤醒与进程调度

下面以"客户端向服务端发送 "Hello""为例,从应用程序一直追踪到网卡,再从服务端网卡追踪到 read()


一、首先建立正确的整体认识

对于一个已经建立 TCP 连接的通信套接字,内核通常会维护两组相互独立的状态:

  • 发送方向的状态和队列
  • 接收方向的状态和队列

因此,一个 TCP 连接可以同时发送和接收数据,这也是 TCP 能够实现全双工通信的基础。

不过,"每个套接字都有一个固定的读缓冲区和一个固定的写缓冲区"只是一种便于入门的抽象。

在 Linux 内核中,真正的实现远比两个连续数组复杂。数据通常通过若干 struct sk_buff、队列、链表以及内存计数机制进行管理,例如:

  • 发送队列
  • 接收队列
  • 乱序接收队列
  • 重传队列
  • backlog 队列
  • 预队列或其他协议栈内部队列

因此,后文中的"发送缓冲区"和"接收缓冲区",应该理解为:

内核为该 TCP 套接字维护的发送数据、接收数据及相关控制状态的统称,而不一定是一整块连续内存。

还需要区分两类套接字:

  1. 监听套接字 lfd

    用于接收新的连接,不承担普通业务数据通信。

  2. 已连接套接字 cfd 或客户端 fd

    表示一条具体 TCP 连接,用于收发业务数据。


二、发送方向:从 write() 到客户端网卡

假设客户端已经与服务端建立连接,并执行:

cpp 复制代码
ssize_t n = write(fd, "Hello", 5);

第 1 步:根据文件描述符找到 TCP 套接字

用户程序中的 fd 只是一个整数,例如:

text 复制代码
fd = 3

它本身不包含 TCP 连接信息。

进入内核后,内核会沿着类似下面的关系找到真正的 TCP 对象:

text 复制代码
文件描述符 fd
    ↓
进程文件描述符表
    ↓
struct file
    ↓
struct socket
    ↓
struct sock
    ↓
struct tcp_sock

其中:

  • struct file 表示打开的内核文件对象
  • struct socket 是 BSD Socket 层对象
  • struct sock 是网络协议栈中的通用套接字对象
  • struct tcp_sock 保存 TCP 特有状态,例如序列号、拥塞窗口和接收窗口

第 2 步:数据从用户空间复制到内核

write() 最终会进入 TCP 的发送逻辑。

在普通阻塞式 Socket 中,内核通常会:

  1. 检查当前连接状态
  2. 检查发送缓冲区是否有可用空间
  3. 从用户空间读取 "Hello"
  4. 将数据复制或组织到内核管理的发送数据结构中
  5. 为数据分配 TCP 序列号
  6. 尝试将数据发送到更低层协议栈

可以将这一过程概括为:

text 复制代码
用户空间 buf
    ↓ copy_from_user 或等价机制
TCP 套接字发送队列

但需要注意,发送队列通常不是一块简单的字符数组,而是由一个或多个 sk_buff 等数据结构组成。


第 3 步:write() 返回究竟代表什么

假设:

cpp 复制代码
ssize_t n = write(fd, "Hello", 5);

返回:

text 复制代码
n = 5

这只代表:

内核已经接受了这 5 个字节,并将其纳入该 TCP 套接字的发送管理范围。

它并不代表:

  • 数据已经到达网卡
  • 数据已经离开本机
  • 数据已经到达服务端
  • 服务端程序已经调用 read()
  • 服务端已经处理了数据

甚至在 write() 返回时,数据可能还没有真正形成一个独立的网络包。

TCP 是否立即发送,还会受到以下机制影响:

  • Nagle 算法
  • 拥塞控制
  • 接收窗口
  • TCP Small Queues
  • 分段与聚合
  • 网卡发送队列状态
  • 调度策略
  • TCP_NODELAY
  • MSG_MORE
  • 网卡硬件卸载能力

第 4 步:write() 不一定一次写完

以下代码是不严谨的:

cpp 复制代码
write(fd, data, length);

因为即使在阻塞模式下,write() 也可能只接受部分数据。

正确做法应该检查返回值:

cpp 复制代码
ssize_t send_all(int fd, const char* data, size_t length) {
    size_t sent = 0;

    while (sent < length) {
        ssize_t n = write(fd, data + sent, length - sent);

        if (n > 0) {
            sent += static_cast<size_t>(n);
            continue;
        }

        if (n < 0 && errno == EINTR) {
            continue;
        }

        return -1;
    }

    return static_cast<ssize_t>(sent);
}

对于非阻塞套接字,如果当前发送缓冲区无法继续接收数据,write() 通常返回:

text 复制代码
-1
errno = EAGAIN 或 EWOULDBLOCK

如果连接已经被对端关闭,继续写入还可能出现:

  • EPIPE
  • SIGPIPE

第 5 步:TCP 为数据建立可靠传输状态

TCP 不是简单地把 "Hello" 加上一个头部就立即丢给网卡。

TCP 还要维护:

  • 发送序列号
  • 已发送但尚未确认的数据
  • 重传定时器
  • 拥塞窗口
  • 对端通告的接收窗口
  • RTT 估计
  • 重复 ACK 状态
  • SACK 信息

假设 "Hello" 对应的 TCP 序列号范围是:

text 复制代码
SEQ = 1000
数据长度 = 5
覆盖字节序号 1000~1004

即使数据已经交给网卡发送,内核通常也不能立刻释放所有相关数据。

因为在对端返回确认之前,TCP 必须保留足够的信息,以便在丢包时进行重传。

因此:

"数据已交给网卡"与"数据已从 TCP 发送缓冲区中彻底释放"不是同一件事。

只有收到相应 ACK 后,TCP 才能确认这些字节已经被对端 TCP 协议栈接收,并逐步释放相关发送资源。


第 6 步:TCP 分段

如果应用程序写入的数据较多,TCP 需要将字节流划分为适合传输的 TCP 段。

例如应用层一次写入:

text 复制代码
10 KB

网络路径允许的单个 TCP 载荷可能只有约:

text 复制代码
1460 字节

那么逻辑上可能需要拆分为多个 TCP 段。

不过在现代 Linux 和现代网卡中,这个过程可能受到以下硬件卸载技术影响:

  • TSO:TCP Segmentation Offload
  • GSO:Generic Segmentation Offload
  • CHECKSUM offload

启用 TSO/GSO 时,内核可能把一个较大的数据块交给网卡,由网卡完成最终分段。

因此,"TCP 协议栈先把数据完整切成一个个小包,再交给网卡"的描述并不总是准确。

更准确的说法是:

TCP/IP 协议栈建立逻辑上的 TCP 分段与协议头信息,实际物理分段可能由内核完成,也可能延迟到网卡硬件完成。


第 7 步:进入 IP 层

TCP 层准备好待发送数据后,将其交给 IP 层。

IP 层主要负责:

  1. 确定目标 IP 地址
  2. 查询路由表
  3. 选择输出网络设备
  4. 填写 IP 头部
  5. 处理分片或路径 MTU 相关逻辑
  6. 经过 Netfilter 等处理路径

抽象路径如下:

text 复制代码
TCP 数据
    ↓
TCP 头
    ↓
IP 头
    ↓
IP 数据包

第 8 步:进入链路层和邻居子系统

如果使用以太网,内核还需要知道下一跳设备的 MAC 地址。

这通常涉及:

  • ARP:IPv4
  • NDP:IPv6
  • 邻居缓存
  • 网关地址
  • 路由结果

链路层封装后,逻辑上形成:

text 复制代码
以太网头
IP 头
TCP 头
应用数据

但在网卡卸载开启时,部分头部、校验和或分段工作可能仍由网卡稍后完成。


第 9 步:进入 qdisc 和网卡驱动

数据不会简单地从 TCP 套接字直接放入网卡 Ring Buffer。

在 Linux 中,发送路径通常还会经过:

text 复制代码
TCP/IP 协议栈
    ↓
网络设备层
    ↓
qdisc 排队规则
    ↓
网卡驱动
    ↓
网卡 TX 描述符环

qdisc 是 Linux 的排队规则系统,例如:

  • fq
  • fq_codel
  • pfifo_fast
  • mq

它负责一定程度上的:

  • 排队
  • 调度
  • 整形
  • 公平性控制

网卡驱动随后把待发送数据映射给 DMA,并在网卡的发送描述符环中填写描述信息。


第 10 步:DMA 与网卡发送

网卡的 TX Ring 通常不是直接存放全部数据内容,而是保存一组发送描述符。

描述符会告诉网卡:

  • 数据位于哪块内存
  • 数据长度是多少
  • 是否需要硬件计算校验和
  • 是否需要 TSO
  • 一个数据包由哪些内存片段组成

可以简化理解为:

text 复制代码
内核中的数据页或 skb
        ↑
        │ DMA 映射
        │
网卡 TX 描述符环
        ↓
网卡读取数据
        ↓
发送到物理介质

网卡通过 DMA 读取内存中的数据,并将其编码为:

  • 电信号
  • 光信号
  • 无线电信号

随后发送到网络中。


三、网络传输:数据如何到达服务端

数据离开客户端后,可能经过:

text 复制代码
客户端网卡
    ↓
交换机
    ↓
路由器
    ↓
防火墙或 NAT
    ↓
服务端所在网络
    ↓
服务端网卡

中间设备主要根据:

  • MAC 地址
  • IP 地址
  • 路由表
  • NAT 状态
  • 防火墙规则

决定数据包下一步去向。

TCP 的可靠性并不是由路由器提供的。

路由器只负责尽力转发 IP 数据包。发生丢包、乱序或重复时,由通信两端的 TCP 协议栈负责恢复。


四、接收方向:从服务端网卡到 read()

第 11 步:服务端网卡收到数据

服务端网卡接收到以太网帧后,通常不会直接把 "Hello" 写入某个 cfd 的接收缓冲区。

网卡此时根本不知道:

  • 哪个进程拥有这个连接
  • 对应的文件描述符是多少
  • 应该交给哪个 struct sock
  • TCP 数据是否乱序
  • 校验和是否合法

网卡只负责接收链路层数据。


第 12 步:RX Ring 与 DMA

网卡驱动通常会提前准备一批可用于接收数据的内存区域,并把对应地址登记在 RX 描述符环中。

网卡收到帧后,通过 DMA 将数据写入这些预先准备的内存页或缓冲区。

可以简化为:

text 复制代码
网络信号
    ↓
网卡
    ↓ DMA
内核预先准备的接收内存

这里需要特别纠正一个常见说法:

网卡不是"把整个数据包直接写进 Ring Buffer"。

更准确地说:

  • RX Ring 主要保存接收描述符
  • 描述符指向真正的数据缓冲区或内存页
  • 网卡通过 DMA 把包数据写到描述符所指向的内存中
  • 网卡再更新描述符状态,表示数据已经就绪

第 13 步:硬中断与 NAPI

早期或简化模型通常描述为:

text 复制代码
每收到一个数据包
    ↓
网卡触发一次硬件中断
    ↓
CPU 立即处理这个包

但现代 Linux 高速网络通常使用 NAPI。

典型过程是:

  1. 网卡收到一批数据
  2. 网卡触发硬中断
  3. 硬中断处理程序暂时关闭或抑制继续中断
  4. 调度 NAPI poll
  5. 内核在软中断上下文中批量处理多个数据包
  6. 数据处理完毕后重新启用中断

因此,更准确的路径是:

text 复制代码
网卡 DMA 接收
    ↓
硬中断进行快速通知
    ↓
NAPI poll
    ↓
NET_RX_SOFTIRQ
    ↓
网络协议栈

这样能够降低高流量场景下的中断开销。


第 14 步:链路层处理

数据进入协议栈后,首先处理链路层头部。

以以太网为例,内核会检查:

  • 目标 MAC 地址
  • 以太网协议类型
  • VLAN 信息
  • 数据包长度
  • 网卡校验和状态

然后根据 EtherType 判断上层协议:

text 复制代码
0x0800 → IPv4
0x86DD → IPv6
0x0806 → ARP

如果是 IPv4,就交给 IPv4 协议栈。


第 15 步:IP 层处理

IP 层会检查:

  • IP 版本
  • 头部长度
  • 目标 IP 地址
  • 数据包长度
  • 分片状态
  • 路由结果
  • Netfilter 规则
  • 上层协议号

如果 IP 头中的协议字段表示 TCP:

text 复制代码
Protocol = 6

就将数据继续交给 TCP 层。


第 16 步:TCP 根据连接信息查找对应套接字

TCP 需要找到这个数据包属于哪条连接。

一条 TCP 连接通常由以下信息标识:

text 复制代码
本地 IP
本地端口
远端 IP
远端端口

也就是常说的四元组:

text 复制代码
<源 IP, 源端口, 目标 IP, 目标端口>

但在 Linux 内核中,查找过程还可能受到以下因素影响:

  • 网络命名空间
  • IPv4 或 IPv6
  • 绑定设备
  • 地址复用规则
  • SO_REUSEPORT
  • 连接状态

因此,"仅凭四元组找到 cfd"在教学上可以接受,但严格地说,内核找到的不是用户空间的整数文件描述符 cfd

内核真正找到的是:

text 复制代码
对应的 struct sock / struct tcp_sock

cfd 只是服务端进程文件描述符表中,指向该连接对象的一个整数索引。

准确表达应该是:

TCP 协议栈根据连接标识查找到对应的内核 TCP 套接字对象,而不是直接查找到用户程序中的 cfd


第 17 步:TCP 校验和序列号处理

找到连接对象后,TCP 还要检查:

  • TCP 校验和
  • 序列号是否合法
  • 数据是否重复
  • 数据是否乱序
  • 是否存在缺口
  • ACK 是否有效
  • 窗口是否合法
  • 是否包含 FIN、RST 等控制标志
  • 是否涉及 SACK

假设当前接收端期望的下一个序列号是:

text 复制代码
RCV.NXT = 1000

收到的数据正好是:

text 复制代码
SEQ = 1000
长度 = 5

那么它覆盖:

text 复制代码
1000~1004

接收完成后,期望序列号可以推进为:

text 复制代码
RCV.NXT = 1005

如果收到的却是更靠后的数据,例如:

text 复制代码
SEQ = 1200

1000~1199 尚未到达,那么这段数据不能立即作为连续字节流交给应用程序。

它通常会先进入乱序队列,等待缺失的数据到达。


第 18 步:数据进入套接字接收队列

当 TCP 确认数据有效,并且已经能够组成从当前接收序列号开始的连续字节流后,数据会进入该 TCP 套接字的接收管理结构。

入门时可以称它为:

text 复制代码
cfd 的读缓冲区

更精确的理解是:

text 复制代码
与该已连接 struct sock 关联的接收队列和内存状态

此时,应用数据 "Hello" 已经被服务端 TCP 协议栈接收。

但是服务端进程是否已经运行、是否已经调用 read(),是另一件事。

TCP 协议栈可以先接收数据,再等待用户进程稍后读取。


第 19 步:接收端发送 ACK

服务端 TCP 收到 "Hello" 后,通常还会向客户端返回 ACK。

例如:

text 复制代码
ACK = 1005

它表示:

序列号 1005 之前的所有字节都已经按序收到,下一步期望收到序列号 1005。

但 ACK 不等于:

  • 服务端应用程序已经调用 read()
  • 服务端程序已经处理 "Hello"
  • 服务端业务逻辑执行成功

ACK 只说明:

数据已经到达服务端 TCP 协议栈,并被纳入可靠接收状态。

这是一个非常重要的边界。


五、read() 如何从 TCP 接收队列取数据

服务端执行:

cpp 复制代码
char buf[1024];
ssize_t len = read(cfd, buf, sizeof(buf));

第 20 步:通过 cfd 找到连接对象

和发送路径类似,cfd 只是文件描述符。

内核通过服务端进程的文件描述符表,找到对应的:

text 复制代码
struct file
    ↓
struct socket
    ↓
struct sock
    ↓
struct tcp_sock

第 21 步:检查是否存在可读取数据

情况一:接收队列中有数据

假设当前已经有:

text 复制代码
Hello

内核就可以将数据从套接字接收队列复制到用户空间的 buf

text 复制代码
TCP 接收队列
    ↓ copy_to_user 或等价机制
用户空间 buf

然后:

cpp 复制代码
len == 5

情况二:接收队列为空

如果这是一个阻塞套接字,并且:

  • 当前没有可读数据
  • 对端没有关闭发送方向
  • 没有发生错误
  • 没有信号中断

那么当前线程会进入睡眠状态,等待以下事件之一:

  • 新数据到达
  • 收到 FIN
  • 连接发生错误
  • 超时
  • 信号到达

线程并不是在 CPU 上不停循环检查,而是会被加入相应等待队列,然后由调度器切换出去。

当 TCP 协议栈把新数据放入接收队列后,会唤醒等待该套接字的线程。


情况三:非阻塞套接字没有数据

如果套接字设置为非阻塞模式,并且当前没有数据可读,read() 通常返回:

text 复制代码
-1
errno = EAGAIN 或 EWOULDBLOCK

第 22 步:读取后数据是否立即消失

普通 read() 成功后,被读取的字节会从应用可见的接收字节流中消费掉。

可以理解为接收队列的读取位置向前移动,相关内存最终被释放或复用。

例如接收队列中有:

text 复制代码
HelloWorld

执行:

cpp 复制代码
read(cfd, buf, 5);

得到:

text 复制代码
Hello

剩余可读数据为:

text 复制代码
World

如果使用:

cpp 复制代码
recv(cfd, buf, 5, MSG_PEEK);

则可以查看数据,但不消费它。


六、为什么 read() 一次不一定读到完整消息

TCP 提供的是:

可靠、有序、双向的字节流。

TCP 不提供应用层消息边界。

假设发送端依次执行:

cpp 复制代码
write(fd, "Hello", 5);
write(fd, "World", 5);

接收端并不保证得到两次对应的 read()

text 复制代码
第一次:"Hello"
第二次:"World"

接收端可能得到:

情况一

text 复制代码
第一次 read:"HelloWorld"

情况二

text 复制代码
第一次 read:"Hel"
第二次 read:"loWorld"

情况三

text 复制代码
第一次 read:"H"
第二次 read:"ello"
第三次 read:"World"

这些结果都符合 TCP 规范。

原因是:

text 复制代码
发送端 write 调用边界
≠
TCP 分段边界
≠
IP 数据包边界
≠
接收端 read 调用边界

TCP 只保证接收端最终看到的字节顺序是:

text 复制代码
HelloWorld

至于每次 read() 返回几个字节,由以下因素共同决定:

  • 当前接收队列中有多少数据
  • 用户传入的缓冲区大小
  • 套接字是否阻塞
  • 调度时机
  • 网络到达时机
  • SO_RCVLOWAT
  • 信号和超时
  • 接收 API 的参数

七、所谓"粘包"和"拆包"到底是什么

"粘包"和"拆包"不是 TCP 出错。

它们只是应用程序错误地把:

text 复制代码
一次 write()

理解成:

text 复制代码
一条具有边界的 TCP 消息

例如发送端发送:

text 复制代码
消息 A:Hello
消息 B:World

TCP 实际只看到:

text 复制代码
HelloWorld

它不知道:

text 复制代码
Hello | World

中间存在一个应用层边界。

因此,应用层必须自行定义协议。


方案一:固定长度

每条消息固定为 64 字节:

text 复制代码
[固定 64 字节消息]
[固定 64 字节消息]

接收端每次累计读取 64 字节,再解析一条消息。


方案二:分隔符

例如:

text 复制代码
Hello\n
World\n

接收端不断读取,直到找到 \n

HTTP/1.x 的部分结构就是这种思路。


方案三:长度字段

常见格式:

text 复制代码
[4 字节长度][消息正文]

例如:

text 复制代码
00 00 00 05 Hello
00 00 00 05 World

接收端首先读取固定长度的消息头,解析正文长度,再继续累计读取对应字节数。

示例:

cpp 复制代码
struct MessageHeader {
    uint32_t body_length;
};

需要注意:

  • 使用网络字节序
  • 校验长度上限
  • 防止整数溢出
  • 处理部分读取
  • 防止恶意超大数据包

八、服务端回显时,数据如何反向流动

服务端读取到:

text 复制代码
Hello

然后执行:

cpp 复制代码
write(cfd, buf, len);

数据将沿相反方向流动:

text 复制代码
服务端用户空间 buf
    ↓
服务端 cfd 对应 TCP 发送队列
    ↓
服务端 TCP/IP 协议栈
    ↓
服务端 qdisc
    ↓
服务端网卡 TX Ring
    ↓
网络
    ↓
客户端网卡 RX Ring
    ↓
客户端 TCP/IP 协议栈
    ↓
客户端 fd 对应接收队列
    ↓
客户端 read()
    ↓
客户端用户空间 buf

TCP 是全双工协议,因此同一条连接上存在两个逻辑上独立的字节流:

text 复制代码
客户端 → 服务端
服务端 → 客户端

每个方向都有独立的:

  • 序列号空间
  • 发送状态
  • 接收状态
  • 流量控制
  • ACK 处理

九、监听套接字 lfd 到底保存什么

监听套接字与已连接套接字承担不同职责。

服务端通常执行:

cpp 复制代码
int lfd = socket(AF_INET, SOCK_STREAM, 0);

bind(lfd, ...);

listen(lfd, 128);

调用 listen() 后,lfd 进入监听状态。

它的主要职责是:

接收新的 TCP 连接建立请求,并管理尚未被应用程序 accept() 的连接。

它不是用来接收普通业务数据的。

因此,不应该简单地说:

lfd 的读缓冲区里存放连接。

更精确的表述是:

监听套接字维护连接请求相关的数据结构,包括未完成握手的连接状态和已经完成握手、等待 accept() 的子连接。


十、TCP 建立连接时涉及哪些队列

教学中通常把监听端的连接管理划分为两个队列:

队列 常用名称 主要内容
未完成连接队列 SYN Queue、半连接队列 已收到 SYN,握手尚未完成的连接请求
已完成连接队列 Accept Queue、全连接队列 已完成握手,等待应用程序调用 accept() 的连接

需要说明的是,这两个名称是概念模型。

Linux 内核中的具体实现由 request_sock、监听队列以及子套接字等数据结构共同完成,并不一定表现为两个简单数组。


第一次握手:客户端发送 SYN

客户端发送:

text 复制代码
SYN

服务端监听套接字收到后,内核会建立连接请求状态,记录:

  • 客户端 IP
  • 客户端端口
  • 服务端 IP
  • 服务端端口
  • 初始序列号
  • TCP 选项
  • MSS
  • 窗口扩大因子
  • SACK 能力
  • 时间戳能力

概念上,这个请求处于半连接状态。


第二次握手:服务端发送 SYN+ACK

服务端返回:

text 复制代码
SYN + ACK

此时连接还没有完全建立。

服务端仍在等待客户端最后的 ACK。


第三次握手:客户端返回 ACK

客户端返回:

text 复制代码
ACK

服务端验证成功后,会创建或完成对应的已连接子套接字,使其进入:

text 复制代码
ESTABLISHED

随后,该连接进入等待 accept() 的队列。


十一、accept() 到底做了什么

服务端执行:

cpp 复制代码
int cfd = accept(lfd, nullptr, nullptr);

accept() 不是读取 TCP 业务数据,也不是重新执行三次握手。

三次握手通常已经由内核协议栈完成。

accept() 的核心工作可以概括为:

  1. 检查监听套接字的已完成连接队列
  2. 如果队列为空,阻塞套接字可能使线程睡眠
  3. 从队列中取出一个已建立连接
  4. 创建新的文件对象和文件描述符
  5. 让新的 cfd 指向该已连接 Socket
  6. cfd 返回给用户程序

所以:

text 复制代码
lfd

代表监听入口,而:

text 复制代码
cfd

代表某一条具体的 TCP 连接。

可以形象地理解为:

  • lfd 是接待台
  • Accept Queue 是等候区
  • accept() 是领取下一位已经完成登记的客户
  • cfd 是为该客户建立的专属服务通道

但从内核实现角度看,并不存在"由 cfd 接管连接"的过程。

连接对象在 accept() 前就已经由内核建立。accept() 只是为用户进程创建一个新的文件描述符,使用户程序能够引用这条连接。


十二、监听套接字是否完全不能接收业务数据

通常情况下,应用程序不会通过监听描述符读取业务数据。

客户端业务数据应该进入对应的已连接子套接字,而不是进入监听套接字。

还存在一个容易忽略的情况:

客户端可能在第三次握手 ACK 中携带数据,或者使用 TCP Fast Open 在建立连接阶段发送数据。

即使如此,这些业务数据最终也属于对应的子连接,而不是作为普通字节流放进监听套接字供 read(lfd, ...) 使用。


十三、三个阻塞函数的真正阻塞条件

1. read()

cpp 复制代码
read(cfd, buf, size);

阻塞套接字通常在以下情况下阻塞:

  • 当前没有可读数据
  • 对端未关闭发送方向
  • 当前没有待处理错误
  • 没有超时或信号中断

它可能因以下事件返回:

  • 至少读取到部分数据
  • 收到 FIN,且所有已接收数据已经读完,此时返回 0
  • 被信号中断,返回 -1errno == EINTR
  • 发生连接错误
  • 设置了接收超时并超时
  • 非阻塞模式下返回 EAGAIN

需要特别注意:

普通阻塞式 read() 通常不要求填满用户缓冲区才返回。

例如:

cpp 复制代码
char buf[4096];
ssize_t n = read(cfd, buf, sizeof(buf));

即使 buf 有 4096 字节,只要当前有 5 个字节可读,read() 就可能返回:

text 复制代码
5

2. write()

cpp 复制代码
write(cfd, data, size);

阻塞式 write() 可能在发送缓冲区没有足够可用空间时等待。

但"发送缓冲区满"并不只是因为网卡还没把数据发走。

发送空间还受到以下因素制约:

  • 数据尚未收到 ACK
  • 对端通告窗口过小或为零
  • 网络拥塞
  • 拥塞窗口限制
  • 本地发送队列压力
  • 内核套接字内存上限
  • TCP Small Queues
  • 网卡和 qdisc 排队压力

因此,更准确地说:

write() 是否能够继续接受数据,取决于 TCP 发送内存是否存在足够可用额度,而不仅仅取决于数据是否已经交给网卡。


3. accept()

cpp 复制代码
accept(lfd, ...);

阻塞式 accept() 在以下情况下等待:

text 复制代码
已完成连接队列为空

当某个连接完成握手并进入等待接受状态后,内核会唤醒阻塞在 accept() 上的线程。

非阻塞模式下,如果当前没有可接受连接,通常返回:

text 复制代码
-1
errno = EAGAIN 或 EWOULDBLOCK

十四、TCP 流量控制为什么会让发送端停下来

TCP 使用接收窗口告诉发送端:

我当前还能接收多少尚未确认的数据。

这个窗口通常称为:

text 复制代码
rwnd

假设服务端应用程序一直不调用 read()

text 复制代码
服务端接收队列不断增长
    ↓
可用接收空间逐渐减少
    ↓
服务端通告的接收窗口变小
    ↓
客户端可发送数据量受到限制

当接收窗口缩小为零时,发送端通常会停止发送新的普通数据,并通过零窗口探测机制定期确认窗口是否重新打开。

但是发送端实际可发送的数据量不只由接收窗口决定,还受拥塞窗口影响。

可以简化为:

text 复制代码
实际允许在途的数据量
≈ min(rwnd, cwnd)

其中:

  • rwnd:接收端流量控制窗口
  • cwnd:发送端拥塞控制窗口

所以:

  • rwnd 防止压垮接收端
  • cwnd 防止压垮网络

十五、read() 返回 0 到底表示什么

当:

cpp 复制代码
ssize_t n = read(cfd, buf, sizeof(buf));

返回:

text 复制代码
n == 0

对于阻塞式 TCP Socket,通常表示:

对端已经正常关闭其发送方向,并且本端已经读完此前到达的所有数据。

这通常是因为接收到了 TCP FIN。

返回 0 并不表示:

  • 暂时没有数据
  • 读缓冲区当前为空但连接仍正常
  • 应该稍后继续等待同一条数据

暂时没有数据时:

  • 阻塞套接字会等待
  • 非阻塞套接字返回 EAGAIN
  • 收到 FIN 并读完数据后才返回 0

十六、完整数据流全景

客户端发送 "Hello" 的完整逻辑路径可以概括为:

text 复制代码
客户端用户空间
    │
    │ write(fd, "Hello", 5)
    ▼
客户端内核 TCP 发送内存
    │
    │ TCP 序列号、拥塞控制、流量控制
    ▼
TCP 层
    │
    ▼
IP 层
    │
    ▼
链路层与邻居子系统
    │
    ▼
qdisc
    │
    ▼
客户端网卡驱动
    │
    ▼
客户端网卡 TX 描述符环
    │
    │ DMA 读取内存
    ▼
客户端网卡
    │
    ▼
交换机、路由器及网络链路
    │
    ▼
服务端网卡
    │
    │ DMA 写入接收内存
    ▼
服务端 RX 描述符环关联的数据缓冲区
    │
    ▼
硬中断
    │
    ▼
NAPI / NET_RX_SOFTIRQ
    │
    ▼
以太网层
    │
    ▼
IP 层
    │
    ▼
TCP 层
    │
    │ 连接查找、校验和、序列号、乱序重组
    ▼
服务端已连接 Socket 接收队列
    │
    │ 唤醒等待进程
    ▼
read(cfd, buf, size)
    │
    ▼
服务端用户空间 buf

十七、不同对象在整个过程中分别负责什么

对象 作用
客户端 fd 引用客户端已连接 TCP Socket
服务端 lfd 引用监听 Socket,负责接收新连接
服务端 cfd 引用某一条已连接 TCP Socket
TCP 发送队列 保存待发送或等待确认的数据及状态
TCP 接收队列 保存已经按序接收、可供应用读取的数据
乱序队列 暂存尚不能组成连续字节流的数据
Accept Queue 保存已建立但尚未被应用 accept() 的连接
TX Ring 保存网卡发送描述符
RX Ring 保存网卡接收描述符
qdisc 对待发送数据进行排队和调度
NAPI 批量处理网卡接收数据,降低中断开销

十八、几个必须彻底纠正的错误认识

错误一:write() 返回说明对方已经收到

错误。

write() 返回通常只说明内核已经接受了这些数据。


错误二:数据交给网卡后,TCP 就可以立即删除发送数据

错误。

在收到 ACK 之前,TCP 通常仍需要保留重传所需的数据或相关状态。


错误三:网卡通过 DMA 直接把数据写进 cfd 的读缓冲区

错误。

网卡只会 DMA 到驱动准备的接收内存。

随后必须经过:

text 复制代码
驱动
→ NAPI
→ 链路层
→ IP 层
→ TCP 层
→ 连接查找
→ 序列号处理
→ 套接字接收队列

错误四:内核根据四元组找到用户空间的 cfd

错误。

内核根据连接信息找到的是内核中的 TCP Socket 对象。

cfd 只是某个进程引用该对象的文件描述符编号。


错误五:一次 write() 对应一次 read()

错误。

TCP 没有应用层消息边界。


错误六:lfd 的读缓冲区里保存 SYN 包

不准确。

监听套接字维护的是连接请求和已完成连接相关的数据结构,而不是供应用程序通过普通 read() 读取的 TCP 业务字节流。


错误七:每收到一个包都会产生一次完整硬中断处理

不准确。

现代 Linux 通常使用硬中断配合 NAPI 和软中断进行批量处理。


错误八:网卡 Ring Buffer 中直接保存完整数据包

不准确。

Ring 中主要是描述符。实际数据通常位于描述符指向的内存页或缓冲区中。


十九、最终结论

整个 TCP 数据流动可以归纳为以下几句话。

第一,write() 的本质是:

把用户空间的字节交给本机内核 TCP 协议栈管理,而不是直接把数据交给对端程序。

第二,网卡发送的本质是:

驱动通过描述符和 DMA 让网卡读取内存中的数据,再发送到物理网络。

第三,网卡接收的本质是:

网卡先把数据 DMA 到驱动准备的通用接收内存,再由内核协议栈逐层解析和分发。

第四,TCP 分发的本质是:

协议栈根据连接标识找到对应的内核 TCP Socket,而不是直接找到用户空间文件描述符。

第五,read() 的本质是:

把已经由 TCP 按序接收的数据,从内核套接字接收队列复制到用户空间。

第六,accept() 的本质是:

从监听套接字的已完成连接队列中取得一条已经建立的连接,并为当前进程创建一个新的文件描述符。

第七,TCP 字节流的本质是:

TCP 只保证字节可靠、有序地到达,不保留应用程序每次 write() 的边界。

最终完整路径是:

text 复制代码
write()
→ TCP 发送管理
→ IP
→ 链路层
→ qdisc
→ 网卡驱动
→ TX Ring
→ DMA
→ 网络
→ 服务端网卡
→ RX Ring
→ DMA
→ NAPI
→ IP
→ TCP
→ Socket 接收队列
→ read()

理解了这条路径之后,就能真正解释:

  • 为什么 write() 成功不代表对方已经处理
  • 为什么发送数据还需要等待 ACK
  • 为什么 read() 可能只返回部分数据
  • 为什么会出现所谓的"粘包"和"拆包"
  • 为什么 accept()read() 完全不是同一类操作
  • 为什么网卡不能直接找到用户程序中的 cfd
  • 为什么高性能网络程序需要 epoll、非阻塞 I/O 和状态机
相关推荐
ITxiaobing20231 天前
如何构建IP维度的假量排查流程:以AppsFlyer Protect360判假后的实战为例
网络·数据库
Eloudy1 天前
基于 RDMA-CM(librdmacm)的聊天程序
网络·人工智能
(Charon)1 天前
【C/C++】手写内存池(三):大块内存的申请、记录与单独释放
网络
一只小菜鸡..1 天前
Stanford CS144 学习笔记 (二):传输层与数据通信机制
网络·笔记·学习
超防局1 天前
网络安全渗透测试常用工具详解
网络·安全·web安全
峥嵘life1 天前
Andorid 打开热点,关闭有线网,ifconfig卡死分析解决
java·服务器·网络
瓦学妹1 天前
代理IP连接失败怎么办?2026代理IP异常排查与解决方法
网络·网络协议·tcp/ip
hehelm1 天前
AI 大模型接入 SDK —项目概述
linux·服务器·网络·数据库·c++
儒雅的烤地瓜1 天前
计算机网络 | 网络诊断双刃剑:Tracert路由追踪实战与Windows防火墙端口配置指南
tcp/ip·计算机网络·wireshark·防火墙·powershell·cmd
国科安芯1 天前
抗辐射微控制器在卫星电源管理分系统中的控制架构与可靠性研究——基于AS32S601型MCU的星载能源系统智能化管理技术分析
网络·单片机·嵌入式硬件·安全·架构·系统架构·能源