第1章
消息技术概论
1.1 TCP/IP简介
1.1.1 基本知识
本书介绍的所有内容都基于TCP/IP协议,因此,这里首先对TCP/IP协议知识作以简单介绍,如果读者想深入学习该协议,可以参考专门介绍它的书籍。
TCP/IP(传输控制协议/网际协议)是互联网中的基本通信语言或协议。它其实是一个两层的程序,分为高层与低层。高层为传输控制协议,负责聚集信息或把文件拆分成更小的包。这些包通过网络传送到接收端的TCP层,接收端的TCP层把包还原为原始文件。低层是网际协议,它处理每个包的地址部分,使这些包正确地到达目的地。网络上的网关计算机根据信息的地址来进行路由选择。即使来自同一文件的分包路由也有可能不同,但最后会在目的地汇合。TCP/IP使用客户端/服务器模式进行通信。
TCP/IP通信是点对点的,即通信是网络中的一台主机与另一台主机之间的。TCP/IP协议组之所以流行,部分原因是因为它可以用在各种各样的信道和底层协议(例如T1和X.25、以太网以及RS-232串行接口)之上。确切地说,TCP/IP是一组包括TCP、IP、UDP(User Datagram Protocol)、ICMP(Internet Control Message Protocol)和其他一些协议的协议组。
许多用户熟悉使用TCP/IP的高层应用协议,包括万维网的超文本传输协议(HTTP)、文件传输协议(FTP)、远程网络访问协议(Telnet)和简单电子邮件传输协议(SMTP),通常都和TCP/IP打包在一起。其他协议是网络主机用来交换路由信息的,包括Internet控制信息协议(ICMP)、内部网关协议(IGP)、外部网关协议(EGP)和边界网关协议(BGP)等。
在架构上,TCP/IP并不完全符合OSI的7层参考模型。传统的开放式系统互连参考模型是一种通信协议的7层抽象的参考模型,其中每一层执行某一特定任务。该模型的目的是使各种硬件在相同的层次上相互通信。这7层是:物理层、数据链路层、网络层、传输层、会话层、表示层和应用层。而TCP/IP通信协议采用了4层的层级结构,每一层都呼叫它的下一层所提供的网络来完成自己的需求。这4层分别为:
· 应用层:应用程序间沟通的层,如简单邮件传输协议(SMTP)、文件传输协议(FTP)、远程网络访问协议(Telnet)等。
· 传输层:在此层中,它提供了结点间的数据传送和应用程序之间的通信服务,主要功能是数据格式化、数据确认和丢失重传等。如传输控制协议(TCP)、用户数据报协议(UDP)等,TCP和UDP给数据包加入传输数据并把它传送到下一层中,这一层负责传送数据,并且确定数据已被送达并接收。
· 互连网络层:负责提供基本的数据封包传送功能,让每一个数据包都能够到达目的主机(但不检查是否被正确接收),如网际协议(IP)。
· 网络接口层(主机-网络层):接收IP数据报并进行传输,从网络上接收物理帧,抽取IP数据报转交给下一层,管理实际的网络媒体,定义如何使用实际网络(如Ethernet、Serial Line等)来传送数据。
在TCP/IP协议组中,和本书相关的主要是传输层协议,即TCP和UDP。
1.1.2 TCP与UDP
传输控制协议(TCP)的目的是提供可靠的数据传输,是面向连接(Connect-Oriented)的协议,旨在参与通信的设备或服务之间必须保持一个虚拟连接。TCP 在数据包接收无序、丢失或在交付期间被破坏时,负责数据恢复。它通过为其发送的每个数据包提供一个序号来完成此恢复。为确保正确地接收数据,TCP要求在目标计算机成功收到数据时发回一个确认(即 ACK)。如果在某个时限内未收到相应的ACK,则重新传送数据包。如果网络拥塞,这种重新传送将导致发送的数据包重复,但是接收计算机可以使用数据包的序号来确认它是否为重复数据包,并在必要时丢弃它。
TCP连接包括三个状态,建立连接、数据传送和终止连接。TCP用三路握手(Three-way Handshake)来建立一个连接,用四路握手(four-way handshake)来终止一个连接。
在建立TCP连接三路握手的过程中,一对终端同时初始化一个它们之间的连接是可能的。但通常是由一端打开一个接口(socket),然后监听来自另一端的连接,这就是通常所指的被动打开(passive open)。服务器端被被动打开以后,用户端就能开始建立主动打开(active open)。
· 客户端通过向服务器端发送一个SYN来建立一个主动打开,作为三路握手的一部分。
· 服务器端应当为一个合法的SYN回送一个SYN/ACK。
· 最后,客户端再发送一个ACK。这样就完成了三路握手,并进入了连接建立状态。
终止TCP连接的四路握手过程,严格来讲,实际上是两个两次握手,首先,A端告知B端它终止从A端到B端的连接,即A端不会再往B端发送数据了(通过向B端发送一个FIN标志),A端的关闭即告完成。此时,我们说A到B的这条TCP连接处于半关闭状态(half-close)。但这时,B端还是可以向A端发送数据的,B端可以在将来的任一时间向A端发送FIN来完成它这端的半关闭。此时,A端的socket可能已经不存在(超时删除),但A主机的TCP/IP协议栈中有一个tcp control socket会代为完成一个ACK动作,完成第二个两次握手,从而彻底断开这条TCP连接。但在实际应用中,通常不会出现一端执行半关闭后,另一端还继续发送数据,过一段时间后才完全关闭情况。通常是一端执行主动关闭后,另一方马上执行被动关闭。
UDP与TCP的主要区别在于,UDP不一定提供可靠的数据传输,它是面向无连接的(connectionless)。事实上,该协议不能保证数据准确无误地到达目的地。一个UDP连接的建立,不必像TCP协议那样需要服务器端侦听,也不需要有客户机端请求连接和服务器端建立连接后双方才能通信。UDP在IP数据报的头部仅仅加入了复用和数据校验(字段)。 UDP 在许多方面非常有效。当某个程序的目标是尽快地传输尽可能多的信息时(其中任意给定数据的重要性相对较低),可使用UDP。ICQ短消息使用UDP发送消息。
许多程序使用单独的TCP连接和单独的UDP连接。一般情况下,TCP用于可靠的大数据量传输,而UDP则常用于数据量较少的数据传输。例如,域名系统中域名地址/IP地址的映射请求和应答(Named)、Ping、BOOTP、TFTP等应用。但这也不是绝对的,在实践中,采用UDP进行可靠大量数据传输的情况也很多。由于TCP传输时要求通信双方建立一个连接,而一般操作系统的连接数是有限的,所以,当开发连接数巨大(如上万)的应用时,也应该考虑使用UDP。如果需要使用UDP传输可靠数据,用户应用程序必须负责解决数据报排序、差错确认、接收确认回复等新问题。
1.1.3 TCP/IP套接口编程基础
这里重点介绍一下TCP/IP的编程基础知识,主要包括几个常用的套接口(socket接口)函数及其用法。socket接口是TCP/IP网络的API,socket接口定义了许多函数或例程,程序员可以用它们来开发TCP/IP网络上的应用程序。
1.Socket建立
为了建立socket,程序可以调用socket函数,该函数返回一个类似于文件描述符的句柄。socket函数原型为:
int socket(int domain,int type,int protocol);
domain指明所使用的协议族,通常为PF_INET,表示互联网协议族(TCP/IP协议族);type参数指定socket的类型:用于TCP的SOCK_STREAM 或用于UDP的SOCK_DGRAM;protocol通常赋值"0"。socket()调用返回一个整型socket描述符,可以在后面的调用使用它。
socket描述符是一个指向内部数据结构的指针,它指向描述符表的入口。调用socket函数时,socket执行体将建立一个socket,实际上“建立一个socket”意味着为一个socket数据结构分配存储空间。socket执行体用于管理描述符表。
socket数据结构中包含两个网络程序之间的一个网络连接的五种信息:通信协议、本地协议地址、本地主机端口、远端主机地址和远端协议端口。
2.Socket配置
通过socket调用返回一个socket描述符后,在使用socket进行网络传输之前,必须配置该socket。面向连接的socket客户端通过调用connect函数在socket数据结构中保存本地和远端信息。无连接的socket客户端和服务端以及面向连接的socket服务端通过调用捆绑(bind)函数来配置本地信息。
bind函数将socket与本机上的一个端口相关联,随后就可以在该端口监听服务请求。bind函数原型为:
int bind(int sockfd,struct sockaddr *my_addr, int addrlen);
sockfd是调用socket函数返回的socket描述符;my_addr是一个指向包含有本机IP地址及端口号等信息的sockaddr类型的指针;addrlen常被设置为sizeof(struct sockaddr)。
struct sockaddr结构类型是用来保存socket信息的:
struct sockaddr { unsigned short sa_family; /* 地址族, AF_xxx */ char sa_data[14]; /* 14 字节的协议地址 */ };
sa_family一般为AF_INET,代表Internet(TCP/IP)地址族;sa_data则包含该socket的IP地址和端口号。
另外还有一种结构类型:
struct sockaddr_in { short int sin_family; /* 地址族 */ unsigned short int sin_port; /* 端口号 */ struct in_addr sin_addr; /* IP地址 */ unsigned char sin_zero[8]; /* 填充0 以保持与struct sockaddr同样大小 */ };
这个结构更方便使用。sin_zero用来将sockaddr_in结构填充到与struct sockaddr同样的长度,可以用bzero()或memset()函数将其置为零。指向sockaddr_in 的指针和指向sockaddr的指针可以相互转换,这意味着如果一个函数所需参数类型是sockaddr时,可以在函数调用的时候将一个指向sockaddr_in的指针转换为指向sockaddr的指针;或者相反。
使用bind函数时,可以用下面的赋值实现自动获得本机IP地址和随机获取一个没有被占用的端口号:
my_addr.sin_port = 0; /* 系统随机选择一个未被使用的端口号 */ my_a ddr.sin_addr.s_addr = INADDR_ANY; /* 填入本机IP地址 */
通过将my_addr.sin_port置为0,函数会自动选择一个未占用的端口来使用。同样,通过将my_addr.sin_addr.s_addr置为INADDR_ANY,系统会自动填入本机IP地址。
注意,在使用bind函数时需要将sin_port和sin_addr转换为网络字节优先顺序;而sin_addr则不需要转换。
计算机数据存储有两种字节优先顺序:高位字节优先和低位字节优先。Internet上数据以高位字节优先顺序在网络上传输,所以对于在内部是以低位字节优先方式存储数据的机器,在Internet上传输数据时就需要进行转换,否则就会出现数据不一致。
下面是几个字节顺序转换函数:
· htonl():将32位值从主机字节序转换成网络字节序。
· htons():将16位值从主机字节序转换成网络字节序。
· ntohl():将32位值从网络字节序转换成主机字节序。
· ntohs():将16位值从网络字节序转换成主机字节序。
bind()函数在成功被调用时返回0,出现错误时返回"-1"并将errno置为相应的错误号。需要注意的是,在调用bind函数时一般不要将端口号置为小于1024的值,因为1~1024是保留端口号,可以选择大于1024中的任何一个没有被占用的端口号。
3.TCP连接建立
面向连接的客户程序使用连接(connect)函数来配置socket并与远端服务器建立一个TCP连接,其函数原型为:
int connect(int sockfd, struct sockaddr *serv_addr,int addrlen);
sockfd是socket函数返回的socket描述符;serv_addr是包含远端主机IP地址和端口号的指针;addrlen是远端地址结构的长度。connect函数在出现错误时返回-1,并且设置errno为相应的错误码。进行客户端程序设计无须调用bind(),因为这种情况下只需要知道目的机器的IP地址即可,而客户通过哪个端口与服务器建立连接并不需要关心,socket执行体程序自动选择一个未被占用的端口,并通知程序数据什么时候到达端口。
connect函数启动和远端主机的直接连接。只有面向连接的客户程序使用socket时才需要将此socket与远端主机相连。无连接协议从不建立直接连接,面向连接的服务器也从不启动一个连接,它只是被动地在协议端口监听客户的请求。
网络监听(listen)函数使socket处于被动的监听模式,并为该socket建立一个输入数据队列,将到达的服务请求保存在此队列中,直到程序处理它们。
int listen(int sockfd, int backlog);
sockfd是Socket系统调用返回的socket 描述符;backlog指定在请求队列中允许的最大请求数,进入的连接请求将在队列中等待接收函数(accept())(参考下文)。backlog对队列中等待服务的请求的数目进行了限制,通常系统默认值为20。如果一个服务请求到来时,输入队列已满,该socket将拒绝连接请求,客户将收到一个出错信息。
当出现错误时listen函数返回-1,并设置相应的errno错误码。
accept()函数让服务器接收客户的连接请求。在建立好输入队列后,服务器就调用accept函数,然后睡眠并等待客户的连接请求。
int accept(int sockfd, void *addr, int *addrlen);
sockfd是被监听的socket描述符,addr通常是一个指向sockaddr_in变量的指针,该变量用来存放提出连接请求服务的主机的信息(某台主机从某个端口发出该请求);addrlen通常为一个指向值为sizeof(struct sockaddr_in)的整型指针变量。出现错误时accept函数返回-1并设置相应的errno错误码。
首先,当accept函数监视的socket收到连接请求时,socket执行体将建立一个新的socket,执行体将这个新socket和请求连接进程的地址联系起来,收到服务请求的初始socket仍可以继续在以前的socket上监听,同时可以在新的socket描述符上进行数据传输操作。
4.数据传输
send()和recv()这两个函数用于在面向连接的socket上进行数据传输。
send()函数原型为:
int send(int sockfd, const void *msg, int len, int flags);
sockfd是想要用来传输数据的socket描述符;msg是一个指向要发送数据的指针;len是以字节为单位的数据的长度;flags一般情况下置为0(关于该参数的用法可参照man手册)。
send()函数返回实际上发送出的字节数,可能会少于希望发送的数据。在程序中应该将send()的返回值与欲发送的字节数进行比较,当send()返回值与len不匹配时,需要对这种情况进行处理。
char *msg = "Hello!"; int len, bytes_sent; …… len = strlen(msg); bytes_sent = send(sockfd, msg,len,0); ……
recv()函数原型为:
int recv(int sockfd, void *buf, int len, unsigned int flags);
sockfd是接收数据的socket描述符;buf 是存放接收数据的缓冲区;len是缓冲的长度,flags被置为0。recv()返回实际上接收的字节数,当出现错误时,返回-1并设置相应的errno错误码。
sendto()和recvfrom()用于在无连接的数据报socket方式下进行数据传输。由于本地socket并没有与远端机器建立连接,所以在发送数据时应指明目的地址。
sendto()函数原型为:
int sendto(int sockfd, const void *msg,int len,unsigned int flags,const struct sockaddr *to, int tolen);
该函数比send()函数多了两个参数,to表示目的机的IP地址和端口号信息,而tolen常常被赋值为sizeof (struct sockaddr)。sendto 函数返回实际发送的数据字节长度或在出现发送错误时返回-1。
recvfrom()函数原型为:
int recvfrom(int sockfd,void *buf,int len,unsigned int flags,struct sockaddr *from,int *fromlen);
from是一个struct sockaddr类型的变量,该变量保存源机的IP地址及端口号。fromlen常置为sizeof (struct sockaddr),当recvfrom()返回时,fromlen包含实际存入from中的数据字节数。recvfrom()函数返回接收到的字节数或当出现错误时返回-1,并设置相应的errno错误码。
如果对数据报socket调用了connect()函数,则也可以利用send()和recv()进行数据传输,但该socket仍然是数据报socket,并且利用传输层的UDP服务。但在发送或接收数据报时,内核会自动为之加上目的地和源地址信息。
5.结束传输
当所有的数据操作结束以后,可以调用close()函数来释放该socket,从而停止在该socket上的任何数据操作:
clos e(sockfd);
也可以调用shutdown()函数来关闭该socket。该函数允许你只停止某个方向上的数据传输,而另一个方向上的数据传输继续进行。如可以关闭某socket的写操作而允许继续在该socket上接收数据,直至读入所有数据。
int shutdown(int sockfd,int how);
sockfd是需要关闭的socket的描述符。参数 how允许为shutdown操作选择以下几种方式:
· 0——不允许继续接收数据
· 1——不允许继续发送数据
· 2——不允许继续发送和接收数据
· 均为允许则调用close ()
shutdown在操作成功时返回0,在出现错误时返回-1并设置相应errno错误码。
一般介绍socket编程的资料都只重点介绍以上函数,但还有下面几个函数应用也非常广泛,并且在后文介绍消息机制时也要用到,所以必须熟练掌握,其中以select函数尤为关键。
6.fcntl
该函数可以改变已打开的文件的性质,指file control,包括已经打开的套接口socket。
int fcntl(int fields, int cmd, .../* int arg */); //若 成功则依赖于cmd,若出错则返回-1。
第三个参数总是一个整数,与上面所示函数原型中的注释部分相对应。但是在作为记录锁用时,第三个参数则是指向一个结构的指针。
fcntl函数有很多功能,包括复制描述字,设置记录锁,设计I/O类型等,这里我们只关心其在cmd=F_GETFL或F_SETFL能获取或设置socket为非阻塞I/O(O_NONBLOCK)或信号驱动I/O(O_ASYNC)。
7.getsockopt与setsockopt
这两个函数可以获取或者设置与某个套接字关联的选项。为了操作套接字层的选项,应该将层的值指定为SOL_SOCKET。为了操作其他层的选项,控制选项的合适协议号必须给出。例如,为了表示一个选项是由TCP解析,层应该设定为协议号TCP。
用法:
int getsockopt(int sock, int level, int optname, void *optval, socklen_t *optlen); int setsockopt(int sock, int level, int optname, const void *optval, socklen_t optlen);
参数:
sock:将要被设置或者获取选项的套接字。
level:选项所在的协议层,如TCP、IPv4或IPv6。
optname:需要访问的选项名。
optval:对于getsockopt(),指向返回选项值的缓冲;对于setsockopt(),指向包含新选项值的缓冲。
optlen:对于getsockopt(),作为入口参数时,表示选项值的最大长度;作为出口参数时,表示选项值的实际长度。对于setsockopt(),表示当前选项的长度。
8.select与I/O复用
I/O复用可以使程序阻塞于select函数,而不是真正的I/O系统调用。select的主要用途是:
int select(int n, fd_set * readfds, fd_set * writefds, fd_set * exceptfds, struct timeval * timeout);
参数n代表最大的文件描述词加1,参数readfds、writefds 和exceptfds称为描述词组,是用来回传该描述词的读、写或例外的状况。底下的宏提供了处理这三种描述词组的方式:
FD_CLR(int fd,fd_set* set);用来清除描述词组set中相关fd 的位。
FD_ISSET(int fd,fd_set *set);用来测试描述词组set中相关fd 的位是否为真。
FD_SET(int fd,fd_set *set);用来设置描述词组set中相关fd的位。
FD_ZERO(fd_set *set);用来清除描述词组set的全部位。
参数 timeout为结构timeval用来设置select()的等待时间,其结构定义如下:
struct timeval { time_t tv_sec; time_t tv_usec; };
根据timeout参数设置的不同,select()的等待时间有以下三种可能。
· timeout设为NULL:在有至少一个描述字准备好之前永远等待下去。
· timeout结构中设定了相应的秒数和微秒数,则等待设定的固定时间。
· timeout结构中相应的秒数和微秒数设定为0,则不等待,立即返回。
9.poll()函数
多连接I/O复用还有一个poll()函数可用,其定义如下:
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
其中fds是一个struct pollfd结构类型的数组,用于存放需要检测其状态的socket描述字。struct pollfd的定义如下:
struct pollfd{ int fd; //descriptor to check short events; //events of interest on fd short revents; //events that occurred on fd };
要测试的条件由events成员指定,而返回的结果则在revents中存储。常用条件及含意说明如下。
POLLIN:普通或优先级带数据可读;
POLLRDNORM:普通数据可读;
POLLRDBAND:优先级带数据可读;
POLLPRI:高优先级数据可读;
POLLOUT:普通数据可写;
POLLWRNORM:普通数据可写;
POLLWRBAND:优先级带数据可写;
POLLERR:发生错误;
POLLHUP:发生挂起;
POLLNVAL:描述字不是一个打开的文件。
第二个参数nfds用来指定数组fds的长度。最后一个参数timeout是poll函数调用阻塞的时间,单位是毫秒。
每次调用这个函数之后,系统不会清空fds数组,操作起来比较方便,特别是在socket连接比较多的情况下,在一定程度上可以提高处理的效率。这一点与select()函数不同,调用select()函数之后,select()函数会清空它所检测的socket描述符集合,导致每次调用select() 之前都必须把socket描述符重新加入到待检测的集合中。因此,select()函数适合于只检测一个socket描述符的情况,而poll()函数适合于检测大量socket描述符的情况。