深入剖析网络发送和接收过程

简介: 本文在基于以下三个条件所写的:   1)  OSI七层网络通信模型。   2)  所阐述的函数是基于Linux2.6.1内核。   3)  在面向连接的通信协议TCP/IPV4的基础上。

本文在基于以下三个条件所写的:

 

1  OSI七层网络通信模型。

 

2  所阐述的函数是基于Linux2.6.1内核。

 

3  在面向连接的通信协议TCP/IPV4的基础上。

 

由于七层模型(应用层, 表示层, 会话层, 传输层, 网络层, 数据链路层, 物理层)可以简化为以下五层结构: 应用层(Application Layer), 传输层(Transport Layer), 网络层(Network Layer), 数据链路层(Data Link Layer), 物理层(Physical Layer).其中七层模型中的前三层都归结为五层结构中的应用层。为了简化讨论,本文主要从这五层结构来探讨。

 

 

 

Layer 5:应用层(Application Layer)

 

 

 

TCP协议上,当通过三方握手建立了连接之后,就进入数据包的实质发送阶段,在本文中以send命令来阐述。当通过send将数据包发送之后,glibc函数库会启用另外一个其定义的别用名函数__libc_sendto(),该函数最后会间接执行到sendto系统调用:

 

inline_syscall##nr(name, args);// ##nr说明是该系统调用带有nrargs参数sendto系统调用的参数值是6,而name就是sendto

 

从上面的分析可以看出glibc将要执行的下面一条语句是

 

inline_syscall6(name,arg1,arg2,arg3,arg4,arg5,arg6)

 

在该函数中一段主要功能实现代码如下:

 

__asm__ __volatile__                                    \

 

          ("callsys # %0 %1             \

 

           : inline_syscall_r0_out_constraint (_sc_0),          \

 

             "=r"(_sc_19), "=r"(_sc_16), "=r"(_sc_17),          \

 

             "=r"(_sc_18), "=r"(_sc_20), "=r"(_sc_21)           \

 

           : "0"(_sc_0), "2"(_sc_16), "3"(_sc_17), "4"(_sc_18), \

 

             "1"(_sc_19), "5"(_sc_20), "6"(_sc_21)              \

 

           : inline_syscall_clobbers);                          \

 

        _sc_ret = _sc_0, _sc_err = _sc_19; 

 

该代码采用了嵌入汇编(详细介绍查阅嵌入汇编相关书籍),其中:

 

_sc_0=sendto;

 

_sc_19 --_sc_21分别是arg1arg6;

 

inline_syscall_r0_out_constraint:功能相当于"=r",选用一个寄存器来存储输出变量。

 

"0"--"6"分别是%0--%6,代表_sc_0--_sc_21

 

接下来函数最终通过Linux中顶顶有名的INT 0X80陷入系统核心。具体的过程可以参考内核相关书籍。下面是一个兄弟对INT 0X80的简要介绍:

 

http://blog.chinaunix.net/u2/65427/showart_712571.html

 

在陷入系统内核以后,最终会调用系统所提供的系统调用函数sys_sendto(),该函数直接调用了__sock_sendmsg(),该函数对进程做一个简单的权限检查之后就触发套接字(socket)中定义的虚拟sendmsg的函数,进而进入到下一层传输层处理。

 

 

 

Layer 4: 传输层(Transport Layer)

 

 

 

由上层的讨论可知,系统触发了sendmsg虚拟接口函数,其实就是传输层中的tcp_sendmsg或是udp_sendmsg,看你所使用的协议而定。本文介绍tcp_sendmsg().

 

该函数需要做如下工作:

 

1)为sk_buff(后面简称skb)分配空间,该函数首先尝试在套接字缓冲队列中寻找空闲空间,如果找不到就使用tcp_alloc_pskb()为其重新分配空间。

 

2  下面这步就会tcp_sendmsg函数的主要部分了,将数据拷贝到缓冲区。它分为如下两种情况:

 

2.1)如果skb还有剩余空间的话,就使用skb_add_data()来向skb尾部添加数据包。代码如下:

 

