C语言实现双人聊天室(超详细)~---socket,进程
C语言实现双人聊天室~---socket
写在前面~
我整个程序都是在linux环境下开发的,使用的系统是archlinux,用的是clion+gcc 。(不会vscode。。。)。虽然C语言不是跨平台的,但是我最终也是把程序弄到了Windows。并且成功运行,与朋友在两台异地电脑成功进行了通信!!!(这当然是最振奋人心的!!!!!!)。
使用截图:
linux下的C语言和Windows下的C语言有些许不同,
(包括他们使用的库(头文件),反正有点差别,这也是我在写完程序后才意识到的。。悲催。。
不过好在找到了解决的办法)后面我会说如果把程序弄成window上也能执行的exe文件
(只针对我写的这个程序,其他仅供参考~ 不敢保证 0.0)
涵盖的C语言范围
socket通信,进程(不是线程),其他 base知识。
好了,让我们开始吧!
服务器端
(不使用服务器端 而直接使用两个客户端进行通信是不现实的,
因为我们把两个程序运行在两台异地的电脑,而这两台电脑肯定是在两个不同的局域网中,内网的,而不是公网。
两个不同局域网是无法直接进行通信的,(当然有解决办法,内网穿透,还是得使用服务器,
这里我就不多说的,等后面看看吧,可能会补充一下,这里我们主要是说程序怎么弄))
服务器段的作用就是接受客户端发来的信息,然后把信息发给另一个客户段。进行通信首先就是要开启服务器段,让服务器端一直等待客户端连接,连接好后就可以与客户端进行通信,但是服务器端的作用是让两个客户端进行通信,这里服务器端就相当与电话接线员,服务器端要同时连接两个客户端,同时进行通信,所以肯定要用到进程(或者线程,这里用进程就行)。
简单描述就是。
现在服务器端分别连接了两个客户端,
一个客户端向服务器发送信息,服务器端接收后把信息发给另一个客户端。
一直这样。直到其中一个客户端断开通信。
服务器端必须同时与两个客户端进行通信,所以服务器端每接收一个客户端后就把它放到一个进程中,继续接受另一个客户端的连接。
所以现在对两个客户端的处理分别再两个进程中,
在两个进程中不断对它们负责的那个客户端接受信息,发送另一个客户端接受到的信息。
使用的工具是管道:
int fd[2];
pipe(fd);
这样fd[0]和fd[1]就是一个单向管道的读取端 和写入端
直接对管道进行读写操作使用的函数是read和write。但是我用了另一个方法。
就是用这一个管道的写入端替换为程序的标准输出端,用管道的读取端替换为程序的标准输入端。
(再加上进程。管道这部分可能会很绕,我当时也是思量了很久,才调试好,不过我会慢慢讲解的,耐心理解一下~)
程序的标准输出端就是平时我们puts()或者printf(); 所输出的地方
printf("%s","hello world!") = fprintf(stdout,"%s","hello world") stdout就是标准输出流。
程序的标准输入端就是 我们getchar() 或者scanf() 数据来源的地方
scanf("%s",str) = fscanf(stdin,"%s",str) stdin 就是标准输入流
替换后,我们直接
puts("接受到的客户端发来的内容");
然后在另一个进程中读取这个管道的内容并发送到客户端,这样就实现了信息从一个客户端发送到另一个客户端。
形象点就是这个样子:
图画的有点丑,将就着吧先。emm~
服务器就负责靠这两个管道和两个进程一直接收信息,转发信息。
服务器等待客户端连接
int listern_d = socket(PF_INET,SOL_SOCKET,0); // 打开套接字
//创建端口,为了和上面开启的套接字绑定
//创建一个结构 sockaddr_in,并为这个结构的内容赋值。
struct sockaddr_in* name;
name = malloc(sizeof(struct sockaddr));
//知道我为什么用malloc吧,不用malloc(),这个函数结束屁都没有,全被清了,emm~ 返回的就是个野指针~ (空指针,文明讲话)
name->sin_family=PF_INET;
name->sin_addr.s_addr=htonl(INADDR_ANY);
name->sin_port=(in_port_t) htons(PORT);
//这些都是固定死的,(对我这个程度的猛新而言)所以就把他们放在一起,
/**
*为了重新使用端口。否则一旦程序终止,上次打开的端口会延迟30s关闭,
这期间其他程序就不能连接这个端口,执行这个语句就表示程序重新使用上次的端口。
REUSE 嘛~
*/
setsockopt(listern_d,SOL_SOCKET,SO_REUSEADDR,(char*)"1", sizeof(int));
bind(listern_d,(struct sockaddr*)name, sizeof(struct sockaddr_in));//绑定端口端口和套接字
listen(listern_d,10);//设置监听队列长度,也就是一次可以同时接收的客户端数量。
puts("Waiting for connection");
//下面开始接受客户端连接
sockaddr_storage c1 =* (sockaddr_storage*) malloc(sizeof(sockaddr_storage));
unsigned int address_size = sizeof(c1);
//得到的这个connection是最重要的,我们发送信息和接收信息就用这个
int connection = accept(listener_d,(struct sockaddr *)&c1,&address_size);
if( connection==-1)error("accept error");
char send_buf[100];
char rec_buf[100];
send(connection,send_buf,sizeof(send_buf),0);//发送信息函数,0是个标识位,代表启用某种功能,我们用0就行。
recv(connection,rec_buf,sizeof(recv_buf),0);//和send差不多,接受的信息就存储到了rec_buf中。直接就可以读取了。
管道
#define WRITE 0;//说实话,我也记不住,就宏定义一下,后面好用。
#define READ 1;
int fd[2][2];//因为要用两个管道,一个管道是fd[2],两个管道就用了二维数组。
使用pipe()初始化管道。
pipe(fd[0]);
pipe(fd[1]);
//然后就可以使用。
char r_buf[100];
char s_buf[100];
//在进程0中:
dup2(fd[1][WRITE],1); //用管道1的写入端替换进程0的输出端。
dup2(fd[0][READ],0); //用管道0的读取端替换进程0的输入端。
//然后发送消息
// 直接fgets(s_buf,sizeof(s_buf),stdin);
//再send(connection,s_buf,sizeof(s_buf),0);
//接收消息
//recv(connection,c_buf,sizeof(c_buf),0);
//puts(r_buf); 替换管道后,直接就把消息输出到了管道!
//在进程1中:
dup2(fd[0][WRITE],1);
dup2(fd[1][READ],0);
//在进程2中:
dup2(fd[1][WRITE],1);
dup2(fd[0][READ],0);
创建进程
每接受到一个客户端连接就创建一个进程单独处理,并继续接收其他客户端的连接。
int pid-fork();
//fork()直接将程序之前的内容克隆一份,并开启新的线程;
if(pid==0) //pid=0表示现在是子进程在运行,非0表示是父进程在运行。(可能不好理解)
{
子进程处理部分
}else
{
父进程处理部分; 与子进程互不干扰。
}
实际是这样:
但我们可以理解成这样:
可以这么说:
一个程序运行后就相当与一个特工开始按照我们编写的命令开始执行任务。而fork()命令则是创建了克隆出一个分身,这个分身与这个特工几乎一模一样,他们要执行的命令都是我们在程序中编写的,对于后面的命令,他们都会执行,也就是说会执行两遍,例如下面
int main()
{
fork();
puts("hello world");
}
输出了两次:但是程序中只有一句puts();
那么如何使他们干不同的事情呢?
我们知道,他们要做的事情都是按照程序来的,他们拿着相同的我们编写的那个程序手册执行命令。
我们把他们要执行的不同的命令分开。用pid值来区别他们,
fork()函数会返回一个值,
(注意,后方高能~)
前面说道fork()会克隆一份之前的程序。那么从int pid=fork()命令开始,包括这条命令的结果pid就已经
被克隆了,所以现在相当与有两个独立的程序,这两个程序的唯一不同就在于这个pid。
(两个进程就相当与两个程序同时进行嘛~)
现在想象一下我们的程序刚开始只有一个,在程序执行到int pid=fork()时,程序克隆出来一个完全相同的程序,
继续往下执行。只不过其中一个程序的pid=0,另一个程序的pid=非零。
之后运行下面的程序代码是一模一样的,所以我们可以判断pid值并进行分流。
int pid =fork();
if(pid==0)
{
子进程代码段;
}else
{
父进程代码段;
}
这样,在两个进程(两个程序)中因为他们各自的pid变量值不同,所以运行这份一样的程序时执行的命令不同;
在父进程这个程序中的pid=非零,所以在父进程的程序执行到这里时跳到了else语句,
在子进程的程序中 pid =0,所以就接着if后的语句开始执行,执行完跳出。
并且克隆出来的这个程序和原来的那个程序的资源是分离的,互不干扰:
如图:
我们只在子进程中修改原来数组的内容,然后父进程和子进程都输出数组的内容;结果为:
等待进程结束
在创建一个进程后,主程序与子进程各自执行命令,如果不等待子进程,当主进程结束时,程序就直接退出结束了。(果然还是分主次的)
简述 fork(), getpid(), getppid();
fork()克隆出一个子进程,函数的返回值在子进程中=0,在父进程中=子进程的pid(就是那个非零值),
getpid() 返回调用函数时进程的pid值
getppid() 返回父进程的pid值;
在父进程中输出pid(pid=fork())=3838 = 子进程的getpid()
子进程的getppid() = 3837 = 父进程的getpid();
关于函数
waitpid(pid_t pid, int* pid_status,OPRT);
pid>0时,只等待进程ID等于pid的子进程,不管其它已经有多少子进程运行结束退出了,只要指定的子进程还没有结束,waitpid就会一直等下去。
pid=-1时,等待任何一个子进程退出,没有任何限制,此时waitpid和wait的作用一模一样。
pid=0时,等待同一个进程组中的任何子进程,如果子进程已经加入了别的进程组,waitpid不会对它做任何理睬。
pid<-1时,等待一个指定进程组中的任何子进程,这个进程组的ID等于pid的绝对值。
参考文章
当我们在程序中加上一句
int pid_status;
waitpid(pid,&pid_status,0);
当父进程执行到这里时,我们前面说道,父进程中int pid= fork(),得到的是克隆出的子进程PID,所以函数一直等待子进程,直到子进程结束,所以父进程到这里就开始一直等。
(可忽略)当子进程执行到这里,他的变量pid=0,所以是等待它的子进程,(也就是循环接收信息和发送信息的进程)
使用进程处理客户端
服务器端总共会用到2个大进程,用来处理两个客户端,每个进程又会有两个进程
(一个父进程,一个子进程)分别进行接受信息和发送信息:
!
我们希望程序的效果是这样的:
服务器端一直等待连接,客户端可以随时发起连接当两个客户端同时处于连接状态时,可以正常通信。
并且当一个客户端断开连接后,重新连接上后,依然可以和之前那个客户端正常通信!
我的实现方法是,为这两个客户端分别弄一个标志,当客户端连接到服务器端时,
会首先向服务器发送一个表示身份的字符串,我用的是"other"和"one";
服务器按照这个标志分配管道等,这样每次客户端重连服务器时使用的还是原来的那个管道,
可以和另一个客户端继续进行通信!
....
....
while (1){
/**
和之前得到的那个connection方法一样。在这里我把它放到函数中的,直接调用函数进行监听
得到返回值“connection”, 然后使用 */
int connection = accept_client(listern_d);
puts("accept one");
int flag=judge(connection);//在函数中接收客户端发送来的身份标志,并判断返回0或者1(双人通信)
pid[flag]=fork(); //根据标识创建进程。
//(创建完进程,这个进程开始执行下面的代码,而主进程下面没有它的代码,
//所以主进程while(1)循环过来继续监听接收)
if (!pid[flag])
{
//子进程进入这里,处理客户端 (别忘了,现在可能有两个子进程都在执行这里面的代码)
dup2(fd[1-flag][WRITE],1); //根据flag寻找管道并替换标准输入输出流,这样对于一个客户端分配固定的管道。
dup2(fd[flag][READ],0);
if (fork()) //创建进程分别处理接受信息和发送信息。
{
while(1) //循环接受信息
{
//从标准输入(管道的读取端)中读取信息并保存到read_buf字符串数组中。
fgets(read_buf, sizeof(read_buf),stdin);
send(connection,read_buf, sizeof(read_buf),0)
}else
{
while (1)
{
recv(connection,accept_buf, sizeof(accept_buf),0);
puts(accept_buf);//吧接收的信息发送到标准输出流(另一条管道的写入端)
}
}
}
close(connection);//关闭连接,不然程序退出端口可能退出不了,这样端口就被占用了。
}
int judge(int connection)
{
int client=-1;
char receive[10];
if(recv(connection,receive,10,0)==-1)error("judge error");
if (strcmp(receive,"one\n")==0)client=one;
if(strcmp(receive,"other\n")==0)client=other;
if (client==-1){
close(connection);
send(connection,fail,strlen(fail),0);
}
fprintf(stderr,"judge finished %d",client);
return client;
}
int accept_client(int listener_d)
{
client c1 =* (client*) malloc(sizeof(client));
unsigned int address_size = sizeof(c1);
int connection = accept(listener_d,(struct sockaddr *)&c1,&address_size);
if( connection==-1)error("accept error");
return connection;
}
处理异常
例如上面使用的函数都可能会导致异常,C语言中没有try catch。。所以只能根据函数的返回值判断进行处理。
上面我把处理异常的部分全删除了,为了是代码更简洁,看起来不那么乱,处理的一般方法如下。
例如
if (send(connection,s_buf,sizeof(s_buf),0)==-1)
{
stderror("send error");
exit(1);
}
那个strerror(errno);只要包含头文件errno.h就可以使用,会返回程序出现异常时的异常提示。
源码在后面给出链接,不要心急,弄懂原理才重要~。
客户端
讲服务器端的时候一些内容已经铺垫完毕,所以客户端就可以精简一些。
连接方式
直接使用公网ip
int s = socket(PF_INET, SOCK_STREAM, 0);
struct sockaddr_in si;
memset(&si, 0, sizeof(si));
si.sin_family = PF_INET;
si.sin_addr.s_addr = inet_addr("127.0.0.1");
si.sin_port = htons(50000);
connect(s, (struct sockaddr *) &si, sizeof(si));
//然后使用s即可,和服务器端好像有点不同
send(s,s_buf,sizeof(s_buf),0);
recv(s,r_buf,sizeof(r_buf),0);
使用域名
struct addrinfo *res;
struct addrinfo hints;
memset(&hints,0, sizeof(hints));
hints.ai_family=PF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
(getaddrinfo("localhost","PORT",&hints,&res)//localhost,PORT自己根据需要修改即可
int s = socket(res->ai_family,res->ai_socktype,res->ai_protocol);
int client =connect(s,res->ai_addr,res->ai_addrlen);
freeaddrinfo(res);
//也是直接使用s即可
send(s,s_buf,sizeof(s_buf),0);
recv(s,r_buf,sizeof(r_buf),0);
处理
之后根据类似服务器端的处理方式创建进程发送信息和接受进系即可;
我就不在写注释了,同时还是把处理异常的部分都去掉了
if (fork())
{
while (1) {
char buf[100];
if (errno==9)exit(1);
//这里是为了处理一个异常,当服务器端断开连接程序就会跳过recv疯狂执行printf(),加上这一句就好了。可以自己试着修改尝试~
recv(s, buf, 100, 0);
printf(">>>>>>>>>:%s\n",buf);
}
} else
{
while (1)
{
char buf[100];
printf("请输入:");
fgets(buf,100,stdin);
send(s,buf,100,0)
}
}
在windows上运行
由于我们的C语言是以linux环境为基础了,不能直接在win上运行
我使用的一个方法是使用cgywin
在windows安装cgywin这是我找的一篇比较详细的安装cgywin的教程。
安装完毕后运行cgywin使用gcc命令 编译C源文件就行了
得到了.exe可执行文件,注意这个文件执行的时候会提示需要cgywin1.dll
我们从cgywin安装目录bin下找到复制到.exe文件目录中即可。
之后就可以运行了:
这是在windowns上运行的客户端。
这是我在linux服务器上运行的服务器端:
服务器使用要开放端口才行,比如我使用的是50000端口,需要执行两步
1,
iptables -I INPUT -p tcp --dport 50000 -m state --state NEW -j ACCEPT //在控制台输入命令开放端口
2,
缺一不可哦~
一些优化~
到此程序核心功能介绍完毕,下面讲讲一些小优化。
启动界面
这是我设置的启动界面,其实就是在程序启动后输出一个字符图形,这个字符图形可以先存储到一个文件中,程序中调用读取输出即可,
char *f ="txt.txt";
FILE* file=fopen(f,"r");
char s[100];
while(fgets(s,100,file))
printf("%s",s); // 不要使用puts puts会多输出一个回车
至于这个字符怎么制作呢,就是用一个小工具图片转字符即可:
积分下载地址(谢谢~)
免费下载地址(不客气~)
处理异常
我们可以编写一个专门处理一场的函数
void error(char* msg)
{
fprintf(stderr,"%s:%s", msg,strerror(errno));
exit(0);
}
程序中可能出现异常的函数有
socket(PF_INET,SOL_SOCKET,0);//打开套接字
setsockopt(listern_d,SOL_SOCKET,SO_REUSEADDR,(char*)"1", sizeof(int))//设置reuse端口
bind(listern_d,(struct sockaddr*)name, sizeof(struct sockaddr_in))//绑定端口和套接字
listen(listern_d,10);//设置监听队列长度
pipe(fd[0]); //初始化管道
pipe(fd[1]);
accept(listener_d,(struct sockaddr *)&c1,&address_size);//接收客户端连接
recv(...); //接收消息
send(...);//发送消息
waitpid(...);//等待进程
处理方式:
if(函数返回值==-1)error("message");
处理异常主要是在写程序时,程序不能正常运行,然后就可以通过捕获异常知道哪里有问题。方便修改。
记录聊天记录
这个只需要在服务器端创建一个追加内容的文件 转发消息时把消息和客户端标识输入到文件即可。
这个我就不演示了。。。。。
==================================================================================
源代码:
==================================================================================
就到这里啦,欢迎大家 点赞 留言 评论~~