网络包的接收

159 阅读25分钟

收比发要复杂点,主要包含2方面

  • APP调用recvfrom系统调用,然后阻塞在内核,等待包的到来
  • 包到达网卡后,触发硬中断,中断例程又把执行流转到软中断,然后执行流收网卡数据,并根据路由信息确定本地包,最后把包放入队列中,唤醒阻塞的进程。

3、网络收包总览

在TCP/IP网络分层模型里,整个协议栈被分成了:物理层、链路层、网络层,传输层和应用层。

物理层对应的是网卡和网线,应用层对应的是常见的Nginx,FTP等各种应用。Linux实现的是链路层、网络层和传输层这3层。

在Linux内核实现中,链路层协议靠网卡驱动来实现,内核协议栈来实现网络层和传输层。内核对更上层的应用层提供socket接口来供用户进程访问。

用Linux的视角来看到的TCP/IP网络分层模型应是下面这样:

在Linux的源码中,网络设备驱动对应的逻辑位于driver/net/ethernet。

其中:

  • 1)intel系列网卡的驱动在driver/net/ethernet/intel目录下;
  • 2)协议栈模块代码位于kernel和net目录。

内核和网络设备驱动通过中断的方式来处理。

当设备上有数据到达时:会给CPU的相关引脚上触发1个电压变化,以通知CPU来处理数据。

对于网络模块:由于处理过程较复杂和耗时,若在中断函数中完成所有的处理,将会导致中断处理函数(优先级过高)将过度占据CPU,将导致CPU无法响应其它设备,如鼠标和键盘的消息。

因此Linux中断处理函数分上下半部。上半部只进行最简单的工作,快速处理然后释放CPU,接着CPU就允许其它中断进来。将绝大部分的工作都放到下半部中,可慢慢从容处理。Linux 2.4以后的内核版本采用的下半部实现方式是软中断,由ksoftirqd内核线程全权处理。不同于硬中断通过给CPU物理引脚施加电压变化,软中断通过给内存中的1个变量的2进制值以通知软中断处理程序。

Linux内核网络收包总览:

如上图:当网卡上收到数据后,Linux中第1个工作的模块是网络驱动。网络驱动会以DMA的方式把网卡上收到的帧写到内存里。再向CPU发起1个中断,通知CPU有数据到达。第2,当CPU收到中断请求后,调用网络驱动注册的中断处理函数。网卡的中断处理函数并不做过多工作,发出软中断请求,然后尽快释放CPU。ksoftirqd检测到有软中断请求到达,调用poll开始轮询收包,收到后交由各级协议栈处理。UDP包会被放到用户socket的收队列中。

从上面这张图中已从整体上把握到了OS对包的处理过程。但要想了解更多网络模块工作的细节,还得往下看。

4、网络数据到来前OS的准备

Linux驱动、内核协议栈等模块在具备收网卡包前,要做很多的准备工作才行。

如:要提前创建好ksoftirqd内核线程,要注册好各协议对应的处理函数,网络设备子系统要提前初始化好,网卡要启动好。只有这些都Ready后,才能真正开始收包。

现在来看看这些准备工作都是怎么做的。

4.1创建ksoftirqd内核线程

Linux的软中断在专门的内核线程ksoftirqd中进行,因此很有必要看下这些进程怎么初始化,这样才能在后面更准确地了解收包过程。该进程数量不是1个,而是N个,N等于机器的核数。

系统初始化时在kernel/smpboot.c中调用了smpboot_register_percpu_thread, 该函数进1步会执行到spawn_ksoftirqd(位于kernel/softirq.c)来创建出softirqd进程。

创建ksoftirqd内核线程:

相关代码如下:

//file: kernel/softirq.c

static struct smp_hotplug_thread softirq_threads = {

.store = &ksoftirqd,

.thread_should_run = ksoftirqd_should_run,

.thread_fn = run_ksoftirqd,

.thread_comm = "ksoftirqd/%u",};

static __init int spawn_ksoftirqd(void){

register_cpu_notifier(&cpu_nfb);

BUG_ON(smpboot_register_percpu_thread(&softirq_threads));

return 0;

}

early_initcall(spawn_ksoftirqd);

当ksoftirqd被创建出来后,它就会进入自己的线程循环函数ksoftirqd_should_run和run_ksoftirqd了。不停地判断有没软中断需被处理。

注意,软中断不仅有网络软中断,还有其它类型:

enum{
    HI_SOFTIRQ=0,
    TIMER_SOFTIRQ,
    NET_TX_SOFTIRQ,
    NET_RX_SOFTIRQ,
    BLOCK_SOFTIRQ,
    BLOCK_IOPOLL_SOFTIRQ,
    TASKLET_SOFTIRQ,
    SCHED_SOFTIRQ,
    HRTIMER_SOFTIRQ,
    RCU_SOFTIRQ, 
};