if (skb_tailroom(skb) > 0) {

 

                                /* We have some space in skb head. Superb! */

 

                                if (copy > skb_tailroom(skb))

 

                                        copy = skb_tailroom(skb);

 

                                if ((err = skb_add_data(skb, from, copy)) != 0)

 

                                        goto do_fault;

 

                        }

 

2.2)如果skb没有了可用空间,内核会使用TCP_PAGE宏来为发送的数据包分配一个高速缓存页空间,当该页被正确地分配后就调用Copy_from_user(to(page地址),fromusr空间),n)将用户空间数据包复制到page所在的地址空间。

 

但是我们都知道数据包在协议层之间的传输是通过skb的,难道将数据包复制到这个新分配的page中,内核就可以去睡大觉了吗?当然不是!接下来内核就要来处理这个问题了,那么怎样来处理呢?

 

此时就需要使用到skb中的另外一个数据区struct skb_shared_info[],但是该数据区在创建skb时是没有为其分配空间的,也就是说它开始纯粹就是个指针,而没有具体的告诉它要指向什么地方。这时大家应该知道它可以指向什么地方了,对,就是page!在内核中对这种情况的具体是通过fill_page_desc(struct sk_buff *skb,int I,struct page *page,int off,int size)来实现的,代码如下:

 

static inline void fill_page_desc(struct sk_buff *skb, int i,

                                  struct page *page, int off, int size)

{

        skb_frag_t *frag = &skb_shinfo(skb)->frags[i];

        frag->page = page;

        frag->page_offset = off;

        frag->size = size;

        skb_shinfo(skb)->nr_frags = i + 1;

}

这里需要注意的是struct skb_shared_info[]只能通过skb_shinfo来获取,在该结构体中skb_flag_t类型的flags[i]就是具体指向page的数组。

 

2.3)至此skb数据包的装载工作算是结束了,接下来就需要做一些后续工作,包括是否要分片,以及后来的TCP协议头的添加。先看在tcp_sendmsg()中的最后一个重要函数tcp_push,它的调用格式如下:

 

static inline void tcp_push(struct sock *sk, struct tcp_opt *tp, int flags,

                            int mss_now, int nonagle)

细心的朋友会发现,在该函数中传输的竟然不是skb,而是一个名为sock的结构体,那这又是什么东东呢?个人理解是它在顶层协议层之间(例如:应用层和传输层之间)的传输起着非常重要的作用,相当于沟通两层之间的纽带。再深入查找下该结构体的构成,我们很容易发现这样一个结构体变量:struct sk_buff_head,有名称我们可以知道它是用来描述skb头部信息的一个结构体,它指向了buffer的数据区。这下我们也明白了点,这个结构体其实还充当了一个队列作用,是用来存储skb的数据区。协议层之间传输完之后,具体到该层处理时内核就会从sk_buff_head逐个中取出skb数据区来处理,例如添加协议头等。

 

好了,tcp_sendmsg到此结束了它的使命了,下面将要需要的一个函数就是在tcp_push()中直接用到的一个函数:__tcp_push_pending_frames(),该函数又直接调用tcp_write_xmit()函数来进一步对数据包处理,它包括一下两步:

 

1)检查是否需要对数据包进行分片,条件是只要skb中全部数据长度大于当前路由负荷量就需要分片。

 

2  采用skb_clone(skb,GFP_ATOMIC)TCP_HEAD分配一个sk_buff空间,这里需要注意的是skb_clone分配空间的特点,它首先是依照参数skb来来复制出一个新的sk_buff,新的skb和旧的skb共享数据变量缓存区,但是结构体缓冲区不是共享的,这似乎和copy on write机制有些相似。

 

3  在分配了一个新的skb之后,内核就会执行tcp_transmit_skb().其实内核中是将23步合在一起的,如下:

 

tcp_transmit_skb(sk, skb_clone(skb, GFP_ATOMIC))

 

接下来就是tcp_transmit_skb函数的实现过程了。

 

1  通过skb_push()skb前面加入tcp协议头信息。这包括序列号,源地址,目的地址,校验和等。

 

