
1. 我把网卡收包这件事彻底拆开之后才发现没那么简单我以前写过不少业务层的东西自认为对网络编程挺熟了直到有一次排查线上抖动顺着一个诡异的延迟一路往下翻最后不得不钻进内核源码里才意识到自己对于“报文到底怎么从网线进到进程手里”这件事的理解其实稀碎。从那之后我花了好几周时间专门把 Linux 内核收报文的完整链路过了一遍包括驱动怎么把数据搬进内存、软中断怎么调度、协议栈怎么剥头、数据又是怎么被塞进 socket 接收队列的。这篇东西不是教科书式的源码逐行注释而是我做完这件事之后的一个梳理。我会按照报文从硬件到应用的流动顺序来写每到一个关键节点就停下来解释为什么内核要这么设计、有什么实际影响、哪些地方容易踩坑。如果你是做网络性能调优、嵌入式开发、或者单纯想把 TCP/IP 和内核机制吃透的运维或后端这篇文章应该能给你省下不少查源码的时间。把“收报文”这三个字拆开其实是三层事情第一层是网卡驱动把硬件收到的数据搬到内存第二层是内核的中断系统和软中断机制决定什么时候去处理这些数据第三层是协议栈和 socket 层决定数据最终怎么交给应用。每一层都有自己的瓶颈也都有自己的优化空间而你平时在业务代码里看到的各种“玄学问题”最后基本都能在这三层里找到答案。2. 数据进内存之前网卡驱动如何把报文“捞”上来2.1 网卡不是收到数据就立刻打断 CPU 的很多人有个误解觉得网卡一收到数据就会立刻发一个中断告诉 CPU“快来看”。早期网卡确实是这么干的每来一个包就触发一次中断CPU 每处理一个包就要从当前的事情里被硬生生拉出来。如果流量稍微大一点比如每秒进来几万个包CPU 就会一直在中断上下文里打转用户态的进程反而得不到执行时间。这就是所谓的中断风暴。现代的网卡驱动早就换了思路核心是把“通知 CPU”这件事做得尽量合并化、批量化。NAPI 机制就是这个思路的典型代表。网卡先把收到的报文通过 DMA 直接写进一片预先分配好的内存缓冲区里也就是 ring buffer环形缓冲区然后当缓冲区里的报文积累到一定数量或者到达一个比较合理的时机才触发一次中断告诉 CPU“这里有一批数据你自己来取”。这种方式极大地减少了中断次数把从“每个包打断一次”变成了“一批包打断一次”。2.2 Ring Buffer 和 DMA数据搬运不靠 CPU 也能完成要理解收包一定绕不开 DMA 和 Ring Buffer。DMA 的全称是 Direct Memory Access它存在的意义就是让网卡可以直接把报文写到内存中而完全不占用 CPU 的参与。CPU 要做的事只是提前告诉网卡“我的这块内存地址在哪你能存多少数据”剩下的搬运工作全部交给网卡上的 DMA 引擎完成。Ring Buffer 则是网卡驱动和 DMA 之间的一座桥。它本质上是一个固定大小的环形队列队列里的每个元素指向一个 sk_buff后面会细讲网卡每收一个包就会按顺序把数据放到下一个可用位置。驱动层通过维护 producer 和 consumer 两个指针来管理这块队列的读写位置。这里有个容易被忽略的性能关键点Ring Buffer 的大小是可以调整的而且不同网卡有不同的默认值。我就遇到过一种情况某个多队列网卡的 ring buffer 默认只有 256 个描述符一旦突发流量进来队列很快就满了新的包直接被网卡丢弃而业务侧的表现在 TCP 层面却只是看起来“稍微慢了一点”很难察觉是驱动层的丢包。用ethtool -g eth0就能看到当前设备的 ring buffer 大小ethtool -G eth0 rx 4096则可以调整。这个操作是成本最低的网络调优手段之一很多人却根本不知道。2.3 多队列网卡与 RSS收包这件事也讲并行单核处理网络收包的时代早就过去了。现代网卡都支持多队列配合 RSSReceive Side Scaling机制可以把不同的报文根据哈希结果分发到不同的队列上每个队列由不同的 CPU 核心来处理相当于把收包这件事从单线程变成了多线程。RSS 的哈希一般基于四元组源 IP、目的 IP、源端口、目的端口。这样做的好处不言而喻——同一个 TCP 连接的所有报文哈希结果一致会落到同一个队列、同一个 CPU 上这样就能保证同一个连接的数据包不会因为被不同 CPU 处理而产生乱序或者锁竞争问题。这里衍生出一个非常实际的排查技巧如果你发现某个网卡的多个队列中断全部落在同一个 CPU 上那说明中断绑定的设置有问题。操作系统默认是让网卡驱动自己去选 CPU 的但很多时候选得并不聪明手动配置一下中断亲和性IRQ affinity比如把 eth0 的 16 个队列分别绑定到 16 个不同的核心上往往能带来非常可观的性能提升。这也是我从那次线上抖动排查中学到的一个重要工具。3. 中断之后的事软中断机制决定“多久才轮到协议栈处理”3.1 硬中断只负责“标记”不做重活即便有了 NAPI 做批量中断内核也依然把中断处理严格分成了两段上半部硬中断和下半部软中断。硬中断的部分极其精简基本就是关掉当前网卡队列的中断把 NAPI 调度的结构体挂到当前 CPU 的待处理队列里然后唤醒 ksoftirqd 对应的软中断线程就完事了。硬中断做这么少的事情背后是有一个惨痛教训的中断上下文里不能睡眠、不能长时间持有锁、不能做任何阻塞操作否则整个系统的响应性能都会崩掉。所以内核的设计哲学就是把“赶紧干完”的事情放进硬中断把“可以仔细干”的事情放进软中断两个阶段之间用 NAPI 的 poll 函数作为桥梁。3.2 ksoftirqd 线程当软中断做不完时的“备胎”软中断首先会在硬中断返回路径中被处理比如中断返回用户态之前内核会检查是否有待处理的软中断如果有就直接处理。这样做的优势是响应非常快不需要额外的上下文切换开销。但这里有一个隐患如果某个 CPU 上待处理的软中断过多处理软中断的时间就会急剧膨胀导致用户态的进程长期得不到调度整个系统的交互响应会卡顿。Linux 的解决办法是通过ksoftirqd这个内核线程来做兜底。每个 CPU 都有一个名为ksoftirqd/n的线程n 是 CPU 编号。当软中断的处理耗时超过一定阈值比如连续处理完一轮软中断之后又发现有新的软中断挂起内核就会放弃继续在当前上下文处理转而唤醒ksoftirqd来慢慢处理保证用户态程序不被饿死。这个机制在实际生产中很好观察如果你发现top里ksoftirqd的 CPU 占用居高不下比如持续占用超过 50%那说明网络收包量已经对 CPU 形成了真实压力需要开始考虑多队列绑核、调大 ring buffer、或者干脆升级硬件。3.3 NAPI 的 poll 机制收包处理的核心入口NAPI 的核心是一个轮询函数在驱动层的结构体里注册名字通常叫ixgbe_poll或者igb_poll看网卡而定。软中断处理阶段内核会对当前 CPU 上每个注册了 NAPI 的设备执行 poll 操作每次 poll 都会尽量地从 ring buffer 里取出报文组织成sk_buff然后交给后续的协议栈处理。NAPI 有一个非常重要的参数叫netdev_budget默认值一般是 300它限制的是“一次软中断处理循环中每个 CPU 上所有设备总共最多能处理的报文数量”。如果你的网络流量非常极端比如每秒百万级的 PPS 攻击包那么即使开启了 NAPI单轮的预算也可能在极短的时间内耗尽。这个事情的实际影响是在高 PPS 场景下不止是应用层 CPU 吃紧内核软中断本身的处理能力也有天花板。我见过一些做游戏服务器的人习惯把/proc/sys/net/core/netdev_budget调得很大比如调到 2000 甚至更高试图让内核在一次处理中吞掉更多报文。这个思路本身没错但必须考虑副作用单次软中断处理占用 CPU 的时间越长中断延迟相应也会变大你的网络延迟指标反而可能劣化。更合理的做法往往是配合多队列和 CPU 绑核让报文均匀散到多个核心上每个核心的预算压力自然就小了。4. 协议栈的逐层剥壳从以太网头到 TCP 状态机的流转4.1 sk_buff贯穿网络栈的核心数据结构在进入协议栈的细节之前必须先把sk_buff讲清楚因为它是整个内核网络收发路径上最核心的数据结构几乎每个函数都在操作它。一个sk_buff里除了保存报文数据本身以外还维护了大量的元信息报文到达的设备、协议类型、网络命名空间、各层头部的位置指针、校验和状态、时间戳等等这些信息加在一起才是内核理解一通报文的基础。sk_buff的设计有一个非常精妙的地方它允许不同协议层“共享”同一个数据缓冲区并且通过指针偏移来定位各自的头部。比如 skb-data 原本指向的是网络层的头部到了传输层处理时代码通过skb_pull_inline之类的操作把它向后移动使协议栈不用重复拷贝数据就能从同一个缓冲区里依次解读不同层的头部。这种零拷贝思路在整个网络栈里贯穿始终也是理解后续 GRO 等机制的前提。4.2 二层处理先判类型再决定走哪条栈报文从网卡驱动进入协议栈后的第一站是__netif_receive_skb_core这个函数它做的事情可以用一句话概括如果注册了二层协议处理函数就交给它否则就按以太网类型字段分发到对应的协议处理入口。常见的以太网类型包括 0x0800 表示 IPv4、0x0806 表示 ARP、0x86DD 表示 IPv6。这里特别想提一下二层数据的特殊性。大部分入门教程讲网络模型的时候眼睛全盯在 TCP/IP 上导致很多人下意识认为所有报文都是 IP 包。但实际上内网环境里 ARP 报文的数量极其庞大尤其是刚开机或者有机器重新连上交换机的那几分钟如果内核软中断压力很高而/proc/net/snmp里的ArpFilter或InReceives有异常波动那你得意识到这不仅仅是 TCP 的事情二层的处理路径同样要吃 CPU。4.3 IP 层拆包重组、路由查找与本地投递的判断IPv4 报文进入ip_rcv之后内核要做的第一件事是检查头部合法性校验版本、头部长度、校验和然后把报文交给ip_rcv_finish。后者会进行路由查找通过查路由表来确定这个报文是应该被转发到别的机器上还是应该投递到本机。如果是投递本机的报文内核会把它交给ip_local_deliver。在这里有一个非常关键的分叉如果这个报文是一个分片它会被先收集到分片重组队列中等待所有分片到齐之后才会被拼成一个完整的 IP 包交给上层。分片重组是个极其消耗资源的操作不仅占内存还会因为等待其他分片而引入额外的延迟。这也是攻击者喜欢发送大量分片包的原因之一让接收方 CPU 卡在重组队列上。在ip_local_deliver后续的处理中内核还会根据 IP 头里的协议字段做一次分流TCP 的报文会被送到tcp_v4_rcvUDP 的报文会被送到udp_rcvICMP 的被送到icmp_rcv。这一步的分流依赖的是提前注册好的协议处理函数逻辑非常清晰。4.4 TCP 层接收路径上真正复杂的地方tcp_v4_rcv是整条链路上复杂度的巅峰。TCP 协议是有状态的所以这一步要先查 socket 哈希表找到属于同一个四元组的 socket。这个查找过程本身就有讲究为了保证高并发下的效率内核把已建立连接的 socket 组织在多级哈希表中用四元组作为哈希键再用一个锁机制来保证并发安全。找到 socket 之后TCP 会做序列号校验、确认号更新、窗口更新然后把数据段塞进 socket 的接收队列。对于乱序到达的报文内核并不会直接丢掉而是会放进一个乱序队列等前面的空洞被填满后再合并进按序队列。这里也很好理解TCP 必须要保证应用层拿到的数据是连续的。还有一个经常出现在性能调优指南里的机制叫 GROGeneric Receive Offload它的本质是把同一连接内连续到达的多个小包合并成一个大的sk_buff交给上层。合并之后协议栈处理每个包的平均开销就大大降低了。TCP 的接收端其实天然受益于 GRO因为 TCP 的连续数据段可以被合并而应用层根本感知不到这种合并。关键是你要意识到如果为了调试目的关闭了 GRO比如为了抓包看到每一个原始报文那你的吞吐量测试结果会立刻变得难看这不是协议栈退化了而恰恰说明 GRO 在正常工作。5. 从内核到应用socket 接收队列与唤醒策略的秘密5.1 socket 接收队列协议栈和应用之间的缓冲区协议栈处理完毕的报文最终会被挂到对应 socket 的接收队列里。这个队列在内核里由两个链表和一个sk_buff头组成一个链表放已经按照序列号排好序的数据一个链表放乱序的报文。应用层调用recv或者read时就是从这个队列的头开始把数据拷贝到用户空间的缓冲区中。这个环节的性能影响点在于“拷贝”。协议栈把数据放进内核队列时是一份数据应用层读取时又要拷贝一份到用户空间这就是传统 read 系统调用的两次拷贝开销。为了优化这一点内核和网卡驱动提供了各种零拷贝方案比如sendfile、splice、以及基于 DMA 的SO_ZEROCOPY等。每种方案适用的场景不一样但核心思想都是尽量绕过内核的中间缓冲让数据从内核态直接流向用户态的目标介质。5.2 epoll 唤醒与批量处理为什么 epoll 比 select 快那么多应用层感知“有数据到了”的机制通常不是去主动轮询而是依赖 IO 多路复用。这里值得细说的是内核在把数据挂到 socket 接收队列之后会调用sk_data_ready回调这个回调最终会唤醒正在 epoll_wait 中等待的进程。epoll 之所以比 select 高效除了因为它的事件由内核维护还有一个关键点内核在唤醒时支持批量处理。比如同一时刻有多个 socket 都收到了数据epoll 会一次性地把这个进程加入到多个 socket 的等待队列中并把对应的事件统一放进就绪列表等进程被唤醒后可以一次性取走多个事件。相比之下select 每次调用都要把关心的 fd 集合从用户态拷贝到内核态再从内核态拷贝回去当 fd 数量变得很大时光拷贝就要耗费大量 CPU。一个实用的经验是如果应用层 QPS 高单次 epoll_wait 返回的事件往往不止一个一定要用循环把所有事件处理完再重新进入 wait。很多人写这类事件循环时偷懒只在返回一个事件后就 continue在高流量下会人为放大系统调用次数。5.3 接收缓冲区大小的调节逻辑socket 接收缓冲区的大小会直接影响 TCP 的流控行为。net.ipv4.tcp_rmem这个内核参数定义了 TCP 接收缓冲区的最小值、默认值和最大值。窗口大小是基于接收缓冲区的大小来计算的如果缓冲区不够大即使网络带宽充足TCP 的接收窗口也会变小从而限制发送端的速率。我在实际调优中常用的手段是用 sysctl 临时把tcp_rmem的最大值调大比如从默认的六兆字节左右调到十六兆字节同时设置好tcp_wmem再看应用层是否需要调用 setsockopt 设置 SO_RCVBUF 来真正用到这部分空间。内核默认会允许应用把 socket 接收缓冲区调大到与系统参数上限一致但前提是应用层面真的设置了 SO_RCVBUF。很多高性能服务框架已经帮你处理好了这一步但如果你自己从头写的话别漏了。6. 内核收报文全景一条链路串起来之后能做什么6.1 从网卡到应用的一次完整旅程把前面几章的内容串起来一次“收报文”的完整旅程大概是这样的网卡收到物理层传来的电信号或光信号解析成以太网帧DMA 把数据写入 ring buffer 对应的内存页。网卡触发硬中断CPU 在硬中断上下文里关掉该队列的中断挂载 NAPI 实例到当前 CPU 的 softnet 处理列表中。软中断阶段内核进入 poll 流程从 ring buffer 中取出报文为每个包分配 sk_buff并逐层调用二层、三层、四层的处理函数。IP 层完成校验、路由查找与本地投递判断TCP 层按序列号把数据放入 socket 接收队列唤醒正在等待的进程。应用层通过 read 或 recv 从 socket 队列中取走数据拷贝到用户空间一个收报文的生命周期到此结束。这条链路里每一步都可以是性能瓶颈也都可以是排查入口。比如你发现整体延迟总是偏高应该从应用层往下看epoll 等待是否合理、socket 缓冲是否太小再往下看软中断 CPU 占比是否过高、中断是否均匀分布到多核再往下看 ring buffer 是否经常满、丢包计数是否在增长最底层看网卡本身是否跑在了正确的速率和双工模式上。从上往下一层层排除才能定位真正的病灶。6.2 用丢包计数缩小排查范围内核在收报文的各个阶段都维护了大量的统计计数器这些计数器就是你排查问题的最好工具。最常用的是netstat -s和ifconfig自带的 RX errors、RX dropped、RX overruns 等字段。这里的判断逻辑需要注意ifconfig上显示的 RX dropped往往不只是驱动层主动丢包还包括因为内存不足导致 sk_buff 分配失败而丢弃的报文。如果你发现这个字段在持续上涨可以先想想当前系统的可用内存是否吃紧再去看cat /proc/net/softnet_stat里每一行的 dropped 字段。这个文件的每一行代表一个 CPU 核心第三列如果持续非零说明确实存在因为 per-CPU 背压丢包的情况通常这也就意味着这个核心上软中断的负载已经过高了。6.3 嵌入式环境需要格外留意的差异点经常有做嵌入式开发的朋友问我嵌入式 Linux 和服务器 Linux 在内核收报文上有什么不同。这个问题问得其实很好。嵌入式设备的 CPU 性能通常远不如服务器级别的 x86 芯片中断和软中断的开销占比会高出很多ksoftirqd抢占业务线程的问题也会表现得更加严重。如果你的嵌入式设备跑着轻量的 Lua 或者 Python 业务逻辑同时又处理着不小规模的网络流量软中断占用过高导致逻辑整体卡顿几乎是必然会发生的事。在嵌入式平台上做网络性能优化最有效的手段通常是这几个尽可能用多队列网卡并开启 RSS把中断绑定到多个核上为网络处理分配合适的 CPU 隔离区避免业务线程和软中断互踢如果业务场景允许强力建议考虑开启内核里的CONFIG_PREEMPT_RT特性或者至少调整 CONFIG_HZ 到更高的频率来换取更低的中断延迟。6.4 梳理链路的过程中值得思考的两三个方向看完这条完整的收报链路之后你可以顺着思考几个常见的扩展方向正好用来验证自己是否真正理解了内核网络栈第一个方向是观察 TCP 三次握手的收包路径。SYN 包从网卡进入后走的其实是完全同一条链路直到tcp_v4_rcv内部才会根据 socket 的状态做不同处理。如果 socket 处于 LISTEN 状态报文不会直接进入接收队列而是进入半连接队列和 accept 队列的交互又是另一套机制。这就是为什么高并发下 SYN 队列长度能成为瓶颈而它和收报文的底层链路又紧密相关。第二个方向是研究 UDP 收包的特殊点。UDP 没有连接状态处理路径比 TCP 简单得多但这也意味着它几乎不消耗协议状态机的复杂度。正是因为这个原因多线程处理 UDP socket 时可以采用SO_REUSEPORT让多个进程共享同一个端口内核在收包时就会直接按源 IP 和源端口的哈希把报文分发到对应的 socket 上等于在 socket 层就做了负载均衡。这一点在实现高性能 DNS 和 NTP 服务时几乎成了标配方案。第三个方向是预判 VLAN 或者隧道报文对链路的影响。带 VLAN tag 的报文和普通报文在二层处理时就会被区分开来内核需要额外剥掉 VLAN 头之后再继续处理这会给每个包增加一点恒定开销。隧道协议则更凶残一个报文到达之后不仅外层 IP 和 UDP 头要解析一遍内层报文还要经过一次完整的协议栈处理等于收包路径整体走了两遍。在这种场景下CPU 开销相比普通流量会成倍的增加理论性能估算一定不能按普通流量的指标来套。7. 我自己踩过的那些坑以及会再回看的检查清单这趟梳理过程中我自己踩过不少坑这里挑三个最有代表性的说一下希望你不用重走一遍。第一个坑是抓包时的“假象”。我用tcpdump抓包时发现抓到的报文中包大小总比应用层预期的小一度以为是丢了数据。后来才搞明白tcpdump 默认抓的是报文的截断版本只有前 96 字节左右而不是完整包。这个和内核收报文的零拷贝设计没有关系纯粹是抓包工具的使用细节但如果你不懂很容易错误地得出“内核丢包了”的结论。第二个坑是netdev_budget调节后的副作用。我之前在测试环境为了追求极限 PPS把这个值调得非常大结果压测数据的尾延迟明显变差因为每次软中断占用的 CPU 时间太长其他中断得不到及时响应。这让我意识到任何内核参数都不是越大越好必须结合业务的 latency 要求来做取舍。第三个坑是 ring buffer 调大后延迟不降反升。在某个流量非常稳定的业务上我把 ring buffer 从默认的 512 调到了 4096本以为能减少丢包结果发现平均延迟反而变高了。原因是缓冲区变大让报文等待被处理的排队时间变长大流量下反而不如小缓冲区时“到达即处理”来得及时。调优一定不能只盯着一个指标看体验上还是要综合延迟和吞吐两端来观察。收报文这条链路上的排查工具和参数很多我列一个自己常用的检查清单按优先级排序出现网络问题时先按这个顺序过一遍先看ifconfig或ip -s link里 RX errors / RX dropped / RX overruns 三个计数是否在增长有增长就先定位丢包发生在哪一层。再看/proc/net/softnet_stat各 CPU 的 dropped 列判断是不是软中断处理不过来导致 per-CPU 背压。用top观察软中断 CPU 占用si 列和 ksoftirqd 的线程占用确认瓶颈是否在网络处理本身。用ethtool -S eth0看驱动层的丢包和错误计数注意不同网卡驱动导出的计数器名称差异很大别用一个型号的经验直接套另一个型号。检查中断亲和性用cat /proc/interrupts看看各队列的中断是否都集中在同一个核心上如果是就手动调整/proc/irq下的 smp_affinity。最后再审视应用层epoll 的事件是否批量处理、接收缓冲区是否设置得合理、是否有不必要的 read 拷贝浪费。这套流程我在好几个项目上反复用过几乎每次都能帮助快速缩小问题范围。如果你也能按这个思路走一遍我相信很多事情你就不用再靠猜了。