基于C++/winsock2,进行最小网元设计(下)

简介: 基于C++/winsock2,进行最小网元设计(下)

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

187ad7b3fbb80f3cd00ef134120ab6b3.png


在本测试中,用户发送的字符串“你好,test.”中同时包含了中文、英文、全半角符号。可以看到,双方可以完成正常的信息收发,应用层能够正确编解码,网络层也能够逐帧发送与确认。


3.9.2 差错的检测与重传


37b719dd7d8e12c52f16b699726a9592.png


在本测试中,我们设置物理层误码率为十万分之 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 单播的支持

82e8df4fa28b2688b9458f7b6b2b3a50.png


在测试中,我们从 11300 端口向 12300 单播“hello”。可以看到,交换机能够正确的学习并更新端口地址表,并充当两主机发送、回复、重传的桥梁,消息最终完好地传递到了 12300 端口。


4.5.2 广播的支持


3bcbeeaa2e69be8e928d7be175adb0ee.png

在测试中,我们从 12300 端口向所有端口广播“helloworld”。可以看到,在发送时,交换机能够正确识别出这是广播消息,并广播给所有剩余端口;在回复时,交换机也能够正确识别出这是单播消息,并单播给 12300 端口。最终,消息完好地传递到了所有端口。


4.5.3 反向学习


  1. 当 11300 端口第一次向交换机的 13101 端口发消息,交换机的表里还没有 13101-11300 的对应关系,于是它自动学习了这组关系;
  2. 当交换机第一次向 12300 端口发消息,它的表里还没有 13102-12300 的对应关系,于是它向所有端口广播了这条消息;
  3. 当 12300 端口第一次向交换机的 13102 端口回复消息,交换机就学习到了 13102-12300 这组关系;
  4. 当交换机向 11300 端口回复消息,此时它已经学会了 13101-11300 的关系,所以回复以单播形式发给 11300 端口。
  5. 此后,所有信息交互都是单播,因为交换机已经把两边与自己的对应关系都学会了。


五、网络层(路由器)


最后,我们将编写路由器程序,并更新物理层配置文件,实现我们的七网元拓扑。


在正常情况下,路由器负责的是 IP 的寻址、分配,提供防火墙,接入互联网等功能,但由于我们的网络拓扑只在本机进行,IP 地址恒为 127.0.0.1,并且也不需要防火墙等服务,所以我们的路由器功能与交换机功能大致类似。


5.1 实体编址

5.1.1 基本原理

当我们的网络拓扑逐渐开始变得复杂,我们需要为我们网络中的每个网元分配明晰合理、规则统一的端口号。

我们编址的规则如下:


  • 第一位:统一为 1
  • 第二位:网元编号,范围为 1-7
  • 第三位:层号,从下往上分别为 1-3
  • 第四、五位:实体号,从 00 开始往上编。

根据这些原则,我们规划出的拓扑端口号如下:


6a03fa3add0527a897e15e0b37bc37b8.png


在数据投递时,用户需要输入目标网元的应用层端口。比如我要从主机 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 阶段四调试

在调试过程中,我们发现了一个令人费解的问题:主机发来的消息,在交换机处会以错误的端口号出现。


fb32415fd3fbc189b7ff20861ed382b9.png


如上图,在主机 2 向主机 1 返回 ACK 时,本应该在 13101 端口(主机 2 对应端口)出现的消息,被交换机误认为是从 13100 端口(主机 1 对应端口)传来,导致端口地址表混乱,后续的收发也无法完成。而在阶段三未加入路由器时,我们并没有出现类似的问题。


我们判断,问题应该出现在 select 驱动部分,但我们小组前后对 SwitchSocket 类和 switcher.cpp 的相关部分进行了若干次的逻辑 + 代码的重构,仍未能解决这一问题。


经过对项目代码的多次审视,我们小组认为代码逻辑没有出现问题;恳请老师指点问题所在,我们小组将不胜感激。


六、反思与总结


6.1 项目的缺点与反思


