Linux 网络栈深度实战:从 sk_buff 到内核旁路的完整架构剖析

Linux 内核的网络协议栈是整个操作系统中最复杂、性能要求最高的子系统之一。它不仅需要处理从物理层到应用层的数据流转,还要在吞吐量和延迟之间取得最佳平衡。本文将从数据包进入网卡的那一刻开始,完整地剖析其在内核中的旅程,并深入探讨 NAPI、netfilter、eBPF 以及内核旁路技术的工程实践。

一、核心数据结构:sk_buff

一切网络处理的起点与终点都围绕着 struct sk_buff(socket buffer)展开。每一个数据包在内核中都被封装为一个 sk_buff 结构,它贯穿协议栈的所有层次。

1.1 sk_buff 核心字段

struct sk_buff {
    struct sk_buff      *next;       // 链表中下一个 skb
    struct sk_buff      *prev;       // 链表中上一个 skb
    struct sock         *sk;         // 关联的 socket
    struct net_device   *dev;        // 关联的网络设备
    unsigned char       *head;       // 缓冲区起始指针
    unsigned char       *data;       // 当前协议层数据起始
    unsigned char       *tail;       // 当前协议层数据结束
    unsigned char       *end;        // 缓冲区结束指针
    unsigned int        len;         // 实际数据长度
    unsigned int        data_len;    // 分片数据长度(paged data)
    __u16               protocol;    // 协议类型(如 ETH_P_IP)
    __u16               transport_header;  // 传输层头偏移
    __u16               network_header;     // 网络层头偏移
    __u16               mac_header;         // 链路层头偏移
    void                *csum_offload;      // 校验和卸载标志
    struct skb_shared_info *shinfo;         // 分片信息(skb_shared_info)
};

关键内存布局理解:

  head ──────────────────────────────────────────► end
  ┌──────────┬────────────┬───────────┬───────────┐
  │  Link    │  Network   │ Transport │  Payload  │
  │  Header  │  Header    │  Header   │           │
  │ (mac_hdr)│(net_hdr)   │(trans_hdr)│    data ──►│
  └──────────┴────────────┴───────────┴───────────┘
  ◄── head  ◄── data       ◄── tail            ◄── end

当数据包从 L2 向上传递到 L3 时,data 指针从 mac_header 移动到 network_header;协议层头部空间通过 skb_push() 和 skb_pull() 灵活伸缩,避免数据拷贝。

1.2 skb 分配与回收

sk_buff 的分配使用 alloc_skb() 和 dev_alloc_skb(),其底层通过 kmem_cache(SLUBS 分配器)进行快速分配。对于高吞吐量场景,内核使用 skb_cache per-CPU 缓存来减少锁争用。

回收时 kfree_skb() 调用 skb_release_all() 释放分片数据(skb_shared_info->frag_list)、DMA 映射等资源。网络层的内存回收普遍使用 RCU(Read-Copy-Update)实现无锁释放。

1.3 skb_shared_info 与分片

当数据包大于 MTU 需要分片,或者使用了 scatter/gather(页挂载)时,skb_shared_info 结构记录额外信息:

struct skb_shared_info {
    __u8        nr_frags;          // 页挂载的分片数
    __u8        tx_flags;          // 传输标志
    unsigned short gso_size;       // GSO 分片大小
    unsigned short gso_segs;       // GSO 分段数
    unsigned short gso_type;       // GSO 类型
    struct sk_buff  *frag_list;    // 分片链表
    skb_frag_t  frags[MAX_SKB_FRAGS]; // 页挂载的分片
};

核心要点:data_len > 0 表示数据部分在 frags 页中,线性数据 + 页挂载数据的组合使零拷贝高效传输成为可能。

二、数据包接收路径:NAPI 机制

2.1 从中断到轮询的演进

早期的 Linux 网络驱动采用纯中断模式:每个数据包到达都触发硬中断,CPU 在处理大量小包时会被中断淹没(Livelock 问题)。NAPI(New API)解决了根本问题:

[ 纯中断模式 ]
  每个包 → IRQ → 处理 → 下一个包 → IRQ → ...

[ NAPI 混合模式 ]
  第一个包 → IRQ → 关闭 RX 中断 → 进入轮询 → 处理所有可用包 → 开启 RX 中断 → 退出轮询