4.2网络子系统初始化

网络子系统初始化:

linux内核通过调用subsys_initcall来初始化各子系统,在源码目录里可grep出许多对此函数的调用。

这里要说的是网络子系统的初始化,会执行到net_dev_init函数:

static int __init net_dev_init(void){
    ......
 
    for_each_possible_cpu(i) {
        struct softnet_data *sd = &per_cpu(softnet_data, i);
 
        memset(sd, 0, sizeof(*sd));
        skb_queue_head_init(&sd->input_pkt_queue);
        skb_queue_head_init(&sd->process_queue);
        sd->completion_queue = NULL;
        INIT_LIST_HEAD(&sd->poll_list);
        ......
    }
    ......
    open_softirq(NET_TX_SOFTIRQ, net_tx_action);
    open_softirq(NET_RX_SOFTIRQ, net_rx_action);
}
subsys_initcall(net_dev_init);

在此函数里,会为每个CPU都申请1个softnet_data数据结构,此数据结构里的poll_list是等待驱动程序将其poll函数注册进来,稍后网卡驱动初始化时可看到这1过程。

另外open_softirq注册了每种软中断都注册1个处理函数。NET_TX_SOFTIRQ的处理函数为net_tx_action,NET_RX_SOFTIRQ的为net_rx_action。继续跟踪open_softirq后发现此注册的方式是记录在softirq_vec变量里。后面ksoftirqd线程收到软中断时,也会使用此变量来找到每种软中断对应的处理函数。

void open_softirq(int nr, void (*action)(struct softirq_action *)){
    softirq_vec[nr].action = action;
}

4.3协议栈注册

OS内核实现了网络层的ip,也实现了传输层的tcp和udp。这些协议对应的实现函数分别是ip_rcv(),tcp_v4_rcv()和udp_rcv()。内核通过注册的方式来实现。

Linux内核中的fs_initcall和subsys_initcall类似,也是初始化模块的入口。fs_initcall调用inet_init后开始网络协议栈注册。通过inet_init,将这些函数注册到了inet_protos和ptype_base数据结构中了。

如下图:

相关代码如下:

//file: net/ipv4/af_inet.c

static struct packet_type ip_packet_type __read_mostly = {

.type = cpu_to_be16(ETH_P_IP),

.func = ip_rcv,};

static const struct net_protocol udp_protocol = {

.handler = udp_rcv,

.err_handler = udp_err,

.no_policy = 1,

.netns_ok = 1,};

static const struct net_protocol tcp_protocol = {

.early_demux = tcp_v4_early_demux,

.handler = tcp_v4_rcv,

.err_handler = tcp_v4_err,

.no_policy = 1,

.netns_ok = 1,

};

static int __init inet_init(void){

......

if (inet_add_protocol(&icmp_protocol, IPPROTO_ICMP) < 0)

pr_crit("%s: Cannot add ICMP protocol\n", __func__);

if (inet_add_protocol(&udp_protocol, IPPROTO_UDP) < 0)

pr_crit("%s: Cannot add UDP protocol\n", __func__);

if (inet_add_protocol(&tcp_protocol, IPPROTO_TCP) < 0)

pr_crit("%s: Cannot add TCP protocol\n", __func__);

......

dev_add_pack(&ip_packet_type);

}

上面的代码中可看到,udp_protocol结构体中的handler是udp_rcv,tcp_protocol结构体中的handler是tcp_v4_rcv,通过inet_add_protocol被初始化了进来。

int inet_add_protocol(const struct net_protocol *prot, unsigned char protocol){

if (!prot->netns_ok) {

pr_err("Protocol %u is not namespace aware, cannot register.\n",

protocol);

return -EINVAL;

}

return !cmpxchg((const struct net_protocol **)&inet_protos[protocol],

NULL, prot) ? 0 : -1;

}

inet_add_protocol函数将tcp和udp对应的处理函数都注册到了inet_protos数组中了。再看dev_add_pack(&ip_packet_type);这行,ip_packet_type结构体中的type是协议名,func是ip_rcv函数,在dev_add_pack中会被注册到ptype_base哈希表中。

//file: net/core/dev.c

void dev_add_pack(struct packet_type *pt){

struct list_head *head = ptype_head(pt);

......

}

static inline struct list_head *ptype_head(const struct packet_type *pt){

if (pt->type == htons(ETH_P_ALL))

return &ptype_all;

else

return &ptype_base[ntohs(pt->type) & PTYPE_HASH_MASK];

}

这里需记住inet_protos记录着udp,tcp的处理函数地址,ptype_base存储着ip_rcv()的处理地址。后面会看到软中断中会通过ptype_base找到ip_rcv函数地址,进而将ip包正确地送到ip_rcv()中执行。在ip_rcv中将会通过inet_protos找到tcp/udp的处理函数,再而把包转发给udp_rcv()/tcp_v4_rcv()。

