avatar

计算机网络作业3-1 基于UDP服务设计可靠传输协议并编程实现

实验3:基于UDP服务设计可靠传输协议并编程实现

实验内容:

任务3-1:利用数据报套接字在用户空间实现面向连接的可靠数据传输,功能包括:建立连接、差错检测、确认重传。流量控制采用停等机制,完成给定测试文件的传输。

报文格式

image-20201213165003849
#pragma pack(1)//以下内容按1Byte对齐,如果没有这条指令会以4Byte对齐,例如u_short类型会用2B存信息,2B补零,方便后续转换成char*格式
struct message//报文格式
{
//ACK=0x01, SYN=0x02, FIN=0x04,EXIST 0x10,startfile 0x20,endfile 0x40
int flag;//标志位
DWORD SendIP, RecvIP;//发送端IP和接收端IP
u_short SendPort, RecvPort;//发送端端口和接收端端口
int msgseq;//消息序号
int ackseq;//恢复ack时确认对方的消息的序号
int index;//用于描述文件大小,需要多少条消息才能传输完成(1条index=0,2条index=1,以此类推)
int filelength;//本次实验使用固定报文长度,故只需要告诉对方有多少条和最后一条除首部信息外有多少位有效信息
int fill;//补零成为16bit的倍数,便于计算校验和
u_short checksum;//校验和
char msg[BUF_SIZE];//报文的具体内容,本次实验简化为固定长度
};
#pragma pack()//恢复4Byte编址格式

状态转换图与交互流程

image-20201213095611419

​ 由于本次实验采用停等机制,即每次client端发送一条消息后,需要等待对方回复==对应==的ACK,才能发送下一条消息,故client端和server端(发送端和接收端)状态转换图类似,这里用一张图说明。这里黑色部分为正常情况,蓝色部分为出现异常时的处理。

下面简要介绍交互大致流程,具体细节将在后面对应部分详述

client端

  • 初始状态0
  • 建立连接:发送一条带有标识SYN的消息,表示希望建立连接,对方收到后回复对应的ACK,连接建立成功。当收到ACK后进入状态1
  • 发送文件
    • 消息1,文件开始标识位SF=1index=发送文件需要的消息条数-1filelength=最后一条消息msg成员的有效位数。当收到对应的ACK后,进入状态2 。每条消息都是收到上一条对应的ACK后再发送。
    • 发送文件,每条msg的有效长度固定为1024,最后一条EF=1,收到ACK后退回状态1
  • 断开连接:发送一条标识为FIN的消息,对方收到后回复对应的ACK,断开连接成功,退回状态0
  • client端由于是主动发送的一端,一般情况下不会设计蓝色线条的状态跳转,但是可能会有超过重传次数情况下做出的断网处理

server端

​ - 初始:状态0

  • 建立连接:收到一条SYN,回复对应的ACK,连接建立成功,进入状态1

  • 发送文件:收到包含SFindexfilelength的消息,开始接收文件,进入状态2。对于每条消息都返回一条ACK 。当收到包含标志位EF的消息,检查index与收到文件消息的条数,若一致,则返回ACK,退回状态1

  • 断开连接:收到包含标识为FIN的消息,返回ACK,进入状态0

注:当server端和client端在一定时间内没有接收或不能接收到对方的消息,需要做断网处理,即自动退回到状态0

无异常情况下的发送流程

image-20201212234000727

上图描绘了在正常情况下的发送流程,由于本次采用停等机制,client端每次发送消息后,都要接收到对方回复的对应的ACK(即收到ACK消息的ackseq=发出消息的msgseq)才能发能发送下一条指令

建立连接

正常情况下的连接建立过程:

image-20201213095738718

​ 客户端发送带有标识SYN的消息x,接收到对方对消息x的确认消息ACK。由于只是单项传输,所以两次握手即可

出现异常情况下的处理过程

