基于 TCP 协议的双向通信程序实现与解析

一、整体工作流程

本代码实现的内网聊天系统采用经典的客户端 - 服务器(C/S)架构模型。系统运行过程中,服务器端与客户端分别遵循以下工作流程:

服务器端的初始化阶段,依次执行 socket 创建、端口绑定及监听状态设置等操作。随后进入循环监听模式,默认设置 5 秒超时机制等待客户端连接请求。当客户端成功连接后,服务器与客户端进入双向通信状态,双方可通过标准输入输出进行消息交互。客户端断开连接后,服务器将关闭当前连接通道,重新进入待连接状态,以处理后续客户端请求。

客户端在初始化时,通过创建 socket 实例并连接至服务器指定端口建立通信链路。连接成功后,客户端启动消息交互循环,实现用户输入消息的发送与服务器响应消息的接收功能。当满足预设退出条件时,客户端将主动断开与服务器的连接,并安全退出程序。

系统基于 TCP 协议通过 socket 接口建立连接,运用 select 函数实现 I/O 多路复用机制,同时监听标准输入及网络数据接收事件,确保通信过程的高效性与可靠性。各功能模块协同配合,共同完成消息收发、时间戳记录及会话管理等核心功能。

二、关键函数作用

2.1 printf_time()

该函数基于time.h库实现系统时间获取与格式化输出功能。其核心逻辑为:通过time()函数获取当前系统时间戳,利用localtime()函数将时间戳转换为本地时间结构体,按照 "[北京时间:% H:% M:% S]" 格式进行格式化输出。在消息接收场景下,该函数用于添加时间戳标识,为通信记录提供精确的时间维度信息,增强消息序列的可追溯性与可读性。

1
2
3
4
5
6
//输出时间戳
void printf_time(){
time_t time_now=time(NULL);
struct tm *now=localtime(&time_now);
printf("[北京时间:%d:%d:%d]\t",now->tm_hour,now->tm_min,now->tm_sec);
}

2.2 handle()

此函数作为 SIGINT 信号的注册处理函数,主要用于捕获用户通过 Ctrl+C 发起的中断请求。当接收到 SIGINT 信号时,函数将创建 "exit.txt" 文件并写入 "exit\n" 标识,该标识作为客户端退出的状态标记,为后续退出逻辑提供判断依据。

1
2
3
4
5
6
7
8
//将^C信号转为下列函数
void handle(int sig){
int fd=open("exit.txt",O_RDWR|O_CREAT|O_TRUNC,0666);
write(fd,"exit\n",5);
printf("已退出\n");
close(fd);
return;
}

2.3 judge()

该函数承担客户端退出状态检测职责。通过尝试读取 "exit.txt" 文件内容,判断客户端是否触发退出操作。若文件读取成功且内容匹配 "exit\n",则向服务器发送退出指令,依次关闭文件描述符、socket 连接并终止程序运行。此函数在客户端消息输入与接收流程中均被调用,确保退出逻辑的实时性与有效性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//判断是否退出
void judge(const int fd){
char buf[1024]={0};
int fp=open("exit.txt",O_RDWR);
if(fp==-1){
return;
}else{
read(fp,buf,5);
if(strcmp(buf,"exit\n")==0){
send(fd,"exit\n",5,0);
close(fp);
close(fd);
exit(1);
}
close(fp);
}
}

2.4 客户端的 stdin_set()

该函数负责处理客户端标准输入消息。执行流程包括:首先调用judge()函数检测退出状态;清空输入缓冲区后读取用户输入,将消息发送至服务器;若检测到 "exit\n" 输入,则执行程序退出操作;最后进行错误检查并清空已处理的输入缓冲区,确保输入处理流程的完整性与安全性。