扩展下,若看下ip_rcv和udp_rcv等函数的代码能看到很多协议的处理过程。

如:ip_rcv中会处理netfilter过滤,若有很多或很复杂的 netfilter规则,这些规则都在软中断的上下文中执行,会加大网络延迟。

再如:udp_rcv中会判断socket收队列是否满了。对应的相关内核参数是net.core.rmem_max和net.core.rmem_default。若有兴趣,建议大家好好读下inet_init此函数的代码。

4.4网卡驱动初始化

每个驱动程序(不仅是网卡驱动)会使用 module_init 向内核注册1个初始化函数,当驱动被加载时,内核会调用此函数。

如igb网卡驱动的代码位于drivers/net/ethernet/intel/igb/igb_main.c:

//file: 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;

}

驱动的pci_register_driver调用完成后,Linux内核就知道了该驱动的相关信息,如igb网卡驱动的igb_driver_name和igb_probe函数地址等。当网卡设备被识别以后,内核会调用其驱动的probe方法(igb_driver的probe方法是igb_probe)。驱动probe方法执行的目的就是让设备ready,对于igb网卡,其igb_probe位于drivers/net/ethernet/intel/igb/igb_main.c下。

主要执行的操作如下:

第5步中看到:网卡驱动实现了ethtool所需的接口,也在这里完成函数地址的注册。当 ethtool 发起1个系统调用后,内核会找到对应操作的回调函数。igb网卡实现函数都在drivers/net/ethernet/intel/igb/igb_ethtool.c下。

相信这次能彻底理解ethtool的工作原理了吧?此命令之所以能查看网卡收发包统计、能修改网卡自适应模式、调整RX 队列的数量和大小,是因ethtool命令最终调用到了网卡驱动的相应方法,而非ethtool本身有此超能力。

第6步:注册的igb_netdev_ops中包含的是igb_open等函数,该函数在网卡启动时被调用。

//file: 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,

......

第7步:在igb_probe初始化过程中,还调用到了igb_alloc_q_vector。他注册了1个NAPI机制所必须的poll函数,对于igb网卡驱动,此函数就是igb_poll,如下代码所示。

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){

......

/* initialize NAPI */

netif_napi_add(adapter->netdev, &q_vector->napi,

igb_poll, 64);

}

4.5启动网卡

当上面的初始化都完成后,就可启动网卡了。

回忆前面网卡驱动初始化时,提到了驱动向内核注册了 struct net_device_ops 变量,它包含着网卡启用、发包、设置mac等回调函数(函数指针)。当启用1个网卡时(如,通过 ifconfig eth0 up),net_device_ops 中的 igb_open方法会被调用。

它通常会做以下事:

//file: drivers/net/ethernet/intel/igb/igb_main.c

static int __igb_open(struct net_device *netdev, bool resuming){

/* allocate transmit descriptors */

err = igb_setup_all_tx_resources(adapter);

/* allocate receive descriptors */

err = igb_setup_all_rx_resources(adapter);

/* 注册中断处理函数 */

err = igb_request_irq(adapter);

if (err)

goto err_req_irq;

/* 启用NAPI */

for (i = 0; i < adapter->num_q_vectors; i++)

napi_enable(&(adapter->q_vector[I ]->napi));

......

}

在上面__igb_open函数调用了igb_setup_all_tx_resources,和igb_setup_all_rx_resources。在igb_setup_all_rx_resources这步操作中,分配了RingBuffer,并建立内存和Rx队列的映射关系。(Rx Tx 队列的数量和大小可通过 ethtool 配置)。

再接着看中断函数注册igb_request_irq:

static int igb_request_irq(struct igb_adapter *adapter){

if (adapter->msix_entries) {

err = igb_request_msix(adapter);

if (!err)

goto request_done;

......

}

}

