☰
VC++6.0实现滑动窗口协议仿真(Stop-and-Wait/Go-Back-N/Selective Repeat)
2026/9/30 12:35:27 网站建设 项目流程

简介:本资源是一份面向计算机专业本科生的《滑动窗口协议仿真》课程设计报告,聚焦计算机网络核心协议原理与VC++编程实践,帮助学习者深入理解流量控制机制及ARQ协议实现逻辑。报告完整覆盖1bit滑动窗口、后退N协议与选择重传协议三类典型机制的原理对比、窗口模型分析、优缺点讨论,并基于VC++实现含sender/receiver双队列模块、超时重传、帧序号管理、ACK确认模拟等关键功能的可运行仿真系统。资源为单文件Word文档(.doc),大小400KB,结构清晰,含封面、任务书、引言、原理详述、模块设计、源码节选、调试说明及参考文献共29页,内容扎实、步骤完整,适合作为课程设计参考范本或网络协议实验拓展材料。已有634人学习下载,对巩固网络分层模型、提升协议编程能力具有直接参考价值。

1. 这不是一份普通课程报告:它是一份能跑通的滑动窗口协议仿真源码包(VC++ 6.0 + Windows 原生实现,含完整 sender/receiver 双线程逻辑与超时/丢包/重传全链路模拟)

你手头这份《课程设计报告-滑动窗口协议仿真.doc》,表面看是29页带封面、目录、致谢的典型本科课设文档,但真正值钱的是藏在第5章「源代码」里的那两段可编译、可调试、可观察的 VC++ 实战代码——它不是伪代码,不是流程图,而是用原生 Win32 API + socket + 多线程 + 队列结构体写成的、能在 Windows XP/7 下直接运行的滑动窗口协议黑匣子。它不依赖任何第三方库,不调用 MFC,不封装成 DLL,所有逻辑裸露在 .cpp 级别:从WSAStartup()初始化网络栈,到CreateThread()启动接收监听线程,再到WaitForMultipleObjects()控制超时重发,甚至用srand((unsigned)time(NULL))模拟 20% 的随机校验失败和丢包。这不是教学演示动画,这是真实复现了「发送方发帧→网络丢包→超时触发→重传→接收方缓存乱序帧→选择性 ACK→最终交付主机」全过程的最小可行系统。适合计算机网络初学者理解 ARQ 机制本质,更适合嵌入式/协议栈开发新人逆向拆解 TCP 流量控制的底层骨架——毕竟,TCP 的滑动窗口,就是从这种 1bit → Go-Back-N → Selective Repeat 的演进路径上长出来的。如果你正被谢希仁《计算机网络》第七章绕晕,或卡在王道408「可靠传输」大题里反复画不出窗口状态变迁图,这份文档+代码组合,就是你缺的那块实体拼图。

2. 协议选型与结构设计:为什么用 VC++ 6.0 写纯 Win32,而不是 Python 或 Java?

2.1 三种滑动窗口协议的本质差异:窗口尺寸决定行为边界