NAPI 的核心是:在高负载时切换到轮询模式,消除中断风暴;低负载时恢复中断模式,降低延迟。

2.2 NAPI 工作流程

硬件中断触发 → 驱动调用 napi_schedule() 将 NAPI 结构加入 per-CPU 的 poll_list → 触发软中断 NET_RX_SOFTIRQ → 软中断处理函数 net_rx_action() 遍历 poll_list → 调用驱动注册的 poll() 函数处理数据包 → 如果处理完所有数据(budget 未耗尽),调用 napi_complete() 并重新启用中断。

net.core.netdev_budget 控制单次软中断允许处理的最大数据包数(默认 300),netdev_budget_usecs 控制最大耗时(默认 2000 µs)。

2.3 NAPI 驱动的 poll 函数示例

static int my_driver_poll(struct napi_struct *napi, int budget) {
    int work_done = 0;
    struct sk_buff *skb;

    while (work_done < budget) {
        // 从 DMA 环形缓冲区取数据
        dma_desc = get_completed_rx_desc();
        if (!dma_desc)
            break; // 无更多数据

        // 分配 skb 并拷贝数据
        skb = napi_alloc_skb(napi, desc->length);
        if (unlikely(!skb))
            break;

        skb_put(skb, desc->length);
        memcpy(skb->data, dma_desc->vaddr, desc->length);

        // 写入接收时间戳
        skb->tstamp = desc->timestamp;
        skb->protocol = eth_type_trans(skb, dev);

        // 递交给协议栈
        napi_gro_receive(napi, skb); // 传递到 GRO 或直接 netif_receive_skb()
        work_done++;
    }

    if (work_done < budget) {
        napi_complete_done(napi, work_done);
        enable_rx_interrupt();  // 重新启用中断
    }

    return work_done;
}

三、GRO/GSO 与传输层交互

3.1 GRO(Generic Receive Offload)

GRO 在 NAPI 的 poll 阶段执行,将同一 TCP 流中的多个连续数据包合并为一个大的 sk_buff。这直接减少了协议栈上层处理的数据包数量。

[ 未启用 GRO ]  5 × 1500B 包 → 5 次协议栈处理
[ 启用 GRO ]    1 × 7500B 超级包 → 1 次协议栈处理(再按需分片递交)

GRO 的核心函数 napi_gro_receive() 根据数据包的协议(IPv4/IPv6)和 TCP/UDP 类型,将其插入到对应 Per-Stream 的 GRO 列表中。当某个条件满足时(如达到最大合并大小 max_gro_segments,或超时触发 flush),合并后的超级包会传递给上层。

3.2 GSO(Generic Segmentation Offload)

GSO 在发送路径上工作,将一个大的数据块(可达 64KB)推迟到网卡层才进行 TCP 分段:

[ 应用层 ] write(64KB)
     ↓
[ TCP 层 ] 创建一个 64KB 的 skb(GSO 创建一个非拆分的 skb)
     ↓
[ 设备层 ] 网卡驱动或协议栈根据 MSS 分段
[ 物理网卡 ] 输出 N 个 MTU 大小的包

GSO 的关键优势在于延迟分段:TCP 层只需处理一次 64KB 的逻辑数据,而不是 40+ 个 MSS 大小的数据包,极大地减少了 CPU 开销。

四、TCP/IP 协议栈实现深度

4.1 从 IP 层到 TCP 层

数据包从 netif_receive_skb() 进入 IP 层处理函数 ip_rcv(),依次完成:

头部校验和验证 → 总长度检查 → 碎片重组(ip_defrag())→ netfilter PREROUTING hook → 路由判断 ip_route_input() → 判断是将包递交本机(ip_local_deliver())还是转发(ip_forward())。

本机递交包经过 netfilter INPUT hook 后进入 TCP 层的 tcp_v4_rcv()。

4.2 TCP 接收路径

TCP 接收是协议栈中最复杂的流程之一:

sk = __inet_lookup_skb(&tcp_hashinfo, skb, ...);

// 监听状态的 socket,或者连接已建立的 socket
if (sk->sk_state == TCP_LISTEN) 
    return tcp_v4_do_rcv(sk, skb);

