/blog_posts/web_coding/4 基本UDP套接口编程

使用 TCP 或 UDP 协议编写套接字程序,存在着本质差异。UDP 是无连接的、不可靠的数据报协议,而TCP 是面向连接的,提供可靠的字节流。然而,有些流行的应用程序适合用 UDP 来实现,例如:DNS(域名系统)、NFS(网络文件系统)和 SNMP(简单网络管理协议)都是使用 UDP 协议实现的。本章将介绍编写 UDP 客户和服务器程序所需的基本函数,随后会给出一个完整的 UDP 客户-服务器程序的例子。而且在本章中还将介绍 connect 在 UDP 套接字编程中的使用和异步错误的概念。

4.1 UDP 套接字编程

使用 UDP 套接字编程可以实现基于 TCP/IP 协议的面向无连接的通信,它分为服务器和客户端两部分,其实现过程如图4-1 所示。

1622090000011

UDP 套接字编程中,服务器端实现的步骤:

  • (1)使用socket()函数创建套接字。
  • (2)为创建的套接字绑定到指定的地址结构。
  • (3)等待接受客户端的数据请求。
  • (4)处理客户端请求。
  • (5)向客户端发送应答数据。
  • (6)关闭套接字。

客户器端实现的步骤很简单,如下所示:

  • (1)使用socket()函数创建套接字;
  • (2)发送数据请求给服务器;
  • (3)等待接收服务器的数据应答;
  • (4)关闭套接字。

UDP 中使用的套接字函数 socket() 、 bind() 和 close() 在前面已经介绍了,下面介绍用于UDP 套接字的新函数 recvfrom 和 sendto 。

4.1.1 recvfrom函数

UDP使用 recvfrom 函数接收数据,它类似于标准的 read ,但是在 recvfrom 函数中要指明目的地址。

结构

recvfrom 函数如下:

#include <sys/types.h>
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *from, size_t *addrlen);

参数

前三个参数:sockfd 、 buf 和 len 等同于函数 read 函数的前三个参数,分别为:调用 socket 函数生成的描述符、指向读入缓冲区的指针和读入的字节数。

flags 参数是传输控制标志,其值如下:

  • 0:常规操作,所做的操作与read相同。
  • MSG_OOB:指明要读的是带外数据而不是一般数据。
  • MSG_PEEK:可以查看可读的数据而不读出,在接收数据后不会将这些数据丢弃。

recvfrom 函数的最后两个参数类似于 accept 的最后两个参数:

  • from 返回与之通信的对方的套接字地址结构告诉用户接收到的数据报来自于谁。
  • 最后一个参数 addrlen 是一个指向整数值的指针(值 - 结果参数 ),存储在数据发送者的套接字地址结构中的字节数。如果 recvfrom 函数中的 from 参数是空指针, 则相应的长度参数 addrlen 也必须是空指针,这表示并不关心发数据方的协议地址。

返回值

该函数调用成功的返回值为接收到数据的长度(以字节为单位),也就是接收的数据报中用户数据的总量。如果调用失败则返回 -1 并置相应的 errno 值。

实例

调用 recvfrom 函数的代码如下:

1. #include <sys/types.h>
2. #include <sys/socket.h>
3. #define MAXDATASIZE 100
4. ……
5. int num, sockfd;
6. socklen_t addrlen;
7. sockaddr_in peer_addr;
8. char buf[MAXDATASIZE];
9. ……
10. addrlen = sizeof(peer_addr);
11. while(1)
12. {
13.     num = recvfrom(sockfd, buf, MAXDATASIZE, 0, (struct sockaddr *)&peer_addr, &addrlen);
14.     if (num < 0)
15.     {
16.         /* handle exception */
17.     }
18.     /* handle data */
19. ……
20. }
21. ……

第 13 行:用 recvfrom 函数接收数据,将接收到的数据保存到 buf 中,整数 num 返回接收的字节数,地址结构 peer_addr 返回发送数据方的协议地址, addrlen 返回存储在 peer_addr 中的字节数。

第 15-17 行:如果调用 recvfrom 函数失败,对接收异常的处理。

第 18 行:处理接收到的数据。

4.1.2 sendto函数

UDP 使用sendto函数发送数据,它类似标准的writewrite,但是与recvfrom 函数相同, sendto 函数中要指明目的地址。

结构

sendto 函数如下:

#include <sys/types.h>
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *to, int addrlen);

参数

前三个参数:sockfd 、 buf 和 len 等同于函数 read 函数的前三个参数,分别为:调用 socket函数生成的描述符、指向发送缓冲区的指针和发送的字节数。

flags 参数是传输控制标志,其值如下:

  • 0:常规操作,所做的操作与write相同。
  • MSG_DONTROUTE:告诉内核目的主机在直接连接的本地网络上,不需要查路由表。
  • MSG_OOB:指明发送的是带外数据。

