公司动态

C++实现停止等待协议:从零构建可靠数据传输模拟器

📅 2026/8/9 8:10:32
C++实现停止等待协议:从零构建可靠数据传输模拟器
1. 项目概述与核心价值最近在整理网络编程相关的学习笔记发现很多朋友对TCP/IP协议栈底层的可靠传输机制很感兴趣但往往停留在理论层面。今天我就以一个经典的“停止等待协议”为蓝本带大家用C从零开始亲手实现一个简化版的可靠数据传输模拟器。这不仅仅是一个编程练习更是理解滑动窗口、超时重传、流量控制等现代网络协议核心思想的绝佳入口。如果你正在学习计算机网络、准备面试或者单纯想提升自己的C系统编程和面向对象设计能力这个项目会非常对胃口。停止等待协议英文叫Stop-and-Wait ARQ是自动重传请求中最基础的一种。它的逻辑直白得就像两个人面对面传纸条A写一张纸条发送一个数据包给B然后必须停下来眼睛盯着B直到B点头或者说“收到”返回确认ACKA才敢写下一张纸条。如果B一直没反应超时或者B说“没看清”收到否定确认NAKA就得把刚才的纸条再抄一遍重传。这个项目就是要用C模拟出这个“传纸条”的完整过程包括数据包封装、定时器管理、确认机制以及简单的错误模拟。2. 协议原理深度拆解与设计思路2.1 停止等待协议的核心状态机实现这个协议首先要吃透它的状态机。这比单纯看定义要清晰得多。我们可以从发送方和接收方两个角色来分别建模。对于发送方其生命周期在一个数据包上循环发送状态将当前序列号的数据包发送出去同时启动一个定时器。这是最“紧张”的时刻数据已经离手命运未知。等待状态进入阻塞或轮询等待来自接收方的反馈。这里的关键是定时器在后台滴答作响。裁决状态收到反馈或定时器到期。有三种可能收到ACK且ACK的序列号与刚发送的数据包序列号匹配。皆大欢喜序列号加1跳回状态1准备发送下一个包。收到NAK或错误的ACK说明对方明确要求重传或确认有误。立即重传原数据包并重置定时器回到状态2继续等待。定时器超时在规定时间内没收到任何反馈。默认网络丢包数据包或ACK丢失重传原数据包重置定时器回到状态2。对于接收方逻辑相对简单等待状态等待一个数据包的到来。校验状态收到数据包后进行校验比如计算校验和。如果数据完好且序列号正是期望的序列号则向上层交付数据并发送一个携带该序列号的ACK。同时期望序列号加1。错误处理如果数据损坏或序列号不是期望的比如收到重复包则发送一个NAK或重新发送上一次的ACK取决于协议变种要求发送方重传。期望序列号不变。注意在实际的TCP等协议中通常没有独立的NAK。对于出错或乱序的包接收方可以选择不回复促使发送方超时重传或回复一个对最近正确接收包的ACK即冗余ACK。在我们的教学实现中为了清晰可以选择实现NAK机制。2.2 为什么选择C来实现你可能会问Python写起来不是更快吗确实但用C实现有不可替代的优势贴近系统底层网络协议最终要在操作系统内核中运行。C能让你更细致地控制内存数据包缓冲区、时间定时器精度和线程/进程并发模型这种体验是高级语言难以提供的。性能考量虽然本项目是模拟但良好的C实现能让你思考如何高效地管理对象如数据包、减少拷贝使用移动语义、处理并发虽然停止等待是串行的但收发两端可以抽象为不同线程。面向对象设计的绝佳练习如何设计Packet类Sender和Receiver类应该如何交互Timer类如何实现这些都能锻炼你的类设计、接口抽象和模块解耦能力。为更复杂协议打基础理解了这个基础版本未来实现回退N步GBN或选择重传SR协议时大部分底层组件包结构、校验、基础网络接口都可以复用主要修改的是发送窗口和接收缓存的管理逻辑。2.3 项目整体架构设计我们不打算涉及真实的网络套接字那样会引入太多网络环境的不确定性。我们将构建一个离散事件模拟环境。核心组件包括虚拟网络层一个Network类负责在Sender和Receiver之间传递Packet对象。它的关键方法是sendToNetwork(Packet pkt)在这个方法里我们可以人为引入丢包、损坏和延迟来模拟不可靠信道。数据包类Packet类包含序列号seq、确认号ack、数据载荷data、校验和checksum以及一个类型字段可能是DATA, ACK, NAK。发送方类Sender类核心属性包括当前序列号nextSeqNum、已发送但未确认的包packetWaitingAck、一个定时器timer。核心方法有sendData()、receiveResponse(Packet ackPkt)、timeoutHandler()。接收方类Receiver类核心属性是期望的序列号expectedSeqNum。核心方法有receiveData(Packet dataPkt)和sendAckOrNak()。定时器类Timer类这是一个关键且有趣的部分。由于是模拟我们不需要真正的系统定时器。可以维护一个模拟的全局时钟currentTime每个Timer记录一个到期时间expireTime。主循环每次递增currentTime并检查是否有定时器到期。更简单的方法是为发送方设置一个bool waitingFlag和一个int timeoutCounter每次主循环迭代递减计数器减到0即视为超时。整个系统的驱动可以是一个简单的while循环不断调用发送方的“是否可发送”逻辑、接收方的“检查网络层”逻辑并更新模拟时钟。3. 核心类设计与实现细节3.1 数据包的设计与编解码数据包是协议实体间交换的基本单位。设计时需要考虑可扩展性和调试便利性。// packet.hpp #ifndef PACKET_HPP #define PACKET_HPP #include cstdint #include string #include vector enum class PacketType : uint8_t { DATA, ACK, NAK }; class Packet { public: Packet() default; Packet(PacketType type, uint32_t seqNum, const std::string payload); // 序列化将包内容转换为字节流用于计算校验和或“发送” std::vectorchar serialize() const; // 反序列化从字节流可能已损坏恢复对象 bool deserialize(const std::vectorchar byteStream); // 计算校验和简单示例字节求和取反 uint16_t calculateChecksum() const; bool verifyChecksum() const { return checksum_ calculateChecksum(); } void setChecksum() { checksum_ calculateChecksum(); } // 损坏模拟随机翻转载荷中的一位 void corruptRandomly(); // Getter Setter PacketType getType() const { return type_; } uint32_t getSeqNum() const { return seqNum_; } uint32_t getAckNum() const { return ackNum_; } // 对于ACK包ackNum有效 std::string getPayload() const { return payload_; } // ... 其他getter/setter private: PacketType type_; uint32_t seqNum_; // 数据包的序列号 uint32_t ackNum_; // 确认号用于ACK包 uint16_t checksum_; std::string payload_; }; #endif // PACKET_HPP实现要点checksum字段在序列化时不参与校验和计算本身否则会陷入“先有鸡还是先有蛋”的循环。通常是将除checksum外的字段按一定规则如16位相加计算出一个值存入checksum。接收方验证时重新计算除checksum外字段的校验和看是否与包中的checksum相等。serialize/deserialize方法在真实网络中对应send/recv系统调用后的缓冲区处理。这里实现它们有助于理解数据在网络中的“字节流”本质。corruptRandomly方法用于在Network类中模拟信道错误这是测试协议鲁棒性的关键。3.2 发送方的状态管理与定时器发送方是协议的核心其状态管理必须清晰。// sender.hpp #ifndef SENDER_HPP #define SENDER_HPP #include packet.hpp #include memory #include queue class Timer { public: Timer(int timeoutInterval) : interval_(timeoutInterval), active_(false) {} void start() { active_ true; remainingTime_ interval_; } void stop() { active_ false; } bool isActive() const { return active_; } // 在模拟时钟滴答中调用 void tick() { if (active_ remainingTime_ 0) --remainingTime_; } bool isTimeout() const { return active_ remainingTime_ 0; } void reset() { remainingTime_ interval_; } private: int interval_; // 超时间隔模拟时间单位 int remainingTime_; bool active_; }; class Sender { public: Sender(int timeout 5); // 超时时间可配置 // 上层应用调用请求发送数据 void receiveFromUpperLayer(const std::string data); // 被模拟环境调用驱动状态机 void tick(); // 处理定时器滴答和状态判断 // 从网络层接收响应ACK/NAK void receiveResponse(const Packet responsePkt); // 获取下一个要发送到网络层的包如果有 bool hasPacketToSend() const; Packet getPacketToSend(); private: enum class State { READY_TO_SEND, WAITING_FOR_ACK }; State state_; uint32_t nextSeqNum_; // 下一个要使用的序列号 uint32_t baseSeqNum_; // 已发送待确认的包序列号停止等待中baseSeqNum_ nextSeqNum_ - 1 std::unique_ptrPacket packetPendingAck_; // 已发送但未确认的包 Timer timer_; std::queuestd::string dataQueue_; // 上层数据缓存队列 void sendPacket(); // 构造并发送一个数据包 void handleTimeout(); }; #endif // SENDER_HPP实现与踩坑点状态清晰READY_TO_SEND和WAITING_FOR_ACK两个状态足够。在tick()函数中根据当前状态决定行为如果是等待状态则检查定时器如果是就绪状态且队列有数据则发送。定时器管理这是最容易出错的地方。务必记住每次发送一个新包必须重启定时器收到正确的ACK后必须停止定时器超时后重发包必须重启定时器。我最初实现时就忘了在收到ACK后停止定时器导致后续逻辑混乱。包缓存packetPendingAck_必须保存已发送包的副本因为超时需要重传。不能只存序列号因为数据载荷可能来自上层重传时需要原数据。序列号回绕虽然是教学项目但好的习惯是使用无符号整数并在比较序列号时考虑回绕问题例如使用(a - b) 0xFFFFFFFF这种比较方式。本项目数据量小可以暂不考虑但心里要有这根弦。3.3 接收方的逻辑与错误处理接收方相对简单但校验和重复包处理是关键。// receiver.hpp #ifndef RECEIVER_HPP #define RECEIVER_HPP #include packet.hpp #include functional class Receiver { public: using DeliverCallback std::functionvoid(const std::string); Receiver(DeliverCallback deliverCb); // 从网络层接收数据包 void receivePacket(const Packet dataPkt); // 获取要发送到网络层的响应包ACK/NAK bool hasResponseToSend() const; Packet getResponseToSend(); private: uint32_t expectedSeqNum_; Packet pendingResponse_; // 准备发出的ACK或NAK bool responseReady_; DeliverCallback deliverToUpperLayer_; // 交付数据给“应用层”的回调 void sendAck(uint32_t ackNum); void sendNak(uint32_t seqNum); // 可选实现 }; #endif // RECEIVER_HPP实现要点交付回调接收方收到正确数据后需要通知“上层应用”。这里使用std::function回调是解耦的好方法方便测试和扩展。重复包处理如果收到序列号小于expectedSeqNum_的包说明是重复包。此时不能向上层交付数据但必须重新发送一个ACK。这是停止等待协议避免ACK丢失导致死锁的关键。很多初学者实现的协议在这里会卡住因为发送方收不到ACK而接收方又在等待下一个新包双方互相等待。校验和失败如果verifyChecksum()失败理想情况是发送NAK。如果像TCP一样不实现NAK则可以选择不发送任何响应让发送方超时重传。在我们的实现中为了演示两种错误处理可以都实现。3.4 不可靠网络的模拟层这是让项目变得有趣和真实的部分。Network类模拟了真实网络中的各种缺陷。// network.hpp #ifndef NETWORK_HPP #define NETETWORK_HPP #include packet.hpp #include random class Network { public: Network(double lossProb 0.1, double corruptProb 0.05, int avgDelay 2) : lossProbability_(lossProb), corruptProbability_(corruptProb), delayDistribution_(avgDelay), gen_(std::random_device{}()) {} // “发送”一个包根据概率决定其命运 void sendPacket(const Packet pkt, std::functionvoid(Packet) deliverCallback); private: double lossProbability_; double corruptProbability_; std::poisson_distribution delayDistribution_; // 用泊松分布模拟延迟 std::default_random_engine gen_; // 模拟延迟在实际项目中可以用队列和未来事件列表来实现 // 这里简化处理在sendPacket内部根据概率和随机数决定是否立即、延迟、丢弃或损坏后传递包 // 注意这是一个简化的同步模拟更复杂的模拟需要事件驱动框架。 }; #endif // NETWORK_HPP在简化的同步模拟中Network::sendPacket可以这样工作生成一个随机数如果小于丢包率则直接返回丢弃。否则再生成一个随机数如果小于损坏率则调用pkt.corruptRandomly()然后将损坏的包通过回调函数“传递”出去。延迟可以通过在主循环中维护一个“未来事件列表”来模拟将包和其预定送达时间放入列表每次主循环迭代检查并交付到期的包。4. 项目集成与模拟主循环将上述组件组装起来形成一个可运行的模拟程序。// main.cpp (模拟引擎) #include sender.hpp #include receiver.hpp #include network.hpp #include iostream #include thread #include chrono int main() { // 1. 初始化组件 Sender sender(5); // 超时5个时间单位 Receiver receiver([](const std::string data) { std::cout [应用层] 收到数据: data std::endl; }); Network network(0.2, 0.1, 3); // 丢包率20%损坏率10%平均延迟3单位 // 2. 模拟上层应用产生数据 std::vectorstd::string messages {Hello, Stop, Wait, Protocol, End}; int messageIndex 0; // 3. 主模拟循环 int currentTime 0; const int maxSimTime 100; bool senderHasData true; while (currentTime maxSimTime senderHasData) { std::cout \n 时间片 currentTime std::endl; // A. 上层数据注入 if (messageIndex messages.size() currentTime % 10 0) { // 每10个时间单位产生新数据 sender.receiveFromUpperLayer(messages[messageIndex]); std::cout [发送方] 从上层接收到数据: messages[messageIndex-1] std::endl; if (messageIndex messages.size()) { senderHasData false; // 所有数据已注入 } } // B. 驱动发送方检查状态、处理超时 sender.tick(); if (sender.hasPacketToSend()) { Packet pktToSend sender.getPacketToSend(); std::cout [发送方] 尝试发送包序列号 pktToSend.getSeqNum() std::endl; // 通过网络发送网络可能丢包、损坏、延迟 network.sendPacket(pktToSend, [receiver](Packet pkt) { // 这是网络延迟后的回调模拟包到达接收方 std::cout [网络] 包到达接收方序列号 pkt.getSeqNum(); if (!pkt.verifyChecksum()) { std::cout (已损坏!) std::endl; } else { std::cout (完好) std::endl; } receiver.receivePacket(pkt); }); } // C. 接收方生成响应 if (receiver.hasResponseToSend()) { Packet resp receiver.getResponseToSend(); std::cout [接收方] 生成响应类型 static_castint(resp.getType()) , ack resp.getAckNum() std::endl; // 响应包同样经过不可靠网络 network.sendPacket(resp, [sender](Packet pkt) { std::cout [网络] 响应到达发送方 std::endl; sender.receiveResponse(pkt); }); } // D. 模拟时间流逝例如网络层内部处理延迟事件 // network.advanceTime(); // 如果Network有内部事件队列需要推进其时间 // E. 简单延时方便观察输出 std::this_thread::sleep_for(std::chrono::milliseconds(500)); currentTime; } std::cout \n模拟结束。 std::endl; return 0; }这个主循环是一个简化的时间片轮转模拟。在更严谨的离散事件模拟中你会维护一个全局的“未来事件列表”按时间顺序调度包到达、定时器到期等事件。但上述循环式模拟对于理解协议交互已经足够直观。5. 测试、调试与常见问题实录5.1 如何设计有效的测试用例测试是协议实现的关键。不要只测“阳光大道”要重点测试“崎岖小路”。理想信道测试将Network的丢包率和损坏率设为0。观察数据是否按顺序正确传输没有不必要的重传。这是基础功能验证。仅丢包测试设置较高的丢包率如30%损坏率为0。观察发送方是否能通过超时机制可靠地重传丢失的数据包。在控制台输出中你应该能看到大量的[发送方] 超时重传序列号X的日志。仅数据损坏测试设置丢包率为0损坏率为20%。观察接收方在收到损坏包时是否发送NAK或不发送ACK以及发送方是否能正确处理NAK或超时并进行重传。ACK丢失测试这是停止等待协议最经典的问题。模拟网络只丢失ACK包。你需要观察到发送方会超时并重传已经正确送达但ACK丢失的包。此时接收方会收到重复包它必须能识别并重新发送ACK而不是向上层重复交付数据。这是检验你的接收方重复包处理逻辑是否正确的关键。综合压力测试同时设置丢包和损坏并发送大量数据。观察最终所有数据是否都能正确、按序交付。可以增加一个计数器统计总发送包数、重传包数计算一下有效吞吐量会很有意思。5.2 调试技巧与日志输出网络协议调试清晰的日志是你的眼睛。结构化日志像上面示例代码一样为每个角色[发送方]、[接收方]、[网络]和关键动作发送、接收、超时、交付打上标签。使用序列号、ACK号、状态等关键信息。状态快照在每次状态转换如Sender::tick()时可以打印出当前状态、nextSeqNum、定时器状态等。这能帮你快速定位状态机卡住的位置。使用调试器在关键函数如receiveResponse,handleTimeout入口设置断点单步执行查看变量值。特别是检查序列号的比较逻辑和定时器的启动/停止标志。5.3 常见问题与排查清单下面是我在实现和教学过程中学生们最容易踩的坑整理成了排查清单问题现象可能原因排查步骤与解决方案发送方发送一次后卡住不再发送1. 定时器未启动或启动逻辑错误。2. 收到ACK后状态未转换回READY_TO_SEND。3. 上层数据队列为空或hasPacketToSend逻辑有误。1. 检查sendPacket()函数末尾是否调用了timer_.start()。2. 在receiveResponse中确认收到正确ACK后设置了state_ READY_TO_SEND并timer_.stop()。3. 检查dataQueue_是否有数据以及hasPacketToSend的判断条件是否包含state_ READY_TO_SEND !dataQueue_.empty()。接收方收到重复数据接收方对重复包序列号小于expectedSeqNum_没有正确处理可能重复交付给了上层。在receivePacket中增加对序列号的判断if (seq expectedSeqNum_-1)说明是上一个包的重复应发送ACK但不交付数据。ACK丢失后通信死锁发送方超时重传旧包接收方收到旧包后如果只是简单地因为序列号不对而丢弃或不回应发送方将永远收不到ACK。这正是上一条的解决方案。接收方对任何已确认过的旧包都必须回复ACK。这能让发送方知道“这个包我早就收到了你别再发了”从而推进序列号。校验和永远验证失败1. 计算校验和的范围不对可能包含了checksum_字段本身。2. 序列化/反序列化时字节序问题本机序 vs 网络序。1. 确认calculateChecksum()函数计算时排除了checksum_字段。通常是将其他字段的字节逐个相加。2. 教学项目可忽略字节序。若考虑应在序列化时将整数转为网络字节序htonl反序列化时转回主机序ntohl。模拟运行一次就结束主循环条件设置不当或者所有数据瞬间被“发送”完没有模拟出等待和重传的过程。1. 确保主循环有足够的时间片maxSimTime。2. 在Network::sendPacket中引入随机延迟不要立即回调。或者使用事件队列模型让包在未来的某个时间点到达。3. 检查Network的丢包/损坏逻辑是否生效如果总是立即成功送达协议会跑得飞快。内存访问错误对packetPendingAck_等指针或智能指针使用不当在对象已失效后访问。1. 使用std::unique_ptr管理动态包对象利用RAII避免内存泄漏。2. 在重传时确保是深拷贝或重新序列化/反序列化包避免直接传递已被网络层“损坏”的包对象引用。5.4 性能优化与扩展思考当基础版本稳定运行后可以思考以下扩展这会让你的项目从“作业级”提升到“作品级”可视化不用GUI可以用字符画在控制台实时绘制发送窗口、接收窗口的状态以及数据包和ACK的流动动画。这非常直观。统计信息在Sender和Network中增加计数器统计总发送包数、重传次数、成功交付数据量。最后计算有效吞吐量和信道利用率。你会发现停止等待协议在延迟大的信道上利用率极低这就是为什么需要滑动窗口协议。可变超时时间实现一个简单的RTT估计动态调整超时时间模拟TCP的RTO计算。迈向滑动窗口这是最自然的扩展。将Sender中的packetPendingAck_从一个包扩展为一个窗口vector或dequebaseSeqNum和nextSeqNum分别指向窗口下沿和上沿1。重传逻辑从单包重传变为回退N步Go-Back-N或选择重传Selective Repeat。这个改动能让你深刻理解滑动窗口协议是如何提升效率的。实现这个项目的过程就像在微观世界里亲手搭建了一条小小的、不可靠的通信隧道并用自己的代码逻辑保证了隧道的可靠。这种从原理到代码的穿透感是只看书和论文无法获得的。当你看到在20%丢包率的恶劣模拟环境下程序依然能顽强地将所有数据正确送达时你会对那些构建了现代互联网的协议设计者们产生由衷的敬佩。