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

2023-11-18

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

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

目录

UDP协议的基本框架

程序实现

消息类型

三次握手

四次挥手

发送消息

以二进制方式读文件

发送消息的基本框架

差错检测

确认重传

接收消息

接收消息的基本框架

以二进制方式写文件

程序测试


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

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

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

UDP协议的基本框架

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

以Server服务器为例:

// 加载环境
WSADATA wsaData;
WSAStartup(MAKEWORD(2, 2), &wsaData);

// 创建数据报套接字
SOCKET sockServer = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);

// 绑定ip地址和端口
sockaddr_in addrServer;
memset(&addrServer, 0, sizeof(sockaddr_in));
addrServer.sin_family = AF_INET;
addrServer.sin_port = htons(8000);
addrServer.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
bind(sockServer, (SOCKADDR*)&addrServer, sizeof(SOCKADDR))

// 客户端地址
sockaddr_in addrClient;

// 接收和发送消息,注意这里的recvfrom是阻塞函数
int len = sizeof(SOCKADDR);
while(true){
    recvfrom(sockSrv, recvBuf, 1024, 0, (SOCKADDR *)&addrClient, &len);
    sendto(sockSrv, sendBuf, 1024, 0, (SOCKADDR *)&addrClient, len);
}

// 关闭监听套接字
closesocket(sockServer);

//清理环境
WSACleanup();

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

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

程序实现

消息类型

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

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

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

三次握手

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

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

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

主动握手的代码如下:

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

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

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

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

等待握手的代码如下:

char *recvBuf = new char[1024];
memset(recvBuf, 0, sizeof(recvBuf));
int addrlen = sizeof(SOCKADDR);
HeadMsg h2;
while (true) {
	if (recvfrom(sockClient, recvBuf, 1024, 0, (SOCKADDR*)&addrServer, &addrlen) > 0) {
		memcpy(&h2, recvBuf, sizeof(h2));
		if (h2.type == SYN_ACK) {
			cout << "Server: [SYN, ACK] Seq=0 Ack=1" << endl;
			break;
		}
		else {
			return false;
		}
	}
}

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

四次挥手

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

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

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

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

// 挥手
HeadMsg h1;
h1.type = FIN;
char *sendBuf = new char[1024];
memset(sendBuf, 0, sizeof(sendBuf));
memcpy(sendBuf, &h1, sizeof(h1));
if (sendto(sockServer, sendBuf, 1024, 0, (SOCKADDR*)&addrClient, sizeof(SOCKADDR)) == -1) {
	return false;
}
else {
	cout << "Server: [FIN, ACK] Seq=n" endl;
}

// 等待挥手
char *recvBuf = new char[1024];
memset(recvBuf, 0, sizeof(recvBuf));
int addrlen = sizeof(SOCKADDR);
HeadMsg h2;
while (true) {
	if (recvfrom(sockServer, recvBuf, 1024, 0, (SOCKADDR*)&addrClient, &addrlen) > 0) {
		memcpy(&h2, recvBuf, sizeof(h2));
		if (h2.type == ACK) {
			cout << "Client: [ACK] Seq=n"<< " Ack=m" << endl;
			break;
		}
		else {
			return false;
		}
	}
}

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

发送消息

以二进制方式读文件

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

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

发送消息的基本框架

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

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

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

// 设置信息头
HeadMsg h;
h.seq = curSeq;
h.len = packLen;
h.type = PSH;
char *sendBuf = new char[h.len+sizeof(h)];
memset(sendBuf, 0, sizeof(sendBuf));
memcpy(sendBuf, &h, sizeof(h));
// data存放的是读入的二进制数据,sentLen是已发送的长度,作为分批次发送的偏移量
memcpy(sendBuf + sizeof(h), data + sentLen, h.len);
sentLen += (int)h.len;
// 计算校验和
h.checkSum = checkSumVerify((u_short *)sendBuf, sizeof(h) + h.len);
memcpy(sendBuf, &h, sizeof(h));

// 发送消息
if (sendto(sockServer, sendBuf, h.len + sizeof(h), 0, (SOCKADDR*)&addrClient, sizeof(SOCKADDR)) == -1) {
	cout << "Error: fail to send messages!" << endl;
	return false;
}

