ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Qt FTP文件传输实战:QFtp迁移、断点续传与PASV排错

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:pwd@host/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 += c++17 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 network

CMake 工程对应写法,注意 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 路径传递给上层目标,否则主工程里 include<QTcpSocket>会找不到头文件。

注意: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 等 230,list发 PASV + LIST 建数据通道。QFtp 内部会把命令排队,所以你连着调三次不会乱序,但如果第一条就失败(比如端口不通),后面两条会一起报错,别以为是三个独立问题。

参数上有两处容易踩:setTransferMode(QFtp::Binary)一定要在连接前设,连上之后再改,部分服务端已经用 TYPE A 协商完了,改不回来;connectToHost的 port 默认是 21,有些测试环境跑在 2121,忘改就是连接超时,而不是认证失败——从报错文案能区分开。

commandFinished(int id, bool error)里的 id 是connectToHost/login/list的返回值,把这几个 id 存下来做映射,排错时能直接看出是哪条命令挂的。我一般用一个QHash<int, 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 不支持流水线)。我一般用一个QQueue<QByteArray> m_cmdQueue加一个m_busy标志,收到终态应答(2xx/4xx/5xx 且非 1xx)才发下一条。150 是「数据通道即将打开」的中间态,不能当成结束。

3.2 PASV 解析与数据通道建立

被动模式的应答长这样:227 Entering Passive Mode (192,168,1,10,195,60),括号里六个数字,前四个是 IP,后两个是端口高位和低位(195*256+60 = 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, QOverload<QAbstractSocket::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 n+STOR让服务端定位,或者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 对象 + moveToThread:

m_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 了再 moveToThread,socket 内部的 notifier 仍然绑在老线程,会出现「信号发了但槽不执行」的玄学问题,这个坑我踩过两次。

信号跨线程必须用Qt::QueuedConnection显式声明,参数类型得先qRegisterMetaType,比如自定义的进度结构体。

取消操作也要走队列:主线程置一个std::atomic<bool> m_cancel,worker 在每次 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 一般 < 200ms,10s 还不通基本是防火墙或端口错
命令应答超时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 ON:

void 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 name,Windows 风格01-01-24 12:00PM <DIR> name)。稳妥做法是能用MLSD就用MLSD(RFC 3659 定义的机器可读格式),返回type=file;size=1024; 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", perm="elradfmwMT") 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() PY

passive_ports锁成 10 个端口是故意的:生产环境防火墙通常只放行一小段,本地先把「端口耗尽」这个场景复现出来,比上线后撞上强。perm="elradfmwMT"里的 T 是改文件时间,缺了会导致MFMT命令不可用,某些客户端会因此报错但不影响主流程,测试时留意一下。

验证脚本单独跑一遍,用大小加哈希双重比对:

head -c 1073741824 /dev/urandom > /tmp/big.bin md5sum /tmp/big.bin # 用你的客户端传到 /srv/ftptest/big.bin,传到大约一半 Ctrl+C 杀掉 # 重新启动客户端,走续传路径 md5sum /srv/ftptest/big.bin # 两个哈希必须一致 stat -c '%s' /srv/ftptest/big.bin # 字节数也必须一致

续传最容易出问题的地方是偏移量算错一位,导致文件从中间开始被覆盖,最终大小对了但内容错了——这种错误只有哈希能发现,大小比对是发现不了的。所以两个校验都要做。

再补一个我常用的技巧:客户端里把每次REST的偏移量和SIZE的返回值打到日志里,续传时人工看一眼「续传起点 = 已传字节数」是否成立。有一次我发现起点比预期少了 64KB,追下去是分块读的时候最后一块没写满就记了偏移,这种错光看进度条是永远发现不了的。

从那以后我每次接 FTP 相关的活,都会先用这个本地服务端把 1GB 文件传到断、断完续、续完对哈希,全过了才敢连客户的机器。希望帮到你。

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

返回列表