// 1. 检查序列号窗口(tcp_sequence_number_check)
// 2. 排队到 socket 的接收缓冲区
// 3. 处理标志位(SYN/ACK/FIN/RST)
// 4. 更新滑动窗口(tcp_rcv_space_adjust)
// 5. 触发 selective ACK(SACK)
// 6. 唤醒阻塞在 recv() 上的进程(sock_def_readable → 完成队列 wake_up)

4.3 Fast Path 与 Offload

Linux TCP 实现了 "Fast Path" 优化,覆盖约 99% 的正常数据包接收场景。Fast Path 在 tcp_rcv_established() 中处理:

队列入队 → 尝试直接拷贝到用户空间(如果此时有阻塞的 read)→ 更新接收窗口 → 检查是否需要发送 ACK。

当检测到异常(乱序、重传、紧急数据),内核切换到 "Slow Path" 通过 tcp_ofo_queue() 处理乱序队列。

4.4 TCP 自时钟与流量控制

TCP 的 ACK 发送不是逐包响应而是自时钟(Self-Clocking)的:

接收方收到数据 → 启动延迟 ACK 定时器(默认 40ms)→ 定时器到期或收到 2 个数据包时发送一个 ACK → ACK 返回发送方使发送方有机会继续发送。

高带宽时延迟 ACK 无法满足需求,TCP_QUICKACK 选项可以禁用延迟 ACK 机制。

五、netfilter 与 iptables/nftables

5.1 五钩子点

netfilter 在内协议栈中挂载了 5 个 hook 点,构成完整的包过滤框架:

[ 入站包 ]
  PRE_ROUTING (NF_INET_PRE_ROUTING) → 路由判断
    本机接收:INPUT (NF_INET_LOCAL_IN)
    转发:    FORWARD (NF_INET_FORWARD)
    本机发出:OUTPUT (NF_INET_LOCAL_OUT)
  POST_ROUTING (NF_INET_POST_ROUTING)
[ 出站包 ]

5.2 hook 优先级与链

每个 hook 点注册了优先级递减的链,规则的匹配优先级:

NF_IP_PRI_RAW → NF_IP_PRI_MANGLE → NF_IP_PRI_NAT_DST → NF_IP_PRI_FILTER → NF_IP_PRI_SECURITY → NF_IP_PRI_NAT_SRC

  1. raw 表:连接跟踪豁免
  2. mangle 表:修改 IP 头(TTL、TOS)
  3. nat 表:地址转换
  4. filter 表:包过滤(DROP/ACCEPT)
  5. security 表:SELinux 标记
  6. conntrack:连接跟踪(状态匹配基础)

5.3 conntrack 连接跟踪

conntrack 维护所有经过内核的网络连接状态,是 nat 表和状态匹配(-m state)的基础:

/proc/net/nf_conntrack 字段示例:
ipv4     2 tcp      6 431999 ESTABLISHED \
  src=192.168.1.10 dst=93.184.216.34 sport=48822 dport=443 \
  src=93.184.216.34 dst=192.168.1.10 sport=443 dport=48822 [ASSURED]

conntrack 表默认大小受 nf_conntrack_max 控制,高并发 NAT 场景需要增大。每个连接占用约 368 字节内存。

5.4 nftables:现代化替代

iptables 存在重复匹配开销高、规则集更新全量替换等问题。nftables 从 Linux 3.13 引入,通过以下特性改进:

  • 基于虚拟机字节码执行规则(不需要为每个 hook 点独立存储规则副本)
  • 增量式规则更新(事务机制)
  • 集合(Set)和映射(Map)支持 O(1) 查找
  • 统一的 nft 命令(代替 iptables/ip6tables/arptables/ebtables)
# nftables 示例:创建黑名单集合
nft add table inet filter
nft add set inet filter blacklist { type ipv4_addr flags dynamic,timeout timeout 1h }
nft add rule inet filter input ip saddr @blacklist drop

六、传输层发送路径

6.1 TCP 发送

用户调用 send()/write() → socket 层调用 tcp_sendmsg()。在 tcp_sendmsg() 中:

  1. 分配或复用 sk_buff
  2. 从用户空间拷贝数据到 skb(stream 模式下可能合并到已有 skb 尾部)
  3. 放入 TCP 发送队列(sk_write_queue)
  4. 调用 tcp_push() 触发实际发送(考虑 Nagle 算法、拥塞控制窗口、发送窗口)
  5. 经过 netfilter OUTPUT/POST_ROUTING hooks
  6. 进入 dev_queue_xmit()(流量控制层)→ 最终从驱动发送

