Skip to main content

从 send() 到网线:Linux 网络发包全路径与缓冲区概念辨析

·5 mins

本文是《从网线到策略引擎:Linux 网络收包全路径深度解析》的镜像篇。收包讲的是「数据如何从网卡到达用户态」,本文反过来,讲一个本地用户态的数据如何通过 TCP 发送到对端,并顺带把几个最容易混淆的概念——网卡、socket、socket 缓冲区、网卡缓冲区、内核缓冲区——一次性辨析清楚。


〇、先把概念辨析清楚(这是理解全流程的地基) #

很多人讲发包讲不清,根子在于把不属于同一层的东西当成一回事。这几个词其实分属三个层次

1. socket —— 是「对象」,不是「缓冲区」 #

socket 是内核里的一个数据结构,代表「一条通信端点」。你 socket() 拿到的 fd,背后就是它。它记着源/目的 IP、端口、协议、连接状态、收发队列指针、TCP 状态机变量等。

socket 是「这条连接的档案 + 控制中心」,本身不存数据,但挂着两个缓冲区

2. socket 缓冲区 —— per-socket 的软件收发队列 #

每个 socket 自带两个队列:

  • 发送缓冲区(send buffer,SO_SNDBUF):send() 写进来的数据先攒在这。对 TCP,它还兼任重传底本——没收到 ACK 的数据必须留在这里。
  • 接收缓冲区(recv buffer,SO_RCVBUF):收到的数据攒在这等 recv()

它在内核内存里,由协议栈管理。

3. 内核缓冲区 —— 一个泛称 #

这个词最容易含糊,它不是某个具体东西

  • 狭义场景下,「内核缓冲区」常就指上面的 socket 缓冲区——相对「用户缓冲区」而言,数据从用户态拷进内核态的那块。
  • 广义上,还包括数据在协议栈里流转时的载体——sk_buff(skb):内核描述一个网络包的核心结构,封装 TCP/IP/以太网头就是往它上面填。

记法:用户缓冲区 ↔ 内核缓冲区,是「用户态 vs 内核态」这条线;socket 缓冲区是内核缓冲区里最靠近应用的那一段。

4. 网卡(NIC)—— 硬件 #

真正把数据变成电信号/光信号送上线的 PCIe 硬件设备。CPU 通过「描述符 + 寄存器(门铃)+ DMA + 中断」这套接口跟它打交道。

5. 网卡缓冲区 —— 一个多义词(务必拆成两层) #

  • (a) 内存里的环形描述符队列(ring buffer,TX/RX ring):本质是个数组,每个槽位(描述符)记着「某个包的数据在物理内存哪个地址、多长」。注意它存的是地址/描述符,不是数据本身。 ethtool -g 看的就是它的大小。
  • (b) 网卡芯片内部的 FIFO:片上一小块 SRAM,吸收 PCIe 总线速率和网线线速之间的差。这是最「硬」的那层硬件缓冲。

写博客/面试时,说「网卡缓冲区」一定要讲清你指的是 ring 还是片上 FIFO。

一张表收口 #

概念层次位置存什么粒度
socket控制结构内核内存连接状态/元数据per-连接
socket 缓冲区软件队列内核内存真实数据(skb 链)per-socket
内核缓冲区泛称内核内存socket 缓冲区 / skb
网卡 ring buffer软硬件交界主存 DMA 区描述符(地址)per-队列
网卡片上 FIFO硬件网卡 SRAM真实数据(瞬时)per-网卡
网卡(NIC)硬件PCIe 设备物理设备

核心区分:socket 缓冲区是软件协议栈的暂存区;网卡缓冲区是硬件 DMA 的暂存区。两者中间隔着整个协议栈 + qdisc,并不是挨着的两块内存。


一、全景概览 #

以一个已连接的 TCP socket 调用 send(fd, buf, len, 0) 为例,数据从用户态到网线要经历下面几个阶段:

