我们将从最基础的模型开始,逐步构建一个功能更完善的版本。

核心概念
要实现两个程序之间的实时通信,最直接的方法是使用 网络套接字,我们将采用 客户端/服务器 架构:
- 服务器:一个程序,它在一个特定的 IP 地址和端口上“监听”连接请求,它负责接收来自客户端的消息,并将消息广播给所有已连接的客户端。
- 客户端:另一个程序,它知道服务器的 IP 地址和端口,主动连接到服务器,客户端可以发送消息到服务器,并接收来自服务器的其他客户端的消息。
对于 C 语言,我们将使用 Berkeley Sockets (BSD Sockets) API,它在几乎所有现代操作系统(如 Linux, macOS, Windows)上都有支持。
第一部分:最简单的模型(一对一聊天)
这个模型中,一个服务器只接受一个客户端的连接,服务器和客户端可以互相发送消息。
服务器端代码 (server.c)
这个服务器会等待一个客户端连接,然后进入一个循环,不断地从客户端接收消息并打印到自己的终端,同时从自己的终端读取并发送给客户端。

// server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8080
#define BUFFER_SIZE 1024
int main() {
int server_fd, client_socket;
struct sockaddr_in address;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE] = {0};
// 1. 创建套接字文件描述符
// AF_INET: IPv4
// SOCK_STREAM: TCP
// 0: 自动选择协议
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 2. 设置套接字选项,允许地址重用
int opt = 1;
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) {
perror("setsockopt");
exit(EXIT_FAILURE);
}
// 3. 绑定套接字到端口和地址
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY; // 监听所有可用的网络接口
address.sin_port = htons(PORT); // 将端口号从主机字节序转为网络字节序
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 4. 开始监听连接
if (listen(server_fd, 3) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
printf("Server listening on port %d...\n", PORT);
// 5. 接受一个客户端连接
if ((client_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
perror("accept");
exit(EXIT_FAILURE);
}
printf("Client connected: %s:%d\n", inet_ntoa(address.sin_addr), ntohs(address.sin_port));
// 6. 通信循环
while (1) {
// 接收来自客户端的消息
int valread = read(client_socket, buffer, BUFFER_SIZE);
if (valread <= 0) {
// valread == 0 表示客户端断开连接
// valread < 0 表示发生错误
printf("Client disconnected.\n");
break;
}
printf("Client: %s", buffer);
// 如果收到 "exit",则退出
if (strncmp(buffer, "exit", 4) == 0) {
break;
}
// 清空缓冲区
memset(buffer, 0, BUFFER_SIZE);
// 从服务器终端读取要发送的消息
printf("Server: ");
fgets(buffer, BUFFER_SIZE, stdin);
// 发送消息给客户端
send(client_socket, buffer, strlen(buffer), 0);
// 如果服务器输入 "exit",则退出
if (strncmp(buffer, "exit", 4) == 0) {
break;
}
}
// 7. 关闭套接字
close(client_socket);
close(server_fd);
return 0;
}
客户端代码 (client.c)
这个客户端连接到服务器,然后进入一个循环,可以发送消息到服务器,并接收来自服务器的消息。
// client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8080
#define BUFFER_SIZE 1024
int main(int argc, char const *argv[]) {
int sock = 0;
struct sockaddr_in serv_addr;
char buffer[BUFFER_SIZE] = {0};
char server_ip[INET_ADDRSTRLEN];
// 如果没有提供服务器IP,则默认为本地回环地址 127.0.0.1
if (argc < 2) {
strcpy(server_ip, "127.0.0.1");
} else {
strcpy(server_ip, argv[1]);
}
// 1. 创建套接字
if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
printf("\n Socket creation error \n");
return -1;
}
// 2. 设置服务器地址
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
// 将IP地址从字符串转换为网络地址格式
if (inet_pton(AF_INET, server_ip, &serv_addr.sin_addr) <= 0) {
printf("\nInvalid address/ Address not supported \n");
return -1;
}
// 3. 连接到服务器
if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
printf("\nConnection Failed \n");
return -1;
}
printf("Connected to server %s:%d\n", server_ip, PORT);
// 4. 通信循环
while (1) {
// 从客户端终端读取要发送的消息
printf("You: ");
fgets(buffer, BUFFER_SIZE, stdin);
// 发送消息到服务器
send(sock, buffer, strlen(buffer), 0);
// 如果输入 "exit",则退出
if (strncmp(buffer, "exit", 4) == 0) {
break;
}
// 清空缓冲区
memset(buffer, 0, BUFFER_SIZE);
// 接收来自服务器的消息
int valread = read(sock, buffer, BUFFER_SIZE);
if (valread <= 0) {
printf("Server disconnected.\n");
break;
}
printf("Server: %s", buffer);
}
// 5. 关闭套接字
close(sock);
return 0;
}
如何编译和运行
-
编译: 你需要使用 C 编译器(如
gcc)来编译这两个文件,在 Linux 或 macOS 上,链接网络库:# 编译服务器 gcc server.c -o server # 编译客户端 gcc client.c -o client
-
运行:
-
第一步:在一个终端窗口中运行服务器:
(图片来源网络,侵删)./server
你会看到 "Server listening on port 8080..." 的提示。
-
第二步:在另一个终端窗口中运行客户端,如果你的服务器在同一台机器上,使用
0.0.1:./client 127.0.0.1
你会看到 "Connected to server..." 的提示。
-
第三步:现在你可以在两个终端里互相发送消息了,输入一行文字按回车,另一端就会收到,输入
exit可以退出程序。
-
第二部分:改进模型(多客户端聊天室)
上面的程序只能支持两个人聊天,一个真正的 "getchat" 应该允许多个客户端加入一个公共聊天室,这需要服务器能够处理多个连接。
主要修改点(服务器端)
-
使用
select系统调用:select是一种 I/O 多路复用技术,它允许我们监视多个文件描述符(套接字),看它们中的任何一个是否准备好进行读、写或异常处理,这样,服务器就可以在一个循环中处理所有客户端的连接,而不需要为每个客户端创建一个新线程。 -
管理客户端列表:服务器需要维护一个列表,记录所有已连接的客户端套接字。
服务器端代码 (multi_server.c)
// multi_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8080
#define BUFFER_SIZE 1024
#define MAX_CLIENTS 10
int main() {
int server_fd, new_socket;
struct sockaddr_in address;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE] = {0};
char *welcome_message = "Welcome to the chat room!\n";
// 用于 select 的文件描述符集合
fd_set read_fds;
int max_sd;
// 存储所有客户端套接字的数组
int client_sockets[MAX_CLIENTS];
for (int i = 0; i < MAX_CLIENTS; i++) {
client_sockets[i] = 0;
}
// 1. 创建套接字
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 2. 绑定套接字
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 3. 监听
if (listen(server_fd, 3) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
printf("Chat room server listening on port %d...\n", PORT);
while (1) {
// 清空文件描述符集合
FD_ZERO(&read_fds);
// 添加主套接字到集合
FD_SET(server_fd, &read_fds);
max_sd = server_fd;
// 添加所有客户端套接字到集合
for (int i = 0; i < MAX_CLIENTS; i++) {
int sd = client_sockets[i];
if (sd > 0) {
FD_SET(sd, &read_fds);
}
if (sd > max_sd) {
max_sd = sd;
}
}
// 等待活动套接字
// select 会阻塞,直到有套接字准备好或超时
if (select(max_sd + 1, &read_fds, NULL, NULL, NULL) < 0) {
perror("select error");
exit(EXIT_FAILURE);
}
// 检查是否有新的连接请求
if (FD_ISSET(server_fd, &read_fds)) {
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
perror("accept");
exit(EXIT_FAILURE);
}
// 将新的客户端套接字添加到数组
for (int i = 0; i < MAX_CLIENTS; i++) {
if (client_sockets[i] == 0) {
client_sockets[i] = new_socket;
printf("New connection from %s:%d, socket fd is %d\n", inet_ntoa(address.sin_addr), ntohs(address.sin_port), new_socket);
send(new_socket, welcome_message, strlen(welcome_message), 0);
break;
}
}
}
// 检查所有客户端是否有数据可读
for (int i = 0; i < MAX_CLIENTS; i++) {
int sd = client_sockets[i];
if (FD_ISSET(sd, &read_fds)) {
int valread = read(sd, buffer, BUFFER_SIZE);
// 客户端断开连接
if (valread == 0) {
getpeername(sd, (struct sockaddr*)&address, (socklen_t*)&addrlen);
printf("Client disconnected, socket fd %d\n", sd);
close(sd);
client_sockets[i] = 0;
}
// 收到客户端消息
else {
// 广播消息给所有其他客户端
for (int j = 0; j < MAX_CLIENTS; j++) {
int target_sd = client_sockets[j];
if (target_sd != 0 && target_sd != sd) {
send(target_sd, buffer, valread, 0);
}
}
}
memset(buffer, 0, BUFFER_SIZE);
}
}
}
// 清理资源(理论上循环不会退出到这里)
for (int i = 0; i < MAX_CLIENTS; i++) {
if (client_sockets[i] != 0) {
close(client_sockets[i]);
}
}
close(server_fd);
return 0;
}
客户端代码 (client.c)
客户端代码不需要改变,它仍然使用之前编写的 client.c。
如何编译和运行多客户端版本
-
编译服务器:
gcc multi_server.c -o multi_server
-
运行:
- 在一个终端运行服务器:
./multi_server
- 打开两个或更多新的终端,分别运行客户端:
./client 127.0.0.1
- 你在任何一个客户端输入的消息,都会被广播到所有其他已连接的客户端,这实现了一个简单的聊天室功能。
- 在一个终端运行服务器:
| 特性 | 一对一模型 (server.c) |
多客户端模型 (multi_server.c) |
|---|---|---|
| 架构 | 简单的客户端/服务器 | 客户端/服务器,带连接管理 |
| 核心技术 | accept, read, send |
select (I/O多路复用) |
| 客户端管理 | 只处理一个连接 | 维护一个客户端套接字数组 |
| 消息处理 | 点对点通信 | 服务器广播(聊天室) |
| 适用场景 | 两个特定程序通信 | 多人在线聊天室 |
从“一对一”到“多客户端”的演进,是网络编程中的一个重要里程碑。select 是实现这一目标的关键,更高级的实现可能会使用 poll 或 epoll (Linux特有) 来获得更好的性能,但 select 的原理是相通的,希望这个详细的教程能帮助你理解如何用 C 语言构建一个聊天程序!
