• C++实现UDP可靠传输(一)


    声明:禁止以任何形式转载本文章。本文章仅供个人学习记录与交流探讨,文章中提供的思路只是一种解决方案,代码也并非完整代码,如有需要,请自行设计协议并完成编程任务。

    食用本文章之前,推荐阅读:C++实现流式socket聊天程序

    目录

    UDP协议的基本框架

    程序实现

    消息类型

    三次握手

    四次挥手

    发送消息

    以二进制方式读文件

    发送消息的基本框架

    差错检测

    确认重传

    接收消息

    接收消息的基本框架

    以二进制方式写文件

    程序测试


    在C++实现流式socket聊天程序中,我们使用TCP协议传输数据,TCP实现的是可靠传输。但对于简单的交互应用和一些对延时敏感的应用来说,TCP需要握手挥手、维护连接状态、差错重传,这些都会增加延时。因此,这些应用通常使用UDP服务,而需要在UDP之上,也就是应用层增加可靠机制,保证数据正常传输。

    本文实现了一个简单的基于UDP协议的可靠传输,实现的功能主要有:

    • 建立连接三次握手
    • 以二进制形式单向传输数据
    • 差错检测:检查消息类型、序列号、校验和
    • 确认重传:包括差错重传和超时重传
    • 流量控制:停等机制
    • 断开连接四次握手

    UDP协议的基本框架

    我们先来看看如何使用UDP协议发送和接收消息。

    以Server服务器为例:

    1. // 加载环境
    2. WSADATA wsaData;
    3. WSAStartup(MAKEWORD(2, 2), &wsaData);
    4. // 创建数据报套接字
    5. SOCKET sockServer = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
    6. // 绑定ip地址和端口
    7. sockaddr_in addrServer;
    8. memset(&addrServer, 0, sizeof(sockaddr_in));
    9. addrServer.sin_family = AF_INET;
    10. addrServer.sin_port = htons(8000);
    11. addrServer.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
    12. bind(sockServer, (SOCKADDR*)&addrServer, sizeof(SOCKADDR))
    13. // 客户端地址
    14. sockaddr_in addrClient;
    15. // 接收和发送消息,注意这里的recvfrom是阻塞函数
    16. int len = sizeof(SOCKADDR);
    17. while(true){
    18. recvfrom(sockSrv, recvBuf, 1024, 0, (SOCKADDR *)&addrClient, &len);
    19. sendto(sockSrv, sendBuf, 1024, 0, (SOCKADDR *)&addrClient, len);
    20. }
    21. // 关闭监听套接字
    22. closesocket(sockServer);
    23. //清理环境
    24. WSACleanup();

    客户端也是同样的流程,只是可以不需要绑定ip地址和端口。

    下面,我们在这个基本框架之上,尝试实现可靠传输。

    程序实现

    消息类型

    首先我们需要为传输的数据设计一个消息头,存储一些重要信息,以便实现后续的功能。需要明确的是,我们需要以二进制形式传输数据,因此必须考虑消息头里的数据类型的位数。在此提供一种设计方案:

    1. struct HeadMsg {
    2. u_short len; // 数据长度,16位
    3. u_short checkSum; // 校验和,16位
    4. unsigned char type; // 消息类型
    5. unsigned char seq; // 序列号,可以表示0-255
    6. };
    • len:表示传输的数据长度,共16位,也就是最多可以传输8192字节的数据
    • checkSum:表示校验和,共16位,负责校验传输的消息和数据是否被损坏
    • type:表示消息类型,共8位,可以自行设计每一位表示的类型,1表示有效,0表示无效
    • seq:表示序列号,共8位,可以表示0-255

    有了消息头之后,我们每次发送消息时都要设置好消息头,再加上要传输的数据。接下来,我们从最简单的三次握手和四次挥手开始。

    三次握手

    在TCP中,三次握手的流程如下:

    • 第一次握手:Client发送SYN消息
    • 第二次握手:Server发送SYN_ACK消息
    • 第三次握手:Client发送ACK消息

    本文章的设计和TCP中的三次握手一致。当然你也可以设计二次握手,即只需一方发出请求连接就确立;也可以设计四次握手,需要双方分别发送建立连接的请求。

    主动握手的代码如下:

    1. HeadMsg h1;
    2. h1.type = SYN;
    3. char *sendBuf = new char[1024];
    4. memset(sendBuf, 0, sizeof(sendBuf));
    5. memcpy(sendBuf, &h1, sizeof(h1));
    6. if (sendto(sockClient, sendBuf, 1024, 0, (SOCKADDR*)&addrServer, sizeof(SOCKADDR)) == -1) {
    7. return false;
    8. }
    9. else {
    10. cout << "Client: [SYN] Seq=0" << endl;
    11. }

    其中的memcpy函数在本文章中会经常使用:

    1. // 从b地址开始,把c个字节的数据写入a地址
    2. memcpy(a, b, c);

    对于发送的消息,初步只设置了消息头的消息类型,其它的功能我们后续一步步完善。

    等待握手的代码如下:

    1. char *recvBuf = new char[1024];
    2. memset(recvBuf, 0, sizeof(recvBuf));
    3. int addrlen = sizeof(SOCKADDR);
    4. HeadMsg h2;
    5. while (true) {
    6. if (recvfrom(sockClient, recvBuf, 1024, 0, (SOCKADDR*)&addrServer, &addrlen) > 0) {
    7. memcpy(&h2, recvBuf, sizeof(h2));
    8. if (h2.type == SYN_ACK) {
    9. cout << "Server: [SYN, ACK] Seq=0 Ack=1" << endl;
    10. break;
    11. }
    12. else {
    13. return false;
    14. }
    15. }
    16. }

    大家可以自行完善三次握手的过程。接下来我们介绍四次挥手:

    四次挥手

    在TCP中,双方都可以先发送挥手,也就是断开连接的请求。你可以设计三次挥手,即把二三次挥手结合在一起发送;也可以设计二次挥手,一方请求断开连接则暴力断开连接;也可以制定哪一方先发送挥手。

    本文章中,为了方便,设计发送端先发送挥手。

    • 第一次挥手:Server发送FIN_ACK消息
    • 第二次挥手:Client发送ACK消息
    • 第三次挥手:Client发送FIN_ACK消息
    • 第四次挥手:Server发送ACK消息

    四次挥手的代码和三次握手类似:

    1. // 挥手
    2. HeadMsg h1;
    3. h1.type = FIN;
    4. char *sendBuf = new char[1024];
    5. memset(sendBuf, 0, sizeof(sendBuf));
    6. memcpy(sendBuf, &h1, sizeof(h1));
    7. if (sendto(sockServer, sendBuf, 1024, 0, (SOCKADDR*)&addrClient, sizeof(SOCKADDR)) == -1) {
    8. return false;
    9. }
    10. else {
    11. cout << "Server: [FIN, ACK] Seq=n" endl;
    12. }
    13. // 等待挥手
    14. char *recvBuf = new char[1024];
    15. memset(recvBuf, 0, sizeof(recvBuf));
    16. int addrlen = sizeof(SOCKADDR);
    17. HeadMsg h2;
    18. while (true) {
    19. if (recvfrom(sockServer, recvBuf, 1024, 0, (SOCKADDR*)&addrClient, &addrlen) > 0) {
    20. memcpy(&h2, recvBuf, sizeof(h2));
    21. if (h2.type == ACK) {
    22. cout << "Client: [ACK] Seq=n"<< " Ack=m" << endl;
    23. break;
    24. }
    25. else {
    26. return false;
    27. }
    28. }
    29. }

    接下来我们介绍发送和接收消息,也就是本文章的重点部分。

    发送消息

    以二进制方式读文件

    我们需要以二进制方式读文件并将其保存到缓冲区,这部分和协议设计没什么关系,但是由于过去很少接触,还是介绍一下:

    1. // 以二进制方式打开文件
    2. ifstream fin(file.c_str(), ifstream::in | ios::binary);
    3. if (!fin) {
    4. cout << "Error: cannot open file!" << endl;
    5. return false;
    6. }
    7. fin.seekg(0, fin.end); // 指针定位在文件尾
    8. int length = fin.tellg(); // 获取文件大小(字节)
    9. fin.seekg(0, fin.beg); // 指针定位在文件头
    10. char *data = new char[length];
    11. memset(data, 0, sizeof(data));
    12. fin.read(data, length);
    13. fin.close();

    发送消息的基本框架

    接下来明确一下我们发送消息的基本框架:

    • 设置头部信息,除了消息类型,现在我们还需要加上序列号和校验和
    • 发送消息并开始计时,等待收到ACK确认的消息
    • 由于文件可能过大,需要分批次发送(别忘了消息头里len是16位的限制!)
    • 收到ACK消息,检查消息类型、序列号、校验和
      • 若收到了错误的ACK消息:重新发送消息
      • 若收到了正确的ACK消息:继续发送下一条消息
    • 若超过最大等待时间还没有收到正确的ACK消息:重新发送消息,重新计时

    细心的同学可能发现,如果一直没有收到或收到错误的ACK消息,发送端会一直重新发送信息。因此,大家可以自行设计如何跳出发送消息,例如设置最大重传次数、最大等待时间等。

    1. // 设置信息头
    2. HeadMsg h;
    3. h.seq = curSeq;
    4. h.len = packLen;
    5. h.type = PSH;
    6. char *sendBuf = new char[h.len+sizeof(h)];
    7. memset(sendBuf, 0, sizeof(sendBuf));
    8. memcpy(sendBuf, &h, sizeof(h));
    9. // data存放的是读入的二进制数据,sentLen是已发送的长度,作为分批次发送的偏移量
    10. memcpy(sendBuf + sizeof(h), data + sentLen, h.len);
    11. sentLen += (int)h.len;
    12. // 计算校验和
    13. h.checkSum = checkSumVerify((u_short *)sendBuf, sizeof(h) + h.len);
    14. memcpy(sendBuf, &h, sizeof(h));
    15. // 发送消息
    16. if (sendto(sockServer, sendBuf, h.len + sizeof(h), 0, (SOCKADDR*)&addrClient, sizeof(SOCKADDR)) == -1) {
    17. cout << "Error: fail to send messages!" << endl;
    18. return false;
    19. }
    20. // 等待接收消息
    21. char *recvBuf = new char[1024];
    22. memset(recvBuf, 0, sizeof(recvBuf));
    23. int addrlen = sizeof(SOCKADDR);
    24. while (true) {
    25. if (recvfrom(sockServer, recvBuf, 1024, 0, (SOCKADDR*)&addrClient, &addrlen) > 0) {
    26. // 收到消息需要验证消息类型、序列号和校验和
    27. else {
    28. // 差错重传并重新计时
    29. }
    30. }
    31. else {
    32. // 超时重传并重新计时
    33. }
    34. }

    差错检测

    收到消息后,我们需要检查消息是否正确。

    首先,需要检查消息类型是否正确。本文章设计的发送数据时的消息类型为PSH,大家也可以自行设计。

    其次,需要检查消息的序列号是否正确。接收端每次发送ACK消息时,序列号都为其最后正确收到的消息的序列号。例如发送端发送一条序列号seq=n的消息,接收端收到后,会发送ACK消息确认,序列号seq=n;若发送端的消息损坏,接收端同样会发送ACK消息,但是这是序列号seq=n-1,也就是最后正确收到的消息是n-1号消息,以此来告诉发送端你的消息损坏了。因此,在发送端,我们只需要正确设置序列号就可以了。本文章的消息头中,序列号有8位,可以表示0-255。在发送端,我们只需要维护一个全局变量seq即可。

    1. // 初始化8位序列号
    2. unsigned char seq = 0;
    3. // 每次成功发送消息后,序列号+1,但是要注意序列号空间有限
    4. seq = (seq + 1) % 256;
    5. // 收到消息时检查序列号
    6. if(h.seq == seq)

    最后,需要检查校验和是否正确。校验和是消息头中的冗余字段,用来检测数据报传输过程中出现的差错。校验和的计算方法是:

    • 将消息头的校验和设置为0
    • 将消息头和数据看成16位整数序列,不足16位的最后补0
    • 每16位相加,溢出的部分加到最低位上
    • 最后的结果取反

    接收端接收到数据时,需要用同样的方法计算校验和,但是不需要先将校验和清零。如果校验和结果全为0,说明消息正确,否则,说明消息损坏。

    实现校验和的具体代码如下:

    1. // 校验和:每16位相加后取反,接收端校验时若结果为全0则为正确消息
    2. u_short checkSumVerify(u_short* msg, int length) {
    3. int count = (length + 1) / 2;
    4. u_short* buf = (u_short*)malloc(length + 1);
    5. memset(buf, 0, length + 1);
    6. memcpy(buf, msg, length);
    7. u_long checkSum = 0;
    8. while (count--) {
    9. checkSum += *buf++;
    10. if (checkSum & 0xffff0000) {
    11. checkSum &= 0xffff;
    12. checkSum++;
    13. }
    14. }
    15. return ~(checkSum & 0xffff);
    16. }

    确认重传

    确认重传包括差错重传和超时重传。

    差错重传就是在刚刚的差错检测部分,如果发现收到的ACK消息有错,则重新发送数据报,代码和上面一样,不再赘述。

    超时重传就是如果超出最大响应时间还没有收到ACK消息,则重新发送数据报。

    1. // 开始计时
    2. clock_t start = clock();
    3. // 发送消息......
    4. // 如果超时
    5. if (clock() - start > maxTime) {
    6. // 重新发送数据报......
    7. // 重新计时
    8. clock_t start = clock();
    9. }

    注意,差错重传和超时重传都要重新计时

    接收消息

    虽然本文章实现的是单向传输,有一个发送端和一个接收端,但是其实双方都需要发送和接收消息,有很多代码是类似的。

    接收消息的基本框架

    1. char *recvBuf = new char[maxSize + sizeof(h1)];
    2. memset(recvBuf, 0, sizeof(recvBuf));
    3. // 等待接收消息
    4. while (true) {
    5. // 收到消息需要验证校验和及序列号
    6. if (recvfrom(sockClient, recvBuf, maxSize+sizeof(h1), 0, (SOCKADDR*)&addrServer, &addrlen) > 0) {
    7. memcpy(&h1, recvBuf, sizeof(h1));
    8. HeadMsg h2;
    9. h2.type = ACK;
    10. char *sendBuf = new char[1024];
    11. memset(sendBuf, 0, sizeof(sendBuf));
    12. if (h1.seq == (lastAck+1)%256 && !checkSumVerify((u_short*)recvBuf, len)) {
    13. lastAck = (lastAck + 1) % 256;
    14. h2.seq = lastAck;
    15. h2.checkSum = checkSumVerify((u_short*)&h2, sizeof(h2));
    16. memcpy(sendBuf, &h2, sizeof(h2));
    17. sendto(sockClient, sendBuf, 1024, 0, (SOCKADDR*)&addrServer, sizeof(SOCKADDR));
    18. memcpy(data + totalLen, recvBuf + sizeof(h1), h1.len);
    19. totalLen += (int)h1.len;
    20. }
    21. else { // 差错重传
    22. h2.seq = lastAck;
    23. h2.checkSum = checkSumVerify((u_short*)&h2, sizeof(h2));
    24. memcpy(sendBuf, &h2, sizeof(h2));
    25. sendto(sockClient, sendBuf, 1024, 0, (SOCKADDR*)&addrServer, sizeof(SOCKADDR));
    26. }
    27. }
    28. }

    其中,需要特别注意lastAck的设置,一定是最后接收到的正确消息的序列号。 

    以二进制方式写文件

    在上述代码中,我们将接收到的数据存入data缓冲区,总长度为totalLen。所有的数据接收完毕后,我们需要将其写入指定位置。

    1. // 以二进制方式写入文件
    2. ofstream fout(file.c_str(), ofstream::out | ios::binary);
    3. if (!fout) {
    4. cout << "Error: cannot open file!" << endl;
    5. return false;
    6. }
    7. fout.write(data, totalLen);
    8. fout.close();

    其实三次握手和四次挥手本质上也是发送和接收消息。现在,大家可以把三次握手和四次挥手的代码更新一下,加上序列号、校验和、确认重传等,使协议更加完整。

    至此,我们完成了基于UDP协议的可靠传输。

    程序测试

    运行程序,可以看到三次握手的过程,成功建立连接。发送端输出消息提示用户输入需要传输的文件或者断开连接。

    我们随便选择一个数据,发送端很快开始发送文件,并输出相关信息。从左到右依次为,发送的数据长度(字节)、消息类型、序列号、校验和。最后输出发送的数据总长度、传输时间和吞吐率。

    查看接收端,同样也输出了相关信息。接收消息完毕后,成功写入文件。

    发送端断开连接,可以看到四次挥手的过程,成功断开连接。

    如果加上我们人为设置的丢包、损坏和延时,测试如下:

    可以看到接收端在很努力地搞破坏,而我们的发送端不辞辛劳地重传。

    本文章成功实现了基于UDP协议的可靠传输。但这个协议的设计还存在一些缺陷,例如,流量控制采用停等机制可能造成延时过长,没有设置拥塞控制等。后续将对这两部分进行改进,拟采用基于滑动窗口的流量控制机制,实现RENO算法的拥塞控制。

  • 相关阅读:
    基于大数据技术的健身用户消费分析系统
    猿如意|手把手教你下载、安装和配置PyCharm社区版
    【从头构筑C#知识体系】0.2 面向对象
    Java中ArrayList 和 LinkedList 的区别
    解决:git SSL certificate problem: unable to get local issuer certificate
    Libuv Timer定时器
    2024/4/24 C++day2
    开发者,微服务架构到底是什么?
    音视频开发第一课-使用C语言开发视频播放器 650元IT外包开发全程记录
    【Redis】redis入门+java操作redis
  • 原文地址:https://blog.csdn.net/Roslin_v/article/details/127823663