一、系统概述
本系统是一个基于 TCP 协议的多人聊天系统,由一个服务器和多个客户端组成。客户端可以连接到服务器,向服务器发送消息,服务器接收到消息后将其转发给其他客户端,实现多人之间的实时聊天。系统使用 C 语言编写,利用了 Unix 系统的网络编程接口和多线程、I/O 多路复用等技术。
二、文件结构
server.c
:服务器端程序,负责监听客户端连接、接收客户端消息并将消息转发给其他客户端。client1.c
:客户端程序,使用poll
函数实现 I/O 多路复用,同时处理用户输入和服务器消息。client2.c
:客户端程序,使用多线程技术,一个线程负责接收服务器消息,另一个线程负责处理用户输入。
三、代码详细分析
1. 数据包结构体
在三个文件中都定义了相同的数据包结构体 Packet
,用于在客户端和服务器之间传输数据。
cpp
typedef struct {
int type; // 0 for message, 1 for disconnect
char data[BUFFER_SIZE];
} Packet;
type
:数据包类型,0 表示消息,1 表示断开连接。data
:数据包携带的数据,最大长度为BUFFER_SIZE
。
2. 服务器端程序(server.c)
2.1 主要变量
server_fd
:服务器套接字文件描述符。client_fd
:客户端套接字文件描述符。max_fd
:记录最大的文件描述符,用于select
函数。activity
:记录select
函数返回的活动文件描述符数量。valread
:记录从客户端读取的数据长度。server_addr
:存储服务器的地址信息。client_addr
:存储客户端的地址信息。client_sockets
:数组用于存储所有客户端的套接字文件描述符。readfds
:文件描述符集合,用于select
函数监听可读事件。
2.2 主要步骤
- 创建套接字 :使用
socket
函数创建一个 TCP 套接字。 - 绑定地址 :使用
bind
函数将套接字绑定到指定的地址和端口。 - 监听连接 :使用
listen
函数开始监听客户端连接。 - 循环处理 :使用
select
函数监听服务器套接字和客户端套接字的可读事件。- 若服务器套接字有可读事件,说明有新的客户端连接请求,使用
accept
函数接受连接。 - 若客户端套接字有可读事件,从客户端读取数据包,根据数据包类型进行相应处理。
- 若数据包类型为消息,将消息转发给其他客户端。
- 若数据包类型为断开连接或读取到的数据长度为 0,说明客户端断开连接,关闭客户端套接字。
- 若服务器套接字有可读事件,说明有新的客户端连接请求,使用
3. 客户端程序(client1.c)
3.1 主要变量
client_fd
:客户端套接字文件描述符。server_addr
:存储服务器的地址信息。packet
:用于存储要发送或接收的数据包。fds
:数组用于存储要监听的文件描述符及其事件。
3.2 主要步骤
- 创建套接字 :使用
socket
函数创建一个 TCP 套接字。 - 连接服务器 :使用
connect
函数连接到服务器。 - 初始化
poll
结构体:监听标准输入和客户端套接字的可读事件。 - 循环处理 :使用
poll
函数监听文件描述符集合中的可读事件。- 若标准输入有可读事件,从标准输入读取数据,设置数据包类型为消息,发送给服务器。
- 若客户端套接字有可读事件,从服务器读取数据包,根据数据包类型进行相应处理。
- 若数据包类型为消息,输出接收到的消息。
- 若数据包类型为断开连接或读取数据失败,说明服务器断开连接,关闭客户端套接字,跳出循环。
4. 客户端程序(client2.c)
4.1 主要变量
client_fd
:客户端套接字文件描述符。server_addr
:存储服务器的地址信息。packet
:用于存储要发送或接收的数据包。thread_id
:存储线程的标识符。
4.2 主要步骤
- 创建套接字 :使用
socket
函数创建一个 TCP 套接字。 - 连接服务器 :使用
connect
函数连接到服务器。 - 创建线程:创建一个线程来接收服务器发送的消息。
- 循环处理:在主线程中,从标准输入读取数据,设置数据包类型为消息,发送给服务器。
- 线程函数 :在子线程中,持续接收服务器消息,根据数据包类型进行相应处理。
- 若数据包类型为消息,输出接收到的消息。
- 若数据包类型为断开连接或读取数据失败,说明服务器断开连接,关闭客户端套接字,退出程序。
四、编译和运行
4.1 编译
gcc server.c -o server
gcc client1.c -o client1
gcc client2.c -o client2 -lpthread
4.2 运行
-
启动服务器:
./server
-
启动客户端:
./client1
./client2
4.3运行结果展示

