TCP套接字示例程序详解:服务器与客户端通信实战
一、程序整体说明
这是一个基于Linux的简易TCP通信示例,包含服务器程序 和客户端程序 ,实现了"客户端发送消息→服务器接收并回复"的完整流程。通过这个示例可以直观理解TCP套接字编程的核心步骤和API使用方法。
先附上整体程序,再逐步讲解。
server.cpp
cpp
/*
简易TCP服务器
*/
#include <iostream>
#include <sys/socket.h> //头文件
#include <netinet/in.h> //操作IP地址的
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
int main(int, char **)
{
// 1.创建套接字
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);//tcp
if (listen_sock == -1)
{
std::cout << "failed to create socket" << std::endl;
return 1;
}
// 2.绑定IP地址
// IP:设备,端口号:进程
std::string ip = "127.0.0.1";
struct sockaddr_in server_addr; // 数据类型sockaddr_in
memset(&server_addr, 0, sizeof(server_addr)); // 地址滞空(清空)
// 指定地址类型
server_addr.sin_family = AF_INET; // 指定IPV4
// 指定IP地址
server_addr.sin_addr.s_addr = INADDR_ANY; // 可以监听所有IP地址
// 指定端口号
server_addr.sin_port = htons(9888);//转类型,网络通信 (h to n 本机到网络)
// server_addr需要将类型转换为struct sockaddr*,因为sockaddr_in更好赋值所以用in
if (bind(listen_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1)
{
std::cout << "failed to bind socket" << std::endl;
return 1;
}
// 3.监听套接字
if (listen(listen_sock, 5) == -1)
{
std::cerr << "failed to listen" << std::endl;
return 1;
}
std::cout << "server is listening" << std::endl;
// 4.接受客户端连接
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);
// 客户端socket
//创建一个新的socket文件描述符来处理客户端的请求
int client_sock = accept(listen_sock, (struct sockaddr *)&client_addr, &client_addr_len);
if (client_sock == -1)
{
std::cerr << "failed to accept" << std::endl;
return 1;
}
// 5.数据交互
// 5.1接受消息
char buffer[1024];
// 读取客户端消息,存入buffer里
int resd_size = read(client_sock, buffer, sizeof(buffer)); // 对应客户端recv
std::cout << "Recerved msg: \n" << buffer << std::endl;
// 5.2发送消息
std::string res_msg = "hello client !";
write(client_sock, res_msg.c_str(), res_msg.size()); // 对应客户端send
// 6.关闭socket
close(client_sock);
close(listen_sock);
}
client.cpp
cpp
#include <iostream>
#include <sys/socket.h> //头文件
#include <netinet/in.h> //操作IP地址的
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
using namespace std;
int main()
{
// 1.创建socket
int client_sock = socket(AF_INET, SOCK_STREAM, 0);
if (client_sock == -1)
{
std::cerr << "failed to create socket" << std::endl;
return -1;
}
// 2.连接服务器
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
// 在网络编程中,需要把字符串变成网络字节序
//(1)老写法
// server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");//本机
//(2)通用写法
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr.s_addr);
server_addr.sin_port = htons(9888);
// 用connect()函数连接
if (connect(client_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1)
{
std::cerr << "failed to connect" << std::endl;
return -1;
}
cout << "connected to server" << endl;
// 3.数据交互
string msg = "hello world !\n";
if(write(client_sock, msg.c_str(), msg.length())==-1)
{
std::cerr << "failed to write" << std::endl;
return -1;
}
//接收消息
char buffer[1024];
if(read(client_sock , buffer,sizeof(buffer)) == -1)
{
std::cerr << "failed to read" << std::endl;
return -1;
}
printf("received to server:\n %s" , buffer);
close(client_sock);
return 0;
}
二、服务器程序详解(server.cpp)
服务器的核心任务是"绑定端口→监听连接→接受客户端→收发数据→关闭连接",代码流程与TCP通信理论完全对应。
1. 头文件与初始化
cpp
#include <iostream>
#include <sys/socket.h> // 套接字核心API(socket/bind/listen等)
#include <netinet/in.h> // 定义sockaddr_in等地址结构体
#include <arpa/inet.h> // 提供IP地址转换函数(inet_pton/ntohs等)
#include <unistd.h> // 提供close/read/write等系统调用
#include <cstring> // 提供memset等字符串操作函数
这些头文件是Linux网络编程的基础,包含了所有必需的函数和结构体定义。
2. 步骤1:创建套接字(socket())
cpp
int listen_sock = socket(AF_INET, SOCK_STREAM, 0); // 创建TCP套接字
if (listen_sock == -1) {
std::cout << "failed to create socket" << std::endl;
return 1;
}
- 作用:创建一个用于网络通信的套接字描述符(类似文件描述符),是后续所有操作的基础。
- 参数解析 :
AF_INET
:使用IPv4协议族;SOCK_STREAM
:创建流式套接字(对应TCP协议,可靠连接);0
:使用默认协议(TCP)。
- 错误处理 :若返回
-1
表示创建失败(如权限不足、系统资源耗尽),需退出程序。
3. 步骤2:绑定IP和端口(bind())
cpp
struct sockaddr_in server_addr; // IPv4地址结构体
memset(&server_addr, 0, sizeof(server_addr)); // 初始化结构体(清零)
server_addr.sin_family = AF_INET; // 协议族(必须与socket()一致)
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定所有本地IP
server_addr.sin_port = htons(9888); // 绑定端口(转换为网络字节序)
// 绑定套接字与地址
if (bind(listen_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
std::cerr << "failed to bind socket" << std::endl;
return 1;
}
- 核心结构体
sockaddr_in
:专门用于IPv4地址存储,需强制转换为通用的struct sockaddr*
传给bind()
。 - 关键参数解析 :
sin_family
:必须设置为AF_INET
(与socket协议族一致);sin_addr.s_addr
:INADDR_ANY
表示绑定本地所有网络接口的IP(如服务器有多个网卡时,可接收任意网卡的连接),htonl()
用于将整数转换为网络字节序;sin_port
:端口号(9888
),需通过htons()
转换为网络字节序(TCP协议规定端口和IP必须用网络字节序传输)。
- 作用:将创建的套接字与"本地IP+端口"绑定,使客户端能通过该端口找到服务器。
4. 步骤3:监听连接(listen())
cpp
if (listen(listen_sock, 5) == -1) { // 第二个参数backlog=5
std::cerr << "failed to listen" << std::endl;
return 1;
}
std::cout << "server is listening" << std::endl;
- 作用:将套接字设置为"监听状态",允许接收客户端的连接请求。
- 参数
backlog
:表示未完成连接队列的最大长度(处于三次握手未完成状态的连接数),超过则新连接被拒绝(这里设为5)。 - 注意 :
listen()
不阻塞,仅标记套接字为监听状态,真正等待连接的是accept()
。
5. 步骤4:接受客户端连接(accept())
cpp
struct sockaddr_in client_addr; // 存储客户端地址信息
socklen_t client_addr_len = sizeof(client_addr); // 地址长度(输入输出参数)
// 阻塞等待客户端连接,返回与该客户端通信的套接字
int client_sock = accept(listen_sock, (struct sockaddr *)&client_addr, &client_addr_len);
if (client_sock == -1) {
std::cerr << "failed to accept" << std::endl;
return 1;
}
- 核心特性 :
accept()
是阻塞函数,若没有客户端连接,会一直等待(直到有连接或被信号中断)。 - 参数与返回值 :
- 输入:监听套接字
listen_sock
、客户端地址结构体指针client_addr
、地址长度指针client_addr_len
; - 返回:新的通信套接字
client_sock
,专门用于与当前客户端收发数据(原监听套接字listen_sock
仍可继续接受其他连接)。
- 输入:监听套接字
- 作用:完成TCP三次握手,建立与客户端的连接,返回通信套接字。
6. 步骤5:数据交互(read()/write())
6.1 接收客户端消息(read())
cpp
char buffer[1024]; // 数据缓冲区(存储接收的消息)
// 从客户端套接字读取数据,存入buffer
int read_size = read(client_sock, buffer, sizeof(buffer));
std::cout << "Received msg: \n" << buffer << std::endl;
- 作用 :从客户端的通信套接字读取数据(客户端发送的消息存于内核读缓冲区,
read()
将其拷贝到用户态的buffer
)。 - 参数 :通信套接字
client_sock
、缓冲区buffer
、缓冲区大小sizeof(buffer)
。 - 注意 :
read()
可能返回实际读取的字节数(小于缓冲区大小),此处简化处理未判断返回值(实际开发需检查)。
6.2 向客户端发送消息(write())
cpp
std::string res_msg = "hello client !"; // 要回复的消息
// 向客户端套接字写入数据(发送到内核写缓冲区,内核异步发送到网络)
write(client_sock, res_msg.c_str(), res_msg.size());
- 作用:将服务器的回复消息写入通信套接字的写缓冲区,由内核发送到客户端。
- 参数 :通信套接字
client_sock
、消息指针res_msg.c_str()
、消息长度res_msg.size()
。
7. 步骤6:关闭套接字(close())
cpp
close(client_sock); // 关闭与客户端的通信套接字(触发四次挥手)
close(listen_sock); // 关闭监听套接字(停止接受新连接)
- 作用:释放套接字资源,关闭TCP连接(内核会完成四次挥手流程)。
三、客户端程序详解(client.cpp)
客户端的核心任务是"创建套接字→连接服务器→收发数据→关闭连接",流程比服务器更简洁(无需绑定和监听)。
1. 步骤1:创建套接字(socket())
cpp
int client_sock = socket(AF_INET, SOCK_STREAM, 0); // 创建TCP套接字
if (client_sock == -1) {
std::cerr << "failed to create socket" << std::endl;
return -1;
}
- 与服务器
socket()
用法完全一致:创建流式套接字,返回客户端套接字描述符。
2. 步骤2:连接服务器(connect())
cpp
struct sockaddr_in server_addr; // 服务器地址结构体
server_addr.sin_family = AF_INET; // 协议族(IPv4)
// 设置服务器IP地址(字符串→网络字节序二进制)
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr.s_addr);
// 设置服务器端口(主机字节序→网络字节序)
server_addr.sin_port = htons(9888);
// 向服务器发起连接请求(完成三次握手)
if (connect(client_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
std::cerr << "failed to connect" << std::endl;
return -1;
}
cout << "connected to server" << endl;
- 核心函数
connect()
:客户端发起TCP三次握手,与服务器建立连接。 - 地址设置关键 :
- IP地址转换:
inet_pton(AF_INET, "127.0.0.1", ...)
将点分十进制字符串(127.0.0.1
是本地回环地址)转换为网络字节序的二进制IP(现代推荐用法,替代老的inet_addr()
); - 端口转换:
htons(9888)
将端口号转换为网络字节序(必须与服务器绑定的端口一致)。
- IP地址转换:
- 阻塞特性 :
connect()
会阻塞直到连接建立或失败(如服务器未启动则返回-1
)。
3. 步骤3:数据交互(write()/read())
3.1 向服务器发送消息(write())
cpp
string msg = "hello world !\n"; // 要发送的消息
// 向服务器套接字写入数据(发送到内核写缓冲区)
if(write(client_sock, msg.c_str(), msg.length()) == -1) {
std::cerr << "failed to write" << std::endl;
return -1;
}
- 作用与服务器的
write()
一致:将消息发送到服务器。
3.2 接收服务器回复(read())
cpp
char buffer[1024]; // 存储服务器回复的缓冲区
// 从服务器套接字读取回复消息
if(read(client_sock, buffer, sizeof(buffer)) == -1) {
std::cerr << "failed to read" << std::endl;
return -1;
}
printf("received from server:\n %s" , buffer); // 打印服务器回复
- 作用与服务器的
read()
一致:读取服务器发送的回复消息。
4. 步骤4:关闭套接字(close())
cpp
close(client_sock); // 关闭客户端套接字,触发四次挥手
四、核心知识点总结
1. 套接字角色区分
- 服务器 有两个套接字:
- 监听套接字(
listen_sock
):仅用于绑定、监听、接受连接,不参与数据交互; - 通信套接字(
client_sock
):每个客户端连接对应一个,专门用于收发数据。
- 监听套接字(
- 客户端 只有一个套接字(
client_sock
):用于连接服务器和数据交互。
2. 网络字节序转换的必要性
- 不同计算机的主机字节序(大端/小端)可能不同,TCP协议规定网络中必须使用大端字节序 ,因此:
- 端口号必须用
htons()
转换(host to network short
,16位); - IP地址(32位)必须用
htonl()
转换(host to network long
)。
- 端口号必须用
3. 数据交互的缓冲区机制
- 数据通过内核缓冲区间接传输:
- 发送:
write()
/send()
将数据写入写缓冲区,内核异步发送到网络; - 接收:内核将网络数据存入读缓冲区 ,
read()
/recv()
从缓冲区读取数据。
- 发送:
4. 程序局限性(优化方向)
- 仅支持单个客户端 :
accept()
只调用一次,处理完一个客户端后程序就退出(实际服务器需循环accept()
并通过多线程/多进程处理多个客户端); - 未处理粘包问题:若客户端发送大数据或连续发送小数据,服务器可能无法正确解析边界(需用"长度前缀法"优化);
- 错误处理简化:未严格检查
read()
/write()
的返回值(可能返回0表示连接关闭,-1表示错误); - 无循环通信:仅一次收发就结束,实际应用需循环接收消息(如
while
循环)。
五、运行流程演示
-
编译程序 :
bashg++ server.cpp -o server # 编译服务器 g++ client.cpp -o client # 编译客户端
-
启动服务器 :
bash./server # 输出 "server is listening",阻塞等待连接
-
启动客户端 (新终端):
bash./client # 输出 "connected to server",发送消息并接收回复
-
预期输出 :
- 服务器终端:
Received msg: hello world !
- 客户端终端:
received from server: hello client !
- 服务器终端:
通过这个示例,能直观理解TCP通信的全流程,后续可基于此扩展为支持多客户端、循环通信的完整服务器。