TCP粘包问题详解和解决方案【C语言】

1.什么是TCP粘包

TCP(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流的传输协议,它保证了数据的可靠性和顺序性。然而,由于TCP是基于字节流而不是消息的,因此在传输过程中可能会出现粘包(Packing)和拆包(Unpacking)问题。

**粘包问题(TCP粘包现象)**指的是发送方在传输数据时,TCP协议把多个发送的小数据包"粘"在一起,形成一个大的数据包发送;或者接收方在接收数据时,多个小的数据包被"粘"在一起,形成一个大的数据包接收。这种现象的发生是由于TCP协议的工作机制导致的。

原因和机制

  1. TCP工作方式:TCP是基于字节流的协议,它并不了解上层应用发送的消息边界(Message Boundary)。它只负责把接收到的字节流按照顺序交给应用层,因此多个发送的小数据包在传输过程中有可能会被合并成一个大的数据包发送,或者一个大的数据包被拆分成多个小数据包接收。

  2. 发送端的粘包

    • 发送端应用程序往往会先把数据放入TCP发送缓冲区,然后TCP根据自身的发送策略(如Nagle算法等)进行发送,可能会合并多个数据包一起发送,以提高网络利用率和性能。
    • 如果发送端应用程序发送的消息比较小,并且发送速率较快,这些小消息在TCP层可能会被合并成一个大的数据包发送,导致接收方接收到的数据出现粘包现象。
  3. 接收端的粘包

    • 接收端应用程序从TCP接收缓冲区中读取数据时,由于TCP层不了解应用层的消息边界,可能一次性把多个发送的小数据包"粘"在一起交给应用层处理。
    • 如果接收端应用程序处理消息的速度跟不上数据的接收速度,会导致接收到的数据出现粘包现象。

例如:

  • 客户端和服务器之间要进行基于TCP的套接字通信
  • 通信过程中客户端会每次会不定期给服务器发送一个不定长度的有特定含义的字符串。
  • 通信的服务器端每次都需要接收到客户端这个不定长度的字符串,并对其进行解析

根据上面的描述,服务器在接收数据的时候有如下几种情况:

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

解决方案

粘包问题在实际的网络编程中是常见的,需要采取一些策略来解决或者减少其影响:

  • 消息边界标记 :在发送的消息中加入特定的消息边界标记(如换行符 \n),接收端根据消息边界标记来分割接收到的数据,从而识别出完整的消息。**有缺陷:**效率低, 需要一个字节一个字节接收, 接收一个字节判断一次, 判断是不是那个特殊字符串

  • 消息长度固定:发送端将每个消息的长度固定,接收端根据固定长度来分割接收到的数据,从而确保每个接收到的数据包含完整的消息。缺点:容易造成空间浪费

  • 消息头部长度字段 :发送端在每个消息前加入一个固定长度的消息头部,包含消息的长度信息,接收端根据头部长度字段来读取对应长度的消息数据。这时候数据由两部分组成:数据头+数据块数据头: 存储当前数据包的总字节数,接收端先接收数据头,然后在根据数据头接收对应大小的字节,**数据块:**当前数据包的内容

  • 使用标准的应用层协议(比如:http、https)来封装要传输的不定长的数据包

2.解决方案具体实现

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

发送端设计

对于发送端来说,数据的发送分为以下四步:

  1. 动态申请内存: 根据待发送的数据长度 N申请一块大小为 N+4 的内存,其中4个字节用于存储包头信息。

  2. 写入包头: 将待发送数据的总长度(N)写入申请的内存的前四个字节中,并将其转换为网络字节序(大端序)。

  3. 拷贝数据并发送: 将待发送的数据拷贝到包头后面的地址空间中,然后将整个数据包发送出去。这里需要确保数据包能够完整发送,因此可以设计一个发送函数,确保当前数据包中的数据全部发送完毕。

  4. 释放内存: 发送完毕后,释放申请的堆内存。

示例代码:

cpp 复制代码
/*
函数描述: 发送指定的字节数
函数参数:
    - 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) {
            perror("send");
            close(fd);
            return -1; // 发送失败
        } else if (len == 0) {
            continue; // 发送未成功,继续尝试
        }
        buf += len;    // 更新待发送数据的起始地址
        count -= len;  // 更新剩余待发送的数据字节数
    }
    return size; // 全部数据发送完毕,返回发送的总字节数
}

/*
函数描述: 发送带有数据头的数据包
函数参数:
    - cfd: 通信的文件描述符(套接字)
    - msg: 待发送的原始数据
    - len: 待发送的原始数据的总字节数
函数返回值: 函数调用成功返回发送的字节数, 发送失败返回-1
*/
int sendMsg(int cfd, const char* msg, int len) {
    if (msg == NULL || len <= 0 || cfd <= 0) {
        return -1; // 参数无效
    }

    // 申请内存空间: 数据长度 + 包头4字节(存储数据长度)
    char* data = (char*)malloc(len + 4);
    if (data == NULL) {
        perror("malloc");
        return -1; // 内存申请失败
    }

    // 将数据长度转换为网络字节序(大端序)并存储在包头
    int bigLen = htonl(len);
    memcpy(data, &bigLen, 4);

    // 将待发送的数据拷贝到包头后面
    memcpy(data + 4, msg, len);

    // 发送带有包头的数据包
    int ret = writen(cfd, data, len + 4);

    // 释放申请的内存
    free(data);

    return ret; // 返回发送的字节数
}

接收端设计

在接收端,需要确保每次接收到的都是完整的数据包,避免粘包问题。以下是具体的步骤和代码实现:

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

示例代码:

cpp 复制代码
/*
函数描述: 接收指定的字节数
函数参数:
    - 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) {
            perror("recv");
            return -1; // 接收失败
        } else if (len == 0) {
            return size - count; // 对方关闭连接,返回已接收的字节数
        }
        pt += len;    // 更新缓冲区指针
        count -= len; // 更新剩余需要接收的字节数
    }
    return size; // 返回实际接收的字节数
}

/*
函数描述: 接收带数据头的数据包
函数参数:
    - cfd: 通信的文件描述符(套接字)
    - msg: 一级指针的地址,函数内部会给这个指针分配内存,用于存储待接收的数据,这块内存需要使用者释放
函数返回值: 函数调用成功返回接收的字节数, 接收失败返回-1
*/
int recvMsg(int cfd, char** msg) {
    // 接收数据头(4个字节)
    int len = 0;
    if (readn(cfd, (char*)&len, 4) != 4) {
        return -1; // 接收数据头失败
    }

    // 将数据头从网络字节序转换为主机字节序,得到数据长度
    len = ntohl(len);
    printf("数据块大小: %d\n", len);

    // 根据读出的长度分配内存,多分配1个字节用于存储字符串结束符'\0'
    char* buf = (char*)malloc(len + 1);
    if (buf == NULL) {
        perror("malloc");
        return -1; // 内存分配失败
    }

    // 接收数据
    int ret = readn(cfd, buf, len);
    if (ret != len) {
        close(cfd);
        free(buf);
        return -1; // 接收数据失败
    }

    buf[len] = '\0'; // 添加字符串结束符
    *msg = buf;

    return ret; // 返回接收的字节数
}

3.TCP循环通信代码

服务端:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>

int readn(int fd, char* buf, int size);
int recvMsg(int cfd, char** msg);
int sendMsg(int cfd, const char* msg, int len);

int main() {
    int server_sockfd, client_sockfd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_addr_len = sizeof(client_addr);

    // 创建服务器套接字
    server_sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_sockfd < 0) {
        perror("socket");
        return 1;
    }

    // 服务器地址配置
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(12345); // 服务器端口
    server_addr.sin_addr.s_addr = INADDR_ANY;

    // 绑定地址和端口
    if (bind(server_sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        perror("bind");
        close(server_sockfd);
        return 1;
    }

    // 监听连接
    if (listen(server_sockfd, 5) < 0) {
        perror("listen");
        close(server_sockfd);
        return 1;
    }

    printf("Server is listening on port 12345...\n");

    // 接受客户端连接
    client_sockfd = accept(server_sockfd, (struct sockaddr*)&client_addr, &client_addr_len);
    if (client_sockfd < 0) {
        perror("accept");
        close(server_sockfd);
        return 1;
    }

    char* data;
    char msg[1024];
    while (1) {
        // 接收客户端消息
        int len = recvMsg(client_sockfd, &data);
        if (len < 0) {
            fprintf(stderr, "Failed to receive data\n");
            break;
        }
        
        printf("Client: %s\n", data);

        // 收到"exit"消息,退出循环
        if (strcmp(data, "exit") == 0) {
            free(data);
            break;
        }

        // 获取服务端要发送的消息
        printf("Server: ");
        fgets(msg, sizeof(msg), stdin);
        msg[strcspn(msg, "\n")] = '\0'; // 去掉换行符

        // 发送消息到客户端
        if (sendMsg(client_sockfd, msg, strlen(msg)) < 0) {
            fprintf(stderr, "Failed to send data\n");
            free(data);
            break;
        }

        free(data); // 处理完数据后释放内存

        // 输入"exit"退出循环
        if (strcmp(msg, "exit") == 0) {
            break;
        }
    }

    // 关闭套接字
    close(client_sockfd);
    close(server_sockfd);
    return 0;
}

/*
函数描述: 接收指定的字节数
函数参数:
    - 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) {
            perror("recv");
            return -1; // 接收失败
        } else if (len == 0) {
            return size - count; // 对方关闭连接,返回已接收的字节数
        }
        pt += len;    // 更新缓冲区指针
        count -= len; // 更新剩余需要接收的字节数
    }
    return size; // 返回实际接收的字节数
}

/*
函数描述: 接收带数据头的数据包
函数参数:
    - cfd: 通信的文件描述符(套接字)
    - msg: 一级指针的地址,函数内部会给这个指针分配内存,用于存储待接收的数据,这块内存需要使用者释放
函数返回值: 函数调用成功返回接收的字节数, 接收失败返回-1
*/
int recvMsg(int cfd, char** msg) {
    // 接收数据头(4个字节)
    int len = 0;
    if (readn(cfd, (char*)&len, 4) != 4) {
        return -1; // 接收数据头失败
    }

    // 将数据头从网络字节序转换为主机字节序,得到数据长度
    len = ntohl(len);
    printf("数据块大小: %d\n", len);

    // 根据读出的长度分配内存,多分配1个字节用于存储字符串结束符'\0'
    char* buf = (char*)malloc(len + 1);
    if (buf == NULL) {
        perror("malloc");
        return -1; // 内存分配失败
    }

    // 接收数据
    int ret = readn(cfd, buf, len);
    if (ret != len) {
        close(cfd);
        free(buf);
        return -1; // 接收数据失败
    }

    buf[len] = '\0'; // 添加字符串结束符
    *msg = buf;

    return ret; // 返回接收的字节数
}

/*
函数描述: 发送指定的字节数
函数参数:
    - 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) {
            perror("send");
            return -1; // 发送失败
        } else if (len == 0) {
            continue; // 发送未成功,继续尝试
        }
        buf += len;    // 更新待发送数据的起始地址
        count -= len;  // 更新剩余待发送的数据字节数
    }
    return size; // 全部数据发送完毕,返回发送的总字节数
}

/*
函数描述: 发送带有数据头的数据包
函数参数:
    - cfd: 通信的文件描述符(套接字)
    - msg: 待发送的原始数据
    - len: 待发送的原始数据的总字节数
函数返回值: 函数调用成功返回发送的字节数, 发送失败返回-1
*/
int sendMsg(int cfd, const char* msg, int len) {
    if (msg == NULL || len <= 0 || cfd <= 0) {
        return -1; // 参数无效
    }

    // 申请内存空间: 数据长度 + 包头4字节(存储数据长度)
    char* data = (char*)malloc(len + 4);
    if (data == NULL) {
        perror("malloc");
        return -1; // 内存申请失败
    }

    // 将数据长度转换为网络字节序(大端序)并存储在包头
    int bigLen = htonl(len);
    memcpy(data, &bigLen, 4);

    // 将待发送的数据拷贝到包头后面
    memcpy(data + 4, msg, len);

    // 发送带有包头的数据包
    int ret = writen(cfd, data, len + 4);

    // 释放申请的内存
    free(data);

    return ret; // 返回发送的字节数
}

客户端:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>

int sendMsg(int cfd, const char* msg, int len);
int recvMsg(int cfd, char** msg);

int main() {
    int sockfd;
    struct sockaddr_in server_addr;

    // 创建套接字
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("socket");
        return 1;
    }

    // 服务器地址配置
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(12345); // 服务器端口
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 服务器IP地址

    // 连接到服务器
    if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        perror("connect");
        close(sockfd);
        return 1;
    }

    char msg[1024];
    char* data;
    while (1) {
        // 客户端输入消息
        printf("Client: ");
        fgets(msg, sizeof(msg), stdin);
        msg[strcspn(msg, "\n")] = '\0'; // 去掉换行符

        // 发送数据到服务器
        if (sendMsg(sockfd, msg, strlen(msg)) < 0) {
            fprintf(stderr, "Failed to send data\n");
            break;
        }

        // 接收服务器消息
        if (recvMsg(sockfd, &data) < 0) {
            fprintf(stderr, "Failed to receive data\n");
            break;
        }
        
        printf("Server: %s\n", data);
        free(data);

        // 输入"exit"退出循环
        if (strcmp(msg, "exit") == 0) {
            break;
        }
    }

    // 关闭套接字
    close(sockfd);
    return 0;
}