五、源码
5.1服务器端程序(server.c)
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <fcntl.h>
#include <errno.h>
// 定义服务器监听的端口号
#define PORT 8080
// 定义数据缓冲区的大小
#define BUFFER_SIZE 1024
// 定义服务器允许的最大客户端连接数
#define MAX_CLIENTS 10
/**
* 定义数据包结构体,用于在服务器和客户端之间传输数据
* type 数据包类型,0 表示消息,1 表示断开连接
* data 数据包携带的数据
*/
typedef struct {
int type; // 0 for message, 1 for disconnect
char data[BUFFER_SIZE];
} Packet;
/**
* 主函数,服务器程序的入口点
* @return 程序的退出状态码,0 表示正常退出
*/
int main() {
// server_fd 为服务器套接字文件描述符,client_fd 为客户端套接字文件描述符
// max_fd 记录最大的文件描述符,用于 select 函数
// activity 记录 select 函数返回的活动文件描述符数量
// valread 记录从客户端读取的数据长度
int server_fd, client_fd, max_fd, activity, valread;
// server_addr 存储服务器的地址信息,client_addr 存储客户端的地址信息
struct sockaddr_in server_addr, client_addr;
// client_len 存储客户端地址结构体的长度
socklen_t client_len = sizeof(client_addr);
// packet 用于存储从客户端接收的数据包
Packet packet;
// client_sockets 数组用于存储所有客户端的套接字文件描述符
int client_sockets[MAX_CLIENTS] = {0};
// readfds 是一个文件描述符集合,用于 select 函数监听可读事件
fd_set readfds;
// 创建服务器套接字,使用 IPv4 地址族和 TCP 协议
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
// 若套接字创建失败,输出错误信息并退出程序
perror("socket failed");
exit(EXIT_FAILURE);
}
// 设置服务器地址信息
server_addr.sin_family = AF_INET;
// 监听所有可用的网络接口
server_addr.sin_addr.s_addr = INADDR_ANY;
// 将端口号从主机字节序转换为网络字节序
server_addr.sin_port = htons(PORT);
// 将服务器套接字绑定到指定的地址和端口
if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
// 若绑定失败,输出错误信息,关闭套接字并退出程序
perror("bind failed");
close(server_fd);
exit(EXIT_FAILURE);
}
// 开始监听客户端连接,允许的最大连接请求队列长度为 3
if (listen(server_fd, 3) < 0) {
// 若监听失败,输出错误信息,关闭套接字并退出程序
perror("listen");
close(server_fd);
exit(EXIT_FAILURE);
}
// 输出服务器启动信息,显示监听的端口号
printf("Server started on port %d\n", PORT);
// 进入无限循环,持续处理客户端连接和数据
while (1) {
// 清空文件描述符集合
FD_ZERO(&readfds);
// 将服务器套接字添加到文件描述符集合中,监听其可读事件
FD_SET(server_fd, &readfds);
// 初始化最大文件描述符为服务器套接字文件描述符
max_fd = server_fd;
// 遍历客户端套接字数组
for (int i = 0; i < MAX_CLIENTS; i++) {
// 获取当前客户端的套接字文件描述符
int sd = client_sockets[i];
if (sd > 0) {
// 若该客户端套接字有效,将其添加到文件描述符集合中
FD_SET(sd, &readfds);
}
if (sd > max_fd) {
// 更新最大文件描述符
max_fd = sd;
}
}
// 调用 select 函数监听文件描述符集合中的可读事件,无超时时间
activity = select(max_fd + 1, &readfds, NULL, NULL, NULL);
if ((activity < 0) && (errno != EINTR)) {
// 若 select 函数调用失败且不是被信号中断,输出错误信息
perror("select error");
}
if (FD_ISSET(server_fd, &readfds)) {
// 若服务器套接字有可读事件,说明有新的客户端连接请求
if ((client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len)) < 0) {
// 若接受连接失败,输出错误信息并继续循环
perror("accept");
continue;
}
// 输出新客户端连接的信息,包括套接字文件描述符、IP 地址和端口号
printf("New connection, socket fd is %d, ip is : %s, port : %d\n",
client_fd, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
// 遍历客户端套接字数组,找到一个空闲位置存储新客户端的套接字文件描述符
for (int i = 0; i < MAX_CLIENTS; i++) {
if (client_sockets[i] == 0) {
client_sockets[i] = client_fd;
break;
}
}
}
// 遍历客户端套接字数组,检查每个客户端是否有可读事件
for (int i = 0; i < MAX_CLIENTS; i++) {
int sd = client_sockets[i];
if (FD_ISSET(sd, &readfds)) {
// 从客户端读取数据包
valread = read(sd, &packet, sizeof(Packet));
if (valread == 0) {
// 若读取到的数据长度为 0,说明客户端断开连接
getpeername(sd, (struct sockaddr*)&client_addr, &client_len);
// 输出客户端断开连接的信息,包括 IP 地址和端口号
printf("Host disconnected, ip %s, port %d\n",
inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
// 关闭客户端套接字
close(sd);
// 将该位置的客户端套接字文件描述符置为 0,表示空闲
client_sockets[i] = 0;
} else {
if (packet.type == 0) {
// 若数据包类型为消息,输出接收到的消息
printf("Received from client %d: %s", sd, packet.data);
// 将消息转发给其他客户端
for (int j = 0; j < MAX_CLIENTS; j++) {
if (client_sockets[j] != sd && client_sockets[j] != 0) {
// 发送数据包给其他客户端
send(client_sockets[j], &packet, sizeof(Packet), 0);
}
}
} else if (packet.type == 1) {
// 若数据包类型为断开连接,输出客户端断开连接的信息
printf("Client %d disconnected\n", sd);
// 关闭客户端套接字
close(sd);
// 将该位置的客户端套接字文件描述符置为 0,表示空闲
client_sockets[i] = 0;
}
}
}
}
}
return 0;
}
5.2客户端程序(client1.c)
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <poll.h>
#include <errno.h>
// 定义服务器监听的端口号
#define PORT 8080
// 定义数据缓冲区的大小
#define BUFFER_SIZE 1024
/**
* 定义数据包结构体,用于在客户端和服务器之间传输数据
* type 数据包类型,0 表示消息,1 表示断开连接
* data 数据包携带的数据
*/
typedef struct {
int type; // 0 for message, 1 for disconnect
char data[BUFFER_SIZE];
} Packet;
/**
* 主函数,客户端程序的入口点
* @return 程序的退出状态码,0 表示正常退出
*/
int main() {
// client_fd 为客户端套接字文件描述符
int client_fd;
// server_addr 存储服务器的地址信息
struct sockaddr_in server_addr;
// packet 用于存储要发送或接收的数据包
Packet packet;
// fds 数组用于存储要监听的文件描述符及其事件
struct pollfd fds[2];
// 创建客户端套接字,使用 IPv4 地址族和 TCP 协议
if ((client_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
// 若套接字创建失败,输出错误信息并退出程序
perror("socket failed");
exit(EXIT_FAILURE);
}
// 设置服务器地址信息
server_addr.sin_family = AF_INET;
// 将端口号从主机字节序转换为网络字节序
server_addr.sin_port = htons(PORT);
// 将服务器的 IP 地址转换为网络字节序
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
// 连接到服务器
if (connect(client_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
// 若连接失败,输出错误信息,关闭套接字并退出程序
perror("connect");
close(client_fd);
exit(EXIT_FAILURE);
}
// 输出连接成功的信息
printf("Connected to server\n");
// 初始化 poll 结构体
// 监听标准输入的可读事件
fds[0].fd = STDIN_FILENO;
fds[0].events = POLLIN;
// 监听客户端套接字的可读事件
fds[1].fd = client_fd;
fds[1].events = POLLIN;
// 进入无限循环,持续处理输入和服务器消息
while (1) {
// 调用 poll 函数监听文件描述符集合中的可读事件,无超时时间
int activity = poll(fds, 2, -1);
if ((activity < 0) && (errno != EINTR)) {
// 若 poll 函数调用失败且不是被信号中断,输出错误信息
perror("poll error");
}
if (fds[0].revents & POLLIN) {
// 若标准输入有可读事件
// 清空数据包的数据部分
memset(packet.data, 0, BUFFER_SIZE);
if (fgets(packet.data, BUFFER_SIZE, stdin) != NULL) {
// 若成功从标准输入读取数据
// 设置数据包类型为消息
packet.type = 0;
// 发送数据包给服务器
send(client_fd, &packet, sizeof(Packet), 0);
}
}
if (fds[1].revents & POLLIN) {
// 若客户端套接字有可读事件
// 清空数据包
memset(&packet, 0, sizeof(Packet));
if (read(client_fd, &packet, sizeof(Packet)) > 0) {
// 若成功从服务器读取数据
if (packet.type == 0) {
// 若数据包类型为消息,输出接收到的消息
printf("Received from server: %s", packet.data);
} else if (packet.type == 1) {
// 若数据包类型为断开连接,输出服务器断开连接的信息
printf("Server disconnected\n");
// 关闭客户端套接字
close(client_fd);
// 跳出循环
break;
}
} else {
// 若读取数据失败,说明服务器断开连接
printf("Server disconnected\n");
// 关闭客户端套接字
close(client_fd);
// 跳出循环
break;
}
}
}
// 关闭客户端套接字
close(client_fd);
return 0;
}
5.3客户端程序(client2.c)
cpp
// 包含标准输入输出库,用于使用 printf、perror 等函数进行输入输出操作
#include <stdio.h>
// 包含标准库,提供 exit 等函数用于程序退出等操作
#include <stdlib.h>
// 包含字符串处理库,提供 memset、strlen 等字符串操作函数
#include <string.h>
// 包含 Unix 标准库,提供 close、read、write 等系统调用函数
#include <unistd.h>
// 包含网络地址转换库,提供 inet_ntoa、htons 等网络地址转换函数
#include <arpa/inet.h>
// 包含线程相关的头文件,用于创建和管理线程
#include <pthread.h>
// 定义服务器监听的端口号
#define PORT 8080
// 定义数据缓冲区的大小
#define BUFFER_SIZE 1024
/**
* 定义数据包结构体,用于在客户端和服务器之间传输数据
* type 数据包类型,0 表示消息,1 表示断开连接
* data 数据包携带的数据
*/
typedef struct {
int type; // 0 for message, 1 for disconnect
char data[BUFFER_SIZE];
} Packet;
// 全局变量,存储客户端套接字文件描述符
int client_fd;
/**
* 线程函数,用于接收服务器发送的消息
* arg 线程函数的参数,此处未使用
* @return 线程返回值,此处为 NULL
*/
void *receive_messages(void *arg) {
// 定义数据包变量,用于存储从服务器接收的数据包
Packet packet;
// 进入无限循环,持续接收服务器消息
while (1) {
// 清空数据包
memset(&packet, 0, sizeof(Packet));
if (read(client_fd, &packet, sizeof(Packet)) > 0) {
// 若成功从服务器读取数据
if (packet.type == 0) {
// 若数据包类型为消息,输出接收到的消息
printf("Received from server: %s", packet.data);
} else if (packet.type == 1) {
// 若数据包类型为断开连接,输出服务器断开连接的信息
printf("Server disconnected\n");
// 关闭客户端套接字
close(client_fd);
// 退出程序,返回失败状态
exit(EXIT_FAILURE);
}
} else {
// 若读取数据失败,说明服务器断开连接
printf("Server disconnected\n");
// 关闭客户端套接字
close(client_fd);
// 退出程序,返回失败状态
exit(EXIT_FAILURE);
}
}
return NULL;
}
/**
* 主函数,客户端程序的入口点
* @return 程序的退出状态码,0 表示正常退出
*/
int main() {
// server_addr 存储服务器的地址信息
struct sockaddr_in server_addr;
// packet 用于存储要发送的数据包
Packet packet;
// thread_id 存储线程的标识符
pthread_t thread_id;
// 创建客户端套接字,使用 IPv4 地址族和 TCP 协议
if ((client_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
// 若套接字创建失败,输出错误信息并退出程序
perror("socket failed");
exit(EXIT_FAILURE);
}
// 设置服务器地址信息
server_addr.sin_family = AF_INET;
// 将端口号从主机字节序转换为网络字节序
server_addr.sin_port = htons(PORT);
// 将服务器的 IP 地址转换为网络字节序
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
// 连接到服务器
if (connect(client_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
// 若连接失败,输出错误信息,关闭套接字并退出程序
perror("connect");
close(client_fd);
exit(EXIT_FAILURE);
}
// 输出连接成功的信息
printf("Connected to server\n");
// 创建线程来接收消息
if (pthread_create(&thread_id, NULL, receive_messages, NULL) != 0) {
// 若线程创建失败,输出错误信息,关闭套接字并退出程序
perror("pthread_create");
close(client_fd);
exit(EXIT_FAILURE);
}
// 进入无限循环,持续从标准输入读取数据并发送给服务器
while (1) {
// 清空数据包的数据部分
memset(packet.data, 0, BUFFER_SIZE);
if (fgets(packet.data, BUFFER_SIZE, stdin) != NULL) {
// 若成功从标准输入读取数据
// 设置数据包类型为消息
packet.type = 0;
// 发送数据包给服务器
send(client_fd, &packet, sizeof(Packet), 0);
}
}
// 关闭客户端套接字
close(client_fd);
return 0;
}