flowchart LR U["user buf
(用户空间)"] -->|"copy_from_user
⚡ring 3→0"| B1["socket send buffer
(SO_SNDBUF, skb 链)"] B1 -->|"TCP 切段 + seq
min(rwnd,cwnd)"| B2["IP 封装
查路由 + ARP"] B2 --> B3["qdisc
(软件队列/QoS)"] B3 -->|"驱动入队 + 门铃"| B4["TX ring
(描述符: 地址)"] B4 -->|"DMA"| B5["NIC FIFO
(片上 SRAM)"] B5 -->|"PHY 编码"| W["网线 → 对端"] classDef kernbuf fill:#fff3e0,stroke:#ef6c00,color:#000 classDef userland fill:#e8f5e9,stroke:#2e7d32,color:#000 classDef nic fill:#eceff1,stroke:#37474f,color:#000 class B1,B2,B3,B4 kernbuf class U userland class B5,W nic

收方向完全镜像:网线 → 片上 FIFO → DMA 进 RX ring → NAPI 轮询/中断 → 协议栈剥头、按 seq 重排 → socket 接收缓冲区 → 进程 recv()copy_to_user 拷回用户态。这部分见收包全路径


二、前提:连接已经建好 #

TCP 是面向连接的,发数据前先有三次握手,结果是双方内核里各有一个 socket,记着一堆状态

  • 自己的发送序号 snd_nxt、已被确认的序号 snd_una
  • 对端通告的接收窗口 rwnd、自己算出来的拥塞窗口 cwnd
  • 重传定时器、RTT 估计等

关键心智模型:TCP 是一条字节流,没有「消息边界」。你 send 三次 100 字节,对端可能一次 recv 到 300,也可能分两次收到 150+150。内核只保证字节有序、不重不漏,不保证你的「消息」被原样切分。


三、第一步:send() —— 数据进 socket 发送缓冲区 #

send(fd, buf, 100, 0)
   │ copy_from_user        ← 用户缓冲区 → 内核缓冲区,唯一一次大拷贝
   ▼
socket 发送缓冲区:  [已发未确认 | 待发送 ........ ]
                    snd_una    snd_nxt

几个要点:

  1. send() 成功返回只代表「数据交给内核了」——不代表已发出、更不代表对端收到。
  2. 数据被拷进该 socket 的发送缓冲区,组织成一串 sk_buff。所以「数据待在发送缓冲区」物理上是「一串 skb 挂在 sk_write_queue 上」;它早就预留好了 headroom,方便后面往前填各层头部。
  3. 缓冲区满了怎么办:阻塞 socket 让 send() 睡眠等待;非阻塞 socket 返回 EAGAIN。这正是高性能网络编程必须处理 EAGAIN + epoll 可写事件的原因。
  4. 大小由 SO_SNDBUF / net.ipv4.tcp_wmem 控制。

你这一步问的「数据待在的缓冲区」就是 socket 发送缓冲区,它也属于「内核缓冲区」这个泛称——只是「内核缓冲区」范围更大、不专指它。


四、第二步:TCP 层 —— 决定何时发、发多少、怎么切 #

数据进了发送缓冲区,不是立刻全发,由 TCP 状态机控制节奏:

可发送字节 = min(rwnd 对端接收窗口, cwnd 拥塞窗口) − 在途未确认字节(snd_nxt − snd_una)
  • rwnd(接收窗口):对端通告「我接收缓冲区还剩多少」→ 流量控制,别淹了对端。
  • cwnd(拥塞窗口):自己按丢包/RTT 估的「网络能扛多少」→ 拥塞控制,别淹了网络。慢启动让 cwnd 从很小开始,所以刚建连发得慢,网络好了越发越快。

谁小听谁的。取出能发的字节,切成 TCP 段,每段加 TCP 头:

  • 序号 seq:这段第一个字节在整条流里的编号 → 对端靠它排序、去重
  • 确认号 ack:捎带「我期望收到对端的下一个字节号」
  • 校验和、窗口、标志位等

「按 MSS 切段」在现代系统上是逻辑描述,不是物理事实。内核默认开启 TSO(TCP Segmentation Offload,网卡不支持时退化为软件 GSO):TCP 层实际构造的是最大可到 64KB 的「超大段」,一路以单个 skb 的身份穿过 IP 层和 qdisc,真正切成 MSS 大小的动作发生在网卡硬件里(GSO 则发生在进驱动前的最后一刻)。好处是协议栈的 per-packet 固定开销被摊薄到几十分之一——这正是收包方向 GRO 的镜像。ethtool -k <iface> | grep segmentation 可查看开关状态。

snd_nxt 前移,启动重传定时器。注意:发出去的字节仍然留在发送缓冲区,等 ACK——这就是 TCP 可靠性的物理依托,没有它重传无从谈起。Nagle 算法还可能攒一攒小包再发;对延迟敏感的场景(交易系统几乎无例外),建连后第一件事就是用 TCP_NODELAY 把它关掉,让小包立即出门。


五、第三步:IP 层 + 邻居子系统 —— 封装与寻路 #

  • IP 层:加 IP 头,查路由表决定走哪个网卡和下一跳。(教科书会说「必要时分片」,但 TCP 路径上 IP 分片实际几乎不会发生:PMTUD 默认置 DF 位,段大小由 MSS 保证不超 MTU。真正会被分片的主要是大 UDP 包。)
  • 邻居子系统:用 ARP 解析下一跳 MAC,加以太网帧头。

这一路数据本身不再拷贝,操作的都是同一个 skb,只是不断往预留的 headroom 里填各层头部。这就是「数据在内核缓冲区里流转」的真实含义——它从「挂在 socket 发送队列上的 skb」变成「在协议栈/qdisc 里流转的 skb」,但始终是同一块内存。


六、第四步:qdisc → 网卡 TX ring —— 进入发送队列 #

skb
 │  入队
 ▼
qdisc(软件排队规则: QoS / 限速 / 优先级,tc 命令调的就是它)
 │  驱动出队、挂到 ring
 ▼
TX ring buffer(环形描述符数组): 写入「skb 数据物理地址 + 长度」
 │  写网卡寄存器“敲门铃”(doorbell)
 ▼
通知网卡: 有包要发

特别强调:ring 里放的是描述符(指针/地址),数据还在原来的内核内存(skb)里没有动。这是初学者最容易误解的一点——以为「进 ring」就是「数据被拷进网卡」,其实只是把地址登记了一下。


七、第五步:网卡 DMA → 上线 —— CPU 退场 #

网卡看到门铃
 → DMA 引擎按描述符里的地址,直接从主存搬数据进片上 FIFO(不经过 CPU)
 → MAC/PHY 编码成电/光信号 → 网线 → 下一跳
 → 发送完成触发 TX 中断(或攒一批再中断,减少中断风暴)
 → 驱动回收描述符、释放自己持有的 skb(见下面的 clone 说明)
  • DMA + 描述符 + 门铃 + 中断 是 CPU 和网卡协作的标准四件套。
  • CPU 全程只做了「放描述符 + 敲门铃」,数据搬运是网卡 DMA 干的。这正是零拷贝技术能省的地方。
  • 对 TCP,一个容易讲错的细节:交给驱动的其实是原始 skb 的一个 clonetcp_transmit_skb 克隆,数据页共享、不发生拷贝)。TX 完成时驱动照常释放这个 clone;留在重传队列里等 ACK 的是原始 skb——两者是同一份数据的两个引用,谁也不用等谁。由此有个实用推论:发送缓冲区的空间是收到 ACK 时才回收的,不是 TX 完成时ssSend-Q 迟迟不降,反映的是 ACK 没回来,而不是网卡没发出去。