static int igb_request_msix(struct igb_adapter *adapter){

......

for (i = 0; i < adapter->num_q_vectors; i++) {

...

err = request_irq(adapter->msix_entries[vector].vector,

igb_msix_ring, 0, q_vector->name,

}

在上面的代码中跟踪函数调用, __igb_open => igb_request_irq => igb_request_msix, 在igb_request_msix中看到了,对于多队列的网卡,为每个队列都注册了中断,其对应的中断处理函数是igb_msix_ring(该函数也在drivers/net/ethernet/intel/igb/igb_main.c下)。

也可看到,msix方式下,每个 RX 队列有独立的MSI-X 中断,从网卡硬中断的层面就可设置让收到的包被不同的 CPU处理。(可通过 irqbalance ,或修改 /proc/irq/IRQ_NUMBER/smp_affinity能修改和CPU的绑定行为)。

当做好以上准备工作以后,就可开门迎客(包)了!

5、开始迎接数据的到来

5.1硬中断处理

首先:当数据帧从网线到达网卡上时,第1站是网卡的收队列。

网卡在分配给自己的RingBuffer中寻找可用内存,找到后DMA引擎会把数据DMA到网卡之前关联的内存里,此时CPU无感。当DMA操作完后,网卡会像CPU发起1个硬中断,通知CPU有数据到达。

网卡数据硬中断处理过程:

注意:当RingBuffer满时,新来的包将丢弃。ifconfig查看网卡时,可里面有个overruns,表示因环形队列满被丢弃的包。若发现有丢包,可能需通过ethtool命令来加大环形队列的长度。

在启动网卡1节,说到了网卡的硬中断注册的处理函数是igb_msix_ring:

//file: drivers/net/ethernet/intel/igb/igb_main.c

static irqreturn_t igb_msix_ring(int irq, void *data){

struct igb_q_vector *q_vector = data;

/* Write the ITR value calculated from the previous interrupt. */

igb_write_itr(q_vector);

napi_schedule(&q_vector->napi);

return IRQ_HANDLED;

}

igb_write_itr只是记录下硬中断频率(据说目的是在减少对CPU的中断频率时用到)。

顺着napi_schedule调用1路跟踪下去,__napi_schedule=>____napi_schedule:

/* Called with irq disabled */

static inline void ____napi_schedule(struct softnet_data *sd,

struct napi_struct *napi){

list_add_tail(&napi->poll_list, &sd->poll_list);

__raise_softirq_irqoff(NET_RX_SOFTIRQ);

}

这里看到:list_add_tail修改了CPU变量softnet_data里的poll_list,将驱动napi_struct传过来的poll_list添加了进来。

其中:softnet_data中的poll_list是个双向列表,其中的设备都带有输入帧等着被处理。紧接着__raise_softirq_irqoff触发了1个软中断NET_RX_SOFTIRQ, 触发过程只是对1个变量进行1次或运算。

void __raise_softirq_irqoff(unsigned int nr){

trace_softirq_raise(nr);

or_softirq_pending(1UL << nr);

}

//file: include/linux/irq_cpustat.h

#define or_softirq_pending(x) (local_softirq_pending() |= (x))

Linux在硬中断里只完成简单必要的工作,剩下的大部分的处理都转交给软中断。

通过上面代码可看到:硬中断处理过程真的很短。只是记录了1个寄存器,修改了1下CPU的poll_list,然后发出个软中断。

5.2ksoftirqd内核线程处理软中断

ksoftirqd内核线程:

内核线程初始化时,介绍了ksoftirqd中2个线程函数ksoftirqd_should_run和run_ksoftirqd。

其中ksoftirqd_should_run代码如下:

static int ksoftirqd_should_run(unsigned int cpu){

return local_softirq_pending();

}

#define local_softirq_pending() \ __IRQ_STAT(smp_processor_id(), __softirq_pending)

这里看到和硬中断中调用了同个函数local_softirq_pending。使用方式不同的是硬中断位置是为了写入标记,这里仅读取。若硬中断中设置了NET_RX_SOFTIRQ,这里自然能读到。

接下来会真正进入线程函数中run_ksoftirqd处理:

static void run_ksoftirqd(unsigned int cpu){

local_irq_disable();

if (local_softirq_pending()) {

__do_softirq();

rcu_note_context_switch(cpu);

local_irq_enable();

cond_resched();

return;

}

local_irq_enable();

}

在__do_softirq中,判断根据当前CPU的软中断类型,调用其注册的action方法。

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);

}

在网络子系统初始化小节, 看到为NET_RX_SOFTIRQ注册了处理函数net_rx_action。所以net_rx_action函数就会被执行到了。

注,硬中断中设置软中断标记,和ksoftirq的判断是否有软中断到达,都基于smp_processor_id()。这意味着硬中断在哪个CPU上被响应,软中断也在此CPU上处理。所以若发现Linux软中断CPU消耗都集中在1个核上,要调整硬中断的CPU亲和性,将硬中断打散到不同的CPU核上去。

再来把精力集中到此核心函数net_rx_action上来:

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);
        }
        budget -= work;
    }
}

函数开头的time_limit和budget用来控制net_rx_action函数主动退出,目的是保证收包不霸占CPU不放。等下次网卡再有硬中断过来再处理剩下的收包。budget可通过内核参数调整。此函数中剩下的核心逻辑是获取到当前CPU变量softnet_data,对其poll_list遍历, 然后执行到网卡驱动注册到的poll函数。

igb网卡就是igb驱动力的igb_poll函数了:

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);
    ...
}

