Linux 下基于 TCP 的 C 语言客户端/服务器通信详解(三个示例逐步进阶)
Linux 下基于 TCP 的 C 语言客户端/服务器通信详解(三个示例逐步进阶)
在 Linux 下,基于 TCP 协议 的客户端/服务器通信可以通过 socket API 来实现。
很多初学者觉得“socket”晦涩又难懂,其实它就是一个“文件描述符”,和 open/read/write/close
一样,可以进行读写,只不过这次数据流是通过网络传输的。
本文通过 三个示例,从最简单的单向通信到双向循环通信,逐步带你理解 TCP 编程的完整流程:
- 客户端发送一次消息到服务器(单向通信)
- 客户端键盘循环发送消息到服务器
- 客户端和服务器双向键盘循环通信(多线程收发分离)
一、客户端发送一次消息到服务器(单向通信)
这是最简单的 TCP 应用场景:客户端发一句话,服务器接收并打印。
可以理解为:打电话 → 说一句话 → 挂电话。
客户端代码
/*
客户端流程:
1. socket() 创建套接字
2. connect() 连接服务器
3. send() 发送数据
4. close() 关闭套接字
*/#include "net.h"int main(int argc, char const *argv[])
{int fd;int ret;// 1. 买电话:创建套接字// AF_INET -> 使用 IPv4 协议族// SOCK_STREAM -> 使用面向连接的 TCP// 0 -> 协议自动选择(一般就是 TCP)fd = socket(AF_INET, SOCK_STREAM, 0);if (fd == -1){perror("socket"); // 打印错误原因exit(1); // 创建失败直接退出}/*2. 绑卡(客户端通常省略)bind() 是给 socket 强行指定“本机的 IP/端口”。- 一般情况下,客户端只需要关心对方(服务器)的 IP/端口;自己的 IP/端口由操作系统自动分配即可。- 如果客户端调用 bind(),可能会因为端口占用/冲突而导致 connect() 失败。所以常规客户端 **直接跳过 bind**。*/// 3. 打电话:connect 连接到服务器struct sockaddr_in server;server.sin_family = AF_INET;server.sin_addr.s_addr = inet_addr("192.168.107.146"); // 服务器 IPserver.sin_port = htons(10001); // 服务器端口(主机字节序 → 网络字节序)ret = connect(fd, (struct sockaddr *)&server, sizeof(server));if (ret == -1){perror("connect");exit(1);}// 4. 发数据char buf[128] = "hello,world";ret = send(fd, buf, strlen(buf), 0);/*send 参数说明:- 第二个参数:要发送的数据- 第三个参数:要发送的字节数注意这里传 strlen(buf):- strlen(buf) 表示字符串的实际有效长度(不包含 '\0')。- 如果传 sizeof(buf)=128,就会把数组后面的 '\0' 也发过去,导致浪费带宽,甚至可能让对方收到一堆没用的空字符。总结:- 发数据时 → strlen(告诉内核“我实际要发多少”)- 收数据时 → sizeof(告诉内核“我最多能装多少”)*/if (ret == -1){perror("send");}// 5. 挂电话:释放套接字close(fd);return 0;
}
服务端代码
/*
服务端流程:
1. socket() 创建套接字
2. bind() 绑定 IP+端口
3. listen() 监听端口
4. accept() 等待客户端连接
5. recv() 接收数据
6. close() 关闭套接字
*/#include "net.h"int main(int argc, char const *argv[])
{int fd;int ret;int new_fd;// 1. 买电话:创建套接字fd = socket(AF_INET, SOCK_STREAM, 0);if (fd == -1){perror("socket");exit(1);}// 防止端口被占用:// 如果服务器异常退出,内核会在几分钟内保留端口(TIME_WAIT 状态),// 重新 bind() 会报“Address already in use”。// 所以要设置 SO_REUSEADDR,允许端口快速复用。// 客户端用处不大,一般是服务器才需要int opt = 1;setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));// 2. 绑卡:指定服务器的 IP 和端口struct sockaddr_in server;server.sin_family = AF_INET;server.sin_addr.s_addr = inet_addr("192.168.107.146"); // 服务器 IPserver.sin_port = htons(10001); // 服务器端口ret = bind(fd, (struct sockaddr *)&server, sizeof(server));if (ret == -1){perror("bind");exit(1);}// 3. 监听:进入“接听模式”// backlog=5 表示内核允许排队的最大连接数为 5listen(fd, 5);printf("tcp server have started...\n");// 4. 接听:等待客户端呼入struct sockaddr_in client;socklen_t len = sizeof(client); // socklen_t 通常等于 unsigned int,(u32)new_fd = accept(fd, (struct sockaddr *)&client, &len);if (new_fd == -1){perror("accept");exit(1);}// 打印客户端信息printf("client connected: %s:%d\n",inet_ntoa(client.sin_addr), // IP 地址转字符串ntohs(client.sin_port)); // 端口转主机字节序// 5. 收数据char buf[128] = {0};ret = recv(new_fd, buf, sizeof(buf), 0);/*recv 参数说明:- 第二个参数:缓冲区- 第三个参数:最大能接收的字节数这里必须用 sizeof(buf),因为缓冲区还没有内容,没法用 strlen 来判断“能接收多少”。(strlen 只能用在已有字符串的场景)总结:- 收数据时 → sizeof(“最多能装多少”)- 发数据时 → strlen(“实际要发多少”)*/if (ret <= 0){perror("recv");close(new_fd);close(fd);return -1;}else{printf("receive from cli: %s\n", buf);}// 6. 挂电话// 先关通信套接字 new_fd,再关监听套接字 fd// 因为 监听套接字 fd 负责“接电话”,通信套接字 new_fd 负责“通话”// 先关 new_fd 表示先结束这次通话,再关 fd 表示整个电话服务不再提供// 如果反过来,先关 fd,监听功能就没了,但当前通话还在,逻辑上不合理close(new_fd);close(fd);return 0;
}
知识点:
- 客户端 通常不需要
bind()
,操作系统会自动分配端口。 - 发送 时用
strlen()
,接收 时用sizeof()
。 - 服务端记得设置
SO_REUSEADDR
,避免异常退出后端口占用。
二、客户端键盘循环发送消息到服务器
上一个例子只能发一次消息,不够灵活。
在实际聊天场景中,我们需要循环发送数据。这里加入 循环+fgets 实现多次通信。
客户端代码
/*
1. 买电话(创建套接字) fd = socket()
2. 绑卡(可选,一般不需要) bind()
3. 打电话(连接服务器) connect()
4. 通话(循环发送数据) send()
5. 挂电话(释放套接字) close()本程序:客户端从键盘输入字符串,循环发送给服务器,直到输入 "quit"。
*/#include "net.h"int main(int argc, char *argv[])
{int fd; // 客户端套接字int ret; // 系统调用返回值// 1. 买电话:创建 TCP 套接字fd = socket(AF_INET, SOCK_STREAM, 0);if (fd == -1){perror("socket"); // 创建失败,打印错误原因exit(1);}// 允许重用端口(防止上次异常退出端口未释放)int on = 1;setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));/*2. 绑卡(客户端通常不写)bind() 用于指定本机 IP/端口。客户端不必写,操作系统会自动分配可用端口。写了 bind 可能与服务器端冲突,导致 connect 失败。*/// 3. 打电话:连接服务器struct sockaddr_in server;server.sin_family = AF_INET; // IPv4 协议族server.sin_addr.s_addr = inet_addr("192.168.107.146"); // 服务器 IPserver.sin_port = htons(10001); // 服务器端口(主机字节序→网络字节序)ret = connect(fd, (struct sockaddr *)&server, sizeof(server));if (ret == -1){perror("connect"); // 连接失败exit(1);}// 4. 通话:循环发送数据char buf[50] = {0};while (1){memset(buf, 0, 50); // 清空缓冲区,保证上次残留数据不会影响本次发送fgets(buf, 50, stdin); // 从键盘读取字符串// send 参数:// buf -> 发送内容// strlen(buf) -> 实际要发的字节数(不含多余 '\0')ret = send(fd, buf, strlen(buf), 0);if (ret == -1){perror("send");}printf("send %d bytes\n", ret);// 输入 quit 即退出循环if (strncmp(buf, "quit\n", 5) == 0)break;}// 5. 挂电话:关闭套接字close(fd);return 0;
}
服务端代码
/*
1. 买电话(创建套接字) fd = socket()
2. 绑卡(绑定服务器 IP/端口) bind()
3. 开机监听(监听端口) listen()
4. 等待来电(接受连接) accept()
5. 通话(循环接收数据) recv()
6. 挂电话(关闭套接字) close()本程序:服务端接收客户端发送的字符串并打印,直到客户端发送 "quit" 或断开。
*/#include "net.h"int main(int argc, char *argv[])
{int fd; // 监听套接字int ret; // 系统调用返回值int new_fd; // 通信套接字(每个客户端对应一个 new_fd)// 1. 买电话:创建 TCP 套接字fd = socket(AF_INET, SOCK_STREAM, 0);if (fd == -1){perror("socket");exit(1);}// 防止端口被占用(上次异常退出可能导致 TIME_WAIT)int on = 1;setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));// 2. 绑卡:指定服务器 IP 和端口struct sockaddr_in server;server.sin_family = AF_INET;server.sin_addr.s_addr = inet_addr("192.168.107.146"); // 本机 IPserver.sin_port = htons(10001); // 端口ret = bind(fd, (struct sockaddr *)&server, sizeof(server));if (ret == -1){perror("bind");exit(1);}// 3. 监听:进入监听模式// backlog = 5 表示内核排队的最大连接数listen(fd, 5);printf("tcp server have started...\n");// 4. 接听:等待客户端连接struct sockaddr_in client;socklen_t len = sizeof(client);new_fd = accept(fd, (struct sockaddr *)&client, &len);if (new_fd == -1){perror("accept");exit(1);}// 打印客户端信息printf("client connected:\nIP = %s\nPort = %d\n",inet_ntoa(client.sin_addr),ntohs(client.sin_port));// 5. 通话:循环接收客户端数据char buf[50] = {0};while (1){memset(buf, 0, 50); // 清空缓冲区ret = recv(new_fd, buf, sizeof(buf), 0);// recv 参数:// buf -> 接收缓冲区// sizeof(buf) -> 最大能接收的字节数printf("recv %d bytes: %s", ret, buf);// 客户端输入 quit 则退出if (strncmp(buf, "quit\n", 5) == 0){printf("client quit\n");break;}// 如果 recv 返回 0,表示客户端已经断开连接if (ret == 0){printf("client offline\n");break;}}// 6. 挂电话// 先关闭通信套接字 new_fd,再关闭监听套接字 fdclose(new_fd);close(fd);return 0;
}
知识点:
- 客户端输入 “quit” → 服务端退出。
memset
保证缓冲区干净,避免上一次数据残留。recv()
返回0
表示客户端关闭连接。
三、客户端和服务器双向键盘循环通信(多线程)
到这里,如果要实现“真正的聊天”,必须支持 收发同时进行。
单线程只能阻塞在 send()
或 recv()
,所以需要 多线程分离收发。
客户端代码
/*
1.买电话 fd=socket()
2.绑卡 bind()
3.打电话 connect()
4.收发数据 send/recv()
5.挂电话 close()客户端键盘循环发送一个字符串给服务器端接收并打印
服务端键盘循环发送一个字符串给客户端接收并打印
*/#include "net.h" // 包含网络相关的头文件,如 <sys/socket.h>, <arpa/inet.h> 等int fd; // 全局变量,存储客户端的 socket 文件描述符// 接收消息的线程函数,负责从服务器接收数据并打印
void *recv_mess(void *arg)
{int ret; // 存储接收操作的返回值char buf[50] = {0}; // 接收数据的缓冲区,初始化为 0pthread_detach(pthread_self()); // 设置线程为分离状态,线程结束时自动回收资源while (1) // 循环接收消息{bzero(buf, 50); // 清空缓冲区ret = recv(fd, buf, sizeof(buf), 0); // 从 socket 接收数据printf("recv srv %d: %s", ret, buf); // 打印接收到的数据和字节数if (strncmp(buf, "quit\n", 5) == 0) // 如果接收到 "quit" 字符串{printf("srv quit\n"); // 打印服务器退出信息break; // 退出循环}if (ret == 0) // 如果返回值是 0,表示服务器断开连接{printf("srv offline\n"); // 打印服务器下线信息break; // 退出循环}}return NULL; // 线程函数返回,因为用了pthread_detach,所以这句话可以省略
}int main(int argc, char *argv[])
{int ret; // 存储函数调用的返回值// 1. 创建 socket(买电话)// AF_INET 表示使用 IPv4,SOCK_STREAM 表示 TCP 协议fd = socket(AF_INET, SOCK_STREAM, 0);if (fd == -1) // 如果创建 socket 失败{perror("socket"); // 打印错误信息exit(1); // 退出程序}// 2. 绑定地址(绑卡)// 作为客户端,不需要绑定地址// 设置 socket 选项,允许重用端口int on = 1;setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); // 防止端口被占用// 3. 连接服务器(打电话)struct sockaddr_in server; // 定义服务器地址结构server.sin_family = AF_INET; // 设置地址族为 IPv4server.sin_addr.s_addr = inet_addr("192.168.107.146"); // 服务器 IP 地址server.sin_port = htons(10001); // 服务器端口号(网络字节序)ret = connect(fd, (struct sockaddr *)&server, sizeof(server)); // 连接到服务器if (ret == -1) // 如果连接失败{perror("connect"); // 打印错误信息exit(1); // 退出程序}// 创建接收消息的线程pthread_t tid; // 线程 IDpthread_create(&tid, NULL, recv_mess, NULL); // 创建线程,运行 recv_mess 函数// 4. 发送数据(收发数据)char buf[50]; // 发送数据的缓冲区while (1) // 循环发送数据{bzero(buf, 50); // 清空缓冲区fgets(buf, 50, stdin); // 从标准输入(键盘)读取一行数据ret = send(fd, buf, strlen(buf), 0); // 发送数据到服务器printf("send %d\n", ret); // 打印发送的字节数if (strncmp(buf, "quit\n", 5) == 0) // 如果输入 "quit"break; // 退出循环}// 5. 关闭 socket(挂电话)close(fd); // 关闭 socket 连接return 0; // 程序正常退出
}
服务端代码(双向循环)
/*
1.买电话 fd=socket()
2.绑卡 bind()
3.监听 listen()
4.接听 accept()
5.收发数据 send/recv
6.挂电话 close()客户端键盘循环发送一个字符串给服务器端接收并打印
服务端键盘循环发送一个字符串给客户端接收并打印
*/#include "net.h" // 包含网络相关的头文件,如 <sys/socket.h>, <arpa/inet.h> 等// 发送消息的线程函数,负责从键盘读取数据并发送给客户端
void *send_mess(void *arg)
{int ret; // 存储发送操作的返回值int newfd = *((int *)arg);// arg 是一个 void * 类型的指针,*((int *)arg) 的作用是将这个指针转换为 int 指针并解引用// 这样可以获取其指向的整数值(在这里是客户端连接的文件描述符 newfd)char buf[50]; // 发送数据的缓冲区pthread_detach(pthread_self()); // 设置线程为分离状态,线程结束时自动回收资源while (1) // 循环发送数据{bzero(buf, 50); // 清空缓冲区fgets(buf, 50, stdin); // 从标准输入(键盘)读取一行数据ret = send(newfd, buf, strlen(buf), 0); // 发送数据到客户端printf("send %d\n", ret); // 打印发送的字节数if (strncmp(buf, "quit\n", 5) == 0) // 如果输入 "quit"break; // 退出循环}return NULL; // 线程函数返回,因为用了pthread_detach,所以这句话可以省略
}int main(int argc, char *argv[])
{int fd; // 服务器监听 socket 的文件描述符int ret; // 存储函数调用的返回值// 1. 创建 socket(买电话)// AF_INET 表示使用 IPv4,SOCK_STREAM 表示 TCP 协议fd = socket(AF_INET, SOCK_STREAM, 0);if (fd == -1) // 如果创建 socket 失败{perror("socket"); // 打印错误信息exit(1); // 退出程序}int on = 1;setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); // 设置 socket 选项,允许重用端口// 2. 绑定地址(绑卡)struct sockaddr_in server; // 定义服务器地址结构server.sin_family = AF_INET; // 设置地址族为 IPv4server.sin_addr.s_addr = inet_addr("192.168.107.164"); // 服务器 IP 地址server.sin_port = htons(10001); // 服务器端口号(网络字节序)ret = bind(fd, (struct sockaddr *)&server, sizeof(server)); // 绑定 socket 和地址if (ret == -1) // 如果绑定失败{perror("bind"); // 打印错误信息exit(1); // 退出程序}// 3. 监听连接(监听)listen(fd, 5); // 设置 socket 为监听状态,最大排队连接数为 5printf("tcp server have started...\n"); // 打印服务器启动信息// 4. 接受客户端连接(接听)struct sockaddr_in client; // 客户端地址结构socklen_t len = sizeof(client); // 客户端地址结构的大小int newfd = accept(fd, (struct sockaddr *)&client, &len); // 接受客户端连接if (newfd == -1) // 如果接受连接失败{perror("accept"); // 打印错误信息exit(1); // 退出程序}printf("ip=%s\n", inet_ntoa(client.sin_addr)); // 打印客户端 IP 地址printf("port=%d\n", ntohs(client.sin_port)); // 打印客户端端口号system("netstat -na | grep 10001"); // 显示当前网络连接状态(调试用)// 创建发送消息的线程pthread_t tid; // 线程 IDpthread_create(&tid, NULL, send_mess, &newfd); // 创建线程,运行 send_mess 函数// 5. 接收数据(收发数据)char buf[50] = {0}; // 接收数据的缓冲区,初始化为 0while (1) // 循环接收数据{bzero(buf, 50); // 清空缓冲区ret = recv(newfd, buf, sizeof(buf), 0); // 从客户端接收数据printf("recv cli %d: %s", ret, buf); // 打印接收到的数据和字节数if (strncmp(buf, "quit\n", 5) == 0) // 如果接收到 "quit"{printf("client quit\n"); // 打印客户端退出信息break; // 退出循环}if (ret == 0) // 如果返回值是 0,表示客户端断开连接{printf("client offline\n"); // 打印客户端下线信息break; // 退出循环}}// 6. 关闭 socket(挂电话)close(newfd); // 关闭与客户端的连接close(fd); // 关闭服务器监听 socketreturn 0; // 程序正常退出
}
知识点:
- 双向通信需要多线程(一个线程收,一个线程发)。
pthread_detach
避免线程资源泄漏。- 输入
"quit"
即可退出循环。
总结
-
单向通信:客户端发一次 → 服务器收一次。
-
单向循环:客户端可多次发送,服务器循环接收。
-
双向循环:客户端 & 服务端都能收发 → 需多线程。
-
编程技巧:
strlen()
用于发送数据长度sizeof()
用于接收缓冲区大小setsockopt(SO_REUSEADDR)
避免端口占用pthread_detach()
防止线程僵死
通过这三步,就能写出一个简易 Linux C 版聊天室 。
后续可以扩展成 支持多个客户端(用 fork
或 epoll
实现)。
(完)