上一篇我们拆解了 sk_buff——内核网络栈的通用包容器。但
sk_buff
不会凭空出现。在收包路径中,是网卡驱动负责从硬件拿到原始数据、分配
sk_buff、填充字段,然后交给协议栈。在发包路径中,是网卡驱动负责把协议栈构造好的
sk_buff 映射到 DMA
地址、写入硬件描述符、触发网卡发送。
驱动和内核网络栈之间的接口契约就是
struct net_device 和
struct net_device_ops。Linux
内核中每一个网络设备——不管是物理的 Intel X710、虚拟的 veth
pair、还是隧道的 VXLAN——都是一个 net_device
实例。
本文拆解三个核心问题:
net_device有哪些关键字段,内核网络栈怎么通过它找到驱动的操作函数?- 网卡驱动的收包模型(NAPI)是怎么在中断和轮询之间切换的?
- 多队列网卡的 TX/RX 队列在内核中怎么组织?
本文基于 Linux 6.6 LTS 内核源码(
include/linux/netdevice.h)。6.8 的差异会标注。
一、net_device 全景:一个超大结构体
struct net_device 定义在
include/linux/netdevice.h,是内核中最大的结构体之一。在
6.8 内核中定义起始于约第 2103 行,字段数量超过 100 个。
这不是设计失误,而是历史积累——网络设备需要承载的信息确实很多:设备名、MAC
地址、MTU、硬件特性、统计计数器、TX/RX 队列、NAPI 列表、XDP
程序、Traffic Control 钩子……所有这些都挂在一个
net_device 上。
为了在高速路径上减少 cache miss,6.x 内核把
net_device 的字段分成了几个 cacheline
对齐的组:
// include/linux/netdevice.h(简化,展示结构分组)
struct net_device {
// --- TX 热路径字段 ---
struct_group(net_device_read_tx,
const struct net_device_ops *netdev_ops; // 驱动操作表
struct netdev_queue *_tx; // TX 队列数组
unsigned int num_tx_queues; // TX 队列总数
unsigned int real_num_tx_queues; // 活跃 TX 队列数
unsigned int tx_queue_len; // 默认 TX 队列长度
// ...
);
// --- TX/RX 共享热路径字段 ---
struct_group(net_device_read_txrx,
unsigned long state; // 设备状态标志
unsigned int flags; // IFF_UP, IFF_PROMISC 等
unsigned short hard_header_len; // L2 头长度
netdev_features_t features; // 硬件特性掩码
// ...
);
// --- RX 热路径字段 ---
struct_group(net_device_read_rx,
struct bpf_prog __rcu *xdp_prog; // XDP 程序
struct netdev_rx_queue *_rx; // RX 队列数组
unsigned int real_num_rx_queues; // 活跃 RX 队列数
unsigned int gro_max_size; // GRO 最大聚合大小
// ...
);
// --- 非热路径字段 ---
char name[IFNAMSIZ]; // 设备名(如 "eth0")
unsigned int mtu; // 最大传输单元
unsigned char addr_len; // MAC 地址长度
unsigned char dev_addr[MAX_ADDR_LEN]; // MAC 地址
struct list_head napi_list; // 所有 NAPI 实例
struct net_device_stats stats; // 传统统计(已弃用)
struct net_device_core_stats __percpu *core_stats; // per-CPU 统计
// ... 还有几十个字段
};这个 cacheline 分组设计意味着:在发包路径中,CPU
只需要加载 net_device_read_tx 这个 cacheline
组就能找到 netdev_ops 和 TX
队列;在收包路径中,只需要加载
net_device_read_rx 就能找到 RX 队列和 XDP
程序。减少不必要的 cache 污染,在高 PPS 场景下影响显著。
二、net_device_ops:驱动操作表
net_device_ops
是网卡驱动向内核注册的”方法表”。内核网络栈通过这张表调用驱动的具体实现,不需要知道底层硬件细节。
// include/linux/netdevice.h, 约第 1437 行
struct net_device_ops {
int (*ndo_open)(struct net_device *dev);
int (*ndo_stop)(struct net_device *dev);
netdev_tx_t (*ndo_start_xmit)(struct sk_buff *skb,
struct net_device *dev);
u16 (*ndo_select_queue)(struct net_device *dev,
struct sk_buff *skb,
struct net_device *sb_dev);
void (*ndo_set_rx_mode)(struct net_device *dev);
int (*ndo_set_mac_address)(struct net_device *dev, void *addr);
int (*ndo_change_mtu)(struct net_device *dev, int new_mtu);
void (*ndo_tx_timeout)(struct net_device *dev,
unsigned int txqueue);
void (*ndo_get_stats64)(struct net_device *dev,
struct rtnl_link_stats64 *storage);
int (*ndo_setup_tc)(struct net_device *dev,
enum tc_setup_type type, void *type_data);
// ... 还有几十个可选操作
};核心操作
| 操作 | 调用时机 | 含义 |
|---|---|---|
ndo_open |
ip link set dev eth0 up |
初始化硬件、分配 ring buffer、注册 NAPI、使能中断 |
ndo_stop |
ip link set dev eth0 down |
停止硬件、释放资源、注销 NAPI |
ndo_start_xmit |
每个发送包 | 发包的核心入口——把 sk_buff
交给硬件 |
ndo_select_queue |
发包时选队列 | 决定这个包走哪个 TX 队列 |
ndo_get_stats64 |
读取统计信息 | ip -s link show 等命令读取时调用 |
ndo_change_mtu |
修改 MTU | ip link set dev eth0 mtu 9000 |
ndo_set_rx_mode |
配置接收过滤 | 混杂模式、组播过滤等 |
ndo_tx_timeout |
TX 超时 | watchdog 发现某个 TX 队列卡死时调用 |
ndo_start_xmit:发包的关键路径
ndo_start_xmit
是内核网络栈和网卡驱动之间最关键的接口。每个要发送的
sk_buff
最终都会到达这个函数。驱动在这里做的事情:
- 把
sk_buff的数据地址映射到 DMA 地址(dma_map_single/dma_map_page) - 填写硬件 TX 描述符(descriptor),包含 DMA 地址、长度、offload 标志
- 更新 TX ring buffer 的 producer 指针
- 通知网卡有新的描述符可以发送(写 doorbell 寄存器)
// 典型的 ndo_start_xmit 骨架(简化)
static netdev_tx_t mynic_start_xmit(struct sk_buff *skb,
struct net_device *dev)
{
struct mynic_tx_ring *ring = &priv->tx_ring[skb_get_queue_mapping(skb)];
// 检查是否有空闲描述符
if (mynic_tx_avail(ring) < skb_shinfo(skb)->nr_frags + 1) {
netif_stop_queue(dev);
return NETDEV_TX_BUSY;
}
// DMA 映射
dma_addr = dma_map_single(dev->dev.parent, skb->data,
skb_headlen(skb), DMA_TO_DEVICE);
// 填写 TX 描述符
desc = &ring->desc[ring->next_to_use];
desc->addr = dma_addr;
desc->len = skb_headlen(skb);
desc->cmd = TX_CMD_EOP | TX_CMD_RS;
// 处理 fragment
for (i = 0; i < skb_shinfo(skb)->nr_frags; i++) {
// 每个 fragment 也要 DMA 映射 + 填描述符
}
// 更新 producer 指针,通知硬件
ring->next_to_use = next;
writel(ring->next_to_use, ring->tail_reg);
return NETDEV_TX_OK;
}返回值 NETDEV_TX_OK 表示驱动已经接管了这个
sk_buff;NETDEV_TX_BUSY
表示队列已满,内核会暂停这个队列的发送。
三、NAPI:中断与轮询的动态切换
为什么需要 NAPI
在千兆网卡时代,每收到一个包都触发一次硬件中断还勉强能接受。到了万兆网卡、每秒数百万包的场景,纯中断模型会导致 interrupt storm(中断风暴)——CPU 把绝大部分时间花在中断处理上,反而没时间处理包数据。
NAPI(New API)的核心思想是:低负载时用中断驱动(低延迟),高负载时切换到轮询模式(高吞吐)。
低负载:
包到达 → 硬件中断 → 内核处理包 → 等待下一个中断
高负载:
包到达 → 硬件中断 → 关闭中断 → 轮询处理一批包 → 还有包?继续轮询
→ 没包了?重新开中断
napi_struct
每个 NAPI 实例用 struct napi_struct
表示:
// include/linux/netdevice.h, 约第 352 行
struct napi_struct {
struct list_head poll_list; // 挂在 per-CPU 轮询列表上
unsigned long state; // 状态标志
int weight; // 每次轮询最多处理的包数
int (*poll)(struct napi_struct *, int); // 轮询回调
struct net_device *dev; // 所属设备
struct gro_list gro_hash[GRO_HASH_BUCKETS]; // GRO 聚合桶
struct sk_buff *skb; // 待处理的 skb
struct list_head rx_list; // 待交付的 GRO_NORMAL 包列表
int rx_count; // rx_list 长度
unsigned int napi_id; // 唯一 ID(busy polling 用)
struct hrtimer timer; // 高精度定时器
struct task_struct *thread; // 线程化 NAPI 的内核线程
struct list_head dev_list; // 设备的 NAPI 列表
int irq; // 关联的中断号
};weight 是每次轮询的”预算”——NAPI 回调最多处理
weight 个包就必须让出 CPU。默认值通常是
64。如果一次轮询处理了 64 个包还有剩余,NAPI
会被重新调度;如果处理不到 64 个,说明包已经处理完了,NAPI
重新开启中断。
NAPI 的生命周期
驱动在 ndo_open 时注册 NAPI 实例:
// 驱动初始化(ndo_open 中)
netif_napi_add(dev, &priv->napi, mynic_poll);
napi_enable(&priv->napi);收包中断发生时,驱动调用 napi_schedule 把
NAPI 实例挂到 per-CPU 的轮询列表上:
// 中断处理函数(硬中断上下文)
static irqreturn_t mynic_interrupt(int irq, void *data)
{
struct mynic_priv *priv = data;
// 关闭这个队列的中断
mynic_disable_irq(priv);
// 调度 NAPI 轮询
napi_schedule(&priv->napi);
return IRQ_HANDLED;
}napi_schedule() 会触发
NET_RX_SOFTIRQ 软中断。在软中断处理函数
net_rx_action() 中,内核遍历 per-CPU 的 NAPI
列表,调用每个 NAPI 实例的 poll 回调:
// 驱动的 NAPI poll 回调(简化)
static int mynic_poll(struct napi_struct *napi, int budget)
{
int work_done = 0;
while (work_done < budget) {
// 从 RX ring buffer 取一个描述符
desc = &ring->desc[ring->next_to_clean];
if (!desc->done)
break; // 没有更多完成的包了
// 分配 sk_buff,把数据从 DMA buffer 拷贝或映射过来
skb = mynic_build_skb(ring, desc);
// 交给 NAPI GRO 处理
napi_gro_receive(napi, skb);
work_done++;
ring->next_to_clean = next;
}
if (work_done < budget) {
// 处理完了,退出轮询,重新开启中断
napi_complete_done(napi, work_done);
mynic_enable_irq(priv);
}
return work_done;
}关键逻辑:
work_done < budget:处理完了所有待处理的包,说明负载不高。调用napi_complete_done()退出轮询模式,重新开启硬件中断。work_done == budget:预算用完了还有包没处理,说明负载很高。不退出轮询,NAPI 会被重新调度继续处理。
NAPI 状态标志
enum {
NAPI_STATE_SCHED, // 已被调度,等待轮询
NAPI_STATE_MISSED, // 在完成过程中有新包到达,需要重新调度
NAPI_STATE_DISABLE, // 正在被禁用
NAPI_STATE_THREADED, // 在专用内核线程中运行
NAPI_STATE_IN_BUSY_POLL,// sk_busy_loop() 正在占用
};NAPI_STATE_MISSED 值得注意:在
napi_complete_done()
执行过程中,如果有新包到达触发了中断,驱动调用
napi_schedule() 时发现 NAPI 还在 complete
过程中,就会设置 MISSED
标志。napi_complete_done() 检测到
MISSED 后会重新调度自己,避免包延迟。
线程化 NAPI
传统 NAPI 在 softirq 上下文中运行,受
netdev_budget(默认 300)和
time_squeeze 限制。6.x 内核支持线程化
NAPI(NAPI_STATE_THREADED),把轮询放到专用内核线程中,可以被
cgroup 调度、设置 CPU 亲和性、用 nice 值控制优先级:
# 开启线程化 NAPI
echo 1 > /sys/class/net/eth0/threaded
# 查看 NAPI 线程
ps aux | grep napi
# root 12345 0.0 0.0 napi/eth0-rx-0四、多队列架构:TX 队列与 RX 队列
现代网卡(万兆及以上)普遍支持多个 TX/RX 队列。每个队列绑定一个中断,通过 IRQ 亲和性绑定到不同 CPU,实现真正的并行收发包。
TX 队列(netdev_queue)
// include/linux/netdevice.h
struct netdev_queue {
struct net_device *dev;
struct Qdisc __rcu *qdisc; // TC qdisc
struct napi_struct *napi; // TX completion NAPI
spinlock_t _xmit_lock; // 发送锁
unsigned long state; // 队列状态
unsigned long trans_start; // 最后发送时间
struct dql dql; // 动态队列限制
};dql(Dynamic Queue
Limits)是一个自适应机制——它根据网卡的实际处理速度动态调整每个
TX
队列允许排队的字节数。避免”队列太深导致延迟高”和”队列太浅导致吞吐低”的两难问题。
发包时,内核通过 ndo_select_queue 或默认的
netdev_pick_tx() 选择一个 TX 队列:
// 默认 TX 队列选择(简化)
u16 netdev_pick_tx(struct net_device *dev, struct sk_buff *skb)
{
// 优先使用 XPS(Transmit Packet Steering)映射
// 否则根据 skb hash 选择队列
int queue_index = skb_tx_hash(dev, skb);
return queue_index;
}RX 队列(netdev_rx_queue)
// include/net/netdev_rx_queue.h
struct netdev_rx_queue {
struct xdp_rxq_info xdp_rxq; // XDP 元数据
struct rps_map __rcu *rps_map; // RPS CPU 映射
struct rps_dev_flow_table __rcu *rps_flow_table; // RPS 流表
struct napi_struct *napi; // 关联的 NAPI
struct xsk_buff_pool *pool; // AF_XDP 缓冲池
struct net_device *dev;
};每个 RX 队列关联一个 NAPI 实例和一个硬件中断。通过 RSS(Receive Side Scaling),网卡硬件根据包的五元组 hash 将包分发到不同 RX 队列,不同 CPU 并行处理。
rps_map 和 rps_flow_table
是软件 RPS/RFS 的配置——在网卡不支持硬件 RSS
的情况下,用软件实现类似的 CPU 分发。
pool 是 AF_XDP 的零拷贝缓冲池——当 XDP socket
绑定到这个队列时,收包直接写入用户态共享内存,绕过整个协议栈。
五、Ring Buffer 与 DMA:硬件到内核的数据传递
描述符 Ring Buffer
物理网卡和驱动之间通过描述符环形缓冲区(Descriptor
Ring Buffer)交换数据。这不是
net_device 或 netdevice.h
中定义的通用结构,而是每个驱动自己实现的,但模式高度一致:
+---+---+---+---+---+---+---+---+
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | ← 描述符数组(固定大小)
+---+---+---+---+---+---+---+---+
^ ^
| |
consumer producer
(driver) (hardware/driver)
RX Ring Buffer:
- 驱动预先分配一批 DMA buffer,把它们的地址写入描述符
- 网卡收到包后,通过 DMA 把数据写入 buffer,在描述符中标记”已完成”
- 驱动在 NAPI poll 中遍历描述符,取出完成的包,构建
sk_buff - 驱动重新分配 buffer,填入空闲描述符,让网卡可以继续写入
TX Ring Buffer:
- 驱动在
ndo_start_xmit中把sk_buff的 DMA 地址写入描述符 - 通知网卡(写 doorbell/tail 寄存器)
- 网卡读取描述符,DMA 读取数据,发送到网线
- 发送完成后网卡标记描述符为”已完成”,驱动在 TX completion
中回收
sk_buff
Ring Buffer 大小调优
# 查看当前 ring buffer 大小
ethtool -g eth0
# 输出示例:
# Ring parameters for eth0:
# Pre-set maximums:
# RX: 4096
# TX: 4096
# Current hardware settings:
# RX: 256
# TX: 256
# 调大 ring buffer(减少高负载下的丢包)
ethtool -G eth0 rx 4096 tx 4096Ring buffer 越大,在流量突发时越不容易丢包,但也意味着更大的延迟和更多的内存消耗。这是一个 latency-throughput tradeoff。
六、设备注册与生命周期
注册流程
网卡驱动加载时,通过 register_netdev()
向内核注册一个 net_device:
// 驱动探测函数(简化)
static int mynic_probe(struct pci_dev *pdev, ...)
{
struct net_device *dev;
// 分配 net_device + 驱动私有数据
dev = alloc_etherdev_mqs(sizeof(struct mynic_priv),
num_tx_queues, num_rx_queues);
// 设置操作表
dev->netdev_ops = &mynic_netdev_ops;
// 设置硬件特性
dev->features |= NETIF_F_SG | NETIF_F_IP_CSUM |
NETIF_F_TSO | NETIF_F_GRO;
// 设置 MAC 地址
eth_hw_addr_set(dev, hw_addr);
// 注册到内核
register_netdev(dev);
return 0;
}alloc_etherdev_mqs() 一次性分配
net_device 结构体、驱动私有数据、TX/RX
队列数组。私有数据通过 netdev_priv(dev)
访问。
features:硬件特性掩码
net_device.features
是一个位掩码,标识网卡支持的硬件卸载能力:
| Feature | 含义 |
|---|---|
NETIF_F_SG |
Scatter-Gather DMA(发送 fragment 列表) |
NETIF_F_IP_CSUM |
IPv4 TCP/UDP 校验和卸载 |
NETIF_F_HW_CSUM |
通用校验和卸载 |
NETIF_F_TSO |
TCP Segmentation Offload |
NETIF_F_GRO |
Generic Receive Offload |
NETIF_F_GSO |
Generic Segmentation Offload |
NETIF_F_RXCSUM |
RX 校验和卸载 |
NETIF_F_HW_VLAN_CTAG_RX |
硬件 VLAN 标签剥离 |
NETIF_F_LOOPBACK |
硬件 loopback |
# 查看网卡硬件特性
ethtool -k eth0 | head -20
# 关闭 TSO(调试用)
ethtool -K eth0 tso off协议栈在发包时会检查
dev->features,决定是否使用硬件卸载。例如,如果网卡支持
NETIF_F_TSO,TCP 层就不需要按 MSS
分段,而是构造一个大
sk_buff(skb_shinfo(skb)->gso_size
= MSS),让网卡硬件来分段。
七、可观测性:追踪 net_device 和 NAPI
追踪 NAPI 调度与完成
# 统计 NAPI poll 频率和每次处理的包数
bpftrace -e '
kprobe:__napi_poll {
@sched = count();
}
kretprobe:__napi_poll /retval > 0/ {
@pkts = hist(retval);
}
interval:s:1 { print(@sched); print(@pkts); clear(@sched); clear(@pkts); }
'观察中断分布
# 查看网卡中断在各 CPU 上的分布
grep eth0 /proc/interrupts
# 典型输出(多队列网卡):
# CPU0 CPU1 CPU2 CPU3
# 45: 123456 0 0 0 eth0-TxRx-0
# 46: 0 234567 0 0 eth0-TxRx-1
# 47: 0 0 345678 0 eth0-TxRx-2
# 48: 0 0 0 456789 eth0-TxRx-3每个队列的中断只在绑定的 CPU 上触发。如果分布不均匀,可以调整 IRQ 亲和性:
# 设置中断 45 的 CPU 亲和性(绑定到 CPU 0)
echo 1 > /proc/irq/45/smp_affinity查看 ring buffer 和队列状态
# 查看 ring buffer 使用情况
ethtool -S eth0 | grep -E 'rx_queue|tx_queue|drop'
# 查看每个队列的统计
ls /sys/class/net/eth0/queues/
# rx-0 rx-1 rx-2 rx-3 tx-0 tx-1 tx-2 tx-3
cat /sys/class/net/eth0/queues/rx-0/rps_cpus
cat /sys/class/net/eth0/queues/tx-0/xps_cpus追踪 TX 队列停止/恢复
# 追踪 TX 队列满导致的停止事件
bpftrace -e '
kprobe:netif_stop_queue {
@stop[comm] = count();
}
kprobe:netif_wake_queue {
@wake[comm] = count();
}
interval:s:5 { print(@stop); print(@wake); clear(@stop); clear(@wake); }
'如果 netif_stop_queue 频繁触发,说明 TX ring
buffer 太小或者网卡发送速度跟不上协议栈的发包速度。
八、小结
net_device 和 net_device_ops
定义了内核网络栈和网卡驱动之间的接口契约。几个关键判断:
net_device的 cacheline 分组是性能关键。 TX 热路径只需加载 TX 组字段,RX 热路径只需加载 RX 组字段。在每秒千万包场景下,一次额外的 cache miss 就是几纳秒的延迟累加。NAPI 不是简单的”中断合并”。 它是一个在中断和轮询之间动态切换的状态机。低负载时中断驱动保证低延迟,高负载时切换到轮询保证高吞吐。
NAPI_STATE_MISSED机制保证切换过程不丢包。多队列 + IRQ 亲和性是万兆网卡的性能基础。 没有多队列,所有包都在一个 CPU 上处理,无论 CPU 多快都会成为瓶颈。RSS/RPS 把不同流分到不同 CPU,是实现线性扩展的前提。
Ring Buffer 大小是 latency-throughput 的 tradeoff。 太小容易在突发流量时丢包,太大增加缓冲延迟。
ethtool -G和 DQL 是两个调优手段。
下一篇我们将跟着一个收到的网络包,走完从网卡中断到 socket
接收队列的完整路径——把
sk_buff、net_device、NAPI
这些零件串成完整的收包链路。
参考文献
- Linux
内核源码,
include/linux/netdevice.h,6.6 LTS / 6.8 - Linux 内核源码,
net/core/dev.c,6.6 LTS - Linux
内核文档,
Documentation/networking/napi.rst - Linux
内核文档,
Documentation/networking/scaling.rst(RSS/RPS/RFS/XPS)
下一篇:收包路径全解:从 NIC 中断到 socket 接收队列
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【Linux 网络子系统深度拆解】收包路径全解:从 NIC 中断到 socket 接收队列
一个网络包从网卡 DMA 到用户态 recvmsg(),要走过硬中断、NAPI 轮询、GRO 聚合、协议分发、IP 路由、Netfilter 钩子、TCP/UDP 处理、socket 队列八个阶段。本文从 Linux 6.6 内核源码出发,逐函数拆解完整的 RX 收包路径,量化每一跳的 CPU 开销,并用 bpftrace 实测各阶段延迟分布。
【Linux 网络子系统深度拆解】sk_buff 全解:内核网络包的终极容器
sk_buff 是 Linux 内核网络栈的通用货币——每一个收到或发出的网络包,都必须装在这个容器里走完全程。本文从 Linux 6.6 内核源码出发,拆解 sk_buff 的内存布局、四大指针操作、clone 与 copy 的代价差异、skb_shared_info 的 fragment 机制,并用 bpftrace 实测 sk_buff 分配热点和生命周期。
【Kubernetes 网络深度系列】Linux 网络栈全景:一个包从网卡到用户态的完整旅程
从 NIC 驱动到用户态 read(),一个网络包在 Linux 内核中到底经历了什么?本文拆解 sk_buff、NAPI、softirq、netfilter 的完整收包路径,并用 bpftrace 实测追踪每一跳的延迟。
【Linux 网络子系统深度拆解】软中断与 ksoftirqd:网络包处理的调度引擎
网络包到达网卡后,真正消耗 CPU 的处理全部发生在软中断上下文。本文从 Linux 6.6 内核源码出发,拆解 softirq 10 向量优先级体系、__do_softirq() 主循环与 MAX_SOFTIRQ_RESTART 放弃策略、ksoftirqd 调度时机、Threaded NAPI 替代方案,以及 CONFIG_PREEMPT_RT 下的行为变化。最后用 bpftrace/perf 实测软中断延迟和 time_squeeze 饥饿。