☰
TFTP C++客户端避坑:Winsock UDP超时重传与端口切换
2026/10/26 3:40:08 网站建设 项目流程

简介:一份面向网络编程初学者与C++进阶学习者的TFTP客户端实现资料,围绕基于UDP的简单文件传输协议展开,帮助读者理解请求与响应、数据分块、确认包及错误码等核心流程,适合课程设计、协议入门和Socket编程练习。压缩包共4个文件,以2个C++头文件、1个cpp源文件和1个exe可执行程序为主,整体约622KB,头文件承载协议常量与函数声明,源文件对应客户端主体逻辑,便于直接编译调试或对照运行。已有1019人学习关注,说明其在网络实验与小型文件传输场景中具有一定参考价值。内容覆盖Winsock初始化、socket创建、地址解析、RRQ/WRQ请求构造、512字节数据块收发、ACK确认与错误处理等关键环节,并提示重试、超时和多线程优化方向。读者可据此搭建可运行的TFTP客户端,掌握UDP套接字编程、协议解析与常见故障排查思路,为后续更复杂的网络应用开发打基础。

1. TFTP 客户端不是「发个 UDP 就完事」:一次抓包暴露的三个致命误解

很多人第一次写tftp c++ socket winsock版本的客户端,都是照着 RFC 那几页纸动手的:五种报文、两个字节的操作码、块号从 1 开始,看起来两小时能跑通。真跑起来才发现,能发出 RRQ 和能把一个几十兆的固件安静收完,中间隔着的不是语法,而是超时判定、端口切换和重复应答这三件事。我见过最典型的翻车现场是:本机测试百发百中,换到局域网另一边就卡在第一个块,日志里一片安静,连错都不报。这份资源值得拆的地方,正在于它把这些「RFC 没画出来的边界」都落成了可运行的 C++ 代码,而不是一个只能在本机回环上自嗨的 demo。适合已经会写 socket、但还没啃过应用层协议状态机的人。

2. 协议帧拆到字节:RRQ/WRQ 的 2 字节操作码与选项协商

TFTP 的报文短到可以手写,但正因为短,任何一个字节放错位置都不会有编译错误,只会表现为对端沉默。这一章先把线格式(wire format)钉死,再谈选项协商这条「非标准但人人都在用」的路。

2.1 五种报文的结构与字节序陷阱

所有 TFTP 报文的第一字段都是 16 位操作码,网络字节序,也就是高字节在前。常见的错误是直接用memcpy把uint16_t拷进去,在小端机器上就变成了0x0100而不是1,服务器看到未知操作码直接丢弃,客户端只看到超时。

操作码报文结构
1RRQ 读请求opcode + filename\0 + mode\0 + [opt\0 val\0]…
2WRQ 写请求同 RRQ
3DATAopcode + block# + 0~n 字节数据
4ACKopcode + block#
5ERRORopcode + errcode + msg\0
6OACKopcode + [opt\0 val\0]…

mode 字段只有octet和netascii两种在实际使用中靠谱,mail早废了。一律用octet,netascii要做 CRLF 转换,各家实现细节不一致,跨厂商设备对接时特别容易出玄学问题——收到的文件比源文件多了几十个字节,全是\r。

文件名必须是相对名,不能带../或盘符。这不是洁癖,是安全底线:服务端如果不做校验,一个 RRQ 就能把整台机器的目录结构摸一遍。客户端这边同样要拒绝服务端返回的、包含路径分隔符的文件名。

错误码要认识,排错时全靠它:

码含义常见触发
0未定义对端实现不规范
1文件不存在路径写错、大小写敏感
2访问违规权限、目录不可写
3磁盘满目标分区满
4非法操作操作码或字段错位
5未知传输 ID端口对不上,最常见的报错
6文件已存在WRQ 覆盖同名文件
8选项协商失败blksize 越界

收到 5 号错误,八成不是协议写错,而是你把包发给了 69 端口,却指望 69 回你数据——下一章讲端口切换。

2.2 选项协商:blksize、timeout、tsize 怎么谈

RFC 1350 只允许 512 字节定长块,实际传输效率很低。后来扩展出了选项协商:客户端在 RRQ/WRQ 末尾追加选项名\0值\0的键值对,服务端如果支持,回一个 OACK(操作码 6)把接受的选项回显;如果不支持,服务端会直接忽略选项,回一个普通的 DATA(块号 1)或 ACK(块号 0)。

