一、协议栈全景:从 socket 到网卡的完整旅程
说实话,很多做网络开发的同学,写了几年 socket 代码,对协议栈的理解还停留在「黑盒」阶段。数据发出去就发出去了,收回来就收回来了,中间发生了什么?不清楚。
我个人习惯,在排查性能问题之前,一定先把整条链路画出来。你想想看,如果连数据从用户态到网卡走了哪些函数、经过了哪些队列都不知道,你怎么定位丢包?怎么优化吞吐?
这一章,我们就来把协议栈这层窗户纸捅破。
1.1 协议栈的层次结构
Linux 网络协议栈,说白了就是七层模型在操作系统里的落地实现。但内核里没那么多花哨的层次,它主要分这么几块:
- 系统调用层:用户态通过 socket、send、recv 等接口进入内核
- 协议无关层:struct sock 的管理、socket 文件系统
- 传输层:TCP/UDP 协议实现,包括拥塞控制、重传、校验和
- 网络层:IP 路由、分片、转发、Netfilter 钩子
- 链路层:邻居子系统、ARP、LLC
- 驱动层:NAPI、ring buffer、硬中断/软中断
嗯,这里要注意:每一层之间不是简单的函数调用,而是通过 sk_buff 这个「快递包裹」来传递数据。
核心认知:协议栈的本质,就是 sk_buff 在不同层级之间的流转。你理解了 sk_buff 的来龙去脉,就理解了协议栈的一半。
1.2 数据发送的完整路径
我曾经帮一个团队排查过线上延迟抖动问题。应用层偶尔出现 200ms 以上的延迟,业务方怀疑是内核问题。我让他们在 send 系统调用前后打上 ftrace,结果发现——问题根本不在协议栈,而是 cgroup 的 CPU 限流。但那次排查让我把发送路径重新梳理了一遍,印象很深。
发送路径大致是这样的:
- 系统调用:send() → sock_sendmsg() → inet_sendmsg()
- 传输层:tcp_sendmsg() → tcp_push() → __tcp_transmit_skb()
- 网络层:ip_queue_xmit() → ip_local_out() → dst_output()
- 邻居层:dev_queue_xmit() → __dev_xmit_skb()
- 驱动层:sch_direct_xmit() → ndo_start_xmit() → 写入 ring buffer
- 硬中断:网卡 DMA 发送完成 → 硬中断 → NAPI 轮询
你看,从用户态到网卡,至少经过 6 层、几十个函数。每一层都可能成为瓶颈。
我的建议:排查发送性能问题时,优先关注两个地方——传输层的 TCP 发送缓冲区是否经常满,以及驱动层的 ring buffer 是否丢包。这两个点我踩过太多次坑了。
1.3 数据接收的完整路径
接收路径比发送路径更复杂,因为涉及中断和软中断的调度。我刚开始看这部分代码时,被软中断的上下文切换搞得晕头转向。
- 硬中断:网卡收到数据 → DMA 到 ring buffer → 触发硬中断
- 软中断:ksoftirqd 或 do_softirq() → net_rx_action()
- 驱动层:NAPI poll → 从 ring buffer 取出 sk_buff
- 链路层:__netif_receive_skb_core() → 协议分发
- 网络层:ip_rcv() → ip_local_deliver() → 分片重组
- 传输层:tcp_v4_rcv() → tcp_queue_rcv() → 放入 receive buffer
- 系统调用:recv() → sock_recvmsg() → 拷贝到用户态
为什么会这么复杂?因为内核要处理并发、要保证公平、要防止某个网卡霸占 CPU。NAPI 机制就是为此设计的——在流量高时切换为轮询模式,减少中断开销。
避坑指南:我曾经遇到过一个问题——接收路径上,软中断占用了单个 CPU 核 100%,导致其他进程得不到调度。原因是 RPS (Receive Packet Steering) 没配置,所有流量都打到了一个核上。后来开了 RPS,把软中断分散到多核,吞吐直接翻倍。
1.4 关键数据结构:sk_buff
sk_buff 是整个协议栈的「血液」。它贯穿每一层,承载着数据、控制信息和元数据。
它的结构非常庞大,我挑几个最关键的字段说:
| 字段 | 作用 | 我的经验 |
|---|---|---|
| head / data / tail / end | 数据指针,控制协议头操作 | 调试内存越界时,先看这四个指针是否合理 |
| len / truesize | 数据长度和实际占用内存 | truesize 过大可能导致 socket 内存超限 |
| sk | 指向所属的 sock 结构 | 抓包时通过 sk 关联 socket 状态 |
| dev | 输入/输出网络设备 | 多网卡环境下,这个字段容易搞混 |
| cb[] | 控制块,各层私有数据 | TCP 用它存序列号,IP 用它存路由信息 |
| destructor | 释放回调函数 | 内存泄漏排查时重点关注 |
sk_buff 最巧妙的设计是它的「头部预留」机制。head 和 data 之间有一段空白,协议栈在向下传递时,每层都可以通过 push 操作在头部添加自己的协议头,不需要重新分配内存。这个设计,说白了就是空间换时间。
性能要点:sk_buff 的分配和释放是协议栈最大的开销之一。所以内核有 sk_buff 缓存池 (skb_cache),以及 slab 分配器来复用对象。你在 perf 里看到 skb_alloc 和 kfree_skb 频繁出现,说明协议栈在拼命分配内存——这时候就该考虑零拷贝或者大页了。
1.5 关键数据结构:net_device
net_device 代表一个网络接口。它不只是一个网卡,也可能是 bond、vlan、bridge、tun/tap 等虚拟设备。
它的字段有上千行,但日常排查问题,你只需要关注这几个:
- flags:IFF_UP、IFF_RUNNING 等状态位。ifconfig 看到的 UP 和 RUNNING 就是从这里来的
- mtu:最大传输单元。MTU 不一致会导致分片,我见过很多性能问题都是 MTU 没对齐
- features:硬件 offload 能力。比如 TSO、GSO、GRO、LRO。这些特性没开对,CPU 消耗会飙升
- rx_queue / tx_queue:ring buffer 队列。丢包时先查这里
- netdev_ops:驱动函数表。ndo_start_xmit 是发送的最后一站
我的习惯:拿到一台新机器,第一件事就是 ethtool -k eth0 查看 offload 特性。很多云厂商默认关闭了 GRO 和 TSO,你得手动打开。另外,ethtool -g eth0 看 ring buffer 大小,默认 256 的队列在 10G 网卡上很容易丢包,我一般调到 4096。
1.6 关键数据结构:sock
sock 是传输层的核心结构。它代表一个网络连接(TCP)或一个端点(UDP)。
它的字段可以分成几类:
| 类别 | 关键字段 | 说明 |
|---|---|---|
| 连接状态 | sk_state | TCP 状态机:ESTABLISHED、CLOSE_WAIT 等 |
| 接收缓冲 | sk_receive_queue / sk_rmem_alloc | 接收数据队列和已分配内存 |
| 发送缓冲 | sk_write_queue / sk_wmem_alloc | 发送数据队列和已分配内存 |
| 拥塞控制 | sk_pacing_rate / sk_sndbuf | pacing 速率和发送缓冲区大小 |
| 内存限制 | sk_rcvbuf / sk_sndbuf | 接收/发送缓冲区的上限 |
| 回调函数 | sk_data_ready / sk_write_space | 数据到达或可写时触发 |
这里有个容易踩的坑:sk_rcvbuf 和 sk_sndbuf 是内核自动调整的,但调整范围受 net.core.rmem_max 和 net.core.wmem_max 限制。我曾经遇到过应用层设置了 SO_RCVBUF 为 16MB,但内核最大值只有 2MB,结果设置被静默截断——接收性能一直上不去。
避坑指南:检查 socket 缓冲区大小时,不要只看应用层的 setsockopt 返回值。用 ss -m 查看实际生效的 rcvbuf 和 sndbuf,那才是内核真正用的值。我踩过这个坑,排查了两天才发现是内核参数限制了应用层的设置。
1.7 协议栈全景图
下面这张图,是我每次培训必画的。它把 socket、sock、sk_buff、net_device 以及各层之间的流转关系串在了一起。
这张图里,左侧的箭头是发送路径,右侧是接收路径。你可以看到,sk_buff 从用户态一路向下,经过每一层的处理,最终到达网卡。接收路径则反过来。
三个关键数据结构分布在不同的层级:sock 在传输层,sk_buff 贯穿全栈,net_device 在驱动层和链路层。它们之间通过指针相互引用——sk_buff 里有 sk 和 dev,sock 里有 sk_buff 队列,net_device 里有队列和回调。
1.8 本章小结
这一章我们做了三件事:
- 画出了协议栈的完整层次,从 socket 到网卡
- 走通了发送和接收两条数据流路径
- 拆解了三个核心数据结构:sk_buff、net_device、sock
嗯,说实话,这些内容看起来基础,但它是后面所有性能分析和故障定位的根基。你想想看,如果连数据在哪一层丢的都不知道,你怎么定位问题?如果连 sk_buff 的指针都不清楚,你怎么理解零拷贝?
我个人建议,看完这一章后,去你的开发机上跑一下 perf,看看实际的数据流是不是和图上画的一致。纸上得来终觉浅,绝知此事要躬行。