UDP是无连接不可靠的数据报协议,不同于TCP提供的面向连接的可靠字节流。常见的使用UDP的应用程序有:DNS,NFS和SNMP。

一、概述

UDP的主要特点如下:

  • UDP是面向无连接的,不需要建立连接就可以传输数据

  • UDP尽最大可能交付,不保证可靠交付

  • UDP是面向报文,对应用层传输的报文添加首部后就直接发送,不合并不拆分

  • UDP没有拥塞控制

  • UDP支持一对一,一对多,多对一,多对多

  • UDP首部八个字节,开销小

基于UDP的套接字编程比基于TCP的相对较为简单。客户不与服务器建立连接,而是只管使用sendto函数给服务器发送数据报,其中必须指定目的地,的地址作为参数。类似的,服务器不接受来自客户端的连接,而是只管调用recvfrom函数,等待来自某个客户的数据到达。recvform将所接收的数据报一道返回客户的协议地址,因此服务器可以把响应发送给正确的客户。下图给出了典型的UDP客户/服务器程序的函数调用。

UDP编程
UDP编程

二、recvform和sendto函数

类似与TCP里面的read和write函数,UDP套接字编程中使用recvform和sendto函数来传输数据,其函数原型如下:

#include <sys/socket.h>

ssize_t recvform(int sockfd, void *buff , size_t nbytes, int flags , 
struct sockaddr *from , socklen_t *addrlen);

ssize_t sendto(int sockfd, const void *buff , size_t nbytes, int flags , 
struct sockaddr *to , socklen_t addrlen);//若成功则返回读或写的字节大小,若出错返回-1

前三个参数分别为:描述符、指向读入或写出缓冲区的指针和读写字节数。flags暂时设为0。

recvfrom的from参数指向一个将由该函数在返回时填写数据报发送者的协议地址的套接字地址结构(从哪里接收),其大小由addrlen指定;

sendto的to参数指向一个含有数据报接收者的协议地址的套接字地址结构(发到哪里去),其大小由addrlen指定。

注意:sendto的最后一个参数是一个整型值,而recvfrom则是一个指向整数值的指针。

不同于TCP在read返回0时表示对端关闭连接,UDP可以写一个长度为0的数据报,接受的数据报也可以长度为0,UDP没有关闭一个连接的概念。

如果recvfrom的from参数是一个空指针,那么相应的长度参数也必须是一个空指针,表示我们并不关心数据发送者的协议地址。

三、UDP回射服务器程序

下面用UDP来重写TCP回射服务器,其函数调用流程图如下:

UDP回射服务器流程
UDP回射服务器流程
服务器程序清单如下:

#include	"unp.h"
int main(int argc, char **argv)
{
	int					sockfd;
	struct sockaddr_in	servaddr, cliaddr;

	sockfd = Socket(AF_INET, SOCK_DGRAM, 0);//UDP面向数据报,SOCK_DGRAM为数据报套接字

	bzero(&servaddr, sizeof(servaddr));//初始化套接字
	servaddr.sin_family      = AF_INET;
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
	servaddr.sin_port        = htons(SERV_PORT);

	Bind(sockfd, (SA *) &servaddr, sizeof(servaddr));//将本地协议地址赋值给套接字

	dg_echo(sockfd, (SA *) &cliaddr, sizeof(cliaddr));//调用回射程序
}
void dg_echo(int sockfd, SA *pcliaddr, socklen_t clilen)
{
	int			n;
	socklen_t	len;
	char		mesg[MAXLINE];

	for ( ; ; ) {
		len = clilen;
		n = Recvfrom(sockfd, mesg, MAXLINE, 0, pcliaddr, &len);//接受客户的消息

		Sendto(sockfd, mesg, n, 0, pcliaddr, len);//回射给客户端
	}
}

四、UDP回射客户程序

#include	"unp.h"