image-20201213100455796
  • ==注意==,为了处理server返回ACK丢包导致的两端状态不同步和其它异常情况,server端的三个状态均可以接收SYN请求,也均可以接收FIN断开请求
  • 消息①:ACK丢包,client端超过一段时间没收到ACK后重新发送SYN
  • 消息②:部分信息丢失或被篡改,即校验和出错,server端不返回ACK,client端检测到没有在规定时间接收到ACK后重新发送消息
  • 消息③:server端虽然返回了ACK,但由于某些原因client端没有在规定时间内接收到ACK,client端仍需要重传
  • 消息④:client端发出消息④后,在超时时限内收到了两条ACK,且ackseq相同,由于这两条ACK是对仅仅是由重传造成的相同消息的确认,故具有相同效力,取较早收到的ACK即可
int buildconnectionCli()//客户端,连接发起方
{
message a, b;
a.set_syn();//SYN=1
a.msgseq = sendseq++;//设置发送序号
if (stopwaitsend(a, b))return 1;
//stopwait函数功能为发送消息a,发生超时事件重传,如果收到了对应的ACK消息b则返回1,超过重传次数依然没有收到返回0
else return 0;//stopwait函数返回0,即没有收到对方返回的消息
}
int buildconnectionSer(message t)//服务端建立连接
{//message t为收到的带有syn标志位的消息
message a,b;
a.set_ack(t);//回复对于消息t的ACK
a.msgseq = sendseq++;//设置消息a的发送序号,这里的设置只是为了程序的统一性,由于只是单向传输,client端只需检查server端发送的ackseq字段不需要检查msgseq字段,所以不会用到
simplesend(a);//发送消息a
cout << "连接成功"<<endl;
return 1;//server端接收到syn消息就代表对于server端连接已经建立
}

连接断开

这里与连接建立类似,由于是单向传输,只需要两次挥手即可,程序逻辑与连接建立类似,client端发送含有FIN的消息,server端收到后回复对应的ACK,连接断开

差错重传

使用校验和方式检测是否出现差错,校验和计算方式与rdt3.0类似,只是根据本次实验设计的报文格式进行了简单修改。校验和计算包括0-255bit。

image-20201213165032779

计算方法如下

void message::setchecksum()//发送方计算校验和并填入响应位置
{
int sum = 0;
u_char* temp = (u_char*)this;//以1B为单位进行处理,注意设置为unsigned类型,否则在后续相加时会按照默认最高位是正负号,影响计算结果
for (int i = 0; i < 16; i++)//取报文的前16组,每组16bit,共计32字节256bit
{
sum += temp[2 * i] << 8 + temp[2 * i + 1];
while (sum >= 0x10000)
{//溢出
int t = sum >> 16;//将最高位回滚添加至最低位
sum += t;
}
}
this->checksum = ~(u_short)sum;//按位取反,方便校验计算
}
bool message::checkchecksum()//接收方对校验和进行检验
{
int sum = 0;
u_char* temp = (u_char*)this;
for (int i = 0; i < 16; i++)
{
sum += temp[2 * i] << 8 + temp[2 * i + 1];
while (sum >= 0x10000)
{//溢出
int t = sum >> 16;//计算方法与设置校验和相同
sum += t;
}
}
//把计算出来的校验和和报文中该字段的值相加,如果等于0xffff,则校验成功
if (checksum + (u_short)sum == 65535)
return true;
return false;
}

根据rdt3.0,当server端检测到校验和错误时,不发送NAK,也不返回ACK,只是等待client端超时重新发送

确认重传

前面在连接建立部分已经简要进行介绍,当client端无法在规定时间内接收到相应消息的ACK时,重传消息

主要涉及以下几种情况

  • server端检测校验和出错,不发送ACK

  • server端发送的ACK丢包

  • server端发送的ACK未在超时时限内收到

注:这里的recv函数非阻塞

bool stopwaitsend(message& a, message b)//a写入待发送消息,如果收到对方返回的ack则成功
{
simplesend(a);//发送消息a
clockstart = clock();//timer
int flag = 0;//重发超过10次退出
while (1)
{//这里的recv函数非阻塞
simplerecv(b);//接收对方发送的消息b
if (b.get_ack()&&b.ackseq==a.msgseq)//b包含对消息a的ack
{//已收到对于消息a的确认,返回1
return 1;
}
clockend = clock();
if (flag == SENT_TIMES)//重发10次依然失败
return 0;
if ((clockend - clockstart) / CLOCKS_PER_SEC >= WAIT_TIME)//超时
{
flag++;
clockstart = clock();//重置计时器
cout << "重传" << flag << endl;
simplesend(a);//重传
}
}
return 0;//0代表发送失败
}

