UDP套接字编程

参考:《UNIX 网络编程 · 卷1 : 套接字联网API》

UDP 与 TCP 之间传输存在差异,也导致编写应用程序存在很多差异。UDP 客户端和服务器不建立连接,而是直接使用 sendto 函数给服务器发送数据报。但必须指定目的地的地址作为参数。服务器也不用接受客户端的连接,而是直接调用 recvfrom 函数,等待来自某个客户的到达。recvform 将所接收的数据报和协议地址返回来。

UDP 典型交互图:

在这里插入图片描述

recvfrom 和 sendto 函数

这两个函数类似于 read 和 write 函数,不过还有其他额外的参数,头文件:#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, void* buff, size_t nbytes, int flags, const struct sockaddr* to, socklen_t addrlen);

参数说明

sockfd:套接字描述符

buff:指向读入或者写出的缓冲区

nbytes:读或写的字节数

flags:标记,简单的程序中总是置为0

from:指向一个由 recvfrom 函数返回时填写发送者的套接字地质结构

to:指向一个数据报接收者的套接字地址结构

addrlen:套接字地质结构的大小

返回值

返回读或写的数据长度。

注意

UPD 可以写入一个长度为 0 的数据报。这会形成一个只包含 IP 首部和一个 8 字节 UDP 首部而没有数据的 IP 数据报。所以 recvfrom 返回 0 是可接受的,并不会像 TCP 那样返回 0 表示对端关闭连接。UDP 是无连接的,不存在关闭一个 UDP 连接之类的事情。

如果 recvfrom 的 from 参数是 NULL,那么 addrlen 也必须是 NULL,表示我们不关心数据发送者的协议地址。

recvfrom 和 sendto 都可以用于 TCP,尽管没有理由这么做。

简单的UDP编程案例

如下,给出一个简单的 UDP 回射服务器案例,仅作为参考,因为还存在很多细节问题,客户端直接使用 UDP 调试工具。

#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <stdlib.h>

#define ERR_EXIT(m)         \
    do                      \
    {                       \
        perror(m);          \
        exit(EXIT_FAILURE); \
    } while (0)

void dg_echo(int sockfd, struct sockaddr *pcliaddr, socklen_t clilen)
{
    int n;
    socklen_t len;
    char msg[1024];
    while (1)
    {
        bzero(msg, 1024);
        len = clilen;
        n = recvfrom(sockfd, msg, 1024, 0, pcliaddr, &len);
        struct sockaddr_in *tmp_addr = (struct sockaddr_in *)pcliaddr;
        printf("接收到来自客户端:%s:%d 的数据:%s\n", inet_ntoa(tmp_addr->sin_addr), ntohs(tmp_addr->sin_port), msg);
        sendto(sockfd, msg, n, 0, pcliaddr, len);
    }
}

int main(int argc, char **argv)
{
    struct sockaddr_in servaddr, cliaddr;
    int sockfd = 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(9700);
    if (bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0)
        ERR_EXIT("bind.");
    dg_echo(sockfd, (struct sockaddr *)&cliaddr, sizeof(cliaddr));
    return 0;
}

我们使用两个客户端对其发送消息,运行结果:

接收到来自客户端:127.0.0.1:10000 的数据:client1
接收到来自客户端:127.0.0.1:10001 的数据:client2

可以看到,UDP 是无连接的,我们这个简单的服务器使用迭代,而不是并发服务器,就是因为 UDP 无连接,单个服务器进程就可以处理所有客户端。一般来说大多数 TCP 服务器是并发的,而大多数 UDP 服务器是迭代的

对于本套接字,UDP 层中隐含有排队发生,事实上每个 UDP 套接字都有一个接收缓冲区,到达该套接字的每个数据报都能进入这个套接字接受缓冲区。当进程调用 recvfrom 时,缓冲区中的下一个数据报以 FIFO 的顺序返回给进程。所以在进程读取该套接字中任何已经排好队的数据报之前,如果有多个数据报到达该套接字,那么相继到达的数据报仅仅加到该套接字的接收缓冲区中。但是这个缓冲区是有限的。可以通过 SO_REVBUF 套接字选项设置。

UDP 客户端中,如果进程首次调用 sendto 没有给它绑定一个端口,内核就在此时会为他绑定一个临时端口。和TCP 一样,UDP 可以调用 bind,但是很少这样做。

