Linux内核分析之网络协议栈-00

This language version is unavailable; showing the other language.

44.1 网络协议栈分层与 sk_buff

sk_buff(skb)是网络栈的通用语:从驱动到 TCP,报文以 skb 传递,各层只做"加头/减头/改偏移",数据本体几乎不拷贝。这套设计的核心是三个决定:描述符与数据区分离、数据区内的四指针移动、协议头偏移的缓存。本节逐段解剖这个"网络栈的通用语",它是读一切网络代码的前提。


44.1.1 skb 布局:描述符与数据区分离

// include/linux/skbuff.h:885-1100(节选)
struct sk_buff {
    union {
        struct {        /* These two members must be first
                       to match sk_buff_head. */
            struct sk_buff *next;       /* :889 */
            struct sk_buff *prev;       /* :890 队列节点 */
            ...
            struct net_device *dev;     /* :893 所属设备 */
        };
        ...
    };
    ...
    ktime_t     tstamp;         /* 时间戳(硬件/软件) */
    ...
    unsigned int    len,        /* :934 逻辑总长(含分片) */
            data_len;   /* 不在线性区的部分(frag 页) */
    __u16       mac_len,
            hdr_len;
    ...
    /* These elements must be at the end, see alloc_skb() for details. */
    sk_buff_data_t      tail;       /* :1092 */
    sk_buff_data_t      end;        /* :1093 */
    unsigned char       *head,      /* :1094 */
                *data;      /* :1094 */
    __u16           inner_transport_header; /* :1076 隧道内层 */
    __u16           inner_network_header;   /* :1077 */
    __u16           transport_header;   /* :1081 L4 偏移 */
    __u16           network_header;     /* :1082 L3 偏移 */
    __u16           mac_header;     /* :1083 L2 偏移 */
};

描述符(sk_buff 结构体)与数据区分离是第一个决定:结构体常驻专用 slab 缓存(skbuff_head_cache,__alloc_skb(),net/core/skbuff.c:672 分配),数据区独立成块——两者可分别操作(clone 只复制描述符、数据引用计数共享,见 44.1.2)。tail/end 用 32 位相对偏移而 head/data 用指针(sk_buff_data_t,skbuff.h:723 在 64 位上是 unsigned int)——省 16 字节/skb,百万 skb 的机器上就是 16MB。

44.1.2 数据区四指针:加剥头只动指针

 skb 数据区布局 (44.1.1 节四指针的图形化):

 head                                       end
  │                                          │
  ▼                                          ▼
  ├─────────┬──────────┬──────────────┬──────┤
  │ headroom│ (保留空隙)│   有效数据    │tailroom│
  └─────────┴────▲─────┴──────▲───────┴──────┘
                 │            │
               data         tail
     headroom: 给各层"加头"的空间 (收到时从 NIC 预留)
     tailroom: skb_pad / 未来扩展空间

 四个移动原语 (include/linux/skbuff.h):
   __skb_push (:2811)  data -= len;  len += len   ← 加头 (L4→L3→L2)
   __skb_pull (:2822)  data += len;  len -= len   ← 剥头 (L2→L3→L4)
   __skb_put  (:2755)  tail += len;  len += len   ← 尾部填数据
   __skb_trim         tail -= len;  len -= len   ← 截尾

 各层穿越的指针舞 (收包方向):
  驱动:      data 指向以太网帧头
  mac_header = data - head            (L2 起点)
  eth_type_trans: data += 14 (pull) → 指向 IP 头
  network_header = data - head        (L3 起点)
  ip_rcv 后: data += 20 (pull)
  transport_header = data - head      (L4 起点)

 发方向完全对称: TCP 加头 = skb_push(tcp头);
  IP 加头 = skb_push(ip头) — 数据一个字节不动!

 三层偏移缓存的意义: tcp_hdr(skb)/ip_hdr(skb)/
  eth_hdr(skb) 全是 (head + 偏移) 的换算宏 —
  每层直接定位自己的头, 无解析扫描

44.1.3 len、data_len 与 truesize:三种"大小"