最后这条是很多人漏掉的兼容分支。代码里如果只判断「收到 OACK 就继续、否则报错」,遇到老设备必然翻车。正确写法是:收到 DATA 块号 1 或 ACK 块号 0,说明协商被拒,立刻退回 512 字节块的默认模式继续跑,不要中断传输。

三个常用选项的取值边界:

  • blksize:合法范围 8~65464。取值别贪大,UDP 载荷加上 IP 头和 UDP 头(各 20 + 8 字节),超过路径 MTU 就会被分片,丢一片整个数据报作废,重传成本反而更高。以太网环境我一般设 1428,留出余量;跨广域链路老老实实 512。
  • timeout:1~255 秒,协商成功后双方都用这个值做重传定时器,比硬编码 5 秒合理得多。
  • tsize:下载时发tsize\0 0\0,服务端在 OACK 里回真实文件大小,可以提前算进度条、提前判断磁盘空间够不够;上传时直接填本地文件字节数。

blksize一旦协商成功,DATA 报文的载荷长度就以新值为准,但最后一个数据块仍然可能小于 blksize,判读结束的标志是「收到长度小于 blksize 的 DATA」,而不是「收到长度为 0 的 DATA」。这条如果写错,文件末尾会多出一个空块,接收方一直等,发送方以为发完了,最后靠超时收场。

2.3 把报文封装成可复用的编解码函数

原始实现最容易犯的错是把打包和解包散落在收发两处,改一个字段要改四个地方。抽成两个纯函数,输入输出都是字节缓冲区,测试起来也方便。

#include <cstdint> #include <string> #include <vector> #include <utility> // 构建 RRQ/WRQ:opcode(2) + filename\0 + mode\0 + [opt\0 val\0]... // 注意:所有 16 位字段必须显式拆成高低字节,不能 memcpy 整个 uint16_t std::vector<uint8_t> buildRequest(uint16_t opcode, const std::string& filename, const std::string& mode, const std::vector<std::pair<std::string, std::string>>& opts) { std::vector<uint8_t> pkt; pkt.reserve(2 + filename.size() + 1 + mode.size() + 1 + opts.size() * 24); pkt.push_back(static_cast<uint8_t>((opcode >> 8) & 0xFF)); // 高字节 pkt.push_back(static_cast<uint8_t>(opcode & 0xFF)); // 低字节 pkt.insert(pkt.end(), filename.begin(), filename.end()); pkt.push_back('\0'); pkt.insert(pkt.end(), mode.begin(), mode.end()); pkt.push_back('\0'); for (const auto& kv : opts) { // 选项必须成对出现,顺序不限 pkt.insert(pkt.end(), kv.first.begin(), kv.first.end()); pkt.push_back('\0'); pkt.insert(pkt.end(), kv.second.begin(), kv.second.end()); pkt.push_back('\0'); } return pkt; } // 解 DATA:只做指针切分,不拷贝数据,避免大文件下的无谓内存分配 struct DataView { uint16_t block = 0; const uint8_t* payload = nullptr; size_t len = 0; }; bool parseData(const uint8_t* buf, size_t n, DataView& out) { if (n < 4) return false; // 最短 DATA 是 4 字节空块 uint16_t op = static_cast<uint16_t>(buf[0] << 8 | buf[1]); if (op != 3) return false; out.block = static_cast<uint16_t>(buf[2] << 8 | buf[3]); out.payload = buf + 4; out.len = n - 4; return true; }

buildRequest里没有用htons,而是手动移位。原因是选项字段是字符串,整包长度不固定,混用字节序函数和手写拼接很容易在某一处漏掉转换;统一手写反而不会忘。parseData返回的是指向接收缓冲区的视图,调用方必须在处理完之前保证缓冲区有效——单线程同步收发时没问题,一旦改成异步就要换成拷贝语义,这点要提前想清楚。

选项列表用vector<pair<string,string>>而不是map,因为 TFTP 对选项顺序不敏感,但保序便于抓包时肉眼比对。

3. Winsock 下的套接字骨架:从 WSAStartup 到 recvfrom 超时

这一层是纯工程问题,协议无关,但在 Windows 上踩的坑比协议本身还多。同一份代码要是在 Linux 上编过,搬到 Winsock 大概率编不过,或者编过了跑起来行为不一样。

3.1 初始化与资源生命周期的正确顺序