/*
函数描述: 发送指定的字节数
函数参数:
    - 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) {
            perror("send");
            return -1; // 发送失败
        } else if (len == 0) {
            continue; // 发送未成功,继续尝试
        }
        buf += len;    // 更新待发送数据的起始地址
        count -= len;  // 更新剩余待发送的数据字节数
    }
    return size; // 全部数据发送完毕,返回发送的总字节数
}

/*
函数描述: 发送带有数据头的数据包
函数参数:
    - cfd: 通信的文件描述符(套接字)
    - msg: 待发送的原始数据
    - len: 待发送的原始数据的总字节数
函数返回值: 函数调用成功返回发送的字节数, 发送失败返回-1
*/
int sendMsg(int cfd, const char* msg, int len) {
    if (msg == NULL || len <= 0 || cfd <= 0) {
        return -1; // 参数无效
    }

    // 申请内存空间: 数据长度 + 包头4字节(存储数据长度)
    char* data = (char*)malloc(len + 4);
    if (data == NULL) {
        perror("malloc");
        return -1; // 内存申请失败
    }

    // 将数据长度转换为网络字节序(大端序)并存储在包头
    int bigLen = htonl(len);
    memcpy(data, &bigLen, 4);

    // 将待发送的数据拷贝到包头后面
    memcpy(data + 4, msg, len);

    // 发送带有包头的数据包
    int ret = writen(cfd, data, len + 4);

    // 释放申请的内存
    free(data);

    return ret; // 返回发送的字节数
}

