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;}
3.7 流量控制
采用 Sleep() 函数。
3.7.1 基本原理
如果发送端发的速度过快,那么有可能导致:
- 发送端口来不及发;
- 网络来不及传;
- 接收端口来不及收;
- 接收端来不及处理。
- ……
所以,在调用 sendto() 函数前,让程序先睡眠适当的时间,就可以做到:等上一波信息完全发出去之后,再发这一波信息。
3.7.2 代码实现
只需要在 sendto() 的上一行调用 Sleep() 即可。
这一操作被封装在了各层对应的 Socket 类内,具体的代码可以在 include/socket.cpp 中找到。
3.8 代码框架
将以上所有的功能配合起来,再对广播模式做出一些适配,我们就可以得到网络层的代码框架:
intmain(intargc,char*argv[]){// 变量、网络库与套接字的初始化。 while(true){// 上层通知当前模式。 if(mode==RECV){for(intframe=0;frame<=recvTotal;){// 接收一帧。 if(timeout){// 如果超时没收到消息,回复NAK。 continue;}// 检查目标端口。 if(notForMe){// 如果发来的帧不是给自己的,既不回复也不接收。 continue;}// 检查序号。 if(isRepeat){// 如果重复了,回复ACK但不接收。 continue;}// 检查校验和。 if(!isVerified){// 如果校验失败,回复NAK且不接收。 continue;}// 接收这一帧。 // 回复ACK。 ++frame;}// 把拼接完的消息交给应用层。 }elseif(mode==UNICAST||mode==BROADCAST){// 确定目标端口。 // 确定要发的消息。 // 计算要分多少帧。 // 逐帧封装。 for(intframe=0;frame<=sendTotal;){// 发送一帧。 // 接收对方的回复。 // 确定要收几次回复。 for(inti=0;i<recvNum;i++){if(timeout){// 如果超时没收到回复,重传。 continue;}if(response==ACK){// 如果收到了ACK,可以发送下一帧。 ++ackTimes;}elseif(response==NAK){// 如果收到了NAK,重传。 }else{// 如果收到了其它信息,重传。 }}// 如果每个接收端都ACK了,就可以发下一帧。 if(ackTimes==recvNum){++frame;}}// 全部发完,封装的帧可以丢弃。 }elseif(mode==QUIT){break;}}}
3.9 阶段二调试
根据上面的代码框架,我们在阶段二写出了应用层与网络层,并使用物理层模拟软件模拟了信道,在两个网元间进行了测试,结果如下。
3.9.1 Unicode 字符的 I/O
在本测试中,用户发送的字符串“你好,test.”中同时包含了中文、英文、全半角符号。可以看到,双方可以完成正常的信息收发,应用层能够正确编解码,网络层也能够逐帧发送与确认。
3.9.2 差错的检测与重传
在本测试中,我们设置物理层误码率为十万分之 1000,即 1%。通过网络层多次的检验、回复、重传,应用层最终能够呈现出正确、完整的字符。并且误码率还能够进一步增大。
四、网络层(交换机)
在网络拓扑中,交换机负责在多个主机间交换信息,从而让广播成为可能,同时也减少了 P2P 通路的数量。它的功能主要分为以下三部分:
- 监听各端口消息
- 时刻注意有没有端口发来消息。
- 维护端口地址表
- 维护本地端口-远程端口的对照表,并通过收发信息时截获端口号进行学习。
- 多主机信息交换
- 找到到达目的地的路径,依此在不同网元间转发信息。
下面我们将分别展示这两种功能。
4.1 监听各端口消息
由于交换机需要同时管辖多个物理层,而且需要同时完成收发,所以不能用主机网元的半双工模式。我们考虑采用 select() 方法,轮流监听各个端口是否有消息到达。整体的逻辑如下。
for(inti=0;;i=(i+1)%num){// 使用select()检查端口i的可读性。 if(!readable){// 如果端口i不可读,则检查下一个。 continue;}// 如果端口i可读,就读取信息并进行转发。 }
4.2 维护端口地址表
对于主机而言,它的信息只有一条路可走——就是发到自己的物理层,然后交给交换机。但对于交换机而言,它的信息有不止一条路可走。直接广播给所有端口然后让它们自行判收,显然是浪费信道资源的一种做法,我们更希望交换机自己能够记住,发给谁的信息要走哪个端口。
这就需要它内部维护一张对照表,将本地自己的物理层端口与远程其他主机的应用层端口联系起来。我们使用 map<unsigned short, unsigned short> 类型对此进行管理。整体的逻辑如下。
// 获取截获的帧的源与目的地址。 // 查表获取这个本地端口有没有注册过源地址。 if(srcPortNotInTable){// 如果表里没有这个源地址,就将这个源地址和本地端口联系起来。 }// 查表获取目的地址对应的本地端口。 if(dstPortNotInTable){// 如果表里没有这个目的地址,就向所有端口广播。下一轮回复的时候就能学习了。 }
4.3 多主机信息交换
这是交换机最基础、最本质的功能。它需要判断消息发送的形式(单播或广播),然后据此采取相应的行动。整体的逻辑如下。
if(isBroadcast||portNotFound){// 如果是广播,或者没找到目的地址对应的本地端口,就给所有端口发消息。 }else{// 如果是单播,就直接发送。 }
4.4 代码框架
将以上的三部分结合起来,就是网络层(交换机)的代码框架。
intmain(intargc,charconst*argv[]){// 变量、网络库与套接字的初始化。 for(inti=0;;i=(i+1)%phyPortNum){// 如果该端口不可读,则检查下一个。 if(!readable){continue;}// 如果可读,就读取消息。 // 获取消息的源与目的端口。 // 反向学习源端口。 // 检索应该发到哪个端口。 // 判断要单播还是广播。 if(isBroadcast||portNotFound){// 给所有端口发消息。 }else{// 给对应端口发消息。 }}}
4.5 阶段三调试
4.5.1 单播的支持
在测试中,我们从 11300 端口向 12300 单播“hello”。可以看到,交换机能够正确的学习并更新端口地址表,并充当两主机发送、回复、重传的桥梁,消息最终完好地传递到了 12300 端口。
4.5.2 广播的支持
在测试中,我们从 12300 端口向所有端口广播“helloworld”。可以看到,在发送时,交换机能够正确识别出这是广播消息,并广播给所有剩余端口;在回复时,交换机也能够正确识别出这是单播消息,并单播给 12300 端口。最终,消息完好地传递到了所有端口。
4.5.3 反向学习
- 当 11300 端口第一次向交换机的 13101 端口发消息,交换机的表里还没有 13101-11300 的对应关系,于是它自动学习了这组关系;
- 当交换机第一次向 12300 端口发消息,它的表里还没有 13102-12300 的对应关系,于是它向所有端口广播了这条消息;
- 当 12300 端口第一次向交换机的 13102 端口回复消息,交换机就学习到了 13102-12300 这组关系;
- 当交换机向 11300 端口回复消息,此时它已经学会了 13101-11300 的关系,所以回复以单播形式发给 11300 端口。
- 此后,所有信息交互都是单播,因为交换机已经把两边与自己的对应关系都学会了。
五、网络层(路由器)
最后,我们将编写路由器程序,并更新物理层配置文件,实现我们的七网元拓扑。
在正常情况下,路由器负责的是 IP 的寻址、分配,提供防火墙,接入互联网等功能,但由于我们的网络拓扑只在本机进行,IP 地址恒为 127.0.0.1,并且也不需要防火墙等服务,所以我们的路由器功能与交换机功能大致类似。
5.1 实体编址
5.1.1 基本原理
当我们的网络拓扑逐渐开始变得复杂,我们需要为我们网络中的每个网元分配明晰合理、规则统一的端口号。
我们编址的规则如下:
- 第一位:统一为 1;
- 第二位:网元编号,范围为 1-7;
- 第三位:层号,从下往上分别为 1-3;
- 第四、五位:实体号,从 00 开始往上编。
根据这些原则,我们规划出的拓扑端口号如下:
在数据投递时,用户需要输入目标网元的应用层端口。比如我要从主机 3 向主机 1 发消息,那么我就要输入主机 1 应用层的端口号 11300。
这么设计的原因是想要更贴近实际:例如我想要给某人发 QQ,我希望直接通过备注或昵称,在我的列表里找到他;而不是通过 QQ 号,甚至他在网络上的 IP 地址找到他——后面这些是软件底层逻辑该关心的事情,而不是用户该关心的。
5.1.2 配置文件
为了更快地启动并配置物理层参数,我们根据以上的拓扑在 ne.txt 进行了对应的配置。
各网元分层:
1 PHY0 NET0 APP0 2 PHY0 NET0 APP0 3 PHY0 SWITCH0 PHY1 PHY2 4 PHY0 NET0 APP0 5 PHY0 NET0 APP0 6 PHY0 SWITCH0 PHY1 PHY2 7 PHY0 ROUTE0 PHY1
网元间连接:
1. 1,0--3,0 2. 2,0--3,1 3. 4,0--6,0 4. 5,0--6,1 5. 7,0--3,2 6. 7,1--6,2
5.2 路由表
在正常情况下,路由器的路由表结构大致如下:
- 目的:数据包想要发到哪台主机;
- 下一站:为了转发,路由器应该把数据包转交给哪台路由器;
- 出口:为了转交,数据包要从路由器哪个端口出去;
- 度量:这条路径要经过多少中继路由器。
但由于我们的网络拓扑中只有一台路由器,所以并不存在“下一站”的概念,度量也始终为 0。我们只需要记录,到哪个远程端口的数据包应该走哪个端口即可。
这与交换机的端口地址表逻辑基本一致,我们可以直接挪用,并同时支持反向学习。代码逻辑不再展示。
5.3 批处理文件
加入路由器后,需要运行的 *.exe 文件数量陡增,用户逐个打开并输入端口号显然是费时费力的一件事情。为此,我们编写了三个批处理文件,分别负责项目的一键编译、启动、关闭。
- OneTouchToCompile.bat:一键编译 src 文件的所有源码。
- OneTouchToGo.bat:一键启动所有网元;
- OneTouchToClose.bat:一键关闭所有网元。
这三个文件可以在项目根目录找到,使用时双击即可。
5.4 阶段四调试
在调试过程中,我们发现了一个令人费解的问题:主机发来的消息,在交换机处会以错误的端口号出现。
如上图,在主机 2 向主机 1 返回 ACK 时,本应该在 13101 端口(主机 2 对应端口)出现的消息,被交换机误认为是从 13100 端口(主机 1 对应端口)传来,导致端口地址表混乱,后续的收发也无法完成。而在阶段三未加入路由器时,我们并没有出现类似的问题。
我们判断,问题应该出现在 select 驱动部分,但我们小组前后对 SwitchSocket 类和 switcher.cpp 的相关部分进行了若干次的逻辑 + 代码的重构,仍未能解决这一问题。
经过对项目代码的多次审视,我们小组认为代码逻辑没有出现问题;恳请老师指点问题所在,我们小组将不胜感激。
六、反思与总结
6.1 项目的缺点与反思
本项目成功实现了阶段一到三的所有基本要求,并完成了阶段四的代码;但在最终的调试中遇到了问题,未能完成阶段四的调试。
除此之外,我们的项目还有一些小缺点:
- 在误码率较高、多次重传的情况下,string 类的 substr() 方法偶尔会抛出上溢错误。由于触发 bug 的频率低,目前尚未定位到错误,但不影响正常的收发;
- 交换机在执行 select() 时,偶尔会将没有信息的端口误认为有信息可读。目前暂且通过“判断读入字节数大于 0”解决了该问题,但这个方案并不漂亮;
- 只支持中英文与标点的传输,但不支持图片传输。这可以通过编写特定的编解码函数,将图片的每个像素点的 RGBA 值转化为 01 序列,再进行传输,但由于时间与精力有限,未能实现支持。
6.2 总结与心得体会
该项目是课程四个项目中最难的一个,它涵盖了课程中的许多知识点:定位、检错、差控、流控、共享、交换、路由、转发、广播……但相应地,它也是给我们小组最多收获的一个。
通过这一项目,我们小组真正地将课堂所学的内容,应用到了实际的编程当中,并且也对这门课程的授课大纲有了更深层次的把握。我们不再只是知识的接收者,而是成为设计者,通过实操真正理解了为什么、怎么样;也因此,我们的专业知识水平、程序设计水平、团队合作水平得到了极大的提高。
虽然我们小组并没有能够将代码写得十全十美、令人满意,但这一项目给我们的收获——我们认为——是远远超过代码本身的。
感谢老师们在我们组项目推进的过程中,提供的悉心教导与陪伴!