Network Driver
本文试图从软件的角度解释一个网络请求的今生前世,我们这里选取 RTL8139 作为我们了解网络驱动的硬件设备。
设备数据结构定义:net_device
Linux
内核中设备的抽象对应的结构体是 struct net_device
struct net_device { char name[IFNAMSIZ]; /* * I/O 相关 */ unsigned long mem_end; unsigned long mem_start; unsigned long base_addr; int irq; // 设备 Index int ifindex; // stats 数据 struct net_device_stats stats; // 接受队列 struct netdev_rx_queue *_rx; unsigned int num_rx_queues; unsigned int real_num_rx_queues; // 发送队列 struct netdev_queue *_tx; unsigned int num_tx_queues; unsigned int real_num_tx_queues;};
对于现在硬件设备都会基于 mmio
模式进行工作,因此我们读取数据也是直接和读取内存类似的方式进行读取的。
网卡数据的读取
外部数据的读取会涉及到中断体系,Linux
将 中断
分为了两个部分: top half
与 bottom half
。top half
是在呼叫 request_irq()
时所指定的 interrupt handler
函数,bottom half
则是真正负责响应中断的 task
。
硬中断部分
对于数据初次抵达硬件,会触发硬件中断,如下图所示(网卡类似)。
因此从中断的入口观察是最好的,下面的代码逻辑从 linux-1.x
版本中截取,比最近的要简单一些,容易理解,另外就是新版的已经改为 NAPI
模式,下文提到再说。
static void rtl8139_interrupt(int irq, void *dev_instance, struct pt_regs *regs) { struct net_device *dev = (struct net_device *) dev_instance; struct rtl8139_private *tp = dev->priv; void *ioaddr = tp->mmio_addr; unsigned short isr = readw(ioaddr + ISR); // 处理接受信号,上面略过了发送部分 if (isr & RxOK) { LOG_MSG("receive interrupt received\n"); while ((readb(ioaddr + CR) & RxBufEmpty) == 0) { unsigned int rx_status; unsigned short rx_size; unsigned short pkt_size; struct sk_buff *skb; // 处理环形队列 if (tp->cur_rx > RX_BUF_LEN) tp->cur_rx = tp->cur_rx % RX_BUF_LEN; rx_status = *(unsigned int *) (tp->rx_ring + tp->cur_rx); rx_size = rx_status >> 16; pkt_size = rx_size - 4; /* 创建内核态的 skb 储存接受到的数据 */ skb = dev_alloc_skb(pkt_size + 2); if (skb) { skb->dev = dev; skb_reserve(skb, 2); /* 16 byte align the IP fields */ // 拷贝数据并且检查 checksum eth_copy_and_sum( skb, tp->rx_ring + tp->cur_rx + 4, pkt_size, 0); skb_put(skb, pkt_size); skb->protocol = eth_type_trans(skb, dev); netif_rx(skb); dev->last_rx = jiffies; tp->stats.rx_bytes += pkt_size; tp->stats.rx_packets++; } else { LOG_MSG("Memory squeeze, dropping packet.\n"); tp->stats.rx_dropped++; } /* 移动读取指针 * / tp->cur_rx = (tp->cur_rx + rx_size + 4 + 3) & ~3; } } return;}
中断模式下,我们收到一个数据包是非常的好理解的,将数据从网卡中 COPY
到我们的内核中即可,netif_rx
就是我们从 Hardware
向 Kernel
转换的核心点。
int netif_rx(struct sk_buff *skb) { int this_cpu = smp_processor_id(); struct softnet_data *queue; unsigned long flags; // 我们为每个 Core 分配了一个 待处理的数据队列,这里的处理如注释所言, // 代码可能被重排,这里只用当前的 CPU 保证了处理路径的最短化 queue = &softnet_data[this_cpu]; //没有超过backlog的限制就置于队列 if (queue->input_pkt_queue.qlen <= netdev_max_backlog) { if (queue->input_pkt_queue.qlen) { if (queue->throttle) // 不然就丢弃 -> 因为还没响应,等于丢包 goto drop;enqueue: dev_hold(skb->dev); __skb_queue_tail(&queue->input_pkt_queue, skb); return softnet_data[this_cpu].cng_level; } goto enqueue; }drop: netdev_rx_stat[this_cpu].dropped++; kfree_skb(skb); return NET_RX_DROP;}
硬中断部分到此结束,我们将数据压入内核的虚拟接受列队中结束。
[选读] 硬中断成本
硬中断
是一个有硬件主导,软件配合的出来体系,但是这样的模式不是没有成本的,打断正在运行的软件进程,我们不得不涉及到进程的切换,还有中断标记的处理等工作。It’s not free. ,有兴趣的可以参考 Profiling I/O Interrupts in Modern Architectures,
截止至今,我们知道了对于从网卡来的数据,我们至少会涉及到 ➊ Hard Interrupt
导致的 进程切换
,并且 ➋ 涉及到一次数据从网卡的 Buffer
向内核拷贝的过程。
软中断部分
软中断的逻辑在 net_rx_action
中
static void net_rx_action(struct softirq_action *h) { int this_cpu = smp_processor_id(); struct softnet_data *queue = &softnet_data[this_cpu]; int bugdet = netdev_max_backlog; for (;;) { struct sk_buff *skb; struct net_device *rx_dev; // 将数据弹出 skb = __skb_dequeue(&queue->input_pkt_queue); // 没数据直接结束 if (skb == NULL) break; skb->h.raw = skb->nh.raw = skb->data; { struct packet_type *ptype, *pt_prev; unsigned short type = skb->protocol; pt_prev = NULL; // 根据 package 类型找到对应的处理函数进行处理 for (ptype = ptype_all; ptype; ptype = ptype->next) { if (!ptype->dev || ptype->dev == skb->dev) { if (pt_prev) { if (!pt_prev->data) { deliver_to_old_ones(pt_prev, skb, 0); } else { atomic_inc(&skb->users); // 调用预置的回调函数 pt_prev->func(skb,skb->dev,pt_prev); } } pt_prev = ptype; } } } return; }}
回调的函数签名如下:
int (*func) (struct sk_buff *, struct net_device *, struct packet_type *);
很标准的指针函数。不过也很讨厌看这部分的代码,如果不是 Runtime
阶段很难找到这个定义的函数是什么。这里实际上在接收到 TCP/IP
协议包的时候,回调的是 int ip_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt)
函数。在看 IP
处理的之前,我们先看看这个 packet_type
的相关函数。
[选读] Packet Type 钩子
packet_type
的定义很简单
struct packet_type { unsigned short type; /* 协议类型 */ struct net_device *dev; /* 不设置就是通配符 */ int (*func)(struct sk_buff *, struct net_device *, struct packet_type *); /* 回调函数 */ void *data; /* 私有数据 */ struct packet_type *next;};
在 dev.c
的源码中,我们可以动态的向 ptype_all
注册我们需要的 ptype
void dev_add_pack(struct packet_type *pt) { if (pt->type == htons(ETH_P_ALL)) { netdev_nit++; pt->next = ptype_all; ptype_all = pt; } else { hash = ntohs(pt->type) & 15; pt->next = ptype_base[hash]; ptype_base[hash] = pt; }}
因此我们如果需要处理比如 arp
的请求,可以单独增加一个,所以在 arp.c
初始化的时候,就可以出发。
static struct packet_type arp_packet_type = { type: __constant_htons(ETH_P_ARP), func: arp_rcv, data: (void *) 1,};void __init arp_init(void) { dev_add_pack(&arp_packet_type);}
TCPDUMP
tcpdump
二层抓包,用的是 libpcap
库,它的基本原理是
先创建socket,内核dev_add_packet()挂上自己的钩子函数
然后在钩子函数中,把skb放到自己的接收队列中,
接着系统调用recv取出skb来,把数据包skb->data拷贝到用户空间
最后关闭socket,内核dev_remove_packet()删除自己的钩子函数
因此代码如下表述
static int packet_create(struct socket *sock, int protocol) { // 略部分代码 if (protocol) { sk->protinfo.af_packet->prot_hook.type = protocol; dev_add_pack(&sk->protinfo.af_packet->prot_hook); /* 增加 Hook 在此协议上 */ sock_hold(sk); sk->protinfo.af_packet->running = 1; } return (0);}
直到此处,我们走到这里算是和硬件越来越远了。再下来的历程,我们要去面对的是更加高纬度的 Socket
抽象。
[选读] 软断成本
对于常见的硬件中断来说,比如键入了一个字母,过程简单,很快可以处理完成,对于网络IO就复杂的多,涉及到从驱动拷贝数据,然后到虚拟的网络协议栈,再到Socket的Buffer中,因此才才产生的了软中断的,将中断一分为二,软中断优先级较低但是因为软件中断复杂,成本上是要更多的。
软中断依然需要 ➊ 进程上下文的切换(这里切换到 内核线程 ksoftirqd
),这里只不过相对于硬中断是操作系统自己进行调度的 ➋ 就是和系统开销调用一下,因为对于正常运行的用户态进程,我们需要把当前的上下文保存。
内核线程 ksoftirqd
也会充分利用多核的能力,对于每一个 core
都会启动一个 ksoftirqd
,暂且可以把它当做一个普通的进程看待。
NAPI
单纯的看,上面的开销,我们一眼就可以发现硬中断部分有一个非常不合理的情况,考虑如果出现以下情况
网卡每隔 10 ms 获得一个数据包,我们硬中断处理需要 9ms
那会出现一个很神奇的现象就是我们虽然每次都要将中断结束,但是每一次刚刚结束又要唤醒,结果我们会浪费大量的时间在处理中断请求上。针对这一的情况,Linux 2.6
之后提供了一个全新的API来处理这种情况(实际上对于大部分的数据,网络请求从不间断)。
既然每次进中断都很浪费,那我们就不要每一次都进中断了,NAPI
的解决之道就是,当我们接收到请求之后,我们将从此网卡读取作为一个 Task
注册到 napi_scheluder
中,然后屏蔽中断,内核定时的去 poll
数据即可。主要就是减少了硬中断切换的成本。
NAPI 定义
struct napi_struct { // poll 列表 struct list_head poll_list; unsigned long state; int weight; // 获得数据的 poll 函数 int (*poll)(struct napi_struct *, int);};
核心的逻辑都是在 poll
的回调函数中。
NAPI 之下的硬中断处理
static irqreturn_t rtl8139_interrupt(int irq, void *dev_instance) { struct net_device *dev = (struct net_device *) dev_instance; struct rtl8139_private *tp = netdev_priv(dev); /* Receive packets are processed by poll routine. If not running start it now. 如注释所言,现在的处理都早 POLL 的子函数中,我们在这里只是向 __napi_schedule 注册我们的 poll 函数 */ if (status & RxAckBits) { if (napi_schedule_prep(&tp->napi)) { RTL_W16_F(IntrMask, rtl8139_norx_intr_mask); __napi_schedule(&tp->napi); } } netdev_dbg(dev, "exiting interrupt, intr_status=%#4.4x\n", RTL_R16(IntrStatus)); return IRQ_RETVAL(handled);}
对于 poll
函数的实现这里就不做展开了,和传统的体系一样,直接将数据拷贝到内核态没有区别。
软中断唤醒
因此到此版本,网络请求的软中断环境代码也相对应的有所改变
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; while (!list_empty(&sd->poll_list)) { struct napi_struct *n; int work, weight; // 获得可运行的 poll 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); // 执行 poll 操作 } }
只将核心的逻辑展示出来,比如代码中 budget
防止饥饿的操作都留于读者自行理解了, NAPI
解决了我们依靠硬中断导致的大量的进程切换的问题,加上现在的操作系统都是基于 DMA
,因此整个过程中的成本就只有 ➊ 软中断导致的线程切换 ➋ 数据的拷贝
关注我们,注入技术原力
原力注入
微信号 : Force_Injection
热点文章
Linux 网络大全(一)—— 基础知识
DevOps 平台就需要一个专业的门户提升逼格!
Kubernetes 网络插件工作原理