简单的 UDP 网络程序
文章目录:
- 简单的UDP网络程序
- 服务端创建套接字
- 服务端绑定
- 启动服务器
- udp客户端
- 本地测试
- INADDR_ANY
- 地址转换函数
- 关于 inet_ntoa
简单的UDP网络程序
服务端创建套接字
我们将服务端封装为一个类,当定义一个服务器对象之后,需要立即进行初始化服务器,在 UDP 网络程序中,初始服务器的第一个步骤就是创建套接字。我们使用 socket 函数创建套接字。
socket 函数的定义如下所示:
int socket(int domain, int type, int protocol);
参数说明:
domain
:整数,指定通信域(Communication Domain)。在同一主机上的进程之间进行通信时,我们使用 POSIX 标准定义的 AF_LOCAL。在不同的主机通过 IPv4 连接的进程之间通信时,我们使用 AF_INET,对于通过 IPv6 连接的进程之间进行通信时,我们使用 AF_INET6。type
:通信类型(Communication Type)。SOCK_STREAM:TCP(可靠的、面向连接的);SOCK_DGRAM:UDP(不可靠的、无连接的)。protocol
:互联网协议的协议值,通常为0。这与数据包的 IP 头中的协议字段中显示的数字相同。
返回值:套接字创建成功返回一个文件描述符,否则返回-1,同时错误码被设置。
在初始化服务器创建套接字时,需要调用 socket 函数创建套接字,我们需要填入的协议家族是 AF_INET(PF_INET),因为我们需要进行的是网络通信。服务器类型填入 SOCK_DGRAM ,因为编写的 UDP 服务器是面向数据报的。第三个参数设置为0,表示使用默认的传输协议。
下面是使用这些参数创建套接字的示例:
#include<iostream>
#include<sys/socket.h>
class UdpServer
{
public:
void init()
{
// 创建套接字
sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd_ < 0)
{
std::cerr << "Failed to create socket." << std::endl;
exit(1);
}
std::cout << "socket create success, sockfd: " << sockfd_ << std::endl;
// ......
}
private:
int sockfd_; // 文件描述符
};
我们对其进行一个测试,查看套接字的创建是否成功。
int main()
{
UdpServer *svr=new UdpServer();
svr->init();
return 0;
}
运行测试结果:
服务端绑定
在套接字创建成功之后,需要将其与网络相关联,以便进行网络通信。对于一个 UDP 服务器,绑定操作是必要的。通过绑定,将套接字与指定的IP地址和端口号关联起来,以便监听和处理该地址上的网络数据。
使用 bind 函数将套接字与特定的IP地址和端口号进行绑定,bind 函数的定义如下:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数说明:
sockfd
:需要绑定的套接字描述符。addr
:指向要绑定的地址结构的指针,需要将其转化为 struct sockaddr 类型。addrlen
:地址结构长度。
返回值:
- 绑定成功返回0。失败则返回-1,且错误码被设置。
bind 函数的主要作用就是将套接字与指定的地址进行关联,以便在该地址上进行监听和处理网络数据。调用 bind 之前,需要确保套接字已经创建成功。
在绑定套接字之前,需要定义一个 struct sockaddr_in 结构,并将网络属性信息填充到该结构体中。由于 struct sockaddr_in 结构体中的一些字段是可选的,建议在填充网络信息之前先将结构体变量进行清空,然后填充协议家族、端口号、IP地址等信息。
下列示例展示了如何使用 bind 函数将套接字绑定到指定的IP地址和端口号:
class UdpServer
{
public:
UdpServer(int port = 8080, std::string ip = "")
: port_((uint16_t)port), ip_(ip), sockfd_(-1)
{
}
~UdpServer() {}
void init()
{
// 1.创建套接字
sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd_ < 0)
{
std::cerr << "Failed to create socket." << std::endl;
exit(1);
}
std::cout << "socket create success, sockfd: " << sockfd_ << std::endl;
// 2.绑定网络信息,指明IP+port
// 2.1 先填充基本信息到 struct sockaddr_in
struct sockaddr_in local; // lock在用户栈上开辟的空间 -> 临时变量 -> 写入内核中
bzero(&local, sizeof(local)); // bzero
local.sin_family = AF_INET; // 填充协议家族,域
local.sin_port = htons(port_); // 填充服务器对应的端口信息,一定会发给对方,_port一定会到网络中
// local.sin_addr // 服务器都必须具有IP地址("xx.yy.zz.aaa"字符串风格点分十进制) -> 4字节IP -> uint32_t ip
local.sin_addr.s_addr = ip_.empty() ? htonl(INADDR_ANY) : inet_addr(ip_.c_str());
// 2.2 绑定网络信息
if (bind(sockfd_, (struct sockaddr *)&local, sizeof(local)) == -1)
{
std::cerr << "Failed to bind socket." << std::endl;
exit(2);
}
std::cout << "Socket bound successfully." << std::endl;
}
private:
uint16_t port_; // 服务器必须得有端口号信息
std::string ip_; // 服务器必须得有IP地址
int sockfd_; // 文件描述符
};
启动服务器
UDP 服务器的初始化在上面已经完成。服务器初始化完成之后,就可以启动服务器并提供服务了。服务器通常是以循环的方式运行的,以便持续接收和处理客户端请求。UDP 服务器在接收到客户端发送的数据后,可以直接读取这些数据,而无需建立连接。
recvfrom函数:
recvfrom 函数是在网络编程中使用的一个系统调用函数,用于从一个指定的套接字接收数据,并将数据存储到指定的缓冲区中。
recvfrom 函数的定义如下:
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
- sockfd:套接字描述符,表示从该套接字描述符索引的文件中读取数据。
- buf:指向接收数据的缓冲区。
- len:期望读取数据的字节数。
- flags:可选的标志参数,用于控制接收操作的行为。常用的标志有:0、MSG_DONTWAIT。
- src_addr:指向用于存储发送方地址信息的 struct sockaddr 结构体的指针,包括协议家族、IP地址、端口号等。
- addrlen:src_addr 结构体的长度,及其可用空间大小,这是一个输出型参数。
当使用 recvfrom 函数接收 UDP 数据时,除了获取数据内容外,还可以获取发送方的网络属性信息,包括IP地址和端口号。调用 recvfrom 函数之前,需要将 addrlen 参数设置为接收方地址结构体的大小,以确保函数可以正确填充发送方的地址信息。
接下来使用下列函数启动服务器:
当使用 recvfrom 函数读取客户端数据后,可以将读取到的数据视为字符串,并将最后一个位置设置为 ‘\0’ ,以便于进行输出。此时,我们也获取到了客户端的IP地址和端口号。recvfrom 函数调用成功之后返回的端口号是网络序列(以大端字节序表示),我们需要调用 ntohs 函数将其转换为主机序列(与本地字节序相匹配)。同样的,获取的IP地址也需要用 inet_ntoa 函数进行转化。
class UdpServer
{
public:
void start()
{
// 服务器设计的时候都是死循环
char inbuffer[1024]; // 储存读取到的数据
char outbuffer[1024]; // 储存发送的数据
while (true)
{
struct sockaddr_in peer; // 输出型参数
socklen_t len = sizeof(peer); // 输入型参数
ssize_t s = recvfrom(sockfd_, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (s > 0)
inbuffer[s] = 0; // 将其当作字符串
else if (s == -1)
{
std::cerr << "recvfrom:" << strerror(errno) << ":" << sockfd_ << endl;
continue; // 调用失败,继续进行读取,服务器不能因为一个客户端连接失败就退出
}
// 读取成功,除了读取到对方的数据,还要读取到对方的网络地址[ip,port]
std::string peerIp = inet_ntoa(peer.sin_addr);
uint32_t peerPort = ntohs(peer.sin_port);
}
}
private:
uint16_t port_; // 服务器必须得有端口号信息
std::string ip_; // 服务器必须得有IP地址
int sockfd_; // 文件描述符
};
在构建服务器时,我们可以引入命令行参数用于指定服务器的IP地址和端口号。使用云服务器,实际上不需要传入IP地址,直接运行程序时指定端口即可,这里可以将IP地址设置为(127.0.0.1),它代表本地主机或本地环回地址,相当于 localhost 。
以下为一个示例代码,演示如何通过命令行参数传递端口号,并将IP地址设置为本地环回地址:
static void Usage(const std::string proc)
{
cout << "Usage:\n\t" << proc << " port [ip]" << endl;
}
// ./udpServer port [ip]
int main(int argc,char *argv[])
{
if(argc!=2&&argc!=3)
{
Usage(argv[0]);
return 1;
}
std::string ip="127.0.0.1";
uint16_t port=atoi(argv[1]);
UdpServer svr(port,ip);
svr.init();
svr.start();
}
如下所示,运行程序并加上端口号就可以创建 udp 服务器成功了:
使用 netstat
命令来查看当前网络的状态,下图标出的就是当前运行的 udpServer 程序:
netstat
是一个用于显示网络连接、路由表和网络接口等相关信息的命令。以下是 netstat
常用的选项:
-a
(all):显示所有的sockets(包括监听和非监听的)。-t
(tcp):显示 TCP 协议相关的连接信息。-u
(udp):显示 UDP 协议相关的连接信息。-n
(numeric):以数字形式显示IP地址和端口号,不进行主机名和服务名的解析。-p
(program):显示与每个连接关联的进程/程序的 PID 和名称。-l
(listening):显示每个处于监听状态的 sockets。
注意:不同操作系统上的 netstat 命令可能会不同,包括选项的名称和支持的功能。因此,在使用 netstat 命令时可以通过 netstat --help
来查看系统文档使用说明。
udp客户端
udp客户端的实现步骤如下所示,用于与服务器进行 UDP 通信:
参数检查
:通过检查命令行参数,来确保命令行输入的参数个数是否正确,这是为了确保程序能够正确获取服务器的IP地址和端口号,避免后续出现错误。获取服务端的IP地址和端口号
:根据命令行参数获取服务器的IP地址和端口号,并将它们存储在变量 server_ip 和 server_port 中。这样客户端就获取到了需要连接服务器的地址信息。创建 UDP 套接字
:使用 socket 函数创建一个 UDP 套接字,即创建一个用于网络通信的套接字。AF_INET 参数表示使用 IPv4 地址,SOCK_DGRAM 参数表示使用数据报(UDP) 套接字类型。创建套接字成功之后,会返回一个文件描述符,用于后续该套接字的操作。设置服务器地址信息
:UDP 通信中,需要指定连接的服务器地址信息。这里通过填充 server 结构体来实现。bzero 函数将 server 结构体清零。sin_family 表示地址族为 IPv4 ,sin_port 表示服务器的端口号,sin_addr.s_addr 表示服务器的IP地址。
// ./udpServer server_ip server_port
// 客户端要连接服务端,需要知道server对应的IP和port
class UdpClient
{
public:
UdpClient(std::string ip = "", uint16_t port = 8080)
: ip_(ip), port_(port), sockfd_(-1)
{
}
void init()
{
// 创建套接字
sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd_ < 0)
{
std::cerr << "socket create error" << std::endl;
exit(1);
}
}
void start()
{
// 填写服务器对应信息
struct sockaddr_in peer;
bzero(&peer, sizeof peer);
peer.sin_family = AF_INET;
peer.sin_port = htons(port_);
peer.sin_addr.s_addr = inet_addr(ip_.c_str());
std::string buffer;
// 启动客户端
while (true)
{
std::cout << "Please Enter# ";
getline(cin, buffer);
// 发送消息给server
sendto(sockfd_, buffer.c_str(), buffer.size(), 0, (const struct sockaddr *)&peer, sizeof(peer));
}
}
~UdpClient()
{
if (sockfd_ >= 0)
close(sockfd_);
}
private:
std::string ip_;
uint16_t port_;
int sockfd_;
};
static void Usage(const std::string name)
{
cout << "Usage:\n\t" << name << " server_ip server_port" << endl;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(2);
}
// 根据命令行,设置需要访问的服务器IP
std::string server_ip = argv[1];
uint16_t server_port = atoi(argv[2]);
UdpClient client(server_ip, server_port);
client.init();
client.start();
return 0;
}
sendto函数
sendto 函数用于在 UDP 通信中发送数据,它的函数定义如下:
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
参数说明:
-
sockfd:使用 socket 函数创建的套接字描述符。
-
buf:指向要发送的数据的缓冲区的指针。数据可以是任意类型的指针,通常是一个 char 数组或字符串。
-
len:要发送的数据的长度,单位是字节。
-
flags:指定发送操作的可选标志。一般使用0或与 MSG_DONTROUTE 等特殊标志进行按位或运算。有以下常用标志:
MSG_DONTROUTE
:不使用路由表发送数据,直接发送到目的地址。
MSG_OOB
:发送外带数据。
MSG_NOSIGNAL
:在发送数据时忽略 SIGPIPE 信号,避免导致进程终止。 -
dest_addr:对端网络相关的属性信息。在 UDP 通信中,该参数指向 sockaddr 结构体的指针,其中包含了目标服务器的IP地址和端口号。
-
addrlen:传入 dest_addr 结构体的长度,通常是 sizeof(struct sockaddr_in)。
本地测试
udp 的服务端和客户端的代码都已经编写完成。因此,我们可以先在本地进行测试,使用本地环回地址(127.0.0.1)作为服务器的IP地址,客户端可以连接到该地址来与服务器进行通信。同时,我们需要确保服务器绑定的端口号与客户端连接时指定的端口号一致。
当客户端和服务端成功建立连接并进行通信时,可以通过使用 netstat
命令来看网络信息来确认连接的建立。
INADDR_ANY
INADDR_ANY 是一个IP地址,当我们不想将套接字绑定到任何特定的IP时使用。在实现通信时,我们需要将套接字绑定到IP地址。当我们不知道自己及其的IP地址或者其它情况时,可以使用特殊的IP地址 INADDR_ANY。它允许我们的服务器接收任何接口作为目标的数据包。
在进行网络测试时,你需要确保服务器能够通过公网IP地址进行访问。然而,直接将服务器绑定到公网IP可能会导致绑定失败的问题。
在云服务器中,你所获得的公网IP地址并不一定是真正的公网IP,而是有云服务器厂商提供的内部IP地址,这些内部IP地址无法直接在服务器代码中进行绑定。
为了让服务器能够通过公网IP进行访问,可以使用一个特殊的绑定值,即 INADDR_ANY
。这个值是系统提供的宏,对应的数值为0。通过将服务器绑定到 INADDR_ANY
,就可以让服务器接收来自任意IP地址的连接请求。
若想要我们写的服务端能够被外部网络进行访问,我们可以这样做:
- 在服务器代码中,将与IP地址相关的代码去掉。这样可以使服务器动态绑定到可用的IP地址。
- 填充 struct sockaddr_in 结构体时,将IP地址设置为 INADDR_ANY 。这样服务器就可以接收到来自任意IP地址的连接请求了。
struct sockaddr_in peer;
bzero(&peer, sizeof peer);
peer.sin_family = AF_INET;
peer.sin_port = htons(port_);
peer.sin_addr.s_addr = INADDR_ANY;
注意:INADDR_ANY
的值是0,它不需要进行网络字节序的转化。因此,在设置时无需进行大小端的处理。
编译并运行修改后的代码时,再次使用 netstat
命令查看网络连接情况时,该服务器的IP地址变成了 0.0.0.0
,意味着该服务器可以接收任意主机发起的连接请求:
地址转换函数
这里主要介绍的是 IPv4 的 socket 网络编程。sockaddr_in 中的成员 struct in_addr sin_addr 表示32位的IP地址。但是我们通常使用点分十进制的字符串表示IP地址,以下函数可以在字符串表示和 in_addr 表示之间进行转换:
字符串转 in_addr 的函数:
#include <arpa/inet.h>
// 将字符串cp表示的IP地址转换为in_addr结构体,并将结果存储在inp中
int inet_aton(const char *cp, struct in_addr *inp);
// 将字符串cp表示的IP地址转换为in_addr_t类型的值
in_addr_t inet_addr(const char *cp);
// 将字符串从src表示的IP地址转换为指定地址族af的二进制表示,并将转换后的结果存储在dst中
int inet_pton(int af, const char *src, void *dst);
in_addr转字符串的函数:
// 将in_addr结构体中的IP地址转换为字符串形式
char *inet_ntoa(struct in_addr in);
// 将指定地址族af的二进制表示src转化为字符串形式,并将结果存储在dst中
const char *inet_ntop(int af, const void *src,char *dst, socklen_t size);
其中 inet_pton 和 inet_ntop 不仅可以转换 IPv4 的 in_addr ,还可以转换 IPv6 的 in6_addr 因此函数接口是 void *addrptr 。
关于 inet_ntoa
inet_ntoa 这个函数的返回值类型是 char*,很显然这个函数在自己内部申请了一块内存来报错IP结果,那么是否需要调用者手动释放呢?
man 手册上说,inet_ntoa 函数,是把这个结果放在了静态存储区。这时不需要我们进行手动释放。那么,若多次调用该函数,会有什么样的效果呢?
请看代码:
#include <iostream>
#include <netinet/in.h>
#include <arpa/inet.h>
using namespace std;
int main()
{
struct sockaddr_in addr1;
struct sockaddr_in addr2;
addr1.sin_addr.s_addr = 0;
addr2.sin_addr.s_addr = 0xffffffff;
char *ptr1 = inet_ntoa(addr1.sin_addr);
char *ptr2 = inet_ntoa(addr2.sin_addr);
cout << "ptr1:" << ptr1 << " ptr2:" << ptr2 << endl;
return 0;
}
运行结果如下所示:
由于 inet_ntoa 函数使用了静态缓冲区,每次调用该函数时,返回的指针都会指向同一个缓冲区。这意味着如果在多个地方同时使用了 inet_ntoa 返回值,并且后续的调用覆盖了之前的结果,那么之前获取的字符串指针将变为无效。
思考:如果有多个线程调用 inet_ntoa,是否会出现异常情况呢?
- 在 APUE 中,明确提出了 inet_ntoa 不是线程安全的函数。因为它使用了一个全局共享的缓冲区来保存转化后的字符串。所以多线程环境下,该函数可能会导致竞争条件,从而出错。
- 但是在 centos7 上测试,并没有出现问题,这可能是在内部加了互斥锁来保证线程安全性。
- 在多线程环境下,推荐使用 inet_ntop 函数,它是线程安全的。inet_ntop 函数要求调用者提供一个缓冲区来存储转换后的字符串,避免了静态缓冲区的共享和竞争条件,可以规避线程安全的问题。
多线程调用 inet_ntoa 代码示例如下:
#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <pthread.h>
void *Func1(void *p)
{
struct sockaddr_in *addr = (struct sockaddr_in *)p;
while (1)
{
char *ptr = inet_ntoa(addr->sin_addr);
printf("addr1: %s\n", ptr);
}
return NULL;
}
void *Func2(void *p)
{
struct sockaddr_in *addr = (struct sockaddr_in *)p;
while (1)
{
char *ptr = inet_ntoa(addr->sin_addr);
printf("addr2: %s\n", ptr);
}
return NULL;
}
int main()
{
pthread_t tid1 = 0;
struct sockaddr_in addr1;
struct sockaddr_in addr2;
addr1.sin_addr.s_addr = 0;
addr2.sin_addr.s_addr = 0xffffffff;
pthread_create(&tid1, NULL, Func1, &addr1);
pthread_t tid2 = 0;
pthread_create(&tid2, NULL, Func2, &addr2);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
return 0;
}
在该程序测试中并没有出现问题,但这并不意味着 inet_ntoa 函数在多线程环境中是安全的。为了确保线程安全,仍然建议使用线程安全的函数 inet_ntop 来替代 inet_ntoa 。