在读取操作中,igb_poll的重点工作是对igb_clean_rx_irq的调用:

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);
 
        /* 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);
}

igb_fetch_rx_buffer和igb_is_non_eop的作用就是把数据帧从RingBuffer上取下来。

为何需2个函数?因有可能帧要占多个RingBuffer,所以在1个循环中获取,直到帧尾部。获取下来的1个数据帧用1个sk_buff表示。收取完数据后,对其进行1些校验,然后开始设置sbk变量的timestamp, VLAN id, protocol等字段。

接下来进入到napi_gro_receive中:

gro_result_t napi_gro_receive(struct napi_struct *napi, struct sk_buff *skb){
    skb_gro_reset_offset(skb);
    return napi_skb_finish(dev_gro_receive(napi, skb), skb);
}

dev_gro_receive代表的是网卡GRO特性,可简单理解成把相关的小包合并成1个大包,目的是减少传送给网络栈的包数,有助于减少 CPU 的使用量。

napi_skb_finish主要就是调用了netif_receive_skb:

//file: net/core/dev.c
static gro_result_t napi_skb_finish(gro_result_t ret, struct sk_buff *skb){
    switch (ret) {
    case GRO_NORMAL:
        if (netif_receive_skb(skb))
            ret = GRO_DROP;
        break;
    ......
}

在netif_receive_skb中,包将被送到协议栈中。声明,以下的5.3、5.4、5.5也都属于软中断的处理过程,只不过由于篇幅太长,单独拿出来成小节。

5.3网络协议栈处理

netif_receive_skb会根据包的协议,如是udp包,会被依次送到ip_rcv(),udp_rcv()中处理。

网络协议栈处理:

//file: 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){
    ...... 
    ret = __netif_receive_skb_core(skb, false);}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;
        }
    }
}

接着__netif_receive_skb_core取出protocol,它会从包中取出协议信息,然后遍历注册在此协议上的回调函数列表。ptype_base 是1个 hash table,在协议注册小节提到过。ip_rcv 函数地址就存在此 hash table中。

//file: 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);
}

pt_prev->func这行就调用到了协议层注册的处理函数了。对于ip包来讲,就会进入到ip_rcv(arp包会进入到arp_rcv)。

5.4ip层处理

//file: 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是1个钩子函数,当执行完注册的钩子后就会执行到最后1个参数指向的函数ip_rcv_finish。
 
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);
}

跟踪ip_route_input_noref 后看到它又调用了 ip_route_input_mc。

Linux网络路由选择很复杂,但总是设置dst_entry的input接口函数来实现具体功能:转发设置ip_forward;本地收设置ip_local_deliver。之后执行流程函数很多,调用路径也很长,从ip_local_deliver到udp_rcv,再到udp_queue_rcv_skb,最后在__udp_enqueue_schedule_skb中把skb加入队列,然后调用sk_data_ready接口唤醒阻塞线程。

在ip_route_input_mc中,函数ip_local_deliver被赋值给了dst.input, 如下:

//file: 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;
    }
}

所以回到ip_rcv_finish中的return dst_input(skb):

/* Input packet from network to transport.  */
static inline int dst_input(struct sk_buff *skb){
    return skb_dst(skb)->input(skb);
}

skb_dst(skb)->input调用的input方法就是路由子系统赋的ip_local_deliver:

