简介一套基于Qt5框架、使用C语言编写的FTP客户端工具源码面向需要在桌面或嵌入式Linux平台集成文件上传与下载功能的开发者。源码包共12个文件其中4个cpp源文件与3个h头文件组成核心逻辑pro工程文件和ui界面文件帮助快速构建项目png图片作为界面辅助资源整体压缩后仅32KB结构清晰便于移植。目前已有1097人学习使用。工具通过QNetworkAccessManager和QFtp类的配合给出从连接服务器、登录认证到调用get下载、put上传的完整实现流程并通过信号与槽机制实时反馈传输进度和状态便于界面更新。源码还针对网络异常、服务器无响应等情况给出了错误处理示例并提示可利用FTPS或SFTP进行加密传输以增强数据安全性开发者可直接参考核心类封装方式也可在此基础之上扩展上传下载任务队列免去单独集成QFtp类的麻烦。1. QT之FTP上传下载工具从接到需求到选出路接到一个内部需求设备客户端要把日志文件传到内网FTP服务器还要能从另一台机器拉取解析。手头那份旧QT工程能跑编译时刷屏QFtp的deprecated警告换到新环境直接连不上服务器。这个标题里说的“QT之FTP上传下载等功能工具源码”正是这类东西——用QT桌面框架实现的FTP客户端工具覆盖上传、下载、目录浏览、进度显示几种日常能力。适合刚接手QT网络程序、或想从零搭一个文件传输小工具的开发者。下面不绕到QT安装和IDE配置直接讲FTP在QT里的选择、核心类设计、可落地代码以及真正吃掉时间的几个坑。2. FTP在QT里为什么有两套连接协议选型与FTPS边界2.1 FTP的21号控制端口和不可见的数据通道先弄懂PASVFTP和HTTP最本质的区别在于连接模型。HTTP是一条连接里完成请求和响应FTP则是典型的双通道控制连接走TCP 21端口专门收发命令与文本响应数据连接是第二通道只在传输文件或目录列表时临时建立。主动模式下服务器主动回连客户端的20端口被动模式下服务器先打开一个临时端口通过227响应把IP和端口号告诉客户端由客户端去连接。现在的网络环境里主动模式基本活不下来。客户端在NAT后面服务器主动连进来的TCP握手会被防火墙丢包于是PASV被动模式成了默认选择。QT里无论用QFtp还是QNetworkAccessManager开发者都看不见这条数据通道但这不代表它不存在。很多“登录成功但一传文件就卡死”的现象问题全部落在这条不可见的通道上。先记住这个判断能连上、能登录、列目录也可能正常但卡在传输阶段十有八九是数据通道没建立要么是防火墙拦了临时端口要么是PASV响应里的地址有问题后面第5章会详细拆。2.2 三套选型QFtp、QNetworkAccessManager、自拼协议该用谁QFtp是老工程最常用的封装list、get、put、mkdir、rename全都有现成方法信号也比较全。但它从Qt5后期就开始被标记deprecatedQt6里已经从标准库移除新项目再依赖它要自己去找独立移植版TLS支持也偏弱。老工程想换Qt版本、不想伤筋动骨时沿用它是可以的但新写工具不建议第一选择它。QNetworkAccessManager是目前QT里仍然在维护的请求类对FTP支持走的是HTTP风格的路子构造ftp://的URL用put上传、get下载上传下载进度信号现成对ftps://也有一定支持。它的短板也很明确不提供命令级操作没有LIST、MKD这类目录接口更不能发REST命令。目录浏览和断点续传这两块官方封装帮不上忙。第三套是自拼协议用QTcpSocket或QSslSocket逐条发FTP命令、逐行解析响应。这套的控制力最强PASV里的内网IP能自己判断、LIST各种格式能自己适配、REST能发、GBK编码能处理。缺点是开发量最大命令队列、超时管理、TLS握手、被动端口解析都要自己维护。我一般建议新工具选QNetworkAccessManager做传输主干目录操作和REST这类命令级能力用一条独立的控制通道自己拼协议补齐两边混搭性价比最高。方案维护状态FTP/FTPS上传下载目录操作断点续传适合场景QFtp已废弃FTP为主FTPS弱现成现成不支持老工程沿用QNetworkAccessManager活跃支持现成缺失不支持纯传输进度新工程主力自拼协议自己维护全支持自己写自己写可支持特殊服务器、断点续传、编码适配选型三板斧只传文件、要进度好看、不想碰协议细节用QNetworkAccessManager要支持断点续传、要兼容GBK服务器、要处理特殊LIST格式走自拼协议两样都要混搭。2.3 FTPS与SFTP的边界连不上的第一排查点FTPS和SFTP经常被混成一个东西。FTPS是FTP over TLS把FTP的控制连接或数据连接套进TLS加密隐式端口是990显式则仍在21端口上协商升级。QT里自拼协议可以用QSslSocketQNetworkAccessManager对ftps://也能处理一部分。SFTP则是SSH协议体系里的文件传输子协议端口22和FTP完全两回事QT没有原生支持通常要引入第三方库。这个边界经常是连不上的第一排查点。某公司内部工具配置界面上写着FTP服务器地址运维实际只开了22端口的SFTP服务拿协议栈全部调试完成后就是登录失败。所以做需求评审时第一件事不是一个类一个类选型而是确认服务器对外发布的具体协议名称。FTP、FTPS、SFTP三个词在需求文档里经常被互相混用落到代码里却是三条完全不同的技术路线。3. 搭一个FTP工具的核心类会话队列与任务分离3.1 控制连接命令串行化FtpSession类的设计骨架FTP控制通道有一个硬约束同一条连接上命令必须串行。服务器逐个处理、逐个响应LIST没返回完就发RETR服务器直接回错误码。这就是为什么FTP客户端的源码里必须有一个命令队列。很多简化的工具写成一个同步阻塞调用点一下按钮卡半天就是没有这个队列意识。我维护的自拼协议小工具里FtpSession是这个样子的struct FtpCommandReply { int code 0; // 220 欢迎、230 登录成功、350 请求成功待处理 QByteArray raw; // 服务器原始响应文本解析时用 bool ok() const { return code 200 code 400; } }; struct FtpRequest { QByteArray verb; // LIST、MKD、REST、RETR QByteArray params; std::functionvoid(FtpCommandReply) done; }; class FtpSession : public QObject { Q_OBJECT public: explicit FtpSession(const QUrl serverUrl, QObject *parent nullptr); void open(); // 连接并登录 void enqueue(const FtpRequest req); QNetworkReply *startTransfer(const QNetworkRequest request, QIODevice *readFrom, QIODevice *writeTo); signals: void connected(); void errorOccurred(int code, const QString message); private slots: void onReplyFinished(); private: void popNextCommand(); QUrl m_serverUrl; QNetworkAccessManager m_manager; QQueueQNetworkReply * m_pendingReplies; // 命令保序的队列 bool m_commandInFlight false; };关键逻辑在enqueue和popNextCommand每次入队检查队列里有没有正在等响应的命令没有就发下一条有就排队。QNetworkReply的finished信号触发m_commandInFlight置false然后从队头取下一个。注意QNetworkReply本身是异步的绝不能在enqueue后面接一个while等待否则事件循环被卡死finished永远不会触发。FtpSession里的open负责TCP连接、TLS握手和USER/PASS登录并把欢迎信息通过connected信号抛出去。登录信息放在QUrl的user和password字段里QNetworkAccessManager自动处理。错误码的语义要保留331表示需要密码550表示文件不存在或没有权限530是未登录这些在界面上要有对应的中文提示不能只透出一个数字。3.2 任务与界面解耦TransferJob的最小接口会话层只管命令和传输上层需要另一个抽象任务。界面按钮点下去创建的不是一个“发送数据的网络请求”而是一个“用户看得见状态的任务”。TransferJob就是这层封装。class TransferJob : public QObject { Q_OBJECT public: enum Direction { Upload, Download }; TransferJob(Direction dir, const QString localPath, const QUrl remoteUrl, QObject *parent nullptr); void start(); // 交给 FtpSession 执行 void cancel(); // 调用底层 reply-abort() signals: void progress(qint64 bytesDone, qint64 bytesTotal); void finished(bool success, const QString message); private: Direction m_direction; QString m_localPath; QUrl m_remoteUrl; QNetworkReply *m_reply nullptr; };这个类的价值在状态机pending、running、finished、canceled。UI层只关心progress和finished两个信号不关心底层用的是QNetworkAccessManager还是自拼socket。取消按钮调用cancel内部执行reply-abort。界面不需要知道abort之后会收到什么错误码统一翻译成“已取消”就行。文件路径和URL的传入时机也要定死任务创建时就固定不能在传输中途改。有的源码里把路径写在按钮的lambda里传一半UI刷新一次把参数改了这种诡异状态就是任务边界没有划清楚导致的。3.3 并发连接数与线程模型传大文件不卡UI一个FtpSession一条控制连接命令天然串行传大文件时其他命令都得排队。一个文件传输工具要同时跑多个任务就得有多个连接每个连接一个FtpSession实例放进独立线程跑事件循环。常见的连接数设置在3到5之间太少了速度上不去太多了服务器直接拒绝新连接返回421 Too many connections。多线程的引入要遵守一个原则QNetworkAccessManager对象必须在它所属的线程里创建跨线程发请求会触发未定义行为。常见做法是每个工作线程里new一个FtpSession线程结束前deleteUI线程通过信号槽和这些Session通信信号槽跨线程时队列连接会自动排队天然线程安全。有一种偷懒方案是单线程加轮询把一个连接的时间片切成小段轮流喂多个任务。这种方案省了线程但吞吐上不去两个大文件同时传时互相拖慢。实际做过一轮对比多连接方案代码量没多多少收益却很直接4个连接并行传4个文件总耗时几乎等于最慢的那个文件而不是四个之和。注意连接数不是越大越好某些服务器对同一账号有全局连接数限制配置成10反而触发风控报错后要等服务器释放体验更差。4. 用QNetworkAccessManager跑通上传下载可抄的四段代码4.1 上传put()的ContentLength与文件生命周期QNetworkAccessManager的put方法接收一个QIODevice指针把设备里的数据读出来发到远端。代码骨架如下QNetworkReply *FtpSession::upload(const QString localPath, const QUrl remoteUrl) { QFile *file new QFile(localPath, this); if (!file-open(QIODevice::ReadOnly)) { emit errorOccurred(-1, tr(无法打开本地文件: %1).arg(localPath)); return nullptr; } QNetworkRequest request(remoteUrl); request.setRawHeader(Content-Length, QByteArray::number(file-size())); QNetworkReply *reply m_manager.put(request, file); connect(reply, QNetworkReply::finished, this, [this, reply, file]() { if (reply-error() ! QNetworkReply::NoError) { emit errorOccurred(reply-error(), reply-errorString()); } file-deleteLater(); // 传输结束才释放文件对象 reply-deleteLater(); }); return reply; }两个地方不能错。Content-Length必须和文件实际字节数一致服务器端需要靠这个头判断数据流的边界少一位数服务器会一直等EOF多一位数会把下一个命令的字节当成文件内容。put传入的file对象必须在finished之后再delete因为put是异步的它只持有了这个QIODevice的指针数据还没读完就释放轻则CRC错误重则崩溃。remoteUrl的构造是ftp://用户名:密码192.0.2.10:21/目录/文件名。密码里有特殊字符时需要URL编码用QUrl::toPercentEncoding处理否则解析会断在或冒号上。还有个细节远端路径的目录分隔符服务器是Windows风格时要用反斜杠Linux风格时用正斜杠这个信息可以从登录后的PWD响应里判断不要写死。4.2 下载get()落盘与QSaveFile原子提交下载用get返回的QNetworkReply是只读的要把数据接出来写盘。直接用QFile有个隐患下载到一半失败或程序崩溃目标盘上留一个残缺文件下次启动再下载可能把残文件当成好文件用。QT自带的QSaveFile就是为了这个场景设计的QNetworkReply *FtpSession::download(const QUrl remoteUrl, const QString localPath) { QSaveFile *out new QSaveFile(localPath, this); if (!out-open(QIODevice::WriteOnly)) { emit errorOccurred(-1, tr(无法创建本地文件: %1).arg(localPath)); return nullptr; } QNetworkReply *reply m_manager.get(QNetworkRequest(remoteUrl)); connect(reply, QNetworkReply::readyRead, this, [out, reply]() { out-write(reply-readAll()); }); connect(reply, QNetworkReply::finished, this, [this, out, reply]() { if (reply-error() QNetworkReply::NoError) { out-commit(); // 写入完成原子替换目标文件 } else { out-cancelWriting(); // 丢弃临时文件保留原文件 } out-deleteLater(); reply-deleteLater(); }); return reply; }readyRead里用readAll是安全的它只读当前内核缓冲区里已有的字节不会阻塞事件循环。QSaveFile先写一个临时文件commit时才替换目标路径这个rename是原子的不会出现半截文件覆盖好文件的情况。cancelWriting则把临时文件清掉原文件保持原样。下载完成后要注意reply-error()FTP服务器找不到文件时也走HTTP风格的错误响应不检查就会把错误页面当成文件内容写进本地盘commit之后得到一个HTML后缀的文件。建议在finished里加一句本地文件大小为零且远端报错时直接删除临时文件并报“服务器文件不存在”。4.3 进度与取消uploadProgress与abort上传和下载的进度信号是现成的QNetworkReply有uploadProgress和downloadProgress分别对应上传方向和下载方向connect(reply, QNetworkReply::uploadProgress, this, [](qint64 bytesSent, qint64 bytesTotal) { if (bytesTotal 0) { double percent bytesSent * 100.0 / bytesTotal; // 更新进度条 UI百分比方式 } else { // bytesTotal -1服务器没给总长度走不确定进度 } });bytesTotal为-1时不要除以总长很多FTP服务器在传输开始时不返回文件大小进度条会瞬间跳到100%。这种情况要切换到“不确定进度”模式只显示已传输字节数不带百分比。UI层最好把这两个信号包装一下统一转成界面能用的进度模型不要让界面的槽函数直接处理底层信号。取消操作调用reply-abort()这个调用立即生效其后的finished信号会被触发error()返回OperationCanceledError。注意abort之后不需要再deletefinished槽里的deleteLater会统一清理。一个常见的错误是在abort之后立刻delete reply导致finished信号变成野指针调用程序直接崩溃。取消逻辑就两行先断信号再abort或者abort后让finished自然收尾。4.4 目录浏览与远端操作listDir、mkdir、rmdir的补丁式实现QNetworkAccessManager没有给LIST接口这个缺口要自己补。方法是用sendCustomRequest发自定义动词QNetworkReply *FtpSession::listDir(const QUrl dirUrl) { QNetworkRequest request(dirUrl); request.setAttribute(QNetworkRequest::CustomVerbAttribute, LIST); return m_manager.sendCustomRequest(request, QByteArray()); }服务器返回的目录列表是一个文本流问题在于格式不统一。Unix风格常见的是“-rw-r--r-- 1 user group 1024 Jan 1 10:00 a.txt”取最后一段就能拿到文件名Windows风格则是“10-20-2021 09:30AMfolder”同样取最后一段。但文件名本身可能带空格单纯split后取最后一段会把“my file.txt”拆坏。更稳的做法是先按第一列判断是什么风格Unix风格跳过前8个字段取剩余部分合并DOS风格从行尾往回找第一个空白之后合并剩余内容。mkdir、rmdir、rename同样用customVerb补// 创建目录MKD 命令 request.setAttribute(QNetworkRequest::CustomVerbAttribute, MKD); reply m_manager.sendCustomRequest(request, QByteArray()); // 删除目录RMD 命令 request.setAttribute(QNetworkRequest::CustomVerbAttribute, RMD); reply m_manager.sendCustomRequest(request, QByteArray()); // 重命名需要两条命令RNFR 旧名 - RNTO 新名 request.setAttribute(QNetworkRequest::CustomVerbAttribute, RNFR); reply m_manager.sendCustomRequest(request, QByteArray());RNFR成功后要紧接着发RNTO中间如果插入其他命令服务器会丢掉重命名上下文。这就是命令队列的价值所在这两个请求在FtpSession里按顺序排队中间不会被其他任务插队。目录解析这块经验是先做Unix风格和DOS风格两个解析器按第一行特征自动切换以后遇到特殊服务器再加第三个适配器不要把解析逻辑写死在某个文件里。5. 避坑清单FTP工具最常见的6个翻车点5.1 PASV返回内网IP数据连接卡住的头号原因现象登录正常LIST目录也能出内容但一传文件就卡在“正在连接数据通道”最后超时。原因服务器配置了被动模式但没配置对外IP。227响应里返回的是服务器的内网网卡地址比如192.168.1.10客户端尝试连接这个地址从公网或另一个网段根本够不着。这个坑特别隐蔽因为列目录走的是控制连接不受影响传输才需要数据通道。解决一是让服务器管理员在FTP服务端配置里设置“被动模式对外IP”和“被动端口段”这是根本解法二是客户端侧做防御解析227响应时检查返回的IP是否可达不可达则用控制连接当前对端IP替换。后者代码就藏在227解析里QRegularExpression re(\\((\\d),(\\d),(\\d),(\\d),(\\d),(\\d)\\)); auto match re.match(QString::fromLatin1(rawLine)); if (match.hasMatch()) { QString ip QString(%1.%2.%3.%4) .arg(match.captured(1), match.captured(2), match.captured(3), match.captured(4)); quint16 port match.captured(5).toUShort() * 256 match.captured(6).toUShort(); // 若 ip 不可路由用 socket-peerAddress().toString() 替换 }我当时给这个坑加了一个调试开关把227的原始响应打到日志里内网环境一对比就看出服务器返回的IP和实际服务器IP不一致省去大量抓包时间。5.2 中英文文件名乱码服务器代码页与UTF-8现象下载一个文件名带中文的文件落盘后名字变成“鏂囦欢.txt”这种完全不可读的乱码或者上传时服务器上存的名字变了样。原因FTP协议最初没有规定编码。RFC 2640推荐UTF-8但大量Windows服务器沿用本地代码页GBK或ANSI。客户端按UTF-8解码服务器返回的字节流自然乱成一团。解决目录列表解析不能默认UTF-8。先发一条OPTS UTF-8 ON服务器响应500就说明不支持UTF-8回退到QString::fromLocal8Bit或QTextCodec按GBK解码。一个可用的策略是按服务器特征记住它的编码同一个服务器复用同一套解析。注意上传文件名同样要按服务器编码转换不能把QString的UTF-16直接塞进命令要先toLocal8Bit再发送。这个坑排查起来很快抓一条LIST原始字节流用Hex看文件名区域中文GBK编码的字节模式和UTF-8一眼就能分出来。5.3 REST断点续传被忽略续传下来的文件比原文件多了一段现象断点续传功能做了测试时发现下载下来的文件比服务器原文件偏大或者上传后的文件尾部多了一段旧数据。原因下载续传时只发了RETR没发REST偏移量服务器重新从头吐数据本地又拼在已有部分后面文件自然多出一截上传续传时同理服务器不知道要从偏移量开始覆盖。解决命令序列必须严格是“REST offset - RETR”或者“REST offset - STOR”。offset是本地已有文件的字节数不能多不能少。上传场景还有一种APPE命令专门做追加写但APPE不能指定偏移只适合“本地已有完整前缀”的简单场景。要注意QNetworkAccessManager完全没有REST能力做续传只能走自拼socket通道这也是很多源码在续传功能上写得很别扭的根本原因。续传前先发一条REST验证服务器支不支持响应350就可以继续响应500或501就退化为重新全量传输别拿用户文件开玩笑。5.4 UI线程执行FTP操作界面假死的根因现象点上传按钮窗口立刻转圈文件越大卡得越久期间窗口无法拖动、按钮点不动。原因FTP操作直接在GUI线程里做了同步等待常见代码是while (!gotResponse) { QCoreApplication::processEvents(); }或者干脆在槽函数里sleep。processEvents在消息循环里挖了一个嵌套循环QNetworkAccessManager的finished信号在这个循环里能被派发但用户的鼠标消息也被处理界面表现为假死加行为错乱。解决把FtpSession放进工作线程UI线程通过信号槽收发状态。上传任务用QThreadPool加QRunnable跑或者直接用一个长期存活的QThreadSesion对象在run里创建线程退出时delete。一个可用的判断标准任何地方看到了QEventLoop或processEvents这种手动驱动事件循环的代码停下来想想是不是架构出了问题。正常异步模型里根本不需要这两样。5.5 reply生命周期错位窗口关闭即崩溃现象传输到一半把窗口关掉程序闪退或者正常传输完成后偶发性崩溃崩溃栈指向QNetworkReply的内部函数。原因QNetworkReply属于它所在的线程和父对象。窗口关闭时界面对象先析构把reply的父对象链释放但底层的FTP传输可能还在进行。等finished信号发出来时接收端已经是一个悬垂指针或者reply本身已经被delete信号到达空槽触发未定义行为。解决在窗口类维护一个活动reply列表QListQPointerQNetworkReply m_activeReplies; void MainWindow::closeEvent(QCloseEvent *event) { for (auto rp : m_activeReplies) { if (rp) { rp-disconnect(); rp-abort(); rp-deleteLater(); } } event-accept(); }QPointer是个好习惯reply被销毁时QPointer自动置空访问前判断一下就不会踩悬垂指针。窗口关闭不是直接delete而是先disconnect断掉所有信号槽再abort最后deleteLater这个顺序反了就崩。这个坑我在某图像处理Demo里遇到过弹窗关掉后偶发崩溃加了这个清理逻辑后再没复现过。5.6 大文件内存暴涨QByteArray全量接收的教训现象下载一个1GB的文件程序内存涨到1GB以上32位程序直接崩溃。上传一个几百MB文件内存曲线同样飙升。原因代码里用了reply-readAll()一次性接收全部数据或者把整个文件读进QByteArray再交给put。QNetworkAccessManager的底层缓冲是边收边攒如果应用层不消费数据一直在内存里叠。解决上传时直接把QFile对象传给put让QT底层从磁盘流式读下载时在readyRead里增量写入一次只读当前缓冲区的数据然后立即写盘。顺手在QNetworkRequest上设置setTransferTimeout和缓冲区上限可以避免服务器慢速发送时内存无限堆积。大文件传输的内存峰值应该接近操作系统的socket缓冲大小而不是文件大小看到内存跟随文件大小线性增长第一反应就查是不是全量readAll了。6. 断点续传、限速与校验把工具做成产品6.1 REST与APPE断点续传的上传下载两套写法自拼socket方案里下载续传的命令序列是PASV - REST offset - RETR filename。服务器返回150表示准备传输之后数据连接开始吐数据从offset处开始。上传续传用PASV - REST offset - STOR filename如果服务器对这个组合支持不友好退一步用PASV - APPE filename。APPE是纯追加只适合“本地文件是完整前缀”的场景无法指定偏移量但很多老服务器的兼容性反而更好。续传前发一条REST测试服务器回350走续传回500或501就整包重传这是最稳的降级策略。6.2 客户端限速别一门心思盯服务器配置内网升级工具会占满千兆带宽导致办公室其他业务卡顿。FTP服务端有全局限速配置但它限制的是该账号或该连接的所有客户端改一次影响所有使用方。客户端限速更灵活每次往socket写入后统计当前速率超过阈值就sleep一小段时间。注意这个sleep必须放在工作线程里放在UI线程就是重新踩第5章第4条的坑。限速目标不必精确在传输线程里维护一个简单的令牌桶每秒钟允许写入NKB超过就休眠剩余时间效果已经足够平滑。6.3 传输完做什么校验文件大小、SIZE命令与MD5周期传输完成的验收不能只看进度条到100%。第一步比对字节数下载完统计本地文件大小与SIZE命令返回值比对不一致直接判失败上传完可以重新下载一遍比对大小或者在服务器端执行SIZE看是否等于本地文件大小。第二步才是内容校验但FTP标准协议里没有MD5命令某些服务器提供MFMT或MD5扩展不是标准。应用层的可行做法是服务器目录里预置一个同名的.md5文件客户端下载完算一次本地MD5与md5文件内容比对这是产品层约定不需要服务器改FTP实现。第三步在现场验收时做一次抽样随机取文件末尾和中间的字节段与服务器端同名文件做字节级比对能快速发现数据通道被代理或中间设备篡改的问题。做这个工具的过程中我最深的体会是FTP的问题大多不在QT的API而在协议本身的双通道和服务器实现差异。现在每接一个文件传输需求先回答三个问题服务器协议确切是FTP、FTPS还是SFTP被动模式端口段在网络侧放没放开文件名字符集是UTF-8还是本地代码页。三个答案齐了代码层面基本不会走弯路。希望帮到你。本文还有配套的精品资源点击获取