因为 UDP 是不可靠的。如果数据报丢失,客户端将一直阻塞在 recvfrom 调用,我们可以为其设置一个超时时间,但这不是最好的解决方法,我们不能判断数据包是没有到达服务器还是服务器应答没有回到客户端。

验证接收到的地址

客户端的临时端口号,任何其他进程都可以向其发送数据报,这就会使这些数据包与正常要通信的服务器的应答混杂起来。一个解决办法就是在 recvfrom 函数中返回数据包发送者的 IP 和 Port,只保留来自要正常通信的服务器的应答,忽略任何其他数据报。

但是这样也有缺陷,如果服务器运行在单个IP地址的主机上,是正常的,如果服务器主机是多宿的,就可能失败(如果没有在其套接字上绑定一个实际的 IP 地址,内核会为其选择源地址是外出接口的主 IP 地址)。解决办法就是:UDP 服务器上配置的每一个 IP 地址创建的套接字,用 bind 绑定每个 IP 地址到各自的套接字,然后就可以在这些套接字上使用 selete,再从可读的套接字给出应答。只要给客户端应答的套接字上绑定 IP 地址就是客户端请求的目的 IP 地址,这就保证应答的源地址与请求的目的地址相同。

服务器进程未运行

如果在不启动服务器的前提下启动客户端,客户端请求服务器的应答,但是永远阻塞在 recvfrom 调用。会响应一个 “port unreachable” 的 ICMP 消息。这个错误不返回给客户进程,是一个异步错误,由 sendto 引起,但是 sendto 却成功返回,UDP 操作成功返回仅仅表示在接口输出队列中具有存放所形成 IP 数据报的空间。此 ICMP 错误知道后来才返回,这才是其称为异步错误的原因。

一个基本的规则是:对于一个 UDP 套接字,由它引起的异步错误却不返回给它,除非它已经链接,那就是给 UDP 套接字调用 connect。

UDP获得四元组

TCP 服务器总是能便捷的访问已连接套接字的四条信息:源 IP 地址、目的 IP 地址、源端口号、目的端口号。而且这四个值在连接的整个生命周期内保持不变。然而对于 UDP 套接字目的 IP 地址只能通过 IPV4 设置 IP_RECVDSTADDR 套接字选项(IPV6 设置 IPV6_PKTINFO),然后调用 recvmsg 而不是 revbfrom,由于 UDP 是无连接的,因此目的 IP 地址可随发送到服务器的每个数据报而改变。UDP 服务器也可接受目的地址为服务器主机的某个广播或多播地址的数据报。

服务器可从到达的 IP 数据报中获取的信息如下表:

来自客户的IP数据报TCP服务器UDP服务器
源IP地址acceptrecvfrom
源端口号acceptrecvfrom
目的IP地址getsocknamerecvmsg
目的端口号getsocknamegetsockname

UDP使用connect函数

想要之前的 ICMP 异步错误返回给 UDP 套接字,我们调用 connect 函数,但是这样做的结果却与 TCP 连接不相同,UDP 没有三路握手的过程。内核只是检查是否存在立即可知的错误,记录对端的 IP 和端口号,然后立即返回到调用进程。

我分需要区分:

  1. 未连接 UDP 套接字,新创建UDP套接字默认如此。

  2. 已连接UDP套接字,对 UDP 套接字调用 connect 的结果。

对于已连接的 UDP 套接字,与默认的未连接 UDP 套接字相比,发生了三个变化。

(1)我们再也不能给输出操作指定目的 IP 地址和端口号。也就是不能使用 sendto,而是使用 writesend。写到已连接 UDP 套接字上的任何内容都自动发送到 connect 指定的协议地址。或者使用 sendto 却不能给它指定目的地址,也就是 sendto 的第五个参数必须是 NULL,并且第六个参数必须为 0。

(2)我们不必使用 recvfrom 以获取数据报的发送者,而是改为 read、recv、recvmsg。在一个已连接 UDP 套接字上,由内核为输入操作返回的数据报只有那些来自 connect 所指定协议地址的数据报。如果目的地是这个已连接 UDP 套接字的本地协议地址,发源地却不是该套接字先早 connetc 到的协议地址的数据报,消息就不会投递到该套接字。这样就限制一个已连接 UDP 套接字仅能与一个对端交互。

