news 2026/9/22 3:51:53

用VC++手写FTP服务器与客户端:从协议到断点续传的完整实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用VC++手写FTP服务器与客户端:从协议到断点续传的完整实践

简介:面向VC/MFC网络通信开发者的FTP客户端与服务器完整源码,适合具有一定C++基础、想深入理解FTP协议实现与Socket编程的读者。资源共26个文件,以7个cpp源文件和7个h头文件为核心,辅以dsw/dsp工程配置、txt说明文件及图标资源,压缩包仅42KB,结构简洁,便于快速定位关键代码。项目分为基于控制台的FTP服务器和带界面的FTP客户端两部分:服务器已覆盖大部分FTP服务端功能,内置扩展接口,可在此基础上演化出更复杂的业务逻辑;客户端封装了FTPClass、FTPSocket等模块,包含连接管理、文件上传下载、目录列表等典型流程。所有源码均在VC6.0中调试通过,并附有账号密码(toldo)与主目录(c:/temp)等运行说明,上手成本低。已有290人学习,适合作为课程设计、毕设参考或网络通信入门提升的实践素材。 先交代一下背景。之前在内网搭文件服务,图省事直接装了Filezilla Server,结果同事那边连不上、连上了又说“不安全的服务器”,Windows资源管理器倒是能进,但传大文件动不动断,断点续传还不支持。折腾了一阵子,我索性用VC++从零写了整套FTP服务器和客户端,服务端能多用户并发、支持被动模式、能处理中文文件名,客户端能上传、下载、断点续传、显示进度。做完之后回头再看,FTP这东西虽然老,但“客户端/服务端”的通信范式在今天的工控、运维、嵌入式场景里仍然是基本功。这篇就把整个项目从设计到踩坑的过程完整记下来,给想用VC++/C++写网络程序的人做个参考。

1. 连不上和“不安全”背后:为什么我决定用VC++重写一套FTP

先讲个反直觉的观察:FTP的问题往往不在FTP本身,而在“你以为你懂它”。市面上免费的FTP服务端一大把,但真放到固定场景里用,总有几个绕不过去的坎。

第一个坎是加密策略。新版Filezilla Server默认要求客户端启用TLS加密,客户端一上来就是明文连接,界面直接报“不安全的服务器”。如果你在纯内网环境、全是自己的设备,这个提示纯粹是添乱;可你要是为了消除提示去配置证书,又要维护一套证书生命周期。第二个坎是权限模型。我现在做的项目要给不同设备绑定不同目录,设备序列号对应独立空间,Filezilla Server那种基于用户配目录的方式,要跟业务系统动态联动非常别扭。第三个坎是集成。文件传完之后要触发后续处理(比如固件包解析、配置校验),现有服务端做不到。

所以当时我的判断是:与其在现成工具上打补丁,不如自己写一套完整版,把服务端和客户端都攥在手里。这套东西要做到三点:不依赖第三方库,用WinSock和标准C/C++跑通;支持主动和被动两种数据连接模式;中文文件名不乱码。做完之后发现,FTP协议本身并不复杂,真正的复杂度全在细节里。

这套代码适合谁?第一是VC++学习者,你可以在里面看到socket编程、多线程、字符串处理的完整工程实践。第二是需要内网文件传输、又希望传输逻辑能被自己控制的开发人员。第三是想深入理解FTP协议的人——自己实现一遍服务端和客户端,比看十遍RFC都管用。

2. 控制连接和数据连接:服务端架构的第一步

FTP最容易被新手忽略的设计,是它用了两条TCP连接。一条叫控制连接,固定走21端口,用来传命令和响应;另一条叫数据连接,用来传目录列表、文件内容。我服务端第一版就把这两条连接混在一起处理,结果LIST命令返回了目录数据,客户端傻等命令响应,最后超时。这个架构问题理清楚之后,后面的代码才顺了。

2.1 控制连接的命令循环长什么样

控制连接本质上是一个文本协议:客户端发一行命令,服务端回3位数字响应码和一段说明文字。比如客户端发USER admin,服务端回331需要密码;再发PASS 123456,回230登录成功。

