感谢国家,终于完成了UDP客户机服务器回显程序。总结下经验,详细讨论下UDP模式下的一些问题。
都知道的是UDP是一个不可靠的,无连接的协议。以上两个特征主要体现在它没有像TCP一样的建立连接的过程,从逻辑上讲TCP的三次握手,通告了双方自己的序列号,实际上也是告诉双方我要跟你进行可靠的数据传输,请你做好准备。而UDP呢,刚好相反,也就是发数据的时候招呼都不打,从代码上讲,就是一旦指定好了通信的目的参数,马上用sendto发出,不像TCP还得connect那样麻烦。
本文需要从代码方面讲述UDP的一些细节。比如sendto这个函数,上面我们已经知道了需要指定通信的目的参数,这个函数的最后两个参数指定了他们。目的套接子的各种地址,以及长度,都说的非常清楚,都需要描述清楚。这个跟connect有点相像。做任何事情想要做好,必须了解它的细节,深刻理解UDP的API,这是非常重要的。
UDP里让人感到头痛的就是recvfrom,因为一旦使用了这个函数,你无法知道,什么时候进程能够结束。因为我们无法知道对方什么时候会sendto内容过来,所以在这之前,recvfrom会已知阻塞着。recvfrom函数也没啥好说的,需要注意的是,最后两个参数。 他们描述了从哪个套接子地址接收到了数据,但是其实这两个参数可以为空的,也就是都是NULL。
recvfrom使用的时候必须bind一个端口号,表示该端口处在活动状态。下面的例子里,如果不让客户机运行,而只运行服务器,那么recvfrom讲得不到任何内容。
然后就是我们都知道的事实了,如果端口没有开启,UDP会发挥一个端口可达的ICMP消息。这里需要明确的是怎么干,算是UDP的端口开启了?从上面的讨论来看,似乎在本地bind就算是开启了这个端口。因为recvfrom无法指定从那个端口甚至从哪个IP收到数据。这里看来也得做个实验,如果我指定recvfrom的最后两个参数为固定值,看看结果又会是什么。然后就直接贴代码吧,当然只是核心部分的。
int len=sizeof(servaddr);
printf("%d",getpid());
while(fgets(mesg,MAXLINE,stdin)!=NULL)
{
sendto(sockfd,mesg,strlen(mesg),0,(struct sockaddr*)&servaddr,len);
n=recvfrom(sockfd,recv,MAXLINE,0,NULL,NULL);
recv[n]=0;
fputs(recv,stdout);
}
很清楚了,从终端输入一串字符,然后用sendto发出去,用recvfrom接收回复,最后fputs到屏幕上。在看看另外一端的:
int len=sizeof(cliaddr);
bind(sockfd,(struct sockaddr*)&servaddr,sizeof(servaddr));
for(;;)
{
n=recvfrom(sockfd,recv,MAXLINE,0,(struct sockaddr*)&cliaddr,&len);
recv[n]=0;
sendto(sockfd,recv,n,0,(struct sockaddr*)&cliaddr,len);
}
这里的解释,先在本地bind一个端口,用recvfrom接收数据,最后再把收到的原模原样返回过去。recvfrom函数的最后两个参数,返回时其中套接字地址结构的内容告诉我们是谁发送了数据包。而sendto函数的最后两个参数则是指定了通信目的地,套机字地址结构的内容已经被我们填好了。
那么总结一下,sendto调用时,必须指定服务器的IP地址跟端口号,而客户的IP跟端口,我们可以使用bind进行指定。UNP中,客户的临时端口是在第一次调用sendto的时候临时选定的不能更改的,而IP地址却可以随着客户发给每个服务器变动。因为客户机是多宿的,可以理解有多个网络接口。
下面来讨论两个问题,第一个问题是关于ICMP的那个大家都知道的那个错误。我们来看看如果不bind,那么会返回端口不可达的错误消息。首先去掉recvfrom前面的bind,下面看看会出现什么情况。同时开启 两个进程,然后在wireshark中抓包查看。
然后ICMP的端口不可达差错传说中会至少返回IP首部后面的前八个字节,那么其中的四个字节刚好是UDP的源端口跟目的端口,这样返回的差错能够告诉系统足够的信息,以让接受到ICMP消息的系统能根据远端口跟目的端口把差错报文与某个特定用户的进程相关联起来。然后我们看看这个ICMP报文。
最下面的右边有关于这些字符的ASCII码,可以看到我发的是hello,总共五个字节,而最终接到的数据是六个字节,那是因为回车键,回车的ascii码刚好是十,十六进制里面就是0a。我们知道一个char是一个字节,这样‘\n’这个字符在内存中的表示形式,用十六禁止表示出来就是0a。而0a的ASCII码解释出来是不可显示的字符(回车键),所以只能用“.”来代替了。
另外多数一句,从做实验的结果来看ubuntu返回的ICMP端口不可达消息返回了IP首部后面的最多九个字节(除过UDP的源目端口号),这也是刚刚做实验的结果,在终端里面输入一大串字符的时候,那么只会返回前九个字符(因为一个字符是一个字节)。有图为证:
可以看到我发的是hellowindows,而差错回显的字符只有hellowind九个字符。关于返回几个字节的问题,发现stevens的书里用小字谈到了这个问题,实在是大神啊。貌似这个结论错了,UDP居然会自动截断我的数据包。
而第二个问题是recvfrom有关系。在刚才的模型中,我们知道如果端口没有开启,会一直显示端口不可达的错误消息。但是刚才实验的时候,除了打第一串字符,回车以后收到了错误,键入新的东西以后会回车就不起作用了,这是为什么呢?难道说存在这种情况,那就是主机接到一个ICMP的端口不可达错误以后,那么对于随后到来的给自己改端口的UDP数据包会自动丢弃?也就是说,主机进程收到这个消息以后,已经发现了这个端口没有开启,所以如果再有数据到来是自动忽略?
stevens的书上讲到:按说接到ICMP错误消息的系统会自动忽略该消息,也就是说,如果你坚持一直发送,那么应该会持续接到ICMP的端口不可达才对。那是为什么呢?刚看了代码才发现是recvfrom惹得祸。
while(fgets(mesg,MAXLINE,stdin)!=NULL)
{
sendto(sockfd,mesg,strlen(mesg),0,(struct sockaddr*)&servaddr,len);
n=recvfrom(sockfd,recv,MAXLINE,0,NULL,NULL);
recv[n]=0;
fputs(recv,stdout);
}
就是因为recvfrom阻塞的进程,所以,当我们键入第二串字符的时候,实际进程还卡在第一个循环,卡在recvform,原因就是recvfrom一直没有返回东西,就一直卡在那里。这里必须的注意.
________________________________________________________________________________________________________
而刚刚翻了UNP,发现上面对此也有交代。从书中不禁感叹,需要掌握网络编程,必须深刻准确的理解每个API的表达达含义,当然这是从内核的角度讲,不然当你调用connect的时候,你却不知道系统何时发了syn,已经sendto成功了,却不能接收。
UNP的服务器紧凑未运行一节,讨论了这个问题,那就是如果客户机不运行,我们的程序将无法实现回显,因为recvfrom在等待一个永远不出现的应答,那么自然的会得到一个端口不可打的错误,有趣的是书上也谈到了如何面对这个错误。
一个基本原则是:对于一个UDP套接子,由它引起的错误却并不返回给它,除非它已经链接。
而地49页提到,对于一个UDP套接字的write调用成功,仅仅表示所写的数据报或者其分片已经被加入到了数据链路层的输出队列中。这也就是我们sendto成功了,而且抓包也看出它成功发出的原因。就在这里,因为sendto成功调用的意思是,要发的数据已经到了输出队列,那么最后究竟有没有发出去呢?不能再细说了,内核知道。
——————————————————————————————————————————————————————————————
在这里我必须提出第三个问题了。上面说道,既然recvfrom会卡住,那么这样会造成后面的数据梅办法发出来,也就是说,在第一串字符被返回错误之后,如果我马上开启本地的端口,也是没用的。想象一下,发送放首先把数据发了出去,然后用recvfrom等待对方用sendto把数据会送个过来,而对面呢,也是需要先recvfrom到数据才能会送的,这样两端都卡在了recvfrom这里,是不是一个死锁问题呢?由于第一个数据已经被返回错误了,那么对方的recvfrom肯定收不到,于是对端会卡住,而奔放也会由于recvfrom不到回送的数据,而拒绝sendto新的数据,这样也卡住了。这就是传说中的阻塞式方法,另外还有非阻塞模型等着我学习。
关于recvfrom函数来说,一旦调用它,进程会从系统调用到数据包到达且被复制到应用进程的缓冲区中或者发生错误才会返回。所以我们说,进程在调用recvfrom到它返回的整段时间内是被阻塞的。
最后来说下,UDP的connect方法。UDP使用它的目的当然不是用来三次握手,UDP里面sys_connect函数的函数指针指向的是unix_dgram_connect(),而TCP用的是unix_stream_connect(),很明显是使用无连接的数据包方法来发送。只是来利用每次发送报文时的一些重复的操作集中到了一起,比如每次都要从用户控件把对方的插口地址拷贝到系统空间。