int main(int argc, char **argv)
{
	int					sockfd;
	struct sockaddr_in	servaddr;

	if (argc != 2)
		err_quit("usage: udpcli <IPaddress>");

	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family = AF_INET;
	servaddr.sin_port = htons(7);
	Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);//将点分十进制地址转换成二进制地址

	sockfd = Socket(AF_INET, SOCK_DGRAM, 0);//建立套接字

	dg_cli(stdin, sockfd, (SA *) &servaddr, sizeof(servaddr));//调用回射程序

	exit(0);
}
void dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)
{
	int	n;
	char	sendline[MAXLINE], recvline[MAXLINE + 1];

	while (Fgets(sendline, MAXLINE, fp) != NULL) {//从标准输入读入数据

		Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);//将文本发送给服务器

		n = Recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL);//读取回射后的文本

		recvline[n] = 0;	/* null terminate */
		Fputs(recvline, stdout);//标准输出
	}
}

测试小结

在ubuntu15.10环境下,测试上述代码:

@Inspiron-N4010:~/unp$ ./udpcli 127.0.0.1
hello
hello

回射服务器正确返回客户端发送的数据

五、数据报丢失

UDP传输本身就不可靠,如果一个客户数据报丢失,客户将永远阻塞于recvfrom调用,等待一个永远不会到达的服务器应答。 我们一般采用设置超时来防止永久阻塞,但仅仅采用超时并不是一个好的解决方案,客户无法判断超时原因是数据报没有到达服务器还是服务器的应答没有回到客户。这就需要在UDP传输的可靠性上下功夫了,后面的笔记中会讲到。

六、验证接收到的响应

由于UDP只管收发数据报,知道客户临时端口号的任何进程都可往客户发送数据报,而且这些数据报会与正常的服务器应答混杂,此时,可以修改客户程序的recvfrom调用以返回数据报发送者的IP地址和端口号,保留来自数据报所发往服务器的应答,而忽略任何其他的数据报。

修改后的客户回射程序如下:

void
dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)
{
	int				n;
	char			sendline[MAXLINE], recvline[MAXLINE + 1];
	socklen_t		len;
	struct sockaddr	*preply_addr;

	preply_addr = Malloc(servlen);

	while (Fgets(sendline, MAXLINE, fp) != NULL) {

		Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);//返回发送者的IP地址和端口号

		len = servlen;
		n = Recvfrom(sockfd, recvline, MAXLINE, 0, preply_addr, &len);
		if (len != servlen || memcmp(pservaddr, preply_addr, len) != 0) {//验证ip地址和端口号
			printf("reply from %s (ignored)\n",
					Sock_ntop(preply_addr, len));
			continue;
		}

		recvline[n] = 0;	/* null terminate */
		Fputs(recvline, stdout);
	}
}

这种做法还是有缺陷的,如果一个主机只有单个IP,那么程序可以非常完美的运行;但是,如果运行在多宿主机(多个IP地址)上,该程序则可能失败。

举个例子,假设服务器的IP有172.24.37.94和172.24,37.100,客户连接服务器172.24,37.100,但是响应的是172.24.37.94,这样程序就出了问题了。

解决上述冲突有两个可行的方法:

  • 得到服务器的IP地址后,客户通过DNS查找服务器主机的名字来验证该主机的域名
  • UDP服务器给服务器主机上配置的每个IP创建一个套接字,用bind捆绑每个IP地址到各自的套接字,然后在所有的套接字上使用select,等待其中的一个变得可读,再从可读的套接字给出应答,这就保证了应答的源地址和请求的目的地址相同。

七、服务器未运行

如果服务器未运行的情况下启动了客户程序,那么在客户键入一行文本后,会永远阻塞于它的recvfrom调用,等待一个永远不会出现的应答。

通过tcpdump发现,服务器主机响应的是一个“port unreachedable”(端口不可达)ICMP消息。

这个ICMP端口不可达错误属于异步错误,因为sendto成功返回(接口队列中存放所形成IP数据报的空间即成功返回)后,ICMP错误才会返回。

