前面我们所实现的通信程序,是基于 TCP 协议的,有连接的方式。也就是在创建 socket 的时候,socket 函数第二个参数总是指定为 SOCK_STREAM,第三个 protocol 参数指定为 0,即默认使用 TCP 协议。
有连接,是指像打电话一样,首先拨通电话(connect),对方接听(accept),然后各自拿着自己的话筒(socket)开始通信。
而无连接不一样,事先不需要建立连接,它类似于发信息。如果要使用无连接的方式通信,在创建 socket 的时候,把 socket 类型指定为 SOCK_DGRAM,同时第三个参数 protocol 参数指定为 0,表示使用默认的 UDP 协议。
有关 TCP 和 UDP 协议的细节,将在 Linux 网络编程学习笔记中详细讨论,所以这里我们只关心怎么编程就行了。
1. 使用 UDP 协议通信的步骤
同样的,UDP 协议通信也需要有一个服务器程序。由于 UDP 协议不需要建立连接,这意味着就不需要被动 socket,因此函数 listen 自然也就派不上用场了。
另一方面,无论是 TCP 通信还是 UDP 通信,socket 都要与套接字地址进行绑定。所以对于服务器来说,如果不事先绑定套接字地址,客户端将找不到它。
- 服务器端程序编写步骤
(1) 创建 socket (socket 函数)
(2) 将 socket 与套接字地址绑定(bind 函数)
(3) 使用 recvfrom 函数接收数据
(4) 使用 sendto 函数发数据
(5) 关闭套接字
- 客户端程序编写步骤
(1) 创建 socket(socket 函数)
(2) 使用 sendto 函数发数据
(3) 使用 recvfrom 函数接收数据
(5) 关闭套接字
在客户端中,我们并没有主动的去 bind 套接字地址,当然你完全可以主动绑定一个,但是这样并没有什么意义,如果在同一台机器中启动多个客户端就肯定会出错。所以,当使用 sendto 函数后,系统为自动为客户端 bind 一个套接字地址(该地址的端口号随机分配)。
2. recvfrom 和 sendto
在 UDP 编程中,最关键的就是这两个函数了,sendto 函数可以指定发送端的套接字地址,recvfrom 函数可以返回对端的套接字地址。这两个函数原型如下:
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
上面的函数中,除了 flags 参数我们不懂,其它参数都有简单。目前我们只需要将 flags 参数设置为 0 就行了。
这两个函数在阻塞 IO 下,都可能会阻塞。比如 recvfrom 会一直阻塞直到有数据到来。sendto 函数也一样,如果缓冲区满的情况下,会阻塞。
3. 大写转换服务器程序的 UDP 版本
这里,我们将上一节的大写转换服务器程序改用 UDP 协议来实现。
3.1 serv 服务器程序
// serv.c
#include <stdio.h>
#include <stdlib.h>
#include <arpa/inet.h>
#define ERR_EXIT(msg) do { perror(msg); exit(1); } while(0)
void upper(char* buf) {
char* p = buf;
while(*p) {
*p = toupper(*p);
++p;
}
}
int main() {
struct sockaddr_in servaddr, cliaddr;
int sockfd, clientfd, ret, n;
socklen_t cliaddrlen;
char buf[64];
// 1. create sockaddr
puts("1. create sockaddr");
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(8080);
// 2. create socket
puts("2. create socket");
// 注意第 2 个参数已经改成了 SOCK_DGRAM
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) ERR_EXIT("socket");
// 3. bind sockaddr
puts("3. bind sockaddr");
ret = bind(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr));
if (ret < 0) ERR_EXIT("bind");
while(1) {
// 注意 recvfrom 的最后一个参数 addrlen 既是输入参数,也是输出参数,所以这里必须要传一个值给它。
cliaddrlen = sizeof(cliaddr);
n = recvfrom(sockfd, buf, 63, 0, (struct sockaddr*)&cliaddr, &cliaddrlen);
// 打印对端的 ip 地址和端口号
printf("%s:%d come in\n", inet_ntoa(cliaddr.sin_addr), ntohs(cliaddr.sin_port));
buf[n] = 0;
puts(buf);
upper(buf);
// 将转换后的数据发送给对端
sendto(sockfd, buf, n, 0, (struct sockaddr*)&cliaddr, cliaddrlen);
}
close(sockfd);
return 0;
}
3.2 cli 客户端程序
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#define ERR_EXIT(msg) do { perror(msg); exit(1); } while(0)
int main() {
int sockfd, ret, n;
char buf[64];
struct sockaddr_in servaddr;
struct sockaddr_in cliaddr;
socklen_t servaddrlen, cliaddrlen;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = inet_addr("127.0.0.1");
servaddr.sin_port = htons(8080);
// 注意第 2 个参数已经改成了 SOCK_DGRAM
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) ERR_EXIT("socket");
while(1) {
scanf("%s", buf);
if (buf[0] == 'q') break;
// 将数据发送给服务器
sendto(sockfd, buf, strlen(buf), 0, (struct sockaddr*)&servaddr, sizeof(servaddr));
// recvfrom 最后两个参数可以为空,表示我们并不关心对端的套接字地址(因为我们本来就知道……)
n = recvfrom(sockfd, buf, 63, 0, NULL, NULL);
buf[n] = 0;
puts(buf);
}
close(sockfd);
return 0;
}
3.3 编译和运行
$ gcc serv.c -o serv
$ gcc cli.c -o cli
图1 基于 UDP 协议的大写转换服务器
4. 总结
- 掌握基于 UDP 协议的基本编程方法