八、第六步:对端收到 → 回 ACK #

网卡 → 协议栈剥头 → 按 seq 检查
   ├ 正好是期望的下一段 → 放进 socket recv buffer,推进期望序号
   ├ 乱序到达(前面的还没来)→ 先存着,发重复 ACK 催前面那段
   └ 重复/已收过 → 丢弃,但仍回 ACK
        │
        ▼
   回 ACK: ack = 期望的下一个字节号;同时通告自己最新的 rwnd

对端进程 recv() 时,才把数据从 recv buffer copy_to_user 拷回用户态。

收到 ACK ≠ 对端进程读到了,只表示「对端内核收下了」。进程 recv 才真正拿到数据。


九、第七步:发送方收 ACK → 滑动窗口前移 #

收到 ack=N → snd_una 推进到 N → [旧 snd_una, N) 这段从发送缓冲区释放
          → 窗口右边界右滑 → 可以发新数据了
超时没收到 → 重传该段,且认为网络拥塞 → cwnd 大降、重新慢启动

这就是滑动窗口:左边被确认就释放、右边随确认和窗口往前开,数据像传送带一样持续流出。超时重传是 TCP 可靠性的核心兜底。


九·五、把发送过程画成时序图 #

第一节的 flowchart 画的是空间——数据穿过哪几层缓冲区;而 send → 切段发出 → 对端回 ACK → 滑动窗口前移 → 超时重传 本质是多个参与者之间随时间来回的交互,这种「谁先谁后、谁等谁、谁回应谁」用时序图更清楚:

