前言
仅作为自己笔记学习
网卡 --> 协议栈
硬中断 --- 软中断
ksoftirqd 内核线程
线程数量 = 机器核数
ksoftirqd 创建过程
- ksoftirqd_should_run主要用于:
- 内核调度器决定是否唤醒 ksoftirqd 线程
- 在中断处理完成后 (irq_exit ()) 判断是否需要触发软中断处理
- 软中断处理时间超过限制时,决定是否将剩余工作交给 ksoftirqd 线程
- run_ksoftirqd主要用于:
- 处理那些不能在硬中断上下文完成的耗时操作 (如网络数据包处理)
- 当软中断处理时间超过 2 毫秒时,作为软中断的 "后备处理机制"
- 在多核系统中,负责本 CPU 上所有软中断的顺序A执行
创建后,判断是否有软中断
网络子系统
Linux内核通过 subsys_initcall 初始化各个子系统。网络子系统通过 net_dev_init 函数。
net_dev_init:
- 为每个CPU申请一个
struct softnet_data,其中 poll_list 用于等待 驱动程序 将其 poll 函数注册进来。 - 调用 open_softirq 为每一个软中断注册 一个处理函数。
- 如图:软中断 NET_TX_SOFTIRQ 处理函数是 net_tx_action;NET_RX_SOFTIRQ 处理函数是 net_rx_action。
- 注册关系 保存在 softirq_vec 变量。ksoftirqd 线程 收到软中断就通过,改变量执行对应的回调函数。
协议栈注册
Linux 内核,fs_initcall 和 subsys_initcall 类似。
fs_initcall:
- 调用 inet_init,把 udp_rcv,tcp_v4_rcv,ip_rcv函数注册到对应的数据结构 inet_protos 和 ptype_base
- 通过 inet_init 初始化
- 调用 inet_add_protocol 将TCP、UDP 处理函数注册到
inet_protos数组。其中 struct udp_protocol 的 handler是 udp_rcv,struct tcp_protocol 的 handler是tcp_v4_rcv - 调用 dev_add_pack 将 struct packet_type ip_packet_type (type协议,func:ip_rcv) 注册到
ptype_base哈希表
- 调用 inet_add_protocol 将TCP、UDP 处理函数注册到
网卡驱动初始化
驱动程序通过 module_init 向内核注册一个 初始化函数。当驱动加载,内核会调用该函数。
例如 igb 网卡驱动:
// drivers/net/ethernet/intel/igb/igb_main.c
static struct pci_driver igb_driver = {
.name = igb_driver_name,
.id_table = igb_pci_tbl,
.probe = igb_probe,
.remove = igb_remove,
...
};
static int __init igb_init_module(void)
{
...
ret = pci_register_driver(&igb_driver);
return ret;
}
- 网卡设备被识别,内核会调用其驱动的probe方法
- 驱动的probe方法执行的目的就是让设备处于ready状态
// drivers/net/ethernet/intel/igb/igb_main.c
static const struct net_device_ops igb_netdev_ops = {
.ndo_open = igb_open,
.ndo_stop = igb_close,
.ndo_start_xmit = igb_xmit_frame,
.ndo_get_stats64 = igb_get_stats64,
.ndo_set_rx_mode = igb_set_rx_mode,
.ndo_set_mac_address = igb_set_mac,
.ndo_change_mtu = igb_change_mtu,
.ndo_do_ioctl = igb_ioctl,
...
};
// drivers/net/ethernet/intel/igb/igb_main.c
static int igb_alloc_q_vector(struct igb_adapter *adapter,
int v_count, int v_idx,
int txr_count, int txr_idx,
int rxr_count, int rxr_idx)
{
...
netif_napi_add(adapter->netdev, &q_vector->napi,
igb_poll, 64);
...
}
-
- 驱动实现 ethtool 所需要的接口,函数地址的注册
-
- 注册 igb_netdev_ops 使用 struct net_device_ops igb_netdev_ops, 包含 igb_open
-
- igb_probe初始化,调用到了igb_alloc_q_vector。它注册了一个NAPI机制必需的poll函数,对于igb网卡驱动来说,这个函数就是igb_poll
启动网卡
igb_open:
- 分配tx,rx传输描述符数组 igb_setup_all_tx_resources igb_setup_all_rx_resources。
- igb_setup_all_tx_resources:分配了RingBuffer,并建立了内存和RX队列的映射关系。
- 申请 igb_rx_buffer 数组内存
- 申请 e1000_adv_rx_desc DMA数组内存
- 初始化队列成员
- igb_setup_all_tx_resources:分配了RingBuffer,并建立了内存和RX队列的映射关系。
- 注册中断处理函数 igb_request_irq
- 调用 igb_request_msix
- 多队列网卡,每个队列注册中断,硬中断处理函数 igb_msix_ring。(在msix方式下,每个RX队列有独立的MSI-X中断,从网卡硬件中断层面让收到的包被不同的CPU处理。通过 irqbalance 设置)
- 调用 igb_request_msix
- 启动 NAPI napi_enable
RingBuffer:两个环形队列数组
- igb_rx_buffer数组: 内核使用,vzalloc申请
- e1000_adv_rx_desc数组:网卡硬件使用,dma_alloc_coherent分配
硬中断处理
网卡接收队列,使用 RingBuffer 存储数据帧
RingBuffer满了,新的数据包丢弃。ifconfig查看网卡,其中有overruns,表示因RingBuffer满而丢弃的包;ethtool命令加大环形队列长度
igb_msix_ring 硬中断处理函数:
- 通过 igb_write_itr 记录硬件中断频率
- 调用
__napi_schedule-->____napi_schedule- 调用 list_add_tail 修改CPU变量
struct softnet_data的 poll_list,将驱动的 napi_struct 的 poll_list 加入。 - 调用
__raise_softirq_irqoff触发 软中断NET_RX_SOFIRQ(对一个变量进行一次或运算)
- 调用 list_add_tail 修改CPU变量
softnet_data的 poll_list 是一个
双向列表,其中的设备都有输入帧待处理
总结:硬中断,记录一个寄存器,修改CPU的 poll_list,发出一个软中断。
ksoftirqd 内核线程 处理 软中断
ksoftirqd_should_run,读取 中断信号
// kernel/softirq.c
static int ksoftirqd_should_run(unsigned int cpu)
{
return local_softirq_pending();
}
// include/linux/irq_cpustat.h
#define local_softirq_pending() \
__IRQ_STAT(smp_processor_id(), __softirq_pending)
run_ksoftirqd:
- __do_softirq:根据当前CPU软中断类型,调用其注册的action方法
// kernel/softirq.c
static void run_ksoftirqd(unsigned int cpu)
{
local_irq_disable();
if (local_softirq_pending()) {
__do_softirq();
...
}
local_irq_enable();
}
__do_softirq
// kernel/softirq.c
asmlinkage void __do_softirq(void)
{
...
do {
if (pending & 1) {
unsigned int vec_nr = h - softirq_vec;
int prev_count = preempt_count();
...
trace_softirq_entry(vec_nr);
h->action(h);
trace_softirq_exit(vec_nr);
...
}
...
}
h++;
pending >>= 1;
} while (pending);
...
}
硬中断中的设置软中断标记,和 ksoftirqd 中的判断是否有软中断到达,都是基于
smp_processor_id()的。这意味着只要硬中断在哪个CPU上被响应,那么软中断也是在这个CPU上处理的。
net_rx_action:
- 通过 local_irq_disable 关闭所有硬中断(poll_list不会重复添加)
- 函数 开头 time_limit budget 控制 net_rx_action 主动退出(保证网络包的接收不霸占CPU)
- budget 可以 内核参数调整
- 核心逻辑:获取当前CPU变量softnet_data,对其中poll_list进行遍历,执行对应的poll函数。
// net/core/dev.c
static void net_rx_action(struct softirq_action *h)
{
struct softnet_data *sd = &__get_cpu_var(softnet_data);
unsigned long time_limit = jiffies + 2;
int budget = netdev_budget;
void *have;
local_irq_disable();
while (!list_empty(&sd->poll_list)) {
...
n = list_first_entry(&sd->poll_list, struct napi_struct, poll_list);
...
work = 0;
if (test_bit(NAPI_STATE_SCHED, &n->state)) {
work = n->poll(n, weight);
trace_napi_poll(n);
}
WARN_ON_ONCE(work > weight);
budget -= work;
...
}
...
}
例如:igb驱动里的igb_poll:
- 核心函数 igb_clean_rx_irq
- igb_fetch_rx_buffer 和 igb_is_non_eop:把数据帧 从 RingBuffer 取下来。(skb从RingBuffer取下后,通过 igb_alloc_rx_buffers 申请新的skb重新挂上)
- 一个数据帧可能占多个RingBuffer,所以在一个循环里取出整个,直到帧尾部。
- 对 skb 进行校验,设置 timestamp、VLAN id、protocol等字段。
- napi_gro_receive
- 网口 GRO 特性,能把相关的小包合并成一个打包,减少传输给网络栈的包数。
- 调用了 napi_skb_finish --- netif_receive_skb(数据包被送到协议栈)
// drivers/net/ethernet/intel/igb/igb_main.c
static int igb_poll(struct napi_struct *napi, int budget)
{
...
if (q_vector->tx.ring)
clean_complete = igb_clean_tx_irq(q_vector);
if (q_vector->rx.ring)
clean_complete &= igb_clean_rx_irq(q_vector, budget);
...
}
// drivers/net/ethernet/intel/igb/igb_main.c
static bool igb_clean_rx_irq(struct igb_q_vector *q_vector, const int budget)
{
...
do {
...
/* retrieve a buffer from the ring */
skb = igb_fetch_rx_buffer(rx_ring, rx_desc, skb);
/* exit if we failed to retrieve a buffer */
if (!skb)
break;
cleaned_count++;
/* fetch next buffer in frame if non-eop */
if (igb_is_non_eop(rx_ring, rx_desc))
continue;
/* verify the packet layout is correct */
if (igb_cleanup_headers(rx_ring, rx_desc, skb)) {
skb = NULL;
continue;
}
...
/* populate checksum, timestamp, VLAN, and protocol */
igb_process_skb_fields(rx_ring, rx_desc, skb);
napi_gro_receive(&q_vector->napi, skb);
...
} while (likely(total_packets < budget));
...
}
网络协议栈处理
netif_receive_skb 根据包协议进行处理。
- netif_receive_skb
__netif_receive_skb__netif_receive_skb_core:- tcpdump 通过虚拟协议实现,通过将抓包函数以协议的形式挂载到
ptype_all上。设备层遍历所有 “协议”。tcpdump 执行 packet_create,其中调用 register_prot_hook 把相关 “协议” 挂载 ptype_all。 - 取出 protocol,取出协议信息,然后 deliver_skb 注册到 对应的 回调函数列表,ptype_base 哈希表(ip_rcv)。
deliver_skb里调用pt_prev->func就调用到协议层注册的处理函数。IP进入ip_rcv,ARP进入arp_rcv。
- tcpdump 通过虚拟协议实现,通过将抓包函数以协议的形式挂载到
// net/core/dev.c
int netif_receive_skb(struct sk_buff *skb)
{
// RPS处理逻辑,先忽略
...
return __netif_receive_skb(skb);
}
static int __netif_receive_skb(struct sk_buff *skb)
{
int ret;
if (sk_memalloc_socks() && skb_pfmemalloc(skb)) {
unsigned long pflags = current->flags;
current->flags |= PF_MEMALLOC;
ret = __netif_receive_skb_core(skb, true);
tsk_restore_flags(current, pflags, PF_MEMALLOC);
} else
ret = __netif_receive_skb_core(skb, false);
return ret;
}
static int __netif_receive_skb_core(struct sk_buff *skb, bool pfmemalloc)
{
...
// pcap逻辑,这里会将数据送入抓包点 tcpdump就是从这个入口获取包的
list_for_each_entry_rcu(ptype, &ptype_all, list) {
if (!ptype->dev || ptype->dev == skb->dev) {
if (pt_prev)
ret = deliver_skb(skb, pt_prev, orig_dev);
pt_prev = ptype;
}
}
...
list_for_each_entry_rcu(ptype,
&ptype_base[ntohs(type) & PTYPE_HASH_MASK], list) {
if (ptype->type == type &&
(ptype->dev == null_or_dev || ptype->dev == skb->dev ||
ptype->dev == orig_dev)) {
if (pt_prev)
ret = deliver_skb(skb, pt_prev, orig_dev);
pt_prev = ptype;
}
}
...
}
// net/core/dev.c
static inline int deliver_skb(struct sk_buff *skb,
struct packet_type *pt_prev,
struct net_device *orig_dev)
{
...
return pt_prev->func(skb, skb->dev, pt_prev, orig_dev);
}
IP 处理
// net/ipv4/ip_input.c
int ip_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt, struct net_device *orig_dev)
{
...
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, skb, dev, NULL,
ip_rcv_finish);
...
}
NF_HOOK 钩子函数。就是 iptables netfilter 过滤。当执行完注册的钩子后就会执行到最后一个参数 ip_rcv_finish。
NF_HOOK 有很多相关的 filter 过滤点
- ip_rcv_finish
- ip_route_input_noref
- ip_route_input_mc:dst.input =
ip_local_deliver
- ip_route_input_mc:dst.input =
- return dst_input(skb)
- skb_dst(skb)->input 的 input就是 路由子系统 赋值的
ip_local_deliver- ip_local_deliver:再经过一个 NF_HOOK,调用 ip_local_deliver_finish
- 根据协议选择分发,将skb包进一步派发到更上层协议。
- ip_local_deliver:再经过一个 NF_HOOK,调用 ip_local_deliver_finish
- skb_dst(skb)->input 的 input就是 路由子系统 赋值的
- ip_route_input_noref
// net/ipv4/ip_input.c
static int ip_rcv_finish(struct sk_buff *skb)
{
...
if (!skb_dst(skb)) {
int err = ip_route_input_noref(skb, iph->daddr, iph->saddr,
iph->tos, skb->dev);
...
}
...
return dst_input(skb);
...
}
// net/ipv4/route.c
static int ip_route_input_mc(struct sk_buff *skb, __be32 daddr, __be32 saddr,
u8 tos, struct net_device *dev, int our)
{
...
if (our) {
rth->dst.input= ip_local_deliver;
rth->rt_flags |= RTCF_LOCAL;
}
...
}
// include/net/dst.h
static inline int dst_input(struct sk_buff *skb)
{
return skb_dst(skb)->input(skb);
}
// net/ipv4/ip_input.c
int ip_local_deliver(struct sk_buff *skb)
{
if (ip_is_fragment(ip_hdr(skb))) {
if (ip_defrag(skb, IP_DEFRAG_LOCAL_DELIVER))
return 0;
}
return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, skb, skb->dev, NULL,
ip_local_deliver_finish);
}
static int ip_local_deliver_finish(struct sk_buff *skb)
{
...
{
int protocol = ip_hdr(skb)->protocol;
const struct net_protocol *ipprot;
...
ipprot = rcu_dereference(inet_protos[protocol]);
if (ipprot != NULL) {
...
ret = ipprot->handler(skb);
...
}
...
}
...
}
前文:inet_protos 保存了 tcp_v4_rcv 和 udp_rcv 函数地址。
收包小结
首先在开始收包之前,Linux要做许多的准备工作:
- 创建ksoftirqd线程,为它设置好它自己的线程函数,后面由它来处理软中断
- 协议栈注册,Linux要实现需要协议,比如ARP、ICMP、IP、UDP和TCP,每一个协议都会将自己的处理函数注册一下,方便包来了迅速找到对应的处理函数
- 网卡驱动初始化,每个驱动都有一个初始化函数,内核会让驱动也初始化一下。在这个初始化过程中,把自己的DMA准备好,把NAPI的poll函数地址告诉内核
- 启动网卡,分配RX、TX队列,注册中断对应的处理函数
以上是内核准备收包之前的重要工作,当上面这些都准备好之后,就可以打开硬中断,等待数据包的到来了
当数据到来以后,第一个迎接它的是网卡:
- 网卡将数据帧DMA到内存的RingBuffer中,然后向CPU发起中断通知
- CPU响应中断请求,调用网卡启动时注册的中断处理函数
- 中断处理函数几乎没干什么,只发起了软中断请求
- 内核线程ksoftirqd发现有软中断请求到来,先关闭硬中断
- ksoftirqd线程开始调用驱动的poll函数收包
- poll函数将收到的包送到协议栈注册的ip_rcv函数中
- 如果是UDP包,ip_rcv函数将包送到udp_rcv函数中(对于TCP包是送到tcp_rcv_v4)
章节总结 ⭐
1)RingBuffer到底是什么,RingBuffer为什么会丢包?
RingBuffer这个数据结构包括igb_rx_buffer环形队列数组、e1000_adv_rx_desc环形队列数组及众多的skb,如下图所示:
网卡在收到数据的时候以 DMA 的方式将包写到 RingBuffer 中。软中断收包的时候来这里把 skb 取走,并申请新的 skb 重新挂上去。
RingBuffer 中指针数组是预先分配好的,而 skb 虽然也会预先分配好,但是在后面收包过程中会不断动态地分配申请。
这个 RingBuffer 是有大小和长度限制的,长度可以通过 ethtool 工具查看
# ethtool -g eth0
Ring parameters for eth0:
Pre-set maximums:
RX: 4096
RX Mini: n/a
RX Jumbo: n/a
TX: 4096
Current hardware settings:
RX: 256
RX Mini: n/a
RX Jumbo: n/a
TX: 256
- Pre-set maximums指的是RingBuffer的最大值
- Current hardware settings指的是当前的设置
如果内核处理得不及时导致RingBuffer满了,那后面新来的数据包就会被丢弃,通过ethtool或ifconfig工具可以看查是否有RingBuffer溢出发生
# ethtool -S eth0
NIC statistics:
...
rx_fifo_errors: 0
tx_fifo_errors: 0
rx_fifo_errors如果不为0的话(ifconfig中体现为overruns指标增长),就表示有包因为RingBuffer装不下而被丢弃了
通过ethtool修改RingBuffer大小:
# ethtool -G eth0 rx 4096 tx 4096
这样可以解决偶发的瞬时的丢包,但有个副作用,就是排队的包过多会增加处理网络包的延时
2)网络相关的硬中断、软中断都是什么?
在网卡将数据放到RingBuffer中后,接着就发起硬中断,通知CPU进行处理。不过硬中断的上下文里做的工作很少,将传过来的poll_list添加到了Per-CPU变量softnet_data的poll_list里(softnet_data的poll_list是一个双向列表,其中的设备都带有输入帧等着被处理),接着触发软中断NET_RX_SOFTIRQ
在软中断中对softnet_data的设备列表poll_list进行遍历,执行完卡驱动提供的poll来收取网络包。处理完后会送到协议栈的ip_rcv、udp_rcv、tcp_rcv_v4等函数中
3)Linux里的ksoftirqd内核线程是干什么的?
ksoftirqd线程数 == CPU核数
内核线程ksoftirqd包含了所有的软中断处理函数,也包括这里提到的NET_RX_SOFTIRQ。在__do_softirq中根据软中断的类型,执行不同的处理函数。对于软中断NET_RX_SOFTIRQ来说是net_rx_action函数
软中断是在ksoftirqd内核线程中执行的。软中断的信息可以从/proc/softirqs读取:这里显示了每一个CPU上执行的各种类型的软中断的次数
# cat /proc/softirqs
CPU0 CPU1
HI: 3 2
TIMER: 48664 907787
NET_TX: 10 10
NET_RX: 2557 3419
BLOCK: 19090 14694
IRQ_POLL: 0 0
TASKLET: 81 12
SCHED: 87740 882170
HRTIMER: 0 0
RCU: 128543 129736
4)为什么网卡开启多队列能提升网络性能?
通过ethtool可以看到当前网卡的多队列情况
# ethtool -l eth0
Channel parameters for eth0:
Pre-set maximums:
RX: 0
TX: 0
Other: 1
Combined: 63
Current hardware settings:
RX: 0
TX: 0
Other: 1
Combined: 8
- 当前网卡支持的最大队列数是63
- 当前开启的队列数是8
通过sysfs伪文件系统也可以看到真正生效的队列数
# ls /sys/class/net/eth0/queues/
rx-0 rx-1 rx-2 rx-3 rx-4 rx-5 rx-6 rx-7
tx-0 tx-1 tx-2 tx-3 tx-4 tx-5 tx-6 tx-7
如果想加大队列数,使用ethtool工具:
# ethtool -L eth0 combined 32
通过/proc/interrupts可以看到该队列对应的硬件中断号
- 网卡输入队列eth0-Tx-Rx-0的中断号是52
- eth0-Tx-Rx-1的中断号是53
- 总共开启了8个接收队列
通过该中断号对应的smp_affinity可以查看到亲和的CPU核是哪一个
# cat /proc/irq/53/smp_affinity
8
亲和性是通过二进制中的比特位来标记的。例如8是二进制的1000,第4位为1,代表的就是第4个CPU核心——CPU3
从以上内容可知,每个队列都会有独立的、不同的中断号。所以不同的队列在将数据收到自己的RingBuffer后,可以分别向不同的CPU发起硬中断通知。而在硬中断的处理中,调用__raise_softirq_irqoff发起软中断的时候,是基于当前CPU核心smp_processor_id的(__raise_softirq_irqoff => or_softirq_pending => local_softirq_pending)
// include/linux/irq_cpustat.h
#define local_softirq_pending() \
__IRQ_STAT(smp_processor_id(), __softirq_pending)
这意味着哪个核响应的硬中断,那么该硬中断发起的软中断任务就必然由这个核来处理
所以在工作实践中,如果网络包的接收频率高而导致个别核si偏高,那么通过加大网卡队列数,并设置每个队列中断号上的smp_affinity,将各个队列的硬中断打散到不同的CPU上就行了。这样硬中断后面的软中断CPU开销也将由多个核来分担
5)tcpdump
工作在设备层,通过虚拟协议实现。通过packet_create把抓包函数以协议的方式挂载在ptype_all。
收到,驱动中 实现的 igb_poll 调用 __netif_receive_skb_core,这个函数会在包送到协议栈函数(ip_rcv,arp_rcv等)之前,将包先送到ptype_all抓包点。
6)iptable/netfiliter 在哪一层?
netfiliter主要在IP、ARP层实现,可以通过NF_HOOK函数了解netfiliter实现。
7)tcpdump能抓到 被 iptable 封禁的包吗?⭐
能抓到 收到的包。
发包相反,netfiliter在协议层被过滤,tcpdump抓不到 被 iptable 封装的发出包
8)网络接收过程中的CPU开销如何查看?
在网络的接收过程中,主要工作集中在硬中断和软中断上,二者的消耗都可以通过top命令来查看:
# top
top - 08:10:30 up 1:04, 1 user, load average: 0.07, 0.11, 0.09
Tasks: 186 total, 2 running, 184 sleeping, 0 stopped, 0 zombie
%Cpu(s): 1.4 us, 2.4 sy, 0.0 ni, 96.0 id, 0.0 wa, 0.0 hi, 0.2 si, 0.0 st
MiB Mem : 7933.7 total, 6385.5 free, 576.6 used, 971.6 buff/cache
MiB Swap: 2680.0 total, 2680.0 free, 0.0 used. 7105.4 avail Mem
- hi是CPU处理硬中断的开销
- si是处理软中断的开销
- 都是以百分比的形式来展示的
9)dpdk
旁路技术
直接从网卡接收数据,省去繁琐的内核协议栈处理、内核态到用户态内存拷贝开销、唤醒用户进程开销...