1. UDP通信

UDP协议是一种无连接的,不可靠的通信协议。下面通过信件的例子说明UDP协议的特点:我们在寄信之前会在信封上填好收件人和寄件人的地址,然后投入邮箱即可,当然信件的特点是我们无法确保对方是否收到,因为在运输过程可能会发生信件丢失的情况。与之类似,UDP协议就是这样一种不可靠的协议。
那么是否说明TCP协议是一种更好的协议呢?
如果从可靠性上说,TCP协议确实是一种更好的协议,但相对与TCP,UDP在结构上更加简单,不会发送类似于ACK的应答包,也不会发送类似于SEQ的流控制,因此效率上比TCP高出很多。除此之外,UDP编程也很简单,同时其丢包概率也不像我们想象的那么高,因此在更重视性能而非可靠性的情况下,UDP是一种很好的选择。

2. UDP通信原理

经过前面的学习,我们都知道UDP工作与IP层之上的传输层,那么在实际数据传输过程中UDP层与IP层分别承担什么责任呢?请看下面这张图:
image.png
在数据过程中,IP层仅负责将数据从主机B运送到主机A,当数据到达主机A时,还是依靠操作系统将数据分配给相应的套接字。

3. 客户端与服务端

类似于寄信,我们把信箱比作UDP套接字,当填上不同的收件人时,就可以将信寄给不同的收件人。UDP与此类似,通过手动指定对端,我们可以使用一个UDP套接字给不同的用户发送数据。
下面我们来介绍一下相关的API函数:

3.1 socket函数

函数原型为:

  1. int socket(int domain, int type, int protocol); // 当type为 SOCK_DGRAM 时表示时UDP

3.2 sendto函数

函数原型为:

  1. #include <sys/types.h>
  2. #include <sys/socket.h>
  3. /**
  4. * @brief 用于UDP发送数据
  5. * @param[in] sockfd 套接字
  6. * @param[in] buf 要发送的数据
  7. * @param[in] len 数据长度
  8. * @param[in] flags 标志,一般置为0
  9. * @param[in] dest_addr 对端地址信息
  10. * @param[in] addr_len 对端地址信息长度
  11. * @return -1:发送失败 len:发送的数据长度
  12. */
  13. ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);

3.3 recvfrom函数

函数原型为

  1. #include <sys/types.h>
  2. #include <sys/socket.h>
  3. /**
  4. * @brief 用于UDP接收数据
  5. * @param[in] sockfd 套接字
  6. * @param[in] buf 要发送的数据
  7. * @param[in] len 数据长度
  8. * @param[in] flags 标志,一般置为0
  9. * @param[out] src_addr 数据发端地址信息
  10. * @param[out] addrlen 数据发端地址长度
  11. * @return -1:接收失败 len:接收的数据长度
  12. */
  13. ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
  14. struct sockaddr *src_addr, socklen_t *addrlen);

3.4 简单示例

  1. server.c
  2. char buf[BUF_SIZE];
  3. int sock_server;
  4. struct sockaddr_in addr;
  5. memset(&addr, 0, sizeof(addr));
  6. struct sockaddr_in addr_client;
  7. socklen_t addr_len;
  8. sock_server = socket(PF_INET, SOCK_DGRAM, 0);
  9. if (-1 == sock_server)
  10. {
  11. printf("申请套接字错误 \n");
  12. return -1;
  13. }
  14. // 初始化网络地址
  15. addr.sin_family = AF_INET;
  16. addr.sin_addr.s_addr = htonl(INADDR_ANY);
  17. addr.sin_port = htons(atoi(argv[1]));
  18. if (-1 == bind(sock_server, (struct sockaddr *)&addr, sizeof(addr)))
  19. {
  20. printf("绑定套接字到本地地址失败 \n");
  21. return -1;
  22. }
  23. while (1)
  24. {
  25. int len = recvfrom(sock_server, buf, BUF_SIZE, 0, (struct sockaddr *)&addr_client, &addr_len);
  26. buf[len] = '\0';
  27. sendto(sock_server, buf, strlen(buf), 0, (struct sockaddr *)&addr_client, sizeof(addr_client));
  28. }
  29. close(sock_server);
  30. // client.c
  31. char buf[BUF_SIZE];
  32. int sock_client;
  33. struct sockaddr_in addr;
  34. memset(&addr, 0, sizeof(addr));
  35. struct sockaddr_in addr_server;
  36. socklen_t addr_len;
  37. sock_client = socket(PF_INET, SOCK_DGRAM, 0);
  38. if (-1 == sock_client)
  39. {
  40. printf("申请套接字错误 \n");
  41. return -1;
  42. }
  43. // 初始化网络地址
  44. addr.sin_family = AF_INET;
  45. addr.sin_addr.s_addr = inet_addr(argv[1]);
  46. addr.sin_port = htons(atoi(argv[2]));
  47. while (1)
  48. {
  49. fputs("input message (q for quit):", stdout);
  50. fgets(buf, BUF_SIZE, stdin);
  51. if (0 == strcmp(buf, "q\n") || 0 == strcmp(buf, "Q\n"))
  52. {
  53. break;
  54. }
  55. sendto(sock_client, buf, strlen(buf), 0, (struct sockaddr *)&addr, sizeof(addr));
  56. int len = recvfrom(sock_client, buf, BUF_SIZE, 0, (struct sockaddr *)&addr_server, &addr_len);
  57. buf[len] = '\0';
  58. printf("receive message from server:%s \n", buf);
  59. }