6.2 Qdisc 流量控制

Linux 的队列规则(qdisc)位于网络层和驱动之间,负责组织包的发送顺序:

默认队列(fq_codel)行为:
┌───────────────────────────────┐
│  多流分类(per-flow queues)   │
│  [Flow A] [Flow B] [Flow C]   │
│   DRR 轮转调度 + CoDel 控制    │
│   目标延迟 5ms,Drop-after-间隔  │
└───────────────────────────────┘

fq_codel 解决了 Bufferbloat 问题:基于 CoDel 算法主动丢弃延迟过大的包,而不是让缓冲区无限膨胀。

七、eBPF 与 XDP:可编程网络的新纪元

7.1 eBPF 基础

eBPF 提供了一种在内核安全地运行用户定义字节码的方法。

核心保护机制:内核加载 eBPF 程序前必须经过 verifier 验证——检查所有路径终止(无无限循环)、检查所有内存访问边界安全、禁止未初始化内存读取。

7.2 XDP(eXpress Data Path)

XDP 在网卡驱动层执行 eBPF 程序,是内核中最早期的包处理介入点。XDP 程序在 DMA 完成后、skb 分配之前执行,因此具有极致的性能。

[ 传统路径 ] NIC → DMA → 分配 skb → NAPI poll → netif_receive → 协议栈
[ XDP 路径 ] NIC → DMA → XDP eBPF 程序 → { XDP_DROP / XDP_PASS / XDP_TX }

XDP 的 BPF 辅助函数可以修改包内容、查询 map、进行.redirect(重定向到其他网卡或 CPU)。返回码决定包命运:

  • XDP_DROP:立即丢弃(DDoS 防护最高效)
  • XDP_PASS:继续进入常规协议栈
  • XDP_TX:从接收包的同一个网卡发送回去
  • XDP_REDIRECT:转发到另一个网卡或 CPU(实现交换机逻辑)

7.3 XDP 实战:DDoS 防护

SEC("xdp_ddos_filter")
int xdp_ddos_filter_prog(struct xdp_md *ctx) {
    void *data_end = (void *)(long)ctx->data_end;
    void *data     = (void *)(long)ctx->data;

    struct ethhdr *eth = data;
    if ((void *)(eth + 1) > data_end)
        return XDP_DROP;

    if (eth->h_proto != bpf_htons(ETH_P_IP))
        return XDP_PASS; // 非 IPv4 正常通过

    struct iphdr *iph = data + sizeof(*eth);
    if ((void *)(iph + 1) > data_end)
        return XDP_DROP;

    u32 src_ip = bpf_ntohl(iph->saddr);

    // 检查黑名单
    u64 *pkt_cnt = bpf_map_lookup_elem(&ban_map, &src_ip);
    if (pkt_cnt && *pkt_cnt > 1000)
        return XDP_DROP;

    return XDP_PASS;
}

7.4 tc BPF 与流量整形

tc BPF 在协议栈的队列规则层运行,提供比 XDP 更高层次的访问权限(可以访问 sk_buff 结构、socket 上下文)。典型用途:

  • 连接级别的流量整形
  • 基于应用层数据的决策(解析 HTTP Host)
  • 复杂负载均衡(Cilium kube-proxy replacement)

7.5 eBPF Map 结构与通信

eBPF 程序通过 Map 与用户态通信,常见 Map 类型包括:

  • BPF_MAP_TYPE_HASH:通用哈希表(用于黑名单、计数器)
  • BPF_MAP_TYPE_PERCPU_HASH:per-CPU 变体(避免锁争用)
  • BPF_MAP_TYPE_RINGBUF:高性能环形缓冲区(替代 perf_event,丢包容忍)
  • BPF_MAP_TYPE_LPM_Trie:最长前缀匹配(路由表、CIDR 规则)

使用 bpf_map_update_elem() / bpf_map_lookup_elem() / bpf_map_delete_elem() 操作 Map。

八、网络零拷贝机制