三个大小字段的语义分工:

 skb->len     逻辑总长 = 线性区数据 + 全部 frags
 skb->data_len 其中在 frag 页里的部分 (len - data_len
              = 线性区大小) — 0 表示完全线性
 skb->truesize 内核为此 skb 实际付出的内存
              = sizeof(sk_buff) + 数据区总分配(含对齐)
              — 45.2 节 socket 配额记账的口径!

 paged frags (非线性区): 大包的页链挂在
   skb_shared_info (skbuff.h:593):
   struct skb_shared_info {
       __u8    nr_frags;        /* frag 页数 */
       unsigned short gso_size; /* TSO/GRO 分段尺寸 */
       unsigned short gso_segs; /* 分段数 */
       struct sk_buff *frag_list; /* 子 skb 链 */
       atomic_t dataref;        /* clone 共享计数 */
       ...
   };
   位于数据区尾部 (end 之前) — 与数据同块内存,
   零额外分配; GRO 合成的大包/零拷贝 sendfile 靠它

44.1.4 克隆族:从零拷贝到深拷贝

// net/core/skbuff.c:2098
struct sk_buff *skb_clone(struct sk_buff *skb, gfp_t gfp_mask)
// :672
struct sk_buff *__alloc_skb(unsigned int size, gfp_t gfp_mask, ...)
复制谱系 (成本递增):

 skb_clone       只复制描述符; 数据区共享
                 (shared_info->dataref++)
   消费者: tcpdump 抓包 / TCP 重传队列 /
           桥多口转发 / netlink 广播 (48.1)
 skb_share_check clone 后检查共享态
 skb_copy        描述符+数据全拷 (真复制)
 pskb_expand_head 重开更大的线性区 (需要写数据
                 但 headroom 不足时 — 唯一"搬数据"
                 的常见路径, 慢!)

 clone 的纪律: 共享数据期间"任何一方不得写数据区"
 — TCP 重传队列持有 clone, 待 ACK 后才 kfree_skb
 (dataref 归零才真释放) — 引用计数的又一次应用
 (17/18 章同族)

44.1.5 分发:ptype 链与协议注册

// net/core/dev.c:172 与 :624
struct list_head ptype_base[PTYPE_HASH_SIZE] __read_mostly;
void dev_add_pack(struct packet_type *pt)
// net/core/dev.c:5965 区(收包分发点, 46.1 节的入口)
static int __netif_receive_skb_core(struct sk_buff **pskb, bool pfmemalloc, ...)
协议分发的骨架:

 __netif_receive_skb_core (:5965):
   [1] taps: ptype_all 上的抓包者先收到 (clone)
       (tcpdump/PACKET socket — 45 章 packet 家族)
   [2] tc ingress / rx_handler (桥/bond 接管,
       44.2.3 节) — rx_handler 可改写 skb->dev
   [3] deliver_ptype_list: 按 skb->protocol 查
       ptype_base 哈希 (eth_type_trans 填的
       ETH_P_IP/IPV6/ARP) → 调各协议的 func
       → ip_rcv (46.1) / ipv6_rcv / arp_rcv

 协议注册 (启动期):
   dev_add_pack(&ip_packet_type)  L3 层注册
   inet_add_protocol(&tcp_protocol, IPPROTO_TCP)  L4 层注册
   (每层有自己的 handler 链 — "分发链"而非
    调用栈, 46 章各函数是这条链的节点)

软中断上下文(42.2 节)是全部收包代码的运行环境:NET_RX_SOFTIRQ 里跑 NAPI poll 与协议栈入口——这也是收包路径"不可睡眠"纪律(所有锁是自旋锁/RCU)的根源。

44.1.6 观测:skb 在哪可以看见

观测 skb 的窗口:

 /proc/net/softnet_net   每 CPU: dropped/time_squeeze/
                         received_rps (42.2/44.3 节)
 /proc/net/sockstat      系统级 skb/内存占用
 dropwatch / bpftrace    kfree_skb tracepoint +
                         丢弃点符号 — "包在哪被丢"的
                         标准排查工具
 ethtool -S              驱动级 rx_dropped 计数
 perf probe skb_consume_text ...