Winsock 必须先WSAStartup才能调用任何 socket 函数,否则返回WSANOTINITIALISED。工程上常见两种写法:在main里手动配对WSAStartup/WSACleanup,或者包一个 RAII 守卫,构造时初始化、析构时清理。我倾向前者,因为WSAStartup失败要报错退出,构造函数里抛异常在某些编译选项下不可靠。

#ifdef _WIN32 #include <winsock2.h> #include <ws2tcpip.h> #pragma comment(lib, "ws2_32.lib") using socket_t = SOCKET; static const socket_t kInvalid = INVALID_SOCKET; #else #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <unistd.h> #include <cerrno> using socket_t = int; static const socket_t kInvalid = -1; #endif bool netInit() { #ifdef _WIN32 WSADATA wsa{}; return WSAStartup(MAKEWORD(2, 2), &wsa) == 0; // 请求 2.2 版本 #else return true; #endif } void netShutdown() { #ifdef _WIN32 WSACleanup(); #endif } void closeSocket(socket_t s) { #ifdef _WIN32 closesocket(s); #else ::close(s); #endif }

这里有个顺序坑:winsock2.h必须排在windows.h之前,否则会被老版winsock.h抢先定义,报一堆重定义错误。如果项目里别的头文件间接包含了windows.h,就在编译选项里加WIN32_LEAN_AND_MEAN,从源头掐掉。

socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)三个参数里第三个写0也能跑,因为 UDP 是SOCK_DGRAM的默认协议,但显式写IPPROTO_UDP更利于阅读。

3.2 超时:SO_RCVTIMEO 的毫秒与微秒陷阱

TFTP 没有长连接,全靠定时器驱动重传。最简单的方式是给 socket 设SO_RCVTIMEO,让recvfrom在超时后返回错误,主循环据此重传。问题在于:Windows 的SO_RCVTIMEO单位是毫秒,参数类型是DWORD;Linux 的单位是struct timeval。同一段代码抄过去,Linux 上会立刻超时——因为你把一个整数当成timeval解释,tv_sec拿到的是低 4 字节的垃圾值。

// 设置接收超时。Windows 传 DWORD 毫秒,POSIX 传 timeval(秒 + 微秒) bool setRecvTimeout(socket_t s, int timeoutMs) { #ifdef _WIN32 DWORD ms = static_cast<DWORD>(timeoutMs); return setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast<const char*>(&ms), sizeof(ms)) == 0; #else struct timeval tv{}; tv.tv_sec = timeoutMs / 1000; tv.tv_usec = (timeoutMs % 1000) * 1000; // 毫秒转微秒 return setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) == 0; #endif }

判断超时错误也要分开写。Windows 下recvfrom返回SOCKET_ERROR,WSAGetLastError()给WSAETIMEDOUT;Linux 下返回-1,errno给EAGAIN或EWOULDBLOCK(这两个值通常相等)。只判WSAETIMEDOUT的代码在 Linux 上会把超时当成致命错误直接退出。

另一个容易忽略的点:SO_RCVTIMEO设在 socket 上是全局生效的。如果一段逻辑里既要用 1 秒超时轮询、又要用 5 秒等待数据,就得反复setsockopt,或者在收包前用select判断可读性、把定时逻辑放在业务层。我一般选后者,因为重传需要指数退避,而SO_RCVTIMEO设一次就不动了,改起来麻烦。select的写法在 Windows 上是select(0, &readfds, nullptr, nullptr, &tv),第一个参数被忽略,传 0 就行。

3.3 端口切换:第一个 DATA 不再来自 69

这是 TFTP 最反直觉的一条,也是新手写出「只能发请求、收不到数据」的主要原因。

客户端把 RRQ 发到服务端的69 端口。服务端收到后,会从自己新分配的临时端口把 DATA 发回来。之后的整个会话都走这个新端口。也就是说,客户端不能写死recvfrom里的端口判断,也不能用connect把 socket 绑定到 69。

正确做法是:第一次recvfrom不校验来源端口(但仍建议校验 IP),拿到数据后把sockaddr_in里的地址和端口记下来,作为本次传输的对端(TID)。后续所有收包都比对这个 TID,不一致的直接丢弃,并按规范回一个错误码 5 给对方——回不回其实都可以,我们的做法是丢弃 + 记一条 debug 日志,因为回错误包本身也可能被用来放大流量。