2  通过tcp_opt结构体(它是在该函数的开始部分从sock结构体中获得的)来访问tcp_func结构体中的.queue_xmit虚拟功能函数,在IPV4中是调用了ip_queue_xmit(),这样就进入了下一层——网络层。

 

 

 

Layer 3:网络层(Network Layer)

 

 

 

ip_queue_xmit()函数中需要做的事情有一下几件:

 

1  是否需要将数据包进行路由,如果需要的话就跳到包路由子程序段。判断是否需要路由是由如下语句执行的:

 

rt = (struct rtable *) skb->dst;

        if (rt != NULL)

                goto packet_routed;

skbdst变量中指明发送目标地址。它存放了路由路径中的下台主机地址。

 

如果是需要对数据包进行路由,那么其执行分如下步骤:

 

1.1)          使用skb_push()skb前面插入一段ip_headsize大小的空间。

 

1.2)          填写ip协议头,包括ttl,protocol

 

1.3)          写入校验和,最后调用NF_HOOK宏,关于NF_HOOK后面介绍。调用的NF_HOOK宏语句如下:

 

    NF_HOOK(PF_INET, NF_IP_LOCAL_OUT, skb, NULL, rt->u.dst.dev,dst_output);

2  如果没有路由地址,内核会尝试从外部可选项中来获取该地址,此时传输层发现没有路由地址会不断地发出重发机制,直到路由地址获取到。当获取到路由地址之后,内核会通过以下语句重新将地址赋给skb->dst.之后就会进入到1)所述的路由子程序段执行。

 

skb->dst = dst_clone(&rt->u.dst);

 

所以这样看来正常情况下内核都会进入1.3)所阐述的NF_HOOK宏的执行。

 

关于NF_HOOK宏,我也不怎么了解,但是查了下内核后可以大体的知道,当二维数组nf_hooks[pf][hook](其下标分别是调用宏中的第一个和第二个参数)中定义了需要的钩子函数时,就会调用nf_hook_slow函数来处理,如果没有定义钩子函数就直接调用NF_HOOK中的最后一个参数所指向的函数,在这里是:dst_output(skb)。在网上搜了下,发现一篇讲解NF_HOOK的帖子,很详细,链接如下:

 

http://www.skynet.org.cn/redirect.php?goto=lastpost&tid=7

 

上面已经谈到,当存在钩子函数时,内核转向nf_hook_slow函数来处理。下面阐述下这个函数:

 

1  检查hook函数是否真的已经设置,如果没有设置就将hook对应位通过移位来设置;当确认已经设置后就取出该钩子函数,如下:

 

elem = &nf_hooks[pf][hook];

2  执行nf_iterate()函数,该函数采用list_for_each_continue_rcu()来搜索HOOK链表中的每个nf_hook_ops钩子结构体,通过其内部变量priority来判断它的优先级是否大于系统所定义的INT_MIN,如果小于就继续搜索,否则就执行该结构体单元中所指向的钩子函数。

 

if (hook_thresh > elem->priority)

                        continue;

 

                /* Optimization: we don't need to hold module

 

                   reference here, since function can't sleep. --RR */

                switch (elem->hook(hook, skb, indev, outdev, okfn)) {

。。。

}

当钩子函数成功执行之后,它会返回一个NF_ACCEPT标志,

 

3  判断nf_iterate()函数的返回标志,如下:

 

switch (verdict) {

        case NF_ACCEPT:

                ret = okfn(skb);

                break;

 

        case NF_DROP:

                kfree_skb(skb);

                ret = -EPERM;

                break;

        }

由上面的代码可以看到,当标志是NF_ACCEPT时,内核会继续调用okfn(skb)函数,也就是传递给NF_HOOK的最后一个参数dst_output(skb)。该函数非常简单,就是间接启用和skb相关的output函数,如下:

 

for (;;) {

                err = skb->dst->output(skb);

 

                if (likely(err == 0))

                        return err;

                if (unlikely(err != NET_XMIT_BYPASS))

                        return err;

        }

内核这句skb->dst->output(skb),就将skb打入到了下面的一层---数据链路层.

 

 

 

Layer 2:数据链路层(Data Link Layer)

 

 

 

上层的output函数最终会触发链路层中的dev_queue_xmit(skb)函数。在该函数中需要做的事情如下:

 

