linux下socket编程处理TCP粘包

一、 数据接收时会出现以下几种情况

  • 一次接收到了客户端发送过来的一个完整的数据包
  • 一次接收到了客户端发送过来的 N 个数据包,由于每个包的长度不定,无法将各个数据包拆开
  • 一次接收到了一个或者 N 个数据包 + 下一个数据包的一部分,还是很悲剧,无法将数据包拆开
  • 一次收到了半个数据包,下一次接收数据的时候收到了剩下的一部分 + 下个数据包的一部分,更悲剧,头大了
  • 另外,还有一些不可抗拒的因素:比如客户端和服务器端的网速不一样,发送和接收的数据量也会不一致

二、几种解决方案

  • 使用标准的应用层协议(比如:http、https)来封装要传输的不定长的数据包
  • 在每条数据的尾部添加特殊字符,如果遇到特殊字符,代表当条数据接收完毕了
    有缺陷:效率低,需要一个字节一个字节接收,接收一个字节判断一次,判断是不是那个特殊字符串
  • 在发送数据块之前,在数据块最前边添加一个固定大小的数据头,这时候数据由两部分组成:数据头 + 数据块
    数据头:存储当前数据包的总字节数,接收端先接收数据头,然后在根据数据头接收对应大小的字节
    数据块:当前数据包的内容

三、数据头 + 数据块解决方案

如果使用 TCP 进行套接字通信,如果发送的数据包粘连到一起导致接收端无法解析,我们通常使用添加包头的方式轻松地解决掉这个问题。关于数据包的包头大小可以根据自己的实际需求进行设定,这里没有啥特殊需求,因此规定包头的固定大小为4个字节,用于存储当前数据块的总字节数。
linux下socket编程处理TCP粘包_第1张图片

3.1发送端

对于发送端来说,数据的发送分为 4 步:
1、根据待发送的数据长度 N 动态申请一块固定大小的内存:N+4(4 是包头占用的字节数)
2、将待发送数据的总长度写入申请的内存的前四个字节中,此处需要将其转换为网络字节序(大端)
3、将待发送的数据拷贝到包头后边的地址空间中,将完整的数据包发送出去(字符串没有字节序问题)
4、释放申请的堆内存。

由于发送端每次都需要将这个数据包完整的发送出去,因此可以设计一个发送函数,如果当前数据包中的数据没有发送完就让它一直发送,处理代码如下:

/*
函数描述: 发送指定的字节数
函数参数:
    - fd: 通信的文件描述符(套接字)
    - msg: 待发送的原始数据
    - size: 待发送的原始数据的总字节数
函数返回值: 函数调用成功返回发送的字节数, 发送失败返回-1
*/
int writen(int fd, const char* msg, int size)
{
    const char* buf = msg;
    int count = size;
    while (count > 0)
    {
        int len = send(fd, buf, count, 0);
        if (len == -1)
        {
            close(fd);
            return -1;
        }
        else if (len == 0)
        {
            continue;
        }
        buf += len;
        count -= len;
    }
    return size;
}

有了这个功能函数之后就可以发送带有包头的数据块了,具体处理动作如下:

/*
函数描述: 发送带有数据头的数据包
函数参数:
    - cfd: 通信的文件描述符(套接字)
    - msg: 待发送的原始数据
    - len: 待发送的原始数据的总字节数
函数返回值: 函数调用成功返回发送的字节数, 发送失败返回-1
*/
int sendMsg(int cfd, char* msg, int len)
{
   if(msg == NULL || len <= 0 || cfd <=0)
   {
       return -1;
   }
   // 申请内存空间: 数据长度 + 包头4字节(存储数据长度)
   char* data = (char*)malloc(len+4);
   int bigLen = htonl(len); 
   memcpy(data, &bigLen, 4);  //将数据长度放入data段的前4个字节,这代表后面将要跟随的数据长度
   memcpy(data+4, msg, len);  //将数据放入4字节长度的后面
   // 发送数据
   int ret = writen(cfd, data, len+4);  //将数据发送出去
   // 释放内存
   free(data);
   return ret;
}

3.2 接收端

了解了套接字的发送端如何发送数据,接收端的处理步骤也就清晰了,具体过程如下:

1、首先接收 4 字节数据,并将其从网络字节序转换为主机字节序,这样就得到了即将要接收的数据的总长度
2、根据得到的长度申请固定大小的堆内存,用于存储待接收的数据
3、根据得到的数据块长度接收固定数目的数据保存到申请的堆内存中
4、处理接收的数据
5、释放存储数据的堆内存

从数据包头解析出要接收的数据长度之后,还需要将这个数据块完整的接收到本地才能进行后续的数据处理,因此需要编写一个接收数据的功能函数,保证能够得到一个完整的数据包数据,处理函数实现如下:

/*
函数描述: 接收指定的字节数
函数参数:
    - fd: 通信的文件描述符(套接字)
    - buf: 存储待接收数据的内存的起始地址
    - size: 指定要接收的字节数
函数返回值: 函数调用成功返回发送的字节数, 发送失败返回-1
*/
int readn(int fd, char* buf, int size)
{
    char* pt = buf;
    int count = size;
    while (count > 0)
    {
        int len = recv(fd, pt, count, 0);
        if (len == -1)
        {
            return -1;
        }
        else if (len == 0)
        {
            return size - count;
        }
        pt += len;
        count -= len;
    }
    return size;
}

这个函数搞定之后,就可以轻松地接收带包头的数据块了,接收函数实现如下:

/*
函数描述: 接收带数据头的数据包
函数参数:
    - cfd: 通信的文件描述符(套接字)
    - msg: 一级指针的地址,函数内部会给这个指针分配内存,用于存储待接收的数据,这块内存需要使用者释放
函数返回值: 函数调用成功返回接收的字节数, 发送失败返回-1
*/
int recvMsg(int cfd, char** msg)
{
    // 接收数据
    // 1. 读数据头
    int len = 0;
    readn(cfd, (char*)&len, 4);  //先读出代表数据长度的前4个字节
    len = ntohl(len);
    printf("数据块大小: %d\n", len);

    // 根据读出的长度分配内存,+1 -> 这个字节存储\0
    char *buf = (char*)malloc(len+1);
    int ret = readn(cfd, buf, len);  //再读出真正长度的数据
    if(ret != len)
    {
        close(cfd);
        free(buf);
        return -1;
    }
    buf[len] = '\0';
    *msg = buf;

    return ret;
}

这样,在进行套接字通信的时候通过调用封装的 sendMsg() 和 recvMsg() 就可以发送和接收带数据头的数据包了,而且完美地解决了粘包的问题。

你可能感兴趣的:(linux,C/C++,嵌入式linux,linux,tcp/ip,网络)