做网络排查这几年,我有一个很深的体会:很多人会用 tcpdump、Wireshark 抓包,也能看懂 TCP 握手和重传,但一旦问题深入到“包进入内核之后到底发生了什么”,就变成黑盒了。就好比你看到快递员把包裹放进了驿站,但驿站里面怎么分拣、怎么登记、怎么通知你取件,全程一无所知。线上偶尔出现的丢包、延迟抖动,往往就藏在这段看不见的路程里。
这篇文章我想把“内核如何接受网络包”这件事讲透。它不会教你写网卡驱动,但会带你沿着一条真实收包路径,把网卡中断、NAPI 轮询、协议栈入口、IP 层校验、TCP 接收队列、最终到用户态 recv 之间的关键环节全部拆开看一遍。适合已经被网络基础概念“喂饱”、但想知道内核源码级别收包细节的开发者、SRE、网络运维,也适合正在排查“看起来一切正常但性能不达标”这类问题的人。
1. 从网卡到内存:收包旅程的起点
1.1 一条网络包的完整生命线
先把整条链路画在脑子里。一个数据包从物理网线进入用户态程序,大致经历这么几个阶段:
- 网卡接收物理信号,解析出以太网帧。
- 网卡通过 DMA 把数据写入内核预先分配好的内存区域(Ring Buffer)。
- 网卡触发硬件中断,通知 CPU“有新包到了”。
- 中断处理程序做最轻量的事:唤醒软中断,然后立刻返回。
- 软中断上下文执行 NAPI 轮询,从 Ring Buffer 中批量取包,构造
sk_buff结构。 - 经过可选 GRO 合并,进入协议栈核心分发函数
__netif_receive_skb_core。 - 按照以太网类型找到对应协议 handler(IP 包进
ip_rcv,ARP 包进arp_rcv)。 - IP 层做校验和路由查找,确定是本地接收还是转发。
- 本地接收的 TCP 包进入
tcp_v4_rcv,找到对应 socket,按序放入接收队列。 - 应用调用
recvmsg,内核把数据从内核缓冲区拷贝到用户态内存。
很多人以为“网卡中断 → CPU 处理协议栈”是一气呵成的,其实关键点在于中间一切都被刻意拆碎了,目的只有一个:让单次收包的开销尽量小,让批处理尽量多。
用一个不那么严谨但形象的比喻:网卡相当于一个勤快的门卫,它按门铃(硬中断)通知你“快递到了”,但不会站在门口等你一件一件拆。真正的拆包工作是管家(软中断)一次性把门口所有快递搬进仓库,分类放好,再通知你“有你的包裹,来签收”。
1.2 为什么硬中断只做最轻的活
硬件中断处理程序有个硬性要求:它运行在中断上下文,不能睡眠、不能做复杂操作、不能持有普通锁,否则会极大拖慢系统。早期内核确实有网卡驱动在中断上下文一路把包送到协议栈,但很快被证明在高压力下会引发“中断风暴”——包越多,中断越多,CPU 全在被打断,业务反而跑不动。
现代驱动的基本逻辑是:硬中断里只做“登记”和“调度”。
c复制// 示意代码:某网卡驱动的硬中断处理函数
static irqreturn_t en2000_rx_irq(int irq, void *data)
{
struct en2000_adapter *adapter = data;
// 告诉 NAPI 子系统:这个设备有数据可收,请调度我
if (napi_schedule_prep(&adapter->napi)) {
// 关闭该网卡 RX 中断,后续收包改为轮询,避免反复被打断
en2000_irq_disable(adapter, EN2000_RX_INT);
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
}
return IRQ_HANDLED;
}
关键就在 napi_schedule_prep 和关闭中断这两步。第一次有包进来时,硬中断被触发;之后驱动主动屏蔽该队列的 RX 中断,转而通过软中断持续轮询 Ring Buffer,直到收完一定数量再重新开启中断。这套机制就是 NAPI(New API),Linux 2.6 之后几乎所有高性能网卡都走这条路。
这也是为什么用 mpstat 看 CPU 中断时,收包高的核心上 soft 占比通常远高于 irq 占比——因为活儿基本都在软中断里干,硬中断只是“点个火”。
1.3 中断亲和性与 NUMA
硬中断发生在哪个 CPU 上,直接决定后续软中断和协议栈处理发生在哪个 CPU 上。现代 CPU 有 NUMA 架构,如果网卡 DMA 内存落在 Node 0,软中断却被调度到 Node 1 的 CPU,跨节点访问内存的开销会明显拖慢收包。
多队列网卡在这方面有天然优势:每个 RX Queue 可以绑定一个独立中断号,再把不同中断号分给不同 CPU,实现并行收包。查看方式很直接:
bash复制# 查看中断号分布
cat /proc/interrupts | grep -i en2000
# 查看当前中断的 CPU 亲和性
cat /proc/irq/78/smp_affinity_list
# 动态调整(比如把 78 号中断绑定到 CPU 2-3)
echo 2-3 > /proc/irq/78/smp_affinity_list
实操里有个容易踩的坑:有些网卡的队列数和 CPU 数不一致,或者 BIOS 开了超线程导致 smp_affinity_list 里看到一堆逻辑 CPU,但实际物理核只有一半。绑定时最好先确认哪些 CPU 是同一个物理核,避免三个队列全绑到一个物理核的两个超线程上,等于白绑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. NAPI 与软中断:收包的真正主力
2.1 软中断在哪里跑
硬中断返回前,内核会检查当前 CPU 是否有 pending 的软中断。如果有,并不会立刻在硬中断上下文里处理,而是等硬中断处理完全结束后,在 irq_exit 路径里调用 invoke_softirq 去执行软中断回调。
这里有一个很微妙的点:软中断执行上下文实际上还是在“从硬中断返回的路上”,但它已经不在硬中断上下文了。普通硬中断里不能用的锁、不能调度的限制,在软中断里部分放宽,但仍然要求不能睡眠、不能长时间占用 CPU。
如果软中断堆积到一定阈值,内核会唤醒 ksoftirqd 内核线程来兜底处理。发生这种情况时,你会看到 top 里 ksoftirqd/1 这类进程 CPU 占用飙升。这通常不是什么好现象——说明软中断已经多到在中断返回路径处理不过来了,进一步意味着收包速率逼近单核处理上限。
2.2 NAPI 轮询:budget 与配额
NAPI 的核心思想是“批量”。每次 net_rx_action 被触发时,会遍历当前 CPU 上所有待调度的 NAPI 实例,逐个调用 poll 回调。
c复制// 简化示意:net_rx_action 的核心循环
while (budget > 0 && !list_empty(&softnet_data->poll_list)) {
struct napi_struct *napi = list_first_entry(...);
// 调用驱动的 poll 函数,最多处理 weight 个包
work = napi->poll(napi, weight);
budget -= work;
if (work < weight) {
// 驱动认为收完了,重新开启硬中断
en2000_irq_enable(napi->dev);
napi_complete_done(napi, work);
}
}
这里有三个关键参数:
weight(NAPI 权重):单次 poll 最多处理的包数,网卡驱动一般注册为 64。budget(全局预算):整个net_rx_action一次运行最多处理的总包数,内核默认 300。time_limit:部分内核版本还限制软中断处理时长,通常为 2 jiffies。
我排查过一起“单核收包触顶”问题:网卡是多队列的,但中断全打在一个 CPU 上,soft 占用到 100%。当时的表象是业务侧 TCP 重传和延迟升高,但系统负载很低。后来把各队列中断分散到多个 CPU 后,同样流量下 soft 从 100% 降到 30%。
看 /proc/net/softnet_stat 时,如果第三列(也就是“time squeeze”)持续增长,说明 net_rx_action 经常跑不完 budget 就被新软中断再次触发,或者 ksoftirqd 都被拉起来了。这是收包路径过载的经典信号。
2.3 GRO:先合并再进协议栈
GRO(Generic Receive Offload)的逻辑也很简单:既然同一连接里可能有大量连续小包,那干脆在进入协议栈之前把它们合并成一个大的 sk_buff,让协议栈少处理很多包头。之前有测试,启用 GRO 后,百字节小包场景下的收包 CPU 开销能降低 25% 甚至更多。
GRO 默认是开启的:
bash复制# 查看网卡 GRO 状态
ethtool -k en2000 | grep gro
# 关闭(仅排障时偶尔需要)
ethtool -K en2000 gro off
排障时有个容易迷惑的点:开启 GRO 后,tcpdump 抓到的包可能比实际线上包“少”,因为多个小包已经被合并成一个大包再交给抓包点。如果你在做 MTU 问题分析或者小包特征分析,建议临时关掉 GRO,否则抓包结果会误导判断。我见过有人因为这个原因误判“应用层根本没收到某些包”,其实是 GRO 合并后数据没丢,只是抓包工具看到的样子变了。
3. 进入协议栈的大门:__netif_receive_skb_core
3.1 一个 skb 如何被分发给多个消费者
不管驱动是直接从 Ring Buffer 取包还是先经过 GRO 合并,最终都会走到 __netif_receive_skb_core。这个函数是收包进入协议栈的“中央分发站”,名字里的 core 名副其实。
它做的事可以概括为三步:
- 先遍历
ptype_all链表,把包递给所有“无差别监听者”。 - 再根据包的以太网类型(
skb->protocol),在ptype_base哈希表中找到对应协议处理函数。 - 递送给这个协议 handler 后,处理流程结束,由协议栈层继续往下走。
ptype_all 里挂的是 AF_PACKET 这类协议族 socket,也就是 tcpdump、libpcap 抓包的位置。如果你开了抓包,同一个 skb 会被“复制”一份出来交给抓包程序,原始那份继续走正常协议栈。这意味着:抓包本身有代价,在流量很大的生产环境长时间抓包,会明显增加这个分发函数的 CPU 开销。
3.2 协议 handler 如何注册
内核里用 dev_add_pack 注册不同类型的包处理函数:
c复制static struct packet_type ip_packet_type __read_mostly = {
.type = cpu_to_be16(ETH_P_IP),
.func = ip_rcv,
};
static int __init inet_init(void)
{
dev_add_pack(&ip_packet_type);
}
常见的协议类型映射:
| 以太网类型 | 处理函数 | 职责 |
|---|---|---|
| ETH_P_IP | ip_rcv | 进入 IPv4 协议栈 |
| ETH_P_IPV6 | ipv6_rcv | 进入 IPv6 协议栈 |
| ETH_P_ARP | arp_rcv | 处理 ARP 请求/应答 |
| ETH_P_8021Q | vlan_rcv | VLAN 标签处理 |
| ETH_P_PPP_SES | pppoe_rcv | PPPoE 会话 |
理解这个表之后,排查一些诡异现象会快很多。比如“同网段 ping 不通但 TCP 能通”这类问题,往往问题不在收包路径,而在于 ARP 包进了 arp_rcv 之后被某个阶段丢弃,跟 TCP 走的完全不是一条道。
3.3 XDP、tc ingress 与协议栈的顺序
现代内核里,包在到达 __netif_receive_skb_core 之前,还有两个可以被用户态干预的挂载点,顺序非常重要:
- XDP(eXpress Data Path):在网卡驱动收包后最早阶段执行,甚至早于
sk_buff的完整构造,运行在驱动专用内存页上。 - tc ingress(入口流量控制):在
__netif_receive_skb_core之前执行。
顺序是:
text复制网卡 DMA → XDP 程序 ← 最早介入点
→ 构造 sk_buff → NAPI poll → tc ingress ← 第二介入点
→ __netif_receive_skb_core → 协议栈
很多用 eBPF 做拦截的人容易搞混这两者。XDP 可以 XDP_DROP 直接丢包,效率最高;tc ingress 丢包则需要走到 sk_buff 构造完成之后、协议栈入口之前。如果目的是“尽可能早地挡掉恶意流量”,XDP 才是正确选择。
4. IP 层与传输层:TCP 包要过的关卡
4.1 ip_rcv 主路径上的校验
包从 __netif_receive_skb_core 出来,以太网类型为 ETH_P_IP 时,进入 ip_rcv。这里做的事情非常务实:
- 检查报文长度是否合法(小于 IP 头长度直接丢)。
- 检查 IP 版本号是否为 4。
- 计算 IP 头校验和。
- 如果
skb共享(比如被抓包程序克隆过),需要skb_share_check复制一份再处理。
任何一步不过,包都会被静默丢弃。这也是为什么“看抓包有包、应用收不到”的第一怀疑对象就是校验和问题。有些网卡支持校验和卸载(checksum offload),理论上由硬件计算校验和,但如果驱动有 bug 或者硬件行为异常,可能把坏校验和的包也递上来。排障时可以临时关闭校验和卸载:
bash复制ethtool -K en2000 rx-checksum off
注意:校验和卸载一般只影响接收路径上“检查”这一步,不影响协议栈内部对 TCP/UDP 校验和的最终计算。
4.2 路由查找与本地投递
ip_rcv_finish 是 ip_rcv 的收尾阶段,主要做两件事:查找路由、确定入口函数。
如果目标地址是本机地址,dst_input 会指向 ip_local_deliver,进入本地投递流程;如果目标地址不是本机,则走 ip_forward,进入转发流程。ip_local_deliver 里还有分片重组逻辑——如果一个 IP 包被分成了多个分片,内核会先在 ip_local_deliver_finish 之前把它们拼回完整包,再继续向上递交。
我在处理一次“UDP 丢包严重但 TCP 正常”的问题时,最终发现就是分片重组缓存耗尽导致大量分片被丢。TCP 有流控和 MSS 协商,本身不容易触发分片,但 UDP 大包一旦超过 MTU 又没设置 DF 位,就会走上分片路径,而分片重组是要占用系统内存资源的,压力一大就开始丢。
在这一层还会路过 netfilter 钩子。如果你开启了 conntrack、iptables 过滤规则,它们通常挂在 ip_rcv 进出的 NF_INET_PRE_ROUTING 点,处理耗时与规则数量、连接表大小直接相关。很多“内核收包慢”的现场,最后定位到的是 conntrack 表过大导致哈希查找变慢,而不是协议栈本身的问题。
4.3 tcp_v4_rcv:一个 TCP 包的内核入口
本地投递的包,在 ip_local_deliver_finish 里按传输层协议分发,TCP 包进入 tcp_v4_rcv。这个函数有几个关键任务:
- 先用四元组(源 IP、源端口、目标 IP、目标端口)查 socket 哈希表,找到对应的
struct sock。 - 如果找不到 socket,走
tcp_v4_send_reset逻辑,按需回 RST。 - 根据 socket 状态,决定走
tcp_v4_do_rcv还是tcp_rcv_state_process。
已建立连接(TCP_ESTABLISHED)的收包会进入 tcp_rcv_established,这是快速路径的核心。它要处理的事情包括:
- 序号检查:当前包序号是否在接收窗口内。
- 时间戳校验:如果启用了 PAWS(Protection Against Wrapped Sequences),时间戳乱序的包会被直接丢弃。
- SACK 处理:如果发现乱序数据,记录 SACK 块。
- 窗口更新:根据接收缓冲区余量向对端通告新窗口。
这些检查都在软中断里完成。看到这里你应该明白一个事实:一次普通的 TCP 收包,不仅仅是“把数据塞进队列”,背后还要做一堆可靠性协议规定的检查。这也是为什么内核收包路径上的 CPU 开销,并不像一些人想象的那么低。
用 netstat -s 可以快速看 TCP 层丢弃:
bash复制netstat -s | grep -A 20 "Tcp:"
重点关注 packets rcvd、delivered、prune called、TCPBacklogDrop 这几项。prune called 表示因为接收缓冲区不足导致的削减行为,TCPBacklogDrop 表示因为 backlog 队列满直接丢包。这两个值如果持续增长,基本可以断定瓶颈在 socket 接收队列或处理能力,而不是网卡或驱动。
5. TCP 在 socket 里的最终去处
5.1 接收队列与内存上限
TCP 包通过所有校验之后,会被放入目标 socket 的接收队列。内核里每个 TCP socket 有两个关键队列:
sk_receive_queue:主接收队列,按序号排好序的数据都在这里。out_of_order_queue:乱序队列,暂时“插队”不进来的数据先放这。
放入接收队列前,内核会检查 sk_rmem_alloc(当前已占用接收内存)是否超过 sk_rcvbuf。如果超过,会尝试通过 tcp_collapse 等机制合并内存;实在不够,就只能丢包或减小接收窗口通告对端。
这里就是 TCP 窗口和内存限制互相纠缠的地方。窗口大小不完全反映传输能力,它同时受 tcp_rmem 控制:
bash复制sysctl net.ipv4.tcp_rmem
# 典型输出
# net.ipv4.tcp_rmem = 4096 87380 6291456
三个值分别是:最小值、默认值、最大值。如果应用读取速度太慢,内核缓冲被占满,接收窗口会逐步收缩到 0。对端看到窗口为 0 就会停止发送,这就是“应用没读,网络传输也停”的根源。
5.2 recvmsg 都做了什么
应用层调用 read 或 recvmsg 时,内核进入 tcp_recvmsg,流程大致是:
text复制lock_sock(sk)
→ 检查接收队列是否有数据
→ 如果没有且 socket 未关闭,等待(sleep_on 到等待队列)
→ 有数据后,把 skb 中的 data 通过 skb_copy_datagram_iter 拷贝到用户态 buffer
→ 释放已读 skb 内存
→ unlock_sock(sk)
注意“拷贝”这一步,它是收包路径上为数不多需要占用内存带宽的操作。如果单次收包很大、应用又读得频繁,CPU 的内存带宽可能先于 CPU 计算能力被打满。这也是为什么像 sendfile、splice、io_uring 这些技术能在特定场景显著优化性能——它们的目标之一就是减少收包后往用户态的冗余拷贝次数。
RX 队列长度也是一个容易被忽略的指标,ss -tni 可以看到:
bash复制ss -tni | head -20
输出里的 rtt、rto、skb 字段能直接反映当前 socket 的收包缓存占用。skb 值很大且持续不降,说明应用读取速度跟不上内核收包速度。
5.3 epoll 通知与唤醒延迟
“内核把包放入队列”和“应用真正读到数据”之间,还隔着一个事件通知机制。
包到达 socket 后,内核调用 sk_data_ready 回调,把 socket 加入 epoll 的 ready 列表,并唤醒等待在 epoll 上的线程。线程被唤醒后,从 epoll_wait 返回,再调用 recvmsg。这中间涉及两次调度延迟:
- 软中断上下文里的唤醒操作。
- 应用线程被调度器选中运行。
高压场景下,如果应用的 epoll 线程数少于 CPU 数,或者线程被错误地绑核,会出现一种奇怪现象:RTT 看起来正常,但应用侧延迟偶尔飙高。查到最后往往不是网络问题,而是唤醒后线程得不到 CPU,排队等调度导致的调度延迟。
另一个和 epoll 相关的坑是惊群问题。多个线程同时 epoll_wait 同一个 socket,当有数据到达时,内核可能唤醒多个线程,但只有一个能读到数据。现代内核已经通过 EPOLLEXCLUSIVE 等机制优化,但老内核版本还是会踩到这个坑。处理方式要么用 EPOLLEXCLUSIVE,要么每个 socket 只允许一个线程负责读。
6. 实测与调优:把收包路径变成指标
6.1 用 perf 观察真实收包热路径
理解收包路径后,遇到性能问题最该做的第一件事不是调参数,而是先确认热点在哪。
bash复制# 采样 CPU 调用栈 10 秒
perf record -g -a sleep 10
perf report --sort symbol
如果热点集中在:
net_rx_action、napi_poll等 NAPI 符号:说明收包本身在大量发生,先看包的速率和大小是否有异常。__netif_receive_skb_core:分发路径开销大,多半和抓包、tap 设备、虚拟化网络设备有关。ip_rcv、ip_local_deliver_finish:IP 层处理成为瓶颈,重点核查 conntrack、iptables、路由表大小。tcp_v4_rcv、tcp_rcv_established:传输层处理成为瓶颈,和 socket 数量、接收队列深度有关。skb_copy_datagram_iter:说明数据拷贝开销大,考虑减少拷贝、调整用户态读取策略。
这个定位思路比盲目调 ring buffer 大小要靠谱得多。我见过有人一遇到丢包就调 netdev_budget、rx-usecs,结果热点根本不在 NAPI,而在 conntrack,调了半天白费劲。
6.2 softnet_stat 的正确读法
/proc/net/softnet_stat 是判断内核收包是否过载的最直接指标。每一行代表一个 CPU,列含义大致是:
| 列 | 含义 |
|---|---|
| 第1列 | 收到包总数(processed) |
| 第2列 | 丢弃包总数(dropped) |
| 第3列 | 时间挤压次数(time_squeeze) |
| 第4列 | 触发 CPU 拥塞的 poll 次数 |
重点关注第三列的变化率。如果它持续增长,说明 net_rx_action 经常因为预算耗尽而被迫让出,是收包路径过载的强信号。对应调整手段主要有:
- 增大
net.core.netdev_budget,让单次软中断处理更多包。 - 调整网卡中断合并参数,减少硬中断频率。
- 如果触发 ksoftirqd 兜底,优先考虑把队列分散到更多 CPU。
这里想强调一个原则:调参之前先把监控数据收集至少一两天,别在峰值还没看到的时候乱动。网络调优的很多参数是此消彼长的,增大 netdev_budget 可能减少 time_squeeze,但会让单次 softirq 占用 CPU 时间变长,进而影响延迟敏感型业务。
6.3 多队列、中断合并与 RPS
多队列是分散收包压力的第一选择。先确认网卡到底有几个队列:
bash复制ethtool -l en2000
如果硬件队列数等于 CPU 核心数,把每个队列的中断绑定到不同 CPU 即可;如果硬件队列数少于 CPU 数,可以开启 RPS(Receive Packet Steering),让内核把收包软中断均摊到更多 CPU 上。
开启 RPS 的方式是按 rps_cpus 掩码配置。比如把 0-3 号 CPU 都纳入某个队列的 RPS 分发:
bash复制echo 0f > /sys/class/net/en2000/queues/rx-0/rps_cpus
RPS 的一个天然缺陷是把同一个 flow 的包分发到不同 CPU 后,可能增加 cache miss 和锁竞争。所以搭配 RFS(Receive Flow Steering)效果更好,它可以根据应用的 CPU 亲和性把同一数据流送到正在读取该 socket 的 CPU 上:
bash复制echo 32768 > /proc/sys/net/core/rps_sock_flow_entries
echo 2048 > /sys/class/net/en2000/queues/rx-0/rps_flow_cnt
这组配置的含义是:全局最多追踪 32768 条流,每个 RX 队列维护 2048 个 socket 映射。RFS 并不是越大越好,它消耗内存,而且流表刷新也需要开销,一般建议先设一个保守值,观察效果再微调。
6.4 常见瓶颈与快速对策
最后列一份我实际踩过、或者帮别人排查过的收包瓶颈速查表:
| 现象 | 检查点 | 常见对策 |
|---|---|---|
| 单核 soft 占用 100%,其他核空闲 | /proc/interrupts 中断分布 |
调整中断亲和性,开启 RPS |
| softnet_stat 第三列持续增长 | /proc/net/softnet_stat |
增大 netdev_budget,调整合并参数 |
| 抓包有包,应用收不到 | 校验和卸载、GRO、netfilter 丢包 | 关闭 checksum offload 测试,检查 conntrack 和防火墙规则 |
| 延迟周期性升高 | 中断合并参数、CPU 调频 | 考虑关闭中断合并的延迟模式,绑定 performance 调频策略 |
| 接收窗口持续很小 | ss -tni 的 skb、tcp_rmem |
调整 tcp_rmem,优化应用读取频率 |
| 小包性能差 | NAPI budget、驱动队列深度 | 调大 ring buffer,关闭 GRO 对比测试 |
| 大量 TCPBacklogDrop | netstat -s |
增大 socket backlog,优化 accept 处理能力 |
每个场景都需要结合具体监控确认,不要照抄参数。
把整条收包路径想明白之后,再看那些号称“高性能”“低延迟”的网络中间件设计,会有一种豁然开朗的感觉。它们做的事情,无非是在这条链路的某个环节上更聪明地发力:要么用 XDP 更早地介入,要么用零拷贝减少数据搬运,要么用更好的内存管理避免锁竞争。内核收包这件事本身并不神秘,它只是在“正确性”和“性能”之间做了大量精妙取舍。把这套取舍逻辑装进脑子里,以后再遇到任何网络疑难杂症,至少知道该往哪条路去追,而不会在黑盒面前束手无策。