1  对传输过来的skb包进行检查,主要是:

 

1.1       数据包有分散的数据片段(skb_info(skb)->nr_frags>0),但是接口不能传输这样的数据包片段(dev->features中没有设置NETIF_F_FRAGLIST),这个时候内核就会执行数据包线性化函数__skb_linearize(skb, GFP_ATOMIC),简单来说该函数就是将skb中的数据片段存储到由内核所创建的一个缓冲区中,并释放掉原来的skb数据区,将skb指向新分配的数据缓冲区。

 

1.2       和上面的条件很相似,不过还添加了一个判断条件,那就是设备是否在高内存缓冲区,并且设备又不支持DMA对数据的存取,此时也需要将数据包线性化。

 

2  如果包没有实现IP校验,就需要再次对数据包检验。

 

3  启用qdisc_run(dev),该函数检查网卡是否可以接收数据,如果不可以就重新检查直到可以发送为止,如果可以就调用qdisc_restart()来具体实现。qdisc_restart()的实现如下:

 

3.1)检查dev中数据包队列是否为空,如果不为空就试图获取驱动程序的使用权限,当网卡可以接收数据包时就调用dev->hard_start_xmit(skb, dev)来执行驱动程序的数据包发送函数。

 

3.2)如果没有获取到驱动程序的使用权限,这中情况一般是在调用hard_start_xmit(skb,dev)时出现了暂时的配置错误。这时可以检查下驱动程序在被什么使用,如果是死循环的话,将数据包丢弃!

 

3.3)执行netif_schedule(dev),在该函数之后的情况我就不再多说了,有一个网友写的很精彩,链接如下:

 

http://linux.chinaunix.net/bbs/viewthread.php?tid=886985&extra=

 

至此,各协议层的数据包发送过程就算是全部完成了,接下来就进入到驱动程序的详细介绍。

 

 

 

网卡底层驱动开发

 

 

 

1)驱动模块的加载module_init(fn)

 

在驱动的开发之中,大家都知道是从module_init(fn)开始的,该内核宏允许你添加自定义的初始化函数。这里稍微扯远点,看下module_init是如何在内核中实现的,展开如下:

 

#define module_init(x)        __initcall(x);

 

#define __initcall(fn) device_initcall(fn)

#define device_initcall(fn)                __define_initcall("6",fn)

#define __define_initcall(level, fn) \

static initcall_t __initcall_##fn __attribute_used__ \

__attribute__((__section__(".initcall" level ".init"))) = fn

 

虽然很长,其实就是做了一件事情,说明了系统最终调用的初始化函数为initcall_t __initcall_##fn(##fnfn代替即可),在内核启动的过程中do_initcalls函数会调用该初始化函数。当然在以上宏定义中还给出一些关于初始化函数的其它信息:

 

__attribute_used__ \

__attribute__((__section__(".initcall"

 

这里的__attribute__used__section__都是GNU编译器的保留字。

 

__attribute__:表示属性,也就是赋予它所修饰的变量或函数后面指定的属性;

used:表示该变量或函数代码的执行过程中会被用到;

__section__(".initcall":指将其所修饰的变量或函数编译进. initcall段。

 

那么initcall段的地址在什么地方呢?它包括在__initcall_start__initcall_end区间里,在arch/i386/kernel/vmlinux.lds.S中找到可以找到该变量。

 

好了,言归正传。接下来内核将要调用初始化函数fn了。

 

2)      初始化函数fn,在该函数中需要做的事情如下:

 

2.1)为对应的网络设备(例如:ether,Wlan)分配net_device结构体(alloc_netdev或是alloc_etherdev),关于这两个函数其实很相似,后者也是直接调用了alloc_netdev最终实现。不同之处在于:后者使调用alloc_netdev时使用了内核所提供的初始化函数ether_setup;前者使用的是程序员自定义的函数xxx_setup。关于alloc_netdev的实现代码如下:

 

alloc_size = sizeof (*dev) + sizeof_priv + 31;

 

        dev = (struct net_device *) kmalloc (alloc_size, GFP_KERNEL);

        if (dev == NULL)

        {

                printk(KERN_ERR "alloc_dev: Unable to allocate device memory.\n");

                return NULL;

        }

 

        memset(dev, 0, alloc_size);

 

        if (sizeof_priv)

                dev->priv = (void *) (((long)(dev + 1) + 31) & ~31);

 

        setup(dev);

        strcpy(dev->name, mask);

从代码可以看出alloc_netdev函数间接调用了上层函数所提供的setup函数来初始化dev结构体。到此大家都知道了初始化函数中包括的是打开,关闭设备,传输函数等各个主要变量的初始化。

 

2.2)通过register_netdev(dev)来注册一个已经初始化好了的net_device设备。其实注册设备就是将dev链接到内核中的netdev的链表之中。

 

  当使用ifconfig来为一个网络设备配置地址时,内核ioctl函数就会设置dev->flag中的IFF_UP标志以打开接口,当IFF_UP设置之后,内核就将调用open函数。

 

3)接口的打开

 