小结

skb 以"描述符/数据区分离 + 四指针移动(push/pull/put/trim 只动指针)+ 三层头偏移缓存"实现网络栈的零拷贝穿越:len/data_len/truesize 三种大小分别服务逻辑长度、frags 承载与内存记账;clone 族谱系(clone 共享数据 → copy 全拷)支撑抓包、重传、广播;ptype 分发链把"分层"实现为逐层剥头 + 按协议号查表路由。理解 skb 的布局与指针舞,是读 44-48 章全部网络代码的门票。下一节看报文两端的设备对象——net_device。

44.2 网络设备注册与 net_device

net_device 是网络栈中最大的结构体之一——但驱动真正要填的只有:设备私有数据、net_device_ops(操作表)、能力位(offload features)与收发回调。注册后它进入全局设备表,收发两端随之接通。本节解剖 net_device 的字段分区、注册流程、操作表与收发入口。


44.2.1 net_device:字段分区

// include/linux/netdevice.h:2109(结构巨大, 按功能分区摘录)
struct net_device {
    char            name[IFNAMSIZ]; /* "eth0" 的名字源 */
    ...
    const struct net_device_ops *netdev_ops;    /* 驱动操作表 */
    ...
    unsigned int        mtu;        /* 1500 默认 */
    unsigned short      type;       /* ARPHRD_ETHER */
    unsigned char       *dev_addr;  /* MAC 地址 */
    unsigned int        num_tx_queues;  /* 发送多队列 */
    unsigned int        real_num_tx_queues; /* 已使能数 */
    ...
    rx_handler_func_t __rcu *rx_handler;    /* 桥/bond 接管点 */
    void __rcu      *rx_handler_data;
    ...
    netdev_features_t   features;   /* 当前 offload 能力 */
    netdev_features_t   hw_features;    /* 可协商能力 */
    ...
    struct kobject      kobj;       /* 39 章模型设备 */
    ...
    struct netdev_queue *_tx;       /* 发队列数组 */
    unsigned int        num_rx_queues;  /* 收队列(配 napi) */
};

字段的分区逻辑与 18.2.3 节 struct zone 同思想——按访问热度与归属分组:驱动在 probe 时设置的身份字段(name/mtu/dev_addr)、core 维护的队列数组、每包读的能力位(features)。rx_handler 是"包进了设备后先给谁"的改写点——桥(br_handle_frame)、bonding、macvlan 全部实现为这个函数指针的替换。

44.2.2 注册流程:alloc → setup → register

// net/ethernet/eth.c:342 与 :376
void ether_setup(struct net_device *dev)
struct net_device *alloc_etherdev_mqs(int sizeof_priv, unsigned int txqs,
                      unsigned int rxqs)
// net/core/dev.c:11563 与 :11341
int register_netdev(struct net_device *dev)
int register_netdevice(struct net_device *dev)
驱动注册的标准序列:

 [1] dev = alloc_etherdev_mqs(sizeof_priv, txqs, rxqs)
       一次分配: net_device + 私有区 + 队列数组
       netdev_priv(dev) 取回私有区 (紧贴结构尾!)
       ether_setup (:342) 预填以太网默认:
       mtu=1500 / type=ARPHRD_ETHER / 广播地址 /
       默认 netdev_ops=ethmac_ops 等
 [2] 驱动覆盖:
       dev->netdev_ops = &my_ops;
       dev->ethtool_ops = &my_ethtool_ops;
       dev->features |= NETIF_F_SG | NETIF_F_TSO ...;
       netif_napi_add(dev, &qp->napi, my_poll);  (44.3 节)
 [3] register_netdev(dev)
       └─ register_netdevice (:11341)
            ├─ 校验 (name 唯一/ops 齐全)
            ├─ 进全局索引: dev_name_hash/dev_index_hash
            │   (ifindex 是容器网络的名字空间, 9 章)
            ├─ list_netdevice + 链入 bus 视图
            └─ call_netdevice_notifiers(NETDEV_REGISTER)
                → uevent (39.1.3 节) → udev/ip link 可见