// 等待接收消息
char *recvBuf = new char[1024];
memset(recvBuf, 0, sizeof(recvBuf));
int addrlen = sizeof(SOCKADDR);
while (true) {
	if (recvfrom(sockServer, recvBuf, 1024, 0, (SOCKADDR*)&addrClient, &addrlen) > 0) {
		// 收到消息需要验证消息类型、序列号和校验和
		else {	
            // 差错重传并重新计时
		}
	}
	else {   
        // 超时重传并重新计时
	}
}

差错检测

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

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

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

// 初始化8位序列号
unsigned char seq = 0;

// 每次成功发送消息后,序列号+1,但是要注意序列号空间有限
seq = (seq + 1) % 256;

// 收到消息时检查序列号
if(h.seq == seq)

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

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

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

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

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

确认重传

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

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

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

// 开始计时
clock_t start = clock();

// 发送消息......

// 如果超时
if (clock() - start > maxTime) {
    // 重新发送数据报......
    // 重新计时
	clock_t start = clock();
}

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

接收消息

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

接收消息的基本框架

char *recvBuf = new char[maxSize + sizeof(h1)];
memset(recvBuf, 0, sizeof(recvBuf));
// 等待接收消息
while (true) {
	// 收到消息需要验证校验和及序列号
	if (recvfrom(sockClient, recvBuf, maxSize+sizeof(h1), 0, (SOCKADDR*)&addrServer, &addrlen) > 0) {
		memcpy(&h1, recvBuf, sizeof(h1));
		HeadMsg h2;
		h2.type = ACK;
		char *sendBuf = new char[1024];
		memset(sendBuf, 0, sizeof(sendBuf));
		if (h1.seq == (lastAck+1)%256 && !checkSumVerify((u_short*)recvBuf, len)) {
			lastAck = (lastAck + 1) % 256;
			h2.seq = lastAck;
			h2.checkSum = checkSumVerify((u_short*)&h2, sizeof(h2));
			memcpy(sendBuf, &h2, sizeof(h2));
			sendto(sockClient, sendBuf, 1024, 0, (SOCKADDR*)&addrServer, sizeof(SOCKADDR));
			memcpy(data + totalLen, recvBuf + sizeof(h1), h1.len);
			totalLen += (int)h1.len;
		}
		else {	// 差错重传
			h2.seq = lastAck;
			h2.checkSum = checkSumVerify((u_short*)&h2, sizeof(h2));
			memcpy(sendBuf, &h2, sizeof(h2));
			sendto(sockClient, sendBuf, 1024, 0, (SOCKADDR*)&addrServer, sizeof(SOCKADDR));
		}
	}
}

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

以二进制方式写文件

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

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

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

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

程序测试

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

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

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

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

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

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

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

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

C++实现UDP可靠传输(一) 的相关文章