/*
函数描述: 接收指定的字节数
函数参数:
    - 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) {
            perror("recv");
            return -1; // 接收失败
        } else if (len == 0) {
            return size - count; // 对方关闭连接,返回已接收的字节数
        }
        pt += len;    // 更新缓冲区指针
        count -= len; // 更新剩余需要接收的字节数
    }
    return size; // 返回实际接收的字节数
}

/*
函数描述: 接收带数据头的数据包
函数参数:
    - cfd: 通信的文件描述符(套接字)
    - msg: 一级指针的地址,函数内部会给这个指针分配内存,用于存储待接收的数据,这块内存需要使用者释放
函数返回值: 函数调用成功返回接收的字节数, 接收失败返回-1
*/
int recvMsg(int cfd, char** msg) {
    // 接收数据头(4个字节)
    int len = 0;
    if (readn(cfd, (char*)&len, 4) != 4) {
        return -1; // 接收数据头失败
    }

    // 将数据头从网络字节序转换为主机字节序,得到数据长度
    len = ntohl(len);
    printf("数据块大小: %d\n", len);

    // 根据读出的长度分配内存,多分配1个字节用于存储字符串结束符'\0'
    char* buf = (char*)malloc(len + 1);
    if (buf == NULL) {
        perror("malloc");
        return -1; // 内存分配失败
    }

    // 接收数据
    int ret = readn(cfd, buf, len);
    if (ret != len) {
        close(cfd);
        free(buf);
        return -1; // 接收数据失败
    }

    buf[len] = '\0'; // 添加字符串结束符
    *msg = buf;

    return ret; // 返回接收的字节数
}
相关推荐
watermelonoops42 分钟前
Deepin和Windows传文件(Xftp,WinSCP)
linux·ssh·deepin·winscp·xftp
疯狂飙车的蜗牛2 小时前
从零玩转CanMV-K230(4)-小核Linux驱动开发参考
linux·运维·驱动开发
远游客07134 小时前
centos stream 8下载安装遇到的坑
linux·服务器·centos
马甲是掉不了一点的<.<4 小时前
本地电脑使用命令行上传文件至远程服务器
linux·scp·cmd·远程文件上传
jingyu飞鸟4 小时前
centos-stream9系统安装docker
linux·docker·centos
XH华4 小时前
初识C语言之二维数组(下)
c语言·算法
超爱吃士力架4 小时前
邀请逻辑
java·linux·后端
fantasy_arch5 小时前
CPU性能优化-磁盘空间和解析时间
网络·性能优化
LIKEYYLL6 小时前
GNU Octave:特性、使用案例、工具箱、环境与界面
服务器·gnu
njnu@liyong6 小时前
图解HTTP-HTTP报文
网络协议·计算机网络·http