停等机制

该机制下client端每发送一条消息,都需要等待接收到对方返回的ACK才能发送下一条,client端代码即解释见确认重传部分,这里对server端代码进行解释,即收到client端的消息且检测校验和正确后,向对方发送相应消息的ACK

bool stopwaitrecv(message& a, message b)//收到的消息写入a中
{
int flag = 0;
while (1)
{
simplerecv(a);//收到对方发来的消息a
if (a.get_exist())//因为将recv函数设成了非阻塞,所以需要检测收到的消息是否为空
{
int check = a.checkchecksum();//检测校验和
if (a.checkchecksum())//检测成功
{
b.set_ack(a);//回复对于收到消息a的ack消息b
b.msgseq = sendseq++;
simplesend(b);
memset((char*)&b, 0, sizeof(message));//防止干扰下一次消息接收
return 1;
}
}
}
cout << "接收失败" << endl;
return 0;
}

给定测试文件的传输

client端:

  • 读入文件至内存中
  • 文件起始消息SF置1,将发送文件所需要的消息条数index最后一条消息msg段有效位数填入报头中indexfilelength相应位置,msg成员填入文件名
  • 将内存中的消息复制到message类对象的msg段,逐条发送
  • 最后一条消息EF置1,告知对方文件发送结束
//读入文件
void readfile(char* name,char content[10000][1024],int &length, int & index)
{
index = 0;
length = 0;
ifstream in(name, ifstream::binary);//以二进制方式读入文件
if (!in)
{
cout << "文件无效" << endl;
return;
}
char t = in.get();
while (in)//如果读入失败(读入完成)则退出
{//将文件内容存入content数组中
content[index][length % 1024] = t;//每行1024个字节,对应msg成员长度为1024
length++;
if (length % 1024 == 0)//上一条缓冲区
{
index++; length = 0;
}
t = in.get();
}
in.close();
}
int sendfile(char* name)//发送文件
{
cout << "开始发送文件" << endl;
int length = 0;
int index = 0;
readfile( name,content,length,index);//读入文件
clock_t timestart = clock();//timer
//组装文件开始消息
message a;
a.index = index;
a.filelength = length;
int t = strlen(name);
for (int i = 0; i < t; i++)
a.msg[i]=name[i];//msg已经初始化全部为0
a.set_startfile();//SF=1
message b;
a.msgseq = sendseq++;
if (!stopwaitsend(a, b))//发送消息a,等待接收ACK,该函数包括超时重传等功能
{
cout << "文件传输失败" << endl;
return 0;
}
for (int i = 0; i <= index; i++)
{
message temp;
if (i == index)
{
temp.set_endfile();
//注意文件内容中会有'\0',不可以使用strcpy
for (int j = 0; j < length; j++)
temp.msg[j] = content[i][j];
}
else
{//消息固定长度为1024
for (int j = 0; j < 1024; j++)
temp.msg[j] = content[i][j];
}
temp.msgseq = sendseq++;//设置消息序号
if (stopwaitsend(temp,b)== 0)
{
cout << "文件发送失败" << endl;
return 0;
}
}
//吞吐率计算
clock_t timeend = clock();
double endtime = (double)(timeend - timestart) / CLOCKS_PER_SEC;
cout << "Total time:" << endtime << endl; //s为单位
cout << "吞吐率:" << (double)(index + 1) * sizeof(message) * 8/endtime /1024/1024<< "Mbps" << endl;
cout << "文件发送成功" << endl;
return 1;//发送成功
}

server端:

  • 接收到包含SF=1的消息,读出index、filelength和文件名,返回ACK
  • 循环接收对方发来的消息,将msg段复制到内存中,逐条返回ACK
  • 当收到的消息包含标识为EF时,表示该条消息msg段有效位数为filelength,将其复制到内存中。校验index与收到文件消息的条数,如果正确返回ACK
  • 将内存中的数据写入文件