//file: net/ipv4/ip_input.c
int ip_local_deliver(struct sk_buff *skb){
    /*     *  Reassemble IP fragments.     */
    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_rcv()和udp_rcv()的函数地址。这里将按包中的协议类型选择进行分发,在这里skb包将会进1步被派送到更上层的协议中,udp和tcp。

5.5udp层处理

在协议注册小节时说过,udp的处理函数是udp_rcv。

ip_local_deliver_finish

ip_protocol_deliver_rcu

udp_rcv

__udp4_lib_rcv

udp_unicast_rcv_skb

udp_queue_rcv_skb

//file: net/ipv4/udp.c
int udp_rcv(struct sk_buff *skb){
    return __udp4_lib_rcv(skb, &udp_table, IPPROTO_UDP);
}
int __udp4_lib_rcv(struct sk_buff *skb, struct udp_table *udptable,
           int proto){
。。。。。。
    sk = __udp4_lib_lookup_skb(skb, uh->source, uh->dest, udptable);
 
    if (sk != NULL) {
        int ret = udp_queue_rcv_skb(sk, skb
    }
。。。。。。
    icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PORT_UNREACH, 0);
}

__udp4_lib_lookup_skb是根据skb来寻找对应的socket,找到后将包放到socket的缓存队列里。若没找到,则发1个目标不可达的icmp包。

static int udp_queue_rcv_skb(struct sock *sk, struct sk_buff *skb)
{
	struct sk_buff *next, *segs;
	int ret;
	if (likely(!udp_unexpected_gso(sk, skb)))
		return udp_queue_rcv_one_skb(sk, skb);
	BUILD_BUG_ON(sizeof(struct udp_skb_cb) > SKB_GSO_CB_OFFSET);
	__skb_push(skb, -skb_mac_offset(skb));
	segs = udp_rcv_segment(sk, skb, true);
	skb_list_walk_safe(segs, skb, next) {
		__skb_pull(skb, skb_transport_offset(skb));
		udp_post_segment_fix_csum(skb);
		ret = udp_queue_rcv_one_skb(sk, skb);
		if (ret > 0)
			ip_protocol_deliver_rcu(dev_net(skb->dev), skb, ret);
	}
	return 0;
}

首先判断包是否需进行 GSO(Generic Segmentation Offload)处理,若否,则直接调用 udp_queue_rcv_one_skb将包加入收队列。若是,调用 udp_rcv_segment 对包分段,并依次将每个分段加入收队列。最后,若收队列中有包,则调用 ip_protocol_deliver_rcu将包交给上层协议处理。

GSO是种网络协议栈的优化技术,可将大块的包分割成更小的包,以便更好地利用网络带宽、减轻主机CPU的负担。在传输大量数据时,GSO可提高网络传输效率和性能,同时减少主机CPU负载,提高系统整体性能。GSO通常用于高速网络接口,如千兆以太网和万兆以太网等。

若网络设备支持GSO,内核会自动对符合条件的UDP包GSO处理,并将多个小的UDP包合并成1个大的包传输,提高网络传输效率。

在Linux内核中,收到的UDP包是否需进行GSO处理,取决于以下几个因素:

1. 收网卡是否支持GSO:只有支持GSO的网卡才能进行GSO处理。

2. 收缓冲区大小:若收缓冲区大小小于UDP包的大小,则需进行GSO处理。

3. MTU大小:若收网卡的MTU小于UDP包的大小,则需进行GSO处理。

4. 是否开启GSO:若开启了GSO,则需进行GSO处理。

若以上条件都满足,则需进行GSO处理,否则不需。在Linux内核中,可通过设置ethtool工具来开启或关闭GSO功能。

在收到UDP包后,可通过1些条件来判断是否需进行GSO处理:

  • 包的总长度大于MTU
  • 包的数据部分大于MSS(最大分段大小),即超过了TCP中的1个分段大小。当使用GSO时,每个分段的大小不能超过MSS。
  • 当前网卡支持GSO特性。可通过检查网卡驱动是否支持GSO功能,若支持则可开启GSO。
  • 内核中的特定参数已开启GSO处理。可通过sysctl工具对“net.core.xfrm_acq_expires”和“net.ipv4.tcp_syncookies”这2个参数进行设置,以启用GSO特性。

若上述条件全满足,则可开启GSO。对UDP包的GSO处理通常是在Linux内核中通过skb_shinfo->gso_segs计算出分段数量,再通过skb_shinfo->gso_size来设置每个分段的大小,从而进行具体的分段操作。

static inline bool udp_unexpected_gso(struct sock *sk, struct sk_buff *skb)
{
	if (!skb_is_gso(skb))
		return false;


	if (skb_shinfo(skb)->gso_type & SKB_GSO_UDP_L4 && !udp_sk(sk)->accept_udp_l4)
		return true;


	if (skb_shinfo(skb)->gso_type & SKB_GSO_FRAGLIST && !udp_sk(sk)->accept_udp_fraglist)
		return true;


	return false;
}

该函数会根据输入参数sk和skb来判断是否需开启UDP数据报的GSO操作。

首先使用skb_is_gso判断当前SKB包是否可进行GSO处理。若否,直接返回false。若是,则检查包的gso_type类型,及是否开启了接受UDP分片或L4协议的标志。

static inline bool skb_is_gso(const struct sk_buff *skb)
{
	return skb_shinfo(skb)->gso_size;
}
//gso_size表示生产GSO大包时的包长度,1般时mss的整数倍

当gso_type为SKB_GSO_UDP_L4,且accept_udp_l4标志没开启时,返回true。

	if (skb_shinfo(skb)->gso_type & SKB_GSO_UDP_L4 && !udp_sk(sk)->accept_udp_l4)
		return true;

同理,当gso_type为SKB_GSO_FRAGLIST,且accept_udp_fraglist标志没开启时,也返回true。

最后,若上述2个判断条件都不满足,则返回false。

static inline struct sk_buff *udp_rcv_segment(struct sock *sk,
					      struct sk_buff *skb, bool ipv4)
{
	netdev_features_t features = NETIF_F_SG;
	struct sk_buff *segs;
	/* Avoid csum recalculation by skb_segment unless userspace explicitly
	 * asks for the final checksum values
	 */
	if (!inet_get_convert_csum(sk))
		features |= NETIF_F_IP_CSUM | NETIF_F_IPV6_CSUM;
	/* UDP segmentation expects packets of type CHECKSUM_PARTIAL or
	 * CHECKSUM_NONE in __udp_gso_segment. UDP GRO indeed builds partial
	 * packets in udp_gro_complete_segment. As does UDP GSO, verified by
	 * udp_send_skb. But when those packets are looped in dev_loopback_xmit
	 * their ip_summed CHECKSUM_NONE is changed to CHECKSUM_UNNECESSARY.
	 * Reset in this specific case, where PARTIAL is both correct and
	 * required.
	 */
	if (skb->pkt_type == PACKET_LOOPBACK)
		skb->ip_summed = CHECKSUM_PARTIAL;
	/* the GSO CB lays after the UDP one, no need to save and restore any
	 * CB fragment
	 */
	segs = __skb_gso_segment(skb, features, false);
	if (IS_ERR_OR_NULL(segs)) {
		int segs_nr = skb_shinfo(skb)->gso_segs;
		atomic_add(segs_nr, &sk->sk_drops);
		SNMP_ADD_STATS(__UDPX_MIB(sk, ipv4), UDP_MIB_INERRORS, segs_nr);
		kfree_skb(skb);
		return NULL;
	}
	consume_skb(skb);
	return segs;
}

sock_owned_by_user判断用户是否正在此socker上进行系统调用(socket被占用),若否,直接放到socket的收队列中。若是,通过sk_add_backlog把包添加到backlog队列。

当用户释放的socket时,内核会检查backlog队列,若有数据再移动到收队列中。

sk_rcvqueues_full收队列若满了,将直接把包丢弃。收队列大小受内核参数net.core.rmem_max和net.core.rmem_default影响。

6、recvfrom系统调用

上面说完了整个Linux内核对包的收和处理过程,最后把包放到socket的收队列中了。再回头看用户进程调用recvfrom后发生了什么。

recvfrom是个glibc的库函数,该函数在执行后会将用户进行陷入到内核态,进入到Linux实现的系统调用sys_recvfrom。

SYSCALL_DEFINE6(recvfrom, int, fd, void __user *, ubuf, size_t, size,
  unsigned int, flags, struct sockaddr __user *, addr,
  int __user *, addr_len)
{
 return __sys_recvfrom(fd, ubuf, size, flags, addr, addr_len);
}

在理解Linux对sys_revvfrom前,先来简单看下socket核心数据结构。该数据结构太大了,只把对和今天主题相关的内容画出来。

如下(socket内核数据机构):

socket数据结构中的const struct proto_ops对应的是协议的方法集合。每个协议都会实现不同的方法集,IPv4 Internet协议族每种协议都有对应的处理方法,如下。udp是通过inet_dgram_ops来定义的,其中注册了inet_recvmsg方法。

01

02

03

04

05

06

07

08

09

10

11

12

13

//file: net/ipv4/af_inet.c

const struct proto_ops inet_stream_ops = {

......

.recvmsg = inet_recvmsg,

.mmap = sock_no_mmap,

......

}

const struct proto_ops inet_dgram_ops = {

......

.sendmsg = inet_sendmsg,

.recvmsg = inet_recvmsg,

......

}

socket数据结构中的另1个数据结构struct sock *sk是个很大,很重要的子结构体。其中的sk_prot又定义了2级处理函数。对于udp,会被设置成udp实现的方法集udp_prot。

01

02

03

04

05

06

07

08

09

10

11

12

//file: net/ipv4/udp.c

struct proto udp_prot = {

.name = "UDP",

.owner = THIS_MODULE,

.close = udp_lib_close,

.connect = ip4_datagram_connect,

......

.sendmsg = udp_sendmsg,

.recvmsg = udp_recvmsg,

.sendpage = udp_sendpage,

......

}

看完了socket变量后,再来看sys_revvfrom的实现过程。

recvfrom函数内部实现过程:

在inet_recvmsg调用了sk->sk_prot->recvmsg:

1

2

3

4

5

6

7

8

9

//file: net/ipv4/af_inet.c

int inet_recvmsg(struct kiocb *iocb, struct socket *sock, struct msghdr *msg,size_t size, int flags){

......

err = sk->sk_prot->recvmsg(iocb, sk, msg, size, flags & MSG_DONTWAIT,

flags & ~MSG_DONTWAIT, &addr_len);

if (err >= 0)

msg->msg_namelen = addr_len;

return err;

}

上面说过此对于udp的socket,此sk_prot就是net/ipv4/udp.c下的struct proto udp_prot。由此找到了udp_recvmsg方法。

UDP层收包的实现是udp_recvmsg。首先调用__skb_recv_udp获取队列中的sk_buff包,若有,就把相关的数据拷贝到用户空间,函数返回。每个socket都有自己的收队列,保存在udp_sock的reader_queue中,若队列中没数据,那就没什么事了,直接把线程挂起,等待调度。

01

02

03

04

05

06

07

08

09

10

11

12

13

14

15

//file:net/core/datagram.c:EXPORT_SYMBOL(__skb_recv_datagram);

struct sk_buff *__skb_recv_datagram(struct sock *sk, unsigned int flags,int *peeked, int *off, int *err){

......

do {

struct sk_buff_head *queue = &sk->sk_receive_queue;

skb_queue_walk(queue, skb) {

......

}

/* User doesn't want to wait */

error = -EAGAIN;

if (!timeo)

goto no_packet;

} while (!wait_for_more_packets(sk, err, &timeo, last));

}

终于:找到了想要看的重点,在上面看到了所谓的读取过程,就是访问sk->sk_receive_queue。若没数据,且用户也允许等待,则将调用wait_for_more_packets()执行等待操作,它加入会让用户进程进入睡眠状态。

7、本文小结

网络模块是OS内核中最复杂的模块,看起来1个简单的收包过程就涉及到许多内核组件间的交互,如网卡驱动、协议栈、内核ksoftirqd线程等,看起来很复杂。本文想通过图示的方式,尽量以容易理解的方式来将内核收包过程讲清楚。

现在让再串1串整个收包过程:当用户执行完recvfrom调用后,用户进程就通过系统调用进行到内核态工作了。若收队列没数据,进程就进入睡眠状态被OS挂起。这块相对较简单,剩下大部分的戏份都是由Linux内核其它模块来表演了。

首先在开始收包前,OS要做许多的准备工作(以Linux为例):

  • 1)创建ksoftirqd线程,为它设置好它自己的线程函数,后面指望着它来处理软中断;
  • 2)协议栈注册,linux要实现许多协议,如arp,icmp,ip,udp,tcp,每个协议都会将自己的处理函数注册下,方便包来了迅速找到对应的处理函数;
  • 3)网卡驱动初始化,每个驱动都有个初始化函数,内核会让驱动也初始化下。在此初始化过程中,把自己的DMA准备好,把NAPI的poll函数地址告诉内核;
  • 4)启动网卡,分配RX,TX队列,注册中断对应的处理函数。