随机推荐

  • Cadence Gerber文件制作过程

    概述 本人使用Cadence 17 4版本 在这做下笔录 介绍下Gerber文件制作过程 Gerber文件的作用 相信画过板子的人都知道 Layout PCB设计后 需要把资料给制作PCB板厂商 同时也能让自己存档作用 好了 下面只要讲解使
  • 基于Pytorch版本的T2T-ViT+ArcFace的人脸识别训练及效果

    目录 一 前言 二 训练准备 1 T2T ViT的Pytorch版本 2 人脸识别数据和代码架构 3 完整训练代码 三 训练和结果 1 训练 2 结果 一 前言 最近 将transformer在CV领域中新出现的T2T ViT模型修改 再加
  • 我的

    CangLongHead22E5229DEF23ED8E0BF2C55E698486BD0D1C555F1275F5D8BD6553F4A1FEF5EA623255CE7EA69C4D729AA0D76938EF3346260603DB47
  • 两层板PCB如何设计的?

    两层板PCB如何设计的 三层板的PCB又是如何设计的 https blog csdn net qq 42053636 article details 89577815 来自专治PCB疑难杂症总群 四大群群友突破1800人啦 添加杨老师微信号
  • 安卓应用开发入门!Android高级工程师系列学习路线介绍,灵魂拷问

    从基础到架构进阶 包含了腾讯 百度 小米 阿里 乐视 美团 58 猎豹 360 新浪 搜狐等一线互联网公司面试被问到的题目 涵盖了初中高级安卓技术点 文章中所列主要为大纲部分 详细内容可以在文末自行获取哈 如果你熟练掌握本文中列出的知识点
  • Qt 笔记4--Qt 读写CSV

    Qt 笔记4 Qt 读写CSV CSV Comma Separated Values 即逗号分隔值 有时也称为字符分隔值 因为分隔字符也可以不是逗号 其文件以纯文本形式存储表格数据 CSV是一种通用的 相对简单的文件格式 被用户 商业和科学
  • 1067:整数的个数(C C++)

    题目描述 给定k 1
  • 【八股】2023秋招八股复习笔记1(CSBase+部分WXG题)

    文章目录 MYSQL redis 网络 系统 安全 C 招聘要求 x3 部分面经和题目 WXG 后端 x5 MYSQL redis redis memcached mysql 线程模型 6 0多线程 持久化 AOF RDB 功能 过期删除
  • aspx页面添加引用代码

  • Windows远程桌面连接报内部错误

    远程桌面连接出现了内部错误解决方法 1 运行里输入ncpa cpl命令 打开网络连接 2 禁用 启用一下 当前的网卡 3 再通过命令 mstsc 打开远程桌面服务 报错问题解决
  • 基于LabVIEW的音频信号采集分析系统

    本设计基于LabVIEW虚拟仪器开发软件 用PC的声卡与外接麦克风组合采集到外界的声音信息 并保存到WAV文件中 再利用LabVIEW软件进行编程来对采集到的信号进行分析处理 能够显示采集到的波形 滤波后的波形以及其幅度 相位谱和功率谱波形
  • 一个简单的基于epoll的web server

    一个简单的基于epoll的web server 性能还不错我根据一个epoll的模型改了一个http server出来 只有129行 还可以精简不少 呵呵 小测了一下 一秒钟处理了一万了请求 当然这里只是把现成的东西输出 没考虑到发送数据处
  • Qt多国语言动态切换(含源代码)

    Qt中文国际化 含高阶做法 作者 melon 日期 2019 7 15 1 国际化需要用到的工具 lrelease exe lupdate exe linguist exe 非必须 这些工具在Qt5 12 2的bin文件夹都可以找到 lup
  • Hibernate用法:查询,更新,删除!

    一 基本数据查询 使用Hibernate进行数据查询是一件简单的事 Java程序设计人员可以使用对象操作的方式来进行数据查询 查询时使用一种类似SQL的HQL Hibernate Query Language 来设定查询的条件 与SQL不同
  • Redis工具类

    public class RdsUtils Resource private static RedisTemplate redisTemplate 设置键值对 param key 键 param value 值 return public
  • Word中批量更新域的两个小方法

    如果只有一个需要更新 对着域右键选择 更新域 即可 很多需要更新的时候 可以如下操作 两种方法应该都可以 1 选择 打印预览 可以更新文档中的所有MOS认证的老师教的 2 CTRL A 全选 然后F9 更新 即可 自己觉得很好用的 批批更新
  • C#密码复杂性校验(二)

    以下是一个使用正则表达式进行密码复杂性校验的示例代码 using System using System Text RegularExpressions class Program static void Main string args
  • 《Unity Shader入门精要》彩图版免费分享~~~~~

    这书很多地方都要币或者要钱 这里就免费分享了 下面是网盘链接 顺手点个赞或者评论一波呗 下载链接 链接 https pan baidu com s 137Y1nkB6h8HIvKOfwFPnbQ 提取码 f8dw 顺手点个赞 蟹蟹蟹蟹
  • 测试人社区——软件测试技术沙龙分享

    作为软件开发领域中至关重要的一环 软件测试的重要性日益凸显 然而 随着软件测试开发技术的不断发展 软件测试也面临着越来越多的挑战 为了更好地应对这些挑战 测试人社区于2023年3月12日举办了技术沙龙 主题为 探索软件测试前沿技术及最佳实践
  • C++实现UDP可靠传输(一)

    声明 禁止以任何形式转载本文章 本文章仅供个人学习记录与交流探讨 文章中提供的思路只是一种解决方案 代码也并非完整代码 如有需要 请自行设计协议并完成编程任务 食用本文章之前 推荐阅读 C 实现流式socket聊天程序 目录 UDP协议的基