接口打开函数中需要做的工作如下:

 

3.1)设置MAC地址,一般来说接口是不支持硬件地址改变的,所以就没有必要自定义MAC地址设置函数,而只需要采用默认设置。默认设置是在eth_setup()函数中赋予的,就是将dev->set_mac_address设置为eth_mac_add(),该函数首先会判断接口是否在工作,只有不在工作时才会启用设置命令memcpy(),如下所示:

 

struct sockaddr *addr=p;

        if (netif_running(dev))

                return -EBUSY;

        memcpy(dev->dev_addr, addr->sa_data,dev->addr_len);

3.2)必要时使用端口申请request_region().为什么说是必要时呢?因为端口申请的目的就是使得进程能够独享IO端口访问权限,不至于出现资源争用。但是当你能确定IO端口只是被单个进程使用时,就可以省去该步骤。但是为了程序的健壮性考虑,还是加上这个函数为妙。下面简单介绍下request_region(),他的调用格式如下:

 

requset_region(start, size, name)

 

调用该函数时时确定start---start+size地址空间是否可以使用,当确认可以使用之后就调用端口读写函数来对端口进行访问。一些端口读写函数如下:

 

void insb(unsigned port, void *addr, unsigned long count);

 

void outsb(unsigned port, void *addr, unsigned long count);

 

读或写从内存地址addr 开始的count 字节. 数据读自或者写入单个 port 端口.

 

void insw(unsigned port, void *addr, unsigned long count);

 

void outsw(unsigned port, void *addr, unsigned long count);

 

读或写 16-位值到一个单个16- 端口.

 

void insl(unsigned port, void *addr, unsigned long count);

 

void outsl(unsigned port, void *addr, unsigned long count);

 

读或写 32-位值到一个单个32- 端口.

 

还补充一点:start的值是在驱动程序中赋予的,一般驱动程序都会定义一个io[n],接着通过MODULE_PARM()将其输出,这样在用户在通过insmod加载模块时可以将该参数传递进来。

 

3.3)中断申请

 

中断申请使用的时request_irq()函数。该函数的调用形式如下:

 

