UDP 套接字编程入门

概述

在使用TCP编写的应用程序和使用UDP编写的应用程序之间存在一些本质差异,其原因在于这两个传输层之间的差别:UDP是无连接不可靠的数据报协议,不同于TCP提供的面向连接的可靠字节流。从资源的角度来看,相对来说UDP套接字开销较小,因为不需要维持网络连接,而且因为无需花费时间来连接连接,所以UDP套接字的速度也较快。
因为UDP提供的是不可靠服务,所以数据可能会丢失。如果数据对于我们来说非常重要,就需要小心编写UDP客户程序,以检查错误并在必要时重传。实际上,UDP套接字在局域网中是非常可靠的。

UDP 客户 / 服务器程序使用的套接字函数
图:UDP 客户 / 服务器程序使用的套接字函数

上图展示了客户与服务器使用UDP套接字进行通信的过程。在UDP套接字程序中,客户不需要与服务器建立连接,而只管直接使用sendto函数给服务器发送数据报。同样的,服务器不需要接受来自客户的连接,而只管调用recvfrom函数,等待来自某个客户的数据到达。

如何编写UDP套接字程序

编写UDP套接字应用程序,涉及一定的步骤:

  • 创建套接字
  • 命名套接字
  • 在服务器端,等待客户的消息
  • 在客户端,发送客户消息
  • 关闭套接字

步骤1:创建套接字

可以使用系统调用socket来创建一个套接字并返回该套接字的文件描述符。

1
2
#include <sys/socket.h>
int socket(int domain, int type, int protocol);

创建的套接字是一条通信线路的一个端点。

domain
domain参数指定哪种协议族,常见的协议族包括 AF_UNIX 和 AF_INET。AF_UNIX 用于通过文件系统实现的本地套接字,AF_INET 用于网络套接字。

type
type参数指定这个套接字的通信类型,取值包括 SOCK_STREAM 和 SOCK_DGRAM。
SOCK_STREAM 即流套接字,基于 TCP,提供可靠,有序的服务。
SOCK_DGRAM 即数据报套接字,基于 UDP,提供不可靠,无序的服务。SOCK_DGRAM 类型的套接字为本文讲述的重点。

protocol
protocol允许为套接字指定一种协议。对于 AF_UNIX 和 AF_INET,我们使用默认值即可。

以下代码创建一个 UDP socket,domain使用 AF_INET,type 使用 SOCK_DGRAM,protocol 协议使用默认的 0 值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <sys/socket.h>
#include <stdlib.h>
#include <stdio.h>

int main(int argc, char **argv)
{
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) {
perror("cannot create socket");
return 0;
}

printf("created socket, fd: %d\n", fd);
exit(0);
}

步骤2:命名套接字

要想让创建的套接字可以被其他进程使用,那必须给该套接字命名。对套接字命名的意思是指将该套接字关联一个IP地址和端口号,可以使用系统调用bind来实现。

1
2
#include <sys/socket.h>
int bind(int socket, const struct sockaddr *address, size_t address_len);

bind系统调用把参数address中的地址分配给与文件描述符socket关联的套接字,地址结构的长度由参数address_len传递。

每种套接字域都有其自己的格式,对于 AF_INET 域来说,套接字地址由结构 socket_in来指定,它至少包含以下几个成员:

1
2
3
4
5
struct sockaddr_in {
short int sin_family; // AF_INET
unsigned short int sin_port; // 端口号
struct in_addr sin_addr; // IP地址
};

成员sin_port表示套接字的端口号。对于客户套接字,我们一般不需要指定套接字的端口号,而对于服务器套接字,我们需要指定套接字的端口号以便让客户正确向服务器发送数据。如果不需要指定端口号,可以将sin_port的值赋为0。

成员sin_addr表示套接字的地址,即机器的IP地址。如果我们没特别为套接字绑定IP地址,可以让操作系统选择一个,即sin_addr使用地址0.0.0.0,使用INADDR_ANY来表示这个地址常量。

对一个套接字进行命名的代码如下:

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
#include <sys/socket.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <netinet/in.h>

int main(int argc, char **argv)
{
// 创建套接字
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) {
perror("cannot create socket");
return 0;
}

printf("created socket, fd: %d\n", fd);

// 命名套接字
struct sockaddr_in myaddr;
memset((void *)&myaddr, 0, sizeof(myaddr));
myaddr.sin_family = AF_INET;
myaddr.sin_addr.s_addr = htonl(INADDR_ANY);
myaddr.sin_port = htons(6240);
if (bind(fd, (struct sockaddr *)&myaddr, sizeof(myaddr)) < 0) {
perror("bind failed");
return 0;
}
printf("bind complete, port number: %d\n", ntohs(myaddr.sin_port));

exit(0);
}

htonl(host to network, long,长整数从主机字节序到网络字节序的转换)htons(host to network, short,短整数从主机字节序到网络字节序的转换)这两个函数用于字机字节序和网络字节序的转换。

步骤三a:服务器接收客户消息

不同于TCP提供的面向连接的可靠字节流协议,UDP 是无连接不可靠的数据报协议。服务器不接收来自客户的连接,而只管调用recvfrom系统调用,等待客户的数据到达。recvfrom的声明如下:

1
2
#include <sys/socket.h>
int recvfrom(int socket, void *buffer, size_t length, int flags, struct sockaddr *src_addr, socklen_t *src_len);

recvfrom的参数说明如下。

  • socket:创建的套接字描述符
  • buffer:指向输入缓冲区的指针
  • length:缓冲区大小
  • flags:在本文中,可以将 flags 置为0即可
  • src_addr:指向客户套接字地址的指针
  • src_len:地址长度