所以,这个ICMP错误并不会返回给客户进程,导致客户永远阻塞与recvfrom调用。

一个基本规则:对于一个UDP套接字,由它引发的异步错误却并不返回给它,除非它已连接。

注:只要SO_BSDCOMPAT 套接字选项没有开启,linux甚至对未连接的套接字也返回大多数ICMP(目的地不可达)错误。

八、UDP的connect函数

上一小节提到,由于ICMP异步错误,导致在服务器未运行的情况下,客户程序会一直阻塞在recvfrom调用上。

在TCP客户/服务器中,我们采用connect函数,让客户和服务器完成三次握手,可以判断服务器程序运行与否。确实,在UDP中,我们也可以调用connect函数,但是,并不会完成三次握手,内核只是检查是否存在立即可知的错误(例如一个显然不可达的目的地),记录对端的IP地址和端口号,然后立即返回到调用进程。

那么,在使用connect函数的UDP套接字程序中,我们必须区分以下两个概念:

  • 未连接的UDP套接字,新创建UDP套接字默认如此
  • 已连接的UDP套接字,对UDP套接字调用connect的结果

对于已连接的UDP套接字,相对于未连接的套接字有三个方面的变化:

  • 不能给输出操作指定目的IP地址和端口号,不适用sendto改用write和read,写到已连接套接字上的任何内容都自动发送到connect指定的协议地址。
  • 不必使用recvfrom以获得数据报的发送者,而改用read,recv和recvmsg
  • 由已连接UDP套接字引发的异步错误会返回给他们所在的进程,而未连接UDP套接字不接收任何异步错误

已连接套接字
已连接套接字

给一个UDP套接字多次调用connect

有以下两个目的:

  • 指定新的IP地址和端口号
  • 断开套接字:将sin_family设置为AF_UNSPEC。

性能

对于一个未连接的套接字,调用sendto函数后,内核执行以下步骤:

连接套接字->输出第一个数据报->断开套接字->连接套接字->……

而采用connect函数后,发送两次数据报,内核执行步骤:

连接套接字->输出第一个数据报->输出第二个数据报…

可见,显示连接套接字的效率要高很多。

九、使用connect改进客户程序

void dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)
{
	int		n;
	char	sendline[MAXLINE], recvline[MAXLINE + 1];

	Connect(sockfd, (SA *) pservaddr, servlen);//连接套接字

	while (Fgets(sendline, MAXLINE, fp) != NULL) {

		Write(sockfd, sendline, strlen(sendline));

		n = Read(sockfd, recvline, MAXLINE);

		recvline[n] = 0;	/* null terminate */
		Fputs(recvline, stdout);
	}
}

运行此客户端程序,结果如下:

@Inspiron-N4010:~/unp$ ./udpcli 127.0.0.1
hello
read error: Connection refused

解释一下为什么在键入一行文本后返回connection refused,而不是调用connect函数的时候?

在UDP套接字上,调用connect并不给对端主机发送任何消息,它完全是一个本地操作,知识保存对端的IP地址和端口号,然后因为发送该数据引发了来自服务器主机的ICMP错误。

十、UDP缺乏流量控制

通过改进客户和服务器端程序,来观察缺乏流量控制的情况下,udp的传输效率。

客户端程序


#define	NDG		2000	//数据的个数
#define	DGLEN	1400	//每个数据的长度

void
dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)
{
	int		i;
	char	sendline[DGLEN];

	for (i = 0; i < NDG; i++) {//发送200个包
		Sendto(sockfd, sendline, DGLEN, 0, pservaddr, servlen);
	}
}

##服务器程序

static void	recvfrom_int(int);
static int	count;

void
dg_echo(int sockfd, SA *pcliaddr, socklen_t clilen)
{
	socklen_t	len;
	char		mesg[MAXLINE];

	Signal(SIGINT, recvfrom_int);

	for ( ; ; ) {
		len = clilen;
		Recvfrom(sockfd, mesg, MAXLINE, 0, pcliaddr, &len);

		count++;//计算接受到的包个数
	}
}