8.1 sendfile()

sendfile() 在 Linux 2.1 引入,允许文件内容直接发送到 socket,跳过用户空间:

// 传统方式:2 次拷贝(磁盘→内核缓冲区→用户空间→socket缓冲区磁盘)
// sendfile():1 次拷贝(磁盘→socket DMA 直接传输)
sendfile(out_fd, in_fd, &offset, count);

8.2 splice()

splice() 在两个文件描述符之间移动数据,不经过用户空间。利用管道缓冲区中的页面引用实现零拷贝:

// 通过管道桥接实现任意 fd-to-fd 零拷贝移动
splice(pipefd[1], NULL, out_fd, NULL, len, SPLICE_F_MOVE);
splice(in_fd, NULL, pipefd[0], NULL, len, SPLICE_F_MOVE);

8.3 mmap() + writev()

将文件映射到用户空间后直接通过 writev() 发送,但内核仍会拷贝数据到 socket 缓冲区(不支持 SG-DMA 时)。

8.4 MSG_ZEROCOPY

Linux 4.14 引入 MSG_ZEROCOPY,实现真正的 socket 零拷贝发送。应用将数据提交给内核后,内核并不立即处理而是等网卡完成 DMA 后通过 socket 错误队列发送 completion 通知。

int ret = send(fd, buf, len, MSG_ZEROCOPY);
// ... 等待 completion notification ...
// 读取错误队列中的 completion 通知
struct sock_extended_err *serr;
struct msghdr msg = {};
msg.msg_control = control_buf;
msg.msg_controllen = sizeof(control_buf);
recvmsg(fd, &msg, MSG_ERRQUEUE);

MSG_ZEROCOPY 的效果:仅在大包(> 约 10KB)场景下显著;需要配合 SO_ZEROCOPY socket 选项使用;少量小包时不适用(通知开销可能超过拷贝开销)。

九、网络性能调优

9.1 核心 sysctl 参数

参数 默认值 说明
net.core.rmem_max 212992 socket 接收缓冲区最大
net.core.wmem_max 212992 socket 发送缓冲区最大
net.core.netdev_budget 300 NAPI 单次 poll 最大包数
net.core.netdev_budget_usecs 2000 (µs) NAPI 单次 poll 最大时间
net.core.somaxconn 4096 TCP Backlog 最大排队
net.ipv4.tcp_max_syn_backlog 4096 SYN 半连接最大队列
net.ipv4.tcp_tw_reuse 2 TIME_WAIT 时期允许端口重用
net.ipv4.tcp_fastopen 3 TCP Fast Open 同时支持客户端和服务端
net.core.default_qdisc fq_codel 默认队列规则
net.ipv4.tcp_congestion_control bbr 拥塞控制算法

9.2 Ring Buffer 调节

# 查看当前 ring buffer
ethtool -g eth0

# 设置为最大
ethtool -G eth0 rx 4096 tx 4096

# 查看丢包统计(ring buffer overflow)
ethtool -S eth0 | grep -E 'drop|miss|abort'

9.3 中断亲和性(IRQ Affinity)

多队列网卡的每个 RX/TX 队列有独立中断号,通过设置中断亲和性将不同队列绑定到不同 CPU:

# 查看网卡队列数
ethtool -l eth0

# 查看中断号
cat /proc/interrupts | grep eth0

# 设置中断 105 绑定到 CPU 0
echo 1 > /proc/irq/105/smp_affinity_list

9.4 RSS/RPS/RFS

  • RSS(Receive Side Scaling):网卡硬件支持的基于哈希的多队列分发。
  • RPS(Receive Packet Steering):纯软件实现的多队列分发,绕过硬件限制。
  • RFS(Receive Flow Steering):根据流哈希将软中断分配到处理该流的 CPU。
# 启用 RPS,4 队列分发给 CPU 0-3
echo f > /sys/class/net/eth0/queues/rx-0/rps_cpus

9.5 拥塞控制算法选择(BBR)

BBR(Bottleneck Bandwidth and RTT)由 Google 提出,不依赖丢包作为拥塞信号:

# 查看当前算法
sysctl net.ipv4.tcp_congestion_control

# 启用 BBR(要求内核 4.9+)
sysctl -w net.ipv4.tcp_congestion_control=bbr