unregister_netdev 的对称拆除必须等"所有引用排空":在途 skb、NAPI 停轮询、等待 RCU 宽限期——与 22.3.5 节 munmap 的"先失效后释放"同纪律。

44.2.3 net_device_ops:驱动操作表

netdev_ops 的核心成员 (驱动必须/常实现的):

 .ndo_open            ifconfig up: 使能中断/分配收包环/napi_enable
 .ndo_stop            down: 对称释放
 .ndo_start_xmit(skb, dev)   发送出口: skb → 硬件描述符
                      (返回 NETDEV_TX_OK/BUSY — 41.2 节同款语义)
 .ndo_set_rx_mode     多播/混杂模式更新 (过滤编程进网卡)
 .ndo_get_stats64     统计快照 (ip -s 的数据源)
 .ndo_set_mac_address / .ndo_change_mtu
 .ndo_validate_addr   MAC 合法性
 .ndo_setup_tc        tc 卸载钩子 (47.2.3 节)
 .ndo_set_features    offload 协商落地
 .ndo_bpf / .ndo_xdp_xmit   XDP 挂载与转发 (44.3.3)

offload 能力位(dev->features)是现代网卡驱动的第二重心:

能力位 含义 栈与硬件的分工变化
NETIF_F_SG 分散聚合 skb frags 直填 DMA(43.2 节 sg 映射)
NETIF_F_TSO TCP 大段卸载 发侧分段下沉硬件(46.3 节 pacing 配合)
NETIF_F_GRO_HW 收侧聚合卸载 GRO 下沉(44.3.3 节)
NETIF_F_HW_CSUM 校验和卸载 栈不算校验和
NETIF_F_HW_L2FW_DOFFLOAD L2 转发卸载 桥卸载

协议栈按协商后的能力位决定自己做还是交给硬件——skbuff.h 的 gso_size/gso_segs(44.1.3 节)就是 TSO 的参数通道。

44.2.4 收发两端的入口

// net/core/dev.c:4766(发送)与 :5718(非驱动收包)
int __dev_queue_xmit(struct sk_buff *skb, struct net_device *sb_dev)
int __netif_rx(struct sk_buff *skb)
发方向 (46 章 TCP 的最终出口):

 tcp_write_xmit → ip_queue_xmit → ... 
   → dev_queue_xmit (:4766)
       ├─ 选发送队列: skb_get_tx_queue 
       │   (多队列: 按流哈希/ XPS CPU 映射)
       ├─ qdisc 入队或直接 xmit (noqueue, 47.2.3)
       ├─ validate_xmit_skb: 分段(TSO)/校验和/加密卸载
       └─ ndo_start_xmit → 硬件描述符环 → 门铃

收方向 (硬件 → 协议栈):

 NIC DMA → 中断 (42.1 只做通知) → NAPI poll (44.3)
   → napi_gro_receive → netif_receive_skb_internal (:6372)
   → __netif_receive_skb_core (44.1.5 节分发)
   非驱动路径 (环回/隧道): __netif_rx → enqueue_to_backlog
   (dev.c:5359, per-CPU backlog 队列 → process_backlog :6637)

观测面:/proc/net/dev(收发计数)、ip -s link(stats64)、ethtool -S(驱动/硬件计数器)、/proc/net/softnet_net(44.3 节)。ip link 的一切属性(mtu/mac/qlen)都直接映射 net_device 字段——用户态可感知的每个设备参数都有一个内核字段与之对应。


小结

net_device 的驱动视图极简(alloc_etherdev 填私有区 + netdev_ops 操作表 + 能力位 + register_netdev),复杂度隐藏在 core 的全局索引、多队列、rx_handler 接管与 offload 协商里;发送入口 dev_queue_xmit(qdisc→校验→xmit)与接收终点 netif_receive_skb(rx_handler→ptype 分发)是协议栈与驱动的两个焊接点。下一节看收包路径的性能生命线——NAPI。

44.3 NAPI —— 中断与轮询的折中

