socket编程C实现基于UDP协议的简单通信含详解
socket编程——C++实现基于UDP协议的简单通信(含详解)
文章后面有代码,可以直接复制在Visual Studio 2022中运行(注意:必须是两个项目,客户端服务端各一个,连接在同一网络中,先运行服务端,并且客户端数据发送的目标IP要改为你服务端的IP)
目录
前言
在了解完socket编程的一些基本理论知识后,很想把理论应用到实践,直接搜项目实战的教程,但是在看了几篇博客文章和一些B站的教程后,发现大部分都是不易上手的基于UDP/TCP的聊天系统,不太适合刚接触C++网络编程的同学,所以这里用C++实现简单的UDP通信,可以帮助大家更好的了解socket编程中的一些重要步骤。
提示:我们无法准确记住所有函数,在大部分情况下要通过帮助文档编程,本篇文章就是通过帮助文档带领大家一步一步实现基于UDP协议的简单通信。这个是我们要用到的微软帮助文档。
帮助文档
一、UDP通信框架
在VS中,如果你没关SDL检查,就在前面加这个
#define _WINSOCK_DEPRECATED_NO_WARNINGS
1.服务端
#include<iostream>
using namespace std;
int main() {
//1.加载库
//2.创建socket
//3.创建服务端sockaddr,socket绑定服务端的IP和端口号
//4.做收发的准备,创建客户端sockaddr(不用赋值)
while (true)
{
//5.接收数据
//6.发送数据
}
//7.关闭套接字
//8.卸载库
}
2.客户端
#include<iostream>
using namespace std;
int main() {
//1.加载库
//2.创建socket
//3.做收发的准备,创建服务端sockaddr(用服务端IP和端口号赋值)
while (true)
{
//4.发送数据
//5.接收数据
}
//6.关闭套接字
//7.卸载库
}
二、服务端实现
1.加载库(WSAStartup 函数)
WSAStartup
我们 socket 编程第一个要用到的函数就是 WSAStartup,嘶?这是干嘛的呢?见名知意,W 代表 Windows 操作系统,S 代表 socket(套接字),A 代表应用程序编程接口(API),函数用于初始化 winsock 库,那么这个函数应该怎么用呢?这时就要用到帮助文档了。
开始写代码,函数要什么就写什么
返回值是错误码,就定义 int 变量接。
wVersionRequested 是版本号,类型是 WORD,因为是[in]参数,需要我们调用者给值。lpWSAData 是指向结构体 WSADATA 的指针,因为是[out]参数,不用给值,调用完函数就有值了。
WORD wVersion = MAKEWORD(2, 2);
WSADATA data;
int err = WSAStartup(wVersion, &data);
MAKEWORD(a,b) 是一个宏,用于将两个字节大小的数合并成一个 WORD 类型的值。在这里,MAKEWORD(2, 2) 的作用是将高位字节设为 2,低位字节也设为 2,从而构造出一个 WORD 类型的数值,表示了 Winsock 库的版本号。
头文件和依赖库
编译器不认识这个函数,可是我也不知道要加什么头文件啊?那我们再看帮助文档,一直往下翻。
#include<winsock2.h>
#pragma comment(lib, "Ws2_32.lib")
返回值与检查(WSAStartup 函数)
最后再来个判断
if (err != 0) {
cout << "加载库失败" << endl;
return 1;
}
else if (LOBYTE(data.wVersion) != 2 || HIBYTE(data.wVersion) != 2) {
cout << "库版本号错误" << endl;
WSACleanup();//卸载错误的库
return 1;
}
else {
cout << "加载库成功" << endl;
}
LOBYTE 和 HIBYTE 都是宏用来判断高位和低位的版本号,这个不用了解太深,MAKEWORD(2, 2) 将参数 2 和 2 组合成一个 16 位的无符号整数,高 8 位是第一个参数,低 8 位是第二个参数。
2.创建套接字(socket 函数)
还是先找帮助文档
三个 int 类型的[in]形参,应该传什么呢
参数 1 地址族
af 这个形参说白了就是 IPv4 还是 IPv6,咱们用 IPv4
参数 2 协议类型
type,socket type 描述连接如何工作,常常是 stream(用于 TCP 连接)或 dgram(用于 UDP 服务)。
参数 3 协议
最后一个参数,protocol,就是协议呗
SOCKET sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
返回值与检查(socket 函数)
看返回值要用 SOCKET 类型接,针对错误再加个判断
如果未发生错误, 套接字 将返回引用新套接字的描述符。否则,将返回值 INVALID_SOCKET,并且可以通过调用 来检索特定的错误代码。
SOCKET sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
//阅读帮助文档了解具体含义,三个参数均为宏定义或枚举类型
if (sock == INVALID_SOCKET) {
cout << "创建套接字失败:" << WSAGetLastError() << endl;
//如果日志中出现错误,上方工具栏->错误查找->输入编号
WSACleanup();//卸载库
return 1;
}
else {
cout << "创建套接字成功" << endl;
}
3.创建服务端 sockaddr,套接字绑定服务端的 IP 和端口号(bind 函数)
绑定的函数是 bind,怎么用呢,来看看帮助文档
参数 1 套接字和参数 3 结构体大小
三个参数,第一个 SOCKET 类型的肯定就是我们刚才创建的啊。第三个 namelen,int 类型的一个结构体大小,跟第二个参数有关。
参数 2sockaddr 指针
那么第二个参数就是关键。因为是[in]参数,还是个指向结构体的指针,我们要手动给它结构体中每一个部分赋值。帮助文档里搜 sockaddr。
[in] name
指向要分配给绑定套接字的本地地址的 结构的指针。
这里我们要用第二个 sockaddr_in,因为它详细分成了第一个指定地址族(Address Family),第二个存储端口号,第三个 in_addr 类型的结构体成员,用于存储 IP 地址,第四个用于填充的空间,以保证 sockaddr_in 的大小与 sockaddr 结构体相同。
sockaddr_in addrUDPServer;
addrUDPServer.sin_family = ;
addrUDPServer.sin_port = ;
addrUDPServer.sin_addr.S_un.S_addr = ;
err = bind(sock, (sockaddr*)&addrUDPServer, sizeof(addrUDPServer));
写好后,开始赋值
1. addrUDPServer.sin_family
addrUDPServer.sin_family 和前面的 af 一样就是问你 IPv4 还是 IPv6,连赋值的宏都是一个 AF_INET
在网络编程中,struct sockaddr_in 是用于表示 IPv4 地址的数据结构。其中,sin_family 成员用于指定地址族(Address Family)。
addrUDPServer.sin_family = AF_INET;
2. addrUDPServer.sin_port
addrUDPServer.sin_port 就是端口号的意思,这个端口要自己设置,我的建议自己查查你的电脑都占用了哪些端口号,找一个空闲的,方法:命令行指令 netstat -ano
这里有一个重点,要用 htons 函数转换成网络字节序
绑定端口号,接入网络的设备有很多种,有的用小端有的用大端,网络上统一规定用网络字节序,
绑定端口号表示当先应用程序可以接收发给这个端口号的数据,网络字节顺序是 TCP/IP 中规定好的一种数据表示格式,可以保证数据在不同主机之间传输时能够被正确解释。网络字节顺序采用 big-endian(大端)存储方式。一般操作系统采用的都是小端模式,而通讯协议采用大端模式。
我这里就随便写一个了 htons(12345),应该没有哪个程序占用【狗头】
addrUDPServer.sin_port = htons(12345);
3. addrUDPServer.sin_addr
接下来更是重量级,要绑定网卡了,一看类型又是结构体,服了,谁知道内部结构是啥,只能上帮助文档里搜了,结果进去一看里面是一个 union 共用体,因为它的所有成员共享同一块内存,也就是说我们只要给一个赋值就行,我一眼就盯到最简单的 u_long 类型的 S_addr。
接下来是个重点,IP 的两种数据类型
IP 的两种数据类型
1.十进制四等分的地址字符串类型 “10.10.10.10”
2.网络字节序类型(可以用 ulong 存)
inet_addr:是将一个 IP 地址字符串 转换为 32 位大端网络字节序整数。
inet_ntoa:是将一个 32 位大端网络字节序整数 转换为 IP 地址字符串。其实就是 in_addr 结构体 类型转字符串类型,结构体 in_addr 内部是一个共用体,其中 S_addr 是 ulong 类型
addrUDPServer.sin_addr.S_un.S_addr = INADDR_ANY;//用这宏定义绑定所有网卡
addrUDPServer.sin_addr.S_un.S_addr=inet_addr("10.10.10.10");
//单独绑定,只能得到这个 IP 接收到的消息
其实在 C++ 中,推荐使用
inet_pton
和
inet_ntop
函数来替代
inet_addr
和
inet_ntoa
函数。这两个函数在处理 IPv4 和 IPv6 地址时更加灵活和安全,而且能够支持更广泛的地址类型。
完成赋值
sockaddr_in addrUDPServer;
addrUDPServer.sin_family = AF_INET;
addrUDPServer.sin_port = htons(12345);
addrUDPServer.sin_addr.S_un.S_addr = INADDR_ANY;
err = bind(sock, (sockaddr*)&addrUDPServer, sizeof(addrUDPServer));
返回值与检查(bind 函数)
如果未发生错误, 则 bind 返回零。否则,它将返回 SOCKET_ERROR,并且可以通过调用 来检索特定的错误代码。
if (err == SOCKET_ERROR) {
cout << "绑定失败:" << WSAGetLastError() << endl;
closesocket(sock);//关闭套接字
WSACleanup();//卸载库
return 1;
}
else {
cout << "绑定成功" << endl;
}
4.做收发的准备,创建客户端 sockaddr(不用赋值)
int nRecvNum = 0;//储存接收到的数据的大小
int nSendNum = 0;//储存要发送数据的大小
char recvBuf[1024] = "";//储存接收的数据
char SendBuf[1024] = "";//储存发送的数据
sockaddr_in addrUDPClient;
int addrUDPClientSize = sizeof(addrUDPClient);
我们创建服务端的 sockaddr 是用来绑定 IP 和端口号的,同样我们服务端收到客户端的数据后,也要知道客户端的 IP 和端口号,所以要创建客户端的 sockaddr 好用来存值
5.接收数据(recvfrom 函数)
这里用到的函数是 recvfrom,来看看帮助文档
参数
前面写了这么多,传什么其实已经很明显了,第一个 SOCKET 传的就是咱们创建的 sock。第二个[out]参数,就是服务端收到的数据要存在一个地方,在准备工作中已经写好了 recvBuf。第三个是大小 sizeof(recvBuf)。
第四个参数 flags
参数用于指定接收操作的行为。具体来说,它是一个位掩码,可以用于设置一些特定的选项。在 Windows Socket API 中,常见的
recvfrom
函数使用的
flags
参数通常为 0,表示没有特殊的选项。
这里写的是服务端的接收,recvfrom 的最后两个参数一定是对方的,也就是客户端,传的参是没赋值的客户端 sockaddr
nRecvNum = recvfrom(sock, recvBuf, sizeof(recvBuf), 0, (sockaddr*)&addrUDPClient, &addrUDPClientSize);
返回值与检查(recvfrom 函数)
如果没有发生错误, recvfrom 将返回接收到的字节数。如果连接已正常关闭,则返回值为零。否则,将返回值 SOCKET_ERROR,并且可以通过调用 检索特定的错误代码。
这里打印一下收到的数据,并且把 IP 也用 inet_ntoa 函数转换成咱们能看懂的字符串。
nRecvNum = recvfrom(sock, recvBuf, sizeof(recvBuf), 0, (sockaddr*)&addrUDPClient, &addrUDPClientSize);
//注意 recvfrom 的最后一个参数是 int*类型,接收数据时,由于接收到的数据长度是动态变化的,因此使用指针传递长度信息能够更好地应对这种情况(动态更新)。
if (nRecvNum > 0) {
//代表数据接收成功,打印接收到的数据
cout << "Client " << inet_ntoa(addrUDPClient.sin_addr) << ":" << recvBuf << endl;
}
else if (nRecvNum == 0) {
//连接已正常关闭,返回值为 0
cout << "连接已断开" << endl;
break;
}
else {
cout << "接收数据失败:" << WSAGetLastError() << endl;
break;
}
6.发送数据(sendto)
这里用到的函数是 sendto,来看看帮助文档
参数
sendto 的参数和 recvfrom 的都是一一对应的,很好写。但是你会发现参数全是[in],SendBuf 我们自己写个输入就行,但是这个[in] const sockaddr *to 呢?
这里就体现出为什么我们这个简单通信要服务端先接收数据了,前面说了,recvfrom 的最后两个参数是客户端的 sockaddr,它们是[out]参数,所以函数调用完,它们就有值了,存的就是客户端信息。
我们知道客户端的地址信息后,sendto 的最后两个参数直接写里就行了。
返回值与检查(sendto)
如果没有发生错误, sendto 将返回发送的总字节数,该字节数可以小于 len 指示的数字。否则,将返回值 SOCKET_ERROR,并且可以通过调用 来检索特定的错误代码。
cin >> SendBuf;
nSendNum = sendto(sock, SendBuf, sizeof(SendBuf), 0, (sockaddr*)&addrUDPClient, addrUDPClientSize);
//注意 sendto 的最后一个参数是 int 类型,在发送数据时,通常已经知道目标地址结构体的长度,所以用 int 就行
if (nSendNum == SOCKET_ERROR) {
cout << "发送数据失败:" << WSAGetLastError() << endl;
break;
}
7.关闭套接字
closesocket(sock);
8.卸载库
WSACleanup();
三、客户端实现
服务端写完再客户端就轻松多了,只需要单独理解一下第三步和第五步就可以。
1.加载库
//1.加载库
WORD wVersionRequested = MAKEWORD(2, 2);
WSADATA data;
int err = WSAStartup(wVersionRequested, &data);
if (err != 0) {
cout << "加载库失败" << endl;
return 1;
}
if (LOBYTE(data.wVersion) != 2 || HIBYTE(data.wVersion) != 2) {
cout << "库版本号错误" << endl;
WSACleanup();//卸载错误的库
return 1;
}
else {
cout << "加载库成功" << endl;
}
2.创建 socket
//2.创建套接字
SOCKET sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (sock == INVALID_SOCKET) {
cout << "创建套接字失败:" << WSAGetLastError() << endl;
WSACleanup();//卸载库
return 1;
}
else {
cout << "创建套接字成功" << endl;
}
3.做收发的准备,创建服务端 sockaddr(用服务端 IP 和端口号赋值)
因为是客户端先发数据,所以要知道目标 IP,注意这里并不是绑定(bind),只是创建服务端的 sockaddr 并用服务端的地址信息赋值,用来存服务端的地址信息,咱们这个简单通信不需要给客户端绑定哈(偷懒狗头)。
运行服务端的电脑要完成以下步骤
1.命令行指令:ipconfig
2.找到无线局域网适配器 WLAN,复制 IPv4 地址
3.客户端
addrUDPServer.sin_addr.S_un.S_addr = inet_addr("复制的地址");
这个客户端为什么不需要绑定 IP 和端口号
绑定 IP 和端口号是在告诉操作系统可以接收发给这个 IP 和端口号的数据,
因为客户端先发送数据,操作系统发现没有绑定,会自动分配任意网卡+
空闲端口号,如果不想要操作系统分配的,也可以自己绑定。
int nRecvNum = 0;
int nSendNum = 0;
char recvBuf[1024] = "";
char SendBuf[1024] = "";
sockaddr_in addrUDPServer;
addrUDPServer.sin_family = AF_INET;
addrUDPServer.sin_port= htons(12345);
addrUDPServer.sin_addr.S_un.S_addr = inet_addr("10.10.10.10");
//服务端 IP,这个端口号要与服务端绑定的一致
int addrUDPServerSize = sizeof(addrUDPServer);
4.发送数据
服务端的 sockaddr 有值,我们发送数据就直接用就行
cin>>SendBuf;
nSendNum = sendto(sock, SendBuf, sizeof(SendBuf), 0, (sockaddr*)&addrUDPServer, addrUDPServerSize);
if (nSendNum == SOCKET_ERROR) {
cout << "发送数据失败:" << WSAGetLastError() << endl;
break;
}
5.接收数据
服务端先接收数据会得到什么,得到数据和客户端的地址信息,服务端通过 recvfrom 接收到数据并得到客户端的地址信息后,才能用 sendto 发给客户端数据。
但是客户端有直接带值的服务端 sockaddr,客户端知道发给谁,还知道谁给我发。所以 recvfrom 的最后两个参数没啥用了,传 nullptr 就行。(强调:实现简单通信,如果是聊天系统的项目实战可别这么写)
nRecvNum = recvfrom(sock, recvBuf, sizeof(recvBuf), 0, nullptr, nullptr);
if (nRecvNum > 0) {
//代表数据接收成功,打印接收到的数据
cout << "Server " << inet_ntoa(addrUDPServer.sin_addr) << ":" << recvBuf << endl;
}
else if (nRecvNum == 0) {
//连接已正常关闭,返回值为 0
cout << "连接已断开" << endl;
break;
}
else {
cout << "接收数据失败:" << WSAGetLastError() << endl;
break;
}
6.关闭套接字
closesocket(sock);
7.卸载库
WSACleanup();
四、执行过程
可以接上手机热点测试,确保客户端的 sockaddr_in addrUDPServer 里存的信息正确,端口号与服务端绑定的一致,IP 是服务端的 IP。这个代码只能客户端发一句,服务端发一句,不能一端连发。
关闭防火墙路径:控制面板\系统和安全\Windows Defender 防火墙
如果是一台电脑,右键任务栏的 VS,再启动一个项目
先运行服务端,如果有这个错误,可以关闭 SDL 检查或加这个
#define _WINSOCK_DEPRECATED_NO_WARNINGS
一定要允许,取消的话就要到防火墙那里改了
再运行客户端,并发个数据
多来几次收发
如果是两台电脑,一定要确定它们连接的是同一网络,并且双方都关闭了防火墙
总结(完整代码)
服务端
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#include<iostream>
#include<winsock2.h>
#pragma comment(lib, "Ws2_32.lib")
using namespace std;
int main() {
//1.加载库
WORD wVersion = MAKEWORD(2, 2);
WSADATA data;
int err = WSAStartup(wVersion, &data);
if (err != 0) {
cout << "加载库失败" << endl;
return 1;
}
else if (LOBYTE(data.wVersion) != 2 || HIBYTE(data.wVersion) != 2) {
cout << "库版本号错误" << endl;
WSACleanup();//卸载错误的库
return 1;
}
else {
cout << "加载库成功" << endl;
}
//2.创建 socket
SOCKET sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
//阅读帮助文档了解具体含义,三个参数均为宏定义或枚举类型
if (sock == INVALID_SOCKET) {
cout << "创建套接字失败:" << WSAGetLastError() << endl;
//如果日志中出现错误,上方工具栏->错误查找->输入编号
WSACleanup();//卸载库
return 1;
}
else {
cout << "创建套接字成功" << endl;
}
//3.创建服务端 sockaddr,socket 绑定服务端的 IP 和端口号
sockaddr_in addrUDPServer;
addrUDPServer.sin_family = AF_INET;
addrUDPServer.sin_port = htons(12345);
addrUDPServer.sin_addr.S_un.S_addr = INADDR_ANY;//用这宏定义绑定所有网卡
err = bind(sock, (sockaddr*)&addrUDPServer, sizeof(addrUDPServer));
if (err == SOCKET_ERROR) {
cout << "绑定失败:" << WSAGetLastError() << endl;
closesocket(sock);//关闭套接字
WSACleanup();//卸载库
return 1;
}
else {
cout << "绑定成功" << endl;
}
//4.做收发的准备,创建客户端 sockaddr(不用赋值)
int nRecvNum = 0;//储存接收到的数据的大小
int nSendNum = 0;//储存要发送数据的大小
char recvBuf[1024] = "";//储存接收的数据
char SendBuf[1024] = "";//储存发送的数据
sockaddr_in addrUDPClient;
int addrUDPClientSize = sizeof(addrUDPClient);
while (true)
{
//5.接收数据
nRecvNum = recvfrom(sock, recvBuf, sizeof(recvBuf), 0, (sockaddr*)&addrUDPClient, &addrUDPClientSize);
//注意 recvfrom 的最后一个参数是 int*类型,接收数据时,由于接收到的数据长度是动态变化的,因此使用指针传递长度信息能够更好地应对这种情况(动态更新)。
if (nRecvNum > 0) {
//代表数据接收成功,打印接收到的数据
cout << "Client " << inet_ntoa(addrUDPClient.sin_addr) << ":" << recvBuf << endl;
}
else if (nRecvNum == 0) {
//连接已正常关闭,返回值为 0
cout << "连接已断开" << endl;
break;
}
else {
cout << "接收数据失败:" << WSAGetLastError() << endl;
break;
}
//6.发送数据
cin >> SendBuf;
nSendNum = sendto(sock, SendBuf, sizeof(SendBuf), 0, (sockaddr*)&addrUDPClient, addrUDPClientSize);
//注意 sendto 的最后一个参数是 int 类型,在发送数据时,通常已经知道目标地址结构体的长度,所以用 int 就行
if (nSendNum == SOCKET_ERROR) {
cout << "发送数据失败:" << WSAGetLastError() << endl;
break;
}
}
//7.关闭套接字
closesocket(sock);
//8.卸载库
WSACleanup();
}
客户端
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#include<iostream>
#include<winsock2.h>
#pragma comment(lib, "Ws2_32.lib")
using namespace std;
int main() {
//1.加载库
WORD wVersionRequested = MAKEWORD(2, 2);
WSADATA data;
int err = WSAStartup(wVersionRequested, &data);
if (err != 0) {
cout << "加载库失败" << endl;
return 1;
}
if (LOBYTE(data.wVersion) != 2 || HIBYTE(data.wVersion) != 2) {
cout << "库版本号错误" << endl;
WSACleanup();//卸载错误的库
return 1;
}
else {
cout << "加载库成功" << endl;
}
//2.创建套接字
SOCKET sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (sock == INVALID_SOCKET) {
cout << "创建套接字失败:" << WSAGetLastError() << endl;
WSACleanup();//卸载库
return 1;
}
else {
cout << "创建套接字成功" << endl;
}
//3.做收发的准备,创建服务端 sockaddr(用服务端 IP 和端口号赋值)
int nRecvNum = 0;
int nSendNum = 0;
char recvBuf[1024] = "";
char SendBuf[1024] = "";
sockaddr_in addrUDPServer;
addrUDPServer.sin_family = AF_INET;
addrUDPServer.sin_port = htons(12345);
addrUDPServer.sin_addr.S_un.S_addr = inet_addr("服务端 IP");
//服务端 IP,这个端口号要与服务端绑定的一致
int addrUDPServerSize = sizeof(addrUDPServer);
while (true)
{
//4.发送数据
cin >> SendBuf;
nSendNum = sendto(sock, SendBuf, sizeof(SendBuf), 0, (sockaddr*)&addrUDPServer, addrUDPServerSize);
if (nSendNum == SOCKET_ERROR) {
cout << "发送数据失败:" << WSAGetLastError() << endl;
break;
}
//5.接收数据
nRecvNum = recvfrom(sock, recvBuf, sizeof(recvBuf), 0, nullptr, nullptr);
if (nRecvNum > 0) {
//代表数据接收成功,打印接收到的数据
cout << "Server " << inet_ntoa(addrUDPServer.sin_addr) << ":" << recvBuf << endl;
}
else if (nRecvNum == 0) {
//连接已正常关闭,返回值为 0
cout << "连接已断开" << endl;
break;
}
else {
cout << "接收数据失败:" << WSAGetLastError() << endl;
break;
}
}
//6.关闭套接字
closesocket(sock);
//7.卸载库
WSACleanup();
}