分享

基于TCP协议的网络程序

 心不留意外尘 2016-10-26

http://lib.csdn.net/article/computernetworks/47364


1、socket编程

      TCP协议作为传输层的主要协议,不仅可以支持本地的数据通信,还可以支持跨网络的进程间通信。在互联网中,我们可以通过“IP地址+端口号”标识唯一的一个进程,“IP地址+端口号”被称为socket,这就是网络socket编程在TCP协议中建立连接的两个进程各有个socket来标识,那么这两个socket组成的socket pair就唯一标识一个连接。socket本身是“插座”的意思,因此用来描述网络连接的一对一关系。TCP/IP协议最早在BSD UNIX上实现,为TCP/IP协议设计的应用层编程接口称为socket API。这里在socket API中主要介绍TCP协议的函数接口。

       我们知道,内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分。网络数据流同样有大端小端之分,那么如何定义网络数据流的地址呢?发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存,因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址。

       TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节。例如UDP段格式, 16位的源端口号1000(0x3e8),则先发0x03,再发0xe8,这16位在发送主机的缓冲区中也应该是低地址存0x03,高地址 存0xe8。但是,如果发送主机是小端字节序的,这16位仍被解释成0xe803,而不是1000。因此,发送主机把1000填到发送缓冲区之前需要做字节序的转换。同样地,接收主机如果是小端字节序的, 接到16位的源端口号也要做字节序的转换。如果主机是大端字节序的,发送和接收都不需要做转换。同理,32位的IP地址也要考虑网络字节序和主机字节序的问题。

        为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。


        这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。例如htonl表示将32位的长整数从主机字节序转换为网络字节序。

2、socket地址的数据类型及相关函数

        socket API是一层抽象的网络编程接口,适用于各种底层网络协议,然而,各种网络协议的地址格式并不相同,如下图所示:


      各种socket地址结构体的开头都是相同的,前16位表示整个结构体的长度,后16位表示地址类型。

      IPv4、IPv6和UNIX Domain Socket的地址类型分别定义为常数AF_INET、AF_INET6、AF_UNIX。这样,只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容。因此,socket API可以接受各种类型的sockaddr结构体指针做参数,例如bind、accept、connect等函数,这些函数的参数应该设计成void *类型以便接受各种类型的指针,但是sock API的实现早于ANSI C标准化,那时还没有void *类型,因此这些函数的参数都用struct sockaddr *类型表示,在传递参数之前要强制类型转换一下。例如:


其中inet_pton和inet_ntop不仅可以转换IPv4的in_addr,还可以转换IPv6的in6_addr,因此函数接口是void *addrptr。


2、基于TCP协议的网络程序

TCP协议通讯流程如下:


       服务器调用socket()、bind()、listen() 完成初始化后,调用accept()阻塞等待,处于监听端口的状态。客户端调用socket()初始化后,调用connect()发出SYN段并阻塞等待服务器应答,服务器应答一个SYN-ACK段,客户端收到后从connect()返回,同时应答一个ACK段,服务器收到后从accept()返回。
       数据传输的过程:建立连接后,TCP协议提供全双工的通信服务,但是一般的客户端/服务器程序的流程是由客户端主动发起请求,服务器被动处理请求,一问一答的方式。因此,服务器从accept()返回后立刻调用read(),读socket就像读管道一样,如果没有数据到达就阻塞等待,这时客户端调用write()发送 请求给服务器,服务器收到后从read()返回,对客户端的请求进行处理,在此期间客户端调用read()阻塞等待服务器的应答,服务器调用write()将处理结果发回给客户端,再次调用read()阻塞 等待下一条请求,客户端收到后从read()返回,发送下一条请求,如此循环下去。如果客户端没有更多的请求了,就调用close() 关闭连接,就像写端关闭的管道一样,服务器的read()返回0,这样服务器就知道客户端关闭了连接,也调用close()关闭连接。注意,任何一方调用close() 后,连接的两个传输方向都关闭,不能再发送数据了。如果一方调用shutdown() 则连接处于半关闭状态,仍可接收对方发来的数据。

       在学习socket API时要注意应用程序和TCP协议层是如何交互的:应用程序调用某个socket函数时TCP协议层完成什么动作,比如调用connect()会发出SYN段, 应用程序如何知道TCP协议层的状态变化,比如从某个阻塞的socket函数返回就表明TCP协议收到了某些段,再比如read()返回0就表明收到了FIN段。

下面说说几个函数

socket函数--创建套接字


bind函数--套接字相关信息的绑定


listen函数--监听远端连接请求


       listen函数使用主动连接套接口变为被连接套接口,使得一个进程可以接受其它进程的请求,从而成为一个服务器进程。在TCP服务器编程中listen函数把进程变为一个服务器,并指定相应的套接字变为被动连接。

       参数sockfd被listen函数作用的套接字,sockfd之前由socket函数返回。在被socket函数返回的套接字fd之时,它是一个主动连接的套接字,也就是此时系统假设用户会对这个套接字调用connect函数,期待它主动与其它进程连接,然后在服务器编程中,用户希望这个套接字可以接受外来的连接请求,也就是被动等待用户来连接。由于系统默认时认为一个套接字是主动连接的,所以需要通过某种方式来告诉系统,用户进程通过系统调用listen来完成这件事。
       参数backlog这个参数涉及到一些网络的细节。在进程正理一个一个连接请求的时候,可能还存在其它的连接请求。因为TCP连接是一个过程,所以可能存在一种半连接的状态,有时由于同时尝试连接的用户过多,使得服务器进程无法快速地完成连接请求。如果这个情况出现了,服务器进程希望内核如何处理呢?内核会在自己的进程空间里维护一个队列以跟踪这些完成的连接但服务器进程还没有接手处理或正在进行的连接,这样的一个队列内核不可能让其任意大,所以必须有一个大小的上限。这个backlog告诉内核使用这个数值作为上限。毫无疑问,服务器进程不能随便指定一个数值,内核有一个许可的范围。这个范围是实现相关的。很难有某种统一,一般这个值会小30以内。

       当调用listen之后,服务器进程就可以调用accept来接受一个外来的请求。

