简介本资源是一份面向计算机网络课程学习者与编程初学者的实践型实验项目聚焦TCP协议底层机制的理解与动手实现重点解决滑动窗口协议抽象概念难以具象化的问题。压缩包为4KB的RAR格式共含2个C语言源文件分别实现滑动窗口协议的发送端与接收端逻辑通过socket编程建立TCP连接完成1bit滑动窗口的完整模拟涵盖帧序号管理、ACK确认、超时重传及窗口滑动等核心流程。已有1308人学习下载体现了其在数据链路层协议教学中的实用价值。读者可直接编译运行双端代码观察窗口动态变化与丢包重传行为配套结构清晰、注释充分便于理解协议状态机设计思路与网络编程接口调用规范是深入掌握可靠传输机制不可多得的轻量级参考实现。1. 滑动窗口不是TCP的“黑匣子”而是你能亲手拧开、调参、观测的流量控制器你有没有在调试一个高吞吐网络服务时发现明明带宽充足但数据却像被卡住一样断续发送抓包一看ACK间隔忽长忽短Window Size字段频繁跳变重传率悄然爬升——这时候问题往往不在应用逻辑而在你默认信任却从未真正看懂的滑动窗口机制。这个项目就是一把螺丝刀它用纯socket编程不依赖任何网络库封装从零实现一个可配置、可打印、可单步跟踪的滑动窗口协议模拟器。它不跑真实业务但能让你亲眼看到每个seq、ack、win如何随丢包、延迟、缓冲区大小实时变化它不替代 TCP却比tcpdump更早暴露窗口收缩的根源。适合刚学完《计算机网络》原理、正卡在“理论懂了但抓包看不懂”的开发者也适合想给新人讲清拥塞控制底层逻辑的带教者——因为所有状态都明文打印到终端所有参数都在config.py里改一行就能验证“为什么窗口不能无限大”。2. 架构与模块拆解三层结构撑起一个可观察的滑动窗口世界这个项目没有花哨的 Web 界面或分布式组件它的力量来自极简而清晰的分层协议层Protocol负责定义滑动窗口核心规则如累积确认、超时重传、窗口更新逻辑传输层Transport封装socket的收发细节处理字节流粘包/拆包、校验和计算、序列号管理仿真层Simulator则是整个系统的“导演”——它启动发送端与接收端进程注入可控的网络异常如随机丢包、固定延迟并实时打印每帧交互的完整状态。三者之间通过明确的数据结构通信如Packet类统一承载seq,ack,data,checksum避免状态散落在全局变量中。这种设计让调试变得极其直接你想知道“为什么第5个包没被确认”直接在Simulator的日志里搜seq5再顺着调用栈进Transport.receive()查ack更新逻辑即可。它不是为生产部署设计的而是为“理解”而生的。2.1 协议层核心SlidingWindowProtocol类的四个关键契约滑动窗口协议的本质是一组状态机规则。本项目将这些规则收敛到protocol/sliding_window.py中的SlidingWindowProtocol类它不直接操作 socket只提供四个接口供上层调用# protocol/sliding_window.py class SlidingWindowProtocol: def __init__(self, window_size: int, timeout_ms: int): self.window_size window_size self.timeout_ms timeout_ms self.base_seq 0 # 当前窗口左边界最小未确认序号 self.next_seq 0 # 当前窗口右边界下一个待发序号 self.unacked_packets {} # {seq_num: (packet_obj, send_time)} 存储已发未确认包 def can_send(self) - bool: 判断是否允许发送新包窗口未满且 next_seq 在 [base_seq, base_seq window_size) 内 return self.next_seq self.base_seq self.window_size def on_packet_sent(self, seq_num: int, packet: Packet): 发送后登记记录序号、时间、包体启动超时计时器 self.unacked_packets[seq_num] (packet, time.time()) def on_ack_received(self, ack_num: int): 收到ACK后更新移动base_seq清理已确认包 if ack_num self.base_seq: # 累积确认所有 seq ack_num 的包均视为已确认 to_remove [seq for seq in self.unacked_packets.keys() if seq ack_num] for seq in to_remove: del self.unacked_packets[seq] self.base_seq ack_num def check_timeout(self) - List[int]: 检查超时返回所有已超时但未确认的seq列表供重传 now time.time() timeout_list [] for seq, (_, send_time) in self.unacked_packets.items(): if now - send_time self.timeout_ms / 1000.0: timeout_list.append(seq) return timeout_list提示can_send()的判断逻辑是滑动窗口“动态性”的核心——它不检查next_seq是否小于某个固定上限而是用base_seq window_size动态计算右边界。这意味着当base_seq因 ACK 推进时窗口自动右移无需手动“滑动”。这是初学者最容易写错的地方常误写成next_seq window_size导致窗口永远卡在开头。2.2 传输层封装SocketTransport如何把字节流变成有序帧transport/socket_transport.py是 socket 操作的唯一出口。它解决两个关键问题如何保证应用层数据不被 TCP 自动拆分/合并即避免粘包以及如何让每个包携带可靠的元信息seq/ack/win。答案是自定义帧格式每个发送的bytes都以 12 字节头部开始结构如下字段长度字节含义示例值seq_num432位无符号整数包的序列号b\x00\x00\x00\x01ack_num432位无符号整数期望收到的下一个序号b\x00\x00\x00\x02window_size216位无符号整数当前接收窗口大小b\x00\x04即4checksum216位校验和对头部数据计算b\xab\xcd# transport/socket_transport.py def pack_packet(self, seq_num: int, ack_num: int, window_size: int, data: bytes) - bytes: 按上述12字节头部data格式打包 header struct.pack(!IIHH, seq_num, ack_num, window_size, 0) # checksum暂置0 payload header data checksum self._calculate_checksum(payload) # 重新打包填入真实checksum header struct.pack(!IIHH, seq_num, ack_num, window_size, checksum) return header data def unpack_packet(self, raw_bytes: bytes) - Optional[Packet]: 解包先读12字节头校验再提取data if len(raw_bytes) 12: return None try: seq, ack, win, chk struct.unpack(!IIHH, raw_bytes[:12]) if chk ! self._calculate_checksum(raw_bytes): # 校验失败则丢弃 return None data raw_bytes[12:] return Packet(seq_numseq, ack_numack, window_sizewin, datadata) except struct.error: return None注意struct.pack(!IIHH)中的!表示网络字节序大端这是跨平台 socket 通信的强制要求。若用本地字节序或小端在不同机器间通信会直接解析出错——这是血泪经验某次在 Mac 上调试正常一上 Linux 服务器就全乱码查了三天才发现字节序没对齐。2.3 仿真层调度NetworkSimulator如何制造可控的“恶劣网络”simulator/network_simulator.py是整个项目的“沙盒引擎”。它不直接调用socket.sendto()而是通过queue.Queue模拟网络传输并注入三类可控扰动丢包Drop按概率drop_rate丢弃发送队列中的包延迟Delay对每个包附加随机延迟范围[min_delay, max_delay]ms再放入接收队列乱序Reorder以概率reorder_rate将包插入接收队列头部而非尾部。# simulator/network_simulator.py def _simulate_transmission(self, packet: bytes, src_addr: tuple, dst_addr: tuple): 模拟一次发送根据配置决定是否丢包、加延迟、乱序 if random.random() self.drop_rate: self.logger.debug(fDROP: {src_addr} - {dst_addr}, seq{self._extract_seq(packet)}) return # 直接丢弃不进入网络 # 计算延迟 delay_ms random.uniform(self.min_delay, self.max_delay) scheduled_time time.time() delay_ms / 1000.0 # 决定是否乱序以reorder_rate概率插入队首 if random.random() self.reorder_rate: self.recv_queue.appendleft((scheduled_time, packet, dst_addr)) else: self.recv_queue.append((scheduled_time, packet, dst_addr))关键设计recv_queue是一个collections.deque支持 O(1) 头部/尾部插入。_simulate_transmission不立即投递而是将(scheduled_time, packet, dst_addr)元组存入队列。主循环中NetworkSimulator.run()会持续检查队列头部若scheduled_time now则取出并socket.sendto()给目标地址。这使得“延迟”不再是阻塞式time.sleep()而是非阻塞的事件驱动调度——这才是真实网络异步性的精髓。3. 运行与调试从零启动一个可打印、可暂停、可复现的滑动窗口实验项目运行不依赖 Docker 或复杂环境只要 Python 3.7 和标准库即可。所有配置集中于config.py修改后无需重启下次运行即生效。整个流程分为三步准备配置 → 启动仿真 → 观察日志。重点在于日志不是简单输出而是按角色Sender/Receiver、按事件类型Send/Ack/Timeout结构化打印方便 grep 定位。3.1 配置文件详解config.py里的六个魔法数字config.py是实验的“控制台”共定义六个核心参数每一项都直接影响窗口行为# config.py WINDOW_SIZE 4 # 发送窗口大小单位包数。设为1即退化为停等协议Stop-and-Wait TIMEOUT_MS 2000 # 重传超时阈值毫秒。太小易误重传太大降低吞吐 DROP_RATE 0.1 # 丢包率0.0~1.0。0.110%丢包用于测试可靠性 MIN_DELAY_MS 50 # 最小网络延迟毫秒。模拟基础RTT下限 MAX_DELAY_MS 200 # 最大网络延迟毫秒。与MIN共同构成抖动范围 REORDER_RATE 0.05 # 乱序概率0.0~1.0。0.055%包被插到队首测试ACK累积性玄学经验初学者常把TIMEOUT_MS设得过小如 100ms结果看到大量重复重传——这不是协议错了而是你的MIN_DELAY_MS是 50msMAX_DELAY_MS是 200ms真实 RTT 可能在 100~400ms 波动100ms 超时必然频繁触发。合理超时值 ≈ 2 × 平均RTT。建议先设DROP_RATE0,REORDER_RATE0只调MIN/MAX_DELAY用ping测出平均延迟后再设TIMEOUT_MS。3.2 启动命令与进程模型三个终端三种视角项目采用多进程模型sender.py和receiver.py分别作为独立进程运行simulator.py作为中央调度器。启动需三个终端或使用tmux/screen终端1启动仿真器# 启动网络仿真器监听本地UDP端口 python simulator.py --port 8888 # 输出INFO: Simulator started on 127.0.0.1:8888终端2启动接收端# 接收端绑定 127.0.0.1:9000向仿真器 127.0.0.1:8888 发送ACK python receiver.py --host 127.0.0.1 --port 9000 --simulator 127.0.0.1:8888 # 输出INFO: Receiver listening on 127.0.0.1:9000, sending ACKs to 127.0.0.1:8888终端3启动发送端# 发送端绑定 127.0.0.1:9001向仿真器 127.0.0.1:8888 发送数据包 python sender.py --host 127.0.0.1 --port 9001 --simulator 127.0.0.1:8888 --data Hello Window # 输出INFO: Sender started, sending Hello Window with window4...为什么需要仿真器因为真实 UDP socket 无法控制丢包/延迟。若让 Sender 直连 Receiver你只能看到“发了”和“没收到”却看不到中间发生了什么。仿真器作为中间人既转发数据又注入故障并打印每一跳日志——这是理解协议的“上帝视角”。3.3 日志解读指南从一行日志定位协议状态日志按[ROLE][EVENT]前缀分类例如[SENDER][SEND]表示发送端发出一个包。关键字段包括seq,ack,win,len(data)。以下是一个典型交互片段及解读[SENDER][SEND] seq0, ack0, win4, data_len12, time1712345678.123 [SENDER][SEND] seq1, ack0, win4, data_len12, time1712345678.124 [SENDER][SEND] seq2, ack0, win4, data_len12, time1712345678.125 [SENDER][SEND] seq3, ack0, win4, data_len12, time1712345678.126 [RECEIVER][RECV] seq0, ack1, win4, data_len12, time1712345678.180 [RECEIVER][RECV] seq1, ack1, win4, data_len12, time1712345678.182 [RECEIVER][RECV] seq2, ack1, win4, data_len12, time1712345678.183 [RECEIVER][RECV] seq3, ack1, win4, data_len12, time1712345678.184 [RECEIVER][SEND_ACK] ack4, win4, time1712345678.185 [SENDER][RECV_ACK] ack4, win4, time1712345678.240 [SENDER][WINDOW_SLIDE] base4, next4, size4, time1712345678.241前4行Sender 连续发出seq0~3因WINDOW_SIZE4base0故next_seq从0走到4窗口填满。中4行Receiver 收到seq0~3但注意ack1—— 这是累积确认收到seq0后ack就应为1期望下一个seq1后续包即使到达只要seq1没丢ack仍为1直到seq1到达才变为2。第9行Receiver 发送ack4表示seq0~3全部确认期望下一个seq4。第10行Sender 收到ack4触发on_ack_received(4)base_seq从0推进到4。第11行WINDOW_SLIDE日志base4, next4表示窗口已完全右移next_seq回到窗口起点可继续发送seq4~7。排查线索如果看到[SENDER][WINDOW_SLIDE] base0, next0长时间不变说明ack从未到达问题在 Receiver 未发 ACK 或仿真器丢包如果[SENDER][TIMEOUT_RETRANSMIT] seq0频繁出现说明ack1丢失或延迟过大需调大TIMEOUT_MS或检查DROP_RATE。4. 避坑指南五个真实踩过的坑省下你三天调试时间这个项目虽小但涉及 socket、字节序、多进程、超时调度多个技术点新手极易掉进看似简单实则隐蔽的坑。以下是我在某高校网络课设指导中学生高频报错的五类问题按“现象→原因→解决”给出可执行方案4.1 现象Sender 打印[SEND] seq0后停止Receiver 完全无日志原因Sender 与 Receiver 的--simulator地址不一致或仿真器未启动。常见错误是 Sender 写--simulator 127.0.0.1:8888而仿真器实际监听0.0.0.0:8888绑定所有接口但本地回环127.0.0.1与0.0.0.0在某些系统防火墙策略下不通。解决统一用127.0.0.1。启动仿真器时显式指定--host 127.0.0.1python simulator.py --host 127.0.0.1 --port 88884.2 现象Receiver 收到包但ack始终为0Sender 不推进窗口原因Packet解包时ack_num字段解析错误。struct.unpack(!IIHH)要求输入字节长度严格为12但若 Sender 发送的包被截断如data过长导致 UDP 包超过 65507 字节raw_bytes长度不足12unpack抛struct.error异常unpack_packet()返回NoneReceiver 丢弃该包自然不会生成 ACK。解决在unpack_packet()开头加长度校验并打印警告if len(raw_bytes) 12: self.logger.warning(fPACKET TOO SHORT: {len(raw_bytes)} bytes, expect 12) return None4.3 现象日志显示[RECEIVER][RECV] seq0但[RECEIVER][SEND_ACK] ack0而非预期的ack1原因Receiver 的ack_num计算逻辑错误。正确逻辑是收到seqN的包ack应为N1期望下一个序号。若代码写成ack seq则seq0时ack0Sender 收到ack0会认为seq0未确认永不推进base_seq。解决检查receiver.py中 ACK 生成处确保# 正确期望下一个包是 seq1 ack_num packet.seq_num 1 # 错误这会导致 ackseq永远无法确认 # ack_num packet.seq_num4.4 现象[SENDER][TIMEOUT_RETRANSMIT] seq0循环出现但抓包显示 Receiver 已发ack1原因Sender 与 Receiver 的timeout_ms配置不一致或系统时间不同步。更常见的是 Sender 的check_timeout()方法中time.time()与send_time的单位不匹配send_time是time.time()秒级浮点数但timeout_ms是毫秒若写成now - send_time self.timeout_ms漏除1000则超时判定永远为真。解决check_timeout()中必须做单位转换# 正确timeout_ms 是毫秒需转为秒 if now - send_time self.timeout_ms / 1000.0: # 错误直接比较秒与毫秒 # if now - send_time self.timeout_ms:4.5 现象设置DROP_RATE0.5但实际丢包率远低于50%或日志中DROP出现极少原因random.random()返回[0.0, 1.0)的浮点数DROP_RATE0.5时random.random() 0.5的概率确实是50%。但丢包日志只在_simulate_transmission()中打印而该方法可能被多次调用如 Sender 连续发4包。若你只扫了一眼日志前几行可能错过后面的DROP。解决用grep统计真实丢包率# 运行实验10秒保存日志 python sender.py ... 21 | tee sender.log # 统计发送总数与丢包数 echo Total SEND: $(grep -c \[SEND\] sender.log) echo Total DROP: $(grep -c DROP: sender.log) # 计算比率 awk BEGIN{send0;drop0} /\[SEND\]/ {send} /DROP:/ {drop} END{printf Drop Rate: %.1f%\n, drop/send*100} sender.log5. 二次开发实战给滑动窗口加上“动态窗口调整”与可视化监控当你跑通基础版本下一步必然是让它更贴近真实 TCP 的智能性。本章带你动手改造两个高价值功能基于丢包率的动态窗口缩放类似 TCP Tahoe和实时状态可视化用 Matplotlib 绘制窗口大小与吞吐量曲线。改造不破坏原有结构所有新增代码集中在protocol/和simulator/下便于你按需启用。5.1 动态窗口从固定WINDOW_SIZE到自适应congestion_window真实 TCP 不会死守一个窗口大小。当检测到丢包超时或重复 ACK它会激进收缩窗口如减半进入“慢启动”或“拥塞避免”阶段。我们在SlidingWindowProtocol中添加congestion_window属性和on_packet_loss()回调# protocol/sliding_window.py class SlidingWindowProtocol: def __init__(self, window_size: int, timeout_ms: int): # ... 原有初始化 ... self.congestion_window window_size # 当前拥塞窗口初始接收窗口 self.ssthresh 65535 # 慢启动阈值初始设为大值 def can_send(self) - bool: 发送许可取 min(接收窗口, 拥塞窗口) effective_window min(self.window_size, self.congestion_window) return self.next_seq self.base_seq effective_window def on_packet_loss(self, seq_num: int): 收到超时或重复ACK时调用执行Tahoe算法 self.ssthresh max(self.congestion_window // 2, 2) # 阈值减半下限为2 self.congestion_window 1 # 窗口重置为1进入慢启动 self.base_seq seq_num # 重传从丢失包开始 self.logger.info(fCONGESTION: loss at seq{seq_num}, ssthresh{self.ssthresh}, cwnd1) def on_ack_received(self, ack_num: int): # ... 原有逻辑 ... # 新增若在慢启动阶段每收到一个新ACKcwnd1 if self.congestion_window self.ssthresh: self.congestion_window 1 self.logger.debug(fSLOW_START: cwnd increased to {self.congestion_window})验证方法在sender.py的主循环中当check_timeout()返回非空列表时调用protocol.on_packet_loss(timeout_list[0])。然后设置DROP_RATE0.2运行你会看到日志中交替出现CONGESTION:和SLOW_START:cwnd从1开始指数增长直到达到ssthresh后线性增长——这就是 TCP 慢启动的雏形。5.2 实时监控用matplotlib.animation绘制窗口与吞吐量热力图日志文本难以直观感受窗口波动。我们添加monitor/plotter.py它通过multiprocessing.Pipe接收Simulator发送的实时状态seq,ack,cwnd,throughput_bps并用FuncAnimation绘制双Y轴曲线# monitor/plotter.py import matplotlib.pyplot as plt from matplotlib.animation import FuncAnimation import numpy as np class RealTimePlotter: def __init__(self, pipe_conn): self.pipe pipe_conn self.times [] self.cwnds [] self.throughputs [] self.fig, self.ax1 plt.subplots(figsize(10, 6)) self.ax2 self.ax1.twinx() def update_plot(self, frame): # 从pipe读取最新状态 while self.pipe.poll(): state self.pipe.recv() # {time: float, cwnd: int, throughput: float} self.times.append(state[time]) self.cwnds.append(state[cwnd]) self.throughputs.append(state[throughput]) # 仅显示最近100个点避免卡顿 if len(self.times) 100: self.times self.times[-100:] self.cwnds self.cwnds[-100:] self.throughputs self.throughputs[-100:] self.ax1.clear() self.ax2.clear() self.ax1.plot(self.times, self.cwnds, b-, labelCongestion Window) self.ax2.plot(self.times, self.throughputs, r--, labelThroughput (bps)) self.ax1.set_xlabel(Time (s)) self.ax1.set_ylabel(CWND (packets), colorb) self.ax2.set_ylabel(Throughput (bps), colorr) self.ax1.legend(locupper left) self.ax2.legend(locupper right) if __name__ __main__: parent_conn, child_conn Pipe() plotter RealTimePlotter(child_conn) ani FuncAnimation(plotter.fig, plotter.update_plot, interval500) plt.show()集成步骤在simulator.py的run()循环中每秒计算一次吞吐量total_bytes_sent / elapsed_time和当前cwnd通过parent_conn.send({time: time.time(), cwnd: cwnd, throughput: throughput})推送给绘图进程。启动时用python -m monitor.plotter 后台运行绘图器再启动simulator.py即可。你会看到一条蓝色曲线窗口大小和一条红色虚线吞吐量实时跳动丢包时蓝色线骤降吞吐量随之暴跌——这才是网络工程师该有的“仪表盘”。从那以后我每次调试网络协议都强制走一遍grep -E (DROP|TIMEOUT|CONGESTION)日志再打开plotter.py看一眼曲线。因为理论可以背但窗口的呼吸、丢包的脉搏、拥塞的颤抖只有在你亲手拧开、调参、观测之后才会真正长进肌肉记忆里。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?