int recvfile(message a)
{//a为包含SF的消息
message t;
t.set_ack(a);//回复对于该消息的确认
a.msgseq = sendseq++;
simplesend(t);
//获取文件基本信息
int index = a.index;
int length = a.filelength;
char name[30];
memset(name, 0, 30);
for (int i = 0; a.msg[i]; i++)
name[i] = a.msg[i];
for (int i = 0; i <= index; i++)
{
message b, c;
memset(content[i], 0, 1024);
//将收到的消息复制到内存中
if (stopwaitrecv(b, c))
{
if (i == index)
{
for (int j = 0; j < length; j++)
content[i][j] = b.msg[j];
}
else
{
for (int j = 0; j < 1024; j++)
content[i][j] = b.msg[j];
}
}
else
{
cout << "出错0" << endl;
return 0;
}
if (i == index)
{//消息条数校验
if(!b.get_endfile())
{
cout << "出错1" << endl;
return 0;
}
}
}
//将content中存储的内容输出到文件名为name的文件中
outfile(name, content, length, index);
cout <<name<< " 文件接收成功" << endl;
return 1;
}
void outfile(char* name, char content[50000][1024], int length, int& index)
{
ofstream fout(name, ofstream::binary);//以二进制方式写入文件
for (int i = 0; i < index; i++)
{
for (int j = 0; j < FILE_PACKET_LENGTH; j++)
fout << content[i][j];//以字节为单位进行写入
}
for (int j = 0; j < length; j++)
fout << content[index][j];
fout.close();
}

附录

程序执行界面展示

image-20201213110800797image-20201213110836519

socket的创建

头文件

#include <WinSock2.h>//windows socket 编程头文件
#pragma comment(lib,"ws2_32.lib")//链接ws2_32.lib库文件到此项目中
//加载socket库
WSADATA wsaData;
//MAKEWORD(2.2),成功返回0
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
{
cout << "socket初始化失败" << endl;
return 0;
}
//创建Socket
//创建一个socket,并将该socket绑定到一个特定的传输层
sock = socket(AF_INET, SOCK_DGRAM, 0);//地址类型(ipv4),数据报套接字
if (sock == INVALID_SOCKET)
{
cout << "socket创建失败";
return -1;
}

//设置recv函数为非阻塞
struct timeval timeout;
timeout.tv_sec = 1;//秒
timeout.tv_usec = 0;//微秒
if (setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (char*)&timeout, sizeof(timeout)) == -1) {
cout << "setsockopt failed:";
}
cout << "server3-1" << endl;
//初始化地址,代码支持输入IP,这里只演示了使用默认IP的情况
addrop.sin_addr.s_addr = inet_addr("192.168.89.1");
addrop.sin_family = AF_INET;//IPv4
addrop.sin_port = htons(SPORT);//端口,30000

addr.sin_addr.s_addr = inet_addr("192.168.89.1");
addr.sin_family = AF_INET;
addr.sin_port = htons(CPORT);//6666
//绑定
//强制类型转换,SOCKADDR_IN方便赋值 SOCKADDR方便传输
//server端需要bind而client端不需要
if (bind(sock, (SOCKADDR*)&addrop, sizeof(SOCKADDR)) == -1)
{
cout << "bind error" << endl; return -1;
}
......//程序执行,消息收发
closesocket(sock);//关闭socket
WSACleanup();
return 0;

消息的发送与接收

为了简化后续代码,将send和recv简化封装成函数

注意这里由于使用的是UDP,每次发送和接收都需要指明目的或者来源IP地址

void simplesend(message& a)
{
a.set_exist();//表示该条消息存在,方便非阻塞式recv判断是否接收到了消息
a.setchecksum();//设置校验和
//用sock发送a中的内容,注意第二个参数是char*,所以需要强制类型转换
//第三个参数为发送缓冲区长度,注意不能设置过长,否则会造成缓冲区溢出
//第四个代表相关操作,一般设置为0
//第五个是目的IP地址,即向哪个IP发送消息
//第六个是addr的长度
if (sendto(sock, (char*)&a, sizeof(message), 0, (struct sockaddr*)&addr, sizeof(sockaddr)) == SOCKET_ERROR);//如果发送失败,这里由于后续有确认重传机制,不在发送函数中进行处理
{
}
//打印每条发送报文标志位相关信息,方便观察传输过程,由于IO时间消耗很大,这里注释掉
//if (a.flag) { cout << "发送 "; a.print(); }
}

