
简介QFTP 源码是一份面向 C/Qt 开发者与网络编程学习者的跨平台 FTP 客户端实现已在 Linux 与 Windows 平台完成测试可用于理解文件上传下载流程或作为自定义 FTP 功能的集成基础。压缩包共 4 个文件由 2 个 .h 头文件和 2 个 .cpp 实现文件组成体积约 24KB体量轻便接口声明与具体逻辑分离便于对照阅读和二次开发。已有 297 人学习或下载。源码覆盖连接管理、FTP 命令发送与响应处理、数据传输以及 URL 信息解析等关键环节读者可观察从建立连接到目录列举、文件获取、上传存储等操作的组织方式并理解响应码判断、异常分支和状态维护思路。对于需要把 FTP 能力嵌入文件管理器、编辑器或自研工具的开发者这份源码可作为裁剪、扩展和跨平台适配的参考底稿也有助于积累网络模块与 Qt 通信组件的实践经验。1. 一份 QFTP 源码为什么在 Qt 5/6 的工程里还得自己留着#include QFtp编译不过是很多人第一次碰到这个资源的原因——从 Qt 5 起 FTP 模块不再随主库一起分发而存量代码里那一串list、get、put、rename又没办法一夜之间换成别的库。这份 QFTP 源码把QFtp和QUrlInfo两个类重新还原成可以直接拖进工程编译的.h/.cpp并且标注了 Linux 与 Windows 双平台测试通过这意味着你在两种工具链下编出来的静态库行为是一致的而不是在一台机器上撞运气。它适合三类人手里有 Qt 4 时代的 FTP 逻辑要往新版本迁的要在嵌入式 Linux 设备和内网 Windows 客户端之间做文件收发的以及嫌 libcurl 的回调式接口不好接信号槽、想让传输进度直接刷在界面上的。作为软件/插件形态的源码资源它的价值不在“新”而在于把一段被官方摘掉的实现完整留了下来。2. 选型与源码结构QFtp 到底补的是哪块能力的缺口先把一件事说清楚QFtp 不是“另一种网络库”它是一段缺失的模块实现。你拿到的源码包本质上是四个文件——qftp.h、qftp.cpp、qurlinfo.h、qurlinfo.cpp前者定义QFtp这个QObject子类和它的命令枚举后者定义QUrlInfo用来承载目录列表里每一条记录的名字、大小、权限、修改时间。编译它得到的不是某个可执行程序而是一个能被主工程链接的静态库或者干脆把源码内联进工程。2.1 QFtp 和 QNetworkAccessManager 的职责分界不少人拿到源码第一反应是“我都用 QNetworkAccessManager 了还要它干嘛”。这个疑问值得正面回答因为两者覆盖的 FTP 能力差距很大。QNetworkAccessManager 确实能处理ftp://开头的 URL但它只做到“按已知完整路径取一个文件”这一层控制通道上的 LIST、NLST、DELE、MKD、RMD、RNFR/RNTO 全都没有暴露出来。也就是说你没法用它列目录、没法判断远端文件是否存在、没法在传输前先探一下大小。QFtp 走的是完整控制通道 数据通道的实现路线控制连接上跑 USER/PASS/CWD/PASV数据连接上跑 RETR/STOR/LIST所有命令按队列串行下发。对比一下就清楚了能力QFtpQNetworkAccessManagerlibcurl下载已知路径文件支持支持支持上传文件支持不支持支持列目录 / 判存在支持list QUrlInfo不支持支持删除 / 改名 / 建目录支持不支持支持进度信号直接接 Qt 槽原生原生仅下载需自行封装事件循环侵入性无Qt 原生异步无需自己做 multi 或线程所以选型判断很直接只做“下载一个已知 URL 的文件”QNetworkAccessManager 够用还能省掉一份源码只要出现列目录、上传、远端文件管理中的任意一项就得回到 QFtp 或者 libcurl。而在一个已经用 Qt 写界面的工程里QFtp 的dataTransferProgress(qint64, qint64)直接连到进度条上就能用省掉了一层适配代码这是它相对 libcurl 最实际的优势。有一点边界要提前认QFtp 不实现 FTPSFTP over TLS也不实现 SFTP。源码里没有 TLS 握手逻辑connectToHost就是明文控制连接。如果你的对端要求加密这个资源帮不上忙别在上面硬改。2.2 源码包里那几个文件各自承担什么打开qftp.h能看到的公开接口大致分四组。第一组是连接管理connectToHost(host, port)、login(user, password)、close()、abort()、state()。第二组是目录操作list(path)、cd(dir)、mkdir、rmdir、remove、rename。第三组是传输get(remote, QIODevice*)、put(QIODevice*, remote, type)。第四组是旁路rawCommand(cmd)直接往控制连接塞一条原始命令setTransferMode(QFtp::Active | QFtp::Passive)切换主被动模式。所有这几组接口都有一个共同点它们全部返回int这个返回值是命令 id不是执行结果。命令被压进内部队列后立刻返回真正的结果要靠信号回传。这一点是理解整个模块的钥匙——QFtp内部维护一个QQueueQFtpCommand每次调用只是入队_q_startNextCommand()在前一条命令收到完成应答后才取下一条发出去。qurlinfo.h里的QUrlInfo则是对 LIST 应答的解析产物。服务端返回的目录列表是一段纯文本Unix 风格长这样-rw-r--r-- 1 ftp ftp 10240 Jan 01 12:00 a.binQUrlInfo把它拆成name()、size()、lastModified()、isDir()、permissions()。解析器对 Unix 风格支持最好遇到非标准格式会解析失败listInfo信号就不发——这是后面排查章节要展开的一个坑。2.3 什么场景该用它什么场景该绕开我把判断收敛成三条。远端目录结构在运行时会变、需要先列出来再决定传哪个文件的用 QFtp需要在同一台设备上既当客户端又当服务端的别用它它只有客户端实现传输量大且需要断点续传的可以用但得自己拼REST命令源码里没有封装续传接口。还有一类场景容易被忽略老旧设备上的 FTP 服务端实现往往很“将就”主被动模式行为不一致、LIST 返回格式不标准、对OPTS UTF8不作应答。QFtp 在这类环境下的容错不如 curl 那么皮实但它的源码是开放的解析不出来的格式你能直接改进qurlinfo.cpp里那段parseDirEntry逻辑这比在二进制库里干等要强。3. 两平台编译落地qmake 与 CMake 两条集成路线源码拿到手第一件事是确认它能在你的工具链下独立编译通过而不是直接塞进主工程边编边改。独立编译的好处是错误定位干净编不过就是源码和 Qt 版本的问题编过了再谈集成。3.1 Linux 下用 qmake 编出静态库并验符号给源码目录写一个.pro把它当独立库对待# qftp.pro —— 独立编成静态库不产出可执行文件 QT core network QT - gui # 这个模块不依赖界面去掉能少一层链接 TARGET qftp TEMPLATE lib CONFIG staticlib c11 CONFIG - app_bundle DESTDIR $$PWD/lib INCLUDEPATH $$PWD HEADERS qftp.h qurlinfo.h SOURCES qftp.cpp qurlinfo.cpp关键在两个地方QT network必须加QFtp内部用QTcpSocket走控制连接、用QTcpServer/QTcpSocket走数据连接缺了它整片符号都链接不上staticlib让 qmake 走ar而不是链接成.so方便后面内联进主工程。HEADERS里列出qftp.h不是为了编译而是为了让 qmake 认出里面的Q_OBJECT宏并生成 moc 规则——这一步漏了链接期会报一堆vtable for QFtp未定义。编译与验收qmake qftp.pro CONFIGrelease make -j$(nproc) # 产物确认 ls -l lib/libqftp.a # 符号确认能看到 QFtp 的构造、connectToHost、get 等方法才算编对 nm -C lib/libqftp.a | grep QFtp:: | head -20nm -C里的-C是 demangle把 C 修饰名还原成可读形式。如果只看到 moc 生成的QFtp::staticMetaObject而看不到QFtp::connectToHost说明qftp.cpp没被真正编进去回头检查SOURCES路径。另外make之前建议先make clean一次源码包如果带着别人机器上的Makefile和.o很容易编出一份半新半旧的库。3.2 Windows MSVC 的编译参数与 ABI 对齐Windows 这边流程一样只是把make换成nmake或jomqmake qftp.pro CONFIGrelease jom # 没有 jom 就用 nmake dir lib\qftp.lib真正容易翻车的是 ABI 对齐不是命令本身。三条经验第一编库用的 Qt 必须和主工程用的 Qt 是同一套 Kit同一个版本、同一个编译器、同一个位数混用 MinGW 编的库给 MSVC 工程链接必然失败第二debug 与 release 的 CRT 不能混主工程是/MDd而库是/MD链接期会直接甩LNK2038: mismatch detected for RuntimeLibrary这时候不要试图加/NODEFAULTLIB糊过去重新编一遍库更快第三源码文件的编码统一成 UTF-8 无 BOM某些编辑器保存时会带上 BOMMSVC 新版能认老版本会在文件头报一个莫名其妙的语法错误。如果你不想编库Windows 下还有一条更省事的路把四个源码文件直接拖进主工程的.pro让 qmake 一起编。代价是每次全量重编都要多编两个文件收益是不存在任何 ABI 问题。3.3 CMake 把 qftp 源码直接编进主工程现在不少工程已经切到 CMake源码包也能这么用cmake_minimum_required(VERSION 3.16) project(ftp_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 11) 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 third_party/qftp/qftp.cpp third_party/qftp/qftp.h # 头文件也要列出来AUTOMOC 靠它扫 Q_OBJECT third_party/qftp/qurlinfo.cpp third_party/qftp/qurlinfo.h ) target_include_directories(qftp PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/third_party/qftp) target_link_libraries(qftp PUBLIC Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Network) add_executable(ftp_demo main.cpp) target_link_libraries(ftp_demo PRIVATE qftp)这里最容易被忽略的是把.h也写进add_library的源文件列表。CMAKE_AUTOMOC只对“被当作源文件处理”的头文件生成 mocqftp.h没列进去编译能过、链接会挂在QFtp::staticMetaObject上报错信息还指向一个跟你代码无关的位置。另外target_link_libraries用的是PUBLIC因为qftp.h暴露了QFtp类型任何链接它的目标都得能看到 Qt Network 的头文件路径。集成完成后写个二十行的冒烟测试确认类真的可用#include QCoreApplication #include QDebug #include qftp.h int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); QFtp ftp; // 只连不发命令验证对象构造与状态机初值 qDebug() initial state: ftp.state(); // 期望 QFtp::Unconnected QObject::connect(ftp, QFtp::stateChanged, [](int s){ qDebug() state - s; }); ftp.connectToHost(QStringLiteral(127.0.0.1), 21); return app.exec(); }冒烟测试只做一件事确认QFtp能被构造、state()返回Unconnected、connectToHost能推进状态机。本地没起 FTP 服务端时会停在Connecting然后转Unconnected这本身就是正常行为说明异步链路是通的。4. 命令队列与信号槽把一次 FTP 会话写成状态机QFtp 的用法和“调一个函数拿一个返回值”完全不同它是队列 信号的模型。写成同步阻塞的思维一定会掉进“命令还没执行完就发下一条”的坑里。4.1 连接、登录、列目录的最小闭环先看一段能跑通的最小代码把连接、登录、列目录串起来// FtpLister.h #pragma once #include QObject #include QStringList #include qftp.h #include qurlinfo.h class FtpLister : public QObject { Q_OBJECT public: explicit FtpLister(QObject *parent nullptr) : QObject(parent), m_ftp(new QFtp(this)) { connect(m_ftp, QFtp::listInfo, this, [this](const QUrlInfo info){ // 每条目录记录都会进到这里按需过滤 if (!info.isDir()) m_names info.name(); }); connect(m_ftp, QFtp::commandFinished, this, FtpLister::onCommandFinished); connect(m_ftp, QFtp::done, this, FtpLister::onDone); } void start(const QString host, quint16 port, const QString user, const QString pass, const QString dir) { m_ftp-setTransferMode(QFtp::Passive); // 绝大多数内网场景走被动 m_ftp-connectToHost(host, port); m_ftp-login(user, pass); // 入队不阻塞 m_ftp-cd(dir); // 入队 m_listId m_ftp-list(); // 入队记下 id 供后续比对 } signals: void finished(bool ok, const QStringList names); private slots: void onCommandFinished(int id, bool error) { if (error) { emit finished(false, m_names); return; } if (id m_listId) { // 目录列表命令正常结束此时 m_names 已收集完整 } } void onDone(bool error) { emit finished(!error, m_names); } private: QFtp *m_ftp nullptr; QStringList m_names; int m_listId -1; };这段代码能说明 QFtp 的两个核心设计。第一connectToHost、login、cd、list四条命令是连着调用的它们不会并发执行QFtp 内部串行推进连上之后才发 USER登录成功才发 CWD切换目录成功才发 LIST。这就是命令队列的价值你不需要自己写状态机去等每一步。第二结果分两个层级回来commandFinished(id, error)每完成一条命令就发一次带命令 id适合做精细控制done(error)在整条队列跑空之后发一次适合做“这一轮做完了”的收尾。需要注意的是listInfo和commandFinished的先后目录内容是在 LIST 的数据连接上陆续到达的listInfo可能发很多次最后一次之后才是commandFinished(m_listId, false)。所以收集动作必须放在listInfo里不能在commandFinished里才去读——那时候数据早就到完了但如果你在commandFinished里才开始处理反而会漏掉中间状态。4.2 get/put 的数据落盘与进度回传下载一个文件接口是get(remotePath, QIODevice*)第二个参数必须是一个已经以写方式打开的QIODevicevoid FtpDownloader::download(const QString remote, const QString local) { m_file.setFileName(local); if (!m_file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { emit finished(false, m_file.errorString()); return; } m_ftp-setTransferMode(QFtp::Passive); m_ftp-connectToHost(m_host, m_port); m_ftp-login(m_user, m_pass); m_getId m_ftp-get(remote, m_file); // 文件对象必须活到传输结束 } void FtpDownloader::onDataProgress(qint64 done, qint64 total) { // total 可能为 -1服务端未在传输前给出大小此时按未知总数处理 emit progress(done, total); } void FtpDownloader::onCommandFinished(int id, bool error) { if (error) { m_file.close(); emit finished(false, m_ftp-errorString()); // errorString 是最近一次错误 return; } if (id m_getId) { m_file.close(); // 收尾必须自己关文件 emit finished(true, QString()); } }参数上有几个细节值得记。get的QIODevice*生命周期由调用方负责QFtp 只往里写数据不接管所有权所以m_file必须是成员变量而不是栈上的局部对象——写成局部变量函数返回时对象析构传输线程再往里写就是野指针。dataTransferProgress的第二个参数有时候是-1表示服务端在传输开始前没告诉大小进度条得按“不确定模式”处理别直接拿它做除数。上传是反过来的put(QIODevice*, remotePath, type)第一个参数以QIODevice::ReadOnly打开第三个参数是QFtp::Binary二进制或QFtp::Ascii文本。二进制传输时一定要显式传QFtp::Binary走 ASCII 模式服务端会做换行符转换传压缩包或可执行文件必然损坏这是血泪经验。4.3 命令 id 与 commandFinished 的配对纪律命令 id 是这套异步模型里唯一能把“结果”和“意图”对上的东西用不好就会出现“下载完成回调里干了上传的收尾”。三条纪律第一每条需要收尾的命令都保存 id。int m_getId m_ftp-get(...)不要图省事用“最后一条命令的 id”去猜。第二不要在一堆命令里靠顺序判断。有人写成“第一次 commandFinished 是登录、第二次是列目录”这在命令失败重试、或者中间插了一条rawCommand之后立刻失效。第三命令失败后队列里剩下的命令通常不会再执行需要重新排队。所以onCommandFinished一旦收到error true正确的做法是收尾退出、把错误抛给上层而不是继续以为后面的命令会跑完。这一点在弱网环境下尤其明显登录失败之后list的 id 永远不会出现在commandFinished里代码如果在等它就会一直挂着。5. 踩坑排查从「卡在 list」到「传到一半断流」这一章都是我实际遇到过、并且有明确现象和解决路径的问题。按“现象 → 原因 → 解决”的顺序写你可以直接拿去对着排查。5.1 卡在 list 不返回被动模式端口与回包地址现象connectToHost和login都正常commandFinished收到了登录成功的回调但list发出去之后既没有listInfo也没有commandFinished界面就一直转圈。原因FTP 的数据连接是另起一条 TCP被动模式下服务端在 PASV 应答里回一个 IP 和端口让客户端去连。很多服务端会在这个应答里塞自己的内网地址比如192.168.x.x如果客户端和服务端之间隔着 NAT 或者防火墙这个地址根本连不通数据连接建立不起来控制连接就一直在等。另一种情况是服务端只开放了 21 端口被动模式用的那一段高位端口被防火墙挡了。解决三步走。先在代码里确认模式是setTransferMode(QFtp::Passive)内网场景几乎都该用被动再确认服务端的被动端口段是固定且已放行的常见做法是把被动端口范围收窄到一段连续的、防火墙已开的口子最后用抓包或者netstat看数据连接有没有真的建立起来——只看到 21 端口的连接、没有第二个端口的连接就是被挡了。如果对端是主被动不兼容的老服务端试着切到QFtp::Active但要意识到主动模式下是服务端回连客户端客户端的防火墙同样要放行。5.2 中文文件名乱码编码协商没做或者做了服务端不认现象listInfo收到的文件名是乱码或者get一个中文名文件时服务端回 550 找不到文件而用别的客户端连同一台服务器是正常的。原因FTP 协议的控制通道默认按 ISO-8859-1 解释参数QFtp内部对路径字符串的处理也是按这个假设来的。服务端如果实际用的是 UTF-8两边就对不上。 RFC 2640 定义了OPTS UTF8 ON这个协商命令但很多服务端要么不实现、要么实现了但只在登录后才认。解决登录成功后、任何带路径的命令之前先发一条rawCommand(QStringLiteral(OPTS UTF8 ON))。如果服务端直接回 500 不支持那就只能退回到“本地转码再发”的路子把QString用toLocal8Bit()转成服务端期望的编码再拼进路径。注意rawCommand也是入队的它和后续的list/get在同一条控制连接上按顺序执行不需要额外等待。5.3 退出时崩溃QFtp 对象析构顺序现象程序关闭窗口时偶发崩溃堆栈落在QTcpSocket相关的位置或者控制台出现QSocketNotifier: Socket notifiers cannot be enabled or disabled from another thread之类的告警。原因QFtp析构时如果命令队列里还有没跑完的命令内部的QTcpSocket会在半路被销毁而信号还在投递中。更隐蔽的一种是QFtp作为栈对象比它依赖的QIODevice比如上传用的文件对象先析构回调里访问了已经销毁的兄弟对象。解决析构前先abort()清空队列并且用deleteLater()而不是deleteFtpClient::~FtpClient() { if (m_ftp) { m_ftp-abort(); // 清空命令队列关闭数据连接 m_ftp-deleteLater(); // 交给事件循环等信号投递完再销毁 } if (m_file.isOpen()) m_file.close(); // 文件对象最后关别在 QFtp 之前 }另外QFtp及其从属的 socket 必须和它的父对象在同一个线程里跨线程使用要么整体搬过去要么用信号槽把调用投递回对象所属线程别直接调。5.4 大文件传到一半断流没有看门狗也没有重连现象几百兆的文件传到 60% 左右停住dataTransferProgress不再发commandFinished也不来程序不报错就是不动了。原因中间链路尤其是内网跨网段会静默丢弃长时间空闲的连接而 QFtp 本身没有超时机制——socket 没断它就一直等。另外服务端可能有单次会话的时长限制或者带宽限制超限后直接掐数据连接但不发任何应答。解决加一个看门狗定时器每次dataTransferProgress触发就重置超时就abort()并按断点续传重来m_watchdog new QTimer(this); m_watchdog-setSingleShot(true); m_watchdog-setInterval(30000); // 30 秒没有任何进度就判定卡死 connect(m_watchdog, QTimer::timeout, this, [this]{ m_ftp-abort(); emit finished(false, QStringLiteral(transfer stalled)); }); connect(m_ftp, QFtp::dataTransferProgress, this, [this](qint64 d, qint64 t){ m_watchdog-start(); // 有进度就续命 emit progress(d, t); });超时阈值别设太短慢速链路上传大文件时单次进度回调之间隔十几秒是正常的设成 5 秒会频繁误杀。三十秒到一分钟是比较好用的区间。5.5 链接报 LNK2019 / undefined reference模块没带 network现象主工程编译通过链接阶段报undefined reference to QFtp::connectToHost(...)或者 MSVC 下LNK2019: unresolved external symbol。原因QFtp的实现依赖QTcpSocket、QTcpServer、QUrlInfo里对QDateTime的使用主工程的.pro或CMakeLists.txt里只写了QT core没加network链接器找不到 Qt Network 里的符号。解决qmake 工程加QT networkCMake 工程加find_package(... COMPONENTS Network)和对应的target_link_libraries。如果静态库和主工程分开编还要确认静态库是以PUBLIC方式传递依赖的——qmake 的LIBS只传库路径不传依赖关系CMake 用target_link_libraries(qftp PUBLIC Qt::Network)才能自动带过去。6. 进阶断点续传、看门狗复位与一份可复现的回归清单把基础流程跑通之后真正决定这套代码能不能上生产的是两件事断了之后能不能接上以及每次改完能不能快速验证没退步。断点续传的实现依赖REST命令QFtp 没有封装它得用rawCommand手工拼。顺序很关键先看本地已下载文件的大小再用追加方式打开文件然后发REST offset最后发RETR。REST必须紧挨着RETR中间插别的命令服务端就会忽略这个偏移量void FtpDownloader::resume(const QString remote, const QString local) { QFileInfo fi(local); const qint64 offset fi.exists() ? fi.size() : 0; m_file.setFileName(local); // 有偏移量就追加没有就新建 const auto mode offset 0 ? (QIODevice::WriteOnly | QIODevice::Append) : (QIODevice::WriteOnly | QIODevice::Truncate); if (!m_file.open(mode)) { emit finished(false, m_file.errorString()); return; } m_ftp-setTransferMode(QFtp::Passive); m_ftp-connectToHost(m_host, m_port); m_ftp-login(m_user, m_pass); if (offset 0) m_ftp-rawCommand(QStringLiteral(REST %1).arg(offset)); // 服务器不支持会回 5xx m_getId m_ftp-get(remote, m_file); }要注意两点REST不是所有服务端都支持回 5xx 时得退化成从头传断点续传的前提是远端文件在两次传输之间没有变化如果服务端那边的文件被替换了续传会拼出一个损坏的文件稳妥做法是续传前先用list拿到远端大小和修改时间做一次比对。看门狗还有一处要复位命令之间。登录慢、列目录慢的场景下光靠dataTransferProgress续命是不够的因为传输还没开始。我一般会在commandFinished里也重置一次定时器这样整个会话的任意阶段卡住都能被兜住。回归验证我固定跑这四个场景覆盖了绝大部分翻车点场景构造方式通过标准空目录列取远端建一个空目录后listlistInfo不触发commandFinished正常返回中文名往返上传含中文名的文件再列目录名字与本地一致无乱码大文件中断续传传输中途abort()后重新调用resume最终文件大小与远端一致校验和相同服务端不可达指向一个关闭的端口在超时时间内收到finished(false, ...)不挂死这套东西我现在的习惯是任何一次改动qftp.cpp或者调整传输参数之后四条场景必须全过一遍才允许合进主线。有一次图省事只测了下载结果中文名那条在 Windows 客户端上整片乱码回滚排查花的时间比跑一遍测试多得多。从那以后我每次改这块都强制走一遍这四条。希望帮到你。本文还有配套的精品资源点击获取