static void
recvfrom_int(int signo)
{
	printf("\nreceived %d datagrams\n", count);
	exit(0);
}

测试小结

在我的主机上同时运行客户和服务器程序,基本上没有丢失

@Inspiron-N4010:~/unp$ sudo ./udpserv 
//客户运行完毕后敲ctrl+c
received 2000 datagrams

UDP套接字接收缓存区

由UDP给某个特定套接字排队的UDP数据报数目受限于该套接字缓冲区的大小。可以使用SO_REVBUF套接字选项来修改该值。

在UDP第七章讲了常用的套接字选项,可以利用setsockopt函数来修改。

具体修改调用如下:

n=220*1024;//新的套接字接受缓冲区
setsockopt(sockfd,SOL_SOCKET,SO_REVBUF,&n,sizeof(n));//修改SO_REVBUF为n

十一、UDP中的外出接口的确定

已连接的套接字还可以用来确定用于某个特定目的地的外出接口。

通过调用getsockname得到本地IP地址和端口号并显示输出,即可获得外出接口。

Connect(sockfd, (SA *) servaddr, servlen);//连接套接字

len= sizeof(cliaddr);
Getsockname(sockfd,(SA *)cliaddr,&len);//通过描述符获得本地端口和描述符
printf("local address %s\n",Sock_ntop((SA*) &cliaddr , len));

十二、使用select函数的TCP和UDP回射服务器程序

【UNIX网络编程第三版】阅读笔记(五):select和poll函数一文中,利用select函数达到IO复用的目的。

本章的最后,利用select来完成TCP和UDP的复用效果。

#include	"unp.h"
int main(int argc, char **argv)
{
	int					listenfd, connfd, udpfd, nready, maxfdp1;
	char				mesg[MAXLINE];
	pid_t				childpid;
	fd_set				rset;
	ssize_t				n;
	socklen_t			len;
	const int			on = 1;
	struct sockaddr_in	cliaddr, servaddr;
	void				sig_chld(int);

	//创建TCP监听套接字
	listenfd = Socket(AF_INET, SOCK_STREAM, 0);

	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family      = AF_INET;
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
	servaddr.sin_port        = htons(SERV_PORT);

	Setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
	Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));

	Listen(listenfd, LISTENQ);

	//创建UDP套接字
	udpfd = Socket(AF_INET, SOCK_DGRAM, 0);

	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family      = AF_INET;
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
	servaddr.sin_port        = htons(SERV_PORT);

	Bind(udpfd, (SA *) &servaddr, sizeof(servaddr));

	Signal(SIGCHLD, sig_chld);	//建立信号处理函数,必须调用waitpid

	FD_ZERO(&rset);
	maxfdp1 = max(listenfd, udpfd) + 1;//最大描述符+1
	for ( ; ; ) {
		FD_SET(listenfd, &rset);
		FD_SET(udpfd, &rset);
		if ( (nready = select(maxfdp1, &rset, NULL, NULL, NULL)) < 0) {
			if (errno == EINTR)
				continue;		
			else
				err_sys("select error");
		}

		if (FD_ISSET(listenfd, &rset)) {
			len = sizeof(cliaddr);
			connfd = Accept(listenfd, (SA *) &cliaddr, &len);
	
			if ( (childpid = Fork()) == 0) {	//创建子进程处理客户需求
				Close(listenfd);	//监听套接字计数-1.为0时关闭
				str_echo(connfd);	
				exit(0);
			}
			Close(connfd);			//连接套接字计数减1,为0时关闭
		}

		if (FD_ISSET(udpfd, &rset)) {
			len = sizeof(cliaddr);
			n = Recvfrom(udpfd, mesg, MAXLINE, 0, (SA *) &cliaddr, &len);//UDP传输

			Sendto(udpfd, mesg, n, 0, (SA *) &cliaddr, len);//回射
		}
	}
}