代码:https://download.csdn.net/download/weixin_55771290/87428996
一、整体架构
1.1 网络拓扑
我们的网络拓扑模型共设有 7 个网元:1 台路由器、2 台交换机、4 台主机。各网元间形成树形拓扑,通过课程提供的物理层模拟软件进行互联。
主机的网元分为三层,分别是应用层、网络层与物理层;而路由器与交换机,由于不需要与用户进行 I/O 操作,不设应用层。各层间通过(手动或自动)设定的端口进行通信。
网元各层的主要功能如下:
- 应用层
- 决定网元模式
- 信息 I/O
- 编解码
- 网络层(主机)
- 帧同步与定位
- 地址读写
- 序号读写
- 差错检测
- 差错控制
- 流量控制
- 网络层(交换机)
- 监听各端口消息
- 维护端口地址表
- 多主机信息交换
- 物理层
- 连接起各网元
- 模拟误码
- 模拟 MTU
- 添加时钟信号等冗余位
1.2 帧结构
在我们的设计中,网元间以帧为单位交换信息;帧内除了用户发送的数据,还有网络层添加的各种控制信息,用于实现差控、流控、判收等功能。
上图是我们组设计的帧结构。它包括以下这几部分:
- 帧头、帧尾(8 位)
- 源地址、目的地址(16 位)
- 帧序号(8 位)
- 数据(32 位,只能少不能多)
- 校验和(16 位)
- 冗余(位数不等,帧同步的副产物)
具体每一部分的功能、原理与实现见第 3 章。
1.3 代码目录结构
程序使用 C++ 编写,网元的每一层是一个 .cpp 文件,交换机、路由器的网络层单独编写一个 .cpp 文件;另外编写有一些封装类、函数的头文件、源文件。
二、应用层
在整个网元中,应用层主要承担着三部分职责:
- 决定网元模式
- 控制整个网元处于接收还是发送模式。
- 信息 I/O
- 发送端:读取用户想传输的消息;
- 接收端:输出用户可辨识的消息。
- 编解码
- 发送端:Unicode 字符 →01 字符串;
- 接收端:01 字符串 →Unicode 字符。
下面将分别展示这三种功能。
2.1 决定网元模式
由于技术限制,本项目中的网元是半双工模式,即同时只能处于收/发状态中的一种。这一选择将通过用户手动输入来激活,然后应用层负责将用户的选择通知到整个网元。整体的逻辑如下。
while(true){if(mode==RECV){// 网元成为接收端。 }elseif(mode==UNICAST||mode==BROADCAST){// 网元成为发送端。 }elseif(mode==QUIT){// 退出程序。 }else{// 无效选项。 }}
2.2 信息 I/O
为了操作的便利,本项目中将使用 string 类型,进行绝大部分字符操作。相应地,信息的 I/O 只需要调用 cin 和 cout 就能够实现。
2.3 编解码
由于项目需要提供对中文 I/O 的支持,所以显然 ASCII 码无法满足项目的需求,而是需要针对 Unicode 字符设计编解码方案。方案如下图所示:
其中,MultiByteToWideChar() 和 WideCharToMultiByte() 来自 windows.h 头文件。在实际中,一个 Unicode 字符可以占到三个字节,但本项目的需求没有那么高,两个字节就能够实现需求,即一个 Unicode 字符对应 16 位二进制数。
具体代码可以在 include/coding.cpp 中找到,该头文件留下 4 个 API 供应用层(与其他层)调用:
- decToBin():将十进制数转化为 01 字符串;
- binToDec():将 01 字符串转化为十进制数;
- encode():将可读字符串编码为 01 字符串;
- decode():将 01 字符串解码为可读字符串。
2.4 代码框架
细化 2.1 节的逻辑后,我们很容易得出应用层的代码框架:
intmain(intargc,char*argv[]){// 变量、网络库与套接字的初始化。 while(true){// 选择当前模式。 if(mode==RECV){// 通知网络层正在接收。 // 从网络层接收消息。 }elseif(mode==UNICAST||mode==BROADCAST){// 通知网络层正在发送。 if(mode==UNICAST){// 如果是单播,还需要额外输入目标端口。 }// 通知要发的消息。 }elseif(mode==QUIT){// 通知下层退出。 break;}else{// 无效选项,报错。 }}}
2.5 阶段一调试
为了给接下来的阶段做铺垫,我们需要先写两个简单的应用层,对网元间通信的方式、网元工作的模式有一定的理解。对于项目指导书中的需求,下面是我们测试的结果。
项目需求: 客户端定时每 500ms 向服务器发送一个随机整数,范围在 1~500 之间; 服务器每收到一份数据也同时产生随机整数与收到的数据相加,只有在结果大于 100 时才会把计算结果返回给客户端,而客户端收到超出 100 的结果则立即产生一个新的数据,而不是在间隔 500ms 后。 客户机需要产生 20 份数据,如果有超过 100 的结果,总运行时间应接近 10-N*0.5,N 为超过 100 的结果的数量。
可以看到,服务端与客户端之间能够进行稳定的通信,客户端通过 select() 实现了超时的判断,实际运行时间与预期时间(10-N*0.5)相符。
通过这个程序,我们了解了控制超时的两种方法:setsockopt() 和 select(),同时也知道如何基于不同事件做出不同的响应,对网元间通信的形式、时序等有了进一步了解。
三、网络层(主机)
在整个网元中,网络层(主机)的功能最多、最重要,主要分为六部分:
- 帧同步与定位
- 发送端:让接收端在杂乱的 01 序列中,找到有用的信息。
- 地址读写
- 发送端:写入源、目的地址,用于交换、路由的实现;
- 接收端:读取源、目的地址,知道信息从哪来、是不是给自己的。
- 序号读写
- 双端:防止传送时的帧间乱序,也用于差错控制协议的实现;
- 差错检测
- 接收端:检查信息有没有传错,如果出错就要求重传。
- 差错控制
- 发送端:如何实现某一帧的重传;
- 接收端:如何让发送端知道要不要重传。
- 流量控制
- 双端:防止自己发得太快,导致网络来不及处理、对方来不及读取……等后果。
下面我们将先展示帧的结构,然后分别展示这六种功能。
3.1 帧的结构
为了方便对帧的各部分操作、解析 01 字符串为帧、转换帧为 01 字符串,我们利用 C++“面向对象”的特点,将帧作为一个类。
帧各部分的数据,作为帧的私有属性存储;对帧的操作,作为帧的公共函数绑定。下面是这个类的结构:
classFrame{private:unsignedshortsrcPort;unsignedshortseq;stringdata;unsignedshortdstPort;unsignedshortchecksum;boolverified;stringcheckTarget;stringextractMessage(stringraw);staticstringtransform(stringmessage);staticunsignedshortgenerateChecksum(stringmessage);staticstringaddLocator(stringmessage);public:Frame();Frame(stringraw);Frame(unsignedshortsrcPort,unsignedshortseq,stringdata,unsignedshortdstPort);~Frame();unsignedshortgetSrcPort();unsignedshortgetSeq();stringgetData();unsignedshortgetDstPort();boolisVerified();stringstringify();staticintcalcTotal(intmessageLen);};
我们需要额外关注这两个函数:
Frame(string raw):直接把 01 字符串解析为帧。
stringify():直接把帧转换成 01 字符串。
它们高度的封装性与实用性,使得网络层代码的描述性、可读性变得更强,逻辑也更加清晰。具体的代码可以在 include/frame.cpp 中找到。
3.2 帧同步与定位
采用面向位的首尾定界法。
3.2.1 基本原理
- 发送端:变换,添加帧头帧尾。
在一帧的首尾加上 0111 1110,以标识帧的始末位置;
帧内的信息也有可能出现 0111 1110 的序列,所以为了防止接收端把帧内信息误当作帧尾,发送端还要在帧内的每个 11111 后面插一个 0,以免帧内出现 0111 1110 子序列。
- 接收端:找到帧头,反变换。
- 在物理层收到的乱码中,找到帧头 0111 1110,然后把帧头剥落;
- 对于接下来出现的每个 11111 子序列:
- 如果接下来出现的是 0,那这个 0 肯定是发送端插的,删掉还原。
- 如果接下来出现的是 1,那这就是帧尾 0111 1110。(因为发送方已经保证了帧内不可能出现连续 6 个 1。)
3.2.2 代码实现
我们主要基于 KMP 算法进行子串定位,然后封装了下面三个函数实现帧同步与定位功能:
- addLocator():实现上述发送端的任务 1;
- transform():实现上述发送端的任务 2;
- extractMessage():实现上述接收端的任务 1、2。
具体的代码可以在 include/frame.cpp 的 Frame 类中找到。
3.3 地址读写
采用 16 位二进制数标识地址。
3.3.1 取 16 位的原因
由于本项目的网元间通信只在本机(127.0.0.1)实现,所以只需要封装源与目的地的端口即可。又因为端口范围是 0~65535,所以每个端口需要用 16 位二进制表示。
3.3.2 代码实现
发送端只需要使用简单的字符串拼接,即可把地址写入帧;接收端也只需要用 string 类的 substr() 方法,就可以提取地址信息。不再展开叙述。
3.4 序号读写
采用 8 位二进制数标识序号。
3.4.1 取 8 位的原因
项目需求提出,传输数据上限约 50 个字符;又根据 2.3 节得出的结论:一个字符为 16 位,所以一段消息最多有 800 位。
一帧最多传输 32 位数据,所以一段消息最多要用 25 帧,才能传输完毕。
又为了校验和的产生方便(见 3.5 节),序号位数需要是 8 的倍数——最少就是 8 位(范围 0~255),已经有充裕的空间标识每一帧。综上,需为序号分配 8 位的空间。
3.4.2 代码实现
序号读写与地址读写相似,只需要简单的拼接和 substr() 即可实现。不再展开叙述。
3.5 差错检测
采用 16 位校验和,不纠错。
3.5.1 基本原理
- 发送端:产生校验和。
- 将前面的源地址、序号、数据、目的地址这四部分的 01 序列拼在一起,每 8 位视作一个整数;
- 全部加起来,得到一个整数;
- 再变成 01 序列,作为校验和。
- 接收端:检验校验和。
- 提取出源地址、序号、数据、目的地址这四部分信息;
- 使用与发送端同样的方法加和;
- 与校验和比较是否相同,相同即验证通过。
3.5.2 视每 8 位为一个整数的原因
- 前四部分最多有 16+8+32+16=72 位;
- 如果使用经典的 Checksum 生成方法,即视 16 位为一个整数,首先 72 无法整除,带来额外麻烦;其次,得出的和有可能超过 65535,校验和不止 16 位,占用更大空间;
- 而如果视 8 位为一个整数,首先 72 能够整除,方便程序实现;其次,和最多只有(72÷8)×255=2295,16 位能够轻松表示。
3.5.3 代码实现
我们封装了函数 generateChecksum(),实现了对任意(长度为 8 的倍数的)01 字符串的校验码生成。
具体的代码可以在 include/frame.cpp 的 Frame 类中找到。
3.6 差错控制
采用停等协议。
3.6.1 基本原理
- 发送端:发送了一帧消息,等待接收端回复;
- 接收端:
- 如果超时,则回复 NAK。
- 如果收到了,但是重复了,则丢弃并回复 ACK;
- 如果收到了,并且校验通过,则回复 ACK;
- 如果收到了,但是校验失败,则回复 NAK;
- 发送端:
- 如果超时,则重传这一帧。
- 如果收到了 ACK,则继续发下一帧;
- 如果收到了 NAK,则重传这一帧;
- 如果收到的既不是 ACK 也不是 NAK,则重传这一帧;
- 回到第 1 步,直到传完所有帧。
3.5.2 采用停等协议的原因
- 可以顺便控制流量:发送端需要等回复,所以不会发得太快;
- 编程难度大大降低:只需要实现简单时序逻辑。
3.6.3 代码实现
根据 3.6.1 节所展示的时序,我们可以搭建出双端代码差错控制协议的框架:
注:本处代码暂不考虑广播与单播判收。
- 发送端
for(intframe=0;frame<=sendTotal;){// 发送一帧。 // 接收对方的回复。 if(timeout){// 重传。 continue;}if(response==ACK){// 可以发送下一帧。 ++frame;}elseif(response==NAK){// 重传。 }else{// 重传。 }}
- 接收端
for(intframe=0;frame<=recvTotal;){// 接收一帧。 if(timeout){// 回复NAK。 continue;}// 检查目标端口。 if(notForMe){// 既不回复也不接收。 continue;}// 检查序号。 if(isRepeat){// 回复ACK但不接收。 continue;}// 检查校验和。 if(!isVerified){// 回复NAK且不接收。 continue;}// 接收这一帧。 // 回复ACK。 ++frame;}