本项目成功实现了阶段一到三的所有基本要求,并完成了阶段四的代码;但在最终的调试中遇到了问题,未能完成阶段四的调试。


除此之外,我们的项目还有一些小缺点:


  1. 在误码率较高、多次重传的情况下,string 类的 substr() 方法偶尔会抛出上溢错误。由于触发 bug 的频率低,目前尚未定位到错误,但不影响正常的收发;
  2. 交换机在执行 select() 时,偶尔会将没有信息的端口误认为有信息可读。目前暂且通过“判断读入字节数大于 0”解决了该问题,但这个方案并不漂亮;
  3. 只支持中英文与标点的传输,但不支持图片传输。这可以通过编写特定的编解码函数,将图片的每个像素点的 RGBA 值转化为 01 序列,再进行传输,但由于时间与精力有限,未能实现支持。


6.2 总结与心得体会


该项目是课程四个项目中最难的一个,它涵盖了课程中的许多知识点:定位、检错、差控、流控、共享、交换、路由、转发、广播……但相应地,它也是给我们小组最多收获的一个。


通过这一项目,我们小组真正地将课堂所学的内容,应用到了实际的编程当中,并且也对这门课程的授课大纲有了更深层次的把握。我们不再只是知识的接收者,而是成为设计者,通过实操真正理解了为什么、怎么样;也因此,我们的专业知识水平、程序设计水平、团队合作水平得到了极大的提高。


虽然我们小组并没有能够将代码写得十全十美、令人满意,但这一项目给我们的收获——我们认为——是远远超过代码本身的。


感谢老师们在我们组项目推进的过程中,提供的悉心教导与陪伴!