// 收到第一个包后固定对端,后续只接受该地址的报文 bool samePeer(const sockaddr_in& a, const sockaddr_in& b) { return a.sin_addr.s_addr == b.sin_addr.s_addr && a.sin_port == b.sin_port; } // 主循环片段 sockaddr_in peer{}; int peerLen = sizeof(peer); bool peerLocked = false; uint8_t buf[65536]; int n = recvfrom(s, reinterpret_cast<char*>(buf), sizeof(buf), 0, reinterpret_cast<sockaddr_in*>(&peer), &peerLen); if (n > 0) { if (!peerLocked) { peerLocked = true; // 第一次收到,锁定 TID } else if (!samePeer(peer, lockedPeer)) { continue; // 来源不符,静默丢弃,不打断当前重传计时 } }

注意peerLen每次调用前都要重置,recvfrom会把它改成实际地址结构长度。曾经有个 bug 排查了一下午,就是因为循环里忘了重置,第二次开始peerLen是 16、传进去却变成了 0,recvfrom直接返回WSAEFAULT,日志里只有一行「接收失败」。

4. 收发状态机:块号、ACK 与那一次必须等到的 Dally

协议帧和套接字都通了之后,剩下的就是状态机。TFTP 本质是一个退化成窗口大小为 1 的停等协议:发一块、等一个 ACK、再发下一块。简单,但每个分支都有讲究。

4.1 上传:等 ACK 的停等循环与重传计数

上传从 WRQ 开始。服务端回 ACK(块号 0)或 OACK,客户端发 DATA 块 1,服务端回 ACK 1,依此类推。每个 DATA 发出后启动定时器,超时没等到对应 ACK 就重发同一块。

重传次数要设上限,我一般取 5 次,配合 3 秒起始超时、每次翻倍,最坏情况 3+6+12+24+48 秒才放弃。对端如果是真挂了还等这么久,体验很差;设太小又会在弱网下误判。实际工程里更稳妥的做法是总时长上限而不是次数上限,比如整个传输 60 秒无进展就中断,这样大文件和小文件的容错表现一致。

// 上传:发一块,等对应 ACK,超时重发;最多 5 次 const uint8_t* data = fileData.data(); size_t remain = fileData.size(); uint16_t block = 1; int retry = 0; while (true) { size_t chunk = std::min<size_t>(blkSize, remain); std::vector<uint8_t> pkt(4 + chunk); pkt[0] = 0; pkt[1] = 3; // opcode = DATA pkt[2] = static_cast<uint8_t>(block >> 8); pkt[3] = static_cast<uint8_t>(block & 0xFF); std::memcpy(pkt.data() + 4, data, chunk); if (sendto(s, reinterpret_cast<const char*>(pkt.data()), static_cast<int>(pkt.size()), 0, reinterpret_cast<const sockaddr*>(&peer), sizeof(peer)) < 0) { return false; // 发送失败直接退出,不重试 } if (!waitAck(s, block, timeoutMs)) { // 内部做 recvfrom + 块号比对 if (++retry > 5) return false; // 重传超限 timeoutMs *= 2; // 指数退避 continue; // 重发同一块,block 不变 } retry = 0; timeoutMs = baseTimeout; data += chunk; remain -= chunk; ++block; // 16 位自然回绕 if (chunk < blkSize) break; // 短块即最后一块 }

关键在最后那个break的条件:chunk < blkSize。如果文件大小恰好是blkSize的整数倍,循环会再发一个长度为 0 的 DATA 作为结束标志,这是协议要求,不是 bug。反过来说,接收方判断结束也只能靠「短块」,不能用「空块」——两者在整数倍场景下是同一个东西,逻辑上会绕进去。

waitAck内部要处理重复 ACK:如果收到的是上一块的 ACK(块号等于block - 1),说明我们之前发的那块 ACK 丢了、对端在重发,这时应当忽略并继续等,不能当成当前块的确认。这个分支不写,弱网下会出现「数据明明到了、进度却卡住」的怪现象。

4.2 下载:短块收尾与 Dally 那一秒

下载的坑主要在结尾。客户端收到最后一个短块、发回 ACK 之后,如果立刻closesocket,而那个 ACK 恰好丢了,服务端会重传最后一个 DATA,此时客户端已经不在监听,这个包无人应答。服务端重传 5 次后报错退出,用户看到的是「文件收完了但传输失败」。

