TCP/IP协议栈在Linux内核中的运行时序分析

 TCP/IP协议栈在Linux内核中的运行时序分析

 0.要求

- 在深入理解Linux内核任务调度(中断处理、softirg、tasklet、wq、内核线程等)机制的基础上,分析梳理send和recv过程中TCP/IP协议栈相关的运行任务实体及相互协作的时序分析。
- 编译、部署、运行、测评、原理、源代码分析、跟踪调试等
- 应该包括时序图

1.TCP/IP协议栈总览
​    在TCP/IP网络分层模型里,整个协议栈被分成了物理层、链路层、网络层,传输层和应用层。物理层对应的是网卡和网线,应用层对应的是我们常见的FTP,HTTP等等各种应用。Linux实现的是链路层、网络层和传输层这三层。

​    在Linux内核实现中,链路层协议靠网卡驱动来实现,内核协议栈来实现网络层和传输层。内核对更上层的应用层提供socket接口来供用户进程访问。我们用Linux的视角来看到的TCP/IP网络分层模型应该是下面这个样子的。



​    在Linux的源代码中,网络设备驱动对应的逻辑位于driver/net/ethernet, 其中intel系列网卡的驱动在driver/net/ethernet/intel目录下。协议栈模块代码位于kernel和net目录。

​    内核和网络设备驱动是通过中断的方式来处理的。当设备上有数据到达的时候,会给CPU的相关引脚上触发一个电压变化,以通知CPU来处理数据。Linux中断处理函数是分上半部和下半部的。上半部是只进行最简单的工作,快速处理然后释放CPU,接着CPU就可以允许其它中断进来。剩下将绝大部分的工作都放到下半部中,可以慢慢从容处理。2.4以后的内核版本采用的下半部实现方式是软中断,由ksoftirqd内核线程全权处理。

​    那么内核收一个包应该是怎样的呢?如下给出简单示意图。



2.linux内核启动

​    Linux内核协议栈等模块在具备接收网卡数据包之前,要做很多的准备工作才行。比如要提前创建好ksoftirqd内核线程,要注册好各个协议对应的处理函数,网络设备子系统要提前初始化好,网卡要启动好。只有这些都准备之后,我们才能真正开始接收数据包。那么我们现在来看看这些准备工作都是怎么做的。


2.1 创建ksoftirqd内核线程

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

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

​    相关代码如下:

 1 //file: kernel/softirq.c
 2 
 3 static struct smp_hotplug_thread softirq_threads = {
 4 
 5     .store          = &ksoftirqd,
 6     .thread_should_run  = ksoftirqd_should_run,
 7     .thread_fn      = run_ksoftirqd,
 8     .thread_comm        = "ksoftirqd/%u",};
 9 static __init int spawn_ksoftirqd(void){
10     register_cpu_notifier(&cpu_nfb);
11 
12     BUG_ON(smpboot_register_percpu_thread(&softirq_threads));
13     return 0;
14 
15 }
16 
17 early_initcall(spawn_ksoftirqd);


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

2.2网络子系统初始化

​    linux内核通过调用subsys_initcall来初始化各个子系统,在源代码目录里你可以grep出许多对这个函数的调用。这里我们要说的是网络子系统的初始化,会执行到net_dev_init函数。

//file: net/core/dev.c

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都申请一个softnet_data数据结构,在这个数据结构里的poll_list是等待驱动程序将其poll函数注册进来,稍后网卡驱动初始化的时候我们可以看到这一过程。

 2.3协议栈注册

​    内核实现了网络层的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数据结构中了。

 2.4网卡驱动初始化与网卡启动

​    这一步主要是初始化对应网卡的驱动程序,启动网卡。驱动向内核注册了 structure net_device_ops 变量,它包含着网卡启用、发包、设置mac 地址等回调函数(函数指针)。当启用一个网卡时(例如,通过 ifconfig eth0 up),net_device_ops 中的 igb_open方法会被调用,之后分配队列内存,注册中断处理函数,然后打开硬中断等包进来。

3.包的接收(收到socket接收队列)

3.1硬中断处理

​    首先当数据帧从网线到达网卡上的时候,第一站是网卡的接收队列。网卡在分配给自己的RingBuffer中寻找可用的内存位置,找到后DMA引擎会把数据DMA到网卡之前关联的内存里,这个时候CPU都是无感的。当DMA操作完成以后,网卡会像CPU发起一个硬中断,通知CPU有数据到达。

​    Linux在硬中断里只完成简单必要的工作,剩下的大部分的处理都是转交给软中断的。只是记录了一个寄存器,修改了一下下CPU的poll_list,然后发出个软中断。

 3.2 ksoftirqd内核线程处理软中断

​    简单讲就是系统会循环执行ksoftirqd内核线程,判断softirq_pengding标志根据软中断类型执行不同函数,之后调用驱动注册的poll函数,取下数据包

然后送给协议栈。

 3.3 网络协议栈处理

 3.3.1网络层之前的处理

​    netif_receive_skb函数会根据包的协议,假如是udp包,会将包依次送到ip_rcv(),udp_rcv()协议处理函数中进行处理。(如果是tcp,则送到arp_rcv(),tcp_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 是一个 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)。

3.3.2网络层处理

​    我们再来大致看一下linux在ip协议层都做了什么,包又是怎么样进一步被送到udp或tcp协议处理函数中的。

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

}


​    这里将会根据包中的协议类型选择进行分发,在这里skb包将会进一步被派送到更上层的协议中,udp和tcp。

3.3.3传输层处理

​    以udp为例,这里的处理函数是udp_rcv()。

//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的缓存队列里。如果没有找到,则发送一个目标不可达的icmp包。

//file: net/ipv4/udp.c

int udp_queue_rcv_skb(struct sock *sk, struct sk_buff *skb){  

    ......
    if (sk_rcvqueues_full(sk, skb, sk->sk_rcvbuf))
        goto drop;

    rc = 0;

    ipv4_pktinfo_prepare(skb);
    bh_lock_sock(sk);
    if (!sock_owned_by_user(sk))
        rc = __udp_queue_rcv_skb(sk, skb);
    else if (sk_add_backlog(sk, skb, sk->sk_rcvbuf)) {
        bh_unlock_sock(sk);
        goto drop;
    }
    bh_unlock_sock(sk);
    return rc;

}

​    sock_owned_by_user判断的是用户是不是正在这个socker上进行系统调用(socket被占用),如果没有,那就可以直接放到socket的接收队列中。如果有,那就通过sk_add_backlog把数据包添加到backlog队列。当用户释放的socket的时候,内核会检查backlog队列,如果有数据再移动到接收队列中。

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

4.recvfrom系统调用

​    上面我们说完了整个Linux内核对数据包的接收和处理过程,最后把数据包放到socket的接收队列中了。那么我们再回头看用户进程调用recvfrom后是发生了什么。我们在代码里调用的recvfrom是一个glibc的库函数,该函数在执行后会将用户进行陷入到内核态,进入到Linux实现的系统调用sys_recvfrom。



​    以上一个包在linux内核下的接收读取便成功了。

5.时序图







發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章