我实现的命令循环大概是这样的:

while (recvCommand(clientSock, cmdBuf)) { parseCommand(cmdBuf, cmd, arg); if (cmd == "USER") handleUser(arg); else if (cmd == "PASS") handlePass(arg); else if (cmd == "PWD") handlePwd(); else if (cmd == "CWD") handleCwd(arg); else if (cmd == "PASV") handlePasv(); else if (cmd == "PORT") handlePort(arg); else if (cmd == "LIST") handleList(arg); else if (cmd == "RETR") handleRetr(arg); else if (cmd == "STOR") handleStor(arg); else if (cmd == "REST") handleRest(arg); else if (cmd == "QUIT") { sendResponse(clientSock, "221 Bye"); break; } }

服务端收到命令后的响应,必须按协议规定来。比如登录成功返回230,路径不存在返回550,命令无法处理返回502。响应码错了,客户端就会做出各种奇怪行为——这算是FTP调试里最常有的事故源。

2.2 主动模式和被动模式,选哪个

数据连接的建立有两种方式,这是FTP最核心的机制。

主动模式(PORT)下,客户端告诉服务端“你连我某个端口”,然后服务端主动发起数据连接。问题是如果客户端在NAT后面,服务端根本连不到客户端的IP。被动模式(PASV)反过来,服务端开一个新端口,告诉客户端“你来连我”,由客户端主动发起数据连接。这样NAT穿透问题就解决了。

我做了个对照表方便选型:

项目主动模式(PORT)被动模式(PASV)
数据连接发起方服务端向客户端发起客户端向服务端发起
服务端端口使用20端口临时打开一个高端口
客户端防火墙友好度较差,需开放入站较好,只做主动出站
服务端防火墙要求放行20和21放行21和PASV端口段
NAT场景兼容不推荐推荐

服务端PASV的响应格式有个经典坑:返回的是IP和端口的组合,而且格式不是给人看的。比如服务端IP是192.168.1.100,开的数据端口是50010,50010除以256得195余40,那响应就是227 Entering Passive Mode (192,168,1,100,195,40)。客户端拿到之后要自己拼回IP和端口。我当时算端口的时候直接用了端口号50010,客户端怎么都解析不对,后来才想起来要拆高字节和低字节。

2.3 每个会话一个线程的理由

服务端的并发模型我最初考虑过select、IOCP,后来还是选择了“每客户端一个线程”。原因很直接:FTP会话的交互频率低,大部分时间阻塞在recv上等客户端命令,一个线程处理一个客户端逻辑清晰、调试方便。IOCP适合大量短连接和高并发请求的场景,用在FTP里属于过度设计。

主线程只做accept,每个新连接创建一个工作线程:

SOCKET clientSock = accept(listenSock, ...); _beginthreadex(NULL, 0, ClientWorker, (void*)clientSock, 0, NULL);

在线程里要注意一点:每个线程的回调函数里必须做一次WSAStartup初始化,不要以为主线程初始化了就万事大吉。我第一版在主线程只初始化了一次,结果新线程里调send直接返回WSANOTINITIALISED,排查了半天。

3. 服务端最容易踩的三个坑,我一个个说

这层坑是最典型的,包括路径遍历、中文乱码和被动端口规划。排序按踩坑的痛苦程度来。

3.1 路径穿越:CWD .. 就能读到根目录之外

如果说哪个漏洞让FTP服务端看起来像个筛子,路径穿越排第一。攻击者发CWD ../../命令,配合RETR命令,就能把服务端磁盘上的任意文件拉走。这个问题不是FTP独有,任何按字符串拼接路径的文件服务都有。

网上能看到很多“简易FTP服务端”代码,基本都是直接操作字符串,把用户输入的目录拼到根目录后面,这是最容易出事的写法。

我处理的方式是:定义一个规范化函数,把收到的相对路径转成绝对路径,并强制校验其前缀必须在根目录内。关键代码如下:

bool NormalizePath(const std::string& rootPath, const std::string& input, std::string& safePath) { std::string fullRoot = GetFullPathA(rootPath); std::string fullInput = GetFullPathA(rootPath + "\\" + input); if (fullInput.compare(0, fullRoot.size(), fullRoot) != 0) { return false; // 越界 } safePath = fullInput; return true; }

这里GetFullPathA对CWD ..的处理是逐级消解的,所以只要前缀校验过了,就说明用户永远跳不出根目录。这个函数要放在所有涉及路径的命令前面统一调用,包括CWD、RETR、STOR、DELE、MKD、RMD。

3.2 中文文件名乱码,是编码问题不是字体问题

FTP协议最初只定义了ASCII,文件名怎么办?RFC 2640建议用UTF-8,但Windows资源管理器自带的FTP客户端默认用的是本地ANSI代码页(中文环境是GBK)。这就导致一个奇妙的局面:服务端按UTF-8存中文名,Windows资源管理器列目录显示乱码;服务端按GBK处理,支持UTF-8的客户端看到乱码。

我不建议二选一,而是做了一个可配置的编码开关。默认跟随系统(GBK),面向Windows资源管理器;再加一个UTF-8模式,面向标准FTP客户端。核心字符串转换函数用两个API就够了:

// UTF-8 转 GBK std::string Utf8ToAnsi(const std::string& utf8) { int wlen = MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, NULL, 0); wchar_t* wbuf = new wchar_t[wlen]; MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, wbuf, wlen); int alen = WideCharToMultiByte(CP_ACP, 0, wbuf, -1, NULL, 0, NULL, NULL); char* abuf = new char[alen]; WideCharToMultiByte(CP_ACP, 0, wbuf, -1, abuf, alen, NULL, NULL); std::string ansi(abuf); delete[] wbuf; delete[] abuf; return ansi; }

反向的GBK转UTF-8就是把两个API的参数对调。另外还要注意,工程属性如果是“多字节字符集”,代码写起来省事,但数据流向一旦涉及外部系统会非常痛苦。我建议直接把项目设置改成“使用Unicode字符集”,内部统一用wchar_t或std::wstring,只在socket收发边界做编码转换。

3.3 被动模式端口没规划好,客户端永远连不上

PASV模式下服务端会临时开一个1024以上的端口。如果你让操作系统随机分配,就会出现这种情况:控制连接通了,登录也成功了,但一发LIST就卡住,或者报“无法与服务器建立连接”——因为随机端口被防火墙拦了。

解决方案是在服务端配置一个被动端口段,比如50000到50100,然后在这段范围内挑选空闲端口绑定。代码长这样:

SOCKET pasvSock = INVALID_SOCKET; for (int port = 50000; port <= 50100; port++) { pasvSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in addr = {0}; addr.sin_family = AF_INET; addr.sin_port = htons(port); addr.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(pasvSock, (sockaddr*)&addr, sizeof(addr)) == 0) { break; } closesocket(pasvSock); pasvSock = INVALID_SOCKET; } listen(pasvSock, 1);

端口段开好之后,防火墙入站规则要么按程序放行,要么显式放行这个端口范围。这个细节解决了大部分“能登录但列不出来目录”的问题。我在服务端日志里会记录每次PASV选中的实际端口,方便排查连不上是端口问题还是网络问题。

4. 客户端不是“连上就完”,还有界面和断点

只写服务端不写客户端,这套代码就缺了一条腿。客户端这边我也遇到了一堆界面与网络纠缠的问题——大量的新手项目都在这里翻车。

4.1 用原生Socket还是WinINet封装

做FTP客户端,摆在面前有两条路。一条是MFC里现成的CFtpConnection,内部封装了WinINet API,代码量非常小,连接、上传、下载几句话就搞定。另一条是用原生socket手写协议,从连接到命令交互全部自己控制。

我当时两个都试了,最后在正式版里选的还是原生socket。原因在于:CFtpConnection对PASV模式的处理不够透明,而且断点续传支持有限,遇到自定义服务端扩展命令也没法用。用原生socket虽然代码多几百行,但每一步在做什么你都清楚,出问题能用抓包工具对着看,心里有底。如果你只是写个临时脚本,CFtpConnection可以;如果你想摸透协议,原生socket才是正道。

4.2 一次标准登录和传输的完整流程

客户端和服务端的完整交互序列大致是这样的:

1. TCP连接服务器21端口 2. 接收 banner(220 开头) 3. 发送 USER 用户名,接收 331 4. 发送 PASS 密码,接收 230 5. 发送 TYPE I(二进制模式),接收 200 6. 发送 PASV,接收 227,解析出数据端口 7. 新建socket连接该数据端口(不急着发数据) 8. 发送 RETR 文件名(或 LIST、STOR),接收 150 9. 通过数据socket收发数据,结束后关闭数据socket 10. 接收 226(传输完成)

注意第7步和第8步的顺序不能反。很多人把RETR和数据连接建立搞成先后阻塞,结果互相等死。正确做法是先连数据端口,再发RETR命令。客户端发明文命令没问题,但接收响应时要处理多行响应的情况,尤其是banner和某些服务器的欢迎语,可能占多行。

4.3 界面上传下载的线程与进度处理

我见过不少VC++新手把socket收放在按钮事件里,结果一传大文件,界面直接假死。Windows界面消息循环和网络阻塞调用天生冲突,必须把网络操作放到单独线程。

我用AfxBeginThread创建一个工作线程,界面线程只负责收集用户输入和刷新列表。工作线程每收到一段数据,就用PostMessage通知界面线程更新进度条,而不是SendMessage。为什么?因为SendMessage会等界面线程处理完才返回,如果界面线程正在处理其他重任务,网络线程会卡住。PostMessage把消息丢进队列就返回,两边互不阻塞。

线程收尾也要小心,用户点了“取消下载”,不能直接强杀线程,否则socket资源来不及释放,下次再连会发现端口冲突。正确做法是设置一个取消标志位,网络线程在每次读数据前检查,通过closesocket来唤醒阻塞的recv,从而实现温和退出。

4.4 断点续传:REST命令是关键

断点续传的实现分三步:第一步,上传或下载前发SIZE命令,获取远程文件大小;第二步,本地文件从偏移量开始读写;第三步,发REST offset命令告诉服务端从指定位置开始传。REST命令要在RETR或STOR之前发,而且要通过控制连接发——这个顺序错了服务端会返回503错误。

要注意的是,断点续传要求服务端也明确支持REST命令。我在自写服务端里把REST记为会话状态,在后续的RETR/STOR时用fseek定位文件指针。这套机制组合起来,大文件传一半断网,重连之后能从断点继续,体验比Filezilla连内网时稳定得多。

5. 拿Filezilla和Windows资源管理器联调的那些坑

自己写的东西要拿到真实环境里接受考验。我把自写服务端和客户端放到网上,再用不同工具去连,果然炸出一堆细节问题。

5.1 Filezilla提示“不安全的服务器”究竟在说什么

新版Filezilla默认要求服务端支持FTP over TLS(FTPS),如果客户端检测到服务端纯明文,就弹出“不安全的服务器”警告。这个提示本身是负责任的安全设计,但在纯内网环境里会把人绕晕。

解决方式有两种。第一种是服务端加TLS支持,用SChannel或OpenSSL实现AUTH TLS指令和加密通道切换。第二种是让Filezilla客户端改为“仅使用普通FTP”——在站点管理器里把加密选项改成“只用普通FTP”。我自写服务端第一版没有TLS,所以测试时用第二种;但真放到稍微重要一点的环境,我会强烈建议直接上FTPS,因为明文FTP在内网也有被抓包的风险,这个后面细说。

写客户端的时候也要注意类似问题:如果连的是公网FTP服务器、又支持TLS,客户端最好也能发AUTH TLS。我在客户端里预留了FTPS扩展位置,只不过默认关闭。

5.2 Windows资源管理器“无法与服务器建立连接”怎么排除

用Windows资源管理器访问FTP,最常见的问题就是能弹出登录框、但点进去后目录为空或者提示无法建立连接。这基本是数据连接的问题,不是账号密码错了。从我的实际经验看,排除步骤很简单:

  1. 在资源管理器地址栏直接输入ftp://IP:21,如果能连上,说明21端口通。
  2. 登录后在界面执行“查看”目录,如果卡住或者报错,90%是PASV数据端口不通。
  3. 去服务端防火墙上确认21和PASV端口段都放行了。
  4. 如果服务端在NAT后面,检查服务端PASV响应里返回的IP是不是内网IP,资源管理器用这个IP连不上就废了。解决办法是服务端做PASV响应时,把回包里的IP替换成外网映射IP。

第4条是最隐蔽的坑。我当时用路由器映射了21端口,客户端登录顺利,一列目录就超时,抓包发现客户端在尝试连接192.168.x.x这个内网地址,当然连不上。后来在PASV响应构造时加入可配置的公网IP,问题才算解决。

5.3 部署到别的机器时,别忘了VC++运行库

代码在本机跑得好好的,拷到测试机一运行,提示“无法启动程序,因为计算机中丢失VCRUNTIME140.dll”——这是VC++ Redistributable没装。用Visual Studio默认编译出的程序依赖动态运行库,目标机器没有对应版本的运行库就会这样。

解决方式有两个:一是项目属性-代码生成-运行库改成多线程静态链接(/MT),这样程序体积大一点,但不需要对方装运行库;二是发布时把vc_redist.x64.exe一起带上,部署时静默安装。我在自己的发布包里选择了后者,因为服务器上还可能跑其他VC++程序,装了运行库一劳永逸。如果你用的是Debug编译还要注意,Debug版依赖调试运行库,目标机器几乎不可能装全,正式分发一律用Release。

6. 个人实践:这套代码在真实环境里怎么用

项目做完之后,我自己把它用在一个工业设备的固件升级场景里。每台设备用一个序列号当登录名,对接的设备目录就是用户名,权限也只能在这个目录里活动。设备端不需要Windows资源管理器,是嵌入式的FTP客户端,直接连服务端做文件传输。整个流程稳定跑了几个月,没出过传输中断的事。

记录几个真实的使用心得,供后来者参考。

第一,FTP服务端千万不要在公网裸奔。明文的账号密码和文件内容可以被抓包直接看到。如果必须对外提供服务,要么给这套代码加TLS(FTPS),要么干脆改用SFTP(走SSH协议),不要因为“只是自己用”就图省事。第二,被动端口段一定要在配置里固定下来,不要用系统随机分配,这是服务端部署最省心的做法。第三,传输大文件时日志要记录命令时间戳和传输字节数,出了问题时,抓包和日志对照着看能快速定位。

坦白说,自己实现一遍FTP之后,再去接触HTTP、MQTT这类协议,都会有种“原来是异曲同工”的通透感。所有的客户端/服务端模式,核心都是建立连接、发请求、收响应、管理资源。FTP的文本命令和清晰的状态码,恰好是最适合入门的范本。如果接下来要扩展,我最想给这套代码加上的是FTPS支持,以及一个简单的Web管理后台。有动手想法的朋友,也可以沿着这两条路继续做下去。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/21 1:50:51

LibreChat:Agent时代的基础设施工具链

1. LibreChat不是另一个ChatGPT前端&#xff0c;而是Agent时代的基础设施探针 LibreChat这个名字&#xff0c;第一眼容易让人误以为是又一个开源版ChatGPT界面——毕竟GitHub上叫“XXXChat”的项目数以百计。但如果你真把它当成UI套壳去跑&#xff0c;十有八九会在第三步卡住&…

作者头像 李华
网站建设 2026/9/21 1:50:46

双4090本地部署Qwen3.6-27B:FP8量化与vLLM多卡推理实战

1. 为什么我选择在两张 4090 上折腾 Qwen3.6-27B先把结论摆在前面&#xff1a;Qwen3.6-27B 这个体量的模型&#xff0c;放在两张 4090 上跑本地推理&#xff0c;是当前消费级硬件里性价比相当高的一套组合&#xff0c;但它绝对不是"插上就能用"的那种省心方案。我从早…

作者头像 李华