从日常使用的APP,到背后支撑的各类服务器,网络通信无处不在,而socket作为实现网络通信的关键技术,更是开发者们必须掌握的核心知识。但在socket编程的道路上,TCP粘包问题宛如一只拦路虎,让无数开发者头疼不已。它隐蔽又棘手,导致数据传输混乱,程序运行异常。
一:socket编程实战基础
1:什么是socket
socket可以看成是用户进程与内核网络协议栈的编程接口。TCP/IP协议的底层部分已经被内核实现了,而应用层是用户需要实现的,这部分程序工作在用户空间。用户空间的程序需要通过套接字来访问内核网络协议栈。
套接口是全双工的通信,它不仅可以用于本机的进程间通信,还可以用于网络上不同主机的进程间通信。
套接字还可以异构系统间进行通信,异构系统指的是在硬件或软件上有所差别的系统,例如安卓系统的手机与windows系统的PC机上都可以实现QQ通信,套接字可以实现在这两个设备上的通信。
2:IPV4套接口地址结构
套接口既然能够连接两个端系统,那它就需要一个地址来标记该端系统,例如两个电话需要电话号码来标记才可以进行拨号。这抽象成套接口的地址结构。IPV4套接口地址结构通常也称为“网际套接字地址结构”,它以sockaddr_in命名,定义在头文件< netinet/in/h >中。
struct sockaddr_in{uint8_t sin_len;sa_family_t sin_family;in_port_t sin_port;struct in_addr sin_addr;char sin_zero[8];
};
说明:
-
sin_len:整个sockaddr_in结构体的长度,在4.3BSD-Reno版本之前的第一个成员是sin_family。
-
sin_family:指定带地址家族,在这里必须设置为AF_INET。socket在设计时不仅可以用于TCP/IP协议,它还可以用于其他协议,例如unix域协议,地址家族用于指定该套接字用于哪种协议。AF_INET表示用于IPV4协议。
-
sin_port:端口号,16位的无符号整数,能够表示到65535。2个字节。
-
sin_addr: IPV4的地址。4个字节的整数。
-
sin_zero:暂不使用,一般将其设置为0。
其中,struct in_addr仅仅是一个32位的无符号整数,可以在终端下输入man 7 ip进行查看:
接下来看一下通用的地址结构。上面说过,socket可以用于不同的协议上,通用的地址结构可以用于任何协议的socket编程。
struct sockaddr{uint8_t sin_len;sa_family sin_family;char sa_data[14];
};
说明:
-
sin_len:整个sockaddr结构大小
-
sin_family:指定该地址家族
-
sa_data:由sin_family决定它的形式
可以看到,在通用地址结构中sa_data是14个字节,而在IPV4的地址结构中,sin_port、sin_addr、sin_zero三个变量加起来也等于14个字节。也即是说,这两种结构是兼容的。
3:网络字节序
字节序可以分为大端字节序与小端字节序:
-
大端字节序(Big Endian) :最高有效位存储于最低内存地址处,最低有效位存储于最高地址内存处。
-
小端字节序(Little Endian):刚好与大端字节序倒过来,最高有效位存于最高内存地址处,最低有效位存储于最低内存地址处。
这样说起来挺抽象,通过一幅图来说明:
上面说过,socket可以用于异构系统之间的通信。而不同的系统采用的字节序可能是不同的,有的系统采用大端字节序,例如Motorola 6800;有的采用小端字节序,如X86。因此,在进行字节传输时,应该同一一个字节序,称为网络字节序。网络字节序采用大端字节序。如果主机A为小端字节序的系统,那么在传输时需要先将小端字节序转换成网络字节序。这需要一些字节序的转换函数。
我们可以编写程序来测试自己的主机是什么字节序:
#include<stdio.h>int main(void)
{unsigned int x = 0x12345678;unsigned char *p = (unsigned char*)&x;printf("%0x,%0x,%0x,%0x\n",p[0],p[1],p[2],p[3]);return 0;
}
输出结果:
在我的电脑上输出结果为:78,56,34,12. 因此我的主机为小端字节序。
4:字节序转换函数
如果主机的字节序与网络字节序不同,那么需要进行字节序的转换。下面是一些字节序转换函数:
# include < arpa/inet.h >uint32_t htonl(uint32_t hostlong);uint16_t htons(uint16_t hostshort);uint32_t ntohl(uint32_t netlong);uint16_t ntohs(uint16_t netshort);
说明:h代表host;n代表network;s代表short;l代表long
描述:
-
htonl()函数将无符号整数hostlong从主机字节序转换成网络字节序。
-
htons()函数将无符号短整型hostshort从主机字节序转换成网络字节序。
-
ntohl()函数功能与 htonl()函数相反
-
ntohs()函数功能与htons()函数相反
我们可以进行验证,刚才已经通过程序测试出我的主机是小端字节序,接下来使用函数 htonl()将整数0x12345678转换成网络字节序。
#include<stdio.h>
#include <arpa/inet.h>
int main(void)
{
unsigned int x = 0x12345678;
unsigned char *p = (unsigned char*)&x;
printf("转换前:%0x,%0x,%0x,%0x\n",p[0],p[1],p[2],p[3]);
unsigned int y = htonl(x);
p = (unsigned char *) &y;
printf("转换后:%0x,%0x,%0x,%0x\n",p[0],p[1],p[2],p[3]);
return 0;
}
输出结果:
5:地址转换函数
对于IP地址,我们通常采用点分十进制的形式进行直观的认识,而程序更多的时候是处理32位的地址,因此需要有函数在点分十进制与32位地址这两种形式间进行转换。
# include < sys/socket.h>
# include < netinet/in.h>
# include < arpa/inet.h>
int inet_aton(const char *cp, struct in_addr *inp);
in_addr_t inet_addr(const char *cp);
char *inet_ntoa(struct in_addr in);
描述:
-
inet_addr()函数:表示将点分十进制的IP地址转换成32位的ip地址(整数)。
-
inet_ntoa()函数:将32位ip地址(网络字节序)转换成点分十进制的ip之地。
实战例程:
#include<stdio.h>
#include<arpa/inet.h>
int main()
{
unsigned long addr = inet_addr("192.168.0.100");//将点分十进制转换为32bit地址
printf("addr = %u\n",htonl(addr));
struct in_addr ipaddr;
ipaddr.s_addr = addr;
printf("ipaddr = %s\n",inet_ntoa(ipaddr)); //网络字节序地址转换为点分十>进制
return 0;
}
输出结果:
6:套接字类型
套接字类型主要有三种:
-
流方套接字(SOCK_STREAM):它对应TCP协议,它提供面向连接的、可靠的数据传输服务,数据无差错、无重复的发送,且按发送顺序接收。
-
数据报套接字(SOCK_DGREAM):提供无连接服务。不提供无错保证,数据可能丢失或重复,并且接收顺序混乱。
-
原始套接字(SOCK_RAW):它提供一种能力,让我们直接跨越传输层,直接对IP层进行数据封装,通过该套接字,我们可以直接将数据封装成IP层能够认识的协议格式。
二:socket编程实战API接口
1:socket()函数
socket()函数用于创建一个套接字。这就好像购买了一个电话。不过该电话还没有分配号码。
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol)
参数说明:
-
domain:指定通信的协议族,这些协议族定义在头文件< sys/socket.h >中。使用IPV4协议族时,该参数设置为AF_INET。
-
type :指定socket的类型。在上一篇文章中介绍过,套接字常用的有三种类型:流式套接字SOCK_STREAM,数据报套接字SOCK_DGRAM,原始套接字SOCK_RAW。
-
protocol : 该参数指定了一种协议类型用于所选择的套接字。如果仅有一种协议支持某种套接字类型,那么该参数可以定义为0,此时使用默认协议;如果一种套接字类型可能有多种协议类型,那么必须显式指定协议类型。关于具体细节,可以man socket进行查阅。
socket()的返回值:成功时返回非负整数;失败时返回-1;
2:bind() 函数
bind()函数绑定一个本地地址到套接字上,这相当于为电话绑定了号码。当一个套接字通过socket()被创建,它并没有绑定到具体的地址上,bind()来完成这个步骤。 bind()函数的函数原型如下:
#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
参数说明:
-
sockfd:socket()函数创建后成功返回的套接字
-
addr : 需要绑定的地址
-
addrlen:套接字的大小
这里需要使用到sockaddr_in结构来表示一个地址,该结构如下:
struct sockaddr_in
{
sa_family_t sin_family;
in_port_t sin_port;
struct in_addr sin_addr;
};
struct in_addr
{
uint32_t s_addr;
}
sockaddr_in需要强制转换为struct sockaddr*类型,传递给bind()函数的第二个参数。下面是一段例程:
int main()
{
int listenfd = socket(AF_INET,SOCK_STREAM,0);
if(listenfd == -1)
err_exit("socket error");
struct sockaddr_in addr;
//填充结构
addr.sin_family = AF_INET;
addr.sin_port= htons(8001); //主机字节序转换为网络字节序
addr.sin_addr= htonl(INADDR_ANY);//绑定主机的任一个IP地址
/*下面两句具有相同的功能:都是绑定到本机ip地址*/
//inet_aton("127.0.0.1",&addr.sin_addr);
//addr.sin_addr.s_addr = inet_addr("127.0.0.1");
if(bind(listenfd,(const struct sockaddr*)&addr,sizeof(addr))==-1)
err_exit("bind error");
}
3:listen()函数
当使用socket()创建了一个套接字时,该套接字默认是主动套接字。使用listen()函数会使套接字称为一个被动套接字,也就是说,该套接字将被用来接受连接的数据,这些数据通过accept()函数接收。
listen()函数的函数原型如下:
#include <sys/types.h>
#include <sys/socket.h>
int listen(int sockfd, int backlog);
参数说明:
-
sockfd : 套接字。
-
backlog: 指定连接队列的长度。
对于给定的监听套接字,内核需要维护两个队列:
-
已完成连接队列:该队列中的连接处于ESTABLISHED状态,也即是已经完成了三次握手过程。
-
未完成连接队列:该队列中的连接处于SYN_RCVD状态,还未建立连接。
两个队列的长度之和不能够超过backlogi。如果一个连接请求到达时未完成队列已满,客户端可能接收到一个错误指示ECONNREFUSED。服务器使用accept()函数从已完成连接队列的队头返回一个连接。下面是TCP为监听套接口维护的两个队列:
4:accept()函数
accept()函数用于从已完成队列的队头返回一个连接。它的函数原型为:
#include <sys/types.h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数说明:
-
sockfd : 服务器套接字
-
addr :用于接收对等方(客户端)的套接字地址。该参数填充为NULL时,不接收任何信息。
-
addrlen:返回对等方的套接字地址长度。如果不关心可以设置为NULL,否则一定要初始化。
函数返回值:成功返回一个非负整数,代表一个套接字;失败返回-1;
5:connect()函数
该函数用于建立一个连接到指定的套接字。函数的原型为:
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数说明:
-
sockfd : 未连接的套接字
-
addr:未连接的套接字地址
-
addrlen:addr的长度
6:简单的socket通信例程
客户端代码:
#include<stdio.h>
#include<stdlib.h>
#include<errno.h>
#include<arpa/inet.h>
#include<netinet/in.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<string.h>
#define ERR_EXIT(m)\
do \
{\
perror(m);\
exit(EXIT_FAILURE);\
}while(0)
int main()
{
/*创建一个套接字*/
int sock = socket(AF_INET,SOCK_STREAM,0);
if(sock == -1)
ERR_EXIT("socket");
/*定义一个地址结构*/
struct sockaddr_in servaddr;
memset(&servaddr,0,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(5888);
servaddr.sin_addr.s_addr = inet_addr("127.0.0.1");
/*进行连接*/
if(connect(sock,(struct sockaddr*)&servaddr,sizeof(servaddr))<0)
{
ERR_EXIT("connect");
}
else
{
printf("连接成功\n");
}
char sendbuf[1024]={0};
char recvbuf[1024]={0};
/*从标准输入中读入*/
while(fgets(sendbuf,sizeof(sendbuf),stdin)!=NULL)
{
write(sock ,sendbuf,strlen(sendbuf));
if(read (sock,recvbuf,sizeof(recvbuf))>0)
{
printf("从服务器接收信息:\n");
fputs(recvbuf,stdout);
}
memset(&sendbuf,0,sizeof(sendbuf));
memset(&recvbuf,0,sizeof(recvbuf));
}
close(sock);
return 0;
}
服务器端代码:
#include<stdio.h>
#include<stdlib.h>
#include<errno.h>
#include<arpa/inet.h>
#include<netinet/in.h>
#include <sys/types.h>
#include <sys/socket.h>
#include<string.h>
#define ERR_EXIT(m)\
do \
{\
perror(m);\
exit(EXIT_FAILURE);\
}while(0)
int main()
{
/* 创建一个套接字*/
int listenfd= socket(AF_INET ,SOCK_STREAM,0);
if(listenfd==-1)
ERR_EXIT("socket");
/*定义一个地址结构并填充*/
struct sockaddr_in addr;
addr.sin_family = AF_INET; //协议族为ipv4
addr.sin_port = htons(5888); //绑定端口号
addr.sin_addr.s_addr = htonl(INADDR_ANY);//主机字节序转为网络字节序
/*将套接字绑定到地址上*/
if(bind(listenfd,(const struct sockaddr *)&addr ,sizeof(addr))==-1)
{
ERR_EXIT("bind");
}
/*监听套接字,成为被动套接字*/
if(listen(listenfd,SOMAXCONN)<0)
{
ERR_EXIT("Listen");
}
struct sockaddr_in peeraddr;
socklen_t peerlen = sizeof(peeraddr);
/*定义一个套接字,通常称为已连接套接字*/
int conn ;
conn = accept(listenfd,(struct sockaddr*)&peeraddr,&peerlen);
if(conn <0)
ERR_EXIT("accept error");
else
printf("连接到服务器的客户端的IP地址是:%s,端口号是:%d\n",inet_ntoa(peeraddr.sin_addr),htons(peeraddr.sin_port));
/*循环获取数据、发送数据*/
char recvbuf[1024];
while(1)
{
memset(recvbuf,0,sizeof(recvbuf));
int ret = read(conn,recvbuf ,sizeof(recvbuf));
fputs(recvbuf,stdout);
write(conn,recvbuf,sizeof(recvbuf));
}
/*关闭套接字*/
close(listenfd);
close(conn);
return 0;
}
三:多连接服务器实现,P2P程序实战例程
在该例程序中,使用"Ctrl+c"结束通信后,服务器是无法立即重启的,如果尝试重启服务器,将被告知:
bind: Address already in use
原因在于服务器重新启动时需要绑定地址:
bind (listenfd , (struct sockaddr*)&servaddr, sizeof(servaddr));
而这个时候网络正处于TIME_WAIT的状态,只有在TIME_WAIT状态退出后,套接字被删除,该地址才能被重新绑定。TIME_WAIT的时间是两个MSL,大约是1~4分钟。若每次服务器重启都需要等待TIME_WAIT结束那就太不合理了,好在选项SO_REUSEADDR能够解决这个问题。
服务器端尽可能使用REUSEADD,在bind()之前调用setsockopt来设置SO_REUSEADDR套接字选项,使用SO_REUSEADDR选项可以使不必等待TIME_WAIT状态消失就可以重启服务器。
/*设置地址重复使用*/
int on = 1; //on为1表示开启
if(setsockopt(listenfp ,SOL_SOCKET,SO_REUSEADDR,&on,sieof(on))<0)
ERR_EXIT("setsockopt error");
处理多客户的服务器
在上一篇文章例程中,服务器端只能够连接一个客户端,并不能处理多个客户端的连接。原因在于服务器使用accept从已连接队列中获取一个连接后,便进入了对该连接的服务中,处于while循环状态。当一个新的客户端连接已经放入已连接队列时,服务器并不能执行到accpet的代码去获取队列中的连接。
为了解决这个问题,我们可以fork()一个子进程,让子进程来处理一个客户端的连接,而父进程循环执行accept的代码,获取新的连接:
int conn ;
while(1)
{
conn = accept(listenfd,(struct sockaddr*)&peeraddr,&peerlen);
if(conn <0)
ERR_EXIT("accept error");
else
printf("连接到服务器的客户端的IP地址是:%s,端口号是:%d\n",inet_ntoa(peeraddr.sin_addr),htons(peeraddr.sin_port));
pid_t pid ;
pid = fork();//创建一个新进程
if (pid ==0) //子进程
{
close(listenfd); //子进程不需要监听套接字,将其关闭
/*循环获取数据、发送数据*/
char recvbuf[1024];
while(1)
{
memset(recvbuf,0,sizeof(recvbuf));
int ret = read(conn,recvbuf ,sizeof(recvbuf));
fputs(recvbuf,stdout);
write(conn,recvbuf,sizeof(recvbuf));
}
exit(EXIT_SUCCESS);
}
if(pid >0) //父进程
{
close(conn);//父进程无需该连接套接字,它的任务是执行accept获取连接
}
else
{
close(conn);
}
}
启动服务器端,使用多个客户端进行连接,可以看到服务器能够同时处理多个连接:
实现一个P2P简单聊天程序
为了实现聊天的功能,客户端与服务器端都需要有一个进程来读取连接,另一个进程来处理键盘输入。使用fork()来完成这个简单的聊天程序。
客户端程序:
//p2pcli.c
#include<stdio.h>
#include<stdlib.h>
#include<errno.h>
#include<signal.h>
#include<arpa/inet.h>
#include<netinet/in.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<string.h>
#define ERR_EXIT(m)\
do \
{\
perror(m);\
exit(EXIT_FAILURE);\
}while(0)
void handler()
{
exit(EXIT_SUCCESS);
}
int main()
{
/*创建一个套接字*/
int sock = socket(AF_INET,SOCK_STREAM,0);
if(sock == -1)
{
ERR_EXIT("socket");
}
/*定义一个地址结构*/
struct sockaddr_in servaddr;
memset(&servaddr,0,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(5888);
servaddr.sin_addr.s_addr = inet_addr("127.0.0.1");
/*进行连接*/
if(connect(sock,(struct sockaddr*)&servaddr,sizeof(servaddr))<0)
{
ERR_EXIT("connect");
}
else
{
printf("连接成功\n");
}
pid_t pid ;
pid = fork();
if(pid == -1)
ERR_EXIT("fork");
if(pid == 0) //子进程复制接收数据并显示出来
{
char recvbuf[1024]={0};
while(1)
{
memset(recvbuf,0,sizeof(recvbuf));
int ret = read(sock ,recvbuf,sizeof(recvbuf));
if(ret == -1)
{
ERR_EXIT("read");
}
if(ret == 0) //连接关闭
{
printf("连接关闭\n");
kill(getppid(),SIGUSR1);
break;
}
else
{
printf("接收到信息:");
fputs(recvbuf,stdout);
}
}
}
else //父进程负责从键盘接收输入并发送
{
signal(SIGUSR1,handler);
char sendbuf[1024]={0} ;
while(fgets(sendbuf,sizeof(sendbuf),stdin)!=NULL)
{
write(sock,sendbuf,strlen(sendbuf));
memset(&sendbuf,0,sizeof(sendbuf));
}
}
close(sock);
return 0;
}
服务器端程序:
// p2pser.c
#include<stdio.h>
#include<stdlib.h>
#include<errno.h>
#include<signal.h>
#include<arpa/inet.h>
#include<netinet/in.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<string.h>
#define ERR_EXIT(m)\
do \
{\
perror(m);\
exit(EXIT_FAILURE);\
}while(0)
/*信号处理函数*/
void handler(int sig)
{
exit(EXIT_SUCCESS);
}
int main()
{
/* 创建一个套接字*/
int listenfd= socket(AF_INET ,SOCK_STREAM,0);
if(listenfd==-1)
ERR_EXIT("socket");
/*定义一个地址结构并填充*/
struct sockaddr_in addr;
addr.sin_family = AF_INET; //协议族为ipv4
addr.sin_port = htons(5888); //绑定端口号
addr.sin_addr.s_addr = htonl(INADDR_ANY);//主机字节序转为网络字节序
/*重复使用地址*/
int on = 1;
if(setsockopt(listenfd,SOL_SOCKET,SO_REUSEADDR,&on,sizeof(on))<0)
{
ERR_EXIT("setsockopt");
}
/*将套接字绑定到地址上*/
if(bind(listenfd,(const struct sockaddr *)&addr ,sizeof(addr))==-1)
{
ERR_EXIT("bind");
}
/*监听套接字,成为被动套接字*/
if(listen(listenfd,SOMAXCONN)<0)
{
ERR_EXIT("Listen");
}
struct sockaddr_in peeraddr;
socklen_t peerlen = sizeof(peeraddr);
int conn ;
conn = accept(listenfd,(struct sockaddr*)&peeraddr,&peerlen);
if(conn <0)
ERR_EXIT("accept error");
else
printf("连接到服务器的客户端的IP地址是:%s,端口号是:%d\n",inet_ntoa(peeraddr.sin_addr),htons(peeraddr.sin_port));
pid_t pid ;
pid = fork();//创建一个新进程
if(pid == -1)
{
ERR_EXIT("fork");
}
if(pid == 0)//子进程
{
signal(SIGUSR1,handler);
char sendbuf[1024] = {0};
while(fgets(sendbuf,sizeof(sendbuf),stdin)!=NULL)
{
write(conn,sendbuf,sizeof(sendbuf));
memset(sendbuf,0,sizeof(sendbuf));
}
exit(EXIT_SUCCESS);
}
else //父进程 用来获取数据
{
char recvbuf [1024]={0};
while(1)
{
memset(recvbuf,0,sizeof(recvbuf));
int ret = read(conn ,recvbuf,sizeof(recvbuf));
if(ret == -1)
{
ERR_EXIT("read");
}
if(ret == 0) //对方已关闭
{
printf("对方关闭\n");
break;
}
fputs(recvbuf,stdout);
}
kill(pid,SIGUSR1);
exit(EXIT_SUCCESS);
}
/*关闭套接字*/
close(listenfd);
close(conn);
return 0;
四:TCP粘包问题及5种完美解决方案
① TCP是个流协议,它存在粘包问题
TCP是一个基于字节流的传输服务,"流"意味着TCP所传输的数据是没有边界的。这不同于UDP提供基于消息的传输服务,其传输的数据是有边界的。TCP的发送方无法保证对等方每次接收到的是一个完整的数据包。主机A向主机B发送两个数据包,主机B的接收情况可能是
产生粘包问题的原因有以下几个:
-
第一 。应用层调用write方法,将应用层的缓冲区中的数据拷贝到套接字的发送缓冲区。而发送缓冲区有一个SO_SNDBUF的限制,如果应用层的缓冲区数据大小大于套接字发送缓冲区的大小,则数据需要进行多次的发送。
-
第二种情况是,TCP所传输的报文段有MSS的限制,如果套接字缓冲区的大小大于MSS,也会导致消息的分割发送。
-
第三种情况由于链路层最大发送单元MTU,在IP层会进行数据的分片。
这些情况都会导致一个完整的应用层数据被分割成多次发送,导致接收对等方不是按完整数据包的方式来接收数据。
② 粘包的问题的解决思路
粘包问题的最本质原因在与接收对等方无法分辨消息与消息之间的边界在哪。我们通过使用某种方案给出边界,例如:
-
发送定长包。如果每个消息的大小都是一样的,那么在接收对等方只要累计接收数据,直到数据等于一个定长的数值就将它作为一个消息。
-
包尾加上\r\n标记。FTP协议正是这么做的。但问题在于如果数据正文中也含有\r\n,则会误判为消息的边界。
-
包头加上包体长度。包头是定长的4个字节,说明了包体的长度。接收对等方先接收包体长度,依据包体长度来接收包体。
-
使用更加复杂的应用层协议。
③ 粘包解决方案一:使用定长包
这里需要封装两个函数:
ssize_t readn(int fd, void *buf, size_t count)
ssize_t writen(int fd, void *buf, size_t count)
这两个函数的参数列表和返回值与read
、write
一致。它们的作用的读取/写入count个字节后再返回。其实现如下:
ssize_t readn(int fd, void *buf, size_t count)
{
int left = count ; //剩下的字节
char * ptr = (char*)buf ;
while(left>0)
{
int readBytes = read(fd,ptr,left);
if(readBytes< 0)//read函数小于0有两种情况:1中断 2出错
{
if(errno == EINTR)//读被中断
{
continue;
}
return -1;
}
if(readBytes == 0)//读到了EOF
{
//对方关闭呀
printf("peer close\n");
return count - left;
}
left -= readBytes;
ptr += readBytes ;
}
return count ;
}
/*
writen 函数
写入count字节的数据
*/
ssize_t writen(int fd, void *buf, size_t count)
{
int left = count ;
char * ptr = (char *)buf;
while(left >0)
{
int writeBytes = write(fd,ptr,left);
if(writeBytes<0)
{
if(errno == EINTR)
continue;
return -1;
}
else if(writeBytes == 0)
continue;
left -= writeBytes;
ptr += writeBytes;
}
return count;
}
有了这两个函数之后,我们就可以使用定长包来发送数据了,我抽取其关键代码来讲诉:
char readbuf[512];
readn(conn,readbuf,sizeof(readbuf)); //每次读取512个字节
同理的,写入的时候也写入512个字节
char writebuf[512];
fgets(writebuf,sizeof(writebuf),stdin);
writen(conn,writebuf,sizeof(writebuf);
每个消息都以固定的512字节(或其他数字,看你的应用层的缓冲区大小)来发送,以此区分每一个信息,这便是以固定长度解决粘包问题的思路。定长包解决方案的缺点在于会导致增加网络的负担,无论每次发送的有效数据是多大,都得按照定长的数据长度进行发送。
④ 粘包解决方案二:使用结构体,显式说明数据部分的长度
在这个方案中,我们需要定义一个‘struct packet’包结构,结构中指明数据部分的长度,用四个字节来表示。发送端的对等方接收报文时,先读取前四个字节,获取数据的长度,由长度来进行数据的读取。定义一个结构体
struct packet
{
unsigned int msgLen ; //4个字节字段,说明数据部分的大小
char data[512] ; //数据部分
}
读写过程如下所示,这里抽取关键代码进行说明:
//发送数据过程struct packet writebuf;memset(&writebuf,0,sizeof(writebuf));while(fgets(writebuf.data,sizeof(writebuf.data),stdin)!=NULL){ int n = strlen(writebuf.data); //计算要发送的数据的字节数writebuf.msgLen =htonl(n); //将该字节数保存在msgLen字段,注意字节序的转换writen(conn,&writebuf,4+n); //发送数据,数据长度为4个字节的msgLen 加上data长度memset(&writebuf,0,sizeof(writebuf)); }
下面是读取数据的过程,先读取msgLen字段,该字段指示了有效数据data的长度。依据该字段再读出data。
memset(&readbuf,0,sizeof(readbuf));int ret = readn(conn,&readbuf.msgLen,4); //先读取四个字节,确定后续数据的长度if(ret == -1){err_exit("readn");}else if(ret == 0){printf("peer close\n");break;
}int dataBytes = ntohl(readbuf.msgLen); //字节序的转换int readBytes = readn(conn,readbuf.data,dataBytes); //读取出后续的数据if(readBytes == 0){printf("peer close\n");break;}if(readBytes<0){err_exit("read");
}
⑤ 粘包解决方案三:按行读取
ftp协议采用/r/n来识别一个消息的边界,我们在这里实现一个按行读取的功能,该功能能够按/n来识别消息的边界。这里介绍一个函数:
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
与read函数相比,recv函数的区别在于两点:
-
recv函数只能够用于套接口IO。
-
recv函数含有flags参数,可以指定一些选项。
recv函数的flags参数常用的选项是:
-
MSG_OOB 接收带外数据,即通过紧急指针发送的数据
-
MSG_PEEK 从缓冲区中读取数据,但并不从缓冲区中清除所读数据
为了实现按行读取,我们需要使用recv函数的MSG_PEEK选项。PEEK的意思是"偷看",我们可以理解为窥视,看看socket的缓冲区内是否有某种内容,而清除缓冲区。
*
* 封装了recv函数返回值说明:-1 读取出错
*/
ssize_t read_peek(int sockfd,void *buf ,size_t len)
{while(1){//从缓冲区中读取,但不清除缓冲区int ret = recv(sockfd,buf,len,MSG_PEEK);if(ret == -1 && errno == EINTR)//文件读取中断continue;return ret;}
}
下面是按行读取的代码:
/*
*读取一行内容
* 返回值说明:== 0 :对端关闭== -1 : 读取错误其他:一行的字节数,包含\n
*
**/
ssize_t readLine(int sockfd ,void * buf ,size_t maxline)
{int ret ;int nRead = 0;int left = maxline ;char * pbuf = (char *) buf;int count = 0;while(true){//从socket缓冲区中读取指定长度的内容,但并不删除ret = read_peek(sockfd,pbuf,left);// ret = recv(sockfd , pbuf , left , MSG_PEEK);if(ret<= 0)return ret;nRead = ret ;for(int i = 0 ;i< nRead ; ++i){if(pbuf[i]=='\n') //探测到有\n{ret = readn (sockfd , pbuf, i+1);if(ret != i+1)exit(EXIT_FAILURE);return ret + returnCount;}}//如果嗅探到没有\n//那么先将这一段没有\n的读取出来ret = readn(sockfd , pbuf , nRead);if(ret != nRead)exit(EXIT_FAILURE);pbuf += nRead ;left -= nRead ;count += nRead;}return -1;
}