accept函数


 处于监听状态的服务器在获得客户机的连接请求后,会将其放置在等待队列中。当系统空闲时,将接受客户机的连接请求,接收客户机的连接请求使用accept函数。accept函数用于面向连接类型的套接字类型(SOCK_STREAM和SOCK_SEQPACKET)。accept函数将从连接请求队列中获得连接信息,创建新的套接字,并返回该套接字的文件描述符。新创建的套接字用于服务器与客户机的通信,而原来的套接字仍然处于监听状态

accept函数的sockfd参数为监听的套接字描述符。addr参数为指向结构体sockaddr的指针。参数addrlen为addr参数指向的内存空间的长度。

返回值:成功返回新的套接字文件描述符;失败返回-1,并会设置全局错误变量 errno。

aconnect函数-----调用connect会激发TCP的三路握手过程。


        connect函数在调用成功返回0;失败的时候返回值-1,并会设置全局错误变量 errno。

        connect函数会产生网络数据的发送,TCP的三次握手也正是在此时开始,connect会先发送一个SYN包给服务端,并从最初始的CLOSED状态进入到SYN_SENT状态,在此状态等待服务端的确认包,通常情况下这个确认包会很快到达,以致于我们根本无法使用netstat命令看到SYN_SENT状态的存在,不过我们可以做一个极端情况的模拟,让客户端去连接一个随意指定服务器(如IP地址为88.88.88.88),因为该服务器很明显不会反馈给我们SYN包的确认包(SYN ACK),客户端就会在一定时间内处于SYN_SENT状态,并在预定的超时时间(比如3分钟)之后从connect函数返回,connect调用一旦失败(没能到达ESTABLISHED状态)这个套接字便不可用,若要再次调用connect函数则必须要重新使用socket函数创建新的套接字。


下面实现简单的客户端/服务器程序

server.c 的作用是接受client的请求,并与client进行简单的数据通信,整体为一个阻塞式的网络聊天工具。

server.c如下:



client.c如下:


运行结果如下:




如果让server先退出会出现这样的问题,那怎么解决呢?

这是因为,虽然server的应用程序终止了,但TCP协议层的连接并没有完全断开,因此不能再次监听同样的server端口。我们用netstat命令查看一下:

            现在用Ctrl-C把client也终止掉,会发现client终止时自动关闭socket描述符,server的TCP连接收到client发的FIN段后处于TIME_WAIT状 态。

         TCP协议规定,主动关闭连接的一方要处于TIME_WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态,因为我们先Ctrl-C终止了server,所以server是主动关闭连接的一方,在TIME_WAIT期间仍然不能再次监听同样的server端口。MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同,在Linux上一般经过半分钟后 就可以再次启动server了。至于为什么要规定TIME_WAIT的时间请读者参考UNP 2.7节。在server的TCP连接没有完全断开之前不允许重新监听是不合理的,因为,TCP连接没有完全断开指的connfd(127.0.0.1:8000)没有完全断开,而我们重新监听的是listenfd(0.0.0.0:8083), 虽然是占用同一个端口,但IP地址不同,connfd 对应的是与某个客户端通讯的一个具体的IP地址, 而listenfd对应的是wildcard address。解决这个问题的方法是使用setsockopt()设置socket描述符的选项SO_REUSEADDR为1,表示允许创建端口号相同但IP地址不同的多个socket描述符。server代码的socket()和bind()调用之间插入如下代码:

端口的作用是对TCP/IP 体系的应用进程进行统一的标志,使运行不同操作系统的计算机的应用进程能够互相通信。

Linux下端口的划分使用是由IANA(Internet Assigned Numbers Authority,因特网已分配数值权威机构)维护的,端口号被划分为3个段。

1、0~1023,熟知端口(公认端口),标记常规的服务进程。这些端口有IANA分配和控制,可能的话,相同端口号就分配给TCP、UDP和SCTP的同一给定服务。如21端口分配给FTP服务,25端口分配给SMTP(简单邮件传输协议)服务,80端口分配给HTTP服务,135端口分配给RPC(远程过程调用)服务等等。

2、1024~49151,登记端口。标记没有熟知端口号的非常规的服务进程,使用这个范围的端口号不受IANA控制,但必须在IANA登记并提供他们的使用情况清单,以防止重复。相同端口号也分配给TCP和UDP的同一给定服务。如6000~6003端口分配给这两种协议的X Window服务器。

3、49152~65535,动态端口/私有端口。IANA不管这些端口,就是我们所说的临时端口,留给客户进程选择暂时使用。当服务器进程收到客户进程的报文时,就知道了客户进程所使用的动态端口号。通信结束后,这个端口号可供其他客户进程以后使用。(49152这个魔数是65536的四分之三)。

       对于端口号一般不固定分配给某个服务,也就是说许多服务都可以使用这些端口。只要运行的程序向系统提出访问网络的申请,那么系统就可以从这些端口号中分配一个供该程序使用。比如1024端口就是分配给第一个向系统发出申请的程序。在关闭程序进程后,就会释放所占用的端口号。 不过,部分端口也常常被病毒木马程序所利用,如冰河默认连接端口是7626、WAY 2.4是8011、Netspy 3.0是7306、YAI病毒是1024等等。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多