UDP套接字编程基础
程序员文章站
2022-03-12 11:27:06
...
下图显示了使用 UDP 套接字编写客户/服务器程序时的大致流程。
UDP 中的客户不需要与服务器建立连接,而是采用 sendto 和 recvfrom 函数来发送和接收数据。
这两个函数的前三个参数等同于 read 和 write 的三个参数:描述符、缓冲区和读写字节数,flag 参数将在后面讨论 recv、send 等函数时再介绍,目前先将其置为 0。
sendto 的 to 参数代表数据报接收者的地址,其大小由 addrlen 参数指定。recvfrom 的 from 参数代表数据报发送者的地址,其大小被放置在 addrlen 参数指向的位置中(注意,如果 from 是空指针,则 addrlen 也必须是一个空指针,表示不关心发送者。这样做存在一个风险:知道客户地址和临时端口号的任何进程都可以冒充服务器的应答向本客户发送数据报)。这两个函数的最后两个参数分别类似于 connect 和 accept 的最后两个参数。尽管这两个函数也可以用于 TCP,不过通常没必要。
在 UDP 中,写一个长度为 0 的数据报会形成一个只包含 IP 首部(IPv4 通常为 20 个字节,IPv6 通常为 40 个字节)和一个 8 字节的 UDP 首部而没有数据的 IP 数据报。这也意味着 recvfrom 返回 0 值是可接受的,而并不像 TCP 套接字上 read 返回 0 值那样表示对端已关闭连接。
下面是使用 UDP 套接字编写的简单回射服务器示例(省略了对函数返回值的检测)。
由于无需连接,所以大多数 UDP 服务器提供的都是一个迭代服务器,而不是像 TCP 服务器那样需要提供一个并发服务器。
每个 UDP 套接字都有一个接收缓冲区,到达该套接字的每个数据报都进入这个缓冲区。当进程调用 recvfrom 时,缓冲区中的下一个数据报就以 FIFO 的顺序递送给进程。
接下来也给出了客户端的代码示例。
要注意的是,这里的 UDP 客户/服务器例子是不可靠的。因为如果一个客户数据报或者服务器的应答丢失了,客户都将永远阻塞于 recvfrom 调用上。防止这样永久阻塞的一般方法是给客户的 recvfrom 设置一个超时,但这也不是完整的解决办法,因为这会使得我们无从判定超时原因是客户的数据报没有到达服务器,还是服务器的应答没有回到客户。后面会再继续讨论如何给 UDP 客户/服务器程序增加可靠性。
另外,为了避免收到来自非请求服务器的数据报,客户程序对 recvfrom 返回的数据报发送者的地址进行了过滤(或直接使用 connect,见下)。不过如果服务器主机是多宿的,这种方式可能会使服务器的某些应答数据报丢失。因为大多数 IP 实现接受目的地址为本主机任一 IP 地址的数据报,而不管数据报到达的接口(RFC 称之为弱端系统模型。反之,强端系统模型则只接受到达接口与目的地址一致的数据报),所以当服务器绑定到其套接字上的是通配 IP 地址时,内核会自行为封装应答的 IP 数据报选择一个源地址作为外出接口的主 IP 地址,而这可能不是客户请求的那个地址,因而被客户程序误过滤掉了。对该问题的一个解决办法是:客户得到由 recvfrom 返回的 IP 地址后,通过在 DNS 中查找服务器主机的名字来验证该主机的域名而非 IP 地址。另一个解决办法是:UDP 服务器给服务器主机上配置的每个 IP 地址都创建一个套接字,并绑定每个 IP 地址到各自的套接字。然后使用 select 来等待其中任何一个变得可读,再从可读的套接字给出应答。
在 UDP 套接字中,对于像由 sendto 等函数引起的异步错误(如向一个未运行的服务器程序发送数据报时会产生“端口不可达”的 ICMP 错误信息,该错误由 sendto 引起,但是 sendto 本身却能成功返回)并不返回给它,除非它已连接。尽管 UDP 是不需要连接的,不过也确实可以在 UDP 套接字上调用 connect,只是它没有三路握手过程,内核只是检查是否存在立即可知的错误,并记录对端的 IP 地址和端口后,然后就立即返回。
已连接的 UDP 套接字与默认的未连接的 UDP 套接字相比,发生了以下三个变化。
(1)不能再给输出操作指定目的地址和端口号,即不使用 sendto(或者将其第五个和第六个参数分别置为空指针和 0)而改用 write 或 send。写到已连接 UDP 套接字上的任何内容都自动发送到由 connect 指定的地址。
(2)同理,不必使用 recvfrom 以获悉数据报的发送者,而改用 read、recv 或 recvmsg,因为内核只返回那些来自 connect 所指定的目的地址的数据报。
(3)会返回异步错误。
一般调用 connect 的通常是 UDP 客户,不过有些 UDP 服务器(如 TFTP)会与单个客户长时间通信,在这种情况下,客户和服务器都可能调用 connect。
下图的 DNS 是一个调用 connect 的例子。
通常通过在 /etc/resolv.conf 中列出服务器主机的 IP 地址,一个 DNS 客户就能被配置成使用一个或多个 DNS 服务器。如果列出的是单个服务器主机(如图中第一个客户),客户进程就可以调用 connect,但如果列出的是多个服务器主机(如图中第二个客户),客户进程就不能调用 connect。另外,DNS 服务器进程通常是处理多个客户请求的,因此不能调用 connect。
此外,拥有一个已连接 UDP 套接字的进程可出于下列两个目的之一再次调用 connect:
(1)指定新的 IP 地址和端口号。这种情况对于 TCP 套接字,connect 只能调用一次。
(2)断开套接字。为了断开一个已连接的 UDP 套接字,可以在再次调用 connect 时把套接字地址结构的地址族成员 sin_family 或 sin6_family 设置成 AF_UNSPEC,这么做可能会返回一个 EAFNOSUPPORT 错误,不过没有关系。使套接字断开连接的是在已连接 UDP 套接字上调用 connect 的进程。
当应用进程知道自己要给同一目的地址发送多个数据报时,显示连接套接字效率会更高。因为在一个未连接的 UDP 套接字上发送数据报时,源自 Berkeley 的内核会暂时连接该套接字,然后发送数据报,之后又断开该连接,即使下一个数据报是发送到同一地址也是如此。
UDP 中的客户不需要与服务器建立连接,而是采用 sendto 和 recvfrom 函数来发送和接收数据。
#include <sys/socket.h> ssize_t recvfrom(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, const struct sockaddr *to, socklen_t addrlen); /* 两个函数的返回值:若成功则为读或写的字节数;否则为 -1 */
这两个函数的前三个参数等同于 read 和 write 的三个参数:描述符、缓冲区和读写字节数,flag 参数将在后面讨论 recv、send 等函数时再介绍,目前先将其置为 0。
sendto 的 to 参数代表数据报接收者的地址,其大小由 addrlen 参数指定。recvfrom 的 from 参数代表数据报发送者的地址,其大小被放置在 addrlen 参数指向的位置中(注意,如果 from 是空指针,则 addrlen 也必须是一个空指针,表示不关心发送者。这样做存在一个风险:知道客户地址和临时端口号的任何进程都可以冒充服务器的应答向本客户发送数据报)。这两个函数的最后两个参数分别类似于 connect 和 accept 的最后两个参数。尽管这两个函数也可以用于 TCP,不过通常没必要。
在 UDP 中,写一个长度为 0 的数据报会形成一个只包含 IP 首部(IPv4 通常为 20 个字节,IPv6 通常为 40 个字节)和一个 8 字节的 UDP 首部而没有数据的 IP 数据报。这也意味着 recvfrom 返回 0 值是可接受的,而并不像 TCP 套接字上 read 返回 0 值那样表示对端已关闭连接。
下面是使用 UDP 套接字编写的简单回射服务器示例(省略了对函数返回值的检测)。
#include <stdio.h> #include <stdlib.h> #include <strings.h> #include <netinet/in.h> #include <sys/socket.h> #define SERV_PORT 9877 typedef struct sockaddr SA; void dgram_echo(int); int main(void){ struct sockaddr_in servaddr; bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(SERV_PORT); int sockfd = socket(AF_INET, SOCK_DGRAM, 0); bind(sockfd, (SA *)&servaddr, sizeof(servaddr)); dgram_echo(sockfd); } #define MAXLINE 2048 void dgram_echo(int sockfd){ struct sockaddr_in cliaddr; socklen_t addrlen = sizeof(cliaddr); char msg[MAXLINE]; for(;;){ socklen_t len = addrlen; int n = recvfrom(sockfd, msg, MAXLINE, 0, (SA *)&cliaddr, &len); sendto(sockfd, msg, n, 0, (SA *)&cliaddr, len); } }
由于无需连接,所以大多数 UDP 服务器提供的都是一个迭代服务器,而不是像 TCP 服务器那样需要提供一个并发服务器。
每个 UDP 套接字都有一个接收缓冲区,到达该套接字的每个数据报都进入这个缓冲区。当进程调用 recvfrom 时,缓冲区中的下一个数据报就以 FIFO 的顺序递送给进程。
接下来也给出了客户端的代码示例。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <strings.h> #include <netinet/in.h> #include <sys/socket.h> #include <arpa/inet.h> #define SERV_PORT 9877 #define MAXLINE 2048 typedef struct sockaddr SA; void dgram_cli(FILE *fp, int sockfd, const SA *paddr, socklen_t addrlen){ char buf[MAXLINE+1]; struct sockaddr *preply_addr = malloc(addrlen); socklen_t len = addrlen; while(fgets(buf, MAXLINE, fp) != NULL){ sendto(sockfd, buf, strlen(buf), 0, paddr, addrlen); int n = recvfrom(sockfd, buf, MAXLINE, 0, prepy_addr, &len); if(len != addrlen || memcmp(paddr, preply_addr, len) != 0) continue; // 过滤可能来自非请求服务器的数据 buf[n] = 0; // null terminate fputs(buf, stdout); } free(preply_addr); } int main(int argc, char *argv[]){ if(argc != 2){ printf("Usage: %s <IPAddress>\n", argv[0]); exit(1); } struct sockaddr_in servaddr; bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_port = htons(SERV_PORT); inet_pton(AF_INET, argv[1], &servaddr.sin_addr); int sockfd = socket(AF_INET, SOCK_DGRAM, 0); dgram_cli(stdin, sockfd, (SA *)&servaddr, sizeof(servaddr)); exit(0); }
要注意的是,这里的 UDP 客户/服务器例子是不可靠的。因为如果一个客户数据报或者服务器的应答丢失了,客户都将永远阻塞于 recvfrom 调用上。防止这样永久阻塞的一般方法是给客户的 recvfrom 设置一个超时,但这也不是完整的解决办法,因为这会使得我们无从判定超时原因是客户的数据报没有到达服务器,还是服务器的应答没有回到客户。后面会再继续讨论如何给 UDP 客户/服务器程序增加可靠性。
另外,为了避免收到来自非请求服务器的数据报,客户程序对 recvfrom 返回的数据报发送者的地址进行了过滤(或直接使用 connect,见下)。不过如果服务器主机是多宿的,这种方式可能会使服务器的某些应答数据报丢失。因为大多数 IP 实现接受目的地址为本主机任一 IP 地址的数据报,而不管数据报到达的接口(RFC 称之为弱端系统模型。反之,强端系统模型则只接受到达接口与目的地址一致的数据报),所以当服务器绑定到其套接字上的是通配 IP 地址时,内核会自行为封装应答的 IP 数据报选择一个源地址作为外出接口的主 IP 地址,而这可能不是客户请求的那个地址,因而被客户程序误过滤掉了。对该问题的一个解决办法是:客户得到由 recvfrom 返回的 IP 地址后,通过在 DNS 中查找服务器主机的名字来验证该主机的域名而非 IP 地址。另一个解决办法是:UDP 服务器给服务器主机上配置的每个 IP 地址都创建一个套接字,并绑定每个 IP 地址到各自的套接字。然后使用 select 来等待其中任何一个变得可读,再从可读的套接字给出应答。
在 UDP 套接字中,对于像由 sendto 等函数引起的异步错误(如向一个未运行的服务器程序发送数据报时会产生“端口不可达”的 ICMP 错误信息,该错误由 sendto 引起,但是 sendto 本身却能成功返回)并不返回给它,除非它已连接。尽管 UDP 是不需要连接的,不过也确实可以在 UDP 套接字上调用 connect,只是它没有三路握手过程,内核只是检查是否存在立即可知的错误,并记录对端的 IP 地址和端口后,然后就立即返回。
已连接的 UDP 套接字与默认的未连接的 UDP 套接字相比,发生了以下三个变化。
(1)不能再给输出操作指定目的地址和端口号,即不使用 sendto(或者将其第五个和第六个参数分别置为空指针和 0)而改用 write 或 send。写到已连接 UDP 套接字上的任何内容都自动发送到由 connect 指定的地址。
(2)同理,不必使用 recvfrom 以获悉数据报的发送者,而改用 read、recv 或 recvmsg,因为内核只返回那些来自 connect 所指定的目的地址的数据报。
(3)会返回异步错误。
一般调用 connect 的通常是 UDP 客户,不过有些 UDP 服务器(如 TFTP)会与单个客户长时间通信,在这种情况下,客户和服务器都可能调用 connect。
下图的 DNS 是一个调用 connect 的例子。
通常通过在 /etc/resolv.conf 中列出服务器主机的 IP 地址,一个 DNS 客户就能被配置成使用一个或多个 DNS 服务器。如果列出的是单个服务器主机(如图中第一个客户),客户进程就可以调用 connect,但如果列出的是多个服务器主机(如图中第二个客户),客户进程就不能调用 connect。另外,DNS 服务器进程通常是处理多个客户请求的,因此不能调用 connect。
此外,拥有一个已连接 UDP 套接字的进程可出于下列两个目的之一再次调用 connect:
(1)指定新的 IP 地址和端口号。这种情况对于 TCP 套接字,connect 只能调用一次。
(2)断开套接字。为了断开一个已连接的 UDP 套接字,可以在再次调用 connect 时把套接字地址结构的地址族成员 sin_family 或 sin6_family 设置成 AF_UNSPEC,这么做可能会返回一个 EAFNOSUPPORT 错误,不过没有关系。使套接字断开连接的是在已连接 UDP 套接字上调用 connect 的进程。
当应用进程知道自己要给同一目的地址发送多个数据报时,显示连接套接字效率会更高。因为在一个未连接的 UDP 套接字上发送数据报时,源自 Berkeley 的内核会暂时连接该套接字,然后发送数据报,之后又断开该连接,即使下一个数据报是发送到同一地址也是如此。