简介:这份面向 Qt 桌面开发者的 QFtp 移植示例,针对 Qt 5.0 以后移除 QFtp 类、旧项目难以继续使用 FTP 模块的问题,将 Qt 4.8.7 中的 QFtp 源码整理为 QFtp.h 与 QFtp.cpp 两个文件,并配套完整的测试工程。压缩包共 17 个文件,约 35KB,包含 5 个 cpp、4 个 h、3 个 ui 以及 png 图标、qrc 资源、pro 工程和 user 配置等,代码、界面与资源组织较为齐全,便于直接编译和二次改造。示例覆盖登录 FTP 服务器、上传、删除、创建、重命名等常用操作,读者可借此理清 QFtp 的接口调用方式、信号槽处理流程和界面交互逻辑。已有 1320 人学习,适合需要在 Qt5 及以上版本恢复 FTP 功能、参考源码移植思路或搭建文件传输测试界面的开发者。
1. 从一个上传卡死的下午说起:Qt 实现 QFtp 为什么还得自己动手
前阵子接了个上位机的活,设备端要定时把日志包推到现场的一台老 FTP 服务器上。打开 Qt Creator 建完工程才想起来,Qt 里早就没有 QFtp 这个东西了——Qt5 把它挪进独立的 qtftp 模块,Qt6 连模块一起砍掉,QNetworkAccessManager 在 Qt6 里也彻底不认 ftp:// 了。绕不开,最后用 QTcpSocket 把 FTP 协议裸写了一遍,照着 Qt4 QFtp 的 API 形态重新实现:异步命令队列、listInfo 信号、commandFinished(id, error)。这份代码是那次踩坑的沉淀,兼容 Qt5.12 与 Qt6,覆盖登录、列目录、上传下载、改名、建删目录。你如果在给工控上位机或嵌入式 HMI 加 FTP 能力,又不想为了几个命令把 libcurl 整个拖进工程,这套东西基本能直接抄。
2. 整体拆分:两条 TCP 连接加一个命令队列,就是整个 QFtp 的骨架
FTP 与 HTTP 最大的差异在于它使用“带外”结构:命令和文件数据走两条不同的 TCP 连接。理解这一点之后,剩下的工作就是把应答码和状态机逐步实现。
2.1 控制连接负责沟通,数据连接负责传输
控制连接在会话开始时建立,直到 QUIT 才断开,端口默认 21(测试环境常用 2121)。客户端通过这条连接发送命令文本,每行以 CRLF 结尾;服务器也通过它返回三位数字应答码。所有“元信息”——登录、切换目录、查询大小、重命名——都只走这一条连接。
数据连接则用于每次实际传输文件或目录列表时临时建立、传输完成后立即关闭。以最常见的 PASV 被动模式为例,完整流程是:控制连接发送PASV,服务器返回227 Entering Passive Mode (h1,h2,h3,h4,p1,p2);客户端按这六个数字算出 IP 和端口并连接过去;然后控制连接发送LIST或RETR;服务器返回150 Opening data connection,数据连接开始输出字节;数据传完后服务器关闭数据连接,控制连接返回226 Transfer complete。
主动模式 PORT 正好相反:由服务器回连客户端。现在几乎被 NAT 和设备防火墙挡死,所以实现里默认只使用 PASV,把 PORT 作为可选项。很多自作聪明的人一开始想“既然要求简单,直接发 LIST 然后读 socket 就行了”,结果读到的全是150、226这种控制应答,文件内容一个字节都没有——因为根本没有建立数据连接。
2.2 命令队列:为什么每个接口都返回一个 int
QFtp 的所有接口都是异步的。调用put()之后,文件不会立刻开始传输,因为控制连接同一时刻只能处理一条命令;如果并发,应答会交错,根本分不清哪个226对应哪次传输。因此接口调用实际上只是入队,返回一个自增的 id,后续通过commandFinished(id, error)与完成的命令对应。
// qftp.h —— 命令描述体与队列成员 struct FtpCommand { int id = -1; Command cmd = None; QString arg; // 远端路径 / 文件名 QString arg2; // rename 用的新名字 QIODevice *dev = nullptr; // get/put 的本地设备,QFtp 不负责 delete TransferMode mode = Binary; // Binary / Ascii qint64 offset = 0; // 断点续传起始偏移,供 REST 使用 }; QQueue<FtpCommand> m_queue; // 待执行 FtpCommand m_current; // 正在执行 bool m_busy = false; // 有命令在途时为 true int m_nextId = 1; // 命令 id 从 1 开始自增,0 表示无效// qftp.cpp int QFtp::put(QIODevice *dev, const QString &remote, TransferMode mode) { if (!dev || !dev->isOpen() || !dev->isReadable()) return 0; // 参数不合法直接返回 0,不入队 FtpCommand c; c.id = m_nextId++; c.cmd = Put; c.arg = remote; c.dev = dev; // 生命周期由调用方保证 c.mode = mode; m_queue.enqueue(c); processNext(); // 队列空闲就立刻启动,否则排队 return c.id; }put的参数里,dev是已经打开的本地设备(QFile、QBuffer 都行),函数不接管所有权,这一点跟 Qt4 原版保持一致;remote是相对当前工作目录的远端路径;mode决定发送TYPE I还是TYPE A。返回值 0 表示调用失败(参数问题),大于 0 才是有效的命令 id。processNext()是队列的唯一驱动点,它判断m_busy,取出队首并派发,收到最终应答后再把m_busy置回 false,并调用下一次processNext()。整条链路的并发安全就靠这一个布尔值保证。
2.3 应答码:三位数字里真正需要处理的只有几个
协议里应答码有几十个,实现时不用全接,按前缀分派就够了。下面这张表是我在实际使用中真正会判断的那些:
| 应答码 | 含义 | 实现里的动作 |
|---|---|---|
| 220 | 服务就绪 | 接着发 USER |
| 331 | 需要密码 | 发 PASS |
| 230 | 登录成功 | 状态切到 LoggedIn,完成当前命令 |
| 227 | 进入被动模式 | 解析六个数字,建数据连接 |
| 229 | 扩展被动模式 EPSV | 解析(|||port|),建数据连接 |
| 150 | 数据连接即将打开 | 标记传输中,等数据泵 |
| 226 / 250 | 传输完成 / 请求动作完成 | finishCommand(false) |
| 257 | 当前目录 | 解析引号里的路径,更新m_cwd |
| 213 | 文件大小 | 断点续传前取 total |
| 350 | 需要进一步信息 | REST / RNFR 的中间应答 |
| 425 / 426 | 数据连接建不起来 / 传输中断 | 立即置错,别等超时 |
| 4xx | 暂时性失败 | 可重试,先记录 |
| 5xx | 永久性失败 | 直接置错并结束命令 |
注意1xx和3xx不是最终态,收到之后必须继续等服务端的下一条应答,不能提前把m_busy清掉,否则后面的应答没有人接,整个队列就会卡住——这是自己编写 FTP 客户端时最常见的死锁来源。
2.4 目录结构与各文件职责
工程按库和 Demo 分离,库部分不依赖 Widgets,可以放进没有 GUI 的嵌入式镜像里跑:
QftpDemo/ ├── CMakeLists.txt ├── src/ │ ├── qftp.h # 对外 API,尽量对齐 Qt4 QFtp 的签名 │ ├── qftp.cpp # 状态机、命令队列、应答分派、数据泵 │ ├── ftpreply.h # 应答行结构体,code + text + 多行标记 │ └── ftplistparser.h/cpp # LIST 行解析,Qt6 下替代 QUrlInfo ├── demo/ │ └── main.cpp # 最小可运行示例,命令行列目录 + 上传 └── tests/ └── tst_qftp.cpp # 用 QTest 起局域服务端做回归ftplistparser单独拆出来的原因在于兼容性:Qt5 里还能直接构造QUrlInfo,Qt6 直接移除,如果把它耦合进qftp.cpp,一个文件里要写两套#if QT_VERSION分支,后面维护时很容易出错。拆成独立解析器之后,上层只认FtpEntry结构体,Qt5/Qt6 只在解析器内部用宏隔开。
3. 核心链路拆解:一次 put 从入队到 226 都发生了什么
上面讲的是组成部分,这一章按时间顺序检查一条命令的完整生命周期:连接建立、应答解析、数据通道、传输收尾,每一步都落到具体代码。
3.1 连接与登录:状态机在什么时候切到 LoggedIn
connectToHost入队后,processNext()调用m_ctrl->connectToHost(host, port),同时把状态置成HostLookup。QTcpSocket的connected信号到达后,状态推进到Connected,但此时还不能算登录完成——必须等220回来,主动发USER,等331再发PASS,收到230才算完。
所有应答统一进入onCtrlReadyRead,这里有一个容易被忽视的细节:TCP 是流,一次readAll()可能读到半行,也可能读到两行半。缓冲区必须累积并逐行剥离:
void QFtp::onCtrlReadyRead() { m_recv += QString::fromLatin1(m_ctrl->readAll()); int pos; while ((pos = m_recv.indexOf(QStringLiteral("\r\n"))) >= 0) { const QString line = m_recv.left(pos); m_recv.remove(0, pos + 2); // 连同 CRLF 一起扔掉 handleLine(line); } } void QFtp::handleLine(const QString &line) { if (line.size() < 3) return; // 多行应答以 "123-说明" 开头,以 "123 结束" 收尾,中间的正文要跳过 if (line.size() > 3 && line.at(3) == QLatin1Char('-')) { m_multiCode = line.left(3).toInt(); return; } if (m_multiCode) { if (line.left(3).toInt() != m_multiCode) return; // 多行应答的中间内容,丢弃 m_multiCode = 0; } m_lastCode = line.left(3).toInt(); dispatchReply(m_lastCode, line.mid(4)); }m_recv用QString而不是QByteArray,是因为 FTP 应答的正文部分在FEAT、SYST这类命令下会带非 ASCII 字符,转一次省得后面反复转换;真正的文件数据全程走QByteArray,不经过字符串层。
dispatchReply是整个状态机的中枢,按码分派:
void QFtp::dispatchReply(int code, const QString &text) { switch (code) { case 220: // 服务就绪,开始登录 sendRaw(QStringLiteral("USER ") + m_user); break; case 331: // 要密码 sendRaw(QStringLiteral("PASS ") + m_password); break; case 230: // 登录成功 setState(LoggedIn); sendRaw(QStringLiteral("TYPE I")); // 统一先切二进制 finishCommand(false); break; case 257: // PWD 的返回 m_cwd = parseQuotedPath(text); finishCommand(false); break; case 227: { // 被动模式,建数据连接 quint16 port = 0; if (!parsePasv(text, port)) { finishCommand(true); break; } // 关键:忽略应答里给的 IP,直接连控制连接的对端地址 m_data->connectToHost(m_ctrl->peerAddress(), port); break; } case 150: // 数据连接即将开始输出 m_transferring = true; break; case 226: case 250: finishCommand(false); break; default: if (code >= 400) finishCommand(true); // 4xx/5xx 一律当错误收尾 break; } }sendRaw()只做一件事:m_ctrl->write(cmd.toUtf8() + "\r\n")。这里必须补上 CRLF,后文避坑那章会专门说。finishCommand(bool error)负责发出commandFinished(m_current.id, error)、清空m_busy、在队列非空时调用processNext(),队列空了就发done(error)。
3.2 PASV 解析:六个数字和一条容易忽略的规则
227那一行形如227 Entering Passive Mode (192,168,1,10,19,137)。六个数字里前四个是 IP,后两个是端口的高字节和低字节,端口 =p1 * 256 + p2,上面这个例子就是 19×256+137 = 5001。
bool QFtp::parsePasv(const QString &text, quint16 &port) { const int l = text.indexOf(QLatin1Char('(')); const int r = text.indexOf(QLatin1Char(')'), l); if (l < 0 || r < 0) return false; const QStringList n = text.mid(l + 1, r - l - 1).split(QLatin1Char(',')); if (n.size() != 6) return false; for (int i = 0; i < 6; ++i) { bool ok = false; n.at(i).trimmed().toUShort(&ok); if (!ok) return false; // 有些服务端会带空格,先 trim 再转 } port = static_cast<quint16>(n.at(4).toUShort() * 256 + n.at(5).toUShort()); return true; }函数只返回端口,IP 干脆不解析——这是刻意的。服务器在 NAT 后面时返回的往往是内网地址甚至0.0.0.0,照着连必然超时,而控制连接的对端地址一定是通的。剩下五个数字只用来校验格式是否合法。m_data是另一个独立的QTcpSocket,连接成功后它的readyRead接onDataReadyRead,disconnected接收尾逻辑。
还有一个更省事的方案:如果服务端FEAT里带EPSV,直接发EPSV,返回229 Entering Extended Passive Mode (|||6446|),只需要解析一个端口,连 IP 都不用管,IPv6 下也能用。我在实现里是先尝试 EPSV,失败再退回 PASV,多写十几行,省掉一类线上问题。
3.3 数据泵:64KB 一块,边读边发
传输方向有两个,但共用一套计数和信号。下载(get和list)走readyRead:
void QFtp::onDataReadyRead() { const QByteArray chunk = m_data->read(64 * 1024); // 单次最多取 64KB if (chunk.isEmpty()) return; if (m_current.cmd == Get) { if (m_current.dev->write(chunk) != chunk.size()) { m_data->abort(); // 本地落盘失败,直接中断 finishCommand(true); return; } } else { m_listBuf += chunk; // LIST 结果先缓存,等 226 再解析 } m_done += chunk.size(); emit dataTransferProgress(m_done, m_total); // total 为 -1 表示未知大小 }上传方向相反,由本地设备驱动,用水位控制写入节奏:
void QFtp::pumpUpload() { // bytesToWrite 是 Qt 内部发送缓冲里还没发出去的字节数, // 控制在 512KB 以内,既不会撑爆内存,也不会因为写太碎而频繁唤醒事件循环 while (m_data->bytesToWrite() < 512 * 1024) { const QByteArray block = m_current.dev->read(64 * 1024); if (block.isEmpty()) { m_data->disconnectFromHost(); // 读完必须主动关,服务器才会回 226 return; } m_data->write(block); m_done += block.size(); emit dataTransferProgress(m_done, m_total); } }pumpUpload挂两个地方:m_data的bytesWritten信号(缓冲腾空后继续灌),以及命令启动时先调一次。m_total在上传前取dev->size(),下载前发SIZE命令拿213的返回值,拿不到就填 -1,UI 层按不确定进度处理。
这里有一条时序规则必须遵守:数据发完先关数据连接,再等控制连接的 226。如果反过来,先等 226 再关数据连接,服务器会认为客户端还在发,226永远不来,命令一直卡在m_busy状态,后续队列全部停摆。这个问题在传输小文件时不一定复现,稍微大一点必然出现,排查起来非常困难。
3.4 LIST 解析:Qt5 用 QUrlInfo,Qt6 得自己写
LIST的返回是一堆文本行,格式取决于服务端,Unix 风格和 DOS 风格最常见:
-rw-r--r-- 1 ftp ftp 1024 Jan 01 12:00 app.log drwxr-xr-x 2 ftp ftp 4096 Jan 01 12:00 logs 01-01-24 12:00PM <DIR> logsQt5 里可以构造QUrlInfo直接抛给上层,Qt6 没有这个类,所以要自己解析成结构体:
// ftplistparser.cpp bool FtpListParser::parseLine(const QString &line, FtpEntry &out) { // Unix 风格:权限位 + 链接数 + 属主 + 属组 + 大小 + 时间 + 名字 static const QRegularExpression unixRe(QStringLiteral( R"(^([\-dl])([\-rwxsStT]{9})\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$)")); // DOS 风格:日期 + 时间 + <DIR>或大小 + 名字 static const QRegularExpression dosRe(QStringLiteral( R"(^(\d{2}-\d{2}-\d{2})\s+(\d{2}:\d{2}[AP]M)\s+(<DIR>|\d+)\s+(.+)$)")); QRegularExpressionMatch m = unixRe.match(line); if (m.hasMatch()) { out.isDir = (m.captured(1) == QLatin1String("d")); out.size = m.captured(3).toLongLong(); out.name = m.captured(5).trimmed(); return !out.name.isEmpty() && out.name != QLatin1String(".") && out.name != QLatin1String(".."); } m = dosRe.match(line); if (m.hasMatch()) { out.isDir = (m.captured(3) == QLatin1String("<DIR>")); out.size = out.isDir ? 0 : m.captured(3).toLongLong(); out.name = m.captured(4).trimmed(); return true; } return false; // 认不出来的行直接跳过,别让解析失败拖垮整次列目录 }解析完的FtpEntry逐条通过emit listInfo(entry)抛给上层,同时累积成一个QList<FtpEntry>,在226到达时整体再发一次listFinished()信号——前者给需要边收边渲染的树控件用,后者给需要完整快照的逻辑用。
如果想减少解析工作,可以优先发MLSD(前提是FEAT里有MLST),返回的每行都是type=file;size=1234;modify=20240101120000; name这种机器友好格式,一个正则就能统一处理,也不用担心语言环境导致的月份缩写差异。代价是有些老服务端不支持,需要按FEAT的返回做降级。
4. 跑起来:CMake 编译、最小示例、本地联调
代码结构清楚了,接下来是从零把它跑起来。这一步不需要真实的 FTP 服务器,本地启动一个就够了。
4.1 用 CMake 把 qftp 编成静态库
库部分只依赖Core和Network,不碰Widgets,交叉编译到 ARM 板子上时可以直接复用:
cmake_minimum_required(VERSION 3.16) project(QftpDemo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # qftp.h 里有 Q_OBJECT,必须开 find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core Network) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Network) add_library(qftp STATIC src/qftp.cpp src/ftplistparser.cpp ) target_include_directories(qftp PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src) target_link_libraries(qftp PUBLIC Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Network ) add_executable(listdemo demo/main.cpp) target_link_libraries(listdemo PRIVATE qftp)find_package(QT NAMES Qt6 Qt5 ...)这种写法让同一份 CMakeLists 在 Qt5 和 Qt6 环境都能配置通过,QT_VERSION_MAJOR会被自动填成 5 或 6,后面所有target_link_libraries都靠它拼目标名。如果工程里还有其他模块,把qftp作为PRIVATE依赖挂上去即可。CMAKE_AUTOMOC如果漏开,编译时会报undefined reference to vtable for QFtp,这是新手最容易遇到的第一个错误。
4.2 二十行跑通列表与上传
Demo 用QCoreApplication,不依赖界面,纯粹验证协议链路:
#include <QCoreApplication> #include <QDebug> #include "qftp.h" int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); QFtp ftp; QObject::connect(&ftp, &QFtp::stateChanged, [](QFtp::State s) { qInfo() << "state ->" << int(s); }); QObject::connect(&ftp, &QFtp::listInfo, [](const FtpEntry &e) { qInfo() << (e.isDir ? "[D]" : "[F]") << e.name << e.size; }); QObject::connect(&ftp, &QFtp::commandFinished, [](int id, bool err) { qInfo() << "command" << id << (err ? "failed" : "ok"); }); QObject::connect(&ftp, &QFtp::done, &app, [&](bool err) { qInfo() << "queue drained, error =" << err; app.quit(); }); ftp.connectToHost("127.0.0.1", 2121); ftp.login("demo", "demo"); ftp.list("/"); return app.exec(); }三个接口连续调用,代码里没有阻塞等待,也不需要自己串回调——队列会按connectToHost → login → list的顺序执行。done(bool)在队列完全排空时触发,所以示例里用它退出事件循环。如果想测试上传,在list后面追加ftp.put(new QFile("app.log"), "/app.log"),注意把QFile打开成ReadOnly,并且对象要活到commandFinished之后再释放,否则数据泵会读到空指针。
4.3 本地起一个测试服务端
不需要搭真实环境,本地起一个轻量服务端即可:
pip install pyftpdlib mkdir -p ./ftproot && echo "hello qftp" > ./ftproot/app.log python -m pyftpdlib -p 2121 -w -d ./ftproot-p指定端口(避免和系统服务冲突,用 2121),-w打开写权限,不写的话上传会直接返回550 Permission denied,-d指定根目录。启动后控制台会打印每条命令和应答,联调时非常方便对照自己发出去的报文。默认还允许匿名登录,用来验证USER anonymous分支。
如果需要测试中文文件名、超长文件名、0 字节文件,在ftproot里放几个对应文件再跑一遍列表即可,这一步能提前发现后面 5.4 节提到的编码问题。
4.4 联调阶段看什么
自己编写协议实现时,最有效的手段是抓取通信双方的实际报文,而不是在代码里加日志。服务端启动后会把每次交互都打出来,格式类似:
[I] 127.0.0.1:52341 - FTP session opened. [D] 127.0.0.1:52341 - USER demo [D] 127.0.0.1:52341 - PASS **** [D] 127.0.0.1:52341 - PASV [I] 127.0.0.1:52341 - Entering passive mode (127,0,0,1,19,137). [D] 127.0.0.1:52341 - LIST /排查时按三条线对照:命令是否按预期顺序发出、每条命令前是否有对应的前置应答、数据连接是否真的建立。如果客户端的m_busy停住不动,先看最后一条[D]是什么命令,再对照协议里的预期应答码,基本能定位到是哪个 case 没有处理。这种排查方式比自己猜测快得多。
5. 避坑与排查:五处最容易翻车的地方
协议实现类的代码,只要理解到位,往往能一次跑通,重启一次就无法复现。下面五条都是实际遇到过、并且能稳定复现的。
5.1 PASV 返回的 IP 不可用,数据连接卡到超时
现象:登录成功,PWD、CWD都正常,一发LIST就卡住,几秒后425 Can't open data connection,有时候直接等三十秒超时。
原因:服务端在 NAT 或容器后面,227里返回的是它自己的内网地址(比如172.17.0.2),客户端照着连必然不通。还有一类服务端会返回0,0,0,0,让客户端自己判断。
解决:不要使用应答里的 IP,用控制连接的peerAddress()作为目标地址,只取端口。如果服务端同时支持EPSV,优先走EPSV,返回的格式里只有端口,这个问题就不存在了。这类环境在设备现场非常普遍,代码里留一个“忽略 PASV IP”的开关比事后增加参数要省事得多。
5.2 命令没写 CRLF,服务器一声不吭
现象:连接建立,220也收到了,发了USER之后再也没有响应,一直等到超时。
原因:RFC 959 要求命令以\r\n结尾。只发\n,有些服务端会宽容处理,有些直接丢弃整行不回应,表现就是彻底卡死,日志里连错误码都没有。
解决:所有命令统一走sendRaw(),内部固定拼接toUtf8() + "\r\n",不要在任何地方直接调用write()。同理,解析应答时也只认\r\n作为行结束符,不要用\n
本文还有配套的精品资源,点击获取