与TCP客户端程序不同的是,UDP客户端无需调用connect函数,仅通过指定对端地址信息便可直接发送数据,当然在UDP程序中也可以使用connect函数,但含义与TCP客户端并不相同,关于这一点我们下面详细解释。<br />除了`connect`函数,UDP客户端并没有为自己分配IP地址和端口,这是因为在调用`sendto函数`时,系统会自动选择本机IP地址和空闲端口号作为本地网络地址。

4. UDP数据包特性

我们都知道TCP连接是基于字节流的,其不存在”数据边界”,即当发送端多次发送数据时,只要接收端接收缓冲足够大,便可一次将这些数据接收回来,这也是”粘包”问题的由来。
UDP数据包不同于TCP,其数据包是存在”数据边界”的,也就是说:发送端发送三次数据时,接收端必须接收三次才能完成全部数据的接收,也就不存在”粘包”问题。
下面看一个例子:

  1. // server.c
  2. for (int i = 0; i < 3; ++i)
  3. {
  4. sleep(5);
  5. int len = recvfrom(sock_server, buf, BUF_SIZE, 0, (struct sockaddr *)&addr_client, &addr_len);
  6. buf[len] = '\0';
  7. printf("第%d次接收,接收数据为:%s \n", i + 1, buf);
  8. }
  9. // client.c
  10. for (int i = 0; i < 3; ++i)
  11. {
  12. sendto(sock_client, "hello world", strlen("hello world"), 0, (struct sockaddr *)&addr, sizeof(addr));
  13. sendto(sock_client, "nice to meet you", strlen("nice to meet you"), 0, (struct sockaddr *)&addr, sizeof(addr));
  14. sendto(sock_client, "bye", strlen("bye"), 0, (struct sockaddr *)&addr, sizeof(addr));
  15. }
  16. 运行结果为:
  17. 1次接收,接收数据为 hello world
  18. 2次接收,接收数据为 nice to meet you
  19. 第三次接收,接收数据为 bye

5. UDP已连接套接字与未连接套接字

根据前面内容,我们知道UDP可以在发送前自由指定对端的网络地址,这种套接字被称为”未连接套接字”,在调用sendto``函数发送未连接套接字上的数据时,会经历下面三个步骤:

  1. 向UDP套接字注册对端IP地址和端口信息
  2. 发送数据
  3. 清除UDP套接字上的端口信息

在每次调用sendto函数时都会重复以上三个步骤,当我们只需要固定给一个对端传送数据时会极大的浪费效率,这时可以利用connect函数将对端地址信息注册到UDP套接字上(这样的UDP套接字叫做“已连接套接字”),这样每次发送时就省去了a、c两个步骤,效率提升非常多,此时我们也可以通过read、write函数进行数据的收发。
我们将前面的客户端改为“已连接套接字”模式:

  1. // client.c
  2. char buf[BUF_SIZE];
  3. int sock_client;
  4. struct sockaddr_in addr;
  5. memset(&addr, 0, sizeof(addr));
  6. struct sockaddr_in addr_server;
  7. socklen_t addr_len;
  8. sock_client = socket(PF_INET, SOCK_DGRAM, 0);
  9. if (-1 == sock_client)
  10. {
  11. printf("申请套接字错误 \n");
  12. return -1;
  13. }
  14. // 初始化网络地址
  15. addr.sin_family = AF_INET;
  16. addr.sin_addr.s_addr = inet_addr(argv[1]);
  17. addr.sin_port = htons(atoi(argv[2]));
  18. // 将对端地址注册到套接字中
  19. if (-1 == connect(sock_client, (struct sockaddr *)&addr, sizeof(addr)))
  20. {
  21. printf("注册本地地址到套接字中失败 \n");
  22. return -1;
  23. }
  24. while (1)
  25. {
  26. fputs("input message (q for quit):", stdout);
  27. fgets(buf, BUF_SIZE, stdin);
  28. if (0 == strcmp(buf, "q\n") || 0 == strcmp(buf, "Q\n"))
  29. {
  30. break;
  31. }
  32. write(sock_client, buf, strlen(buf));
  33. int len = read(sock_client, buf, BUF_SIZE);
  34. buf[len] = '\0';
  35. printf("receive message from server:%s \n", buf);
  36. }