sendto 的最后两个参数类似于 connect 的最后两个参数:用数据报将发往的协议地址来装填套接字地址结构。

  • 函数 sendto 的参数 to 的类型是套接字地址结构,指明数据将发往的协议地址,它的大小由 addrlen 参数来指定。
  • 但是 sendto 函数中的最后一个参数是一整数值,而不是值-结果参数。

返回值

该函数调用成功的返回值为发送数据的长度(以字节为单位)。如果调用失败则返回 -1并置相应的 errno 值。

发送一个长度为 0 的数据报是允许的,在 UDP 情况下,长度为 0 的数据报是一个包含 IP 头部(一般说来 IPv4 的头部为 20 字节, IPv6 的头部为 40 字节)、 8 字节 UDP 头部和没有数据的 IP 数据报。这也意味着对于数据报协议,recvfrom 返回 0 值也是可行的:由于 UDP 是无连接的,所以它并不表示对方已关闭了连接,这与 TCP 套接字上的 read 返回 0 的情况不同。

recvfrom 和 sendto 也可用于 TCP 协议,但是一般不这么使用。

4.2 UDP套接字编程实例

下面是一个简单的 UDP 套接字编程实例,将使用前面介 绍的 UDP 套接字函数,涉及 UDP 套接字编程的主要方面,它分为服务器和客户端两个部分。由于 UDP 是无连接的通信方式,因此不需要专门的并发技术就能服务多个不同的客户端,只需通过套接字地址区分。

程序实现的功能是:

  • 客户根据用户提供的IP地址,将用户从终端输入的信息发送给服务器,然后等待服务器的回应。
  • 服务器接收客户端发送的信息,并显示,同时显示客户的IP地址、端口号,并向客户端发送信息。如果服务器接收的客户信息为“bye”,则退出循环,并关闭套接字。
  • 客户接收、显示服务器发回的信息,并关闭套接字。

UDP服务端程序

UDP 服务器端程序如下(程序 4-1):

1622093386651

1622093424990

1622093437773

1622093447649

第 1~8 行:所需的头文件。

第 9~10 行:定义端口号和接收缓冲区的大小。

第 20~24 行:调用 socket 函数,产生 UDP 套接字。如果出错打印错误信息。

第 25~28 行:初始化 server 套接字地址结构,并对地址结构中的成员赋值。当前的本地地址设为“ INADDR_ANY ”,端口号为 1234 ”,这里的端口号和 IP 地址都要转换成网络字节序。

第 29~33 行:将套接字和指定的协议地址绑定。

第 37 行:接受客户端的信息,并存放在 buf 中,客户端的地址信息存放在 client 地址结构中。如果成功 num 返回接收的字符串的长度。

第 38~42 行:如果调用 recvfrom 函数发生错误,则打印错误信息。

第 44 行:显示接收到的客户信息、客户的 IP 地址和端口号。通过 inet_ntoa() 函数将 IP地址转换成可显示的 ASCII 串,通过 htons() 函数将端口号转换成网络字节序。

第 45 行:发送 Welcome 字符串给客户端。

第 46~47 行:如果客户端发来的字符串是“ bye ”,则退出循环。

第 49 行:关闭套接字。

UDP客户端程序

对应的UDP客户端程序如下(程序 4-2):

1622094062024

1622094073087

1622094085590

1622094107194

第 1~8 行:所需的头文件。

第 9~10 行:定义端口号和缓冲器大小。这里的端口号是要与之通信的服务器的端口号。

第 17~21 行:检查用户的输入。如果用户输入不正确,提示用户正确的输入方式。本例要求用户从命令行输入要发送 消息。

第 22~26 行:通过用户输入的点分十进制形式的 IP 地址,获得服务器的相关地址信息。gethostbyname() 函数将在后面的章节介绍。

第 27~31 行:调用 socket() 函数产生套接字描述符。

第 32~35 行:初始化服务器的地址结构,并为地址结构的成员赋值。

第 36 行:将用户从命令行输入的消息发送给服务器 server 。

第 41~45 行:接收服务器发过来的字符串,并保存在 buf 中。接收的真正字节数被存储在 num 中。同时 peer 返回接收服务器的地址。

第 46~50 行:由于 UDP 套接字是无连接的,它可能接收到其他服务器发来的信息,所以应判断信息是否来自于相应的服务器 。首先,比较 recvfrom 函数调用后返回的地址长度 len 是否等于结构体 server 的长度。如果不是,则说明消息来自于其他服务器。然后判断 server 和 peer 变量中的内容是否一致。如果一致,则说明收到的信息来自于相应的服务器。注意,server 和 peer 使用 memcmp 函数进行比较时,首先应转换成常量指针才能使用。

第 51~52 行:显示来自于服务器接的 buf 中信息。

第 55 行:关闭套接字。

程序运行结果

服务器端的运行结果如下:

./udpserver
You got a message <hello> from cient.
It's ip is 127.0.0.1, port is 32769.

客户端的运行结果如下:

./udpclient 127.0.0.1 hello
Server Message: Welcome