确切的说,一个已连接 UDP 套接字仅仅与一个 IP 地址交换数据报,因为 connect 到多播或广播地址是可能的。

(3)由已连接 UDP 套接字引发的异步错误会返回给它们所在的进程,而未连接 UDP 套接字不饿能接受任何异步错误。错误总结如下:

套接字类型write或send不指定目的地的sendto指定目的地的sendto
TCP套接字可以可以EISCONN
UDP套接字,已连接可以可以EISCONN
UDP套接字,未连接EDESTADDRREQENOTCONN可以

任何其他 IP 地址或端口的数据报都不投递给已连接 UDP 套接字,因为它不与该 connect 的地址想匹配。这些数据报可能会投递给同一个主机上的其他某个 UDP 套接字。如果没有相匹配的其他套接字,UDP 将丢弃它们并生成响应的 ICMP 端口不可达错误。

一般情况下,调用 connect 的通常是 UDP 客户端,但是某些网络应用中的 UDP 服务器会与单个客户长时间的通信(如 TFTP),这种情况下,客户端和服务器都可能调用 connect。

  • UDP多次调用connect函数

拥有一个已连接 UDP 套接字的进程可出于以下两个目的之一再次调用 connect:

  1. 指定新的 IP 地址和端口号(不同于TCP的 connect 只能调用一次)。
  2. 断开套接字。为了断开一个UDP 已连接套接字,我们再次调用 connect 时把套接字地址结构的地址族成员设置为 AF_UNSPEC。
  • 调用connect可以提高性能

当进程在一个未连接的 UDP 套接字上调用 sendto 时,内核暂时连接该套接字,发送数据报,然后断开连接。在一个未连接的 UDP 套接字上给两个数据报调用 sendto 函数是涉及内核执行下列 6 个步骤:连接套接字》输出第一个数据报》断开套接字连接》连接套接字》输出第二个套接字》断开套接字连接。

当应用进程知道自己要给同一个目的地址发送多个数据报时,显示连接套接字效率更高。调用 connect 后调用两次write 涉及内核执行如下步骤:连接套接字》输出第一个数据报》输出第二个数据报。这种情况下内核只复制一次含有目的 IP 地址和端口的套接字地质结构,相反调用两次 sendto 时,需要复制两次。临时去连接未连接的 UDP 套接字大约会耗费每个 UDP 传输三分之一的开销。

UDP套接字接收缓冲区

如果我们写一个循环 2000 次,每次发送 1400 字节大小的 UDP 数据报给服务器,服务器接受数据并统计次数,服务器只收到 30 个,其中有些是因为 UDP 不可靠并且缺乏流量控制,检查 netstat 的输出,可以看到服务器主机接收到的数据报是 2000,其中因套接字缓冲区已满而丢弃的数据报的数目为 1970 个,因为接受套接字的接受队列已满而被丢弃的数据包的数目。

由于 UDP 给某个特定套接字排队的 UDP 数据报数目受限于套接字接受缓冲区的大小。可以使用 SO_RECVBUF 套接字选项修改改值,修改成 240KB 后,继续循环发送,虽然接收缓冲区有所改善,但是不能从根本上解决问题。

UDP中的外出接口确定

已连接 UDP 套接字还可以用来确定用于某个特定目的地的外出接口。这是由 connect 函数应用到 UDP 套接字时的一个副作用造成的:内核选择本地 IP 地址(假设其进城未曾调用 bind 显式指派它)。这个本地 IP 地址通过为目的 IP 地址搜索路由表得到外出接接口,然后选用该接口的主 IP 地址而选定。

在 UDP 上调用 connect 并不给对端主机发送任何信息,完全是一个本地操作,只是保存对端的 IP 地址和端口号。在一个未绑定端口号的 UDP 套接字上调用 connect 同时也给该套接字指派一个临时端口。

使用select函数的UDP服务器

我们可以创建一个 TCP 套接字并 listen,需要设置 SO_REUSEADDR 套接字选项防止该端口已有连接存在。

再创建一个 UDP 套接字并 bind 绑定与 TCP 服务器相同的端口,不需要独立与调用 bind 之前设置 SO_REUSEADDR 选项,因为 TCP 端口是独立于 UDP 套接字端口的。然后将其套接字都加入 select 的描述符集合中,当 select 返回时,处理 TCP 的接收连接,或者处理 UDP 的接受消息。

  • 4
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

code_peak

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值