一、系統概述
????????本系統是一個基于 TCP 協議的多人聊天系統,由一個服務器和多個客戶端組成。客戶端可以連接到服務器,向服務器發送消息,服務器接收到消息后將其轉發給其他客戶端,實現多人之間的實時聊天。系統使用 C 語言編寫,利用了 Unix 系統的網絡編程接口和多線程、I/O 多路復用等技術。
二、文件結構
server.c
:服務器端程序,負責監聽客戶端連接、接收客戶端消息并將消息轉發給其他客戶端。client1.c
:客戶端程序,使用?poll
?函數實現 I/O 多路復用,同時處理用戶輸入和服務器消息。client2.c
:客戶端程序,使用多線程技術,一個線程負責接收服務器消息,另一個線程負責處理用戶輸入。
三、代碼詳細分析
1. 數據包結構體
????????在三個文件中都定義了相同的數據包結構體?Packet
,用于在客戶端和服務器之間傳輸數據。
typedef struct {int type; // 0 for message, 1 for disconnectchar 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)
#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 disconnectchar 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);}// 開始監聽客戶端連接,允許的最大連接請求隊列長度為 3if (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)
#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 disconnectchar 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)
// 包含標準輸入輸出庫,用于使用 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 disconnectchar 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;
}