4.3 UDP中对数据报各项处理

4.3.1 数据报的丢失

由于 UDP 客户 服务器例子是面向无连接的, 是不可靠的 。如果一个客户数据报丢失,客户端会永远阻塞于在 recvfrom 的调用( 如上一节的 UDP 客户端例子) 等待一个永远不会到达的服务器应答。与此相似,如果客户数据报到达了服务器,但服务器的应答信息在传送过程中丢失了, 客户也将永远阻塞于 recvfrom 的调用。解决上述问题的惟一方法就是给客户端的 recvfrom 函数调用设置一个超时时间,这一点在后面的章节中会介绍。

但是仅仅为调用 recvfrom 函数设置超时时间,并不是一个完整的办法。假设函数调用超时了,无法辨别超时原因,是由于数据报没有到达服务器,还是服务器的应答信息没有回到客户,在实际应用中请求丢失和应答丢失有很大的区别。所以除了给调用 recvfrom 这样的函数设置超时时间外,还可以通过给 UDP 客户服务器程序增加可靠性来解决这个问题。

4.3.2 验证收到的响应

在 UDP 客户服务器例子中,如果知道客户临时端口号,任何进程都可往客户发送数据报,那么这些数据报会与正常的服务器应答混淆。在写 UDP 客户端例子时,解决此问题的办法是:

if (len != sizeof(server) || memcmp((const void *)&server, (const void *)&peer, len) != 0)

首先比较调用 recvfrom 函数返回的地址长度 len 与正常服务器的地址长度。然后比较 recvfrom 函数返回的服务器地址和正常服务器的地址是否一致,忽略不是来自客户端的数据报所发往服务器的任何数据报。然而,这样的处理也有一些缺陷。

如果服务器仅在一台具有单个 IP 地址的主机上运行,客户端程序会工作的很好,但若服务器是多宿的,此程序就有可能失败。例如:在有两个接口和两个 IP 地址的主机上运行此程序,这里指定的服务器 IP 地址不共享与客户主机相同的子网。这时 recvfrom 函数返回的服务器的 IP 地址,有可能不是它所发送数据报的目的 IP 地址。再有,如果发送数据报到接口 的某个非主 IP 地址(即一个别名),客户端程序也将失败。

一个解决办法是,给定由 recvfrom 返回的 IP 地址后,由客户在 DNS 中查找服务器主机的名字来验证相应主机的域名而不是它的 IP 地址。另一个解决办法是,由 UDP 服务器给服务器主机上配置的每个 IP 地址创建一个套接字,并捆绑相应的 IP 地址到此套接字上,然后将所有这些套接字放入到一个可读描述符集合中,使用 select 函数查看 些套接字可读,再对可读的套接字作应答。由于用于应答的套接字捆绑了客户请求的目的 IP 地址(否则该数据报不会被发送到此套接字上),这就保证应答的源地址与请求的目的地址相同,这种方法在后续章节会有相应的介绍。

4.3.3 服务器进程未运行

以前面给出的 UDP 客户服务器程序为例,在不启动服务器的情况下启动客户端,这时候客户端会永远阻塞于 recvfrom 函数调用上,等待一个永不到来的服务器应答。

发送这种情况的原因是:当客户端发送 UDP 数据报给一个没有运行的服务器,服务器主机会以“端口不可达”的 ICMP 消息响应。但是,此 ICMP 错误报文不会返回给客户进程,所以客户端会永远阻塞于对 recvfrom 函数的调用。

这种ICMP 错误报文称为异步错误(asynchronous error)。该错误由 sendto 发送函数引起的,但 sendto 函数本身却返回成功,这是由于 UDP 输出操作返回成功仅仅表示在接口输出队列上为 IP 数据报留出空间。 ICMP 错误直到很晚才返回,所以称之为异步错误。

对于异步错误有一个基本规则:除非套接字已连接,否则异步错误是不返回给 UDP 套接字的。这样设计的原因是,如果 UDP 客户在单个 UDP 套接字上连续发送三个数据报给三个不同的服务器(即三个不同的 IP 地址 ),然后调用 recvfrom 函数循环读取应答数据报。假设发送的三个数据报中有两个数据被正确传送,也就是说,三个服务器中有两个服务器在运行,那么没有运行服务器的主机就会以一个 ICMP “端口不可达”的错误报文来响应。此 ICMP 出错消息包含有引起错误的数据报的 IP 头部和 UDP 头部,这样便于其接收者确定是哪个套接口引起的错误。对于发送三个数据报的客户端,需要知道引起错误的数据报的目的地址,以区分是三个数据报中的哪一个引起的错误。但内核无法将此信息返回给进程, recvfrom 可以返回的仅有的信息是 errno 值,它没有办法返回错误数据报的目的 IP 地址和目的端口号。因此规定仅在进程已将 UDP 套接字连接到确切的对方后,这些异步错误才返回给进程。

4.4 connect 函数用于UDP○