基础知识

UDP 输出 TCP/IP 协议分层中的传输层。
UDP 是一个不可靠的通信协议,没有重传和确认,没有有序控制,也没有拥塞控制。
UDP 不保证报文的有效传递,不保证报文的有序,也就是说使用 UDP 的时候,我们需要做好丢包、重传、报文组装等工作。
UDP 比较简单,适合的场景还是比较多的,我们常见的 DNS 服务,SNMP 服务都是基于 UDP 协议的,这些场景对时延、丢包都不是特别敏感。另外多人通信的场景,如聊天室、多人游戏等,也都会使用到 UDP 协议。
OSI和TCP/IP

报文格式

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报文

UDP 编程

服务器:
1、创建 socket。
2、绑定要监听的 ip 和 port。
3、循环:
3.1、调用 recvfrom 读取接收到的报文,如果没有报文则阻塞在这里。
3.2、收到报文处理完后调用 sendto 将相应发给客户端。

客户端:
1、创建 socket。
2、循环:
2.1、调用 sendto 发送请求。
2.2、调用 recvfrom 接收相应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#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编程时序
代码参考我之前的文章:UDP 回显程序https://blog.csdn.net/Hanoi_ahoj/article/details/105358383

UDP 报文的“无连接”的特点,可以在 UDP 服务器重启之后,继续进行报文的发送,这就是 UDP 报文“无上下文”的最好说明。

有连接的 UDP

通过上文,在 UDP 中是不需要类似于 TCP 编程中的 connect 建立连接的。
其实 UDP 也可以是 “有连接” 的。

下面通过一个程序来测试一下:
客户端: 需要注意的是在创建完 socket 后进行了 connect,绑定了服务器的 ip 和 port。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 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;
}

服务器: 就是正常的服务器,收到请求原封不动返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#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;
}
1
2
gcc client.c -o client
gcc server.c -o server

测试:
1、不运行 server,只运行 client。
可以看到,在调用 sendto 的时候发送了,但是当走到 recvfrom 的时候出错 Connection refused。

client
2、运行 server 和 client。
正常的接收请求、处理请求过程。
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