BBR 在高带宽延迟积(BDP)网络和丢包率较高的链路上表现尤为突出,可以实现比传统 CUBIC 算法 2-25 倍吞吐提升。

十、内核旁路技术概览

10.1 DPDK

DPDK(Data Plane Development Kit)通过在用户空间实现网卡驱动,完全绕过内核协议栈:

[ DPDK 路径 ]
NIC → DPDK PMD 驱动(用户态轮询)→ 应用直接处理

优势:零中断、零内核上下文切换、0 拷贝、CPU 独占 poll 达到 100Mpps/核心。代价:需要专用 CPU 核、丧失内核协议栈的成熟功能(NAT、过滤、拥塞控制需自行实现)。

10.2 AF_XDP

AF_XDP 是 Linux 4.18 引入的新 socket 类型,提供内核与用户空间之间的零拷贝通道:

[ AF_XDP 路径 ]
NIC → 内核处理基础路由 → 通过 UMEM 共享内存直接递交用户态 → 用户态处理后选择 XDP_PASS 或 XDP_DROP

AF_XDP 使用用户态注册的 UMEM 内存池作为包的缓冲区,NIC 直接 DMA 到用户态可访问的内存,消除了传统 sk_buff 分配和拷贝的开销。

10.3 io_uring 与网络

Linux 5.1 引入的 io_uring 改变了异步 I/O 的范式,同样适用于网络操作:

// 提交一个异步 recvmmsg 请求
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recvmsg(sqe, fd, &msg, 0);
io_uring_sqe_set_data(sqe, my_context);
io_uring_submit(&ring);

// 批量收割完成事件
struct io_uring_cqe *cqe;
unsigned head;
io_uring_for_each_cqe(&ring, head, cqe) {
    process_response(cqe);
}
io_uring_cq_advance(&ring, count);

io_uring 通过共享环形缓冲区实现了 kernel-user 通信的零 syscall 开销,尤其在 NVMe 和网络领域有极大性能提升。

十一、网络栈性能剖析实战

11.1 关键指标监控

# 接口吞吐与丢包
sar -nDEV 1

# TCP 重传率(高重传通常表示拥塞或丢包)
ss -ti | grep retrans

# TCP 各状态连接数量
ss -tan | awk '{print $1}' | sort | uniq -c

#Ring Buffer 溢出统计
ethtool -S eth0 | grep -E 'drop|error|buffer'

# 软中断 CPU 占用
mpstat -P ALL 1 | grep softirq

11.2 常见性能瓶颈排查

  1. netstat -s | grep -iE 'drop|error|fail' 输出非零值
  2. CPU 单核跑满(查看 /proc/softirqs NET_RX_SOFTIRQ)→ 增大 netdev_budget、启用 RPS/RFS、开启 GRO
  3. TCP 重传率高 → 检查物理链路质量、确认时钟同步、调优拥塞控制
  4. 内存不足导致接收失败 → 检查 dmesg | grep oom、调整 tcp_rmem 区间
  5. conntrack 表满 → dmesg | grep conntrack、增大 nf_conntrack_max
  6. Socket backlog 大 → 调整 somaxconn、tcp_max_syn_backlog、增大应用 accept 速率

十二、总结

Linux 网络栈是一个精密的、多层次的流水线系统。从网卡硬件的 DMA 和 RSS,到 NAPI 的中断轮询编排,再到内核协议栈的分层处理,再到 eBPF/XDP 的可编程扩展,每一层都做了大量针对高并发和网络吞吐的优化。

理解网络栈的核心意味着:当网络性能不达预期时,知道去哪里排查(Ring Buffer、NACK、retransmits、conntrack、中断亲和性);当需要极致网络性能时,知道用什么工具(XDP 做包过滤、io_uring 做批量 I/O、eBPF map 做状态分发);当需要通信模式创新时,知道在哪里入手(AF_XDP 用户态协议栈、tc BPF 可编程调度、nftables 现代化防火墙)。

网络栈的演进还在持续。io_uring、eBPF、XDP 这些技术正在重塑网络 I/O 范式,未来高性能网络应用将越来越依赖可编程的、内核态与用户态深度协同的架构。而底层核心——sk_buff 和 NAPI——依然是理解整个系统不变的基础。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部