int request_irq(unsigned int irq, void (*handler)(int irq, void *dev_id, struct pt_regs *regs )

 

irq是要申请的硬件中断号。在Intel平台,范围0--15

 

handler是向系统登记的中断处理函数。这是一个回调函数,中断发生时,系统调用这个函数,传入的参数包括硬件中断号,device id,寄存器值。

 

dev_id就是下面的request_irq时传递给系统的参数dev_id

 

irqflags是中断处理的一些属性。比较重要的有SA_INTERRUPT

标明中断处理程序是快速处理程序(设置SA_INTERRUPT)还是慢速处理程序(不设置SA_INTERRUPT)。快速处理程序被调用时屏蔽所有中断。慢速处理程序不屏蔽。还有一个SA_SHIRQ属性,设置了以后运行多个设备共享中断。dev_id在中断共享时会用到。一般设置这个设备为device结构本身或者NULL。中断处理程序可以用dev_id找到相应的控制这个中断的设备,或者用irq2dev_map找到中断对应的设备。

 

在该函数的具体实现中就是先用kmalloc()来分配一个struct irqaction,用所传递过来的参数填充该结构体中的相应值,最后将该结构体压入系统中断队列之中。

 

 

 

   4  数据的发送

 

讲到数据的发送,我们就不得不稍微再回到前面一点:关于IO端口的分配。

 

IO端口分配成功之后,就需要使用该IO端口来访问设备存储空间,提前为数据包的发送来建立缓冲区。那么这个缓冲区的建立过程又是怎样的呢?

 

首先通过inw/outw(当然这要看你分配的IO端口是多少位的了),来分别向网卡控制寄存器读出状态/写入命令。当控制器命令成功写入之后,就需要做一个简单的检查,从寄存器读取状态值来判断缓冲区空间是否已经分配好了。好了,当缓冲区分配好了之后,我们就进入正式的数据发送的讨论。

 

   4.1)在驱动程序的开始就定义一个描述你所使用卡的结构体,之后就使用dev->priv来初始化该数据结构,之后的传输过程就主要靠你自定义的这个数据结构来完成了。

 

  4.2)调用正式的数据发送函数

 

     4.2.1)该函数首先对数据包头类型进行区分,并赋予相应的数据包头的大小。

 

     4.2.2)查询上面所阐述的数据缓冲区是否空闲,如果空闲就将数据打入到网卡所分配的数据缓冲中,这个打入过程是通过outw/outsb来完成的(即由内存向某以端口地址写入size大小的数据量)。

 

     4.2.3)设置硬件发送命令,将该命令写入到网卡数据发送控制寄存器中,启动网卡发送。

 

     4.2.4)判断数据是否发送成功。

 

这样数据包在链路层和物理层的发送工作就算是全部完成了!

 

相关文章
|
7月前
|
存储 Python
Python网络编程基础(Socket编程) UDP 发送和接收数据
【4月更文挑战第10天】对于UDP客户端而言,发送数据是一个相对简单的过程。首先,你需要构建一个要发送的数据报,这通常是一个字节串(bytes)。然后,你可以调用socket对象的`sendto`方法,将数据报发送到指定的服务器地址和端口。
|
Python
143 python网络编程 - UDP发送、接收数据
143 python网络编程 - UDP发送、接收数据
82 0
|
3月前
|
网络协议 网络虚拟化
接收网络包的过程——从硬件网卡解析到IP
【9月更文挑战第18天】这段内容详细描述了网络包接收过程中机制。当网络包触发中断后,内核处理完这批网络包,会进入主动轮询模式,持续处理后续到来的包,直至处理间隙返回其他任务,从而减少中断次数,提高处理效率。此机制涉及网卡驱动初始化时注册轮询函数,通过软中断触发后续处理,并逐步深入内核网络协议栈,最终到达TCP层。整个接收流程分为多个层次,包括DMA技术存入Ring Buffer、中断通知CPU、软中断处理、以及进入内核网络协议栈等多个步骤。
|
4月前
|
Java
java网络编程 UDP通信协议实现局域网内文件的发送和接收
java网络编程 UDP通信协议实现局域网内文件的发送和接收
java网络编程 UDP通信协议实现局域网内文件的发送和接收
|
5月前
|
缓存 安全 Web App开发
Chrome插件实现问题之网络进程接收到URL请求后会如何解决
Chrome插件实现问题之网络进程接收到URL请求后会如何解决
|
4月前
|
网络协议 网络虚拟化
解析接收网络包的过程
【8月更文挑战第6天】IP层->TCP层->Socket层
|
7月前
|
网络协议
网络编程-UDP协议(发送数据和接收数据)
网络编程-UDP协议(发送数据和接收数据)
|
7月前
|
设计模式 监控 网络协议
socket通信处于网络协议那一层和两种接收发送消息方式
socket通信处于网络协议那一层和两种接收发送消息方式
91 2
|
6月前
用UDP协议实现发送接收的网络聊天室
用UDP协议实现发送接收的网络聊天室
46 0
|
存储 监控 Cloud Native
剖析Linux网络包接收过程:掌握数据如何被捕获和分发的全过程(上)
剖析Linux网络包接收过程:掌握数据如何被捕获和分发的全过程