基础知识
UDP 输出 TCP/IP 协议分层中的传输层。
UDP 是一个不可靠的通信协议,没有重传和确认,没有有序控制,也没有拥塞控制。
UDP 不保证报文的有效传递,不保证报文的有序,也就是说使用 UDP 的时候,我们需要做好丢包、重传、报文组装等工作。
UDP 比较简单,适合的场景还是比较多的,我们常见的 DNS 服务,SNMP 服务都是基于 UDP 协议的,这些场景对时延、丢包都不是特别敏感。另外多人通信的场景,如聊天室、多人游戏等,也都会使用到 UDP 协议。
报文格式
UDP 报头长 8 字节,分别是源端口、目的端口、UDP 报文长度、校验和。
因为 UDP 报文长度只用 2 个字节记录,所以包含报头长度的报文最大长度为 65535 字节。
用UDP协议发送时,用sendto函数最大能发送数据的长度为:65535- IP头(20) - UDP头(8)=65507字节。用sendto函数发送数据时,如果发送数据长度大于该值,则函数会返回错误。
由于IP有最大MTU,因此,
UDP 包的大小应该是 1500 - IP头(20) - UDP头(8) = 1472(Bytes)
UDP 编程
服务器:
1、创建 socket。
2、绑定要监听的 ip 和 port。
3、循环:
3.1、调用 recvfrom 读取接收到的报文,如果没有报文则阻塞在这里。
3.2、收到报文处理完后调用 sendto 将相应发给客户端。
客户端:
1、创建 socket。
2、循环:
2.1、调用 sendto 发送请求。
2.2、调用 recvfrom 接收相应。
#include <sys/socket.h>
// 返回值:收到数据的字节数
// 参数:
// sockfd:socket描述符
// buff:本地缓存
// nbytes:缓存最大接收字节数
// flags:I/O 相关的参数,一般使用 0 即可
// from:发送端的 ip 和 port 等信息
// addrlen:from 的大小
ssize_t
recvfrom(int sockfd, void *buff, size_t nbytes, int flags,
struct sockaddr *from, socklen_t *addrlen);
// 返回值:发送了多少字节
// 参数:和上面的 recvfrom 类似
ssize_t
sendto(int sockfd, const void *buff, size_t nbytes, int flags,
const struct sockaddr *to, socklen_t addrlen);
代码参考我之前的文章:UDP 回显程序https://blog.csdn.net/Hanoi_ahoj/article/details/105358383
UDP 报文的“无连接”的特点,可以在 UDP 服务器重启之后,继续进行报文的发送,这就是 UDP 报文“无上下文”的最好说明。
有连接的 UDP
通过上文,在 UDP 中是不需要类似于 TCP 编程中的 connect 建立连接的。
其实 UDP 也可以是 “有连接” 的。
下面通过一个程序来测试一下:
客户端: 需要注意的是在创建完 socket 后进行了 connect,绑定了服务器的 ip 和 port。
// UDP connect 测试客户端
#include <stdio.h>
#include <string.h>
#include <strings.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
int main() {
int socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
if (socket_fd < 0) {
perror("socket");
return -1;
}
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(9090);
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
int ret = connect(socket_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
if (ret < 0) {
perror("connect");
return -1;
}
while (1) {
char buf[1024] = {0};
printf("input>");
scanf("%s", buf);
ssize_t n = sendto(socket_fd, buf, strlen(buf), 0, (struct sockaddr*)&server_addr, sizeof(server_addr));
if (n < 0) {
perror("sendto");
continue;
}
printf("%zd bytes sent to [%s:%d]\n", n, inet_ntoa(server_addr.sin_addr), ntohs(server_addr.sin_port));
bzero(buf, sizeof(buf));
n = recvfrom(socket_fd, buf, sizeof(buf), 0, NULL, NULL);
if (n < 0) {
perror("recvfrom");
return -1;
}
printf("resp: %s\n", buf);
}
close(socket_fd);
return 0;
}
服务器: 就是正常的服务器,收到请求原封不动返回。
#include <stdio.h>
#include <string.h>
#include <strings.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
int main() {
int socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
if (socket_fd < 0) {
perror("socket");
return -1;
}
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr("0.0.0.0");
server_addr.sin_port = htons(9090);
int ret = bind(socket_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
if (ret < 0) {
perror("bind");
return -1;
}
// 一般服务器不进行 connect 操作
while (1) {
char buf[1024] = {0};
struct sockaddr_in client_addr;
bzero(&client_addr, sizeof(client_addr));
socklen_t client_addr_len = sizeof(client_addr);
ssize_t n = recvfrom(socket_fd, buf, sizeof(buf) - 1, 0,
(struct sockaddr*)&client_addr, &client_addr_len);
if (n < 0) {
perror("recvfrom");
continue;
}
buf[n] = '\0';
printf("req->[%s:%d] %s\n", inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port), buf);
n = sendto(socket_fd, buf, strlen(buf), 0, (struct sockaddr*)&client_addr, client_addr_len);
if (n < 0) {
perror("sendto");
continue;
}
}
close(socket_fd);
return 0;
}
gcc client.c -o client
gcc server.c -o server
测试:
1、不运行 server,只运行 client。
可以看到,在调用 sendto 的时候发送了,但是当走到 recvfrom 的时候出错 Connection refused。
2、运行 server 和 client。
正常的接收请求、处理请求过程。
UDP connect 的作用
不用 connect 的话,不开启 server,运行 client,程序会阻塞在 recvfrom 上。直到服务器重启或者超时。
通常 UDP 的服务器是不需要进行 connect 的,因为 connect 以后这个服务器就只能对这个客户端进行服务器了。
connect 的作用就是让程序尽早收到错误信息返回:
通过对 UDP 套接字进行 connect 操作,将 UDP 套接字建立了“上下文”,该套接字和服务器端的地址和端口产生了联系,正是这种绑定关系给了操作系统内核必要的信息,能够将操作系统内核收到的信息和对应的套接字进行关联。
调用 sendto 或者 send 操作函数时,应用程序报文被发送,我们的应用程序返回,操作系统内核接管了该报文,之后操作系统开始尝试往对应的地址和端口发送,因为对应的地址和端口不可达,一个 ICMP 报文会返回给操作系统内核,该 ICMP 报文含有目的地址和端口等信息。
进行了 connect 操作,帮助操作系统内核从容建立了(UDP 套接字——目的地址 + 端口)之间的映射关系,当收到一个 ICMP 不可达报文时,操作系统内核可以从映射表中找出是哪个 UDP 套接字拥有该目的地址和端口,别忘了套接字在操作系统内部是全局唯一的,当我们在该套接字上再次调用 recvfrom 或 recv 方法时,就可以收到操作系统内核返回的“Connection Refused”的信息。
在对 UDP 进行 connect 之后,关于收发函数的使用,很多书籍是这样推荐的:
使用 send 或 write 函数来发送,如果使用 sendto 需要把相关的 to 地址信息置零;
使用 recv 或 read 函数来接收,如果使用 recvfrom 需要把对应的 from 地址信息置零。
其实不同的 UNIX 实现对此表现出来的行为不尽相同。
效率因素:
因为如果不使用 connect 方式,每次发送报文都会需要这样的过程:
连接套接字→发送报文→断开套接字→连接套接字→发送报文→断开套接字 →………
而如果使用 connect 方式,就会变成下面这样:
连接套接字→发送报文→发送报文→……→最后断开套接字
我们知道,连接套接字是需要一定开销的,比如需要查找路由表信息。所以,UDP 客户端程序通过 connect 可以获得一定的性能提升。
参考:极客时间 - 网络编程实战(https://time.geekbang.org/column/article/129807)
EOF