
简介一套基于socket编程模拟数据链路层滑动窗口协议的C语言示例程序面向计算机网络课程学习者与协议实验开发者聚焦TCP连接下1bit滑动窗口协议的完整实现通过发送端与接收端的控制流帮助理解帧序号、停止等待与确认重传机制。压缩包内共2个文件均为C语言源码分别对应发送端与接收端程序整体仅4KB结构精简适合逐行研读。目前已有1307人学习下载可用于课堂实验、课程设计或课后自学验证。编译运行两端程序后读者能直观观察到滑动窗口协议在可靠传输中的实际工作流程包括数据帧的发送、等待、确认与超时重传。在此基础上还可扩展捎带确认、连续重传等变体是动手理解数据链路层协议机制的高性价比参考。1. 用socket编程模拟滑动窗口协议先把可靠传输的账算明白用socket编程模拟滑动窗口协议很多人第一反应是拿TCP socket直接收发数据结果跑完发现窗口滑动、超时重传全是协议栈干的自己什么都没模拟出来。真正的难点不在socket收发而在三个状态管理窗口边界的模运算、每个包独立定时器的生命周期、接收端乱序缓存与累积确认的配合。这篇文章拆的是一份可直接运行的模拟工程发送端、接收端、丢包注入、测试脚本齐全。新手跑通能看到窗口真实滑动熟手可以对照真实TCP的New Reno与SACK机制理解教科书上那些状态变量到底在防什么。2. 滑动窗口拆解为什么状态变量必须和线序号分开2.1 传输选型用UDP socket模拟而不是TCP socket模拟滑动窗口协议的第一步是选底层传输。我直接选UDP socket原因很实在TCP协议栈内部已经把滑动窗口、重传、拥塞控制实现完了你在TCP之上再套一层滑动窗口等于在别人铺好的路上再修一条路所有重传和窗口滑动都是协议栈在幕后干完的自己写的代码只是摆设。UDP只负责尽力投递不保证顺序、不保证不丢包这个不可靠的前提正好把滑动窗口要解决的问题暴露出来。具体做法是发送端和接收端各起一个UDP socket绑定到不同端口。为了模拟网络损伤我在应用层按概率随机丢弃数据包。丢包位置可以放在发送端发出前也可以放在接收端收到后。我建议两端都留一个loss_rate参数测试时分别注入这样既能验证发送端超时重传也能验证接收端乱序缓存。这里有个参数很关键socket的settimeout。我一般设成0.05秒让收包线程每50毫秒轮询一次避免recvfrom永久阻塞后定时器的回调线程永远没机会运行。如果你设成None跑起来就是一收包就卡死这是第一个容易翻车的地方。2.2 序号空间与窗口边界为什么模8配窗口4滑动窗口协议有个经典约束窗口大小为W时序号空间至少是2W。想象一下窗口长度4序号空间只有4发送端把线序号0、1、2、3发出后收到线序号0的ACK它没法判断这是确认了0号包新确认还是重复的旧ACK。如果把序号空间翻倍到8窗口4那么ACK线序号0在base0时代表确认1个包在base4时代表确认4个包两种场景能通过模运算区分开。但这里藏着一个模拟器里最反直觉的坑窗口状态变量必须用单调递增的绝对序号只有真正写到网络包里的seq/ack字段才用模序号。如果你让base直接取模存储当base从7滑到0时窗口左边界看起来是后退了后续所有比较逻辑全部错乱。真实TCP也一样snd_una是线性字节序号IP包里的序号是32位回绕的协议栈用线性变量维护窗口语义。窗口判断的通用公式是(wire_seq - (base % SEQ_MOD) SEQ_MOD) % SEQ_MOD算出来的是线序号相对窗口左边界的前进量。这个公式在跨0回绕时依然成立是所有窗口比较的通用写法后面代码里会反复用到。2.3 超时重传策略为什么选择重传而不是Go-Back-NGo-Back-N只需要一个定时器超时就把整个窗口重传代码量最少但链路利用率很糟——一个包丢了后面已经顺利到达的包都要重来一遍。选择重传Selective Repeat是每个包维护一个独立定时器超时只重传那一个包接收端需要缓存乱序包并做按序上抛。代价是多十几行缓存逻辑换来的是不重复传输那些已经到达的包。真实TCP从New Reno开始实际上偏向选择重传配合SACK选项精确告知丢包位置。所以这份模拟工程采用选择重传每个包一个threading.Timer。RTO固定设成0.3秒比本地UDP的RTT通常不到1毫秒大两个数量级。如果设成10毫秒本地环路缓冲区稍微排队就会误判超时触发一堆无意义重传。真实网络里RTO用SRTT和RTTVAR动态估算模拟器用固定值就行但要把默认值暴露成常量方便调。另外发送循环里我加了一个sleep(0.02)控制发包节奏避免在同一时钟周期内把窗口内所有包塞进socket缓冲区。否则一个发送循环结束你根本分不清丢包是网络模拟出来的还是本地缓冲溢出造成的。3. 从零实现Python UDP socket 模拟滑动窗口的完整流程3.1 协议公共模块包格式与窗口常量定义先定义公共模块protocol.py。数据包结构是type加seq加payloadACK包结构是type加ack序号加接收窗口通告。这里的seq和ack字段都是模序号只有8个取值。# protocol.py —— 滑动窗口协议公共定义 import struct # 包类型常量 PKT_DATA 1 # 数据包 PKT_ACK 2 # 确认包 PKT_FIN 3 # 结束包 # 窗口与序号参数 SEQ_MOD 8 # 序号空间0~7 回绕 WINDOW_SIZE 4 # 窗口大小必须小于 SEQ_MOD / 2 RTO_TIMEOUT 0.3 # 重传超时单位秒 # 数据包type(1字节) seq(1字节) payload(不超过1024字节) def pack_data(seq_wire, payload): return struct.pack(!BB, PKT_DATA, seq_wire) payload def unpack_data(pkt): pkt_type, seq_wire struct.unpack(!BB, pkt[:2]) return pkt_type, seq_wire, pkt[2:] # ACK包type(1字节) ack(1字节) 接收窗口通告(2字节) def pack_ack(ack_wire, recv_wind): return struct.pack(!BBH, PKT_ACK, ack_wire, recv_wind) def unpack_ack(pkt): pkt_type, ack_wire, recv_wind struct.unpack(!BBH, pkt) return pkt_type, ack_wire, recv_wind这里用struct.pack(!BBH)感叹号表示网络字节序B是1字节无符号整数H是2字节无符号整数。seq字段只有1字节所以序号空间不能超过255这里模8够用。recv_wind用2字节范围0到65535模拟真实TCP的16位窗口通告字段。注意WINDOW_SIZE必须小于SEQ_MOD的一半。这是2.2节讲的约束窗口4、序号空间8才能保证窗口内不会出现同一个线序号对应两个不同位置的包。如果你把WINDOW_SIZE改成5跑一段时间就会出现奇怪的重复确认和丢包而且是随机出现的特别难查。3.2 发送端设计窗口填充、ACK处理与超时重传发送端维护两个绝对序号base是窗口左边界也就是最小未确认的绝对序号next_seq是下一个待发送的绝对序号。发送时把绝对序号取模转成线序号写入网络包。这样的好处是窗口是否满直接比较next_seq - base WINDOW_SIZE不需要模运算。# sender.py —— 发送端核心逻辑选择重传 import socket import threading import time import random from protocol import * class Sender: def __init__(self, dest, loss_rate0.0): self.sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.sock.settimeout(0.05) self.dest dest self.base 0 # 最小未确认绝对序号 self.next_seq 0 # 下一个待发送绝对序号 self.wind WINDOW_SIZE self.recv_wind WINDOW_SIZE # 对端通告的接收窗口 self.timers {} # 绝对序号 - Timer self.buffer {} # 绝对序号 - 待重传数据 self.lock threading.Lock() self.rng random.Random() self.loss_rate loss_rate def send(self, payload): chunks [payload[i:i1024] for i in range(0, len(payload), 1024)] total len(chunks) while self.next_seq total: # 填窗口窗口未满且还有数据待发 while (self.next_seq - self.base) min(self.wind, self.recv_wind) \ and self.next_seq total: self._transmit(self.next_seq, chunks[self.next_seq]) self.next_seq 1 time.sleep(0.02) # 收ACK处理窗口滑动 try: data, _ self.sock.recvfrom(2048) pkt_type, ack_wire, rw unpack_ack(data) if pkt_type PKT_ACK: self._handle_ack(ack_wire, rw) except socket.timeout: pass # 等所有包都被确认 while self.timers: try: data, _ self.sock.recvfrom(2048) pkt_type, ack_wire, rw unpack_ack(data) if pkt_type PKT_ACK: self._handle_ack(ack_wire, rw) except socket.timeout: time.sleep(0.002)发送端主循环只有两件事把窗口塞满收ACK滑窗。窗口没满且还有数据就继续发。窗口满了就阻塞收ACK直到对端释放位置。第二个while是收尾用的等所有定时器清空这个循环在避坑章节还会细说。_transmit和_handle_ack是核心def _transmit(self, seq_abs, data): seq_wire seq_abs % SEQ_MOD self.buffer[seq_abs] data if self.rng.random() self.loss_rate: print(f[SEND-LOST] abs{seq_abs} wire{seq_wire}) else: self.sock.sendto(pack_data(seq_wire, data), self.dest) print(f[SEND] abs{seq_abs} wire{seq_wire}) timer threading.Timer(RTO_TIMEOUT, self._on_timeout, args[seq_abs]) timer.daemon True timer.start() self.timers[seq_abs] timer def _handle_ack(self, ack_wire, recv_wind): with self.lock: self.recv_wind recv_wind # 计算ACK确认了多少个包处理跨0回绕 diff (ack_wire - (self.base % SEQ_MOD) SEQ_MOD) % SEQ_MOD if diff 0: return # 重复ACK直接忽略 for i in range(diff): seq_abs self.base i timer self.timers.pop(seq_abs, None) if timer: timer.cancel() self.buffer.pop(seq_abs, None) self.base diff print(f[ACK] wire{ack_wire} base{self.base}) def _on_timeout(self, seq_abs): with self.lock: # 序号已移出窗口放弃重传 if seq_abs self.base or seq_abs self.base self.wind: return print(f[RETRANSMIT] abs{seq_abs}) self.sock.sendto(pack_data(seq_abs % SEQ_MOD, self.buffer[seq_abs]), self.dest) timer threading.Timer(RTO_TIMEOUT, self._on_timeout, args[seq_abs]) timer.daemon True timer.start() self.timers[seq_abs] timer发送端丢包的设计是数据包仍占用窗口位置、仍启动定时器只是不真正sendto。这样能模拟数据交给了网络但被网络丢掉发送端感知不到只能靠超时发现。重传时不再做丢包过滤保证测试场景能收敛。_handle_ack里那个diff公式是全文最需要吃透的一行。ack_wire是接收端回的下一个期望线序号base % SEQ_MOD是发送端窗口左界对应的线序号两者相减取模得到的就是这次ACK确认了多少个绝对序号。diff等于0表示重复ACK不移动窗口。确认后base直接加diff不需要模运算所以窗口滑动永远是往右走的。3.3 接收端设计乱序缓存、累积确认与窗口通告接收端维护一个base也是绝对序号表示下一个期望按序接收的包。收到线序号等于base % SEQ_MOD时按序接收然后循环冲刷缓存收到窗口内的乱序包就暂存收到窗口外的包直接丢弃。每处理完一个包回一个累积ACK和当前的剩余窗口。# receiver.py —— 接收端缓存乱序包、累积确认 class Receiver: def __init__(self, port, loss_rate0.0): self.sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.sock.bind((127.0.0.1, port)) self.sock.settimeout(0.05) self.base 0 # 下一个期望的按序绝对序号 self.wind WINDOW_SIZE self.cache {} # wire_seq - payload self.data b self.rng random.Random() self.loss_rate loss_rate def receive(self): while True: try: pkt, addr self.sock.recvfrom(2052) pkt_type, seq_wire, payload unpack_data(pkt) if pkt_type PKT_DATA: if self.rng.random() self.loss_rate: print(f[RECV-LOST] wire{seq_wire}) continue self._process(seq_wire, payload, addr) elif pkt_type PKT_FIN: break except socket.timeout: continue return self.data def _process(self, seq_wire, payload, addr): expected self.base % SEQ_MOD if seq_wire expected: # 按序到达直接上抛 self.data payload self.base 1 # 尝试从缓存中连续取出后续包 while self.base % SEQ_MOD in self.cache: self.data self.cache.pop(self.base % SEQ_MOD) self.base 1 elif (seq_wire - expected SEQ_MOD) % SEQ_MOD self.wind: # 乱序包且在接收窗口内 self.cache[seq_wire] payload print(f[CACHE] wire{seq_wire}) else: print(f[DISCARD] wire{seq_wire} 超出接收窗口) # 累积确认ack字段 当前期待的下一个线序号 self.sock.sendto(pack_ack(self.base % SEQ_MOD, self._free_window()), addr) def _free_window(self): return max(0, self.wind - len(self.cache))接收端的cache用线序号做键因为窗口大小4、序号空间8窗口内同一个线序号最多出现一次重复到达时覆盖是安全的。_free_window算的是剩余缓存空间缓存占1个剩余窗口就少1个这个字段会被发送端当成recv_wind使用决定还能发多少个新包。3.4 丢包模拟与测试脚本三个场景跑出窗口的完整行为测试脚本把上面两个类串起来跑三个场景完全无丢包、接收端丢包、发送端丢包。payload我建议用8KB数据分成8个1024字节的chunk这样超过窗口4会触发窗口滑动和序号回绕。# test.py —— 三个场景正常、接收端丢包、发送端丢包 import threading import time from sender import Sender from receiver import Receiver def run_scenario(name, send_loss, recv_loss): print(f 场景{name} (send_loss{send_loss}, recv_loss{recv_loss}) ) recv Receiver(9001, loss_raterecv_loss) threading.Thread(targetrecv.receive, daemonTrue).start() time.sleep(0.2) sender Sender((127.0.0.1, 9001), loss_ratesend_loss) payload bA * 8192 bEND sender.send(payload) time.sleep(0.5) ok (recv.data payload) print(f接收端拼装长度{len(recv.data)}校验{通过 if ok else 失败}) print() run_scenario(正常无丢包, 0.0, 0.0) run_scenario(接收端丢包20%, 0.0, 0.2) run_scenario(发送端丢包20%, 0.2, 0.0)跑出来的预期结果场景1没有重传所有包按序到达base逐步滑到8场景2接收端丢包发送端超时后重传日志里能看到[CACHE]和[RETRANSMIT]交替出现但最终数据完整场景3发送端丢包重传发生在发送端超时回调里接收端完全感知不到最终也能收敛。判断模拟器正确性的唯一标准是三种场景下recv.data payload都成立而且场景1中向量的[ACK]日志里base始终保持递增。如果base往回跳说明绝对序号和线序号混用了回去检查2.2节的分层设计。4. 常见问题与避坑滑动窗口模拟中的五个翻车现场4.1 现象NAK风暴导致窗口停滞重传刷屏跑接收端丢包场景时日志里出现大量[RETRANSMIT]而且窗口base一直不动吞吐掉到零。原因是接收端对乱序包回了NAK带错误序号的否定确认发送端把NAK当成了重传窗口内所有包的指令两边对窗口外包的处理策略不一致。解决方法是接收端一律不回NAK只回累积ACKack字段永远是当前期待的下一个线序号发送端只认ACK忽略diff等于0的重复确认交给每个包自己的超时定时器处理。真实TCP里的快速重传Fast Retransmit是另一个话题模拟器第一步先把超时重传跑稳再加DUP ACK逻辑不迟。4.2 现象定时器重置与重传死循环程序卡死某个包反复超时每次重传后紧接着又超时日志里的[RETRANSMIT]间隔几乎等于RTO然后程序永远停在等ACK的循环里。原因有两个一是重传前没有把旧的Timer从timers字典里取消掉旧的回调排队堆积二是RTO设得太短低于本地连续发包的间隔0.02秒一次的发包节奏很容易触碰到假超时。解决方法是重传前必须timer.cancel()并从字典pop干净再创建新的TimerRTO至少是发包间隔的10倍以上我通常设300毫秒起步。另外回调函数开头一定要检查该绝对序号是否仍在窗口内窗口已经滑过去了就不要再重传了。4.3 现象发送端发两个包就停住接收端明明还有缓存空间发送端窗口计算用的是min(self.wind, self.recv_wind)如果recv_wind一直是0发送端就会认为对端接收窗口满了永久等待。实际原因是接收端在回ACK时把窗口通告写成了固定值WINDOW_SIZE或者发送端把本端窗口和对端通告混用一个变量导致对端剩余空间被误判。解决方法是接收端每次回ACK时都用_free_window()动态计算剩余窗口等于WINDOW_SIZE减去当前缓存占用的包数发送端用两个独立变量分别表示本端发送上限和对端通告窗口取较小者作为有效窗口。把这两个变量合并成一个的写法从逻辑上就说不通。4.4 现象序号回绕后ACK被当成无效包丢弃窗口永久卡住这是最玄学的一个坑。发送端发完线序号7后下一个包线序号变成0此时收到ack0程序判断ack base以为是重复的旧ACK直接丢掉。窗口左边界其实应该前进但程序再也没机会收到下一个ACK整个连接挂死。原因就是2.2节说的窗口状态用了线序号base从7滑到0时看起来像后退了所有小于号比较全部失效。解决方法是强制分层窗口状态只用绝对单调序号网络包字段保留模序号。收到ACK时用diff (ack_wire - (base % SEQ_MOD) SEQ_MOD) % SEQ_MOD计算确认数量而不是去比较ack和base的线序号大小。4.5 现象发送端发完数据立刻发FIN接收端丢了几百字节发送端最后一个包确认前就发送FIN接收端手里还握着乱序缓存FIN到达后被当成普通包处理或者触发提前flush最后拼接出来的data长度小于payload。原因是发送端没有等所有定时器清空就结束了发送循环。解决方法是发送端发FIN之前必须保证self.timers为空也就是send()方法里那个收尾while循环要跑完。接收端收到FIN时先把乱序缓存全部冲刷、base推进到最终位置再回一个ACK表示结束。两端的顺序都不能省否则就会复现数据差最后一块的诡异现象。5. 验证与进阶用轨迹日志把滑动窗口调到教科书级表现跑通测试只是第一步第二个包丢的时候窗口到底怎么动肉眼很难从零散的[ACK]和[RETRANSMIT]日志里拼出来。我给模拟器加了一个轨迹日志函数每次ACK处理后打印窗口左边界、右边界、下一个待发送序号和剩余窗口def trace(sender, tag): eff_wind min(sender.wind, sender.recv_wind) right_edge sender.base eff_wind free eff_wind - (sender.next_seq - sender.base) print(f[{tag}] t{time.time():.3f} base{sender.base} fright{right_edge} next{sender.next_seq} ffree{free} inflight{sender.next_seq - sender.base})把trace挂到_handle_ack末尾跑一次发送端丢包场景你会看到base从0一步步走到8right始终等于base加4free和inflight加起来恒等于4。这个不变量是滑动窗口协议的核心窗口内已发送未确认的包数加上剩余可用窗口永远等于窗口大小。如果哪一行日志里这个等式不成立说明状态更新逻辑有bug。这套模拟器的状态语义和真实TCP是逐一对得上的整理成下面这张表模拟器变量真实TCP变量语义self.basesnd_una最早未确认序号self.next_seqsnd_nxt下一个发送序号self.windcwnd本端拥塞窗口self.recv_windrcv_wnd对端通告窗口self.timerstcp_timer重传计时器集合做到这步一个进阶玩法就顺理成章了把Sender和Receiver抽成纯协议状态机不碰socket只暴露handle_data和handle_ack两个方法然后上面接一个TUN虚拟网卡把真实网络进来的包喂给这个状态机。这样模拟器就从教学玩具变成了一个可以对照tcpdump抓包结果的半真实协议实现。我自己的习惯是每写一个协议模拟都先加trace再跑场景。不管日志多难看只要窗口不变量保持协议行为就是可预期的。希望这个思路帮到你以后排查真实的网络协议栈问题时也能少绕弯路。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。