目录
在这篇文章中,会创建TCP服务端和客户端、UDP服务端和客户端,让他们进行交互,在这中间穿插着介绍相关接口,帮助大家更好的理解。
核心概念 socket
在开始学习前,我们首先要了解socket是什么,socket是一个由操作系统提供的、用于代表一条网络连接的、独一无二的整数标识符。
他不是连接的本身,而是连接的代号,我们可以通过这个代号,来命令操作系统对真正的连接进行操作(发送、接收、关闭连接)。
我们在使用所有跟网络通信的函数的时候,比如connect、send、recv、closesocket,把这个数字传给他们,这样操作系统就知道我们是要对哪条连接操作。
TCP
TCP服务端
主要逻辑流程
1、初始化Winsock环境
因为在windows中,使用C++进行网络编程时,我们实际上时在直接调用windows操作系统提供的Winsock接口,Winsock的功能代码被封装在一个Ws2_32.dll的动态链接库文件中,而因为绝大多数windows程序是不需要网络功能的,windows采取按需服务模式,只有程序表示他需要网络功能的时候,操作系统才会为他加载Ws2_32.dll,并初始化相关的资源,下面的代码就是在通知windows,该程序需要使用到Ws2_32.dll。
头文件
#include <WinSock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "Ws2_32.lib")
WSADATA wsaData;
int nResult = WSAStartup(MAKEWORD(2, 2), &wsaData);
if (nResult != 0) {
std::cerr << "WSAStartup 失败,错误码:" << nResult << std::endl;
return -1;
}
- MAKEWORD(2, 2)这个参数是告诉操作系统,我们希望使用2.2版本的Winsock功能,通过 wsaData 结构体返回它实际能提供的版本信息。
2、创建服务端监听套接字
m_listenSocket = socket(AF_INET, SOCK_STREAM, 0);
if (m_listenSocket == INVALID_SOCKET) {
std::cerr << "创建套接字失败,错误码:" << WSAGetLastError() << std::endl;
return -2;
}
涉及函数接口
socket函数
SOCKET WSAAPI socket(
int af,
int type,
int protocol
);
参数介绍
1)af(地址族)
这个参数指定网络通信使用的地址格式。
常用参数:
- AF_INET:用于IPv4地址族
- AF_INET6:用于IPv6地址族
- AF_UNIX:域间套接字专用
- AF_UNSPEC:未指定
2)type(套接字类型)
这个参数定义套接字的通信语义
常用参数:
- SOCK_STREAM (最常用):流套接字,用于TCP协议
- SOCK_DGRAM:数据报套接字,用于UDP协议
3)protocol(协议)
这个参数用来指定在前两个参数确定的情况下,具体使用哪个协议
常用参数:
- 0:让系统根据af和type自动选择最合适的协议
- IPPROTO_TCP:显示指定使用TCP协议
- IPPROTO_UDP:显示指定使用UCP协议
返回值
- 成功:返回一个 SOCKET 类型的值。这是一个 句柄 ,一个非负整数,是新创建套接字的唯一标识。后续所有网络函数( bind , connect , send 等)都将使用这个句柄作为第一个参数。
- 失败:在 Windows 上,返回 INVALID_SOCKET (通常是 (SOCKET)(~0) ),在 Linux 上,返回 -1。
3、将创建的监听套接字绑定到指定的ip和接口
将套接字绑定IP和端口,就是服务端告诉操作系统,这个套接字负责所绑定的IP和端口,当客户端往这个IP和端口发送数据时,我们传入监听套接字,通过accept接收即可。
示例代码:
sockaddr_in serverAddr;
serverAddr.sin_family = AF_INET;
inet_pton(AF_INET, sIp.c_str(), &serverAddr.sin_addr.s_addr);
serverAddr.sin_port = htons(nPort);
/*绑定*/
if (SOCKET_ERROR==bind(m_listenSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr))) {
std::cerr<<"绑定失败,错误码:"<<WSAGetLastError()<<std::endl;
closesocket(m_listenSocket);
WSACleanup();
return -3;
}
涉及函数接口
sockaddr_in结构体
struct sockaddr_in {
short sin_family; // 地址族 (Address Family)
unsigned short sin_port; // 端口号 (Port Number)
struct in_addr sin_addr; // IP 地址 (IP Address)
char sin_zero[8]; // 填充位 (Padding)
};
1)sin_family
常用参数值:
- AF_INET:指定使用IPv4
- AF_INET6:指定使用IPv6
2)sin_port
- 端口号是一个16位的数字(0-65535),在使用时必须使用htons()函数转换
3)sin_addr.s_addr
这是一个32位的无符号整数,在使用时通常用inet_addr()函数或者INADDR_ANY宏来赋值
- INADDR_ANY绑定到本机所有可用的网络接口
struct in_addr {
unsigned long s_addr; // 存放一个32位的IPv4地址
};
inet_pton 函数
inet_pton 函数的作用是将一个以 字符串形式表示的、人类可读的IP地址 (例如 "192.168.1.1" 或 "2001:db8::1"),转换成它在网络中传输时使用的 二进制网络字节序格式 。
函数原型
INT WSAAPI inet_pton(
[in] INT Family,
[in] PCSTR pszAddrString, /*PCSTR (在 C++ 中就是 const char* )*/
[out] PVOID pAddrBuf /*PVOID (在 C++ 中就是 void* )*/
);
参数介绍
1)Family
地址族 (Address Family) 。这个参数告诉函数,你提供的 IP 地址字符串是 IPv4 格式还是 IPv6 格式。
常用参数:
- AF_INET : 用于指定pszAddrString是一个 IPv4 地址 (例如: "127.0.0.1")。
- AF_INET6 : 用于pszAddrString是一个 IPv6 地址 (例如: "::1")。
2)pszAddrString
源字符串 (Source String) 。这是一个指向包含人类可读 IP 地址的字符串的指针。
3)pAddrBuf
目标缓冲区 (Destination Buffer) 。这是一个指向内存缓冲区的指针,函数会将转换后的二进制格式 IP 地址存放在这里
- 如果 af 是 AF_INET ,这个缓冲区的大小必须至少是 sizeof(struct in_addr) (4 字节)。
- 如果 af 是 AF_INET6 ,这个缓冲区的大小必须至少是 sizeof(struct in6_addr) (16 字节)。
- 通常,这个指针会指向一个 sockaddr_in 或 sockaddr_in6 结构体中的地址成员。
返回值
- 1:转换成功
- 0:输入字符串无效,这里的字符串也就是要转换成二进制网络字节序的字符串。
- -1:发生错误
bind函数
bind 函数的作用是将一个之前用 socket() 函数创建的 套接字(socket) 与一个具体的 本地 IP 地址和端口号 进行**“绑定”**。
可以把它理解为给你的房子(套接字)装上一个明确的门牌号(IP地址 + 端口号)。当网络上发往这个“门牌号”的数据包到达时,操作系统就知道应该把这个数据包交给你的这个套接字来处理。
客户端程序通常 不需要 调用 bind ,因为操作系统会自动为客户端的套接字分配一个临时的、未被占用的端口号。
函数原型
int WSAAPI bind(
[in] SOCKET s,
[in] const struct sockaddr *name,
[in] int namelen
);
参数介绍
1)s
套接字描述符 (Socket Descriptor) 。这是一个整数值,它代表了你要进行绑定的那个套接字。
2)name
套接字地址结构 (Socket Address Structure) 。这是一个指向特定地址结构体的指针,包含了要绑定的 IP 地址和端口号信息。
- 这是一个 通用指针类型 。在实际编程中,我们从不直接使用 struct sockaddr 。
- 对于 IPv4 ,我们创建一个 struct sockaddr_in 结构体,填充好它,然后将它的指针 强制类型转换 为 (const struct sockaddr *) 再传给 bind 函数。
3)namelen
地址结构体的大小 。这个参数告诉 bind 函数,你传入的第二个参数 name 指向的那个结构体到底有多大。
4、开始监听套接字
/*第四步:开始监听*/
if (listen(m_listenSocket, SOMAXCONN) == SOCKET_ERROR) {
std::cerr << "listen 监听失败:" << WSAGetLastError() << std::endl;
closesocket(m_listenSocket);
WSACleanup();
return -1;
}
while (true) {
sockaddr_in clientAddr;
int nAddrSize = sizeof(clientAddr);
SOCKET clientSocket = accept(m_listenSocket, (SOCKADDR*)&clientAddr, &nAddrSize);
if (clientSocket == INVALID_SOCKET) {
std::cerr << "accept 失败:" << WSAGetLastError() << std::endl;
continue;
}
std::cout << "主线程:新用户上线,分机号是:" << clientSocket << std::endl;
/*开启新线程处理*/
std::thread clientThread(
&TcpServer::handleClient, // 成员函数指针
this, // 对象实例
clientSocket, // 第一个参数
clientAddr // 第二个参数
);
clientThread.detach();
}
涉及函数接口
listen函数
listen 函数的作用是将一个已经用 bind 绑定了地址的 套接字(socket) 从一个主动的、可以发起连接(用 connect )的套接字,转换成一个 被动的、专门用于“监听”的套接字 。
函数原型
int WSAAPI listen(
[in] SOCKET s,
[in] int backlog
);
参数介绍
1)s
套接字描述符 (Socket Descriptor),传入我们用于监听的套接字。
2)backlog
设置等待队列的最大长度。
- 当服务器非常繁忙,来不及用 accept 函数处理新的客户端连接请求时,操作系统会为这个监听套接字维护一个队列,用来存放那些已经完成了 TCP 三次握手、正在等待被服务器应用程序接受的连接。
- backlog 参数就指定了这个队列的最大长度。
- 如果队列已满,此时再有新的客户端尝试连接,服务器的 TCP/IP 协议栈会 拒绝 这个新的连接请求。客户端的 connect 函数会立刻失败,通常返回 ECONNREFUSED 错误。
在现代操作系统中,直接使用宏 SOMAXCONN 是 最推荐 的做法。 SOMAXCONN 是一个系统定义的常量,它会自动为 backlog 设置一个合理的、足够大的值。这使得你的代码更具可移植性。
返回值
0:表示套接字已经进入监听状态了
-1/SOCKET_ERROR:失败,失败的原因可能包括:
EBADF (POSIX) / WSAENOTSOCK (Windows) : 提供的套接字描述符不是一个有效的套接字。
EOPNOTSUPP (POSIX) / WSAEOPNOTSUPP (Windows) : 该套接字类型不支持 listen 操作(例如,你不能在一个 UDP 套接字上调用 listen )。
WSAEINVAL (Windows) : 套接字还没有被 bind 绑定,或者已经在监听状态
accept函数
accept是一个阻塞函数,如果没有连接请求,会暂停程序执行。
函数原型
SOCKET WSAAPI accept(
[in] SOCKET s,
[out] sockaddr *addr,
[in, out] int *addrlen
);
1)SOCKET s
- 第一个参数传入的是服务端的监听套接字,也就是已经经过 socket() , bind() , listen() 标准三步曲初始化的 监听套接字。
2)sockaddr *addr
- 第二个参数是一个输出型参数,通常我们传入一个 sockaddr_in 结构体变量的地址,并将其强制类型转换为 (sockaddr*) ,当接收到客户端的信息时,accept函数会填入这个参数。
3)int *addrlen
- 地址信息的大小,我们需要先告诉 accept 函数,为第二个参数 addr 准备的“登记表”有多大。
返回值
- 成功:返回一个全新的SOCKET句柄,所有与这个客户端进行数据交互操作都是用这个新的套接字
- 失败:返回INVALID_SOCKET,可以通过WSAGetLastError()获取失败原因。
5、数据收发
void TcpServer::handleClient(const SOCKET& clientSocket, const sockaddr_in& ClientAddr) {
/*开始处理*/
char recvBuf[MAX_LEN];
memset(recvBuf, 0, sizeof(recvBuf));
char cIpBuffer[MAX_LEN];
inet_ntop(AF_INET, &ClientAddr.sin_addr.s_addr, cIpBuffer, MAX_LEN);
while (recv(clientSocket, recvBuf, MAX_LEN, 0) > 0) {
std::cout << "收到" << cIpBuffer << "发送的" << recvBuf << std::endl;
const char* sendBuf = "已收到您发的消息" ;
send(clientSocket, sendBuf, strlen(sendBuf),0);
}
}
涉及函数介绍
send 和 recv 是用于在 已连接 的、面向流的套接字(如 TCP 套接字)上进行数据传输的两个核心函数。
- send : 发送 数据到对方。
- recv : 从对方 接收 数据。
send函数
函数原型
int WSAAPI send(
[in] SOCKET s,
[in] const char *buf,
[in] int len,
[in] int flags
);
1)s
已连接的套接字描述符 。
在 服务器端 ,这是 accept 函数返回的那个 新的 套接字描述符,代表与特定客户端的连接。
在 客户端 ,这是调用 connect 时使用的那个套接字。
2)buf
数据缓冲区指针 。一个指向内存中要发送数据的缓冲区的指针。
3)len
要发送的数据长度 (单位:字节)。即你想从 buf 中发送多少字节的数据。
4)flags
发送标志 。通常情况下,这个参数被设置为 0 。它允许一些特殊的发送行为,但对于常规的 TCP 通信, 0 是最常用和最标准的设置。
返回值
- 成功 ( > 0 ) : 返回 实际发送出去的字节数。
- 失败 (SOCKET_ERROR / -1) : 发送失败。
recv函数
函数原型
int WSAAPI recv(
[in] SOCKET s,
[out] char *buf,
[in] int len,
[in] int flags
);
参数介绍
1)s
已连接的套接字描述符 (同 send )。
2)buf
接收缓冲区指针 。一个指向用于存放接收到数据的缓冲区的指针。你必须保证这个缓冲区有足够的空间。
3)len
接收缓冲区的最大长度 (单位:字节)。这个参数告诉 recv 函数,你的缓冲区最多能装多少字节的数据,以防止缓冲区溢出。
4)flags
接收标志 。通常情况下,这个参数被设置为 0 。
返回值
- 成功 ( > 0 ) : 返回 实际接收到的字节数 。
- 连接已正常关闭 ( == 0 ) : 返回值为 0 是一个明确的信号,表示 对方已经调用 close 或 shutdown 正常关闭了连接 。
- 失败 ( < 0 ) : 即 SOCKET_ERROR (Windows) 或 -1 (POSIX)。表示发生了错误。
TCP客户端
主要逻辑流程
1、初始化Winsock环境
客户端这里与服务端一样,同样需要先初始化Winsock环境
WSADATA wsaData;
int nResult = WSAStartup(MAKEWORD(2, 2), &wsaData);
if (nResult != 0) {
std::cerr << "WSAStartup 失败,错误码:" << nResult << std::endl;
return -1;
}
2、创建客户端连接套接字
SOCKET clientSocket = socket(AF_INET, SOCK_STREAM, 0);
if (clientSocket == INVALID_SOCKET) {
std::cerr << "准备套接字失败,错误码:" << WSAGetLastError()<< std::endl;
return -2;
}
3、连接服务端
在连接之前,要先准备我们要连接的服务端ip地址信息
/*准备要连接的服务端地址信息*/
sockaddr_in serverAddr;
serverAddr.sin_family = AF_INET;
inet_pton(AF_INET, sIp.c_str(), &serverAddr.sin_addr.s_addr);
serverAddr.sin_port = htons(nPort);
通过connect接口连接服务端
if (connect(clientSocket, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) {
std::cerr << "连接服务器失败,错误码:" << WSAGetLastError() << std::endl;
return -3;
}
connect函数
函数原型
int connect(
SOCKET s,
const sockaddr* name,
int namelen
);
参数介绍
1)SOCKET s
- 客户端创建的连接套接字
2)const sockaddr* name
- 这个是我们创建用来描述要连接的服务端的IP地址信息,具体的参考前面sockaddr_in结构体介绍
3)int namelen
- 第二个参数结构体的大小
返回值
- 成功:返回0
- 失败:返回非0
4、数据发送
int nSendSize = send(clientSocket, sInput.c_str(), sInput.size(), 0);
if (nSendSize == SOCKET_ERROR) {
std::cerr << "send failed with error: " << WSAGetLastError() << std::endl;
closesocket(clientSocket);
WSACleanup();
return -4;
}
上面服务端已经介绍了send和recv函数,这里就不再介绍了。