本文详细介绍了 TCP 与 UDP 套接字编程,并在 Windows 下使用 C++ 实现套接字编程,对代码做了十分精细的讲解,这部分内容非常重要,是计算机网络学到目前为止第一次编程,也是网络编程开发中最基础的一个部分,必须彻底掌握。
1. Windows 使用 C++ 实现 TCP Socket
在 Windows 下进行套接字编程需要遵循如下步骤:
- 初始化 Winsock 库:使用
WSAStartup
初始化 Winsock 库。该函数需要指定所使用的 Winsock 版本(通常使用 2.2 版本)。 - 创建套接字:使用
socket()
函数创建一个 TCP 套接字,参数指定协议族(AF_INET)、套接字类型(SOCK_STREAM)以及协议(IPPROTO_TCP)。 - 绑定地址和端口(针对服务器端):服务器端需要使用
bind()
函数绑定本地 IP 地址和端口号,以便客户端可以连接到该地址。 - 监听连接(服务器端):通过
listen()
函数使套接字进入监听状态,等待客户端的连接请求。 - 接受连接(服务器端):使用
accept()
函数接受客户端的连接请求,并获得一个新的套接字用于与客户端通信。 - 连接服务器(客户端):客户端使用
connect()
函数连接到服务器的 IP 地址和端口号。 - 数据传输:服务器和客户端可以通过
send()
和recv()
函数进行数据的发送和接收。 - 关闭套接字和清理环境:通信完成后,使用
closesocket()
关闭套接字,并调用WSACleanup()
清理 Winsock 资源。
我们以 CLion 编译器为例,创建一个套接字编程的项目 TCP&UDP_Socket
,然后创建好 tcp_server.cpp
与 tcp_client.cpp
。
首先我们需要配置一下 CMakeLists.txt
文件,指定我们的两个可执行文件,然后需要链接 Winsock 库:
cpp
cmake_minimum_required(VERSION 3.28)
project(TCP&UDP_Socket)
set(CMAKE_CXX_STANDARD 20)
add_executable(TCPServer tcp_server.cpp)
add_executable(TCPClient tcp_client.cpp)
target_link_libraries(TCPServer ws2_32)
target_link_libraries(TCPClient ws2_32)
CMake 是一个跨平台的自动化构建工具,它帮助开发者通过编写简单的配置脚本来生成适用于不同平台和编译器的构建文件,从而简化软件项目的构建、测试和打包过程。
CMakeLists.txt
是 CMake 的核心配置文件,存储了项目的构建规则和配置信息。这个文件中可以包含:
- 项目名称和版本:使用
project()
命令定义项目名称、版本信息等。 - CMake 最低版本要求:使用
cmake_minimum_required()
命令指定最低 CMake 版本。 - 添加目标:通过
add_executable()
、add_library()
等命令定义可执行文件或库。 - 依赖管理与链接:使用
target_link_libraries()
指定目标之间的依赖关系和链接库。
配置完就可以实现服务器与客户端的代码了,首先是服务器 tcp_server.cpp
:
cpp
#include <iostream>
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib") // 显式链接 Winsock 库,在编译时自动将 ws2_32.lib 添加到链接器的输入中
const int PORT = 8080; // 监听端口
const int BUFFER_SIZE = 1024; // 接收消息时的缓冲区大小
int main() {
// 1. 初始化 Winsock
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
std::cerr << "WSAStartup failed." << std::endl;
return 1;
}
// 2. 创建服务器套接字
SOCKET serverSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (serverSocket == INVALID_SOCKET) {
std::cerr << "Socket creation failed: " << WSAGetLastError() << std::endl;
WSACleanup();
return 1;
}
// 3. 配置服务器地址和端口
sockaddr_in serverAddr{}; // 表示 IPv4 地址信息的结构体,{} 利用 C++11 的列表初始化将所有成员初始化为零
serverAddr.sin_family = AF_INET; // 使用 IPv4 协议
serverAddr.sin_addr.s_addr = INADDR_ANY; // 监听所有本地 IP
serverAddr.sin_port = htons(PORT); // 将端口转换为网络字节序
// 4. 将地址和端口与服务器套接字绑定
if (bind(serverSocket, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) {
std::cerr << "Bind failed: " << WSAGetLastError() << std::endl;
closesocket(serverSocket);
WSACleanup();
return 1;
}
// 5. 开始监听
if (listen(serverSocket, SOMAXCONN) == SOCKET_ERROR) {
std::cerr << "Listen failed: " << WSAGetLastError() << std::endl;
closesocket(serverSocket);
WSACleanup();
return 1;
}
std::cout << "TCP Server listening on port " << PORT << std::endl;
// 6. 接受客户端连接
sockaddr_in clientAddr{};
int clientAddrLen = sizeof(clientAddr);
SOCKET clientSocket = accept(serverSocket, (sockaddr*)&clientAddr, &clientAddrLen);
if (clientSocket == INVALID_SOCKET) {
std::cerr << "Accept failed: " << WSAGetLastError() << std::endl;
closesocket(serverSocket);
WSACleanup();
return 1;
}
std::cout << "Client connected!" << std::endl;
// 8. 与客户端通信
char buffer[BUFFER_SIZE];
while (true) {
// 接收数据
int bytesReceived = recv(clientSocket, buffer, BUFFER_SIZE, 0);
if (bytesReceived > 0) {
buffer[bytesReceived] = '\0'; // 确保字符串终止
std::cout << "Received: " << buffer << std::endl;
// 发送响应
const char* response = "Hello Client!";
send(clientSocket, response, static_cast<int>(strlen(response)), 0);
std::cout << "Sent: " << response << std::endl;
} else if (bytesReceived == 0) {
std::cout << "Client disconnected." << std::endl;
break;
} else {
std::cerr << "Receive failed: " << WSAGetLastError() << std::endl;
break;
}
}
// 9. 清理资源
closesocket(clientSocket);
closesocket(serverSocket);
WSACleanup();
return 0;
}
然后是客户端 tcp_client.cpp
:
cpp
#include <iostream>
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")
const char* SERVER_IP = "127.0.0.1"; // 本地回环地址
const int PORT = 8080; // 服务器端口
const int BUFFER_SIZE = 1024; // 接收消息时的缓冲区大小
int main() {
// 1. 初始化 Winsock
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
std::cerr << "WSAStartup failed." << std::endl;
return 1;
}
// 2. 创建客户端套接字
SOCKET clientSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (clientSocket == INVALID_SOCKET) {
std::cerr << "Socket creation failed: " << WSAGetLastError() << std::endl;
WSACleanup();
return 1;
}
// 3. 配置服务器地址和端口
sockaddr_in serverAddr{};
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(PORT);
inet_pton(AF_INET, SERVER_IP, &serverAddr.sin_addr); // 转换 IP 地址为二进制格式
// 7. 连接到服务器
if (connect(clientSocket, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) {
std::cerr << "Connect failed: " << WSAGetLastError() << std::endl;
closesocket(clientSocket);
WSACleanup();
return 1;
}
std::cout << "Connected to server!" << std::endl;
// 8. 与服务器通信
const char* message = "Hello Server!";
send(clientSocket, message, static_cast<int>(strlen(message)), 0);
std::cout << "Sent: " << message << std::endl;
char buffer[BUFFER_SIZE];
int bytesReceived = recv(clientSocket, buffer, BUFFER_SIZE, 0);
if (bytesReceived > 0) {
buffer[bytesReceived] = '\0';
std::cout << "Received: " << buffer << std::endl;
} else {
std::cerr << "Receive failed: " << WSAGetLastError() << std::endl;
}
// 9. 清理资源
closesocket(clientSocket);
WSACleanup();
return 0;
}
下面对代码的每一步进行详细的讲解(顺序编号与代码中一致,按顺序逐步理解):
- 初始化 Winsock(服务器与客户端)
WSAStartup()
是 Winsock API 的初始化函数,如果初始化成功,则返回值为 0;如果失败,返回非零错误代码,表明具体的错误原因。初始化成功后指针&wsaData
会接收 Winsock 初始化后返回的一些信息,例如 Winsock 的版本、描述信息等。MAKEWORD(2, 2)
是一个用于生成一个 16 位版本号的宏。其中低字节代表主版本号(major version),高字节代表次版本号(minor version)。MAKEWORD(2, 2)
表示请求使用 Winsock 的 2.2 版本。这也是当前广泛使用的版本,提供了比较完善的功能和兼容性。
- 创建套接字(服务器与客户端)
SOCKET
数据类型是在 Windows 平台上定义的一个数据类型,位于头文件<winsock2.h>
中。它是一个抽象的套接字句柄,用于标识和操作一个网络连接。它在不同的平台上内部可能是一个无符号整数或指针类型,但在 Windows 下由 Winsock API 统一管理。AF_INET
表示使用 IPv4 地址族(Address Family IPv4),用于网络通信时使用 32 位地址;SOCK_STREAM
指定套接字类型为流式套接字 ,适用于基于 TCP 的可靠数据传输(TCP 中数据是作为连续的字节流进行传输);IPPROTO_TCP
明确指定使用 TCP 协议。这三个宏都在<winsock2.h>
(以及相关的<ws2tcpip.h>
)中定义,保证在 Windows 下使用 Winsock API 时具有正确的常量值。socket()
函数会创建一个新的套接字,用于网络通信。函数返回一个SOCKET
类型的值,表示创建成功的套接字句柄,之后可以利用这个句柄进行绑定、监听、连接、数据收发等操作。如果返回值为INVALID_SOCKET
,则说明创建套接字失败,需调用WSAGetLastError()
获取错误码。
- 配置服务器地址和端口(服务器与客户端)
sockaddr_in
是用于表示 IPv4 地址信息的结构体 ,定义在头文件<winsock2.h>
或<ws2tcpip.h>
中。它包含了网络地址族sin_family
(short
类型)、端口号sin_port
(u_short
类型)以及 IP 地址sin_addr
等属性,还可能包含填充字节sin_zero
以确保结构体大小与通用地址结构sockaddr
一致。sockaddr_in
中的sin_addr
属性也是一个结构体,其中s_addr
成员保存 32 位的 IP 地址。INADDR_ANY
表示任何本地 IP 地址,即绑定时使用主机上的所有网络接口。这样服务器程序就能接收来自任意网络接口的连接请求。- 网络传输使用的是网络字节序(大端序),而主机通常使用本机字节序(可能是小端序),
htons()
(Host TO Network Short)函数用于将主机字节序的 16 位整数转换为网络字节序。这里将常量PORT
转换后赋值给sin_port
,保证端口号在网络传输时能正确解释。 - 客户端:
inet_pton
(Internet Presentation to Network)函数将以文本(Presentation)形式表示的 IP 地址转换为网络(Network)字节序的二进制形式,这个格式适用于网络通信函数使用。
- 将地址和端口与服务器套接字绑定(服务器)
bind()
函数用于将一个套接字和一个本地地址(包括 IP 地址和端口)绑定在一起。这是服务器端设置套接字的一个必要步骤,使得套接字知道在哪个地址和端口上等待客户端连接。绑定后,套接字就与该地址关联,可以通过这个地址对外通信。如果bind()
执行成功,返回值为 0,如果绑定失败,则返回SOCKET_ERROR
(通常为 -1),并且可以调用WSAGetLastError()
函数获取具体的错误代码,从而了解失败的原因。- 将
serverAddr
(类型为sockaddr_in
)的地址 转换为通用地址类型sockaddr*
。这是因为bind()
函数要求传入的地址参数为sockaddr*
类型。
- 开始监听(服务器)
- 调用
listen()
函数后,套接字将变为"被动"模式,即不再主动发起连接,而是等待来自客户端的连接请求。函数返回 0 表示成功,套接字已成功进入监听状态,如果失败,则返回SOCKET_ERROR
。 SOMAXCONN
是一个预定义的常量,定义在<winsock2.h>
头文件中,不同平台可能对其具体数值有所不同,表示允许在套接字的挂起连接队列中的最大连接请求数。使用SOMAXCONN
能让系统选择一个合理的最大值,从而简化开发者的配置。
- 调用
- 接受客户端连接(服务器)
- 首先定义了一个 IPv4 地址结构体
clientAddr
,用于存储连接过来的客户端的 IP 地址和端口号。 accept()
函数用于从处于监听状态的套接字serverSocket
的挂起连接队列中提取出一个连接请求,并为该连接创建一个新的套接字。当有客户端连接时,accept()
会填充clientAddr
中的客户端地址信息,并通过clientAddrLen
告知地址结构的实际大小。accept()
阻塞调用会一直等待直到有客户端发起连接,然后返回一个新的、用于后续数据通信的套接字。
- 首先定义了一个 IPv4 地址结构体
- 连接到服务器(客户端)
connect()
函数用于建立客户端与服务器之间的连接。客户端通过该函数发起连接请求,并与服务器建立通信通道。调用此函数后,如果连接成功,则客户端的套接字将与服务器端对应的套接字建立起连接,从而可以进行数据传输。如果连接成功,connect()
返回 0,否则返回SOCKET_ERROR
。
- 发送数据并接收响应(服务器与客户端)
send()
函数用于向已连接的套接字发送数据,其中变量message
是一个指向数据缓冲区的指针,该缓冲区中存储了要发送的数据,标志参数设为 0 表示默认的发送方式,不使用特殊的发送选项。函数返回值为成功发送的字节数,即实际发送出去的数据长度,如果发送失败,则返回SOCKET_ERROR
。recv()
函数用于从一个已经连接的套接字中接收数据,其中变量buffer
存储接收到数据的字符数组(缓冲区),BUFFER_SIZE
表示缓冲区的大小,指定最多接收多少字节的数据,标志参数设为 0 表示不使用特殊标志,即默认接收方式。函数返回值为实际接收到的字节数,如果返回 0,表示对方已经正常关闭连接,如果返回SOCKET_ERROR
表示出现错误。
- 清理资源
- 使用
closesocket()
函数关闭套接字,释放与该套接字相关的系统资源,确保不再使用这个网络连接。使用WSACleanup()
函数终止 Winsock 库的使用,释放在调用WSAStartup()
时分配的资源。
- 使用
2. Windows 使用 C++ 实现 UDP Socket
UDP Socket 无需握手建立连接,服务器只需要与本地的 IP 和端口进行绑定,发送端在每一个发送的报文中都需要明确地指定目标的 IP 地址和端口号,服务器必须从收到的分组中提取出发送端的 IP 地址和端口号才能响应发送端。
我们先创建好 udp_server.cpp
与 udp_client.cpp
,然后修改一下 CMake 配置文件:
cpp
cmake_minimum_required(VERSION 3.28)
project(TCP&UDP_Socket)
set(CMAKE_CXX_STANDARD 20)
add_executable(TCPServer tcp_server.cpp)
add_executable(TCPClient tcp_client.cpp)
add_executable(UDPServer udp_server.cpp)
add_executable(UDPClient udp_client.cpp)
target_link_libraries(TCPServer ws2_32)
target_link_libraries(TCPClient ws2_32)
target_link_libraries(UDPServer ws2_32)
target_link_libraries(UDPClient ws2_32)
UDP Socket 的实现与 TCP 基本一样,先上代码,首先是服务器 udp_server.cpp
:
cpp
#include <iostream>
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")
const int PORT = 8888;
const int BUFFER_SIZE = 1024;
int main() {
// 1. 初始化 Winsock
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
std::cerr << "WSAStartup failed." << std::endl;
return 1;
}
// 2. 创建服务器套接字
SOCKET serverSocket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (serverSocket == INVALID_SOCKET) {
std::cerr << "Socket creation failed: " << WSAGetLastError() << std::endl;
WSACleanup();
return 1;
}
// 3. 配置服务器地址和端口
sockaddr_in serverAddr{};
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = INADDR_ANY;
serverAddr.sin_port = htons(PORT);
// 4. 将地址和端口与服务器套接字绑定
if (bind(serverSocket, (sockaddr*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) {
std::cerr << "Bind failed: " << WSAGetLastError() << std::endl;
closesocket(serverSocket);
WSACleanup();
return 1;
}
std::cout << "UDP Server listening on port " << PORT << std::endl;
// 5. 接收和发送数据
sockaddr_in clientAddr{};
int clientAddrLen = sizeof(clientAddr);
char buffer[BUFFER_SIZE];
while (true) {
// 接收数据
int bytesReceived = recvfrom(serverSocket, buffer, BUFFER_SIZE, 0, (sockaddr*)&clientAddr, &clientAddrLen);
if (bytesReceived > 0) {
// 打印客户端信息及内容
int clientPort = ntohs(clientAddr.sin_port);
char clientIP[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &clientAddr.sin_addr, clientIP, INET_ADDRSTRLEN);
buffer[bytesReceived] = '\0'; // 确保字符串终止
std::cout << "Received from " << clientIP << ":" << clientPort << ": " << buffer << std::endl;
// 发送响应
const char* response = "Hello Client!";
int sendResult = sendto(serverSocket, response, static_cast<int>(strlen(response)), 0, (sockaddr*)&clientAddr, clientAddrLen);
if (sendResult == SOCKET_ERROR) {
std::cerr << "Send failed: " << WSAGetLastError() << std::endl;
} else {
std::cout << "Sent to " << clientIP << ":" << clientPort << ": " << response << std::endl;
}
} else {
std::cerr << "Receive failed: " << WSAGetLastError() << std::endl;
break;
}
}
// 6. 清理资源
closesocket(serverSocket);
WSACleanup();
return 0;
}
然后是客户端 udp_client.cpp
:
cpp
#include <iostream>
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")
const char* SERVER_IP = "127.0.0.1"; // 服务器 IP
const int PORT = 8888; // 服务器端口
const int BUFFER_SIZE = 1024;
int main() {
// 1. 初始化 Winsock
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
std::cerr << "WSAStartup failed." << std::endl;
return 1;
}
// 2. 创建客户端套接字
SOCKET clientSocket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (clientSocket == INVALID_SOCKET) {
std::cerr << "Socket creation failed: " << WSAGetLastError() << std::endl;
WSACleanup();
return 1;
}
// 3. 配置服务器地址和端口
sockaddr_in serverAddr{};
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(PORT);
inet_pton(AF_INET, SERVER_IP, &serverAddr.sin_addr);
// 5. 发送数据
const char* message = "Hello Server!";
int sendResult = sendto(clientSocket, message, static_cast<int>(strlen(message)), 0, (sockaddr*)&serverAddr, sizeof(serverAddr));
if (sendResult == SOCKET_ERROR) {
std::cerr << "Send failed: " << WSAGetLastError() << std::endl;
closesocket(clientSocket);
WSACleanup();
return 1;
}
std::cout << "Sent: " << message << std::endl;
// 5. 接收数据
char buffer[BUFFER_SIZE];
sockaddr_in fromAddr{};
int fromAddrLen = sizeof(fromAddr);
int bytesreceived = recvfrom(clientSocket, buffer, BUFFER_SIZE, 0, (sockaddr*)&fromAddr, &fromAddrLen);
if (bytesreceived > 0) {
buffer[bytesreceived] = '\0';
std::cout << "Received: " << buffer << std::endl;
} else {
std::cerr << "Receive failed: " << WSAGetLastError() << std::endl;
}
// 6. 清理资源
closesocket(clientSocket);
WSACleanup();
return 0;
}
现在来看一下 UDP Socket 与 TCP Socket 不同的几个地方。
首先是使用 socket()
创建 Socket 时,UDP 使用 SOCK_DGRAM
表示创建一个数据报套接字 ,这种套接字是面向消息的,不提供数据流的可靠传输,而是用于发送独立的数据包。这正好符合 UDP 的特点(无连接、不保证可靠性)。IPPROTO_UDP
明确指定使用 UDP 协议。这样,系统就知道这个套接字将基于 UDP 进行数据传输。
其次是 UDP 中服务器没有监听与接受客户端连接的过程,同理客户端没有连接到服务器的过程。服务器与客户端直接使用 sendto()
与 recvfrom()
函数进行通信,sendto()
与 send()
相比的区别在于每次发送都需要指定接收端的地址信息,同样 recvfrom()
也需要接收发送端的地址信息,可以使用 ntohs()
(Network TO Host Short)函数将 16 位数值的端口号从网络字节序转换为主机字节序,使用 inet_ntop()
函数将网络字节序中的 IP 地址(二进制形式)转换为文本(字符串)格式。
由于 UDP 中不存在客户端结束连接这个操作,因此服务器的 UDP Socket 在接收没有出问题的情况下会不断等待消息,多启动几次客户端代码就可以看到服务器产生了多次通信:
UDP Server listening on port 8888
Received from 127.0.0.1:52045: Hello Server!
Sent to 127.0.0.1:52045: Hello Client!
Received from 127.0.0.1:53888: Hello Server!
Sent to 127.0.0.1:53888: Hello Client!
...
通过上面的例子,能够发现在 TCP 和 UDP 套接字编程中,客户端通常不需要显式绑定地址和端口 ,客户端默认会使用本地主机的所有可用网络接口,即 INADDR_ANY
,操作系统自动分配一个临时端口 (Ephemeral Port),范围通常为 1024~65535,TCP 客户端在调用 connect()
函数时系统会为套接字分配本地 IP(根据路由选择最佳接口),并分配一个随机临时端口;UDP 客户端在第一次发送或接收数据时完成端口分配。
我们所使用的 127.0.0.1
为本地回环地址,我们也可以使用 ipconfig
命令(Linux 为 ifconfig
)查看我们本机的 IPv4 地址,这是一个局域网地址,通常分配给某个网络接口,这个接口与本地回环接口不一样。
客户端在配置服务器地址时可以试试改成我们本机的局域网地址:
cpp
inet_pton(AF_INET, "192.168.82.14", &serverAddr.sin_addr);
这时候运行客户端会看到服务器那边接收到的客户端地址改变了:
Received from 192.168.82.14:63857: Hello Server!
Sent to 192.168.82.14:63857: Hello Client!
因为目标地址是一个局域网地址,因此客户端也自动使用我们本机的局域网接口发送消息,服务器端我们设置了监听所有本地 IP,所以包括本地回环地址与本地 IPv4 地址。
同样我们也可以在客户端自己绑定 IP 与端口号:
cpp
sockaddr_in clientAddr{};
clientAddr.sin_family = AF_INET;
clientAddr.sin_port = htons(12345); // 指定固定端口
clientAddr.sin_addr.s_addr = inet_addr("192.168.82.14"); // 指定从局域网 IP 发出消息
if (bind(clientSocket, (sockaddr*)&clientAddr, sizeof(clientAddr)) == SOCKET_ERROR) {
std::cerr << "Bind failed: " << WSAGetLastError() << std::endl;
closesocket(clientSocket);
WSACleanup();
return 1;
}
这样启动客户端就能看到服务器的输出:
Received from 192.168.82.14:12345: Hello Server!
Sent to 192.168.82.14:12345: Hello Client!