简介:这是一份基于 Python 的计算机网络课程实验源码,模拟数据链路层 GBN(后退 N 帧)协议,实现可靠文件传输,适合计算机、网络工程、通信与自动化等专业的课程设计、实验作业或项目初期立项参考。压缩包内共 21 个文件,以 12 个 Python 脚本为主,配合 7 个 ini 配置、1 个 txt 说明和 1 个 README,整体约 22KB,结构轻量且便于阅读。代码按协议核心、帧/字节/地址处理、UDP 通信、日志记录、随机错误注入和自动测试等模块拆分,并带有配置样例与运行说明,可直接运行验证;通过修改丢包率、超时时间等参数,能直观观察 GBN 滑动窗口、超时重传以及可靠文件传输的完整流程。目前已有 425 人学习,适合需要完成数据链路层实验、复习网络原理或动手扩展可靠传输功能的在校学生与初学者,也可作为课堂教学演示素材。
1. 数据链路层GBN模拟,到底难在哪
很多同学在计算机网络课上第一次接触数据链路层的GBN协议(Go-Back-N,后退N帧),教材画了一堆窗口和箭头的时序图,考试也能默写,但真要动手写代码就懵了——发送方怎么维护窗口?超时重传重传哪些帧?接收方收到乱序帧怎么处理?这些都是纸面上的概念,落到Python代码里全是细节。这个实验项目的本质,就是在一对UDP Socket之间模拟一条会丢包的信道,再用GBN协议把文件从一端可靠地搬到另一端,以此验证滑动窗口和自动重传机制到底是怎么工作的。它适合计算机网络课程的学生、准备保研或求职时想拿项目说话的本科生,以及想补一补“协议怎么变成代码”这块短板的开发者。
2. GBN核心机制在代码里怎么落地:窗口、序号与定时器
2.1 先想清楚GBN的行为边界
写代码之前,必须把GBN协议的行为约束理解到位。GBN是停等协议的改进版,发送方一次可以连续发送窗口内的多个帧,不用等每一帧的确认再发下一帧。但约束也很明确:只有收到ACK才把窗口向前滑动;任何一帧超时,发送方要重传从超时帧开始的窗口内所有帧;接收方只接受按序到达的帧,乱序帧一律丢弃,但会为最后一个正确接收的帧重复发送ACK。这三个约束对应到代码里,就是三个核心数据结构:发送窗口(一个列表)、计时器(一个数值或时间戳)、帧缓存(接收方的期望序号)。
很多初学者在写GBN时,最容易犯的一个错误是把计时器做成了“每帧一个”,最后代码变成选择性重传。正确的GBN实现里,因为接收方会把乱序帧全部丢弃,发送方只需要一个计时器,记的是窗口内最早未确认帧的等待时间。一旦这个计时器超时,就把整个窗口的所有帧全部重发一遍。
从代码结构上看,我建议把整个实验拆成五个模块:帧格式定义、发送方状态机、接收方状态机、模拟信道、文件分块与重组。这样每个模块都可以单独调试,不用每次跑完整流程才看结果。
2.2 帧格式定义:头部、数据和校验字段
GBN协议工作在数据链路层,帧是它传输的基本单位。在Python模拟实现里,帧就是一条自定义的二进制结构,至少要有:帧类型(数据帧还是ACK帧)、序号、确认号、校验值、长度和数据载荷。序号字段在真实链路层只有有限的bit位,比如3位能表示0到7,窗口大小就不能超过7。模拟实现里可以用int直接表示,但想做得严谨一点,建议还是用取模运算模拟序号回绕。
import struct import zlib # 帧格式定义 # 类型: 0=DATA, 1=ACK # 序号: 0~63 循环使用(模拟3bit序号空间) # 长度: 数据载荷的字节数 # 校验: CRC32值,用于检测传输错误 # 数据: 原始字节 FRAME_HEADER = struct.Struct('!BBII') # 类型, 序号, 长度, 校验值 def make_data_frame(seq_num: int, payload: bytes) -> bytes: checksum = zlib.crc32(payload) & 0xffffffff header = FRAME_HEADER.pack(0, seq_num & 63, len(payload), checksum) return header + payload def make_ack_frame(ack_num: int) -> bytes: # ACK帧不需要数据载荷 header = FRAME_HEADER.pack(1, 0, 0, 0) # 用序号字段携带确认号,减少一个字段 # 也可以拆成ack_num专用字段,这里为简洁直接复用seq位置 header = FRAME_HEADER.pack(1, ack_num & 63, 0, 0) return header这里用了Python的struct模块来打包二进制数据。!BBII表示网络字节序(大端)、一个无符号字节、一个无符号字节、两个无符号整数,分别对应帧类型、序号、长度、校验值。把序号限制在0到63之间,是在模拟真实链路层序号空间的有限性,这样你就必须处理“序号回绕”的问题,而不是用一个无限增长的整数。做了这个限制之后,窗口大小最大只能是63的一半再取整,因为接收方的接收窗口是1,协议要保证发送窗口内的序号不能和重传帧的序号产生歧义。
2.3 发送方状态机:窗口滑动与超时重传
发送方的核心是一个字典或列表,用来缓存已经发出但尚未确认的帧。配合一个定时器,记录最早未确认帧的发出时间。每次收到有效的ACK时,把窗口起点推进到确认号加一,并重置计时器。每次计时器超时,就把窗口内所有帧重新发一遍。
import time import socket class GBNSender: def __init__(self, window_size=4, timeout=0.5): self.window_size = window_size self.timeout = timeout self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.sock.settimeout(timeout) # 窗口内待确认帧的缓存,key为序号,value为完整的二进制帧 self.unacked = {} # 窗口起点,即最早未确认帧的序号 self.base = 0 # 下一个要发送的序号 self.next_seq = 0 # 最后一次发送base帧的时间戳,用于超时判断 self.last_send_time = time.time() def send_file_chunk(self, data: bytes, peer_addr): seq = self.next_seq frame = make_data_frame(seq, data) self.sock.sendto(frame, peer_addr) self.unacked[seq] = frame if self.base == seq: self.last_send_time = time.time() self.next_seq = (self.next_seq + 1) % 64 def handle_ack(self, ack_num: int): # 累计确认:收到的ack_num表示该帧及之前所有帧都确认了 while self.base != ack_num: self.unacked.pop(self.base, None) self.base = (self.base + 1) % 64 # 重置定时器,以当前窗口最早帧为准 if self.base in self.unacked: self.last_send_time = time.time() def check_timeout(self, peer_addr): if self.base in self.unacked and (time.time() - self.last_send_time) > self.timeout: # 超时:重传窗口内全部帧,同时重置计时器 for seq in sorted(self.unacked.keys()): self.sock.sendto(self.unacked[seq], peer_addr) self.last_send_time = time.time() return True return False这段代码的核心是handle_ack和check_timeout。handle_ack用了一个while循环来做累计确认,收到的ack_num表示接收方已经正确收到了序号为ack_num的帧,那么窗口内比它旧的帧全部可以清除。check_timeout判断窗口内最早帧的等待时间是否超过阈值,超时就把unacked字典里的帧全部重新发送。
一个值得注意的细节是self.sock.settimeout(timeout)。UDP默认是阻塞模式,如果不设置超时,recvfrom会一直卡住,接收线程就死了。设置超时之后,接收循环里要捕获socket.timeout异常。超时时间不能设得太短,否则在高丢包率下会频繁触发重传,导致窗口永远满着;也不能设得太长,否则一个帧丢了要等很久才恢复,吞吐量断崖式下降。
3. 模拟不可靠信道:UDP加随机丢包,构造数据链路层的真实面貌
3.1 为什么用UDP模拟物理信道
GBN协议本身是数据链路层的协议,但我们的实验跑在应用层,用的是Python的socket编程。真实的数据链路层跑在网卡和驱动里,普通用户碰不到,所以实验的常见做法是用UDP来模拟物理信道。UDP天然是不可靠的,有丢包、乱序、重复的可能,这一点和真实物理信道的噪声干扰有相似之处。我们要做的,就是在这一层之上,用GBN协议把“不可靠”变成“可靠”——这正是这个实验项目的意义所在。
模拟信道的常见接法有两种。一种是两个进程直接通信,信道逻辑写在发送方和接收方内部;另一种是单独写一个信道进程,收发双方都把数据先发给信道,由信道决定转发还是丢弃,更接近真实网络拓扑。后者调试起来更直观,因为你可以单独控制丢包率、延迟和重复帧的概率,不用改收发双方的代码。
import socket import random import time class LossyChannel: def __init__(self, listen_addr, forward_addr, loss_rate=0.2, delay_ms=10): self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.sock.bind(listen_addr) self.sock.settimeout(0.1) self.forward_addr = forward_addr self.loss_rate = loss_rate self.delay_ms = delay_ms def run(self): while True: try: data, addr = self.sock.recvfrom(4096) # 模拟信道中的噪声,按概率丢弃数据包 if random.random() < self.loss_rate: continue # 模拟传播延迟 time.sleep(self.delay_ms / 1000) self.sock.sendto(data, self.forward_addr) except socket.timeout: pass丢包率loss_rate是实验中最关键的参数。我推荐先设成0.1左右跑通全流程,再逐步加大到0.3、0.5,观察GBN协议的吞吐量变化。丢包率超过0.5之后性能会急转直下,因为每次丢包都要重传整个窗口,信道利用率趋近于零。
这里要注意一个细节:丢包模拟是按“包”为单位随机丢弃的,但从真实物理信道来看,噪声通常是突发性的,可能在一段时间内连续丢包,而不是均匀随机。如果想模拟突发丢包,可以改成“马尔可夫丢包模型”,用一个状态变量记录当前是否处于突发期,处于突发期时丢包概率提高到0.8,否则降到0.05。这个不做也不影响主流程,但做了之后实验报告会好看很多。
3.2 接收方状态机:只收按序帧,严格拒绝乱序
接收方实现相对简单,但正是它的简单决定了GBN协议的复杂度都堆在了发送方。接收方只需要维护一个expected_seq变量,表示期望收到的下一个序号。收到帧先做校验,通过后检查序号是否等于期望值,如果等于就上交给文件写入模块并发送ACK,然后把期望值加一;如果不等于,说明是乱序帧或重复帧,果断丢弃,并且要重新发送上一次的ACK。为什么要重复发ACK?因为ACK本身也可能丢,重发一次能防止发送方误以为帧丢了。
class GBNReceiver: def __init__(self, listen_addr): self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.sock.bind(listen_addr) self.sock.settimeout(0.2) self.expected_seq = 0 self.last_ack = -1 def recv_frame(self): try: data, addr = self.sock.recvfrom(4096) except socket.timeout: return None, None ftype, seq, length, checksum = FRAME_HEADER.unpack(data[:FRAME_HEADER.size]) payload = data[FRAME_HEADER.size:] # 校验失败视为坏帧,直接丢弃,不发送任何反馈 if zlib.crc32(payload) & 0xffffffff != checksum: return None, None if ftype != 0: return None, None if seq == self.expected_seq: self.expected_seq = (self.expected_seq + 1) % 64 self.last_ack = seq return payload, addr else: # 乱序帧丢弃,但重发ACK帮助发送方推进窗口 if self.last_ack != -1: self.sock.sendto(make_ack_frame(self.last_ack), addr) return None, None实现GBN接收方的逻辑时,有一个细节值得反复推敲:收到重复帧时要不要重发ACK?很多代码会直接丢弃不响应。但如果发送方因为ACK丢失而超时重传,接收方此时已经把这个序号的数据上交了,如果不重发ACK,发送方还会继续超时,导致吞吐量下降。所以正确的做法是:收到重复帧时,必须重发对应的ACK。这也是GBN和SR的重要区别——SR接收方可以缓存乱序帧,GBN接收方永远只能线性推进。
3.3 线程结构:发送和接收必须并行
socket程序初学者最容易犯的错是单线程收发。UDP虽然是全双工的,但Python的socket在阻塞模式下,recvfrom会卡住当前线程。如果发送方的代码先调用了sendto然后一直循环recvfrom等ACK,那就根本没机会继续发送窗口内的新帧,窗口机制压根跑不起来。
所以GBN发送方必须开两个线程:主线程负责读文件分块、调用send_file_chunk发送新帧;后台线程负责循环调recvfrom收ACK、调handle_ack滑动窗口。接收方同理,主线程在recv_frame里收数据,一旦收到完整帧就写入文件。有的同学会把ACK也交给单独线程处理,其实没必要,ACK是轻量交互,接收方主循环里顺带就干了。
import threading def start_sender(sender: GBNSender, peer_addr, file_data: bytes, chunk_size=1024): chunks = [file_data[i:i+chunk_size] for i in range(0, len(file_data), chunk_size)] total_frames = len(chunks) def ack_listener(): while True: try: data, _ = sender.sock.recvfrom(128) ftype, seq, _, _ = FRAME_HEADER.unpack(data[:FRAME_HEADER.size]) if ftype == 1: sender.handle_ack(seq) except socket.timeout: continue t = threading.Thread(target=ack_listener, daemon=True) t.start() idx = 0 while idx < total_frames or sender.unacked: # 窗口没满且还有数据要发,就继续发 while idx < total_frames and len(sender.unacked) < sender.window_size: sender.send_file_chunk(chunks[idx], peer_addr) idx += 1 # 处理超时重传 if sender.check_timeout(peer_addr): print('超时重传,窗口内帧数:', len(sender.unacked)) time.sleep(0.01) time.sleep(0.5)这段代码是我实际调试时用的骨架。主循环的退出条件有两层:一是文件的所有分块都发送完毕,二是窗口里的帧全部确认完毕。第一个条件解决“有没有发完”,第二个条件解决“对方有没有收完”。很多同学只看第一个条件,文件发完就结束进程,结果接收方还没收完数据就被强制终止,文件永远不完整。
4. 可靠文件传输:分块、校验和与写盘逻辑
4.1 文件分块策略与参数选择
文件大小和分块大小,直接影响传输效率。分块太大会导致一个帧出错就要重传整个窗口的大量字节,分块太小则帧头部开销占比太高,吞吐量上不去。对于这个课程实验,我一般建议用512到1024字节的分块。以太网的MTU是1500字节,UDP数据载荷如果超过1472字节就可能触发IP分片,在模拟信道里分片逻辑反而引入额外变量,所以避开最好。用1024字节分块,加入帧头之后总长度在1040字节左右,远小于MTU。
def split_file_into_chunks(file_path: str, chunk_size=1024): chunks = [] with open(file_path, 'rb') as f: while True: data = f.read(chunk_size) if not data: break chunks.append(data) return chunks接收方需要知道总共要收多少块,才能判断文件是否完整。这个信息怎么传达?有几种做法:在文件传输之前先发一个元数据帧,包含文件名、总块数、总字节数;或者在第一个数据帧的载荷里带上这些信息。元数据帧的做法更清晰,接收方收到元数据后才开始初始化文件写入。
4.2 接收方写盘:先写临时文件,收完再改名
写盘逻辑看似简单,但藏着几个坑。第一个坑是文件重复写入:接收方的expected_seq是循环使用的,如果序号回到0,写入位置也回到文件开头,会把之前的数据覆盖。解决办法是维护一个偏移量字典,记录每个序号对应的文件偏移,或者更简单——每收到一个合法帧,就用f.seek(seq * chunk_size)定位到该帧在文件中的位置。这个做法在文件大小不超过64个分块时有效,超过之后序号回绕就会写错位置。
def receive_file(receiver: GBNReceiver, save_path: str, total_chunks: int, chunk_size=1024): received = 0 with open(save_path + '.part', 'wb') as f: while received < total_chunks: payload, addr = receiver.recv_frame() if payload is None: continue # 已经通过expected_seq保证按序,直接追加写即可 f.write(payload) received += 1 import os os.rename(save_path + '.part', save_path)这里用了.part临时文件,传完再改名。好处是中途出错了不会污染原文件,下次重新传输直接覆盖临时文件就行。对于课程实验来说这已经够用,但如果想更严谨,还应该在元数据里保存总字节数,最后一帧如果不足1024字节时不会多写。
4.3 断点续传与整体校验:实验报告里最加分的一步
文件传完之后,怎么证明传输是可靠的?最直观的办法是对比原始文件和接收文件的哈希值。Python的hashlib库算SHA256只需要几行代码,但能给你的实验报告提供硬核证据。在发送方开始传文件之前,先算出原始文件的SHA256值,随元数据一起发给接收方;接收方收完文件后算一遍SHA256,两个值一致说明GBN协议保证了比特级的可靠性。
import hashlib def file_sha256(file_path: str) -> str: h = hashlib.sha256() with open(file_path, 'rb') asf: for chunk in iter(lambda: f.read(8192), b''): h.update(chunk) return h.hexdigest()断点续传是这个实验可以做的进阶功能。GBN的窗口机制决定了它天然支持部分重传——已经确认的帧不会被重发,所以只要在发送方维护一个“已写盘序号”的持久化记录,接收方中途退出后再启动,发送方跳过已确认的帧继续发后面的就行。这个功能课程要求里一般不写,但做出来之后,你的实验报告就有了别人没有的亮点。
5. GBN实验最容易翻车的5个坑:现象、原因和修复
5.1 窗口满了程序却卡死,不发送也不超时
现象:发送方发了4个帧之后,就一直在等ACK,但控制台既不打印超时重传,也没有后续动作,卡在原地不动。
原因:这是典型的超时处理缺失。检查代码看recvfrom是否设置了settimeout,如果socket处于阻塞模式且没有设置超时,recvfrom会一直等下去,后台线程被锁死,check_timeout根本没机会执行。还有一种可能:设置了超时但捕获异常后直接continue,忘了在check_timeout里重发窗口。
解决:给socket加settimeout(0.1)或更小的值,在except socket.timeout分支里不做处理直接continue,让主循环继续跑;同时确认check_timeout在每次循环都被调用,而不是只触发一次。
5.2 校验和用简单的sum函数,传输内容被二进制零“骗”了
现象:文件传完了,但SHA256对不上,而且每次出错的位置还不一样,看起来是随机损坏。
原因:常见的错误是用sum(payload) % 256做校验和。对于二进制文件,一个字节从0xFF变成0x00,sum值的变化不是线性的,更严重的问题是如果两个字节恰好互补(比如0x01和0xFF替换成0x02和0xFE),sum值完全不变,错误就漏过去了。Python内置sum算的是无符号整数累加,对二进制数据来说碰撞概率太高。
解决:换成zlib.crc32或者hashlib.md5。CRC32对偶然性比特错误有很强的检出能力,对课程实验绰绰有余;如果要模拟真实数据链路层的FCS,CRC32也更贴近IEEE 802.3的帧校验序列设计。
5.3 序号回绕后窗口判断逻辑崩溃
现象:传一个超过64块的较大文件,跑到第70块左右,发送方和接收方的窗口突然全乱,重复帧、丢帧此起彼伏。
原因:序号都是按% 64取模的,当文件有几百个分块时,序号0会再次出现。如果你的窗口判断用的是if seq <= self.base + self.window_size这种简单比较,回绕之后这个表达式就失效了——base可能接近63,next_seq回到0,窗口判断彻底错乱。
解决:不要用绝对值比较序号大小,要用模运算判断窗口边界。比如判断一个序号seq是否在窗口内,用((seq - base) % 64) < window_size。这里window_size必须小于等于32(序号空间的一半),否则GBN的接收窗口为1时无法区分新旧帧。这正好呼应了教材里“窗口最大为2^n - 1”的结论,你会在代码里真切踩到这个数学边界。
5.4 传输结束后文件少了最后一块
现象:发送方已经把全部块发完并退出,接收方的临时文件大小比原文件少了1024字节左右,正好缺最后一块。
原因:发送方主循环的退出条件写成了idx >= total_frames,也就是“所有帧都发出去了”就算完,但没有等待窗口内帧全部确认。最后几个帧发出去了,接收方可能还没来得及处理,发送方进程就结束了。
解决:退出条件写成idx >= total_frames and not sender.unacked,同时确认最后一个ACK已经收到。在退出前用time.sleep(0.5)或循环等待给接收方留出处理时间,保证收尾的ACK往返完成。
5.5 UDP缓冲区溢出导致“间歇性丢包”,怎么调都没用
现象:丢包率设成0,文件传出去还是偶尔少帧,而且文件越大越明显。
原因:这根本不是协议问题,是UDP接收缓冲区溢出了。当发送方连续快速发几百个帧,接收方的socket缓冲区(默认一般几十KB)不够,内核直接丢弃后续数据包。你看到的“丢包”其实是缓冲区满了被丢弃,和物理信道丢包无关。
解决:调大socket接收缓冲区,在收发双方都加上setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1048576)。注意这个值要设成1MB左右才有明显效果,而且要在bind之前设置。还有一个办法是限制发送速率,在send循环里加time.sleep(0.005),控制发送节奏,不要一把梭。
6. 验证GBN实现的正确性:丢包率测试与窗口参数调节
验证GBN对不对,只靠一次“文件传输成功”远远不够,因为丢包是随机的,运气好一次丢包都没碰上,协议逻辑根本没过脑。我习惯的做法是设计一个控制变量的实验矩阵:固定文件大小、固定分块大小、固定超时时间,只改变丢包率,分别在下传0%、1%、5%、10%、20%时各跑10次,统计传输成功率和总耗时。如果成功率不是100%,说明协议有bug;如果10%丢包率下耗时长到不可接受,说明窗口参数或超时时间不合适。
python sender.py --file test.bin --size 512000 --chunk 1024 --window 4 --loss 0.1 python sender.py --file test.bin --size 512000 --chunk 1024 --window 8 --loss 0.1 python sender.py --file test.bin --size 512000 --chunk 1024 --window 16 --loss 0.1同一丢包率下,窗口从4调到16,你会发现两个现象:第一,传输时间明显下降,因为允许更多帧在飞行中,吞吐量上去了;第二,窗口越大,单次超时的代价越高——重传整个窗口的字节数更多。这两个因素的平衡点做题多了就能感觉出来,一般课程实验里窗口设8比较稳妥。
从GBN往更高级的协议延伸也是很好的实验报告素材:GBN重传整个窗口效率太低,SR协议改为只重传丢失的帧,接收方需要维护一个缓存区暂时存放乱序帧。改动的地方其实不多——发送方把unacked字典的遍历重传改成只重传超时那一帧,接收方把expected_seq的严格等值判断改成滑动窗口加缓存。做一次对比实验,你会对TCP为什么用“带选择性确认的滑动窗口”有更深的体会。这也是我当年做这个实验之后最大的收获:教材上那些枯燥的协议细节,根本不是死记硬背的东西,写一遍代码全通了。希望帮到你。
本文还有配套的精品资源,点击获取