中断驱动的收包在高 PPS 下崩溃:每包一个中断 = 中断风暴(42.2 节的 ksoftirqd 打满、cache line 反复弹跳)。NAPI(New API)的答案是混合模式:首包中断把设备"登记"进轮询列表,随后关闭 RX 中断、在软中断里批量轮询收包,收干净了才重新开中断——中断次数从 O(包数) 降为 O(批次),且批处理天然利于缓存与 GRO 合成。本节解剖 napi_struct 的状态机、驱动侧收包循环与 NAPI 家族的新形态。


44.3.1 为什么"纯中断"撑不住

纯中断收包的恶性循环 (无 NAPI 的反事实):

 高 PPS 场景 (10G 线速 ~14.8Mpps 小包):
   每包一个中断 → 14.8M 中断/秒
   → 中断处理本身的 CPU 占用 > 包处理 CPU 占用
   → 硬中断打断了正要处理包的软中断 (42.2 节)
   → 活锁: CPU 全在应答中断, 一个包都收不完

 中断 vs 轮询的真实权衡:
   低负载: 中断优 (包稀疏, 轮询空转浪费)
   高负载: 轮询优 (中断开销摊薄, 批量取缓存友好)
 NAPI = 自适应切换: 负载轻→中断驱动;
   负载重(队列非空)→持续轮询

44.3.2 napi_struct 与状态机

// include/linux/netdevice.h:381-410(节选)
struct napi_struct {
    /* This field should be first or softnet_data.backlog needs tweaks. */
    unsigned long       state;      /* NAPI_STATE_* 位 */
    /*
     * The poll_list must only be managed by the entity which
     * changes the state of the NAPI_STATE_SCHED bit.  This means
     * whoever atomically sets that bit can add this napi_struct
     * to the per-CPU poll_list, and whoever clears that bit
     * can remove from the list right before clearing the bit.
     */
    struct list_head    poll_list;  /* 挂 per-CPU poll_list */

    int         weight;     /* 单次 poll 配额基数 */
    u32         defer_hard_irqs_count;
    int         (*poll)(struct napi_struct *, int); /* 驱动轮询函数 */
    ...
    /* CPU on which NAPI has been scheduled for processing */
    int         list_owner;
    ...
};

注释原文即协议:poll_list 的进出由 NAPI_STATE_SCHED 位的持有者独占管理——谁原子置位谁入队、谁清位谁出队。一次轮询周期因此天然单线程,无锁轮转。每个 RX 队列配一个 napi_struct(多队列网卡 = 多个独立 NAPI 实例,可分布到多 CPU)。

NAPI 状态机:

   IDLE ──包到达──▶ SCHED (CAS 置 NAPI_STATE_SCHED
     ▲              │       + 挂入 per-CPU poll_list)
     │              │ NET_RX_SOFTIRQ 被调度 (42.2 节)
     │              ▼
     │          POLLING (驱动 poll 执行, 最多 budget 个包)
     │              │
     │   ┌──────────┼────────────┐
     │   │预算内收干净│还有包(budget 用尽│
     │   ▼          ▼            │或队列仍非空)
     │  重新使能   napi_schedule   │
     │  中断+清位   重排入队 (再来一轮)│
     └──(IDLE)──────┘             │
                                  └─▶ 仍 POLLING
// net/core/dev.c:6764
bool napi_complete_done(struct napi_struct *n, int work_done)
// :5359 与 :6637(backlog 路径——非 NAPI 驱动的兼容层)
static int enqueue_to_backlog(struct sk_buff *skb, int cpu, ...)
static int process_backlog(struct napi_struct *napi, int quota)

process_backlog(:6637)揭示 NAPI 的完备性:连"老式 netif_rx"路径也被实现了成 NAPI——per-CPU 的 softnet_data.backlog 队列配一个内建 napi(netdevice.h:3516 的 softnet_data,含 poll_list/output_queue/process_queue/time_squeeze 字段)。整个收包子系统只有一种执行模型。

44.3.3 驱动侧的收包循环