1
2
3
4
5
6
7
8
9
10
11
12
13
//输入信息
void stdin_set(int fd,char *buf){
judge(fd);
bzero(buf,1024);
int ret=read(STDIN_FILENO,buf,sizeof(buf));
send(fd,buf,ret,0);
if(strcmp(buf,"exit\n")==0){
printf("退出\n");
exit(1);
}
ERROR_CHECK(ret,0,"你不中啊");
bzero(buf,ret);
}

2.5 客户端的 recv_set()

该函数实现客户端接收服务器消息的功能。执行过程包括:调用judge()函数检测退出状态;接收服务器数据并进行错误处理;数据接收成功后,调用printf_time()添加时间戳并输出消息内容;最后清空接收缓冲区,为后续消息接收做好准备。

1
2
3
4
5
6
7
8
void recv_set(int fd,char *buf){
judge(fd);
ssize_t ret_r=recv(fd,buf,1024,0);
ERROR_CHECK(ret_r,-1,"recv fail");
printf_time();
printf("接到服务端信息:%s",buf);
bzero(buf,ret_r);
}

2.6 服务器端的 recv_set()

该函数用于处理服务器接收客户端消息的逻辑。消息接收后首先进行错误检查,若接收失败则返回错误标识;成功接收后添加时间戳,若检测到 "exit\n" 消息或接收长度为 0(客户端断开连接),则返回特定标识触发会话退出流程;否则输出接收到的消息内容并清空缓冲区,维持当前会话状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
//接收信息
int recv_set(int fd,char *buf){
ssize_t ret_r=recv(fd,buf,1024,0);
ERROR_CHECK(ret_r,-1,"recv fail");
printf_time();
if(strcmp(buf,"exit\n")==0||ret_r==0){
printf("客户已经退出,等待新客户(5s内没有新接入则退出)\n");
return 1;
}
printf("收到客户信息:%s",buf);
bzero(buf,ret_r);
return 0;
}

2.7 服务器端的 stdin_set()

该函数负责处理服务器端标准输入消息。通过读取用户输入并发送至客户端,完成消息转发功能。执行过程包含错误检查与缓冲区清理操作,确保消息发送的可靠性与输入处理的独立性。

1
2
3
4
5
6
7
//发送信息
void stdin_set(int fd,char *buf){
int ret=read(STDIN_FILENO,buf,sizeof(buf));
ERROR_CHECK(ret,-1,"recv fail");
send(fd,buf,ret,0);
bzero(buf,ret);
}

三、socket 相关函数解析

  • socket():创建网络套接字,原型int socket(int domain, int type, int protocol)AF_INET指定 IPv4,SOCK_STREAM基于 TCP,0采用默认协议。客户端用其获取的描述符建立连接,服务器用于监听。
  • connect():客户端专用,原型int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen),用于建立 TCP 连接,成功返 0,失败返 - 1。
  • bind():服务器将套接字与 IP、端口绑定,原型同connect(),绑定后可监听客户端请求。
  • listen():使服务器套接字监听,原型int listen(int sockfd, int backlog)backlog设连接队列最大长度。
  • accept():服务器接受连接,原型int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen),成功返回新描述符通信。
  • send():在连接套接字上发数据,原型ssize_t send(int sockfd, const void *buf, size_t len, int flags)flags为 0 用默认方式,返回发送字节数。
  • recv():接收数据,原型类似send(),数据存buf,返回接收字节数,0 表示对方关闭,-1 表示失败。
  • close():关闭套接字并释放资源,用于客户端退出或连接终止。

四、select 函数的应用

select 函数在本系统中实现 I/O 多路复用功能,通过同时监听多个文件描述符的可读状态,实现高效的事件驱动处理机制。

在客户端主循环中,首先使用FD_ZERO()函数清空文件描述符集合,随后通过FD_SET()函数将客户端套接字描述符及标准输入描述符添加至待监听集合。调用select(10, &set, NULL, NULL, NULL)进行事件监听,其中第一个参数为待检查的最大文件描述符值加 1,第二参数指定监听的可读事件集合,后三个参数设置为 NULL 表示仅监听可读事件且不设置超时。

