Qt FTP文件传输实战:QFtp迁移、断点续传与PASV排错
简介围绕 Qt 框架实现 FTP 文件传输的示例代码包面向具备 C 基础、需要在桌面应用中集成远程文件上传下载能力的开发者。内容以 QFtp 类为主线覆盖连接服务器并设置地址与端口、账号登录、get/put 上传下载、目录列举与切换、新建目录以及基于信号槽的进度反馈与错误处理并给出两套示例工程的界面与主程序组织方式便于对照理解实现思路也可据此扩展多线程传输、断点续传与文件列表显示等功能。压缩包共 53 个文件、约 4.08MB以 8 个 cpp 源文件与 4 个头文件为核心配套 2 个 ui 界面文件、2 个 qrc 资源文件、2 个 sln 与 2 个 vcproj 工程配置另含 obj、pdb、ilk 等编译中间文件及可执行程序便于快速编译验证运行效果。已有 981 人学习适合作为跨平台 FTP 模块的入门参照与二次开发起点。1. 上传到 97% 断线之后的那三分钟在 Qt 里做 ftp 文件传输最容易被低估的是上传到 97% 突然断线之后的那三分钟协议本身反而不算难点。我见过不少工程把 QFtp 当成一个「connect 完就能 put」的黑匣子现场网络一抖几 GB 的镜像包就得从头再来。这份 Qt FTP 文件传输代码包针对的正是这个场景控制通道是独立状态机数据通道单独开 socket上传下载都带 REST/APPE 续传超时、重连、进度全部走信号槽不卡界面。它适合已经会用 Qt、但没手写过 FTP 协议层的开发者也适合手里有 Qt4 老代码要迁到 Qt5/Qt6 的人——QFtp 早就不在 QtNetwork 里了照抄老教程必定编译不过。纯 C 加 QtNetwork不引第三方库桌面端、工控上位机、嵌入式 Linux 的 Qt 工程都能塞进去。2. 四条路线选型Qt5 之后 QFtp 到底去哪了选型这一步决定了后面 80% 的坑。同样是「qt ftp 文件传输」用 QFtp 移植版、用 QNetworkAccessManager 走 ftp:// 协议、用裸 QTcpSocket 自己写协议、或者把 libcurl 包一层代码量和可控性完全不是一个量级。我一般先问三个问题要不要目录操作LIST/CWD/MKD、要不要断点续传、目标机器上能不能加第三方依赖。这三个答案基本就把路线锁死了。2.1 四条路线对照能力边界比 API 更重要维度QFtp 移植版QNetworkAccessManager裸 QTcpSocket 手写libcurl 封装目录列表 / 建目录 / 改名支持信号回 QUrlInfo基本不可用全支持自己解析支持需自己拼命令上传支持 put能力受限实测常卡在 425/550支持 STOR/APPE支持断点续传需自己发 rawCommand(REST n)不支持完全可控支持 CURLOPT_RESUME_FROM代码量小几百行极小大1500 行起中需处理回调线程第三方依赖无源码随工程编译无无有交叉编译要单独编适合场景Qt4 迁移、内部工具只做下载需要精确控制协议细节服务端批量同步QFtp 原本是 Qt4 QtNetwork 里的类Qt5 把它拆成了独立的附加模块Qt6 干脆不提供。所以你在 Qt5 工程里#include QFtp报「找不到文件」是正常的不是环境装错了。绝大多数人走的路是把 qftp 的源码目录直接拷进工程的 3rdparty 下随工程一起编译不装模块、不改 Qt 安装目录换机器也能编。QNetworkAccessManager 那条路看着最省事manager-get(QNetworkRequest(QUrl(ftp://user:pwdhost/file)))就能下载但它对 FTP 的支持是残缺的下载能跑上传和目录操作在很多版本上直接返回 425 或者 550而且你拿不到任何中间状态进度只能靠 downloadProgress 信号重连逻辑完全插不进去。只做「定时拉一个配置文件」这种需求可以用但凡涉及上传趁早换路线。裸 QTcpSocket 手写是这份代码包的主线。FTP 协议本身不复杂——控制通道全是命令\r\n加三位码 应答\r\n的文本往返难的是被动模式、多行应答、TCP 粘包这几处细节。写通了之后你会发现它比任何封装都好调因为每一行收发你都能打日志。libcurl 适合服务端批量同步场景它把续传、重试、限速都做好了。缺点是在 Qt 工程里要处理跨线程回调和 C 风格的内存管理而且嵌入式交叉编译得单独编一遍 curl 和它的依赖工程复杂度上来了。桌面端内部工具我一般不选它。2.2 把代码接进 qmake 与 CMake两种工程都要能编qmake 工程里如果你用 QFtp 移植版别去QT ftp除非你真的编译安装了那个附加模块直接把源码加进来最稳QT core gui network CONFIG c17 TARGET ftpclient # 方式一QFtp 源码随工程编译推荐换机器不用重装环境 INCLUDEPATH $$PWD/3rdparty/qftp SOURCES $$PWD/3rdparty/qftp/qftp.cpp \ $$PWD/3rdparty/qftp/qurlinfo.cpp HEADERS $$PWD/3rdparty/qftp/qftp.h \ $$PWD/3rdparty/qftp/qurlinfo.h # 方式二自行编译安装 qtftp 附加模块后才可用 # QT ftp # 手写协议层的话只需要 network下面这行就够了 # QT core gui networkCMake 工程对应写法注意 Qt6 已经没有 QFtp只能走自研客户端或者引第三方cmake_minimum_required(VERSION 3.16) project(ftpclient LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_AUTOMOC ON) find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core Gui Network) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Gui Network) # QFtp 移植版作为静态库编进来注意要开 AUTOMOC add_library(qftp STATIC 3rdparty/qftp/qftp.cpp 3rdparty/qftp/qurlinfo.cpp) target_include_directories(qftp PUBLIC 3rdparty/qftp) target_link_libraries(qftp PUBLIC Qt${QT_VERSION_MAJOR}::Network) set_target_properties(qftp PROPERTIES AUTOMOC ON) add_executable(ftpclient main.cpp ftpsession.cpp ftpworker.cpp) target_link_libraries(ftpclient PRIVATE qftp Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Gui Qt${QT_VERSION_MAJOR}::Network)第 2 段里的AUTOMOC ON是必须的qftp.h 里有 Q_OBJECT 宏不开 moc 会在链接期报一堆undefined reference to vtable for QFtp这个报错看着像缺库其实是没生成 moc 文件。target_link_libraries(qftp PUBLIC ...Network)用 PUBLIC 是为了把 Network 的 include 路径传递给上层目标否则主工程里 includeQTcpSocket会找不到头文件。注意Qt6 用户不要在这上面浪费时间找 QFtp 的替代包官方没有。要么用 Qt5 编一个独立的小进程做传输要么直接用这份代码包里的手写协议层。2.3 第一段能跑通的代码连接、登录、列目录不管后面多复杂先让「连上、登进去、把目录列出来」跑通这一步能验证账号、端口、防火墙、编码四件事。下面这段是 QFtp 路线的最小闭环可以直接放进一个 QObject 子类里#include QFtp #include QUrlInfo #include QDebug class FtpProbe : public QObject { Q_OBJECT public: explicit FtpProbe(QObject *parent nullptr) : QObject(parent), m_ftp(new QFtp(this)) { // 状态机只有 LoggedIn 之后才允许发业务命令 connect(m_ftp, QFtp::stateChanged, this, [this](int state) { qInfo() state - state; if (state QFtp::LoggedIn) { m_ftp-list(); // 列当前目录 } }); // 每条命令结束都会回调id 是命令返回的序号 connect(m_ftp, QFtp::commandFinished, this, [this](int id, bool error) { if (error) { qWarning() cmd id failed: m_ftp-errorString(); } else { qInfo() cmd id ok; } }); // 目录条目是逐条吐出来的不是一次性数组 connect(m_ftp, QFtp::listInfo, this, [](const QUrlInfo info) { qInfo() info.name() info.size() info.lastModified().toString(yyyy-MM-dd hh:mm); }); } void start(const QString host, quint16 port, const QString user, const QString pwd) { // 关键顺序setTransferMode 必须在 connectToHost 之前调用 m_ftp-setTransferMode(QFtp::Binary); m_ftp-connectToHost(host, port); // 返回命令 id可用于对账 m_ftp-login(user, pwd); } private: QFtp *m_ftp; };逻辑上这四步是串行的setTransferMode只设置内部标志connectToHost发出 TCP 连接并等待 220 应答login发 USER 再发 PASS 等 230list发 PASV LIST 建数据通道。QFtp 内部会把命令排队所以你连着调三次不会乱序但如果第一条就失败比如端口不通后面两条会一起报错别以为是三个独立问题。参数上有两处容易踩setTransferMode(QFtp::Binary)一定要在连接前设连上之后再改部分服务端已经用 TYPE A 协商完了改不回来connectToHost的 port 默认是 21有些测试环境跑在 2121忘改就是连接超时而不是认证失败——从报错文案能区分开。commandFinished(int id, bool error)里的 id 是connectToHost/login/list的返回值把这几个 id 存下来做映射排错时能直接看出是哪条命令挂的。我一般用一个QHashint, QString存「id → 命令名」日志里打出来比数字直观得多。3. 协议层拆解控制通道状态机与 PASV 数据通道QFtp 能覆盖 80% 的场景剩下 20% 是它盖不住的进度粒度不够细、断点续传要自己发 rawCommand、连接保持和心跳不好控。这份代码包真正有价值的部分是手写的那一层核心就三件事——把 TCP 流切成一行行应答、把 PASV 应答里的地址端口解出来、把文件内容按块泵进数据通道。3.1 控制通道把「一行命令一行应答」写成状态机FTP 的应答格式是三位码 空格 描述 \r\n多行应答是250-第一行、250-第二行、250 最后一行。TCP 是字节流一次 readyRead 可能收到半行也可能收到三行半所以必须自己攒缓冲区void FtpSession::onControlReadyRead() { m_rxBuf m_ctl-readAll(); int pos; while ((pos m_rxBuf.indexOf(\r\n)) 0) { const QByteArray line m_rxBuf.left(pos); m_rxBuf.remove(0, pos 2); if (line.size() 3) continue; const int code line.left(3).toInt(); // 多行起始250-...中间行可能是纯文本 if (line.size() 3 line.at(3) -) { m_multiCode code; m_multiBuf.clear(); m_multiBuf.append(line.mid(4)); continue; } // 多行结束250 最后一行 if (m_multiCode code) { m_multiBuf.append(\n); m_multiBuf.append(line.mid(4)); const QByteArray payload m_multiBuf; m_multiCode 0; m_multiBuf.clear(); emit replyReceived(code, payload); continue; } // 普通单行应答 emit replyReceived(code, line.mid(4)); } }m_rxBuf是成员变量而不是局部变量因为跨 readyRead 的半行必须留住m_multiCode记住当前多行块的码值只有遇到同码值的「空格分隔行」才算结束。replyReceived(code, payload)之后统一走一个 switch把 220/331/230/227/150/226/425/550 分别处理——这就是状态机的核心把所有命令的「下一步」收敛到一个地方比在每个函数里waitForReadyRead好维护一百倍。命令发送侧只需要保证两件事所有命令以\r\n结尾同一时刻只允许一条命令在飞FTP 不支持流水线。我一般用一个QQueueQByteArray m_cmdQueue加一个m_busy标志收到终态应答2xx/4xx/5xx 且非 1xx才发下一条。150 是「数据通道即将打开」的中间态不能当成结束。3.2 PASV 解析与数据通道建立被动模式的应答长这样227 Entering Passive Mode (192,168,1,10,195,60)括号里六个数字前四个是 IP后两个是端口高位和低位195*25660 49980。解析本身不难难的是那个 IP 经常是错的void FtpSession::handlePasvReply(const QByteArray payload) { static const QRegularExpression re( R(\((\d{1,3}),(\d{1,3}),(\d{1,3}),(\d{1,3}),(\d{1,3}),(\d{1,3})\))); const auto m re.match(QString::fromLatin1(payload)); if (!m.hasMatch()) { emit failed(QStringLiteral(PASV 应答无法解析: %1) .arg(QString::fromLatin1(payload))); return; } const QString ipFromReply QString(%1.%2.%3.%4) .arg(m.captured(1), m.captured(2), m.captured(3), m.captured(4)); const quint16 port quint16(m.captured(5).toInt() * 256 m.captured(6).toInt()); // 服务端在 NAT 后面时常回内网地址此时必须用控制连接的地址兜底 QHostAddress addr; if (m_cfg.forceControlPeerAddress || QHostAddress(ipFromReply).isLoopback() m_ctl-peerAddress().isLoopback() ipFromReply.startsWith(10.)) { addr m_ctl-peerAddress(); } else { addr QHostAddress(ipFromReply); } m_data new QTcpSocket(this); m_data-setProperty(pasvPort, port); connect(m_data, QTcpSocket::connected, this, FtpSession::onDataConnected); connect(m_data, QTcpSocket::readyRead, this, FtpSession::onDataReadyRead); connect(m_data, QOverloadQAbstractSocket::SocketError::of(QTcpSocket::error), this, FtpSession::onDataError); m_data-connectToHost(addr, port); }forceControlPeerAddress这个开关是我加的血泪配置默认打开。原因很简单只要客户端和服务端之间隔着任何一层地址转换227 里的 IP 就是服务端自己看到的地址客户端根本连不过去表现为数据通道一直 connecting 直到超时而控制通道一切正常——这个现象非常迷惑人日志里看不到任何 4xx/5xx。用控制连接的对端地址替换绝大多数情况直接就好了。数据通道建好之后服务端会先回 150传完回 226。所以onDataConnected里不要急着写数据等 150 到了再泵有些服务端顺序反过来稳妥做法是「数据 socket connected 且收到 150」两个条件都满足才开始或者干脆在 connected 里先写一小块服务端会缓冲住。超时这块必须自己管QTcpSocket的connectToHost默认超时很长建议在数据通道上挂一个 30 秒的 QTimer连不上就 abort 并重试一次 PASV。3.3 上传下载与断点续传REST 和 APPE 别用混续传分两个方向。下载续传是客户端先看本地文件多大然后REST n告诉服务端「从这个偏移开始发」紧接着RETR上传续传有两种做法REST nSTOR让服务端定位或者APPE让服务端直接追加。实测下来APPE的兼容性比REST STOR好因为部分服务端只对 RETR 支持 REST。bool FtpSession::uploadFile(const QString localPath, const QString remotePath, bool resume) { m_file.setFileName(localPath); if (!m_file.open(QIODevice::ReadOnly)) { emit failed(m_file.errorString()); return false; } qint64 offset 0; if (resume) { offset queryRemoteSize(remotePath); // 发 SIZE同步等 213 if (offset 0 || offset m_file.size()) offset 0; } if (!m_file.seek(offset)) return false; m_uploaded offset; m_total m_file.size(); enterPassive(); // 先建数据通道 if (offset 0 m_cfg.useAppe) { sendCommand(APPE remotePath.toUtf8()); } else { if (offset 0) sendCommand(QString(REST %1).arg(offset)); sendCommand(STOR remotePath.toUtf8()); } return true; }queryRemoteSize走的是控制通道的SIZE命令返回 213 加字节数文件不存在时服务端回 550这里要当成「从 0 开始」而不是失败。m_total用本地文件大小不要用远端大小否则进度条会算错。真正的数据泵在bytesWritten里推进不要一次把整个文件读进内存void FtpSession::pumpChunk() { constexpr qint64 kChunk 64 * 1024; // 背压控制未发出的数据超过 512KB 就先停等 bytesWritten while (!m_file.atEnd() m_data-bytesToWrite() 8 * kChunk) { const QByteArray chunk m_file.read(kChunk); if (chunk.isEmpty()) break; m_data-write(chunk); m_uploaded chunk.size(); emit progress(m_uploaded, m_total); } if (m_file.atEnd() m_data-bytesToWrite() 0) { // 先关数据通道服务端收到 EOF 才回 226 m_data-disconnectFromHost(); } }8 * kChunk这个水位是背压的关键不设的话往 QTcpSocket 里狂 write 会把内存吃光尤其在嵌入式设备上设太小又跑不满带宽。64KB 分块是折中块再大进度回调就变粗块太小则系统调用次数上去了。disconnectFromHost必须在文件读完且缓冲区排空之后调顺序反了服务端会认为传输被中断回 426 然后把半截文件留在那。下载同理RETR之后收到 226 才算完整收到 426/451 必须把本地临时文件删掉重来否则你会得到一个「看起来大小对但内容错」的文件。3.4 线程模型为什么 FTP 客户端不能待在 GUI 线程里QFtp 本身是异步的理论上放 GUI 线程不会卡。但只要你用了一次waitForReadyRead(30000)去等 SIZE 的返回界面就冻结 30 秒。上传大文件时更明显——进度信号太密主线程光处理重绘就掉帧。标准做法是 worker 对象 moveToThreadm_thread new QThread(this); auto *worker new FtpWorker; // 内部不含 socket纯逻辑 worker-moveToThread(m_thread); connect(m_thread, QThread::started, worker, [] { worker-start(m_cfg); // 所有 socket 在子线程里创建 }); connect(worker, FtpWorker::progress, this, MainWindow::onProgress, Qt::QueuedConnection); connect(worker, FtpWorker::finished, m_thread, QThread::quit); connect(m_thread, QThread::finished, worker, QObject::deleteLater); m_thread-start();关键是「所有 socket 在哪个线程创建就只能在哪个线程用」。FtpWorker的构造函数里不要new QFtp或者new QTcpSocket要在start()槽里创建——因为start()是在子线程执行的那时创建的对象才归属子线程。如果在主线程 new 了再 moveToThreadsocket 内部的 notifier 仍然绑在老线程会出现「信号发了但槽不执行」的玄学问题这个坑我踩过两次。信号跨线程必须用Qt::QueuedConnection显式声明参数类型得先qRegisterMetaType比如自定义的进度结构体。取消操作也要走队列主线程置一个std::atomicbool m_cancelworker 在每次 pumpChunk 开头检查为真就m_data-abort()然后发 QUIT。直接跨线程调abort()会让 socket 处于半关闭状态重连时收到 421。4. 参数怎么调传输模式、超时、编码的取值依据代码能跑通只是及格线能不能在客户的网络里稳定跑一周全看这几个参数。我见过太多「本地测试完美、上线就断」的案例追下去都是超时给太短、缓冲区给太大、或者编码没协商。4.1 TYPE I 与 TYPE A一个字节的差别能让文件全废FTP 有 ASCII 和 Binary 两种传输模式。ASCII 模式下服务端会把行尾统一成自己的格式Unix 的\n转 Windows 的\r\n文本文件看不出问题二进制文件必坏——一个 100MB 的固件包传完变成 103MB校验永远不过。所以除了明确知道是纯文本日志一律TYPE I。这条命令要在登录成功之后、任何数据通道命令之前发case 230: // 登录成功 sendCommand(TYPE I); break; case 200: // TYPE 设置成功 startNextTask(); break;有个细节部分服务端在TYPE I之后仍然对LIST做行尾转换这是正常的LIST 的结果走数据通道但按文本处理不影响文件。判断有没有踩这个坑很简单传完之后比大小本地QFileInfo::size()和远端SIZE返回的字节数必须完全相等差一个字节就是模式错了。4.2 超时与重连给每个阶段配一个独立的表不要用一个通用的 30 秒超时管所有事。连接、命令应答、数据通道空闲、整体任务四个阶段的合理值差一个数量级参数建议值依据与调整方式控制通道连接超时8–10 s跨公网 RTT 一般 200ms10s 还不通基本是防火墙或端口错命令应答超时30 sLIST 大目录上万条可能超过 10s别设 5s数据通道连接超时30 sPASV 端口没放行时是这个表现超时后重试一次 PASV数据通道空闲超时60 s大文件传输中服务端磁盘慢会短暂无数据太短会误杀单次读写块大小64 KB小于 8KB 系统调用开销明显大于 512KB 进度粒度太粗发送水位bytesToWrite 上限512 KB决定内存占用和背压灵敏度失败重试次数3 次指数退避 2s / 4s / 8s超过 3 次基本是配置问题不是抖动断线重连后是否续传是重连后先 SIZE 比对再决定 REST 偏移这张表里的值不是拍脑袋是「公网 中等负载服务端」的经验值。局域网内部可以把连接超时压到 3 秒重试次数减到 1 次失败快反而好排查。重连逻辑要放在统一的位置别在每条命令的回调里各写一份我一般做一个onTaskFailed(reason)内部判断「已重试次数 上限」就重建控制通道 → 重新登录 → 重新 TYPE I → 用 SIZE 算出新偏移 → 继续传。重建控制通道时必须把旧的m_data和m_ctl都deleteLater只关不删会漏 socket 句柄长时间跑任务会耗尽。注意重连后不要假设服务端还记得你之前的工作目录。每次重连都要重新CWD到目标目录否则 STOR 会写到家目录去日志里还看不出来。4.3 中文文件名与 UTF8 协商乱码只在真机上出现控制通道默认是 7 位 ASCII中文文件名在本地和服务端之间能不能对上取决于服务端支不支持 UTF8 扩展。标准做法是先发FEAT看服务端能力列表里有没有UTF8有就OPTS UTF8 ONvoid FtpSession::negotiateEncoding() { sendCommand(FEAT); // 213- 多行应答里找 UTF8 connect(this, FtpSession::replyReceived, this, [this](int code, const QByteArray payload) { if (code ! 213) return; if (payload.contains(UTF8)) { sendCommand(OPTS UTF8 ON); // 200 表示开启成功 } else { // 服务端不支持退化成 GBK本地转换后再发 m_codec QTextCodec::codecForName(GBK); } }, Qt::SingleShotConnection); }FEAT的应答是多行的211-开头、211结尾所以 3.1 里那个多行解析逻辑在这里直接复用。部分服务端不支持FEAT回 500这时候不要慌直接按 UTF-8 发现代服务端九成都能识别。本地和服务端编码不一致时STOR里的路径要转码QByteArray toWire(const QString localName) const { // 服务端支持 UTF8 就走 UTF-8否则按协商结果转 return m_codec ? m_codec-fromUnicode(localName) : localName.toUtf8(); }文件名里带空格和特殊字符时路径不做 URL 编码那是 HTTP 的规则FTP 命令里直接用原始字节空格会被当成路径一部分服务端能正确处理。但要注意换行符绝对不能出现在文件名里否则会把命令截断服务端回 500 而你完全看不懂为什么。LIST返回的文件名解析也有坑不同服务端的 LIST 格式不一样Unix 风格-rw-r--r-- 1 owner group 1024 Jan 1 12:00 nameWindows 风格01-01-24 12:00PM DIR name。稳妥做法是能用MLSD就用MLSDRFC 3659 定义的机器可读格式返回typefile;size1024; name解析起来没有歧义服务端不支持再回退到 LIST 正则。5. 排查清单五个血泪现场每个都卡过我半天下面这五条都是我在实际项目里踩过的共同点是「现象看着像另一回事」。FTP 协议的报错码信息量很低425 可能是防火墙也可能是端口范围没配所以定位全靠现象加抓包对照。5.1 控制通道一切正常数据通道一直连不上现象登录成功CWD、LIST有的能成有的卡住上传时界面停在 0%日志里没有任何错误码30 秒后超时。原因PASV 应答里的 IP 是服务端的内网地址客户端照着连当然连不上。或者服务端配置的被动端口范围比如 50000–50100没有在防火墙上放行227 返回的端口是随机的放开 21 端口没有任何用。解决客户端侧打开「强制使用控制连接对端地址」的开关忽略 227 里的 IP只取端口服务端侧把被动端口范围固定成一个小区间并全段放行。定位手段是同时在客户端和服务端抓包看 SYN 到底发到哪个 IP 和端口去了一眼就看出来。5.2 文件传完了大小对不上或者校验和永远不过现象上传显示 100% 完成服务端SIZE返回的字节数比本地大几 KB小文件没事大文件必错。原因传输模式没设成二进制服务端在 ASCII 模式下做行尾转换。文件里恰好有\n字节二进制文件里这太常见了每个都被扩成\r\n。解决登录成功后第一件事发TYPE I并且确认服务端回了 200 而不是 504。传完之后强制做一次SIZE与本地大小比对不相等直接报错重传——这个校验要写进流程不能靠人眼看进度条。5.3 上传到一半服务端文件变成 0 字节或者卡在 99%现象进度条走到 99.x% 就不再动最后超时或者服务端那边文件大小突然归零。原因数据泵的循环条件写错bytesToWrite() 0时提前disconnectFromHost服务端认为传输被中断回 426 并丢弃临时文件。另一种是m_file.atEnd()判断在最后一块还没写出去时就为真。解决关闭数据通道的条件必须是「文件已读完 且 发送缓冲区已排空」两个同时成立并且要在bytesWritten信号里再检查一次不能只在写循环里判断。另外服务端回 226 才算成功收到 426/451 一律删掉本地重来别做「可能有部分成功」的假设。5.4 中文文件名在本地正常传上去变成问号现象STOR 测试文件.txt执行后服务端列出的是????.txt或者一堆乱码550找不到文件。原因控制通道用QString::toLocal8Bit()发送在 Windows 上是 GBK在服务端是 UTF-8两边对不上。或者服务端压根没开 UTF8 扩展。解决统一走 4.3 里那套 FEAT OPTS UTF8 ON 的协商发送前用toUtf8()不要用toLocal8Bit()。如果服务端确实不支持 UTF8就在配置里显式指定 GBK 并做转换别指望自动识别。5.5 传了几个文件之后程序崩了或者句柄耗尽现象单个文件传输完全正常连续传 20 个之后进程内存持续上涨最后QAbstractSocket: too many open files。原因每次传输都new QTcpSocket作为数据通道传完只disconnectFromHost()没deleteLater()。QTcpSocket 的父对象是 session父对象不死 socket 就不死。解决onDataFinished里必须m_data-deleteLater(); m_data nullptr;。控制通道在整个任务期间复用不要每个文件重连一次——重连的开销不大但每次重连都要重做 FEAT/TYPE/CWD出错面变大。6. 上线前先在本地压一遍一个 40 行的测试服务端和我的收尾习惯客户现场的 FTP 环境千奇百怪能不能续传、被动端口开没开、UTF8 支不支持靠问是问不出来的。我现在的固定动作是交付前在本地起一个可控的服务端把被动端口范围锁死、编码设成 UTF-8然后把 1GB 的文件传到一半强杀进程看能不能续上。Python 起一个测试服务端四十行就够python -m pip install pyftpdlib python - PY from pyftpdlib.authorizers import DummyAuthorizer from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import FTPServer auth DummyAuthorizer() # 只给测试目录的读写权限别拿真实数据跑 auth.add_user(tester, tester, /srv/ftptest, permelradfmwMT) handler FTPHandler handler.authorizer auth handler.passive_ports range(50000, 50010) # 锁死被动端口模拟防火墙场景 handler.encoding utf-8 # 中文文件名必须走这条 handler.banner test ftp ready FTPServer((0.0.0.0, 2121), handler).serve_forever() PYpassive_ports锁成 10 个端口是故意的生产环境防火墙通常只放行一小段本地先把「端口耗尽」这个场景复现出来比上线后撞上强。permelradfmwMT里的 T 是改文件时间缺了会导致MFMT命令不可用某些客户端会因此报错但不影响主流程测试时留意一下。验证脚本单独跑一遍用大小加哈希双重比对head -c 1073741824 /dev/urandom /tmp/big.bin md5sum /tmp/big.bin # 用你的客户端传到 /srv/ftptest/big.bin传到大约一半 CtrlC 杀掉 # 重新启动客户端走续传路径 md5sum /srv/ftptest/big.bin # 两个哈希必须一致 stat -c %s /srv/ftptest/big.bin # 字节数也必须一致续传最容易出问题的地方是偏移量算错一位导致文件从中间开始被覆盖最终大小对了但内容错了——这种错误只有哈希能发现大小比对是发现不了的。所以两个校验都要做。再补一个我常用的技巧客户端里把每次REST的偏移量和SIZE的返回值打到日志里续传时人工看一眼「续传起点 已传字节数」是否成立。有一次我发现起点比预期少了 64KB追下去是分块读的时候最后一块没写满就记了偏移这种错光看进度条是永远发现不了的。从那以后我每次接 FTP 相关的活都会先用这个本地服务端把 1GB 文件传到断、断完续、续完对哈希全过了才敢连客户的机器。希望帮到你。本文还有配套的精品资源点击获取