标准解法叫 Dally:发完最后一个 ACK 后,不马上关 socket,而是再等一小段时间(通常等于当前超时值),期间如果收到重传的 DATA,就再补发一次 ACK然后继续等;等到超时没包了,才真正关闭。

// 收完最后一块后的 Dally:防止最后一个 ACK 丢失导致服务端空等 sendAck(s, peer, lastBlock); uint8_t tmp[65536]; sockaddr_in from{}; int fromLen = sizeof(from); setRecvTimeout(s, dallyMs); // 通常取当前超时值 int r = recvfrom(s, reinterpret_cast<char*>(tmp), sizeof(tmp), 0, reinterpret_cast<sockaddr_in*>(&from), &fromLen); if (r > 0 && samePeer(from, peer) && static_cast<size_t>(r) >= 4 && tmp[1] == 3 && static_cast<uint16_t>(tmp[2] << 8 | tmp[3]) == lastBlock) { sendAck(s, peer, lastBlock); // 对端没收到 ACK,再补一次 } // 不论有没有补发,此处都关闭,不再循环

有人把 Dally 写成while循环,理论上对端会一直重传、客户端一直补 ACK,形成死循环。规范里 Dally 只做一轮,收到一次重传就补一次 ACK,然后收工。写成循环在正常网络下看不出问题,遇到对端实现有 bug 会挂死。

4.3 块号回绕与参数取值

块号是 16 位无符号数,从 1 开始,收到 65535 之后回到 0,再到 1。用uint16_t自增天然回绕,但比较逻辑要小心:如果你的代码写成if (recvBlock == expectedBlock + 1),在expectedBlock == 65535时+1会整型提升成int的 65536,永远比不上uint16_t的 0。正确写法是先算uint16_t next = expected + 1;再比。

块号回绕对应的文件大小是blksize × 65536:默认 512 字节时约 32MB,1428 字节时约 93MB。也就是说,传固件、传镜像这种几十兆的文件,必然会跨过回绕点。这也是很多「小文件正常、大文件卡死」问题的根因。

参数取值上,我踩过的组合大致是这几档:

场景blksize初始超时重传上限
同机房、有线14283 秒5 次
跨网段、有丢包10245 秒5 次
无线、弱网5125 秒8 次
对接未知老设备512(不协商)5 秒5 次

blksize越大,单块重传的代价越高。丢包率 1% 的链路上,1428 字节块的期望重传字节数其实比 512 字节块更高,只是协议开销占比低。真实选择要在两端做一次小文件试传,看 Wireshark 里的重传次数再定,别拍脑袋。

5. 避坑与排查:五条把几十兆固件传成 0 字节的血泪记录

下面这五条,基本覆盖了我见过的绝大多数「跑不通」现场。每条都按现象、原因、解决三段写,对着日志能直接定位。

5.1 五条高频翻车记录

第一条:请求发出去了,一个字节都没回来。现象是客户端日志显示sendto成功,然后一直超时。原因通常是 RRQ 里的字节序错了,或者文件名带了路径。抓包看第一个包的操作码字段,如果是00 01就对了,如果是01 00,服务端直接当未知操作丢弃,连 ERROR 都不回。解决方式是老老实实按高低字节手写,别用memcpy拷uint16_t。

第二条:第一个 DATA 收到了,后面全超时。现象是块 1 正常,ACK 1 发出去之后没下文。原因八成是对端换了端口,你还在往 69 发 ACK。抓包能看到你的 ACK 目标端口是 69,而服务端监听的是 5 万多的临时端口,它收不到,就一直重传 DATA 1,你收到重复块又不知道该怎么处理。解决方式是第一次收包时锁定 TID,之后所有sendto都用这个地址。

第三条:进度条走到 99% 卡住,最后超时失败。现象是最后一个块重复收了很多次,文件其实已经完整。原因是结尾逻辑写错:要么没做 Dally,最后一个 ACK 丢了没人补;要么把「短块」判成了「还没结束」,一直在等下一个 DATA。排查时数一下实际收到的字节数,如果等于tsize协商出来的大小,那就是收尾逻辑的问题,不是传输的问题。

第四条:大文件传到某个固定位置必然失败。现象很规律,每次都在三十多兆的位置断。原因就是块号回绕。uint16_t从 65535 加 1 得到 0,如果你的比较逻辑写成了整型、或者用了std::map<uint16_t, ...>去记已确认的块,回绕后就会重复或者误判。解决是把所有块号比较都收敛到uint16_t类型上,最好写一个isNextBlock(cur, expected)的小函数,全项目只走这一处。