select 函数将阻塞当前线程,直至检测到可读事件发生。事件触发后,通过FD_ISSET()函数分别检查标准输入与套接字描述符的状态:若标准输入可读,则调用stdin_set()处理用户输入;若套接字可读,则调用recv_set()处理服务器响应消息。

服务器端的 select 应用逻辑与客户端类似,在与客户端的会话循环中,同时监听与客户端通信的套接字描述符及标准输入描述符。当对应描述符可读时,分别执行消息接收与发送操作。这种设计模式有效避免了单一操作的阻塞问题,显著提升了系统的并发处理能力与运行效率。

五、信号处理与退出机制

5.1 信号处理

客户端通过signal(SIGINT, handle)函数注册 SIGINT 信号处理函数,实现对用户中断请求的响应。当用户按下 Ctrl+C 触发 SIGINT 信号时,handle()函数将被调用,创建 "exit.txt" 文件并写入退出标识,为后续退出流程提供状态标记。

5.2 退出机制

  • 输入 "exit" 退出:客户端stdin_set()函数检测到用户输入 "exit\n" 时,立即执行程序退出操作。服务器端recv_set()函数接收到 "exit\n" 消息后,将终止当前会话循环,关闭连接并重新进入监听状态。

  • 信号处理退出:客户端在消息输入与接收流程中调用judge()函数,通过检查 "exit.txt" 文件内容判断是否触发退出请求。若检测到退出标识,则向服务器发送退出消息,依次关闭文件与套接字资源后退出程序。服务器端接收到退出消息后,按既定流程处理连接关闭操作。

  • 服务器超时退出:服务器在等待客户端连接阶段,通过alarm(5)设置 5 秒超时机制。若超时时间内未收到连接请求,将触发 SIGALRM 信号,由于未注册该信号处理函数,系统将按默认行为终止进程,实现无连接情况下的自动退出。若在超时前接收到连接请求,则调用alarm(0)取消超时设置,继续进行会话处理。

六、完整代码

客户端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
#include <my_header.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include <signal.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/mman.h>
/* Usage:从内网进行聊天沟通
* 客户端使用函数SOCKET CONNECT SEND RECV CLOSE
*/

//输出时间戳
void printf_time(){
time_t time_now=time(NULL);
struct tm *now=localtime(&time_now);
printf("[北京时间:%d:%d:%d]\t",now->tm_hour,now->tm_min,now->tm_sec);
}

//将^C信号转为下列函数
void handle(int sig){
int fd=open("exit.txt",O_RDWR|O_CREAT|O_TRUNC,0666);
write(fd,"exit\n",5);
printf("已退出\n");
close(fd);
return;
}

//判断是否退出
void judge(const int fd){
char buf[1024]={0};
int fp=open("exit.txt",O_RDWR);
if(fp==-1){
return;
}else{
read(fp,buf,5);
if(strcmp(buf,"exit\n")==0){
send(fd,"exit\n",5,0);
close(fp);
close(fd);
exit(1);
}
close(fp);
}
}

//输入信息
void stdin_set(int fd,char *buf){
judge(fd);
bzero(buf,1024);
int ret=read(STDIN_FILENO,buf,sizeof(buf));
send(fd,buf,ret,0);
if(strcmp(buf,"exit\n")==0){
printf("退出\n");
exit(1);
}
ERROR_CHECK(ret,0,"你不中啊");
bzero(buf,ret);
}