滑动窗口协议不是单一算法,而是一族协议,其核心差异仅在于发送窗口(SW)与接收窗口(RW)的尺寸组合。这份课设精准抓住了这个关键点,并在代码中通过结构体定义和逻辑分支显式体现:

  • 1bit 滑动窗口(Stop-and-Wait):SW = 1, RW = 1
    对应代码中main()函数内固定只取QueueFront(&QueueQ)一帧发送,且必须收到 ACK 才DeLine()删除队头。此时QueueLen(&QueueQ)永远 ≤ 1,GetFrameFromHost()虽递归生成多帧,但实际只让一帧进入发送流水线。这是最简模型,信道利用率最低,但逻辑最清晰,适合作为起点验证基础收发链路。

  • 后退 N(Go-Back-N):SW > 1, RW = 1
    文档虽未在代码中显式实现动态窗口大小(如MAX_WINDOW_SIZE = 4),但其行为已隐含在超时重传逻辑中:当WaitForMultipleObjects()返回WSA_WAIT_TIMEOUT时,TerminateThread()强制结束接收线程,随后switch(rand() % 5)有 80% 概率执行DeLine(&QueueQ)——注意!这里删的是当前队头帧,而非整个窗口。但结合GetFrameFromHost()的递归填充和QueueFront()的固定取首帧,实际效果是:若第3帧丢失导致超时,后续第4、5帧虽已入队,却因第3帧未确认而无法推进,最终形成“回退到出错帧”的语义。这是对 Go-Back-N 的轻量级模拟,牺牲了严格窗口管理,换取了 VC++ 6.0 下的可实现性。

  • 选择重传(Selective Repeat):SW > 1, RW > 1
    接收方代码中DeLine(LinkQueue *q, frame *pf, unsigned int curw)函数签名里的curw参数即为接收窗口大小占位符,QueueAnswer(LinkQueue *q, unsigned int curw)更通过if (curw == 1)分支暗示了窗口尺寸可配置。虽然最终实现中curw未被动态赋值(默认按 1 处理),但结构已预留扩展接口。真正的选择重传需接收方维护一个buffer[SW_SIZE]数组,对每个seq号帧独立标记received状态,并只对缺失seq发送 NAK。本课设在原理章节(2.4节)对此有清晰图解,代码骨架也已铺好,只需补全缓冲区管理和 ACK/NACK 构造逻辑即可升级。

提示:不要被“VC++ 6.0 过时”劝退。正是这种受限环境逼出了最硬核的底层思维——没有 STL 容器,所以手写链表队列;没有 C++11 线程,所以用CreateThread+WaitForMultipleObjects;没有现代异常处理,所以满屏if(ret == SOCKET_ERROR)。这些不是缺陷,而是教科书级的协议实现范本。

2.2 核心数据结构:用纯 C 风格链表模拟帧队列与窗口状态

协议仿真的灵魂在于状态管理,而本课设用极简的struct framenode+LinkQueue实现了发送/接收双端队列,其设计直指滑动窗口本质:

typedef struct framenode { frame head_data; // 当前帧完整数据(含 head + data) struct framenode *next; // 指向下一帧节点 } Framenode; typedef struct { Framenode *front; // 队头:待发送/待交付的第一帧 Framenode *rear; // 队尾:最新加入的帧 } LinkQueue;

这个结构体不是装饰品,它在运行时实时映射窗口状态:

  • 发送窗口状态:QueueLen(&QueueQ)返回的长度即为当前已发送但未确认的帧数(即 SW 中“已使用”部分)。front指向最早发出的帧(等待 ACK),rear指向最新生成的待发帧。
  • 接收窗口状态:接收方LinkQueue的front指向下一个期待的seq帧(expect_seq),rear指向已缓存的最高seq帧。DeLine(q, &pf, curw)中的curw若设为 4,则意味着接收方最多缓存 4 个乱序帧,超出则丢弃——这正是 Selective Repeat 的缓冲区约束。

所有队列操作函数都服务于状态流转:

  • InitLine():清空窗口,front = rear = NULL
  • GetFrameFromHost():向窗口注入新帧(模拟应用层有数据要发)
  • QueueFront():读取窗口最左端帧(seq最小,最急待确认)
  • DeLine():从窗口左端移除帧(收到 ACK,窗口左边界右移)
  • QueueLen():量化窗口占用程度(判断是否拥塞、是否该暂停注入)

这种将抽象“窗口”具象为“链表长度+指针位置”的做法,比任何 UML 图都更深刻地揭示了滑动窗口的内存本质——它不是一个数学概念,而是一段被malloc分配、由指针维护的连续内存区域。

2.3 线程与同步:用临界区(Critical Section)保护共享队列

双机通信在单机上仿真,必然涉及并发——发送方主线程不断GetFrameFromHost()入队,接收线程recv()出队并DeLine()。若无同步,front/rear指针可能被同时修改,导致链表断裂。本课设采用 Win32 最轻量的同步原语:临界区(Critical Section)。

关键代码在接收线程函数ReceiveFun()中:

DWORD WINAPI ReceiveFun(LPVOID pArg) { EnterCriticalSection(&gCS); // 进入临界区:锁定队列操作 frame *packetreceive = (frame *)pArg; ret = recv(socketClient, (char *)packetreceive, sizeof(*packetreceive), 0); LeaveCriticalSection(&gCS); // 离开临界区:释放锁 return ret; }

而全局临界区gCS在main()中初始化:

CRITICAL_SECTION gCS; // 全局声明 // ... 在 main() 开头 ... InitializeCriticalSection(&gCS); // 初始化 // ... 在 main() 结尾 ... DeleteCriticalSection(&gCS); // 销毁

这里藏着一个易被忽略的细节:recv()本身是线程安全的,但packetreceive指向的内存(即frame结构体)会被DeLine()修改。EnterCriticalSection()锁定的不是 socket,而是对packetreceive所关联队列节点的操作权。当主线程在DeLine()中遍历链表删除节点时,接收线程若正尝试recv()写入同一节点,就会发生内存冲突。临界区确保了“读-改-写”操作的原子性。

注意:TerminateThread()是粗暴的线程终止方式,现代开发应避免。但在此仿真场景中,它恰恰模拟了真实网络中“超时即断连”的不可靠性——线程被杀,队列状态冻结,下次重发时重新InitLine(),这比优雅退出更能暴露协议鲁棒性缺陷。

3. 编译与运行实操:从 .doc 文档到可执行程序的完整链路

3.1 环境复现:VC++ 6.0 + Windows XP/7 的黄金组合

这份课设诞生于 2011 年,其技术栈具有鲜明的时代烙印。要让代码跑起来,必须还原当时的开发环境:

  • IDE:Microsoft Visual C++ 6.0(非 VS2010/2019)。原因:代码中大量使用#include <windows.h>原生 API,且WSAStartup(MAKEWORD(1,1))明确指定 Winsock 1.1 版本,VS 新版本默认链接 Winsock 2.x,会导致socket()等函数符号未定义。
  • 操作系统:Windows XP SP3 或 Windows 7(32位)。64位系统需额外处理指针大小(sizeof(void*)=8),而代码中frame结构体假设unsigned int为 4 字节,sizeof(frame)计算依赖此假设。
  • 编译设置:在 VC++ 6.0 中新建 “Win32 Application” 工程,取消勾选 "Precompiled Headers"(因代码无stdafx.h),在 Project Settings → Link → Object/Library Modules 中添加ws2_32.lib(Winsock 库)。

提示:若在 Windows 10/11 上运行,需以兼容模式启动 VC++ 6.0(右键属性 → 兼容性 → 勾选“以兼容模式运行”并选 Windows XP SP3),否则CreateThread()可能返回无效句柄。

3.2 代码整合:从文档碎片到可编译工程的三步缝合

文档中的源码分散在第5章,需手动拼接。以下是关键整合步骤(以发送方为例):

Step 1:创建sender.cpp文件,粘贴并补全头文件与宏定义

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <time.h> #include <windows.h> #include <winsock.h> #define MAX_LENGTH 100 #define MAXPOOL 100 #define SLEEPMS 100 #define RECEIVE_MAX_LENGTH 1024 #define SEND_MAX_LENGTH 1024 #define TIMEOUT 1000 // 从文档第7页复制 typedef enum 和 struct 定义 typedef enum {data = 1,ack,nak,tout} frame_kind; typedef struct frame_head{ frame_kind kind; unsigned int seq; unsigned int ack; unsigned char data[MAX_LENGTH]; }Head; typedef struct frame { frame_head head; unsigned int size; } Frame; // ... (继续粘贴 Framenode 和 LinkQueue 定义) // 从文档第8-9页复制函数声明(InitLine, QueueEmpty 等) void InitLine(LinkQueue *q); int QueueEmpty(LinkQueue *q); frame QueueFront(LinkQueue *q); int QueueLen(LinkQueue *q); void GetFrameFromHost(LinkQueue *q); void DeLine(LinkQueue *q); // 全局变量声明(文档第12页起) SOCKET socketClient; CRITICAL_SECTION gCS; // 从文档第14-17页复制 main() 函数主体 void main() { // 此处粘贴文档中完整的 main() 函数代码 // 注意:需将文档中分散的 printf("\n") 等语句整合到正确位置 }

Step 2:实现ReceiveFun()线程函数(文档第17页)

DWORD WINAPI ReceiveFun(LPVOID pArg) { EnterCriticalSection(&gCS); frame *packetreceive = (frame *)pArg; int ret = recv(socketClient, (char *)packetreceive, sizeof(*packetreceive), 0); LeaveCriticalSection(&gCS); return ret; }

Step 3:接收方同理创建receiver.cpp,并确保main()中的 socket 绑定端口为 7001

接收方main()中需调用bind()和listen(),而文档第20页代码被截断。补全关键段:

void main(){ Begin: WORD wVersionRequested; WSADATA wsaData; wVersionRequested = MAKEWORD(1, 1); int err = WSAStartup(wVersionRequested, &wsaData); if (err != 0) { Sleep(SLEEPMS); return; } SOCKET socketServer = socket(AF_INET, SOCK_STREAM, 0); SOCKADDR_IN serveradd; serveradd.sin_family = AF_INET; serveradd.sin_port = htons(7001); // 必须与 sender 的 connect 端口一致 serveradd.sin_addr.s_addr = INADDR_ANY; if(bind(socketServer, (SOCKADDR*)&serveradd, sizeof(serveradd)) == SOCKET_ERROR) { printf("Bind failed!\n"); WSACleanup(); return; } listen(socketServer, 5); SOCKET clientSocket = accept(socketServer, NULL, NULL); // 等待 sender 连接 // 此处插入文档中 receiver 的主循环逻辑(等待 recv, 校验, 发送 ack/nak) }

3.3 运行验证:观察 DOS 窗口中的协议心跳

编译成功后,得到sender.exe和receiver.exe。按顺序执行:

  1. 先启动接收方:receiver.exe,DOS 窗口显示建立连接 ...并阻塞在accept()。
  2. 再启动发送方:sender.exe,窗口显示:
    建立连接 ... 你好接收方,我是发送方! 按任意键继续!
  3. 敲回车,发送方开始发送:
    • 每次输出类似:发送帧号: 0, 数据大小: 42
    • 若超时:超时!重发帧号: 0
    • 若收到 ACK:收到 ACK, 确认号: 0, 删除队头帧
    • 若收到 NAK:收到 NAK, 重发帧号: 0

关键观察点:

  • QueueLen(&QueueQ)输出值:稳定在 1 表示 Stop-and-Wait;若出现 2~4 则说明 Go-Back-N 窗口已打开(需微调GetFrameFromHost()注入频率)。
  • seq和ack字段变化:seq递增表示新帧,ack等于seq+1表示累积确认(Go-Back-N 特征)。
  • Sleep(SLEEPMS)的作用:控制发送节奏,SLEEPMS=100时约 10fps,便于肉眼跟踪;调小至10可测试高吞吐场景。

提示:若看到发送数据出错!或接受连接提示信息出错!,90% 是端口未释放。用netstat -ano | findstr :7001查 PID,taskkill /PID xxx /F强制结束残留进程。

4. 避坑指南:五个血泪经验总结,专治编译失败、闪退、逻辑错乱

4.1 现象:VC++ 6.0 编译报错error C2065: 'SOCKET' : undeclared identifier

原因:未正确包含winsock.h头文件,或#include <winsock.h>位置错误(必须在#include <windows.h>之后)。VC++ 6.0 的winsock.h不支持#pragma comment(lib, "ws2_32.lib"),必须手动在 Project Settings 中添加库。
解决:

  1. 确保#include <winsock.h>在#include <windows.h>之后;
  2. Project → Settings → Link → Object/Library Modules → 输入ws2_32.lib;
  3. 在main()开头调用WSAStartup()前,添加#pragma comment(lib, "ws2_32.lib")(虽非必需,但可防疏漏)。

4.2 现象:程序运行一闪而退,DOS 窗口瞬间关闭

原因:main()函数末尾缺少阻塞等待,printf("按任意键退出!\n"); while (!kbhit()) {};未生效,或Sleep(SLEEPMS)时间过短导致线程未启动完毕主函数已退出。
解决:

  1. 在main()结尾WSACleanup();之后,添加getch();(需#include <conio.h>);
  2. 将while (!kbhit()) {};替换为Sleep(5000);(等待5秒),确保线程有足够时间完成;
  3. 检查CreateThread()返回值:若为NULL,说明线程创建失败,需检查gCS是否已InitializeCriticalSection()。

4.3 现象:发送方一直显示发送数据出错!,接收方无任何输出

原因:端口 7001 被占用,或防火墙拦截。connect()失败后goto Begin无限循环,但未打印错误码,造成假死。
解决:

  1. 运行netstat -ano | findstr :7001,杀掉占用进程;
  2. 在connect()后添加错误诊断:
    if(SOCKET_ERROR==connect(socketClient,(SOCKADDR*)&clientadd,sizeof(SOCKADDR)) ) { printf("Connect failed! Error: %d\n", WSAGetLastError()); // 关键! WSACleanup(); Sleep(SLEEPMS); goto Begin; }
  3. 临时关闭 Windows 防火墙,或在防火墙中放行sender.exe。

4.4 现象:接收方收到帧,但seq字段全为 0,data区域乱码

原因:frame结构体内存对齐问题。VC++ 6.0 默认按 8 字节对齐,但frame_head中unsigned char data[MAX_LENGTH]是变长数组,sizeof(frame)计算不准确,导致send()发送的字节数与recv()接收的字节数不匹配。
解决:

  1. 在结构体定义前添加#pragma pack(1)强制 1 字节对齐;
  2. 在结构体后添加#pragma pack()恢复默认对齐:
    #pragma pack(1) typedef struct frame { frame_head head; unsigned int size; } Frame; #pragma pack()
  3. 确保send()和recv()的第三个参数均为sizeof(Frame),而非sizeof(frame_head)+MAX_LENGTH。

4.5 现象:超时重传逻辑失效,永远不重发,或重发次数过多

原因:WaitForMultipleObjects()的超时判断逻辑有误。文档代码中if(r == WSA_WAIT_TIMEOUT)后直接TerminateThread(),但r是WaitForMultipleObjects()返回值,其值为WAIT_TIMEOUT(常量,非WSA_WAIT_TIMEOUT)。WSA_WAIT_TIMEOUT是 Winsock 错误码,此处混淆了 API。
解决:

  1. 将if(r == WSA_WAIT_TIMEOUT)改为if(r == WAIT_TIMEOUT);
  2. TerminateThread()是危险操作,改为更安全的CloseHandle(hThread)+ 重置线程句柄;
  3. 在CreateThread()前添加hThread = NULL;,避免野指针:
    HANDLE hThread = NULL; hThread = CreateThread(NULL, 0, ReceiveFun, (LPVOID)&packetreceive, 0, NULL); if(hThread == NULL) { printf("CreateThread failed! Error: %d\n", GetLastError()); continue; } int r = WaitForMultipleObjects(1, &hThread, TRUE, timeOut); if(r == WAIT_TIMEOUT) { CloseHandle(hThread); // 安全关闭 hThread = NULL; // 执行重发逻辑 }

5. 进阶改造:把课设代码升级为可验证的协议分析工具

5.1 添加帧日志与可视化:用文本文件记录每一帧的生死簿

协议仿真价值在于可观测性。原始代码仅靠printf输出,信息零散难追溯。我们为其添加结构化日志,生成trace.log:

// 在 sender.cpp 开头添加 FILE *logFile = NULL; void initLog() { logFile = fopen("trace.log", "w"); if(logFile) fprintf(logFile, "Time\tSeq\tKind\tSize\tStatus\tRTT(ms)\n"); } void logFrame(unsigned int seq, frame_kind kind, unsigned int size, const char* status, unsigned long rtt) { if(logFile) { fprintf(logFile, "%lu\t%u\t%s\t%u\t%s\t%lu\n", GetTickCount(), seq, (kind==data)?"DATA":(kind==ack)?"ACK":"NAK", size, status, rtt); fflush(logFile); // 立即写入,避免缓冲 } } // 在 main() 开头调用 initLog(); // 在 send() 后添加: unsigned long sendTime = GetTickCount(); ret = send(socketClient, (char *)&packetsend, sizeof(packetsend), 0); logFrame(packetsend.head.seq, packetsend.head.kind, packetsend.size, "SENT", 0); // 在 ReceiveFun() 收到帧后,计算 RTT: unsigned long rtt = GetTickCount() - sendTime; logFrame(packetreceive->head.seq, packetreceive->head.kind, packetreceive->size, "RECEIVED", rtt);

生成的日志可导入 Excel 或 Python(pandas.read_csv())做统计分析:

TimeSeqKindSizeStatusRTT(ms)
1234560DATA42SENT0
1235010ACK0RECEIVED45
1236001DATA38SENT0
1236801ACK0RECEIVED80

价值:一眼看出丢包(无对应 ACK 行)、超时(RTT > 5000ms)、重传(Seq 重复出现)、乱序(Seq 非单调递增)。

5.2 参数化窗口大小:从硬编码到命令行配置

原始代码中窗口大小(MAXPOOL,MAX_LENGTH)全为宏定义,修改需重编译。我们将其升级为运行时参数:

// 在 main() 开头解析命令行 int swSize = 1; // 默认 Stop-and-Wait int rwSize = 1; if(argc > 1) swSize = atoi(argv[1]); if(argc > 2) rwSize = atoi(argv[2]); printf("SW=%d, RW=%d\n", swSize, rwSize); // 修改 GetFrameFromHost():限制队列长度 void GetFrameFromHost(LinkQueue *q, int maxSw) { if(QueueLen(q) >= maxSw) return; // 达到发送窗口上限,停止注入 // ... 其余逻辑不变 } // 调用时传入 swSize GetFrameFromHost(&QueueQ, swSize);

运行时输入sender.exe 4 4即启用 Go-Back-N(SW=4, RW=1)或 Selective Repeat(SW=4, RW=4),无需改代码。

5.3 模拟真实网络损伤:用概率模型注入丢包、延迟、乱序

原始代码用rand() % 5模拟 20% 丢包,过于粗糙。我们构建更真实的损伤模型:

// 在 receiver.cpp 中替换校验逻辑 float lossRate = 0.1f; // 10% 丢包率 float delayRate = 0.05f; // 5% 延迟率 float reorderRate = 0.02f; // 2% 乱序率 if((float)rand()/RAND_MAX < lossRate) { // 丢包:什么都不做,模拟网络静默 printf("帧 %u 丢失\n", packetreceive->head.seq); return; } if((float)rand()/RAND_MAX < delayRate) { // 延迟:Sleep 随机毫秒 Sleep(100 + rand()%500); } if((float)rand()/RAND_MAX < reorderRate && QueueLen(&recvQueue) > 0) { // 乱序:插入到队列中间而非队尾 insertAtRandomPos(&recvQueue, packetreceive); return; } // 正常处理...

效果:trace.log中将出现RTT=2000(延迟)、Seq=3出现在Seq=5之后(乱序),这才是贴近真实网络的仿真。

5.4 验证协议正确性:用 Wireshark 抓包对比

终极验证不是看printf,而是用专业工具抓取本机环回(loopback)流量。在sender.cpp中将clientadd.sin_addr.s_addr改为inet_addr("127.0.0.1"),启动 Wireshark,过滤ip.addr == 127.0.0.1 and tcp.port == 7001。你将看到:

  • DATA帧:TCP payload 中seq=0,data="..."
  • ACK帧:TCP payload 中kind=2 (ack),ack=1
  • 重传包:相同seq的 TCP segment 出现两次

玄学时刻:当 Wireshark 抓到的ACK序列与trace.log完全一致,且RTT分布符合你设定的损伤模型时,你就亲手造出了一个可信任的协议黑匣子。那一刻,谢希仁书上的“滑动窗口”不再是抽象名词,而是你内存里跳动的front/rear指针,是你日志里一行行SENT/RECEIVED的 timestamp。

从那以后我每次调试网络协议,都强制走一遍Wireshark + 日志 + 源码三线比对——因为协议的真相,永远藏在比特流与指针的夹缝里,不在 PPT 的动画里。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询