以上是内核准备收包前的重要工作,当上面都ready后,就可打开硬中断,等待包的到来。

当数据到来了后,第1个迎接它的是网卡:

  • 1)网卡将数据帧DMA到内存的RingBuffer中,然后向CPU发起中断通知;
  • 2)CPU响应中断请求,调用网卡启动时注册的中断处理函数;
  • 3)中断处理函数几乎没干啥,就发起了软中断请求;
  • 4)内核线程ksoftirqd线程发现有软中断请求到来,先关闭硬中断;
  • 5)ksoftirqd线程开始调用驱动的poll函数收包;
  • 6)poll函数将收到的包送到协议栈注册的ip_rcv函数中;
  • 7)ip_rcv函数再讲包送到udp_rcv函数中(对于tcp包就送到tcp_rcv)。

现在,可回到开篇的问题了:在用户层看到的简单1行recvfrom,Linux内核要替做如此之多的工作,才能让顺利收到数据。

这还是简单的UDP,若是TCP,内核要做的工作更多。

理解了整个收包过程后,就能明确知道Linux收1个包的CPU开销了:

  • 1)用户进程调用系统调用陷入内核态的开销;
  • 2)CPU响应包的硬中断的CPU开销;
  • 3)ksoftirqd内核线程的软中断上下文花费的。

