声明:禁止以任何形式转载本文章。本文章仅供个人学习记录与交流探讨,文章中提供的思路只是一种解决方案,代码也并非完整代码,如有需要,请自行设计协议并完成编程任务。
食用本文章之前,推荐阅读:C++实现流式socket聊天程序
目录
在C++实现流式socket聊天程序中,我们使用TCP协议传输数据,TCP实现的是可靠传输。但对于简单的交互应用和一些对延时敏感的应用来说,TCP需要握手挥手、维护连接状态、差错重传,这些都会增加延时。因此,这些应用通常使用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
- };
有了消息头之后,我们每次发送消息时都要设置好消息头,再加上要传输的数据。接下来,我们从最简单的三次握手和四次挥手开始。
在TCP中,三次握手的流程如下:
本文章的设计和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中,双方都可以先发送挥手,也就是断开连接的请求。你可以设计三次挥手,即把二三次挥手结合在一起发送;也可以设计二次挥手,一方请求断开连接则暴力断开连接;也可以制定哪一方先发送挥手。
本文章中,为了方便,设计发送端先发送挥手。
四次挥手的代码和三次握手类似:
- // 挥手
- 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消息,发送端会一直重新发送信息。因此,大家可以自行设计如何跳出发送消息,例如设置最大重传次数、最大等待时间等。
- // 设置信息头
- 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位相加后取反,接收端校验时若结果为全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算法的拥塞控制。