相关实践学习
深入解析Docker容器化技术
Docker是一个开源的应用容器引擎,让开发者可以打包他们的应用以及依赖包到一个可移植的容器中,然后发布到任何流行的Linux机器上,也可以实现虚拟化,容器是完全使用沙箱机制,相互之间不会有任何接口。Docker是世界领先的软件容器平台。开发人员利用Docker可以消除协作编码时“在我的机器上可正常工作”的问题。运维人员利用Docker可以在隔离容器中并行运行和管理应用,获得更好的计算密度。企业利用Docker可以构建敏捷的软件交付管道,以更快的速度、更高的安全性和可靠的信誉为Linux和Windows Server应用发布新功能。 在本套课程中,我们将全面的讲解Docker技术栈,从环境安装到容器、镜像操作以及生产环境如何部署开发的微服务应用。本课程由黑马程序员提供。 &nbsp; &nbsp; 相关的阿里云产品:容器服务 ACK 容器服务 Kubernetes 版(简称 ACK)提供高性能可伸缩的容器应用管理能力,支持企业级容器化应用的全生命周期管理。整合阿里云虚拟化、存储、网络和安全能力,打造云端最佳容器化应用运行环境。 了解产品详情: https://www.aliyun.com/product/kubernetes
相关文章
|
存储 编解码 算法
基于C++/winsock2,进行最小网元设计(上)
基于C++/winsock2,进行最小网元设计
204 1
基于C++/winsock2,进行最小网元设计(上)
|
8月前
|
编译器 C++ 开发者
【C++篇】深度解析类与对象(下)
在上一篇博客中,我们学习了C++的基础类与对象概念,包括类的定义、对象的使用和构造函数的作用。在这一篇,我们将深入探讨C++类的一些重要特性,如构造函数的高级用法、类型转换、static成员、友元、内部类、匿名对象,以及对象拷贝优化等。这些内容可以帮助你更好地理解和应用面向对象编程的核心理念,提升代码的健壮性、灵活性和可维护性。
|
4月前
|
人工智能 机器人 编译器
c++模板初阶----函数模板与类模板
class 类模板名private://类内成员声明class Apublic:A(T val):a(val){}private:T a;return 0;运行结果:注意:类模板中的成员函数若是放在类外定义时,需要加模板参数列表。return 0;
115 0
|
4月前
|
存储 编译器 程序员
c++的类(附含explicit关键字,友元,内部类)
本文介绍了C++中类的核心概念与用法,涵盖封装、继承、多态三大特性。重点讲解了类的定义(`class`与`struct`)、访问限定符(`private`、`public`、`protected`)、类的作用域及成员函数的声明与定义分离。同时深入探讨了类的大小计算、`this`指针、默认成员函数(构造函数、析构函数、拷贝构造、赋值重载)以及运算符重载等内容。 文章还详细分析了`explicit`关键字的作用、静态成员(变量与函数)、友元(友元函数与友元类)的概念及其使用场景,并简要介绍了内部类的特性。
191 0
|
6月前
|
编译器 C++ 容器
【c++11】c++11新特性(上)(列表初始化、右值引用和移动语义、类的新默认成员函数、lambda表达式)
C++11为C++带来了革命性变化,引入了列表初始化、右值引用、移动语义、类的新默认成员函数和lambda表达式等特性。列表初始化统一了对象初始化方式,initializer_list简化了容器多元素初始化;右值引用和移动语义优化了资源管理,减少拷贝开销;类新增移动构造和移动赋值函数提升性能;lambda表达式提供匿名函数对象,增强代码简洁性和灵活性。这些特性共同推动了现代C++编程的发展,提升了开发效率与程序性能。
243 12
|
7月前
|
设计模式 安全 C++
【C++进阶】特殊类设计 && 单例模式
通过对特殊类设计和单例模式的深入探讨,我们可以更好地设计和实现复杂的C++程序。特殊类设计提高了代码的安全性和可维护性,而单例模式则确保类的唯一实例性和全局访问性。理解并掌握这些高级设计技巧,对于提升C++编程水平至关重要。
143 16
|
8月前
|
编译器 C语言 C++
类和对象的简述(c++篇)
类和对象的简述(c++篇)
|
7月前
|
编译器 C++
类和对象(中 )C++
本文详细讲解了C++中的默认成员函数,包括构造函数、析构函数、拷贝构造函数、赋值运算符重载和取地址运算符重载等内容。重点分析了各函数的特点、使用场景及相互关系,如构造函数的主要任务是初始化对象,而非创建空间;析构函数用于清理资源;拷贝构造与赋值运算符的区别在于前者用于创建新对象,后者用于已存在的对象赋值。同时,文章还探讨了运算符重载的规则及其应用场景,并通过实例加深理解。最后强调,若类中存在资源管理,需显式定义拷贝构造和赋值运算符以避免浅拷贝问题。
|
7月前
|
存储 编译器 C++
类和对象(上)(C++)
本篇内容主要讲解了C++中类的相关知识,包括类的定义、实例化及this指针的作用。详细说明了类的定义格式、成员函数默认为inline、访问限定符(public、protected、private)的使用规则,以及class与struct的区别。同时分析了类实例化的概念,对象大小的计算规则和内存对齐原则。最后介绍了this指针的工作机制,解释了成员函数如何通过隐含的this指针区分不同对象的数据。这些知识点帮助我们更好地理解C++中类的封装性和对象的实现原理。
|
7月前
|
安全 C++
【c++】继承(继承的定义格式、赋值兼容转换、多继承、派生类默认成员函数规则、继承与友元、继承与静态成员)
本文深入探讨了C++中的继承机制,作为面向对象编程(OOP)的核心特性之一。继承通过允许派生类扩展基类的属性和方法,极大促进了代码复用,增强了代码的可维护性和可扩展性。文章详细介绍了继承的基本概念、定义格式、继承方式(public、protected、private)、赋值兼容转换、作用域问题、默认成员函数规则、继承与友元、静态成员、多继承及菱形继承问题,并对比了继承与组合的优缺点。最后总结指出,虽然继承提高了代码灵活性和复用率,但也带来了耦合度高的问题,建议在“has-a”和“is-a”关系同时存在时优先使用组合。
410 6

热门文章

最新文章