资讯动态

基于Qt的FTP上传Demo实现:手写QTcpSocket详解与踩坑指南

发布时间:2026/10/15 17:14:08 来源:尧图企业网站定制
简介这是一套基于Qt框架的FTP上传示例工程面向需要快速掌握QNetworkAccessManager网络文件传输能力的Qt开发者。工程以完整Demo为载体包含上传管理类、主窗口、上传对话框等清晰模块完整演示了如何通过put()方法将本地文件上传至FTP服务器并实现进度回显与错误处理的基本思路。内容涵盖网络请求构造、Content-Type头设置、文件打开校验、上传信号槽监听等关键环节代码结构紧凑、便于学习。压缩包共13个文件以cpp源文件、h头文件、ui界面文件为主辅以pro工程配置和qt_zh_CN.qm语言文件整体仅47KB短小精悍。读者可直接导入Qt Creator运行调试也可在此基础上拓展多文件上传、断点续传或敏感信息加密等功能。已有446人学习下载适合Qt入门者边读边练也可作为日常开发中FTP上传功能的实用参考。1. Qt实现FTP上传Demo这个需求比想象中更「技术活」把一套工控设备定时采集的温度报表自动传回上位机 FTP 服务器这个需求让某开发者在 Qt 6 里卡了两个晚上。他按网上老教程引入 QFtp编译直接报模块不存在换成 QNetworkAccessManager 构造一个 ftp:// 的 URL 去 upload编译能过运行却抛出 Protocol not supported。Qt 实现 FTP 上传 Demo 最反直觉的地方就在这里Qt 官方从 Qt 6 开始移除了对 FTP 的官方支持网上大量示例代码已经过时即使选择自带网络库能编译也不代表能上传能上传也不代表服务器认你这个文件名。这篇笔记从协议细节讲起给出手写 FTP 命令的最小可靠 Demo把被动模式、中文文件名、断点续传这几类高频翻车点一次说清。适合刚接触 Qt 网络编程的新人也适合要接手维护旧工程里 FTP 上传逻辑的运维开发。2. FTP 上传的协议层认知控制连接、数据连接与 Qt 的选型边界2.1 FTP 上传不是一条 TCP 连接控制通道与数据通道FTP 与 HTTP 上传最大的差异在于它用两条独立的 TCP 连接。第一条是控制连接默认 21 端口客户端在这里发 USER、PASS、PASV、STOR、QUIT 这些命令服务器用三位数字响应码回复220、331、230、227、226 这些数字每个都有明确含义第二条是数据连接真正传输文件字节流的通道。数据连接什么时候建、由谁发起引出了主动模式和被动模式两种行为。主动模式PORT的流程是客户端在控制连接上告知服务器「我开了一个端口等你连」然后服务器主动向客户端发起数据连接。问题在于客户端多半在 NAT 后面或带防火墙内网 IP 发过去服务器压根连不回来于是命令已发、数据连接却建不起来表现为 STOR 之后无限期卡死。被动模式PASV则反过来客户端发 PASV服务器返回一个可连的 IP 和端口由客户端发起数据连接。跨网段、过防火墙的 FTP 上传几乎只有被动模式能稳定跑通。我在 Demo 里默认启用被动模式并且建议你把「支持主动/被动切换」直接写进设计里因为不少老旧内网 FTP 服务只开放了其中一种。另一点容易被忽略的是FTP 响应可能跨多个 TCP 包到达也可能一次 readyRead 收到好几条响应。新手最容易踩的坑是「收到 227 了但字符串被截断一半」所以靠谱的响应解析必须做缓冲按「行」处理而不是按「包」处理。下行文的代码会专门处理这个。2.2 QFtp 模块Qt 5 时代的遗产与 Qt 6 的移除事实QFtp 是 Qt 官方曾经提供的 FTP 客户端模块在 Qt 5 时代就已经从核心模块中拆出来独立维护老项目里常见做法是把它以 .pri 方式直接编进工程然后像这样调用先 connectToHost再 login然后 put 上传最后 quit。它本身是异步信号槽设计commandFinished 信号会在每条 FTP 命令结束后触发内部维护一个命令队列避免你手动处理复杂的响应状态。但从 Qt 6 开始官方不再提供 QFtp 的维护版本从 UI 到 API 全面切换到了 Qt 6 的模块体系直接把 Qt 5 时代的 QFtp 源码拖到 Qt 6 工程里编会撞上 QRegExp 移除、QNetworkProxy API 变更、qmake 工程格式差异等一系列兼容问题。所以今天再启动一个新项目我的建议是不要再赌 QFtp 能省事。如果你接手的是 Qt 5 老工程里面已经有能跑的 QFtp 代码那就别动它维持原样如果是新工程直接用下一节给的三条路线做选型别走回头路。关于 QFtp 还有一点值得说明它内部的命令队列思想是对的后续你手写实现时也要复刻这种「一次只发一个命令、收到响应再发下一个」的串行机制。FTP 控制连接上并发发命令绝大多数服务端会给你 500 语法错误因为协议本身是半双工的。2.3 Qt 6 没有 QFtp 怎么办三条路线与选型结论我实测过并会在 Demo 中使用的是下面三条路线各有清晰边界。方案可用版本FTP 上传支持被动模式维护成本适合场景QFtp 遗留模块Qt 5需自行编入工程完整支持高新版兼容性差老工程维护、不换 Qt 版本QNetworkAccessManagerQt 5 / Qt 6几乎不可用上传基本是坑不可控低不建议用于 FTP 上传自己用 QTcpSocket 写命令Qt 5 / Qt 6 / Qt 7 均可用完整可控完全可控中核心代码两三百行新项目、跨版本长期维护QNetworkAccessManager 这条路线必须多说一句很多开发者被它 HTTP 的方便带偏以为 setUrl(QUrl(ftp://...)) 再调用上传接口就行。实际上 Qt 的 QNAM 对 FTP 的支持极其有限主要服务于读取 FTP 目录列表这类基础场景用它做文件上传很可能运行期直接报 Protocol not supported。我第一次在新项目里试这条路线编译很顺利跑起来才在控制台看到错误属于典型的「黑匣子翻车」。所以新项目我默认走第三条路线QTcpSocket 本质就是 TCP 客户端FTP 上传的命令文本完全公开自己维护一个几十行的状态机得到的可控性远超任何封装好的模块而且 Qt 版本升级不影响核心逻辑。选型结论一句话新工程手写 QTcpSocket老工程能跑就继续用 QFtp千万别在 QNetworkAccessManager 上浪费时间做 FTP 上传它是为 HTTP 设计的。3. 基于 QTcpSocket 手写 FTP 上传 Demo从连接、登录到 STOR3.1 工程准备.pro 配置与最小工程结构新建一个纯 C Qt Console 工程即可不依赖 UI。在 .pro 里只需要打开 network 模块QT core network CONFIG c17 console CONFIG - app_bundle TARGET ftp_upload_demo SOURCES main.cpp \ ftpuploader.cpp HEADERS ftpuploader.h参数说明QT network 提供 QTcpSocket、QFile 等核心类C17 为了代码里用 lambda 表达式更顺手Qt 5.12 以上都支持。Console 模式方便你把日志直接打到终端观察 FTP 响应码这是我调试协议类功能时的习惯——保留一个 text 输出开关比断点调试高效得多。类设计上我一般把 FTP 上传封成一个 FtpUploader对外只暴露四个方法设置主机和端口、设置用户名密码、触发上传、查询错误信息所有内部状态与 socket 全部私有。这样做的好处是后续无论是接断点续传还是接队列上传调用方代码不用变。3.2 控制连接的响应解析与登录状态机FTP 客户端最容易写烂的部分不是发命令而是收响应。服务器返回的每一条响应以三位数字开头后跟空格或连字符。数字后跟空格表示本条响应结束数字后跟减号表示还有后续多行响应要继续读直到再次出现「三位数字 空格」为止。// 在 FtpUploader 私有槽中处理控制连接的可读数据 void FtpUploader::onCmdReadyRead() { m_replyBuf QString::fromLatin1(m_cmdSocket-readAll()); // 检查是否拿到一个完整行简单版只处理单行响应 int pos m_replyBuf.indexOf(\r\n); if (pos 0) return; // 半包等下次 readyRead const QString line m_replyBuf.left(pos).trimmed(); m_replyBuf.remove(0, pos 2); // 丢弃已处理的行 const int code line.left(3).toInt(); switch (m_state) { case State::Connecting: if (code 220) { // 服务就绪开始登录 sendCommand(USER m_user); m_state State::UserSent; } break; case State::UserSent: if (code 331) { // 需要密码 sendCommand(PASS m_pass); m_state State::PassSent; } break; case State::PassSent: if (code 230) { // 登录成功 enterPassiveMode(); } break; // ... 后面处理 PASV、STOR、QUIT default: break; } }逻辑说明m_replyBuf 是类成员 QByteArray用来跨 readyRead 累积数据indexOf(\r\n) 判断是否凑齐一行这是处理 TCP 粘包拆包的最小可靠办法。状态机把控制连接的每一步固定住USER 和 PASS 之间绝不会交错。sendCommand 内部统一在命令末尾补 \r\n避免反复拼字符串漏掉回车导致服务器一直不响应。参数说明FTP 用户名和密码以纯文本在控制连接上发送如果你接的是生产环境建议至少换成 FTPS后文第 5 章会讲这段代码里 m_user 和 m_pass 直接 ascii 编码即可不要混入中文用户名密码用 UTF-8 只是少数现代服务器的特殊配置。3.3 被动模式 PASV解析服务器的数据端口登录成功后客户端要发 PASV 命令服务器返回 227 响应响应里括号内是一串用逗号分隔的六个数字前四个是 IP后两个是端口的高低位void FtpUploader::enterPassiveMode() { sendCommand(PASV); m_state State::PasvSent; } void FtpUploader::parsePasvReply(const QString line) { int left line.indexOf((); int right line.lastIndexOf()); if (left 0 || right 0) { emit failed(PASV 响应格式异常: line); return; } const QStringList nums line.mid(left 1, right - left - 1).split(,); if (nums.size() ! 6) { emit failed(PASV 参数数量不对); return; } const QString dataIp nums[0] . nums[1] . nums[2] . nums[3]; const quint16 dataPort nums[4].toUShort() * 256 nums[5].toUShort(); m_dataSocket-connectToHost(dataIp, dataPort); m_state State::DataConnecting; }逻辑说明端口低位和高位的换算经常有人写反p1 * 256 p2 这一行是整个 FTP 手写实现里最容易出错的地方。我在一个早期 Demo 里把两个数写反了连上的是完全不相干的端口服务端日志一片红排查到半夜才发现是高低位弄颠倒了。建议你在拿到 227 响应后先用系统自带的文件传输对比工具验证一遍 IP 和端口是否和服务器主动模式日志一致再继续写 STOR 逻辑。参数说明被动模式下服务器返回的 IP 不一定是客户端能直连的地址某些服务器在 NAT 后面返回内网 IP客户端连不通。成熟的客户端会尝试「用控制连接的对端 IP 替换响应里的 IP」我在 Demo 里保留了按响应原文直连的方式但把这个替换逻辑作为 TODO 注释生产环境务必加上。3.4 发送 STOR 与数据写入把文件字节流交出去数据连接建立成功后控制连接上发 STOR 命令然后往数据 socket 里写文件内容。完整流程是先写文件名再写文件数据全部写完关闭数据连接最后在控制连接上等到 226void FtpUploader::onDataConnected() { // 数据连接已连上通知服务器准备接收文件 sendCommand(STOR m_remoteName.toLocal8Bit()); m_state State::StorSent; } void FtpUploader::onStorAccepted() { // 服务器返回 150表示准备接收数据 if (!m_file-open(QIODevice::ReadOnly)) { emit failed(m_file-errorString()); return; } // 简单 Demo一次性读完再写 const QByteArray all m_file-readAll(); m_dataSocket-write(all); m_dataSocket-flush(); } void FtpUploader::onDataDisconnected() { // 数据写完等待控制连接上的 226/250 m_state State::WaitFinished; }逻辑说明STOR 后面的文件名用的是 toLocal8Bit而不是 toUtf8这是兼容大量中文 Windows FTP 服务器的保守做法具体原因在第 4 章第 4 条展开。代码里文件是一次性 readAll适合小文件 Demo超过几十 MB 就必须改成按块读写监听 bytesWritten 信号驱动下一批数据否则内存会被撑爆。参数说明m_remoteName 在调用上传接口时传入建议统一只传文件名而不传目录目录切换用后续的 CWD 命令解决这样职责更清晰。服务器返回 150 后如果长时间没进入 onDataDisconnected多半是数据连接根本没建立成功这时候需要回看第 3.3 节的 PASV 解析。3.5 完整调用示例把上面的状态机串起来FtpUploader *ftp new FtpUploader(this); // 信号槽注册顺序固定 connect(ftp, FtpUploader::progress, this, [](qint64 sent, qint64 total) { qInfo() progress: sent / total; }); connect(ftp, FtpUploader::failed, this, [](const QString err) { qWarning() upload failed: err; }); connect(ftp, FtpUploader::finished, this, []() { qInfo() upload finished; }); // 连接与上传参数 ftp-setHost(192.168.1.10, 21); ftp-setCredential(demo_user, demo_pass); ftp-setPassiveMode(true); // 本地文件 - 服务器文件名 ftp-upload(C:/data/report_20250201.csv, report.csv);逻辑说明整个 FtpUploader 全程异步不阻塞 UI 线程。upload 方法内部先 connectToHost再走状态机所有进度通过信号抛给调用方。setPassiveMode 在上层只传一个布尔值内部决定发 PASV 还是 PORT这层抽象让调用方完全不用关心 FTP 协议的复杂度。参数说明端口默认 21如果服务器跑在非标准端口需要在 setHost 里显式传入。用户名、密码、远端文件名这些参数建议用配置文件或命令行传入别写死在类内部否则后面接界面时会很被动。这里的 finished 信号只在收到 226 之后发出它才是「上传成功」的唯一可信标志别拿数据连接断开当成成功。4. FTP 上传 Demo 的 5 个翻车现场从协议报错到中文乱码4.1 编译通过、运行报 Protocol not supported现象代码里用了 QNetworkAccessManager构造 ftp:// 开头的 URL编译期一切正常程序跑到上传那一步控制台输出 Protocol not supported文件没有任何上传动作。原因Qt 的 QNetworkAccessManager 并不是万能协议处理器它对 FTP 的支持在 Qt 5 中就已降级为读取场景Qt 6 更是基本放弃。协议名不匹配时它不会在编译期告诉你而是运行期抛错特别坑人。解决换回 QTcpSocket 手写或者直接使用 QFtp仅限你的工程还停留在 Qt 5。替换时注意QNAM 的上传进度回调是 bytesWritten而手写 FTP 是看数据连接写完后再等控制连接 226语义完全不同别照搬旧回调逻辑。4.2 登录成功、PASV 有响应但 STOR 后死等现象控制连接上 220、331、230 都正常发完 PASV 也拿到了 227数据连接显示已连接但 STOR 命令发出去后程序一直等不到 150或者等到 150 却等不到数据写入完成。原因最常见的是服务器返回的 PASV 地址不可直连。比如服务器在 Docker 或 NAT 后面227 括号里返回的是内网 IP客户端从公网根本连不过去还有一种情况是防火墙只放行了 21 端口没有放行 PASV 协商出来的高端口段。解决先确认数据连接的对端地址。通用做法是「如果数据连接的 IP 和控制连接对端 IP 不同就以控制连接对端 IP 为准重连一次」再看防火墙是否放行了 PASV 端口范围很多服务器支持在配置里固定 PASV 端口区间比如只开放 50000-50100客户端侧把区间也加进防火墙放行规则问题立刻消失。4.3 响应到达不全显示 22 而不是 220现象程序收到的响应字符串不完整比如 220 变成 22PASV 的 227 括号内容被截断后续状态机直接走进 default 分支卡在登录或被动模式。原因TCP 是流式协议一次 readyRead 可能只带来半个响应也可能一次带来三个完整响应。直接用 readAll 解析必然出问题这是所有手写协议解析器都会遇到的粘包/拆包问题。解决必须做缓冲。把每次 readAll 的结果追加到一个成员 QByteArray然后循环检查是否包含完整行——以 \r\n 为结束符有完整行才提取没有就返回等待下一次 readyRead。上文的 onCmdReadyRead 已经做了这件事但注意它只处理单行响应如果有人用带减号的扩展响应比如 220- Welcome还需要循环判断第三位是不是减号继续累积生产环境别省略。4.4 中文文件名上传后变成乱码或直接 550现象远端文件名含中文比如「报表_20250201.csv」上传完到服务器一看成了乱码或问号有些服务器直接拒绝返回 550 No such file or directory。原因FTP 协议本身没有强制规定文件名字符集老服务器默认按本地代码页解析Windows 中文服务器多为 GBKLinux 服务器多为 UTF-8。客户端用 UTF-8 发GBK 服务器就识别不了。解决分两步走。如果服务器支持控制连接上先发 OPTS UTF-8 ON服务器返回 200 后再用 UTF-8 发文件名服务器不支持就退化回本地编码 QByteArray name m_remoteName.toLocal8Bit()。我现在的习惯是封装一个 tryUtf8ThenLocal 的辅助函数优先协商 UTF-8拿到 500 响应再回落两种模式都兼容。4.5 大文件上传时进度条卡死界面像冻结现象上传 200MB 文件时传着传着进度条不动了界面点击没反应过很久才突然跳一大截或直接超时失败。原因同步式 write 大块数据 在 GUI 线程里频繁刷新进度双倍卡顿。一次 write 几十 MB 进 socket 缓冲区事件循环被长时间占用进度信号又连到界面控件直接更新重绘积压。解决改成按块驱动。数据连接上每写 64KB就等待 bytesWritten 信号再写下一块进度信号频率做节流。另外所有 FTP 网络操作都放到 QThreadPool 或独立线程的工作对象里跑主线程只收信号改 UI。这也是为什么我坚持 FtpUploader 不做成单例或全局状态每个上传任务一个实例天然适合丢线程。5. 从 Demo 到可用工具断点续传、队列上传与重试机制5.1 断点续传用 REST 命令续传中断的文件断点续传不是重新传整个文件而是从失败前记录的字节偏移量继续。FTP 协议里对应的命令是 REST紧跟着当前偏移量服务器返回 350 之后客户端再发 STOR数据从偏移处接着写void FtpUploader::resumeUpload(const QString remoteName, qint64 offset) { m_resumeOffset offset; sendCommand(QString(REST %1).arg(offset).toLatin1()); m_state State::RestSent; } // 状态机收到 350 后进入 STOR 数据发送 void FtpUploader::onRestAccepted() { sendCommand(STOR m_remoteName.toLocal8Bit()); m_state State::StorSent; } // 在 onStorAccepted 里打开文件后定位偏移 if (!m_file-open(QIODevice::ReadOnly)) { emit failed(m_file-errorString()); return; } m_file-seek(m_resumeOffset);逻辑说明REST 的偏移量是相对文件开头的绝对字节数不是相对本次传输的位置。偏移量的持久化建议放到一个独立的进度文件里格式就是「远端文件名 当前偏移」上传成功或服务器返回 226 后再删除这样程序闪退也能找回续传点。参数说明不是所有服务器都支持 REST部分老服务端对 STOR 前的 REST 返回 502 命令未实现。客户端在收到 502 后应该自动降级为整文件重传并且提示调用方「该服务器不支持断点续传」这个降级路径我在上个 Demo 里没写后来被用户吐槽过属于容易漏掉的一环。5.2 上传队列串行化是 FTP 的命根子一个任务队列里有 100 个小文件要传最容易踩的坑是「一个接一个」实现成「一个循环全发出去」。控制连接是半双工的并发发 STOR 必然互相干扰。我一般用一个 QQueue 保存待上传项每完成一个再 pop 下一个。核心是保证一次只有一个任务处于传输中靠状态机回到 Idle 时触发队列弹出void FtpUploader::onFinishedOne() { emit progress(1, 0); // 通知上一个完成 if (m_queue.isEmpty()) { sendCommand(QUIT); emit finished(true, QString()); return; } const UploadItem item m_queue.dequeue(); startUpload(item.localPath, item.remoteName); }逻辑说明QUIT 必须放在队列全部清空的最后不能在每个文件传完都发一次否则服务器提前断开控制连接。startUpload 内部重新初始化文件句柄、清零偏移量、发 PASV相当于把整个状态机重置回可复用状态。参数说明队列重试次数、单文件超时这些参数应该做成 FtpUploader 的构造参数别在类内部写死。我习惯把单文件超时默认设在 30 秒数据连接超过 30 秒没动静就判定失败这个参数对弱网环境尤其重要。5.3 重试策略区分可重试与不可重试错误上传失败的原因五花八门但重试策略不能一刀切。以我的血泪经验至少分成三类可立即重试的网络瞬断、服务器 421 连接超时、可延迟重试的服务器 5xx 临时故障、磁盘满、不可重试的403 权限拒绝、550 文件不存在、文件名非法。bool FtpUploader::canRetry(const QString errCode) { if (errCode.startsWith(4)) return true; // 4xx可以重试 if (errCode.startsWith(5)) return false; // 5xx协议级错误 if (errCode.contains(timeout)) return true; // 网络超时 return false; }逻辑说明5xx 大概率是命令本身有问题重试一百次也是同一个结果举例说 553 文件名不允许重试只会浪费带宽。4xx 是临时的比如 450 文件被占用过几秒再试通常能过。重试次数上限设 3 次超过就把该文件从队列里移除记录失败日志继续下一个不能让一个坏文件把整个队列卡死。参数说明重试间隔用指数退避第一次 1 秒第二次 2 秒第三次 4 秒别固定间隔。这个细节对弱网场景和服务器限流场景都很有效。5.4 安全基线FTP 明文协议的生产环境替代FTP 的用户名、密码、文件内容全部明文传输在公网或含中间设备的链路上等于裸奔。如果 Demo 要变成生产工具至少做到两点第一优先用 FTPS即 FTP over TLS控制连接和数据连接都走 QSslSocket第二不能换协议的情况下明确告知用户这是内网专用工具禁止用于互联网环境。Qt 对 FTPS 的支持方式是在控制连接上发送 AUTH TLS 命令然后把 cmdSocket 升级为 QSslSocket之后的 USER/PASS/STOR 全部在加密通道里发送。这个改造对手写协议栈来说并不难——核心响应解析和状态机完全不变只是 socket 类型换了。SFTP 则是另一套基于 SSH 的协议不在 FTP 命令体系内需要引入独立库。我给生产环境定的基线是内网明文 FTP 可以用但密码不硬编码从系统配置或环境变量读取任何跨公网传输必须走 FTPS连 FTPS 都不支持的服务器直接放弃别在上面跑业务数据。6. 上线之前的最后验证从能跑到能信的检查清单Demo 跑通只代表「本地正常」不代表「生产可靠」两者之间的距离是几张检查表。我交付前必跑的项目如下检查项做法通过标准小文件完整性上传 1KB 文件对比字节数和内容无差异大文件稳定性上传 100MB 以上文件断开网络一次再续传续传后文件 MD5 与源一致中文文件名分别用 UTF-8 和本地编码测试服务器端显示与本地一致被动模式穿透在公司防火墙环境下完整走一轮上传不卡死、226 正常返回服务器异常响应用错误密码、错误路径各试一次程序不崩溃错误信号有明确文本队列连续传连续入队 50 个不同大小文件全部成功或失败记录完整我一般会把「断网重试」作为最后一个测试项目找一个弱网环境把网线拔掉确认程序能在超时后走重试逻辑而不是无限卡死。这一步过了Demo 才有资格说自己是可用的工具。基于上面的检查我还习惯做一个 10MB 文件的 MD5 对比脚本用通用文件传输命令把同一个文件传到服务器再和 Qt 工具传的结果做哈希比对两边完全一致才算彻底过关。这个习惯救过我一次——有一次数据连接写的内容经过了文本模式转换文件大小没变但内容被改了换行符哈希比对立刻暴露而字节数对比根本发现不了。FTP 上传这个方向我的教训是协议很老、坑很实、封装的库在 Qt 6 时代明显断层但它的控制逻辑又足够简单值得亲手写一遍状态机。希望这篇笔记里的代码和踩坑记录能让你少熬两个排查的夜晚。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