简介:面向北京邮电大学计算机网络实验课程的滑动窗口协议模拟项目,适合正在完成计网期末大作业或课程设计的本科生参考。资源以C语言实现,完整覆盖数据链路层滑动窗口核心机制,包括选择性重传、回退N帧及其改进版本,并集成CRC32校验、命令行参数解析等底层模块;协议头文件与各算法源文件分离,模块化程度高,便于逐段阅读和二次修改。压缩包共18个文件,以8个C源文件和4个头文件为主体,附带Visual Studio工程文件、项目说明文档、生成效果图及README,整体仅71KB,结构清晰,直接编译即可运行。源码经过本地编译验证,评审分达95分以上,难度适中,内容经助教审定,既适合作为期末答辩的完整方案,也可作为同类实验的排错对照。目前已有173人学习,值得需要高质量计网实验参考的同学下载使用。
1. 滑动窗口协议模拟实验:从教材图到可运行的 C 代码
滑动窗口协议在计网教材里画得清清楚楚,发送方维护窗口、接收方反馈 ACK、超时重传,逻辑好像就三句话。但一旦落到 C 语言实现,问题就从「协议对不对」变成「定时器为什么没触发」「CRC 明明算了为什么对不上」「窗口滑动到底该往前挪几格」。这份资源就是某邮电类高校计网实验的完整答案:一套用 C 写的滑动窗口协议模拟器,支持 Go-Back-N 和 Selective Repeat 两种经典协议,外加一个改进版 GBN,含工程文件、源码和运行说明,源码本地编译可运行,评审分在 95 以上。适合正在做计网课程设计、期末大作业的同学,也适合想彻底搞懂窗口机制底层逻辑的开发者。
2. 整体架构拆解:一个模拟器如何把三层协议跑起来
2.1 目录结构与模块职责
拿到压缩包解压后,整个工程的文件划分非常清晰,典型的「公共模块 + 协议实现」两层结构。先看一份文件清单:
| 文件 | 职责 |
|---|---|
| protocol.h / protocol.c | 帧格式定义、协议类型枚举、公共状态机 |
| datalink.h / datalink.c | 模拟数据链路层:发送、接收、丢包注入、计时器 |
| gobackn.c | Go-Back-N 协议实现 |
| selective.c | Selective Repeat 协议实现 |
| gbn_improve.c | 改进版 GBN(快重传 + 动态超时) |
| crc32.c | CRC32 校验算法 |
| lprintf.h / lprintf.c | 带时间戳的日志输出模块 |
| getopt.h / getopt.c | 命令行参数解析(Windows 环境移植) |
| datalink.sln / datalink.vcxproj | Visual Studio 工程文件 |
| README.md | 编译与运行说明 |
这个划分值得借鉴的地方在于:把 CRC 校验、日志、参数解析这些「和协议无关」的东西全部抽成独立模块,gobackn.c、selective.c、gbn_improve.c 里只写协议逻辑。这样同一套模拟链路可以反复切换协议,不需要复制粘贴。对于要交课程设计的同学,这种结构在答辩时也特别好讲——模块边界清清楚楚,老师一眼就能看出你没把代码堆在一个文件里。
2.2 模拟链路层是怎么工作的
datalink.c 在这套代码里扮演的角色是「虚拟信道」。它不对接真实的网卡,而是用随机数生成器模拟丢包、乱序和延迟。核心思路是:上层协议调用send_frame()发送帧,datalink 内部先判断「这一帧要不要丢」,根据传入的丢包率生成随机数,决定是直接丢弃还是转发给接收方。
// datalink.c 核心发送逻辑(简化结构) int send_frame(frame_t *frame, double loss_rate) { if (rand() % 100 < loss_rate * 100) { lprintf("DROP seq=%d, simulate packet loss\n", frame->seq); return 0; // 模拟丢包,返回 0 表示发送失败 } // 转发到接收端,记录日志 lprintf("SEND seq=%d, len=%d\n", frame->seq, frame->len); deliver_to_receiver(frame); return 1; }这里loss_rate是 0~1 之间的浮点数,比如传 0.1 就代表 10% 的丢包率。rand() % 100 < loss_rate * 100这个判断方式简单直接,但注意它只模拟了随机丢包,没模拟突发丢包——真实网络里连续丢包的概率更高,这部分在改进版里可以自己扩展(后面第 6 章会讲)。
2.3 公共基础设施:日志、CRC、命令行参数
这套代码最容易被忽略但最有工程价值的是三个辅助模块。lprintf.c 是一个加强版 printf,输出每条日志都带相对时间戳,用来追溯「第几毫秒发了哪一帧、超时在哪一刻触发」非常有用。跑一次模拟下来,整个协议交互过程就是一条清晰的时间线,对写实验报告、截图说明问题特别好使。
getopt.c 是 Linux 下命令行解析库的 Windows 移植版。因为原工程跑在 Visual Studio 里,而 VS 没有自带 getopt,作者就把这个文件一并放进来了。启动时通过参数指定协议类型、窗口大小、丢包率,不用改代码就能切换场景,例如:
datalink.exe -p gbn -w 4 -l 0.1-p指定协议(gbn / sr / improve),-w指定窗口大小,-l指定丢包率。比在代码里改宏定义再重新编译要高效得多,这对我来说是个很重要的加分项——课程设计的演示环节,现场调参比现场改代码体面多了。
3. 核心代码逐段拆解:帧结构、CRC32 与窗口状态机
3.1 帧结构定义:字节对齐是个大坑
所有协议都建立在帧结构之上。protocol.h 里定义的帧格式决定了整个模拟器的数据流形态。一个完整的数据帧至少需要承载:帧类型、序号、确认号、数据长度、数据内容、校验值。
// protocol.h 帧结构与协议类型定义 typedef enum { FRAME_DATA = 0, FRAME_ACK = 1, FRAME_NACK = 2 } frame_type_t; #pragma pack(push, 1) // 取消字节对齐,避免结构体出现填充字节 typedef struct { frame_type_t type; // 帧类型:数据帧 / ACK / NACK unsigned int seq; // 发送序号 unsigned int ack; // 确认号(捎带确认时使用) unsigned short len; // 有效数据长度 unsigned char payload[256]; // 数据载荷 unsigned int crc; // CRC32 校验值 } frame_t; #pragma pack(pop)#pragma pack(push, 1)这行值得单独拿出来说。C 语言结构体默认会对齐到 4 字节边界,如果去掉这个指令,frame_t 里会插入填充字节,发送端和接收端计算出来的帧大小不一致,CRC 永远对不上。我第一次写类似的模拟器时就栽在这里,打印结构体大小和实际收发字节数差了十几个字节,查了半天才发现是字节对齐问题。这类模拟实验的数据帧是直接在「内存拷贝」层面模拟信道传输的,结构体怎么定义,收发双方就必须按同样的内存布局解释。
3.2 CRC32 校验与常见翻车点
数据帧经过模拟信道可能被「篡改」——注意这和丢包不是一回事。有的实验要求模拟比特翻转,让接收方发现校验错误并丢弃帧。crc32.c 用的是标准查表法,初始化一个 256 项的 CRC 表,之后每字节查表计算,性能好且实现稳定。
// crc32.c 查表法核心逻辑 static unsigned int crc_table[256]; void crc32_init() { for (unsigned int i = 0; i < 256; i++) { unsigned int c = i; for (int k = 0; k < 8; k++) { if (c & 1) c = 0xEDB88320 ^ (c >> 1); // 标准 CRC32 多项式 else c >>= 1; } crc_table[i] = c; } } unsigned int crc32_calc(unsigned char *buf, unsigned int len) { unsigned int crc = 0xFFFFFFFF; for (unsigned int i = 0; i < len; i++) { crc = crc_table[(crc ^ buf[i]) & 0xFF] ^ (crc >> 8); } return crc ^ 0xFFFFFFFF; }接收方拿到帧后重新计算 CRC,和帧尾携带的 crc 字段比对,不一致就丢弃。这里有个参数细节:CRC 的计算范围必须包含「帧类型 + 序号 + 确认号 + 长度 + 载荷」,但绝不能包含 crc 字段本身。很多同学会在计算时用sizeof(frame_t)把整个结构体算进去,结果自己把校验值也算进了校验范围,永远对不上。正确做法是单独定义计算长度,或者计算出 crc 后再填充字段。
3.3 滑动窗口的状态机:发送方与接收方的同步
窗口协议的核心是两组变量:发送方的 base(窗口下沿)、next_seq(下一个待发序号),接收方的 expected_seq(期望收到的序号)。窗口滑动的本质是这些整数变量的推进。看发送方的核心结构:
// gobackn.c 发送窗口基础结构 #define MAX_SEQ 32 // 序号空间,窗口最大不能超过 16 typedef struct { unsigned int base; // 窗口下沿(最早未确认的帧) unsigned int next_seq; // 下一个要发送的新帧 unsigned int window_size; // 窗口大小 frame_t *buffer[MAX_SEQ]; // 已发送未确认的帧缓存 } sender_t; // 尝试发送新帧 void send_new_frame(sender_t *snd) { // 窗口满:next_seq 不能超过 base + window_size if (snd->next_seq - snd->base >= snd->window_size) { lprintf("WINDOW FULL, stop sending\n"); return; } frame_t *f = create_data_frame(snd->next_seq); snd->buffer[snd->next_seq % MAX_SEQ] = f; send_frame(f, current_loss_rate); snd->next_seq++; }next_seq - base表示当前「在途未确认」的帧数。当这个差值等于 window_size 时,说明窗口已经填满,发送方必须停下来等 ACK。这个判断逻辑是所有滑动窗口协议的命门——如果这里写成>=或>时边界出错,窗口会多滑一格或少滑一格,表现出的症状是「协议跑一会儿就卡死」或者「序号错乱」。
3.4 定时器与超时重传
Go-Back-N 只需要一个定时器——因为它要么全部重传,要么等待累积 ACK 推进窗口。模拟器里这个定时器的实现不会真的去调用系统时钟中断,而是在主循环里不断检查「当前时间 - 发送 base 帧的时间」,超过超时阈值就触发重传。
// 主循环中的超时检查 while (running) { handle_received_frames(); // 处理收到的 ACK / 数据帧 // 检查 base 帧是否超时 double elapsed = get_elapsed_time(); if (elapsed - send_time[snd.base % MAX_SEQ] > timeout_ms) { lprintf("TIMEOUT base=%d, retransmit window\n", snd.base); retransmit_window(&snd); // 重传 base 到 next_seq-1 的所有帧 } }retransmit_window是 GBN 的「笨重」所在:重传窗口里的所有帧,即使接收方已经收到了其中的绝大部分。这也是 GBN 在丢包率升高时吞吐率断崖式下跌的原因——窗口越大,一次超时重传的代价越高。理解了这个函数,后面看 selective.c 的改进就顺理成章了。
4. 三种协议实现对比:GBN、选择性重传与改进版
4.1 gobackn.c:累积确认与整体重传
Go-Back-N 的接收方逻辑极其简单:只接受顺序到达的帧,一旦收到失序帧直接丢弃,并重新发送对最后一个有序帧的 ACK。发送方收到 ACK 时执行「累积确认」——不管 ACK 确认的是哪一帧,base 直接跳到 ack + 1,因为 GBN 规定 ACK n 代表「n 及之前的所有帧都成功收到」。
// gobackn.c 收到 ACK 后的窗口推进 void handle_ack(sender_t *snd, unsigned int ack_seq) { // 累积确认:ack_seq 之前的帧全部确认 if (ack_seq >= snd->base) { snd->base = ack_seq + 1; lprintf("ACK %d, base move to %d\n", ack_seq, snd->base); } // 如果 base 追上 next_seq,说明全部确认,停止计时器 if (snd->base == snd->next_seq) { stop_timer(); } }这里ack_seq >= snd->base的条件还需要加一个序号空间的限制,因为当序号回绕(从 31 回到 0)时,简单的大小比较会失效。原工程里的序号空间设计为 MAX_SEQ=32,窗口最大 16,保证「窗口内序号不重复」,这是教材里「序号空间 ≥ 2 倍窗口大小」的工程落地。
4.2 selective.c:选择重传的乱序缓存
Selective Repeat 的接收方要做的核心事情就是「乱序缓存」。它必须把提前到达的帧先存起来,等缺的帧到齐后再一起向上层交付。
// selective.c 接收缓冲区 typedef struct { int received[MAX_SEQ]; // 位图,标记哪些序号已收到 frame_t *frames[MAX_SEQ]; // 缓存帧 unsigned int expected; // 期望的连续序号起点 } receiver_t; // 收到一帧的处理逻辑 void handle_data_frame(receiver_t *rcv, frame_t *f) { if (f->seq < rcv->expected || rcv->received[f->seq % MAX_SEQ]) { lprintf("DUP seq=%d, discard\n", f->seq); // 重复帧或过期帧 send_ack(f->seq); // 仍然回复 ACK,防止发送方超时 return; } rcv->frames[f->seq % MAX_SEQ] = copy_frame(f); rcv->received[f->seq % MAX_SEQ] = 1; // 尝试连续交付 while (rcv->received[rcv->expected % MAX_SEQ]) { deliver_to_upper_layer(rcv->frames[rcv->expected % MAX_SEQ]); rcv->received[rcv->expected % MAX_SEQ] = 0; rcv->expected++; } send_ack(f->seq); // 对每一帧单独确认 }SR 的 ACK 不是累积的,每一帧都要单独确认,接收窗口是「缓存 + 位图」的组合。注意处理重复帧时也要回 ACK——这算一个经典的边界问题:如果接收方收到了已经交付过的帧就静默丢弃,发送方的超时重传会一直在同一个帧上打转,因为发送方以为 ACK 丢了才重传。
4.3 gbn_improve.c:改进版到底改了哪里
改进版在代码结构上基于 gobackn.c,但加入了两个关键机制:快重传和动态超时。所谓快重传,就是发送方连续收到 3 个重复 ACK 时,不等定时器超时,立即重传对应帧。这在真实 TCP 里是标配手段,放在课程设计里属于「超出教材但完全合理」的加分项。
// gbn_improve.c 快重传判断 void handle_ack_improve(sender_t *snd, unsigned int ack_seq) { if (ack_seq == snd->base) { snd->dup_ack_count++; if (snd->dup_ack_count >= 3) { lprintf("FAST RETRANSMIT base=%d\n", snd->base); retransmit_frame(snd->base); // 只重传 base 一帧,不是整个窗口 snd->dup_ack_count = 0; } } else if (ack_seq > snd->base) { snd->base = ack_seq + 1; snd->dup_ack_count = 0; // 窗口推进,重置计数 } }动态超时的时间不是固定写死的,而是统计最近几次 RTT(发送到收到 ACK 的往返时间)取均值再加上一定余量。固定超时的问题很明显:网络一抖动就超时误判,频繁重传反而加剧拥塞。改进版维护一个 RTT 样本数组,每次收到 ACK 时更新估算值。
4.4 三种协议对比与选型参考
| 对比维度 | Go-Back-N | Selective Repeat | 改进版 GBN |
|---|---|---|---|
| 接收方缓存 | 无,失序帧直接丢弃 | 有,缓存失序帧 | 无 |
| 确认方式 | 累积确认 | 逐帧确认 | 累积确认 + 重复 ACK 快重传 |
| 重传粒度 | 整个窗口 | 只重传缺失帧 | 单帧(快重传)/ 窗口(超时) |
| 实现复杂度 | 低 | 高 | 中 |
| 丢包率高时吞吐率 | 急剧下降 | 保持稳定 | 下降但优于 GBN |
放在计网实验的场景下,我建议至少把 GBN 和 SR 两个版本都跑通并对比输出日志——这本身就可以作为实验报告的核心分析内容。改进版适合学有余力想冲高分的人,答辩时你能说清楚「快重传为什么能减少超时等待」就已经超过大部分同学了。
5. 编译运行与避坑指南
5.1 从解压到看到结果的完整步骤
这套工程是用 Visual Studio 构建的,拿到手上最稳妥的做法是直接用 VS 打开datalink.sln编译运行。具体过程:解压到本地(注意路径不要带中文),双击 .sln,等待 VS 加载完成,确认解决方案配置是 Debug + x86(部分 Windows 环境可能需要 x64,取决于你的系统),直接 Ctrl+B 编译,无报错后 Ctrl+F5 运行。
运行后程序会进入命令行交互界面,按 README 里的参数格式输入协议类型、窗口大小、丢包率即可。一组值得复现的经典对比实验是这样的:
datalink.exe -p gbn -w 4 -l 0.1 datalink.exe -p sr -w 4 -l 0.3第一组用 10% 丢包率跑 GBN,窗口 4;第二组用 30% 丢包率跑 SR,窗口 4。对比两组日志里重传的次数和最终完成传输的时间,就能直观看到选择性重传在高丢包率下的优势。每个帧的发送、确认、丢弃和超时事件都会通过 lprintf 打印到屏幕上,跑完可以在输出窗口看到完整的时序。
5.2 避坑记录:四个真实踩过的坑
坑一:编译报错 error C4996(fopen 被认为不安全)。现象是 VS 把fopen、strcpy这类函数标记为不安全,直接报错中断编译。原因是 VS 默认启用安全检查,而工程里用了标准 C 库函数。解决方式有两种:在项目属性 → C/C++ → 预处理器里加_CRT_SECURE_NO_WARNINGS,或者把报错函数替换成fopen_s等带_s后缀的安全版本。我一般选第一种,改代码容易引入新问题。
坑二:程序跑起来后所有帧都丢失,输出全是 DROP。现象是发送方发一帧丢一帧,接收方一个 ACK 都回不来。原因是丢包率参数传的是整数而非小数,比如传了 50 而不是 0.5。datalink.c 里rand() % 100 < loss_rate * 100中 50 * 100 = 5000,判断永远成立。解决:确认丢包率参数始终是 0~1 之间的小数。这属于典型的参数取值范围与代码实现不一致导致的翻车。
坑三:CRC 校验总是失败。现象是接收方打印CRC ERROR,但发送方明明计算过。原因有两个方向:一是结构体字节对齐导致收发双方的帧大小不一致,二是计算 CRC 时把 crc 字段本身算进去了。解决:检查是否用了#pragma pack(push, 1),以及计算长度是否为offsetof(frame_t, crc)而不是sizeof(frame_t)。这是所有类似模拟器里最容易遇到的玄学问题。
坑四:窗口大小超过序号空间的一半时死锁。现象是程序在某处卡住,不再发送任何帧,也没有超时发生。原因是序号空间 MAX_SEQ=32,窗口最大只能到 16。这是协议设计的基本约束——窗口超过序号空间一半时,接收方无法区分「新帧」和「重传的旧帧」。解决:-w参数控制范围,不要超过 16。
提示:如果用命令行参数跑完看不到完整输出,把控制台窗口的「快速编辑模式」关掉,否则程序运行时鼠标误点到控制台窗口会暂停执行。
6. 二次开发:把模拟协议挂到真实 UDP 链路上
拿到这份代码最典型的二次开发方向,是把协议栈里的send_frame()和deliver_to_receiver()两个函数从「虚拟信道」替换成真实的 UDP socket 收发,让滑动窗口协议跑在真实网络上。这个改造既能用于课程设计的进阶展示,也能应用到比如局域网文件传输的小工具上。
// 改造思路:把 send_frame 指向 socket 发送 int send_frame_udp(frame_t *frame, double loss_rate) { int sock = get_udp_socket(); sendto(sock, (char *)frame, sizeof(frame_t), 0, &remote_addr, sizeof(remote_addr)); // 注意:真实网络自带的丢包率和模拟参数 loss_rate 无关 return 1; }改造时要注意几个关键差异:真实网络里不需要手动模拟丢包,但需要处理网络的乱序到达;loss_rate参数可以保留但应该从模拟链路中移除;接收端recvfrom后必须重新做 CRC 校验,因为 UDP 只保证包完整性不保证内容不被篡改(虽然概率极低)。改造完成后,用本机 127.0.0.1 回环地址做自测,再通过设置防火墙丢包规则来验证三种协议在真实网络下的行为差异。
一个小技巧是保留原有的 lprintf 日志体系,在发送和接收两条路径上各自打上时间戳。跑一次真实网络传输后,用日志对比「发送序号」和「确认序号」的推进速度,能直观地看出窗口利用率是否达到了预期。从那以后我每次拿到协议代码,都会先跑一遍丢包率递增的实验,再决定是否信任它的窗口和缓存边界处理——这个习惯帮我绕开过不少看起来像随机 bug、实际上却是边界条件写错的问题。希望这份拆解能让你在做计网实验时少走几步弯路。
本文还有配套的精品资源,点击获取