//读取服务端信息
void recv_set(int fd,char *buf){
judge(fd);
ssize_t ret_r=recv(fd,buf,1024,0);
ERROR_CHECK(ret_r,-1,"recv fail");
printf_time();
printf("接到服务端信息:%s",buf);
bzero(buf,ret_r);
}
int main(int argc, char *argv[]){
//检查端口号
ARGS_CHECK(argc,2);
//设置客户端socket
int fd= socket(AF_INET,SOCK_STREAM,0);
ERROR_CHECK(fd,-1,"error socket");

//获取网络地址
struct sockaddr_in addr;
addr.sin_family=AF_INET;
addr.sin_port=htons(atoi(argv[1]));
addr.sin_addr.s_addr=inet_addr("127.0.0.1");

//connect 链接对面
int ret=connect(fd,(struct sockaddr*)&addr,sizeof(addr));
ERROR_CHECK(ret,-1,"error connect");
printf("输入消息并按回车发送,输入'exit'退出\n");
char buf[1024]={0};

//设置监听
fd_set set;
FD_ZERO(&set);

//移除exit.txt判定文件
remove("exit.txt");

signal(SIGINT,handle);
while(1){
FD_SET(fd,&set);
FD_SET(STDIN_FILENO,&set);
select(10,&set,NULL,NULL,NULL);
if(FD_ISSET(STDIN_FILENO,&set)){
stdin_set(fd,buf);
}
if(FD_ISSET(fd,&set)){
recv_set(fd,buf);
}
}
close(fd);
return 0;
}

服务端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93

#include <my_header.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <signal.h>
/* Usage:服务端 输入socket bind listen accept recv send close */
//打印时间戳
void printf_time(){
time_t time_now=time(NULL);
struct tm *now=localtime(&time_now);
printf("[北京时间:%d:%d:%d]\t",now->tm_hour,now->tm_min,now->tm_sec);
}
//接收信息
int recv_set(int fd,char *buf){
ssize_t ret_r=recv(fd,buf,1024,0);
ERROR_CHECK(ret_r,-1,"recv fail");
printf_time();
if(strcmp(buf,"exit\n")==0||ret_r==0){
printf("客户已经退出,等待新客户(5s内没有新接入则退出)\n");
return 1;
}
printf("收到客户信息:%s",buf);
bzero(buf,ret_r);
return 0;
}
//发送信息
void stdin_set(int fd,char *buf){
int ret=read(STDIN_FILENO,buf,sizeof(buf));
ERROR_CHECK(ret,-1,"recv fail");
send(fd,buf,ret,0);
bzero(buf,ret);
}
int main(int argc, char *argv[]){
ARGS_CHECK(argc,2);
//看端口是否正确

int fd=socket(AF_INET,SOCK_STREAM,0);
//生成文件描述符

struct sockaddr_in addr;
addr.sin_family=AF_INET;
addr.sin_port=htons(atoi(argv[1]));
addr.sin_addr.s_addr=inet_addr("127.0.0.1");
//描述网络主机地址

bind(fd,(struct sockaddr*)&addr,sizeof(addr));
//固定服务端端口号

listen(fd,10);
//监听 数值5~10

while(1){
//设置信号
struct sigaction sa;
sigaction(SIGALRM,&sa,NULL);
alarm(5);

//设置新的socket接收对面的socket
struct sockaddr_in client_a;
socklen_t clientlen=sizeof(client_a);
int new_fd=accept(fd,(struct sockaddr*)&client_a,&clientlen);
ERROR_CHECK(new_fd,-1,"error accept");
printf("接收到客户信号,正在等待输入\n");
//看全连接队列,没有则等待,有则拿出一条连接

alarm(0);
//如果连接到新的客户端,就不自动退出
char buf[1024]={0};

//设置select结构体,对具体情况进行监听
fd_set set;
FD_ZERO(&set);
while(1){
FD_SET(new_fd,&set);
FD_SET(STDIN_FILENO,&set);
select(10,&set,NULL,NULL,NULL);
if(FD_ISSET(new_fd,&set)){
if(recv_set(new_fd,buf)){
break;
}
}
if(FD_ISSET(STDIN_FILENO,&set)){
stdin_set(new_fd,buf);
}
}
close(new_fd);
}
close(fd);
return 0;
}