简介:计算机网络实验报告以TCP协议迭代开发为主线,面向计算机网络课程实验与大作业场景,适合正在学习传输层协议或需要完成类似实验的高校学生。报告完整记录了RDT 2.0、RDT 2.2、RDT 3.0、选择重传协议以及Reno拥塞控制五种机制的实现过程:从前期的错误检测与ACK校验,到超时重发、选择应答,再到慢开始、拥塞避免、快恢复与乘法减小等策略,均配有代码思路与LOG文件分析。针对LOG文件无法直接观察cwnd变化的难点,报告给出了在每个传输轮次后输出发送包序号、接收包序号及当前cwnd值的解决方案,并总结了迭代开发的优缺点与实验系统改进建议。资源压缩包仅含1个doc文档,约945KB,内容紧凑,兼具原理说明、问题排查与经验反思。目前已有291人学习下载,可作为TCP协议实验报告的优质参考,帮助读者深入理解拥塞控制的动态过程和传输层工作原理。
1. 从 RDT 2.0 到 Reno 拥塞控制:这份 TCP 实验报告能帮你打通什么
刚接触计算机网络大作业的人,最容易卡在一个问题上:教材里把可靠数据传输、滑动窗口、拥塞控制拆成好几章讲,每章都懂,合在一起就不知道代码该怎么写了。这份计算机网络实验报告走的是一条很经典的路线——从 RDT 2.0 开始,一步步把位错、ACK 损坏、丢包、乱序、拥塞全加回去,最终落到 Reno 拥塞控制的 cwnd 曲线。它本质上就是《计算机网络自顶向下》里可靠数据传输章节的工程化落地:每加一个信道故障,log 里就多一类记录,你盯着日志就能看到协议行为的变化。适合正在写传输层大作业的人拿来对照结构,也适合想搞懂「慢开始和拥塞避免到底在 log 里长什么样」的读者直接照着复现。
2. 可靠传输的三次迭代:位错、ACK 损坏与超时重发的 log 判读
2.1 RDT 2.0 的发送端循环:校验和与确认号队列
RDT 2.0 解决的是「信道上可能出现位错」的问题。接收端对收到的每一个包都要重新计算校验和,跟包里的校验字段比对,不一致就认为出错、不返回 ACK;发送端则循环检查确认号队列,看有没有新收到的 ACK。这里有个容易被忽略的细节:发送端不是在「收到 ACK 之后」才继续发,而是在一个 while 循环里不断轮询确认号队列。我当年第一次写的时候,把 ACK 处理写成了一个阻塞等待函数,结果发送端一次只能发一个包,整个实验的吞吐量直接变成停等协议的水平,后来才意识到轮询才是这个版本的灵魂。
发送端主循环常见的写法是这样,伪代码如下:
# RDT 2.0 发送端主循环(伪代码) seq = 0 # 当前发送序号 while True: if have_data_to_send(): pkt = make_pkt(seq, data, checksum(data)) # 计算校验和 udt_send(pkt) # 发送到信道 seq = 1 - seq # 序号翻转 ack = check_ack_queue() # 非阻塞检查确认号队列 if ack is not None and ack.seq == last_send_seq: # 确认号与当前发送序号匹配,说明对端收到了 mark_delivered(ack)这段逻辑里最关键的是check_ack_queue()。它不是recv()那样阻塞等待,而是每轮循环都去看一眼队列里有没有新 ACK。这样设计的目的,是让发送端在等待 ACK 期间还能继续干别的事(比如超时计时、下一轮的数据准备)。确认号队列本身保存的是接收端返回的原始 ACK 包,发送端拿到之后要先比对ack.seq,确认它回应的是当前这一号包。
参数上最容易踩的是校验和的位数。实验框架里校验和通常用 16 位反码和,也就是把数据按 16 位切块相加、溢出回卷、最后取反。如果你用 32 位整数硬存,截断时机不对,两个不同的报文可能算出同一个校验和,log 里就会出现「明明校验和错了但接收端照样回 ACK」的诡异现象。做法上我一般会单独写一个verify_checksum(pkt)函数,跟发送端共用一个实现,避免两边的算法漂移。
2.2 RDT 2.2 与 RDT 3.0:冗余 ACK 设计成关键转折点
RDT 2.2 解决的问题是「ACK 包本身也可能出现位错」。这意味着接收端传回来的 ACK 有可能在信道里被翻转,发送端如果拿一个错误的 ACK 当作成功信号,就会丢状态。课本标准答案是取消 NAK,改用「对最后一个正确收到包的冗余 ACK」——接收端每收到一个新包,不管它是不是乱的,都回当前期望序号的 ACK。发送端收到重复 ACK 就明白:上一个 ACK 丢失或损坏了,但不急着重发(那是 RDT 3.0 的事)。
RDT 3.0 在这个基础上加了超时重传。发送端发出数据后启动倒计时定时器,如果超时还没等到正确 ACK,就重发当前包。这份报告里明确写了「以上出错均能在超时之后重发」,指的就是把位错和 ACK 损坏都收敛到同一个兜底机制上。实现上要注意:定时器是对「最旧未确认包」启动的,不是对每个包各起一个定时器。
# RDT 3.0 发送端状态机(伪代码) start_timer() # 发出包后启动单个定时器 while True: event = wait_for_event() # 等待 ACK / 定时器超时 if event.type == 'ACK_RECEIVED': if not verify_checksum(event.pkt): continue # ACK 位错,忽略,等待超时 stop_timer() seq = 1 - seq elif event.type == 'TIMEOUT': udt_send(current_pkt()) # 重发当前未确认包 start_timer()stop_timer()必须放在校验和验证通过之后。我见过一个翻车现场:发送端收到错误 ACK 后直接把定时器停了,然后永远等不到重发,log 看起来就像「信道丢包但协议无动于衷」。这个顺序问题非常隐蔽,因为错误 ACK 出现的概率不高,跑几十轮才触发一次,你不刻意构造位错场景根本观察不到。
2.3 三份 log 的对照读法
做完三段迭代,你的目录下应该有三份 log。判断实现是否正确,不是看它「跑通了」,而是看三份 log 在同样的故障注入下表现不同:
| log 文件 | 注入的故障 | 应该观察到的现象 |
|---|---|---|
| rdt2.0.log | 数据包位错 | 接收端丢弃坏包,不产生 ACK;发送端继续轮询队列,队列为空 |
| rdt2.2.log | ACK 包位错 | 发送端校验和失败,拒绝该 ACK;接收端持续回冗余 ACK |
| rdt3.0.log | 数据包丢失 | 定时器超时,发送端重发相同序号;log 中出现连续相同的发送序号 |
刚写完的人最容易把 RDT 2.0 和 RDT 2.2 的 log 混为一谈,因为两版都是「校验和出错→不确认」。区分它们只有一个办法:看 log 里有没有接收端的「冗余 ACK」记录。RDT 2.0 没有冗余 ACK,接收端对坏包是完全沉默的;RDT 2.2 则要求接收端对每个正确包都回当前期望序号的 ACK,即使它跟上一个包重复。如果你的 RDT 2.2 log 里看不到重复 ACK,说明接收端的 ACK 逻辑还没改过来。
3. 选择响应协议:逐个应答背后的窗口与缓存设计
3.1 选择重传 vs 回退 N 步:为什么接收端要逐个应答
报告里第四个实验叫「选择响应协议」,本质上就是选择性重传(Selective Repeat)。接收端对于每一个校验和正确的接收包都进行应答,不因为某个包出错就把后面的包全部丢掉。这跟 Go-Back-N 有本质区别:GBN 接收端只接受按序到达的包,乱序的包直接丢弃;而选择重传里,接收端会把乱序但正确的包缓存起来,等缺失的包补上之后一并交给上层。
这一点可以直接从 log 里验证:如果接收端收到 3 号包时 2 号包还没到,GBN 的 log 会显示「丢弃 3 号包」,而选择重传的 log 会显示「缓存 3 号包,应答 2 号 ACK」。应答的序号是接收端期望的序号,不是实际收到的序号,这是最容易看岔的地方。
看代码的话,接收端的核心分支大概是这样的:
# 选择重传接收端逻辑(伪代码) if verify_checksum(pkt): if pkt.seq == expected_seq: deliver_to_upper_layer(pkt) expected_seq += 1 # 检查缓存中是否有连续包可以继续递交 while has_cached(expected_seq): deliver_to_upper_layer(cache_pop(expected_seq)) expected_seq += 1 else: cache_pkt(pkt) # 乱序包先缓存 send_ack(expected_seq - 1) # 回应的是「已连续收到的最后序号」 else: ignore(pkt) # 校验和错误,丢弃关键点是用expected_seq标记连续边界,send_ack(expected_seq - 1)告诉发送端:到这个序号为止都是连续收到的,你只缺中间那些空洞。这跟 RDT 2.2 的冗余 ACK 长得一样,但语义不同——RDT 2.2 是停等协议下的冗余确认,选择重传里这是带窗口的流水线确认。
3.2 发送端窗口:不是所有未确认包都要重传
选择重传的发送端要维护一个滑动窗口,窗口内可能有多个已发送但未确认的包。定时器超时后,只重传那个超时的包,而不是把窗口里所有包都重发一遍。窗口大小和序号空间有个硬约束:窗口大小不能超过序号空间的一半,否则接收端无法区分「新包」和「重传的旧包」。
每次超时事件里,发送端要维护一个dup_ack_count变量,连续收到相同 ACK 达到阈值(Reno 里是 3 次)就触发快速重传。这块你会在第 4 章看到它和拥塞控制联动。选择重传阶段还没有拥塞窗口,只有接收窗口,所以发送端只关心一个问题:哪些包的 ACK 还没到,各自对应的定时器什么时候超时。
# 发送端超时处理(伪代码) def on_timeout(seq): if seq in window: udt_send(make_pkt(seq, buf[seq], checksum(buf[seq]))) start_timer(seq) # 为该包重启独立定时器选择重传的每个未确认包都需要独立计时。如果只用一个全局定时器,超时后重发的可能是窗口里最旧的那个包,但丢的假设上却是另一个,log 里会出现大量无意义重传,吞吐直接崩掉。这是很多实现跑不出理想曲线的首要原因。
3.3 性能差异:从 log 里的重传次数反推窗口行为
对比实验报告里 RDT 3.0 和选择重传的 log,最直观的指标是「重传次数」。同样的丢包率下,RDT 3.0 每丢一个包就重发一个,而选择重传因为接收端缓存了乱序包,发送端只需要补发空洞。
真正拉大差距的是多包丢失场景。假设窗口是 8,一次突发丢包丢了 2 号和 5 号:RDT 3.0 的 log 会有两次超时重传;选择重传的 log 里除了两次超时,还可能因为后续 ACK 触发快速重传而提前补发。你可以写个小脚本统计每份 log 里retransmit行的总数,按 1000 个包为单位归一化,重传率差 3 到 5 倍都属于正常范围。
如果跑出来的结果相差 10 倍以上,基本可以断定接收端的缓存没做好——乱序包到达后被丢了,发送端被迫重传整个孤立区间的包,行为退化成了 GBN。排查时先看 log 里有没有cache标签,再看缓存递交后的deliver是否连续。
4. Reno 拥塞控制的完整链路:慢开始、快恢复与 cwnd 可视化
4.1 慢开始与拥塞避免:cwnd 的爬升路径从头看到尾
进入 Reno 阶段,发送端的窗口从「接收窗口」升级为「拥塞窗口 cwnd 和接收窗口的最小值」。这份实验的要求是解释慢开始、拥塞避免、快重传、快恢复、乘法减小五类行为,全部通过 log 文件验证。
慢开始的规则是:cwnd 从 1 开始,每个传输轮次结束翻倍。一个传输轮次不是「发一个包」的时间,而是「把当前 cwnd 个包全部发出去并收到对应 ACK」的往返。因此第 1 轮发 1 个,第 2 轮发 2 个,第 3 轮发 4 个,指数增长到 ssthresh 之前。
拥塞避免阶段,cwnd 每个轮次只加 1。这两个阶段的切换点就是 ssthresh。лог 里观察 cwnd 序列,会看到一段陡峭的 1、2、4、8 曲线,撞到 ssthresh 后变成平缓的 9、10、11、12。这个拐点如果不在 log 里,你根本没法跟面试官或老师解释「你确实实现了拥塞避免」。
发送端每个轮次的处理逻辑,报告里给出的解决方案可以归成这样:
# 拥塞控制发送端轮次处理(伪代码) def on_ack(ack): if cwnd < ssthresh: cwnd *= 2 # 慢开始,指数增长 else: cwnd += 1 # 拥塞避免,线性增长 log_round(seq_no, ack_no, cwnd, ssthresh) def on_loss(): ssthresh = max(cwnd // 2, 2) # 乘法减小:阈值减半 cwnd = ssthresh # Reno 快恢复后从新阈值开始log_round就是报告中说的「每经过一个传输轮次后输出当前网络状态」。它输出的四个字段我建议固定成:当前发送包序号、当前接收包序号、cwnd、ssthresh。发送序号和接收序号帮助你定位这轮对应的数据区间,cwnd 和 ssthresh 告诉你协议正处于哪个阶段。
4.2 快重传与快恢复:三个重复 ACK 触发的分水岭
快重传的触发条件不是超时,而是连续收到 3 个重复 ACK。Reno 的快恢复处理是:ssthresh 减半,cwnd 减半(有些实现是 cwnd = ssthresh),然后进入拥塞避免,而不是像 Tahoe 那样把 cwnd 降到 1 重新慢开始。
这里有一个实现层面的坑:快重传和慢开始的「乘法减小」都会修改 ssthresh,如果你在on_loss()里同时被超时和快速重传两个路径调用,ssthresh 会被减两次。实验过程中合理做法是让超时进入「慢开始重新起步」,而快速重传只做 ssthresh 减半并维持线性增长,两条路径分开处理。
日志里区分两种丢包处理的办法是看重传后的第一个 cwnd 值:如果是接近 ssthresh 的大值,说明走的是快恢复;如果变成 1,那就是超时重传后重新慢开始。这个差异非常明显,扫一眼就能判断实现有没有串线。
4.3 解决「log 看不出阶段」的核心手段:轮次级状态输出
报告的 2 分困难题里写得很实在:最大的困难是无法单独从 log 文件中直接看出慢开始以及拥塞避免等过程。原因很典型——传统实验框架的 log 记录的是「包级事件」,比如发送了哪个序号、收到了哪个 ACK,这些事件本身不携带 cwnd 信息,你把几百行日志从头翻到尾也画不出窗口变化。
我当时用的方法跟报告里写的一致:在每个传输轮次结束后额外输出一条状态行。代码上就是在on_ack()的结尾加一行输出:
# 每轮次结束输出当前窗口状态(python) logger.info(f"round={round_count} send_seq={next_seq} ack_seq={last_ack} " f"cwnd={cwnd} ssthresh={ssthresh} state={tcp_state}")state字段是我自己加的,取值为SS(慢开始)、CA(拥塞避免)、FR(快恢复),这样后续分析脚本可以直接按状态字段做统计,不用再靠 cwnd 的数值变化反推阶段。加上这个字段之后,log 的可读性直接从「黑匣子」变成「透明链路」。
这份 log 样例大概长这样:
round=1 send_seq=0 ack_seq=-1 cwnd=1 ssthresh=8 state=SS round=2 send_seq=1 ack_seq=0 cwnd=2 ssthresh=8 state=SS round=3 send_seq=3 ack_seq=2 cwnd=4 ssthresh=8 state=SS round=4 send_seq=7 ack_seq=6 cwnd=8 ssthresh=8 state=CA round=5 send_seq=15 ack_seq=14 cwnd=9 ssthresh=8 state=CA看到第 4 轮 cwnd 撞到 ssthresh=8,状态从 SS 切成 CA,整个拥塞控制的过程一眼就明白了。这份报告后面所有阶段论证,靠的就是这一行日志。
5. 避坑清单:日志分析、超时参数与迭代顺序的常见问题
5.1 现象:log 文件几百行,却看不出慢开始和拥塞避免的边界
原因:日志只记录了包收发事件,没有输出 cwnd 和 ssthresh。包级事件的信息量不够,你只能看到「发了、确认了」,看不到窗口在怎么变化。 解决:每个传输轮次结束后输出状态行,至少包含发送包序号、接收包序号、cwnd、ssthresh。不要在发送函数里插日志,要在收到 ACK 并完成窗口更新之后再打,否则打出来的是旧窗口值。
5.2 现象:慢开始跳过了 ssthresh,直接进入线性增长,但阈值没变
原因:发送端代码里把慢开始的翻倍条件和拥塞避免的加一条件写反了,或者判断条件用了cwnd <= ssthresh,导致 cwnd 等于 ssthresh 的那一轮直接进位。 解决:用if cwnd < ssthresh做慢开始,else做拥塞避免。同时确认 ssthresh 初始值不是 0,有些框架默认 ssthresh=0,会直接跳过整个慢开始过程。
5.3 现象:快重传触发后,cwnd 直接变回 1
原因:实现的是 TCP Tahoe 而不是 Reno。Tahoe 在快速重传后强制 cwnd=1,重新慢开始;Reno 则是 ssthresh 减半、cwnd 减半,进入快恢复。 解决:检查丢包处理分支。Reno 的快恢复路径里,重传后 cwnd 应该等于新 ssthresh,而不是 1。如果题目要求 Reno,这个值不对就是版本错误。
5.4 现象:重复 ACK 计数不触发快重传,每次都等到超时
原因:dup_ack_count没有在收到新 ACK 时重置。例如连续收到 3 个重复 ACK 之后,第 4 个是新 ACK,计数逻辑若没清零,再收到重复 ACK 就会提前触发。 解决:收到任何「不等于当前期望序号」的 ACK 时累计,收到新 ACK 时清零。快重传阈值固定为 3,不要把它跟 ssthresh 混用。
5.5 现象:多份 log 之间时间戳对不上,分析脚本无法合并
原因:不同迭代版本的 Logger 没有区分标记,RDT 2.0 和 Reno 的日志格式混在一个文件里,或者重启实验时没有清空旧文件。 解决:每个版本单独一个 log 文件,第一行写入版本号,例如# rdt3.0或# reno。分析脚本按版本号分块处理,不要依赖文件生成时间。
6. 让 log 替你说话:一个快速提取 cwnd 曲线的小脚本
第 4 章加了轮状态输出之后,还有一个收尾工作要做:把 log 里的 cwnd 序列提取出来,画成曲线或者算阶段占比。手工翻文件既不高效也不准确,我习惯在实验结束后跑一个一次性脚本:
# 提取 cwnd 序列并按阶段统计(python) import re pattern = re.compile( r"round=(\d+).*?cwnd=(\d+) ssthresh=(\d+) state=(\S+)" ) rounds, cwnds, states = [], [], [] with open("reno.log") as f: for line in f: m = pattern.search(line) if m: rounds.append(int(m.group(1))) cwnds.append(int(m.group(2))) states.append(m.group(4)) # 输出阶段切换点 for i in range(1, len(states)): if states[i] != states[i - 1]: print(f"round {rounds[i]}: {states[i-1]} -> {states[i]}, cwnd={cwnds[i]}")这个脚本只做一件事:扫描round=开头且带cwnd=的状态行,把阶段切换点打印出来。输出大概是round 4: SS -> CA, cwnd=8,正好对应慢开始撞 ssthresh 的那一轮。如果 log 里没有打印state字段,你也可以用 cwnd 的序列自己判断:连续翻倍是慢开始,连续加一是拥塞避免。
我一般还会顺手算一下每个阶段的轮次占比。慢开始占的轮次少但发出去的包多,拥塞避免轮次多但每个轮次只多一个包,这个对比能直观说明两种增长方式的差异。把这些数据直接写进实验报告的分析段落,比单纯贴 log 截图有说服力得多。
另外说一个我自己的习惯:每次实验前先跑一遍回归,把前一版 log 备份下来,用 diff 对比本次改动对行为的影响。RDT 2.0 到 2.2 的改动如果让 2.0 的 log 也变了,说明你改坏了公共的校验或确认逻辑,而不是只加了新功能。从那以后我每次跑拥塞控制实验都强制走一遍这个流程:先确认新 log 第一行有 version 标记,再确认每轮状态行里有 cwnd 和 ssthresh,最后才跑长时模拟。这套检查帮我省掉的排错时间,远比写脚本花掉的时间多,希望帮到你。
本文还有配套的精品资源,点击获取