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

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

食用本文章之前,推荐阅读: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算法的拥塞控制。

  • 16
    点赞
  • 85
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
UDP是一种面向无连接的传输协议,它在传输数据时不提供可靠性,容易出现丢包、错包的情况。为了实现UDP可靠传输,可以采用以下策略: 1. 检验和:发送方在发送数据前,对数据进行检验和计算,并将计算结果附加在数据中。接收方在接收到数据后,重新计算检验和,并将计算结果与附加的检验和进行比对,如果不一致则认为数据损坏,需要重新发送。 2. 序列号和确认:发送方在发送每个数据包时都给数据包分配一个唯一的序列号。接收方在接收到数据包后,发送一个确认消息给发送方,告知发送方已成功接收到序列号为n的数据包。发送方在收到确认消息后,认为序列号为n的数据包已经可靠传输,可以发送下一个数据包。 3. 超时重传:发送方在发送数据包后设定一个定时器,如果在规定时间内没有收到接收方的确认消息,发送方就会认为数据包丢失,需要重新发送。 4. 滑动窗口:发送方可以同时发送多个数据包,接收方可以接收多个数据包。发送方和接收方分别维护一个窗口,窗口大小表示可以同时传输的数据包的数量。发送方根据接收方发送的确认消息来调整发送窗口的大小,以提高传输效率。 通过以上策略的组合应用,可以在UDP协议下实现可靠传输。当然,这些策略也会增加网络传输的开销和延时,需要根据具体业务需求进行权衡和选择。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值