NAPI 驱动的标准写法 (虚代码, 对应真实 NIC 驱动):

 /* probe/open 时 */
 netif_napi_add(dev, &qp->napi, my_poll, NAPI_POLL_WEIGHT=64);
 napi_enable(&qp->napi);

 /* 硬中断 (42.1 节: 只做这些!) */
 my_isr(irq, dev_id)
 {
     if (!napi_schedule_prep(&qp->napi))
         return IRQ_NONE;            /* 已在轮询中 */
     my_disable_rx_irq(qp);          /* 关中断 */
     __napi_schedule(&qp->napi);     /* 入 poll_list
                                        + raise NET_RX */
     return IRQ_HANDLED;
 }

 /* 软中断里被调 (NET_RX_SOFTIRQ) */
 int my_poll(struct napi_struct *napi, int budget)
 {
     int work = 0;
     while (work < budget && 环里有已 DMA 的包) {
         skb = 构造 skb (复用环描述符页, 43.2 节流式映射);
         napi_gro_receive(napi, skb);   /* GRO 合成入口 */
         work++;
     }
     if (work < budget) {               /* 预算内收干净 */
         napi_complete_done(napi, work); /* 清位+重开中断
                                           (:6764, 含中断
                                           聚合 deferral) */
         return work;
     }
     return budget;                     /* 没收完: 再来一轮 */
 }

budget 与 weight 的旋钮语义:budget 限制单次软中断的包数(42.2 节风暴防护在 NAPI 的落实——time_squeeze 计数"没收完被挤出"的次数);weight 是补货节奏;napi_complete_done 的重开中断前还有 defer_hard_irqs_count(中断聚合/ethtool coalescing:硬件再延迟数微秒聚批,把中断次数进一步压到 O(毫秒))。

44.3.4 GRO:轮询点上的合成

GRO (Generic Receive Offload):

 poll 里收到的每个小包经 napi_gro_receive:
   同流 (同四元组) + 序号衔接 + 标志兼容
   → 合成一个巨型 skb (44.1.3 节 frags 装多个包)
   → 交给协议栈: tcp_v4_rcv 一次处理 N 个 MSS

 收益: 上层每包成本 ÷ N (校验和/拷贝/锁)
 对偶: 发送侧 TSO (46.3 节) — 大包给硬件分段
 GRO_HW: 合成也下沉硬件 (features 协商, 44.2.3)

 前提纪律: 同一批合成包必须"同去同往" —
   转发场景需要正确分段还原 (GRO 转发的经典 bug 源)

44.3.5 新形态谱系与观测

轮询思想的极致化:

 busy polling (SO_BUSY_POLL + sysctl):
   进程在 recvmsg 里自己轮询驱动环 —
   绕开软中断/唤醒, 延迟到微秒级 (45 章低延迟语境)
 XDP (eXpress Data Path):
   驱动 poll 里最先执行 BPF 程序 — 包还没构造
   skb 就可 DROP/FORWARD/重定向 (DDoS 前置防线)
 AF_XDP: XDP 重定向到用户态 UMEM 环 —
   零协议栈零拷贝 (DPDK 思想的内核原生版)
 io_uring zero-copy recv (37 章家族延伸)

 观测:
 /proc/net/softnet_net: 每 CPU 的
   processed / dropped / time_squeeze / received_rps
   (softnet_data 字段, netdevice.h:3516)
 ethtool -S: rx_packets/rx_dropped/中断计数
 ethtool -c: 中断聚合参数

小结

NAPI 以"SCHED 位独占 poll_list 管理 + 首包中断后关中断批量轮询 + 预算内收干净才重开中断"的混合模式把收包中断次数降为 O(批次),budget/weight/中断聚合提供延迟-吞吐旋钮;process_backlog 证明连老式收包路径也被统一进 NAPI 模型,GRO 在轮询点把小包合成大包与发送侧 TSO 对偶,XDP/AF_XDP/busy-poll 是轮询思想的极致化谱系。高 PPS 下的一切收包设计都是"中断与轮询折中"这句话的变奏。网络架构章至此完成;下一章进入 socket 层——进程与网络栈的接口。