void simplerecv(message& a)
{
memset(a.msg, 0, sizeof(a.msg));//初始化全为0,防止收到前面收到消息的影响
//参数与sendto函数类似,只是第五项的目的IP地址改为源IP地址,即从哪个IP接收消息
recvfrom(sock, (char*)&a, sizeof(message), 0, (struct sockaddr*)&addr, &addr_len);
//if (a.flag) { cout << "接收 "; a.print(); }
}

支持用户输入IP

//这里仅以一端作为演示
cout << "是否使用默认IP?是1,否2 ";
int i;
cin >> i;
if (i == 2)
{
char s[20] = {};
char c[20] = {};
cout << "请输入服务器IP: " ;
cin >> s;
cout << "请输入客户端IP: ";
cin >> c;
addrop.sin_addr.s_addr = inet_addr(s);
addr.sin_addr.s_addr = inet_addr(c);
}

message类

初始化

message::message() {
memset(this, 0, sizeof(message));//初始化全0
SendPort = SPORT;//这里以server端为例
RecvPort = CPORT;
SendIP = addr.sin_addr.s_addr;
RecvIP = addrop.sin_addr.s_addr;
}

标志位的获取与设置

int message::get_syn()
{
if (this->flag & 0x02)//flag的右第二位是0还是1
return 1;
else return 0;
}
void message::set_ack(message b)//ack需要指明是对哪条消息的确认
{
if (get_ack() == 0)
flag += 0x01;//一个标识位只需要1bit
this->ackseq = b.msgseq;
}
void message::set_syn()
{
if (get_syn() == 0)
flag += 0x02;
}

消息处理流程

server:

while (1)
{
message b;
simplerecv(b);
if (b.get_exist())
{
if(!tackle(b))break;//对收到的消息b进行处理,当server端选择退出时时返回0
}
Sleep(20);//防止频繁接收空消息占用大量CPU资源
}
int tackle(message b)//处理收到的报文
{
if (b.get_syn())//如果b中包含syn,则执行建立连接相关部分
{
if (buildconnectionSer(b))
{
status = 1;//设置状态
cout << "建立连接" << endl;
}
else cout << "连接建立失败" << endl;
}
else if (b.get_startfile())
{
if (status)
{
cout << "开始接收文件" << endl;
recvfile(b);
}
else cout << "请先建立连接" << endl;
}
if (b.get_fin())//如果client请求断开连接
{//这里可能出现双方状态不一致的情况,所以server端支持所有状态下的断开连接请求
if (byeser(b))
{
status = 0;
sendseq = 0;//发送序号重置
}
else cout << "未连接" << endl;
cout << "对方已经断开连接,是否结束程序?0退出,1不退出 ";
int i;
cin >> i;
if (i == 0)
return 0;
}
return 1;
}

client

while (1)
{
int op;
cout << "请选择操作:传输文件1,退出0 " << endl;//用户进行操作选择
cin >> op;
if (op == 0)
break;
else if (op != 1)
cout << "无效操作" << endl;
else if (op == 1)
{//选择发送文件
cout << "请输入文件名 " << endl;
char name[30] = {};
cin >> name;
//建立连接,发送文件,断开连接
if (buildconnectionCli())
{
if (sendfile(name))
{
cout << "文件发送成功" << endl;
}
else cout << "文件发送失败" << endl;
}
else cout << "连接建立失败" << endl;
if (byecli())
{
cout << "连接已断开" << endl;
sendseq = 0;//发送序号置零
}
else cout << "连接断开失败" << endl;
}
sendseq = 0;//发送序号置零
}
Author: Michelle19l
Link: https://gitee.com/michelle19l/michelle19l/2021/01/15/大三上/计网/计网作业3-1/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
Donate
  • 微信
    微信
  • 支付寶
    支付寶