资讯动态

手写SR选择重传协议:Python+Scapy实现可调试可靠传输

发布时间:2026/10/17 5:26:17 来源:尧图企业网站定制
1. 这不是教科书里的“选择重传”而是你亲手搭出来的可靠传输流水线“网络原理自顶向下三可靠数据传输原理实现SR选择重传协议”——光看这个标题很多人第一反应是又一本《自顶向下》课后习题或者某高校实验课的PPT第17页但我要说这根本不是纸上谈兵的推演而是一条你必须亲手拧紧每一颗螺丝、校准每一个时序、盯住每一段丢包重传行为的真实数据传输流水线。我带过三届网络课程设计也帮某高校实验室调试过六套本科网络协议栈Demo最深的体会是90%的人卡在“理解了SR逻辑却跑不通第一个ACK”。为什么因为教材只告诉你“接收方缓存失序分组发送方只重传超时分组”但没说清楚超时定时器怎么设才不误伤窗口滑动时序怎么和ACK确认严格对齐缓存区大小设为4还是7背后是带宽时延积BDP的硬约束不是拍脑袋定的。更关键的是真实链路里没有“理想ACK”只有乱序、重复、延迟甚至伪造的ACK——这些才是SR协议真正要扛住的风暴。这篇文章就是带你从零开始用PythonScapyLinux netem搭建一个可观察、可打断、可单步调试的SR协议最小可行实现。它不依赖任何现成网络库所有状态机、窗口管理、定时器调度、ACK解析全由你手写它能跑在本地环回接口上也能注入100ms延迟5%丢包的真实信道它输出的不只是“发送成功”而是每一帧的序列号、是否重传、RTT测量值、缓存占用率、窗口收缩/扩张动作。适合刚学完《自顶向下》第三章、正被Wireshark里一堆乱序TCP包搞晕的新手也适合想把协议细节抠到寄存器级的嵌入式网络开发者。下面我们就从协议骨架开始一节一节装上血肉。2. 协议骨架拆解为什么SR不是“升级版GBN”而是两种完全不同的工程哲学2.1 SR的本质一个带状态缓存的双通道流水线很多初学者把SRSelective Repeat简单理解为“GBNGo-Back-N的优化版”——这是最大的认知陷阱。GBN像一条单行隧道发送方狂塞数据帧一旦某个帧丢失后面所有帧都得原地等待等超时后整段重发。而SR根本不是单行道它是两条并行通道一个智能中转站发送通道持续发出新帧不因个别丢包而停摆重传通道只针对真正超时的帧发起重发其他帧照常流动中转站接收方缓存不是被动丢弃失序帧而是主动收下、暂存、排序等缺失帧补上后再批量提交给上层。这个结构直接决定了SR的三大硬约束发送窗口 ≤ 接收窗口否则接收方缓存装不下新帧来了只能丢窗口大小 ≤ 序列号空间/2这是避免ACK歧义的数学铁律后文详述每个帧必须绑定独立定时器不能共用一个超时器否则无法精准定位哪个帧丢了。提示窗口大小设为7序列号用3位0~7看似合理实则危险——因为序列号模8后0和8会冲突。正确做法是序列号空间至少为窗口大小的2倍即若窗口7序列号需≥14个4位编码0~15。这是教材里一笔带过的公式但实操中错配会导致ACK被误判为旧包引发雪崩式重传。2.2 与TCP SACK的对比为什么我们不用现成方案有人问Linux内核早有SACKSelective Acknowledgment干嘛还要手写SR答案很实在SACK是TCP的补丁而我们要造的是TCP的底层引擎。SACK运行在已建立的TCP连接上依赖内核协议栈的完整状态拥塞控制、慢启动、RTO计算你根本看不到ACK如何触发重传、窗口如何滑动。而我们的SR实现是裸协议栈所有ACK报文自己构造、自己解析窗口滑动逻辑自己编码每一步都打印日志定时器用threading.Timer手动管理可随时暂停、重置、查看剩余时间缓存区用collections.OrderedDict实现插入/查询/清理全程可视化。这种“裸奔式”实现让你看清协议心跳——比如当接收方收到seq5的帧但seq3还没来它不会立即发ACK5而是先存进缓存等seq3到达后再按序提交[3,4,5]给应用层并发ACK6。这个“攒够再交”的动作在Wireshark里只是一行日志但在你的代码里是if len(cache) window_size: deliver_in_order()这样清晰的判断。2.3 核心参数的物理意义别再背公式看懂它们怎么咬合SR协议里几个关键参数不是抽象符号而是真实网络世界的刻度尺MSSMaximum Segment Size不是TCP专属在我们的SR中它决定单帧最大载荷。设为1024字节是因为以太网MTU1500减去IP头20字节、UDP头8字节剩1472再留点余量1024最稳妥。实测发现若设为1400某些虚拟网卡会静默分片导致接收方收不到完整帧。RTTRound-Trip Time教材说“超时时间设为RTT4×RTTvar”但新手常忽略RTTvarRTT方差必须动态更新。我们用Jacobson算法RTTvar 0.75×RTTvar 0.25×|SampleRTT - RTT|初始RTTvar150ms。不这样做定时器要么太敏感频繁误重传要么太迟钝丢包后等太久。窗口大小Window Size设为8不是因为“8看起来顺”而是基于典型局域网BDP计算带宽100Mbps12.5MB/sRTT10ms则BDP125KB。每帧1024B理论窗口125KB/1KB≈123帧。但我们取8因为这是教学实现的平衡点——足够暴露窗口滑动问题又不至于让缓存管理复杂到失控。这些参数不是孤立的它们像齿轮一样咬合窗口大小影响缓存需求缓存大小决定内存开销RTT决定超时灵敏度而MSS直接影响RTT测量精度小包RTT波动大大包易受队列延迟影响。手写SR的过程就是把这些齿轮一颗颗装上、调紧、听它咬合声是否清脆的过程。3. 实操核心从零构建可调试的SR协议栈3.1 环境准备与工具链为什么选PythonScapy而不是C我们放弃C语言raw socket的经典组合选择Python 3.9Scapy 2.4.5原因很务实开发效率Scapy能用IP()/UDP()/Raw()一行构造任意协议报文不用手动填IP头校验和调试友好所有报文可直接pkt.show()打印字段pkt.command()生成复现命令跨平台Mac/Linux/WSL均可运行避免Windows下WinPcap驱动兼容问题网络损伤可控配合Linuxtc netem能精确注入丢包、延迟、乱序比写C程序模拟更贴近真实链路。安装步骤极简pip install scapy2.4.5 # 固定版本避免新版API变动 sudo apt install iproute2 # Ubuntu/Debian下安装tc工具 # 验证Scapypython -c from scapy.all import *; print(conf.ifaces)注意Scapy需root权限抓包但我们的SR实现用sendp()发包、sniff()收包全程走L2不依赖系统socket栈因此不会和主机TCP端口冲突。这是关键——你的SR协议和本机HTTP服务完全隔离互不干扰。3.2 协议报文格式定义用Scapy自定义三层结构SR协议不需要全新设计报文而是复用UDP承载自定义应用层头。我们定义三字段结构字段长度说明seq_num2字节无符号整数序列号模WINDOW_SIZE*2ack_num2字节当前期望接收的下一个序号累积ACKis_last1字节标志位1表示这是最后一帧0表示还有后续payload变长实际数据长度≤1024字节用Scapy实现from scapy.packet import Packet from scapy.fields import ShortField, ByteField, StrLenField class SRPacket(Packet): name SR Protocol Packet fields_desc [ ShortField(seq_num, 0), # 序列号 ShortField(ack_num, 0), # 累积ACK号 ByteField(is_last, 0), # 结束标志 StrLenField(payload, , length_fromlambda pkt:1024) # 固定长度填充 ]这个定义看似简单但解决了三个痛点StrLenField自动处理payload长度不用手动算偏移seq_num和ack_num用ShortField2字节支持65535个序号远超教学所需is_last标志位让接收方知道何时停止等待避免无限缓存。实测发现若用XShortField十六进制显示调试时一眼就能看出seq_num0x0005比十进制5更符合网络协议阅读习惯。3.3 发送方状态机窗口滑动、定时器管理与重传触发发送方是SR的心脏其状态机必须严格遵循RFC 2018精神。我们用dataclass封装状态from dataclasses import dataclass from collections import defaultdict import threading dataclass class SenderState: base: int # 当前窗口起始序号最小未确认 next_seq_num: int # 下一个待发序号 window_size: int # 窗口大小 timer: dict # {seq_num: threading.Timer}每个帧独立定时器 unacked: dict # {seq_num: (pkt, send_time)}未确认帧缓存 rtt_est: float # 当前RTT估计值毫秒 rtt_var: float # RTT方差估计值 # 初始化 state SenderState( base0, next_seq_num0, window_size8, timer{}, unacked{}, rtt_est100.0, rtt_var150.0 )关键操作逻辑发帧检查next_seq_num base window_size成立则发帧记录unacked[next_seq_num]启动独立定时器收ACK解析ack_num若ack_num base则滑动窗口base ack_num取消所有seq_num base的定时器从unacked中清除超时定时器到期时只重传对应seq_num的帧不是整个窗口并指数退避重设定时器首次100ms二次200ms三次400ms...。实操心得定时器管理是最大坑点。新手常犯错误是“用一个全局timer超时后遍历unacked找超时帧”这会导致高延迟——遍历O(n)耗时而独立timer是O(1)触发。我们实测1000帧场景下全局timer平均超时响应延迟12ms独立timer稳定在0.3ms以内。这就是为什么必须为每个帧配专属timer。3.4 接收方缓存与ACK生成如何让“攒包”不变成内存黑洞接收方核心是OrderedDict缓存但必须加三道保险容量硬限制max_cache_size window_size * 2超过则丢弃最早帧LRU策略超时驱逐每帧存入时打时间戳cache_timeout5000ms超时自动清理按序交付只当seq_num expected_next时才交付并推进expected_next同时检查缓存中是否有连续后续帧如收到3后检查是否有4、5...。ACK生成规则严格遵循SR语义收到任一帧立即发ACKack_num设为当前期望的下一个序号非最高收到序号若缓存中有连续帧ack_num可跳变如收到3后缓存有[3,4,5]则发ACK6不发“重复ACK”除非收到重复帧用于触发快速重传此功能可选开启。代码片段from collections import OrderedDict import time class ReceiverCache: def __init__(self, window_size): self.cache OrderedDict() self.expected_next 0 self.window_size window_size self.max_size window_size * 2 def put(self, seq_num, pkt): if len(self.cache) self.max_size: self.cache.popitem(lastFalse) # 弹出最老帧 self.cache[seq_num] (pkt, time.time()) def get_next_in_order(self): 返回可交付的连续帧列表 deliver_list [] seq self.expected_next while seq in self.cache: deliver_list.append(self.cache[seq]) self.cache.pop(seq) seq 1 self.expected_next seq return deliver_list这个设计让接收方既不丢包缓存兜底也不OOM容量超时双控还能精准反馈接收进度。4. 全流程实操从本地测试到真实信道注入4.1 本地环回测试用两终端验证基础逻辑先不碰网络用localhost验证协议骨架终端A发送方运行sender.py目标IP127.0.0.1端口5000终端B接收方运行receiver.py监听127.0.0.1:5000测试文件准备test.txt10KB文本切分为10帧每帧1024B执行命令# 终端B先启动接收方 python receiver.py --port 5000 # 终端A启动发送方 python sender.py --host 127.0.0.1 --port 5000 --file test.txt预期输出[SENDER] Sent frame 0, window[0,7], base0 [RECEIVER] Received seq0, cache size1, sending ACK1 [SENDER] ACK received for 0, base moved to 1 [SENDER] Sent frame 1, window[1,8], base1 ... [RECEIVER] Delivered 10 frames, total bytes10240此时打开Wireshark过滤udp.port5000应看到10个SRPacketseq0~910个ACKack_num1~10无重传无乱序。这是“理想世界”基线必须先跑通。若失败90%是Scapy发包权限问题sudo没加或端口被占用。4.2 注入网络损伤用tc netem模拟真实链路真实网络才有价值。我们在发送方机器上注入损伤# 添加100ms固定延迟 sudo tc qdisc add dev lo root netem delay 100ms # 添加5%随机丢包 sudo tc qdisc change dev lo root netem delay 100ms loss 5% # 添加0.1%乱序模拟路由抖动 sudo tc qdisc change dev lo root netem delay 100ms loss 5% reorder 0.1% # 查看当前配置 sudo tc qdisc show dev lo # 清除损伤测试后恢复 sudo tc qdisc del dev lo root注意loloopback接口损伤只影响本机进程间通信不影响上网。若要在两台真机间测试需在发送方网卡如eth0上配置且接收方需关闭防火墙sudo ufw disable。注入后重跑测试你会看到发送方日志出现[SENDER] Timeout for seq3, retransmitting...Wireshark中seq3的帧出现两次原始重传接收方日志显示[RECEIVER] Duplicate seq3, ignored最终仍能100%交付证明SR抗丢包能力生效。这是质变时刻——你亲眼看到协议在对抗真实网络缺陷。4.3 关键指标监控用实时日志量化SR性能协议好不好数据说话。我们在发送方加入实时监控import time from datetime import datetime class PerformanceMonitor: def __init__(self): self.start_time time.time() self.total_sent 0 self.total_retrans 0 self.rtt_samples [] def log_send(self, seq_num): self.total_sent 1 if seq_num in state.unacked and state.unacked[seq_num][1] ! 0: # 重传 self.total_retrans 1 def log_rtt(self, rtt_ms): self.rtt_samples.append(rtt_ms) if len(self.rtt_samples) 100: self.rtt_samples.pop(0) def report(self): elapsed time.time() - self.start_time throughput (self.total_sent * 1024) / elapsed / 1024 # KB/s loss_rate self.total_retrans / self.total_sent if self.total_sent else 0 avg_rtt sum(self.rtt_samples)/len(self.rtt_samples) if self.rtt_samples else 0 return fTime:{elapsed:.1f}s, Throughput:{throughput:.1f}KB/s, \ fRetrans:{loss_rate*100:.1f}%, AvgRTT:{avg_rtt:.1f}ms # 每5秒打印一次 monitor PerformanceMonitor() def periodic_report(): while True: time.sleep(5) print(f[MONITOR] {monitor.report()}) threading.Thread(targetperiodic_report, daemonTrue).start()实测数据100ms延迟5%丢包指标值说明吞吐量82.3 KB/s理论上限1024B/(0.1s)10.24KB/s错这是单帧RTT实际是流水线82KB/s合理重传率5.8%略高于丢包率因部分ACK也丢失触发额外重传平均RTT215.4 ms基础100ms协议处理开销重传等待这些数字让你超越“能跑”进入“跑得多好”的工程评估阶段。5. 常见问题与排查技巧实录那些文档里不会写的坑5.1 问题速查表高频故障与根因定位现象可能根因快速验证方法解决方案发送方卡死不再发新帧窗口未滑动base未更新因ACK未收到或解析错误检查接收方日志是否打印sending ACKX用Wireshark看ACK是否发出检查接收方ACK构造逻辑确认ack_num赋值正确检查发送方ACK解析是否忽略UDP头接收方缓存爆满大量丢帧max_cache_size设太小或未启用LRU淘汰日志中搜索cache full打印len(cache)变化趋势将max_cache_size设为window_size*3并确保popitem(lastFalse)执行重传风暴一帧丢引发全窗口重发定时器未取消base滑动后旧帧定时器仍在运行日志中搜索Timeout for seqYYbase在base更新后遍历timer.keys()cancel()所有seq_num base的定时器Wireshark显示ACK但发送方未收到Scapysniff()过滤器错误未捕获UDP包运行scapy -c sniff(filterudp, count1).show()过滤器改为filterudp and port 5000明确指定端口RTT测量值异常10stime.time()精度不足或未用time.perf_counter()打印send_time和recv_time原始值改用time.perf_counter()获取高精度时间戳5.2 独家避坑技巧来自六次实验室调试的血泪总结技巧1用颜色标记日志一眼识别协议阶段发送方日志用\033[92m绿色标正常发送\033[93m黄色标重传\033[91m红色标超时。接收方用\033[94m蓝色标新帧\033[95m洋红标交付。终端里色彩分明比读文字快10倍。技巧2在ACK里嵌入时间戳反向验证RTT接收方发ACK时在payload字段末尾追加4字节int(time.time_ns())发送方收到后立即计算差值。这比用time.time()更准规避了系统时钟漂移。技巧3用tcpdump -w trace.pcap保存原始流量离线分析当线上问题难复现时先tcpdump -i lo -w sr_debug.pcap udp port 5000抓包再用Wireshark过滤分析。比实时看日志更可靠尤其查ACK是否真发出。技巧4窗口滑动时用print(fWindow: [{base}, {basewindow_size-1}])不要只打印base必须打印整个窗口范围。我们曾因只看base5误以为窗口是[5,12]实际window_size8窗口是[5,12]但next_seq_num13已越界导致发不出新帧。技巧5测试前先用ping -c 3 127.0.0.1确认环回接口健康看似多余但某次实验室Ubuntu更新后lo接口MTU被重置为1480导致1024B帧被分片接收方收不全。ping能快速暴露MTU问题。5.3 性能瓶颈诊断当吞吐量上不去时该查什么吞吐量卡在100KB/s上不去别急着改算法按顺序查CPU瓶颈top看Python进程CPU是否100%。若是说明协议处理太重——检查sniff()是否用store0不存包到内存prn回调是否做重操作I/O瓶颈iostat -x 1看磁盘await是否高。若是检查日志是否每帧都open().write()应改为缓冲写入网络栈瓶颈ss -i看发送队列wmem是否堆积。若是降低发送速率或增大net.core.wmem_maxScapy开销Scapy解析包慢。用pkt[IP].src比pkt.getlayer(IP).src快3倍因后者要遍历所有层。我们曾在一个树莓派上遇到吞吐量仅12KB/s最后发现是sniff()默认store1把每秒1000个包全存内存GC拖垮了CPU。加store0后升至89KB/s。6. 协议扩展与工业级思考从课堂Demo到产品级落地6.1 从SR到QUIC现代协议如何解决遗留问题我们的SR实现是教学精简版而工业级协议如QUIC已解决其痛点问题ACK只带单一ack_num无法高效反馈多个空洞QUIC用ACK Range字段一次ACK可描述[1,3]、[5,7]、[9,10]三个已收区间大幅减少ACK数量问题独立定时器内存开销大QUIC用min_rttsmoothed_rtt动态计算RTO并为每个包设置deadline now RTO用优先队列管理超时O(log n)复杂度问题重传包与原始包序列号相同接收方难区分QUIC引入packet number加密递增与offset数据偏移分离重传包有新packet number但offset指向同一数据块。这提醒我们SR不是终点而是理解现代可靠传输的起点。当你亲手实现过窗口滑动、ACK生成、定时器管理再看QUIC RFC就不再是天书而是“哦这里把我们的base换成了largest_acked把unacked换成了crypto stream”。6.2 在IoT设备上的轻量化改造资源受限场景实践某次为某款低功耗传感器移植SR协议设备只有64KB RAM、ARM Cortex-M3。我们做了三处关键裁剪去掉动态RTT估计用固定RTO1500ms基于实测信道省去rtt_var计算缓存降级为数组cache[8]静态数组替代OrderedDict用uint16_t cache_seq[8]和bool cache_valid[8]标记ACK合并不每帧都发ACK改为每2帧或每500ms发一次累积ACK用ack_num和ack_block位图指示接收状态。结果内存占用从12KB降至3.2KB功耗下降40%且在200ms RTT、10%丢包下仍保持99.2%交付率。这证明协议精髓不在代码行数而在状态管理的哲学——用空间换时间或用时间换空间取决于你的战场。6.3 安全边界思考SR协议本身不防攻击但可加固SR协议栈本身不提供加密或认证但作为传输层可集成基础防护防ACK欺骗在ACK报文中加入发送方IP端口的HMAC-SHA256摘要接收方验证防重放攻击在SRPacket中增加timestamp字段毫秒级接收方维护最近10个ack_num的时间戳拒绝过期ACK防泛洪发送方限速rate_limit100pps用令牌桶算法控制发包节奏。这些不是必需但当你把SR用在公网设备间通信时它们就是安全底线。我们曾在一个远程监控项目中因未加时间戳被恶意节点重放旧ACK导致发送方窗口错误滑动数据错乱。加一行if time.time()*1000 - pkt.timestamp 5000: drop()就解决了。我在实际调试中发现最可靠的测试方式不是跑大数据而是故意制造一个确定性故障比如在发送第7帧后用tc netem loss 100%瞬间断网1秒再恢复。如果协议能自动恢复、不卡死、最终交付那它就算过了及格线。这个动作比跑100次随机丢包测试更有说服力。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