
简介基于QT框架开发的FTP客户端完整源码工程面向学习Qt网络编程与文件传输的初中级开发者。整体压缩包约10.82MB共148个文件包含81张界面截图、9个cpp源文件、6个头文件以及ui界面文件、qss样式表、qm翻译文件和qch/qhc辅助文档编译运行即可查看完整功能界面。已有216人学习下载。代码基于QFtp类实现服务器连接、身份认证、目录切换与内容列举、文件上传下载等完整FTP交互流程覆盖主动与被动两种数据连接模式主窗口、设置对话框与FTP文件树控件等模块分工清晰借助信号与槽机制反馈命令完成状态并及时处理异常。整体结构明了适合Qt网络编程入门实战便于进一步扩展断点续传、多线程传输等功能。 一个 QT FTP 客户端需求落到你手上时通常不是缺界面而是卡在传输层文件能列出来一传大文件就崩溃或者界面假死十几秒。我翻过几个 ftp 源码包毛病高度一致——控制连接和数据连接没分清楚被动模式端口解析随手写TYPE没切二进制就开始传中文文件名乱成一锅粥。这里不从界面讲起先把协议层拆明白再给一个能在 Qt 里跑通的最小客户端最后把断点续传、多任务和发布环境这些坑一个个填上。适合要自己维护 FTP 客户端或者拿到源码包想改出可用版本的人。2. 拆开 QT FTP 客户端控制连接、数据连接与 Qt 侧三条实现路线2.1 控制连接与数据连接为什么被动模式才是客户端的默认选择FTP 和 HTTP 最大的区别是它有两根 TCP 连接。控制连接固定连服务器的 21 端口用来发USER、PASS、CWD、RETR这类命令并读取服务器的文本应答数据连接则是临时的传目录列表和文件内容时才会建立。这个设计在上世纪 70 年代没问题放到今天 NAT 和防火墙环境下就成了灾难——尤其是主动模式。主动模式PORT里客户端在控制连接上告诉服务器“我这边开了一个端口等你来连”服务器就去连客户端的那个端口。家用路由、云主机安全组、企业防火墙基本都会掐掉这种“从外向内”的连接表现在客户端上就是列目录卡在150之后超时然后整条控制连接被服务器断开。被动模式PASV正好反过来客户端在控制连接上发PASV服务器返回227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)把数据连接的 IP 和端口告诉客户端由客户端主动去连。所以我一般直接把客户端锁死在被动模式不做主动模式开关省掉一整个维度的排查工作量。另一个容易被忽略的是应答格式。FTP 应答分成单行和多行两种多行应答的格式是220-开头、同一状态码的220结尾中间可以夹好几行文本。很多简单实现只readLine一次拿到220-就当结束了结果把后续行当作下一条命令的响应去解析状态机就乱了。下面这段是我常用的读取函数先处理多行再返回状态码。2.2 Qt 侧三条路QFtp、QNetworkAccessManager 与自研 QTcpSocket 的取舍拿到一个 QT FTP 客户端需求技术上其实有三条路可选先看对比再决定走哪条方案维护状态支持断点续传自定义命令扩展典型问题QFtp 模块已移出 Qt 主线部分一般Qt 5 以上需自行编译 qtftp老接口与新信号风格不一致QNetworkAccessManager ftp:// URL随 Qt 网络模块不支持差底层 FTP 支持受编译选项影响GET/PUT 之外的命令无从下手QTcpSocket 手写命令层自己维护完全可控能加任意命令要处理多行响应、超时和数据连接代码量略大QNetworkAccessManager 的版本适合“下载单个文件、测试连通性”这种轻量场景网上很多 Demo 也是这么写的构造一个ftp://user:passhost/file的 URL 丢给get()然后等readyRead把数据写进本地文件。但注意两点第一这个 FTP 支持是 QtNetwork 编译时的可选项预编译包里未必开启第二它不暴露 PASV、REST、APPE 这些命令想断点续传只能换路。源码包里那种带目录递归、断点续传、任务队列的客户端我基本都是用 QTcpSocket 自研命令层控制力足够核心代码也就两三百行。先说最核心的应答读取函数。它要能处理多行应答同时给出状态码后续任何命令交互都要复用它bool FtpClient::readReply(QByteArray buffer, int *code, int timeoutMs) { buffer.clear(); if (!ctrlSocket-canReadLine() !ctrlSocket-waitForReadyRead(timeoutMs)) { return false; } QByteArray line ctrlSocket-readLine(); if (line.isEmpty()) { return false; } buffer line; // 多行应答形如 220-...结束行形如 220 ... bool multi (line.size() 3 line.at(3) -); while (multi) { if (!ctrlSocket-canReadLine() !ctrlSocket-waitForReadyRead(timeoutMs)) { return false; } QByteArray next ctrlSocket-readLine(); buffer next; if (next.size() 3 next.at(3) next.left(3) line.left(3)) { break; } } if (code) { *code line.left(3).toInt(); } return true; }说明几处关键参数waitForReadyRead(timeoutMs)是阻塞式读取只能用在工作线程里不能在 UI 线程直接调否则界面会卡死readLine读到的是控制连接上的文本行末尾包含\r\n而状态码判断只看前三字节因为 226 后面可能是任意描述文本多行结束的判断条件是“下一行前三个字符与起始行相同且第四个字符是空格”这覆盖了大多数服务器实现。我在这个函数上吃了不少亏早期版本只判断空格不判断相同状态码遇到425和散落的多行文本就对不上了。有了这个函数命令层就能统一发命令、读应答、判状态码sendCommand(USER admin)之后readReply(...)期望 331再发PASS期望 230。这套模式贯穿登录、列目录、下载、续传所有动作。3. 最小可用客户端登录、列目录、下载三个动作如何落地3.1 连接与登录命令序列220、331、230 三个状态码走通登录的过程本质是在控制连接上做三次握手式的问答。客户端连上 21 端口后服务器先发220 Service ready for new user客户端回USER 用户名服务器如果认了这个用户名会回331 Password required然后客户端再发PASS 密码服务器回230 User logged in。整个过程不需要数据连接参与是纯控制连接交互。SFTP 是另一个协议很多新人把它和 FTP 混在一起这里提一句本方案只讲标准 FTP听到“密钥登录”“22 端口”那是 SSH File Transfer Protocol路由完全不同。下面是登录实现bool FtpClient::login(const QString host, quint16 port, const QString user, const QString pass) { ctrlSocket-connectToHost(host, port); if (!ctrlSocket-waitForConnected(5000)) { lastError QStringLiteral(connect failed: %1) .arg(ctrlSocket-errorString()); return false; } QByteArray reply; int code 0; if (!readReply(reply, code, 5000) || code ! 220) { lastError QStringLiteral(expected 220, got %1).arg(code); return false; } sendCommand(USER user.toUtf8()); if (!readReply(reply, code, 5000) || code ! 331) { lastError QStringLiteral(expected 331, got %1 : %2) .arg(code).arg(QString::fromUtf8(reply)); return false; } sendCommand(PASS pass.toUtf8()); if (!readReply(reply, code, 5000) || code ! 230) { lastError QStringLiteral(login rejected, got %1).arg(code); return false; } return true; }几个参数别照抄就完事waitForConnected(5000)的单位是毫秒5 秒是内网和公网都能接受的折中值实际项目里我会把它提出来放到配置项里USER和PASS后面的值用toUtf8()编码是安全做法用户名密码一般只含 ASCII但少数服务器允许带扩展字符用 UTF-8 至少不会在客户端本地这一侧先变异。登录失败时不要只记一个code把完整应答文本也存进lastError方便复现问题——我见过有人只存 code530结果查了一天也没搞清到底是密码错还是被限 IP。sendCommand的实现就是ctrlSocket-write(cmd \r\n)加一个flush不必单独解释。要注意的是 FTP 命令一律以\r\n结尾只写\n的服务器也可能收下但严格按标准来最好。3.2 PASV LIST/RETR列目录与下载走同一条数据连接通道登录之后列目录和下载文件在协议层面几乎是一模一样的流程先发PASV拿到数据端口再发LIST或RETR随后在数据连接上收字节收完后控制连接返回226 Transfer complete。区别只在于 LIST 的数据是流式文本RETR 的数据是文件字节流处理方式完全一致。PASV 的应答格式是227 Entering Passive Mode (202,96,80,10,192,13)括号里有 6 个数字前四个是 IP后两个是端口的高位和低位。注意有些服务器在 NAT 环境下会返回内网 IP比如192.168.x.x这种情况按着应答去连必然失败属于服务器部署问题客户端能做的最多是把它记录到日志里提示用户检查服务器配置。bool FtpClient::startPassiveDataConnection() { QByteArray reply; int code 0; sendCommand(PASV); if (!readReply(reply, code, 5000) || code ! 227) { lastError QStringLiteral(PASV failed: %1).arg(code); return false; } QRegularExpression re(\\((\\d),(\\d),(\\d),(\\d),(\\d),(\\d)\\)); auto m re.match(QString::fromLatin1(reply)); if (!m.hasMatch()) { lastError QStringLiteral(bad PASV reply: %1) .arg(QString::fromLatin1(reply)); return false; } QString dataHost QStringLiteral(%1.%2.%3.%4) .arg(m.captured(1), m.captured(2), m.captured(3), m.captured(4)); quint16 dataPort m.captured(5).toUShort() * 256 m.captured(6).toUShort(); dataSocket.reset(new QTcpSocket(this)); dataSocket-connectToHost(dataHost, dataPort); return dataSocket-waitForConnected(5000); } bool FtpClient::listDir(const QString remotePath, QByteArray *output) { if (!startPassiveDataConnection()) return false; sendCommand(CWD remotePath.toUtf8()); QByteArray reply; int code 0; if (!readReply(reply, code, 5000) || code ! 250) { lastError QStringLiteral(CWD failed: %1).arg(code); return false; } sendCommand(LIST); if (!readReply(reply, code, 5000) || code ! 150) { lastError QStringLiteral(LIST failed: %1).arg(code); return false; } output-clear(); while (dataSocket-waitForReadyRead(5000)) { output-append(dataSocket-readAll()); } dataSocket-disconnectFromHost(); if (!readReply(reply, code, 5000) || code ! 226) { lastError QStringLiteral(LIST incomplete: %1).arg(code); return false; } return true; }这里要敲两个细节。第一CWD不是必须的LIST和RETR可以直接带相对或绝对路径但先 CWD 有一个好处后续所有命令都可以用相对路径日志里的语义也更清楚所以我在列目录前先 CWD 一次。第二数据连接的读取循环我用的waitForReadyRead(5000)在读到 EOF 时返回 false但要注意正常结束和异常断开都表现为等待超时所以收完字节后必须再读一次控制连接确认226——这是判断“传输真的完整”的唯一依据。只盯着数据连接看不出半截断控制连接那个226才是闭环。3.3 别把界面卡死把 FTP 操作放进工作线程刚才的代码全是用waitForReadyRead写的阻塞式调用如果直接在按钮的点击槽里跑downloadUI 线程就会卡在网络上窗口转圈、无响应Windows 甚至会提示你“强制关闭”。qt 多线程的常见做法是做一个 worker 对象把 FTP 客户端扔到子线程里界面线程只负责收信号刷新。习惯的写法是QObject子类加moveToThread把 FtpClient 和 worker 都搬到子线程由信号槽驱动任务的开始和结束。子线程里阻塞等待没问题信号槽跨线程会自动排队UI 线程不会停。class FtpWorker : public QObject { Q_OBJECT public slots: void startDownload(const QString host, quint16 port, const QString user, const QString pass, const QString remote, const QString local) { bool ok false; QString msg; FtpClient client; if (client.login(host, port, user, pass)) { ok client.download(remote, local, 0); msg ok ? QStringLiteral(done) : client.lastErrorString(); } else { msg client.lastErrorString(); } emit finished(ok, msg); } signals: void finished(bool ok, const QString msg); };启动方式是创建线程和 worker把 workermoveToThread到线程线程start后用Qt::QueuedConnection连接界面发来的下载请求与 worker 的槽worker 的finished再连回界面线程。waitForReadyRead在子线程里放心用它阻塞的是 worker 线程界面该刷新刷新。另一个常见做法是用QThreadPool加QRunnable适合下载任务很多、需要并发的情况这个在第 4 章展开。这里提醒一句千万别在 worker 里用QMessageBox::information之类的弹窗去同步提示结果跨线程的模态窗口会卡住事件循环日志信号或者通知信号才是干净的路子。4. 把客户端做成能用的产品进度、断点续传与多文件并发4.1 大文件传输进度与内存控制不要一把梭 readAll网上不少 Demo 下载文件是这么写的QNetworkReply的finished里readAll()然后整个写进本地文件。小文件没问题到了几百 MB 的视频或镜像轻则内存暴涨重则 32 位程序直接崩。qt 大文件网络传输的正确姿势是边收边写给readyRead挂槽函数数据来一批写一批。自研的 socket 版本也同理把数据连接的readyRead交给槽函数处理每次只读bytesAvailable()或限制最大的缓冲块写完马上推进度。Progress 更新的频率也要控制每来一个包就发信号界面刷新的开销会超过传输本身我一般按“超过 1 MB 或间隔超过 100 毫秒”才发一次视觉上进度条流畅CPU 占用也低。void DownloadTask::onDataReady() { const qint64 chunk dataSocket-bytesAvailable(); if (chunk 0) return; QByteArray block dataSocket-readAll(); localFile-write(block); receivedBytes block.size(); const qint64 total remoteSize; if (total 0 (receivedBytes - lastEmitted) 1024 * 1024) { lastEmitted receivedBytes; emit progressChanged(receivedBytes, total); } }这里有几个参数要调remoteSize是哪来的在点下载之前我会先发一次SIZE 远程文件路径大部分服务器会回213 文件大小这个是进度条分母的主力。拿不到 SIZE 时进度条的total就传-1界面显示“传输中”而不是百分比。chunk的判断不要只看 0有些服务器会在消息边界发空数据直接跳过。localFile的打开方式要跟断点续传配合见下一节。4.2 断点续传REST 命令与追加写文件的配合断点续传在 FTP 里的核心命令是REST它告诉服务器“从偏移量 N 开始发送”。要注意的是REST不是一个独立动作它必须紧跟在RETR前面才生效而且很多服务器要求先TYPE I切到二进制模式再REST否则按文本模式传\r\n会被转换续传下来的文件前段后段拼接处直接损坏。顺序我建议这么走TYPE I→PASV→REST offset→RETR remotePath。有服务器对REST的位置敏感把 REST 放在 PASV 前面会返回 503 顺序错误把 REST 放在 PASV 后面是目前兼容性最好的选择。本地文件侧续传时不能简单地WriteOnly覆盖而是在文件末尾继续写。QFile localFile(localPath); QIODevice::OpenMode openMode QIODevice::WriteOnly; if (resumeOffset 0) { openMode | QIODevice::Append; } if (!localFile.open(openMode)) { lastError QStringLiteral(open local file failed: %1) .arg(localPath); return false; } sendCommand(TYPE I); readReply(reply, code, 5000); // 期望 200 sendCommand(PASV); // 这里建立数据连接 sendCommand(QString(REST %1).arg(resumeOffset).toUtf8()); if (!readReply(reply, code, 5000) || code ! 350) { lastError QStringLiteral(server does not support REST: %1) .arg(code); return false; } sendCommand(RETR remotePath.toUtf8());踩坑点在于“REST 被拒绝”和“REST 假成功”。被拒绝的情况响应码通常是502 Command not implemented或504这时应该自动降级成从头下载并给用户一个明确的提示而不是把错误抛到脸上。假成功的情况更隐蔽服务器回350但最终传下来的文件大小和本地已有文件加起来的字节数对不上这是服务器对 REST 支持不完整的表现所有续传函数返回后必须校验总字节数和文件大小。我现在统一用一个校验函数传完读一下本地QFileInfo::size()跟“续传前大小 本次收到的字节数”做比较对不上就标记失败。4.3 多文件任务队列与并发数QThreadPool 的正确用法一次下载几十个文件的场景不能给每个文件开一个QThread。线程创建销毁的成本、同时打开的 socket 数、服务器的并发连接限制都会让你翻车。习惯做法是维护一个任务队列配合QThreadPool::globalInstance()并发数控制在 3 到 5。class DownloadTask : public QRunnable { public: void run() override { FtpClient client; if (!client.login(host, port, user, pass)) { emitError(taskId, client.lastErrorString()); return; } bool ok client.download(remotePath, localPath, resumeOffset); if (!ok retryTimes maxRetry) { retryTimes; QThreadPool::globalInstance()-start(this); return; } emitFinished(taskId, ok); } };这个写法里有个具体的坑QRunnable本身不是QObject不能直接emit信号。我一般让DownloadTask持有一个QObject子类的回传指针或者回调一个全局信号转发器也可以用QtConcurrent::run加QFutureWatcher拿结果。另外注意QRunnable默认会在run()返回后被QThreadPool自动删除如果你设置setAutoDelete(false)就得自己管生命周期否则内存泄漏和悬垂指针二选一。生产者和消费者的关系在这里很直观界面线程往队列里“生产”任务线程池里的 worker 是“消费”方重试逻辑写在run()内部而不是外部循环否则排队的语义会乱。并发数不是越大越好。很多 FTP 服务器对单 IP 的连接数有限制超过 5 个直接拒绝或踢掉旧连接我一般把QThreadPool的maxThreadCount设成 4再额外做一层“同一时间单服务器最多 4 个任务”的信号量控制。批量任务里的失败重试最多两次再多就要进死信队列等人处理了。5. 避坑排查Qt FTP 客户端最常见的 5 个翻车现场5.1 编译期qt 5.15.2 报 dependent 头文件不存在现象拿到源码包用 qt 5.15.2 msvc2019_64 打开.pro编译直接报错形如:-1: error: dependent ..\..\..\..\..\..\Qt\5.15.2\msvc2019_64\include\QtWidgets\qtwidgetsversion.h does not exist。原因源码包里.pro手写了INCLUDEPATH和DEPENDPATH用的是一长串..相对路径压缩包解开到不同目录层级后路径全断了。这种问题最常见于网上分享的源码包作者在自己机器上能编换目录就废。解决先删掉.pro里手写的INCLUDEPATH和DEPENDPATH改成QT widgets network这种模块声明然后用 Qt Creator 重新打开.pro让 kit 自动配置 msvc2019_64 的 Qt 路径。如果必须保留自定义 include用$$PWD做绝对路径拼装不要用..逐级往上爬。# 手动清理后通常这样检查 qmake 是否拿到正确 Qt 版本 qmake -v qmake 你的项目.pro -o Makefile5.2 发布后qt.qpa.plugin 找不到 windows 平台插件现象开发机里跑得好好的把 exe 拷到别的机器上双击弹窗qt.qpa.plugin: could not find the Qt platform plugin windows in 程序直接崩溃还有些机器连弹窗都没有Process Explorer 能看到进程起来立刻死掉。原因exe 依赖 Qt 的 platform 插件路径是 exe 旁边的platforms/qwindows.dll。开发环境能跑是因为 PATH 里能找到 Qt 的 bin 和 plugins目标机器上没有。这是 qt 发布软件最经典的翻车点不是代码问题。解决用windeployqt打发布包这是 Qt 自带的部署工具会拷贝全部依赖。发布目录里必须出现platforms\qwindows.dll否则都是白搭。# 在 Qt 的命令行环境下执行 windeployqt --release --no-translations 你的程序.exe # 验证目录结构 dir platforms如果某些环境必须绿色免安装也可以手动设环境变量QT_QPA_PLATFORM_PLUGIN_PATH指向 platforms 所在目录但部署脚本里配置这个变量容易忘不如直接按标准目录结构放。5.3 中文文件名乱码UTF-8 和 GBK 的拉锯战现象连 Linux 的 vsftpd 一切正常连 Windows 服务器的 FileZilla Server 或 IIS 时列目录中文名全是乱码CWD 中文目录失败或进入错误目录。原因FTP 协议本身没规定目录列表的字符编码。vsftpd 默认 UTF-8Windows 上不少 FTP Server 按本地代码页 GBK 输出。客户端这边 Qt 的QString::fromUtf8把 GBK 字节按 UTF-8 解自然乱。解决登录后先发一条OPTS UTF8 ON看服务器是否回200或202。如果服务器不支持 UTF-8就用QTextCodec::codecForName(GBK)解码列表的原始字节。QTextCodec *codec QTextCodec::codecForName(GBK); if (codec) { QString decoded codec-toUnicode(rawListBytes); }这个方案在 Qt 5 下直接可用Qt 6 里QTextCodec移到了 Core5Compat 模块需要额外引入。列目录的原始字节先存QByteArray再统一按检测到的编码解码不要在字节层面断章取义。路径这块CWD 命令的参数编码也要跟列表一致否则目录名“软件包”会在服务器端对不上。5.4 传一半就断数据连接超时和内存暴涨现象下载 1 GB 文件传到 70% 左右连接断开或者程序内存占用直线飙到几个 GB界面失去响应最后以 qt 崩溃收场。原因两个问题同时存在。一个是用readAll()把整个文件读进了内存另一个是数据连接上长时间没数据、waitForReadyRead抛了超时但控制连接还没收到226代码直接把 socket 关了导致服务器侧无法确认回传的文件字节数和本地文件大小对不上。解决第一readyRead里分批写文件绝不readAll整个响应代码见 4.1。第二给数据接收做独立超时比如 30 秒没有任何新字节就主动断开并标记失败断开之后不要直接报错把已接收字节数记下来下次用REST续传。第三写完文件后做一个大小校验QFileInfo的size()要和 FTP 的SIZE命令返回值一致。断点续传不是“附加功能”它同时就是大文件传输的兜底方案。5.5 搭测试服务器时本地 FTP 行为与生产环境不一致现象本地用 FileZilla Server 搭的测试环境一切正常一上生产环境就超时、乱码、断点续传失败而且问题千奇百怪。原因不同 FTP 服务器对的 REST 顺序要求不同被动模式端口范围差异大还有的服务器返回的 PASV 地址是内网 IP。本地测出来的“正常”不代表协议层面的兼容性好。解决客户端这边做三件事——把REST的位置做成可配置PASV前或PASV后各试一次把OPTS UTF8 ON不支持的场景自动降级到 GBKPASV 返回的内网 IP 记录下来给用户一条明确提示。至于 ftp 服务器怎么搭建本地建议直接装 FileZilla Server 或 Docker 起一个 vsftpd别用 Windows 自带的 IIS FTP行为差太多测不出你要的边界。6. 进阶验证命令行模式、传输日志与哈希校验客户端做到能下载、能续传、能并发离“可交付”还差最后一步可验证。我习惯给客户端加一个命令行入口跑通之后再挂界面这样自动化测试和运维排查都能复用同一套逻辑。QCommandLineParser parser; parser.addOption(QCommandLineOption({h, host}, FTP host)); parser.addOption(QCommandLineOption({P, port}, FTP port, 21)); parser.addOption(QCommandLineOption({u, user}, username)); parser.addOption(QCommandLineOption({p, pass}, password)); parser.addOption(QCommandLineOption({r, remote}, remote path)); parser.addOption(QCommandLineOption({o, local}, local path)); parser.addOption(QCommandLineOption({R, resume}, resume offset, 0));命令行模式跑完之后如果下载的是文件用QCryptographicHash算一个 MD5 或 SHA256跟服务器或源文件比对。这一步能一次性暴露文本/二进制模式搞错、拼接偏移算错、尾部多收字节这三类问题。生产环境里我见过太多“能下完但无法解压”的场景最终还是哈希校验兜住了。传输日志也要结构化。控制连接上一来一回每行都记方向、状态码、命令或响应文本。排错时最关键的证据是“最后一条命令是什么、服务器的响应是什么”。比如断线发生在RETR之后控制连接回的是426 Connection closed; transfer aborted而不是226那答案已经很清楚了——数据连接异常不是服务器主动断开续传方向是对的。CRC 和哈希不一致时看日志里有没有TYPE A的残留基本能找到根因。我自己验收客户端时有个习惯先跑一遍命令行模式看日志最后几十行确认控制连接收尾是 226再跑哈希校验最后才打开界面做人工测试。界面容易掩盖协议层的问题这套流程会强制你面对协议本身。qt 崩溃的大多数场景都不是界面代码的问题而是网络层没有兜底。按这个顺序排查能省掉很多来回折腾的时间希望帮到你。本文还有配套的精品资源点击获取