sequenceDiagram participant App as 用户进程 participant Snd as socket发送缓冲区 participant TCP as TCP/IP协议栈 participant NIC as 本地网卡 participant Peer as 对端(内核+进程) App->>Snd: send() → copy_from_user Note right of App: 返回≠送达,仅“交给内核” Note over Snd: 存为skb,等TCP调度 Snd->>TCP: 取min(rwnd,cwnd)字节,切MSS段+seq TCP->>NIC: IP/以太网封装 → qdisc → TX ring NIC->>Peer: DMA上线,seq=X Note over Snd: skb留在重传队列,启动定时器 Peer->>Peer: 按seq重排,入recv buffer Peer-->>NIC: ACK(ack=X+len, 通告rwnd) NIC-->>Snd: 收到ACK Note over Snd: snd_una前移,释放已确认skb,窗口右滑 alt 超时未收到ACK Note over Snd,NIC: 重传定时器触发 Snd->>NIC: 重传该段,cwnd大降→慢启动 end

两张图分工:flowchart 看「数据在空间上穿过哪几层缓冲区」,时序图看「收发双方在时间上如何交互」——合起来才是发包的完整图景。


十、把 TCP 的「可靠 + 有序」拆成四个机制 #

你看到的现象背后机制
数据不会丢发送缓冲区留底 + ACK 确认 + 超时/快速重传
数据不会乱每字节有 seq,对端按序号重排
不会发太快淹了对端rwnd 流量控制(对端通告)
不会发太快淹了网络cwnd 拥塞控制(自己估算)

十一、辨析回填(验证你真的分清了) #

  1. socket vs socket 缓冲区:前者是「连接的控制对象」,后者是它挂着的「数据队列」。一个管控制,一个存数据。
  2. socket 缓冲区 vs 网卡 ring:都叫「缓冲区」,但一个是 per-socket 软件队列、跟 TCP 重传强相关;一个是 per-网卡硬件描述符环、跟 DMA 强相关。中间隔着整个协议栈和 qdisc,绝不是挨着的两块内存。
  3. 网卡缓冲区的两义:内存里的 ring(存地址)≠ 片上 FIFO(存瞬时数据)。
  4. 内核缓冲区是泛称:对应用看,它是「send 数据拷进去的那块(≈socket 缓冲区)」;对协议栈看,它是「流转中的 skb」。
  5. 发送方向只有一次大拷贝(用户→内核),之后全靠 skb 指针流转 + 网卡 DMA。sendfile/splice/io_uring 的 SEND_ZC(6.0+;普通 io_uring send 仍有这次拷贝)/AF_XDP/DPDK 做的事,本质就是消掉这一次拷贝、或干脆绕过内核协议栈
  6. send() 返回 ≠ 送达对端 ACK ≠ 对端进程读到。前者只到内核发送缓冲区,后者只到对端内核接收缓冲区。

十二、常用排查命令 #

目的命令
查看 socket 收发缓冲区使用/积压ss -tnpm(看 Send-Q / Recv-Q
查看/调发送缓冲区上限sysctl net.ipv4.tcp_wmem
查看 TX/RX ring 大小ethtool -g <iface>
查看网卡发送丢包/错误ethtool -S <iface> | grep -i 'tx|drop'
查看 qdisc 配置tc -s qdisc show dev <iface>
查看协议栈发送侧统计netstat -s | grep -i 'segments|retrans'
追踪单包发送延迟perf trace -e net:*bpftrace

十三、总结 #

一个本地用户态的数据通过 TCP 发到对端,可以浓缩成一句话:

用户数据 copy_from_usersocket 发送缓冲区 → TCP 在 min(rwnd,cwnd) 允许的范围内切成带 seq 的段 → 经 IP/以太网封装、qdisc、网卡 TX ring、DMA 上线 → 对端按 seq 重排放进 接收缓冲区并回 ACK → 发送方收 ACK 后滑动窗口、释放缓冲、继续发;超时则重传。

理解的关键是分清三层缓冲区:socket 缓冲区是软件协议栈的暂存区(也是 TCP 重传底本),网卡 ring 是放地址的硬件描述符环,片上 FIFO 是最硬的瞬时缓冲——它们之间隔着整个协议栈,绝不是挨着的。

延伸阅读:收包方向的完整路径(NIC → DMA → 硬中断 → NAPI → 协议栈 → socket buffer → 用户态),见《从网线到策略引擎:Linux 网络收包全路径深度解析》。关于 socket 缓冲区与应用层缓冲区的区别、以及缓冲区不足导致丢包的真实案例,见《深度解析UDP高丢包问题》。关于绕过内核协议栈、消掉拷贝的两条路径对比与纳秒级实测,见《kernel socket vs DPDK:一条 WebSocket 帧的两段旅程》。关于网卡中断、多队列与核心隔离对延迟的影响,见《从一次排查看网卡中断与核心隔离的本质》