第五条:Windows 上能跑,换台机器就拒绝连接。现象是socket或sendto返回WSAEACCES/WSAENETUNREACH。原因往往不是代码,而是目标机器上的防火墙没放行出站 UDP,或者本机装了某类安全软件拦截。这条没什么技术含量,但占了我排查时间的相当一部分。解决方式是先用系统自带的客户端工具(如tftp命令)在同一台机器上试一次,能通就说明是代码问题,不通就先查网络策略。

5.2 一张症状对照表与排查顺序

现场排错最怕东一榔头西一棒子。我的固定顺序是:先抓包看有没有回包 → 再看回包的操作码 → 再看端口对不对 → 最后看块号和长度。四步走完,九成问题能定位。

症状最可能原因定位手段
完全无回包字节序错、端口被拦、IP 写错抓包看请求是否出现在对端网卡
只有 ERROR 5TID 不匹配对比 ACK 的目标端口与 DATA 的源端口
ERROR 1文件名或路径错检查是否带了/或\
中途大量重传blksize 过大导致分片把 blksize 降到 512 复测
末尾反复重传Dally 缺失看最后一个 ACK 之后是否还有 DATA
收到乱码文件名mode 用了 netascii统一改成 octet

还有一条经验:先关掉选项协商跑通,再逐项打开。很多人一上来就把 blksize 设到 8192、timeout 设成 1 秒,结果对端根本不支持,直接退回默认模式,代码里的分支没覆盖到,表现就是「有时行有时不行」。用最小配置(512 字节、无选项)打通链路,再加 blksize,再加 tsize,每加一项验证一次,问题范围永远只有一个变量。

提示:抓包时把过滤器设成udp.port == 69 || udp.length > 4,能同时看到请求和后续的数据流;只看 69 端口会漏掉全部数据报文。

6. 抓包对照与进阶:把重传参数和 windowsize 调成能跑的样子

协议跑通只是及格线。真正让它能用的,是拿抓包结果去反推参数,而不是拍脑袋设数字。

6.1 用 Wireshark 做一次参数体检

传一个 10MB 左右的文件,抓完包看三个统计:Statistics → Protocol Hierarchy里 UDP 的字节占比、Expert Information里的重传提示、以及任意一个 DATA 包的Length字段。如果 DATA 的 Length 稳定在 1476 上下(1428 载荷 + 8 UDP + 20 IP + 20 以太网头),说明没分片;如果频繁出现Fragmented IP protocol,就把 blksize 往下调。

重传次数也能直接读出来:同一个块号的 DATA 出现两次,就是一次重传。如果重传集中在固定的块号区间,多半是链路上某台设备的瞬时拥塞,把初始超时从 3 秒提到 5 秒比调小 blksize 更有效。

6.2 三个能让吞吐翻倍的改动

第一,开windowsize(RFC 7440)。它把停等协议变成了真正的滑动窗口,一次发 N 个块再统一 ACK,在高延迟链路上提升非常明显。代价是实现复杂度上一个台阶:要维护发送窗口、处理乱序 ACK、处理窗口内的部分重传。如果只是传几百 K 的配置文件,不值得。

第二,握手阶段并发探测。TFTP 没有握手,第一个 RRQ 丢了就得等超时。可以同时向两个不同 TID 发请求,谁先回用谁——但这会违反「一个客户端一个 TID」的约定,只在完全可控的内网环境里试过。

第三,把重传定时器从固定值改成 RTT 估计。用最近几次 ACK 的往返时间做加权平均,超时设为srtt + 4×rttvar,和 TCP 一个思路。弱网下这个改动收益最大,代价是要多记一份状态。

我最后一次改这块,是因为一个跨机房的传输任务,固定 5 秒超时导致每次丢包都要干等 5 秒,整批任务比预期慢了四倍。改成 RTT 自适应之后,同样的丢包率下总耗时降了一半多。从那以后我每次接 TFTP 相关的活,都强制先跑一遍抓包对照,把 blksize 和超时按实测数据定下来,再也不相信「默认值应该够用」这种判断。整套拆下来,代码量不大,但每一处分支背后都有一次具体的失败,按上面的顺序拼起来就是一个能跑的客户端。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询