recvfrom的返回值为读入数据的长度。

我们来看下服务器是如何使用recvfrom来接收客户的数据。

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
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <netdb.h>
#include <arpa/inet.h>

#define BUFSIZE 2048
#define SERVICE_PORT 6240

int main(int argc, char **argv)
{
struct sockaddr_in myaddr; // 服务器地址
struct sockaddr_in remaddr; // 客户地址
socklen_t addrlen = sizeof(remaddr);
int recvlen;
int fd;
unsigned char buf[BUFSIZE];

// 创建套接字
if ((fd = socket(AF_INET, SOCK_DGRAM, 0)) < 0) {
perror("cannot create socket\n");
return 0;
}

// 命名套接字
memset((char *)&myaddr, 0, sizeof(myaddr));
myaddr.sin_family = AF_INET;
myaddr.sin_addr.s_addr = htonl(INADDR_ANY);
myaddr.sin_port = htons(SERVICE_PORT);
if (bind(fd, (struct sockaddr *)&myaddr, sizeof(myaddr)) < 0) {
perror("bind failed");
return 0;
}

// 服务器接收客户消息
while (1) {
printf("waiting on port %d\n", SERVICE_PORT);
recvlen = recvfrom(fd, buf, BUFSIZE, 0, (struct sockaddr *)&remaddr, &addrlen);
printf("received %d bytes\n", recvlen);
if (recvlen > 0) {
buf[recvlen] = 0;
printf("received message: \"%s\"\n", buf);
}
}
}

服务器在循环里面不断调用recvfrom函数,接收客户的数据,并输出接收到的客户数据的长度和具体内容。

步骤三b:客户向服务器发达消息

UDP是无连接的,故客户可以直接向服务器发送消息而不需要建立连接。客户使用sendto系统调用向服务器发送消息:

1
2
#include <sys/socket.h>
int sendto(int socket, const void *buffer, size_t length, int flags, const struct sockaddr *dest_addr, socklen_t dest_len);

sendto函数的参数说明如下:

  • socket:创建的套接字描述符
  • buffer:输出缓冲区的指针
  • length:缓冲区大小
  • flags:正常应用中,flags一般设置为0
  • dest_addr:指向服务器套接字地址的指针
  • dest_len:地址长度

以下代码演示客户如何使用UDP套接字向服务器发送消息:

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
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <netdb.h>
#include <sys/socket.h>

#define BUFLEN 2048
#define MSGS 5
#define SERVICE_PORT 6240

int main(void)
{
struct sockaddr_in myaddr, remaddr;
int fd, i, slen = sizeof(remaddr);
const char *server = "127.0.0.1"; // 服务器IP
char buf[BUFLEN];

// 创建套接字
if ((fd=socket(AF_INET, SOCK_DGRAM, 0))==-1)
printf("socket created\n");

// 命名套接字
memset((char *)&myaddr, 0, sizeof(myaddr));
myaddr.sin_family = AF_INET;
myaddr.sin_addr.s_addr = htonl(INADDR_ANY);
myaddr.sin_port = htons(0); // 无须指定特定的端口
if (bind(fd, (struct sockaddr *)&myaddr, sizeof(myaddr)) < 0) {
perror("bind failed");
return 0;
}

// 构造服务器的地址
memset((char *) &remaddr, 0, sizeof(remaddr));
remaddr.sin_family = AF_INET;
remaddr.sin_port = htons(SERVICE_PORT);
if (inet_aton(server, &remaddr.sin_addr)==0) {
fprintf(stderr, "inet_aton() failed\n");
exit(1);
}

// 客户向服务器发送消息
for (i=0; i < MSGS; i++) {
printf("Sending packet %d to %s port %d\n", i, server, SERVICE_PORT);
sprintf(buf, "This is packet %d", i);
if (sendto(fd, buf, strlen(buf), 0, (struct sockaddr *)&remaddr, slen)==-1)
perror("sendto");
}

// 关闭套接字
close(fd);
return 0;
}

上面代码中,为方便起见,我们使用了inet_aton来构造服务器的套接字地址,inet_aton的作用是将一个字符串IP地址转换为一个32位的网络字节序的IP地址。

执行客户程序,可以看到以下输出:

$ ./send
Sending packet 0 to 127.0.0.1 port 6240
Sending packet 1 to 127.0.0.1 port 6240
Sending packet 2 to 127.0.0.1 port 6240
Sending packet 3 to 127.0.0.1 port 6240
Sending packet 4 to 127.0.0.1 port 6240

服务器程序则看到以下输出:

$ ./recv
waiting on port 6240
received 16 bytes
received message: “This is packet 0”
waiting on port 6240
received 16 bytes
received message: “This is packet 1”
waiting on port 6240
received 16 bytes
received message: “This is packet 2”
waiting on port 6240
received 16 bytes
received message: “This is packet 3”
waiting on port 6240
received 16 bytes
received message: “This is packet 4”
waiting on port 6240

步骤四:关闭套接字

操作系统为每个套接字分配了一个文件描述符,为了让操作系统回收该文件描述符,可以使用 close 系统调用:

1
close(fd);

参考资料

  1. Linux程序设计(第4版),Neil Matthew等著,人民邮电出版社,2010年
  2. UNIX 网络编程卷1:套接字联网API(第三版), W.Richard Stevens 等著
  3. https://www.cs.rutgers.edu/~pxk/417/notes/sockets/udp.html