简介:这份 Qt FTP 文件传输示例源码包面向使用 C++ 与 Qt 开发桌面应用的初中级开发者,聚焦在客户端中集成 FTP 上传、下载及目录管理能力的实际需求,适合课程设计、网络编程练习与小型工具开发参考。压缩包共 53 个文件、约 4.08MB,包含 8 个 cpp 与 4 个头文件承载主要逻辑,2 组 ui、qrc 资源及 vcproj、sln 工程文件便于直接打开和界面复用,另附 obj、pdb、ilk 等编译中间文件,可辅助对照构建环境与调试信息。已有 981 人学习,说明其在 Qt 网络编程入门场景中具有一定参考价值。源码围绕 QFtp 的连接登录、get 与 put 传输、list、cd、mkdir 目录操作以及进度和错误信号处理展开,myftp、myftp2 两个工程可对照阅读,帮助读者理解 FTP 客户端的基本流程、信号槽组织方式与常见异常处理思路,并据此改造成符合项目需要的传输模块,也可为断点续传、多线程传输和文件列表显示等扩展提供起点。
1. 从一台不联网的老设备说起:qt ftp 文件传输在 Qt 里到底怎么落地
上个月帮人看一个工控客户端,需求朴素得不能再朴素:设备每天生成一堆日志和截图,要定时扔到内网一台十几年前装的 FTP 服务器上,服务器只认 FTP,不认 HTTP 上传。开发人员最初图省事,决定直接用QNetworkAccessManager,结果发现两行代码就能下载,但上传没有接口;再翻文档才意识到,Qt5 之后QFtp已经被从 QtNetwork 里移出,变成独立附加模块了。这就是 qt ftp 文件传输在 Qt 里最真实的起点:它完全可行,但需要先选定一条技术路线。
这篇内容面向这样的人:手上有个 Qt 桌面端或者嵌入式客户端,要对接老 FTP 服务,需要上传、下载、列目录,又不想为了一个协议拖进一整套第三方重型库。下面按「怎么选 → 工程怎么搭 → 核心代码怎么写 → 哪里会出错 → 怎么改成能上生产的版本」推下去,代码都能直接抄进工程改。
2. 三条技术路线选型:QFtp 模块、QNetworkAccessManager 还是自己写 QTcpSocket
同样一个「把本地文件传到 FTP 服务器」的需求,在 Qt 里有三种写法,差别不在代码量,而在你能拿到多少控制权。选错了,后面每一个功能点都要付出代价。
2.1 QFtp 附加模块:最省事,也最容易被漏装
QFtp在 Qt4 时代是 QtNetwork 的一部分,Qt5 拆分之后改叫 qtftp 附加模块,需要单独安装,或者把源码内联进工程。它的 API 是纯异步的:connectToHost、login、list、get、put每条都返回一个 command id,执行结果通过commandFinished(int id, bool error)回来。
这套设计的好处非常实在:命令排队、被动模式握手、数据连接生命周期、dataTransferProgress(qint64, qint64)进度信号,全部由它处理。坏处也明显:需要安装模块;这个模块多年没有新功能;Qt6 里没有对应的替代品;内联源码时还会牵涉私有头文件。
qmake 工程里加一行就够了:
# .pro:前提是 Qt 安装目录下真的存在 FTP 附加模块 QT += core gui network ftp CONFIG += c++11CMake 工程对应写法:
# Qt5::Ftp 由附加模块的 config 文件提供,不是 Qt 自带 find_package(Qt5 COMPONENTS Core Network Widgets Ftp REQUIRED) target_link_libraries(ftp_demo PRIVATE Qt5::Core Qt5::Network Qt5::Widgets Qt5::Ftp)如果find_package直接报找不到 Ftp 组件,说明模块没装,不用怀疑写法,去安装器里勾上,或者走源码内联这条路。
2.2 QNetworkAccessManager:下载能凑合,上传基本没戏
它的本职是 HTTP 客户端。ftp://这种 URL 用get()请求一下还能拿到数据,但上传、列目录、改名、删除这些操作一个都没有,较新版本对 FTP 的支持还在持续收缩。判断标准很简单:只读、单文件、不关心进度细节,用它没问题;只要涉及上传或者目录同步,直接换路线,别在这里浪费时间。
2.3 手写 QTcpSocket:可控,但要自己处理应答码
FTP 协议本身的复杂度并不高:一条控制连接跑文本命令,一条数据连接跑字节流,总共需要实现的命令不到十条——USER、PASS、TYPE、PASV、PWD、CWD、LIST、SIZE、STOR、RETR、QUIT。自己写的优势是完全可控,能塞进没有任何额外依赖的嵌入式镜像,想加MLSD、REST断点续传、自定义超时都随便。代价是应答码、多行应答、226 收尾这些细节得一条条处理正确,写错一个状态就卡死在那儿。
三条路线的取舍我通常这么定:
| 路线 | 上传 | 下载 | 列目录 | 进度信号 | 额外依赖 | 适用场景 |
|---|---|---|---|---|---|---|
| QFtp 附加模块 | 支持 | 支持 | 支持 | 自带 | 需安装 qtftp | 常规客户端,赶工期 |
| QNetworkAccessManager | 不支持 | 有限支持 | 不支持 | 仅下载 | 无 | 只读拉取单个文件 |
| 自写 QTcpSocket | 自己实现 | 自己实现 | 自己实现 | 自己发 | 无 | 嵌入式、有特殊协议需求 |
2.4 把选型落到工程文件里
不想装模块的,把 qtftp 的源码内联进来:qftp.cpp、qftp.h、qurlinfo.cpp、qurlinfo.h,外加它们引用的那几个_p.h私有头,一起丢到third_party/qftp/目录,然后:
INCLUDEPATH += $$PWD/third_party/qftp HEADERS += $$PWD/third_party/qftp/qftp.h \ $$PWD/third_party/qftp/qurlinfo.h SOURCES += $$PWD/third_party/qftp/qftp.cpp \ $$PWD/third_party/qftp/qurlinfo.cpp QT += network这里有个容易忽略的点:qftp.cpp内部 include 的是 Qt 的私有头,私有头路径在不同小版本之间会变。我的做法是把 Qt 版本固定在某个 LTS 小版本上再改代码,升级 Qt 的时候把这块列为必查项,否则编译报错会让人查很久。
3. 工程结构与传输链路:控制连接、数据连接和一条命令队列
选完路线,接下来是工程怎么搭。FTP 这个协议的结构决定了代码结构——两条连接、一个状态机、一条命令队列,只要这三样东西的边界划清楚,后面加功能就不会互相干扰。
3.1 目录结构和类的职责划分
一份能直接开工的目录大概长这样:
ftp_demo/ ├── ftp_demo.pro # 或 CMakeLists.txt ├── third_party/qftp/ # 可选:qtftp 源码内联 ├── src/ │ ├── main.cpp │ ├── mainwindow.h/.cpp/.ui # 只碰界面:地址、账号、本地/远端路径、进度条 │ ├── ftpclient.h/.cpp # 封装层:登录、上传、下载、列目录、错误转译 │ ├── ftpworker.h/.cpp # 批处理:队列、重试、多任务串行化 │ └── appsettings.h/.cpp # 主机/端口/账号/最近路径的持久化 └── resources/职责边界我卡得比较死:MainWindow里只允许出现信号槽连接和界面刷新,一行 FTP 相关代码都不写;FtpClient对外只暴露四个方法和四个信号(progress、finished、entry、stateChanged),不 include 任何界面头文件;FtpWorker负责批量任务的排队与重试。
一条硬性约束:同一时刻只允许一个数据传输在跑。FTP 的控制连接是会话级的,CWD会改变当前目录,多任务并发很容易把状态搞乱,这是我在一个批量同步需求上反复排查才确认的结论。
3.2 控制连接上的应答解析与命令队列
FTP 应答的格式是三位数字加分隔符:220 ready是单行,多行应答用220-开头、220结尾。解析时最容易犯的错,是假设「一次readyRead就是一条完整应答」。TCP 是字节流,应答可能被拆成两段,也可能两条粘在一起,必须自己维护缓冲区。
// 每次 readyRead 都追加到缓冲区,再按 CRLF 切行 void FtpClient::onCtrlReadyRead() { m_recv += m_ctrl->readAll(); int pos; while ((pos = m_recv.indexOf("\r\n")) >= 0) { const QString line = QString::fromLatin1(m_recv.left(pos)); m_recv.remove(0, pos + 2); m_lines.append(line); // 第 4 个字符是空格 => 这条应答结束;续行是 '-',继续攒 if (line.size() >= 4 && line.at(3) == QLatin1Char(' ')) { const int code = line.left(3).toInt(); const QString text = m_lines.join(QLatin1Char('\n')); m_lines.clear(); handleReply(code, text); } } }fromLatin1是有意选的:应答头本身是 ASCII,用 Latin1 能保证字节不被破坏,正文里如果带 UTF-8 文件名,后续再用QString::fromUtf8转换一次。m_lines用来累积多行应答,LIST之类命令在150之后经常跟一堆注释行,不攒起来会把状态机带偏。
命令队列的核心是「上一条命令收到终态应答才发下一条」,否则PASV还没回就发STOR,服务器会直接回425:
void FtpClient::sendRaw(const QString &cmd) { m_queue.enqueue(cmd); pump(); } void FtpClient::pump() { // 控制连接没就绪、队列空、或者上一条还没终态,都不发 if (m_busy || m_queue.isEmpty() || m_ctrl->state() != QAbstractSocket::ConnectedState) return; m_busy = true; m_ctrl->write((m_queue.head() + "\r\n").toUtf8()); m_ctrl->flush(); // 命令很短,flush 避免被 Nagle 算法攒着 }m_busy在handleReply里释放,判据是应答码:1xx属于中间态(150、125),后面还有226,这时候不能放开队列;2xx、3xx、4xx、5xx都是终态,释放之后pump()才会发下一条。
3.3 被动模式 PASV 的握手顺序
客户端一律用被动模式。主动模式(PORT)要求服务器反连客户端,客户端在 NAT 或防火墙后面基本不通,线上报障里这类问题占的比例不小。
标准顺序是:TYPE I→PASV→ 解析227拿到地址和端口 → 建立数据连接 → 发STOR/RETR/LIST→ 收150→ 传数据 → 关数据连接 → 收226。数据连接要在发传输命令之前建立,虽然不少服务器容忍反过来,但先连更稳。
227应答的解析:
bool FtpClient::parsePasv(const QString &reply, QString *ip, quint16 *port) { // 典型格式:227 Entering Passive Mode (192,168,1,10,195,80) const int l = reply.indexOf(QLatin1Char('(')); const int r = reply.indexOf(QLatin1Char(')'), l); if (l < 0 || r < 0) return false; const QStringList p = reply.mid(l + 1, r - l - 1).split(QLatin1Char(',')); if (p.size() != 6) return false; *ip = QString("%1.%2.%3.%4").arg(p[0], p[1], p[2], p[3]); *port = static_cast<quint16>(p[4].toInt() * 256 + p[5].toInt()); return true; }端口那块要按高字节 * 256 + 低字节算,直接p[5].toInt()只能拿到 0 到 255,连上去就是超时。另外很多服务器(尤其是容器里跑的服务,以及云上的 FTP 网关)会在这个应答里返回自己的内网地址,这种时候不能信:
if (ip.isNull() || isPrivateIpText(ip)) ip = m_ctrl->peerAddress().toString(); // 只信端口,地址用控制连接对端3.4 进度回传与状态刷新
上传的进度 = 已经写进数据 socket 的字节数,总量 =QFileInfo::size();下载的进度 = 已经落盘的字节数,总量优先用SIZE命令的返回值,拿不到就发-1,让界面显示「不确定」而不是编一个假百分比。
界面刷新要做节流,这一点经常被忽略:大文件传输时dataTransferProgress一秒能发几千次,每次都去setValue()会直接把主线程占满,表现是进度条在动但窗口拖不动。我一般在FtpClient里加一个QElapsedTimer,间隔 100ms 或者进度变化超过 1% 才往外发一次信号,UI 立刻顺畅。
4. 核心代码拆解:上传、下载、列目录三段可直接抄的实现
这一部分给两份实现。QFtp 版适合直接用在产品里,自写 QTcpSocket 版适合需要完全掌控协议或者无法安装附加模块的场景。两份的逻辑骨架是一样的,看懂了可以对照着改。
4.1 基于 QFtp 的封装类:上传、下载、进度、错误
先看头文件里的接口,尽量收窄:
// ftpclient.h:QFtp 封装,对外只暴露必要的入口和信号 class FtpClient : public QObject { Q_OBJECT public: explicit FtpClient(QObject *parent = nullptr); void connectToServer(const QString &host, quint16 port, const QString &user, const QString &pwd); void upload(const QString &localPath, const QString &remotePath); void download(const QString &remotePath, const QString &localPath); void listDir(const QString &remoteDir); signals: void progress(qint64 done, qint64 total); void finished(bool ok, const QString &msg); void entry(const QString &name, qint64 size, bool isDir); void stateChanged(const QString &desc); private slots: void onCommandFinished(int id, bool error); void onListInfo(const QUrlInfo &info); private: QFtp *m_ftp = nullptr; QFile *m_file = nullptr; // 传输中的本地文件句柄 int m_transferId = -1; // 当前传输命令的 id,用来判定回调归属 bool m_uploading = false; QString m_error; };连接和登录部分,QFtp 内部自带命令队列,连着写是安全的:
void FtpClient::connectToServer(const QString &host, quint16 port, const QString &user, const QString &pwd) { if (!m_ftp) { m_ftp = new QFtp(this); connect(m_ftp, &QFtp::commandFinished, this, &FtpClient::onCommandFinished); connect(m_ftp, &QFtp::listInfo, this, &FtpClient::onListInfo); connect(m_ftp, &QFtp::dataTransferProgress, this, &FtpClient::progress); } m_ftp->connectToHost(host, port); // 非 21 端口必须显式传 m_ftp->login(user, pwd); // 会排在 connect 之后执行 m_ftp->setTransferMode(QFtp::Binary); // 传图片/压缩包必须二进制 }参数上要注意port:connectToHost有重载,不传端口时默认 21,很多内网服务器为了避开扫描会改成 2121、8021 之类,改完不生效八成是这里漏了。
上传的写法:
void FtpClient::upload(const QString &localPath, const QString &remotePath) { m_file = new QFile(localPath, this); if (!m_file->open(QIODevice::ReadOnly)) { emit finished(false, tr("本地文件打不开:%1").arg(localPath)); return; } m_uploading = true; m_transferId = m_ftp->put(m_file, remotePath, QFtp::Binary); if (m_transferId == -1) emit finished(false, m_ftp->errorString()); }put(QIODevice *, const QString &, TransferType)这个重载不接管 device 的所有权,但要求它在commandFinished之前一直可读。所以m_file挂在this下面,回调里再关闭并deleteLater()。反过来,如果在put之后立刻close(),服务器收到的就是 0 字节文件,这个问题特别隐蔽,因为客户端侧没有任何报错。
回调里判定归属:
void FtpClient::onCommandFinished(int id, bool error) { if (error) m_error = m_ftp->errorString(); if (id == m_transferId) { // 只处理当前传输这条 if (m_file) { m_file->close(); m_file->deleteLater(); m_file = nullptr; } emit finished(!error, error ? m_error : tr("传输完成")); m_transferId = -1; } }下载是对称的,m_ftp->get(remotePath, m_file, QFtp::Binary),m_file用QIODevice::WriteOnly打开。列目录走m_ftp->list(dir),条目在listInfo(const QUrlInfo &)里逐个回来,用info.isDir()、info.size()、info.name()取字段,比自己去解析LIST的文本输出可靠得多——这是 QFtp 最值得用的一点。
4.2 自研版:PASV 解析加 STOR 上传
自己写的时候,先把状态机画清楚,代码就顺了。枚举比一堆 bool 好维护:
// 一条控制连接上的阶段,够覆盖登录 + 单次上传 enum Step { Idle, // 等 220 WaitUser, // 已发 USER WaitPass, // 已发 PASS WaitType, // 已发 TYPE I WaitPasv, // 已发 PASV WaitStor, // 数据连接已建,等 150 Transfer, // 数据在传,等 226 WaitDone };应答分发是整套代码的核心,每个分支只做一件事——判断这个码是不是当前阶段期望的,是就推进到下一阶段,不是就失败退出:
void FtpClient::handleReply(int code, const QString &text) { switch (m_step) { case Idle: // 220 服务就绪 if (code == 220) { sendRaw("USER " + m_user); m_step = WaitUser; } break; case WaitUser: // 331 要密码 / 230 免密 if (code == 331) { sendRaw("PASS " + m_pwd); m_step = WaitPass; } else if (code == 230) { sendRaw("TYPE I"); m_step = WaitType; } else { m_step = Idle; emit finished(false, text); } break; case WaitPass: if (code == 230) { sendRaw("OPTS UTF8 ON"); sendRaw("TYPE I"); m_step = WaitType; } else { m_step = Idle; emit finished(false, text); } // 530 账号密码错 break; case WaitType: if (code == 200) { sendRaw("PASV"); m_step = WaitPasv; } break; case WaitPasv: { QString ip; quint16 port = 0; if (code != 227 || !parsePasv(text, &ip, &port)) { m_step = Idle; emit finished(false, text); break; } if (isPrivateIpText(ip)) ip = m_ctrl->peerAddress().toString(); // 只信端口 m_data = new QTcpSocket(this); connect(m_data, &QTcpSocket::connected, this, &FtpClient::onDataConnected); connect(m_data, &QTcpSocket::bytesWritten, this, &FtpClient::onDataWritable); m_data->connectToHost(ip, port); // 先连数据口 m_step = WaitStor; break; } case WaitStor: if (code == 150 || code == 125) { // 服务器允许开始发 m_file->open(QIODevice::ReadOnly); m_step = Transfer; onDataWritable(); // 主动灌第一批数据 } break; case Transfer: if (code == 226 || code == 250) { // 传输收尾 m_file->close(); m_step = Idle; emit finished(true, tr("上传完成")); } break; } if (code >= 400 && m_step != Idle) { // 4xx/5xx 一律按失败处理 m_step = Idle; emit finished(false, text); } if (code >= 200) m_busy = false; // 1xx 是中间态,不放队列 pump(); }数据连接建立之后才发STOR,这个顺序不能反:
void FtpClient::onDataConnected() { sendRaw(QString("STOR %1").arg(m_remoteName)); }真正的发送用「水位」控制,不让一次性把整个文件读进内存:
void FtpClient::onDataWritable() { if (m_step != Transfer || !m_file) return; const qint64 chunk = 64 * 1024; // 写缓冲低于 4 块才继续读,避免大文件把内存吃满 while (m_data->bytesToWrite() < chunk * 4 && !m_file->atEnd()) { const QByteArray buf = m_file->read(chunk); if (buf.isEmpty()) break; m_sent += buf.size(); m_data->write(buf); } emit progress(m_sent, m_total); if (m_file->atEnd() && m_data->bytesToWrite() == 0) m_data->disconnectFromHost(); // 关数据连接,服务器才会回 226 }最后那句disconnectFromHost()是关键。不关数据连接,服务器就一直等 EOF,客户端这边收不到226,状态机会停在Transfer,界面上就是「卡在 99%」。
4.3 下载与目录递归遍历
下载的方向反过来:RETR之后在数据 socket 的readyRead里往本地文件写,disconnected时关闭文件、等控制连接的226。总量优先发SIZE拿,拿不到就发-1。
列目录分两种服务器:支持MLSD的返回的是结构化的type=file;size=123; name,解析起来干净;老服务器只给LIST的 Unix 风格文本,得靠正则:
// 兼容 drwxr-xr-x 1 owner group 4096 Jan 01 12:00 dirname 这类输出 static const QRegularExpression kListRe( QStringLiteral(R"(^([\-dl])\S*\s+\d+\s+\S+\s+\S+\s+(\d+)\s+\w{3}\s+\d+\s+[\d:]+\s+(.+)$)"));递归上传的坑在目录切换。CWD是会话级的,深递归时如果靠CWD进去再退出来,很容易算错层级。我一般改成全程绝对路径:MKD建目录、STOR /a/b/c.txt直接用完整远端路径,一次交互就能定位,少了一半出错机会。MKD已存在时服务器回550,这个错误要显式忽略,不然第二次同步就断了。
4.4 常见应答码的落点
调试阶段把这张表贴在显示器边上,能省很多时间:
| 应答码 | 含义 | 客户端该做什么 |
|---|---|---|
| 220 | 服务就绪 | 发 USER |
| 331 / 230 | 需要密码 / 登录成功 | 发 PASS 或直接推进 |
| 150 / 125 | 数据连接已开,准备传输 | 开始读写数据 socket |
| 226 / 250 | 传输完成 / 操作成功 | 关闭本地文件,结束本次任务 |
| 227 | 进入被动模式 | 解析 IP 和端口,建数据连接 |
| 425 | 数据连接建不起来 | 检查 PASV 解析与防火墙 |
| 426 | 连接被关闭,传输中断 | 数据 socket 提前断了,重试 |
| 450 / 451 / 452 | 临时不可用 | 退避后重试 |
| 530 | 未登录 | 账号密码或权限问题 |
| 550 | 文件不可用 / 无权限 | 路径、权限、MKD已存在 |
5. 避坑与排查:五次翻车记录
下面这几条都是我在真实联调里遇到的问题,按「现象 → 原因 → 解决」写,对着排能省下大量时间。
5.1 传上去的文件大小对,图片却打不开
现象:上传一个 jpg,服务器上大小和本地差几十字节,图片打不开,zip 解压报校验失败。
原因:没设二进制模式,走的是 ASCII 传输。FTP 在 ASCII 模式下会把行尾转成 CRLF,遇到0x1A还可能被当成 EOF 截断,二进制文件基本会损坏。
解决:连接建立后立刻发TYPE I(QFtp 是setTransferMode(QFtp::Binary)),传输全程不改。判断文件是否需要文本模式,只看扩展名是不够的,我现在的策略是默认全部二进制,真正做到「所有文件都一律使用二进制」。
5.2 上传卡在 99%,永远等不到 226
现象:数据都发完了,progress也走到 100%,但finished信号一直不触发,界面挂在那里。
原因:数据 socket 没有关闭。服务器在等 EOF 才认为传输结束,然后才回226。
解决:本地文件读完之后调用m_data->disconnectFromHost(),并且不要立刻delete这个 socket,等disconnected信号到了再清理。用 QFtp 的话这个问题不存在,它内部处理了。
5.3 登录和列目录都正常,一传数据就 425 超时
现象:USER/PASS都通,PASV也回了227,但连数据端口必超时。
原因:两种可能。一是227里返回的是服务器内网地址,客户端照着连当然连不上;二是服务器被动模式端口段被防火墙挡了,只放行了 21。
解决:客户端侧强制用控制连接的对端地址覆盖应答里的 IP,只取端口;服务器侧把被动模式的端口范围固定下来并在防火墙上放行,同时把对外地址配成客户端可达的那个。这两件事得同时做,只改一边仍然不通。
5.4 中文文件名变问号,或者干脆 550
现象:上传测试数据.csv,服务器上显示成一串乱码,或者直接报550找不到路径。
原因:编码不一致。客户端发的是 UTF-8,服务器按 GBK 解释,两边都没错,但都理解错了。
解决:先发OPTS UTF8 ON探测,服务器回200就说明支持 UTF-8,全程用 UTF-8;回500或501就说明这是一台老服务器,得按本地编码转换后再发。Qt5 之后QTextCodec挪到了 core5compat 模块,用之前先确认工程里加了没有,否则编译直接失败。
5.5 传输时窗口拖不动
本文还有配套的精品资源,点击获取