后面再专门发篇文章实际观察下这些开销。

另外:网络收发中有很多末支细节并没展开说,如说:no NAPI, GRO,RPS等。

发包基本流程

创建socket

socket是内核统1对外的网络描述符,使用前先创建,内核中每个协议族都有自己的实现方式,inet_create函数就是IPv4创建socket的方法

发包

inet_sendmsg是IPv4协议族中发包入口,不管是stream包,还是packet包都由此函数处理,但它并没做啥实事,只是根据socket具体绑定的协议调用tcp_sendmsg或udp_sendmsg函数。

UDP组包

udp_sendmsg首先调用ip_route_output_flow确定路由对象;然后调用ip_make_skb分配sk_buff保存数据,关于包的所有信息都保存在sk_buff中,这是协议层和设备层通讯的关键数据结构;最后调用udp_send_skb把包发出去。

路由选择

发数据涉及到的路由项非常复杂,是由绑定在dst_entry中的output接口负责,1个dst_entry对象代表1个路由表项。单纯的UDP发就是把它绑定为ip_output函数发往对端的路由项。ip_output中有1个HOOK,此是网络的1个钩子,这里没设置,所以直接调用ip_finish_output把包发出去。

设备接口

IP包准备好后,调用网络设备框架发出去。框架代码是较完善的,但最终的功能还是要由设备驱动来实现,net_device的net_device_ops封装了网络设备的各种接口,驱动代码必须实现其中的某些接口,比如ndo_start_xmit就是发数据的关键接口,几乎每个网卡驱动都要实现它。Intel的e1000网卡的驱动代码在drivers/net/ethernet/intel/e1000中可找到。