Linux内核网络收包全过程
上半部、下半部、软中断、NAPI网卡收到一个数据包到它被用户态程序recv()拿走中间经历了什么一、总览一张图看完整条路径网卡硬件 │ ▼ DMA写入数据到内存环形缓冲区(Ring Buffer) │ ▼ 触发硬件中断(IRQ) │ ╔══════════════════════════════════════╗ ║ 上半部 (Hard IRQ) ║ ║ - 响应中断屏蔽当前中断线 ║ ║ - 仅做最小工作调度NAPI poll ║ ║ - napi_schedule() → __raise_softirq ║ ║ - 关闭网卡中断(关irq) ║ ╚══════════════════════════════════════╝ │ ▼ 返回中断上下文重新开中断 │ ╔══════════════════════════════════════╗ ║ 软中断 (Softirq) ║ ║ ksoftirqd 或 中断返回时执行 ║ ║ NET_RX_SOFTIRQ → net_rx_action() ║ ║ │ ║ ║ ▼ ║ ║ NAPI poll 循环 ║ ║ - 从Ring Buffer批量取包(最多64个/轮) ║ ║ - 构建skb ║ ║ - 送入协议栈 ║ ║ - 预算用完或无包 → 退出poll ║ ╚══════════════════════════════════════╝ │ ▼ 下半部协议栈处理 │ IP层 → 路由查找 → Netfilter → TCP/UDP层 │ ▼ 放入Socket接收队列(sk_receive_queue) │ ▼ 唤醒用户进程(或epoll就绪) │ 用户态 recv()/read() 取走数据二、硬件层DMA 与 Ring Buffer现代网卡Intel igb、mlx5 等收包不走 PIO走的是 DMA。网卡驱动在初始化时向内核申请一块环形缓冲区Ring Buffer物理地址告诉网卡。网卡收到帧后通过 DMA 直接把帧数据写入这块内存不需要 CPU 参与。┌─────────────────────────────────┐ │ Descriptor Ring (硬件层) │ │ │ │ [desc0][desc1][desc2]...[descN]│ │ │ │ │ │ │ ▼ ▼ ▼ │ │ [skb] [skb] [skb] ← 预分配 │ │ │ │ tail(网卡写) head(驱动读) │ └─────────────────────────────────┘每个 descriptor 记录了一个 skb 的 DMA 地址。网卡写完一个包推进 tail 指针然后触发中断。驱动侧维护一个 head 指针每处理完一个包就推进 head。head 和 tail 之间的距离就是还没处理的包数量。关键点Ring Buffer 的大小默认 256/512直接影响丢包概率。ethtool -g eth0查看ethtool -G eth0 rx 4096调大。在高流量场景下Ring Buffer 太小会导致网卡 DMA 无处写包直接丢包。三、上半部Hard IRQ只做一件事网卡写完 DMA、推进 tail 之后向 CPU 的 APIC 发中断信号。CPU 响应这个中断进入上半部处理。上半部的核心原则是快进快出。它只做三件事读取网卡的中断状态寄存器确认是收包中断而不是发包完成、链路状态变化等其他中断调用napi_schedule()把当前 NAPI 实例挂到当前 CPU 的 softnet_data 队列上并触发NET_RX_SOFTIRQ关闭网卡的收包中断防止中断风暴// 驱动中断处理函数以igb为例简化staticirqreturn_tigb_intr(intirq,void*data){structigb_adapter*adapterdata;u32 icrrd32(E1000_ICR);// 读中断原因if(icrE1000_ICR_RXT0){// 收包中断napi_schedule(adapter-napi);// 调度NAPIigb_irq_disable(adapter);// 关中断}returnIRQ_HANDLED;}上半部执行期间CPU 是关本地中断的至少关当前中断线所以它停留的时间越短越好。所有重活——收包、构建 skb、协议栈处理——全部推迟到下半部。四、下半部机制演进从 tasklet 到 NAPI理解 NAPI 之前先搞清楚它解决的是什么问题。4.1 老方案纯中断驱动早期的驱动每收一个包就发一次中断CPU 每次都进上半部、构建 skb、送协议栈。这在低速网卡上没问题但到了千兆网卡每秒几十万个小包CPU 被中断淹没——这叫中断风暴interrupt storm。包1 → 中断 → 收包 → 返回 包2 → 中断 → 收包 → 返回 ← CPU一直在中断上下文打转 包3 → 中断 → 收包 → 返回 根本没空做协议栈处理 ...4.2 NAPI中断 轮询的混合模型NAPINew API的核心思想是第一个包用中断通知后续包用轮询收。包1 → 中断 → 调度NAPI → 关中断 │ ▼ poll循环开始软中断上下文 │ ├→ 包1从Ring Buffer取构建skb ├→ 包2继续取不用等中断 ├→ 包3继续取 │ ... ├→ 包64本轮预算用完退出 │ ▼ Ring Buffer空了 → 开中断等待下一个包 或预算用完 → 重新调度下一轮pollNAPI poll 函数有一个**预算budget**参数默认每轮最多处理 64 个包。这是为了防止 poll 一直占着 CPU饿死其他软中断和进程。4.3 驱动侧 NAPI 实现要点驱动需要实现一个 poll 函数staticintigb_poll(structnapi_struct*napi,intbudget){structigb_ring*rx_ring...;intwork_done0;while(work_donebudget){// 从Ring Buffer取一个包skbigb_fetch_rx_buffer(rx_ring);if(!skb)break;// Ring Buffer空了// 填充skb元数据协议类型、校验和状态等skb-protocoleth_type_trans(skb,netdev);napi_gro_receive(napi,skb);// 送入协议栈走GROwork_done;}if(work_donebudget){// 没处理满预算说明Ring空了napi_complete_done(napi,work_done);igb_rx_irq_enable(adapter);// 重新开中断}// 否则返回budget内核会继续调度下一轮pollreturnwork_done;}napi_complete_done()告诉 NAPI 框架本轮 poll 结束可以重新接受中断了。如果返回 budget内核的 softirq 处理逻辑会再次调用 poll继续收包。五、软中断SoftirqNAPI 的执行环境NAPI 的 poll 函数运行在软中断上下文具体是NET_RX_SOFTIRQ。5.1 软中断的触发时机软中断不是由硬件触发的而是代码里显式 raise 的。napi_schedule()内部调用的就是__raise_softirq_irqoff(NET_RX_SOFTIRQ)。软中断的执行时机有两个硬中断返回时CPU 从上半部退出、恢复中断之前会检查有没有 pending 的 softirq如果有就原地执行ksoftirqd 内核线程如果 softirq 执行时间太长或嵌套太多层内核会把剩余的 softirq 交给 per-CPU 的ksoftirqd线程处理Hard IRQ │ ├── napi_schedule() │ └── __raise_softirq(NET_RX_SOFTIRQ) │ ▼ (中断返回) ┌──────────────────────────┐ │ 检查pending softirq │ │ 有 → 原地执行 │──→ net_rx_action() → napi poll │ 太多/太久 → ksoftirqd │ └──────────────────────────┘5.2 net_rx_action() 的核心逻辑staticvoidnet_rx_action(structsoftirq_action*h){structsoftnet_data*sdthis_cpu_ptr(softnet_data);intbudgetREAD_ONCE(netdev_budget);// 默认300while(!list_empty(sd-poll_list)){structnapi_struct*nlist_first_entry(...);intworknapi-poll(napi,napi-weight);// weight默认64budget-work;if(budget0)break;// 总预算用完退出}if(!list_empty(sd-poll_list))__raise_softirq_irqoff(NET_RX_SOFTIRQ);// 还有活再raise一次}netdev_budget是一次 softirq 处理中所有 NAPI 实例的总预算默认 300napi-weight是单个 NAPI 实例的预算默认 64。两个参数共同限制了 softirq 最多占用多少 CPU 时间。5.3 ksoftirqd 的介入条件当 softirq 执行时间超过netdev_max_backlog默认 300000 字节但实际看包数量或嵌套深度超过MAX_SOFTIRQ_RESTART默认 10内核会放弃在当前上下文继续处理唤醒ksoftirqd。ksoftirqd是 per-CPU 内核线程优先级很低SCHED_OTHERnice0。它的好处是不会阻塞用户进程太久坏处是引入了调度延迟。面试时如果被问为什么抓包看到 ksoftirqd 占用 CPU 高答案通常是流量太大softirq 处理时间超过了内核算法允许的上限被迫降级到线程模式处理。六、协议栈处理从 skb 到 SocketNAPI poll 里调用的napi_gro_receive()是收包进入协议栈的入口。从这里开始skb 要穿过层层协议。6.1 GROGeneric Receive OffloadGRO 在 NAPI 层就把多个小包合并成一个大包减少后续协议栈的处理次数。包1(TCP seq0, len1460) ─┐ 包2(TCP seq1460, len1460)─┤→ GRO合并 → 一个大skb(len4380) 包3(TCP seq2920, len1460) ─┘GRO 是软件实现的不依赖网卡硬件。对应的硬件机制叫 LROLarge Receive Offload但 LRO 会破坏一些语义比如不更新校验和现代内核更推荐用 GRO。6.2 协议栈遍历napi_gro_receive() │ ▼ dev_gro_receive() → 尝试GRO合并 │ ▼ (无法合并或GRO flush) netif_receive_skb() │ ├── 经过 RPS (Receive Packet Steering)多队列网卡把包分发到不同CPU │ ▼ __netif_receive_skb() │ ├── 遍历ptype_all链表 (AF_PACKETtcpdump抓包在这里) │ ├── 查路由ip_route_input() │ ├── 本机的包 → 继续往上走 │ ├── 转发包 → ip_forward() → 发出 │ └── 查不到 → 丢弃 │ ├── Netfilter NF_INET_PRE_ROUTING hook │ ├── IP层处理去分片、校验和验证 │ ├── Netfilter NF_INET_LOCAL_IN hook │ ├── 根据protocol字段分发 │ ├── IPPROTO_TCP → tcp_v4_rcv() │ ├── IPPROTO_UDP → udp_rcv() │ └── IPPROTO_ICMP → icmp_rcv() │ ▼ tcp_v4_rcv() │ ├── 查sock__inet_lookup_skb() │ ├── Netfilter NF_INET_LOCAL_IN (对TCP而言) │ ├── tcp_v4_do_rcv() → tcp_rcv_established() │ ├── 校验序列号、窗口 │ ├── 处理ACK、SACK │ ├── 数据放入sk_receive_queue乱序放入out_of_order_queue │ └── 更新拥塞窗口 │ ▼ sk_data_ready(sk) → 唤醒等待该socket的进程6.3 skb 的关键段变化在整个路径中skb 的指针在不断移动skb结构体: ┌──────┬──────────────────────────────────┬──────────┐ │ head │ data ────────────────────► tail │ end │ └──────┴──────────────────────────────────┴──────────┘ DMA刚写完: data指向以太网帧头 eth_type_trans()后: data指向IP头mac_header被记录data往后挪14字节 ip_rcv()后: data指向TCP/UDP头IP头被消费 tcp_v4_rcv()后: data指向payloadTCP头被消费每层协议通过skb_pull()推进 data 指针通过skb-mac_header、skb-network_header、skb-transport_header记录各层头部的起始位置。七、用户态接收从内核到 recv()TCP/UDP 处理完后数据被挂在 socket 的接收队列上。// tcp_rcv_established() 末尾if(tcp_queue_rcv(sk,skb)){// 数据入队成功sk-sk_data_ready(sk);// 回调通知}sk_data_ready的默认实现是sock_def_readable()它调用wake_up_interruptible_poll()唤醒在sk_sleep等待队列上睡眠的进程。如果用户用了 epoll这个回调会把该 fd 标记为 EPOLLIN 就绪epoll_wait()返回。用户态调用recv()或read()时内核执行tcp_recvmsg()从sk_receive_queue里把 skb 的 payload 拷贝到用户缓冲区。八、性能相关的关键参数与机制理解了完整路径再看几个调优参数就清晰了参数默认值作用调优场景net.core.netdev_budget300一次softirq所有NAPI的总包预算高PPS场景调大减少ksoftirqd降级net.core.netdev_max_backlog1000per-CPU backlog上限RPS开启时调大netdev_budget_usecs20000softirq最大执行时间(us)防止softirq饿死进程rx-usecs(ethtool -C)驱动相关中断合并的延迟阈值延迟敏感调小吞吐优先调大rx-frames(ethtool -C)驱动相关收到多少帧后才触发中断配合rx-usecs做中断合并Ring Buffer大小 (ethtool -G)256/512DMA环形缓冲区深度突发流量场景调大防丢包中断合并Interrupt Coalescing网卡支持攒几个包再发一次中断或者等一段时间再发中断。这是硬件层面的 NAPI 补充。ethtool -C eth0 rx-usecs 50表示收到包后等 50 微秒再触发中断期间继续攒包。RPSReceive Packet Steering单队列网卡只有一个 CPU 处理 softirqRPS 在软件层把包分发到其他 CPU 的 backlog让多核都能处理协议栈。多队列网卡用 RSSReceive Side Scaling硬件层就按哈希分到不同队列每个队列绑一个 CPU。九、回顾Q上半部和下半部的区别上半部是硬件中断处理关中断执行只做最小工作调度 NAPI、关中断。下半部是软中断或 tasklet开中断执行做实际收包和协议处理。上半部不能被同中断线打断下半部可以被硬件中断打断。QNAPI 解决了什么问题纯中断驱动在高速网卡下导致中断风暴CPU 被中断上下文淹没。NAPI 用第一个包触发中断后续包用轮询批量处理减少中断次数提高吞吐。Qsoftirq 和 tasklet 的区别tasklet 是 softirq 的封装同一 CPU 上同类型 tasklet 串行执行。NAPI 用的是NET_RX_SOFTIRQ直接处理没有 tasklet 这层封装。现代内核网络栈不用 tasklet。Qksoftirqd 什么时候会跑softirq 嵌套太深超过MAX_SOFTIRQ_RESTART10或执行时间太长时内核放弃在中断返回上下文继续处理唤醒 ksoftirqd 线程。高流量时看到 ksoftirqd 占 CPU 高说明 softirq 处理不过来。Q数据包从网卡到用户态经历了几次拷贝理想情况下零拷贝DMA 直接写内存用户态 mmap 或 splice。常规路径一次拷贝从 skb 的 data 区域拷贝到用户缓冲区recv()时。如果开启了 GRO/LRO合并后仍然是一次拷贝。