从 send() 到网线:Linux 网络发包全路径与缓冲区概念辨析
本文是《从网线到策略引擎: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) 为例,数据从用户态到网线要经历下面几个阶段:
(用户空间)"] -->|"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
几个要点:
send()成功返回只代表「数据交给内核了」——不代表已发出、更不代表对端收到。- 数据被拷进该 socket 的发送缓冲区,组织成一串
sk_buff。所以「数据待在发送缓冲区」物理上是「一串 skb 挂在sk_write_queue上」;它早就预留好了 headroom,方便后面往前填各层头部。 - 缓冲区满了怎么办:阻塞 socket 让
send()睡眠等待;非阻塞 socket 返回EAGAIN。这正是高性能网络编程必须处理EAGAIN+epoll可写事件的原因。 - 大小由
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 的一个 clone(
tcp_transmit_skb克隆,数据页共享、不发生拷贝)。TX 完成时驱动照常释放这个 clone;留在重传队列里等 ACK 的是原始 skb——两者是同一份数据的两个引用,谁也不用等谁。由此有个实用推论:发送缓冲区的空间是收到 ACK 时才回收的,不是 TX 完成时。ss里Send-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 → 滑动窗口前移 → 超时重传 本质是多个参与者之间随时间来回的交互,这种「谁先谁后、谁等谁、谁回应谁」用时序图更清楚:
两张图分工:flowchart 看「数据在空间上穿过哪几层缓冲区」,时序图看「收发双方在时间上如何交互」——合起来才是发包的完整图景。
十、把 TCP 的「可靠 + 有序」拆成四个机制 #
| 你看到的现象 | 背后机制 |
|---|---|
| 数据不会丢 | 发送缓冲区留底 + ACK 确认 + 超时/快速重传 |
| 数据不会乱 | 每字节有 seq,对端按序号重排 |
| 不会发太快淹了对端 | rwnd 流量控制(对端通告) |
| 不会发太快淹了网络 | cwnd 拥塞控制(自己估算) |
十一、辨析回填(验证你真的分清了) #
- socket vs socket 缓冲区:前者是「连接的控制对象」,后者是它挂着的「数据队列」。一个管控制,一个存数据。
- socket 缓冲区 vs 网卡 ring:都叫「缓冲区」,但一个是 per-socket 软件队列、跟 TCP 重传强相关;一个是 per-网卡硬件描述符环、跟 DMA 强相关。中间隔着整个协议栈和 qdisc,绝不是挨着的两块内存。
- 网卡缓冲区的两义:内存里的 ring(存地址)≠ 片上 FIFO(存瞬时数据)。
- 内核缓冲区是泛称:对应用看,它是「
send数据拷进去的那块(≈socket 缓冲区)」;对协议栈看,它是「流转中的 skb」。 - 发送方向只有一次大拷贝(用户→内核),之后全靠 skb 指针流转 + 网卡 DMA。
sendfile/splice/io_uring 的SEND_ZC(6.0+;普通 io_uring send 仍有这次拷贝)/AF_XDP/DPDK 做的事,本质就是消掉这一次拷贝、或干脆绕过内核协议栈。 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_user进 socket 发送缓冲区 → 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 帧的两段旅程》。关于网卡中断、多队列与核心隔离对延迟的影响,见《从一次排查看网卡中断与核心隔离的本质》。