简介一套面向VC/MFC与网络通信初学者的FTP客户端与服务器完整示例工程基于VC6.0调试通过。工程同时提供控制台版本的FTP服务器和MFC界面客户端源码头文件结构清晰关键功能均以类接口形式开放便于在此基础上扩展成支持断点续传、多用户权限等复杂FTP应用。压缩包共26个文件以cpp/h源码、dsw/dsp工程文件、txt说明文档及rc/ico界面资源为主整体仅42KB轻量且便于逐文件阅读源码与工程文件可直接对应适合对照学习FTP协议、Socket通信和MFC界面组织。已有291人学习浏览。资料内含可直接编译的服务器与客户端程序服务器实现了大部分FTP常用指令客户端封装了连接、列目录等操作并附默认账号toldo、主目录c:/temp等测试配置可帮助快速验证整体流程也适合作为课程设计或毕业设计的基础框架。1. 一份能跑起来的 VC FTP 源码包凭什么值得下如果你手里这份资源标题写着「Ftp服务器与客户端VC完整版」那它大概率不是网上那种拿几个 socket 函数拼出来的课堂作业而是一份能同时编译出服务端和客户端的 MFC 工程。我用它搭过本地研发环境的临时文件中转也把它当网络通信源代码的模板排查过三次以上的连接问题。这个包适合两类人刚学完 socket 想看看完整协议栈怎么组织的新手以及有内网传输需求、不想引入几十兆第三方库的从业者。完整版的意思不是 UI 华丽而是命令响应、主动被动模式、断点续传这些硬功能都在拿手里就能编译、能跑、能改。2. FTP 协议骨架先把命令流、主动/被动模式、数据通道关系理清无论是服务器还是客户端FTP 让人最容易绕晕的一点是它有两组连接一条负责发命令、收响应的控制连接一条真正搬文件的数据连接。只看源码不先看协议很容易在构造函数里迷失。第 2 章先把这两条线拉直后面去读那份 VC/MFC 源代码时你才知道每个函数到底在给谁擦屁股。2.1 命令通道基于文本的请求-响应状态机FTP 控制连接默认使用 21 端口客户端连上之后所有交互都是可读文本客户端发一行命令服务器回一行或多行响应响应码是三位数字。不用把 RFC 的每条细节都背下来但下面这组命令与响应对应关系要非常熟命令含义常见响应USER提交用户名331 需要密码 / 230 已登录PASS提交密码230 / 530 认证失败TYPE设置传输类型200 成功PASV请求被动模式227 进入被动模式PORT主动模式信息200 / 425RETR下载文件150 准备传输 / 226 完成STOR上传文件150 / 226QUIT断开221 再见这张表的价值在于调试时可以把焦点从“程序为什么卡住”转成“命令发出后到底回了什么码”。5xx 是永久错误4xx 是临时错误3xx 表示需要后续命令比如 USER 之后通常要等 PASS。源码里几乎每个网络分支都写在这组响应码上读的时候先盯住 150、226、550 三个码。如果把整个会话画成状态其实就是一个很简单的状态机空闲状态收到 USER跳到等待密码收到正确 PASS 后进入已登录已登录才能收 PASV、RETR传输进行中不要插别的命令结束后回到空闲。服务器端每个会话线程的核心骨架就是这个状态机后面 3.2 节里的m_bWaitPass、m_nState都是为它服务的。2.2 数据通道主动模式与被动模式怎么选文件内容不走 21 端口而是由服务器另外开一条连接。主动模式下服务器主动去连接客户端报出来的 IP 和端口被动模式下服务器先开一个监听端口把地址告诉客户端由客户端发起连接。现代网络里客户端绝大多数躲在 NAT 后面主动模式经常连不进去所以客户端默认用 PASV而服务器端如果想兼容老式命令行 FTP两种模式都得保留。选型的依据不是哪个快而是谁更容易穿防火墙。主动模式的数据连接由服务器发起服务器要能出网但客户端防火墙通常不会拦被动模式的数据连接由客户端发起对客户端网络更友好但服务器需要放行一批高端口。经常见到的“连上了但列不出目录”现场就是因为服务器只实现了 PORT客户端却发 PASV两边各等各的直到超时。这里有个容易翻车的细节数据连接建立成功后命令连接还保持活着。传输结束后由服务器主动关掉数据连接控制连接继续复用。如果一次下载完成卡在那不动多半不是命令没回而是程序在等待某个永远不会来的关闭事件。FTP 协议里数据通道由服务器主动关闭是传输完成的标志写代码时一定要留这个位置不要等客户端去关。2.3 源码模块怎么划分先看文件再读代码我拿到工程后第一步不是打开主对话框而是把源码包的文件列一遍勾出哪些文件是干什么的。这份包大体可以分成四块模块常见文件职责监听与连接服务器监听类、连接管理类在 21 端口监听accept 后分发给会话线程会话处理会话类、命令映射类解析命令、维护登录状态、管理数据连接数据传输数据 socket 类、文件传输类建立数据连接、读写文件、控制传输模式客户端界面主对话框类、登录对话框类操作按钮、列表展示、进度显示按这个结构读下来整条链路就是客户端发命令 - 服务器会话线程解析 - 需要传输时打开数据连接 - 文件读写 - 响应码回给客户端。这种拆分的好处是调试时可以单测一块比如数据连接是通的但文件写不进去问题就缩小到文件传输类而不是全局乱查。这也是我推荐把这个包当网络通信源代码样例的原因结构比功能更重要。2.4 227 响应解析六个数字如何拼成可连接的地址被动模式下服务器把地址塞在 227 响应里227 Entering Passive Mode (192,168,1,20,200,1)。括号里六个数字前四个是 IP后两个是端口的高 8 位和低 8 位真实端口是200 * 256 1 51201。这个拼装逻辑在源码里通常就是一个几十行的函数但它决定了 PASV 流程的成败。// 简化版 227 响应解析完整工程里还要处理 IPv6 与异常分支 CString strReply _T(227 Entering Passive Mode (192,168,1,20,200,1)); int nPos strReply.Find(_T(()); if (nPos 0) return -1; CString strData strReply.Mid(nPos 1); strData strData.Left(strData.Find(_T()))); int ip1, ip2, ip3, ip4, p1, p2; if (_stscanf(strData, _T(%d,%d,%d,%d,%d,%d), ip1, ip2, ip3, ip4, p1, p2) ! 6) return -1; UINT nPort (UINT)(p1 * 256 p2); // 高字节左移八位逻辑说明先从响应文本里抠出括号内内容再用_stscanf一次读出六个整数端口由两个字节拼出来。Find与Mid是 MFC 字符串常用操作抗干扰性一般如果服务器返回227 这类扩展格式会解析失败但常见实现够用。参数说明p1 * 256 p2是最容易写错的地方把两个数字加起来当端口是常见错误结果往往是个特别小的端口。调试时可以把这个计算值打印出来对照服务器监听的端口确认一遍再继续往下走。这个习惯能让后面所有数据连接问题缩短一半排查时间。3. 服务器端落地多线程监听、用户校验与文件传输的实现服务器端是这个包最能体现“完整版”价值的部分。这里不谈抽象设计直接看工程里最常用的几条路径accept 后怎么处理、登录与权限怎么拦、RETR/STOR 怎么把文件和网络接起来。3.1 从监听 socket 到会话线程accept 之后必须做的三件事服务器端不能把第一个连接处理完再收下一个所以源码里常见做法是监听 socket 的 OnAccept 回调里 accept立刻给当前客户端创建一个专属线程由那个线程维护命令循环。三个必做动作把新 socket 句柄 detach 出来、构造会话对象、把句柄 attach 到会话对象上。如果漏了 detach临时对象的析构会关掉刚建立的连接。// 监听 socket 的 OnAccept 回调完整工程里这段在监听类中 void CListenSocket::OnAccept(int nErrorCode) { CSocket sockNew; if (!Accept(sockNew)) return; // 关键动作先 detach防止局部 socket 析构时关掉连接 SOCKET hClient sockNew.Detach(); CFtpSession* pSession new CFtpSession(hClient, m_config); if (pSession ! NULL) AfxBeginThread(SessionThreadProc, pSession); }逻辑说明Accept返回一个新的 socket但CSocket对象是栈上的临时对象函数结束时会把底层句柄也关掉所以必须用Detach把裸句柄摘出来再交给会话对象。AfxBeginThread启动线程后会话的命令循环在这个线程里执行监听 socket 与这个客户端互不干扰。参数说明hClient是系统分配的真实 socket 句柄m_config是全局配置对象所有会话共享这就埋了一个线程安全的大坑第 5 章会专门讲。每个会话独立线程的做法在桌面场景足够并发几十个连接时要注意线程创建开销但至少比单线程阻塞要清晰得多。3.2 用户校验与虚拟根目录三条规则定下安全边界FTP 服务器最容易出事的是目录穿越。用户把路径写成../../..如果服务器直接拼到系统路径上就能读到根目录外的文件。这份源码的登录模块通常维护一个虚拟根目录所有命令路径都拼在这个根目录下规则有三条。第一未登录状态只接受 USER、PASS、QUIT其他命令一律回530 Not logged in。第二登录后每个用户有自己的根目录命令里的路径要拼在根目录下。第三路径中的..要显式检查一旦拼出来的绝对路径越界直接拒绝。// 收到 USER 命令后的简化处理 void CFtpSession::OnUser(const CString strUser) { if (!m_bLoggedIn m_nState STATE_IDLE) { if (strUser.CompareNoCase(_T(anonymous)) 0) { m_strRootDir m_pConfig-GetPublicDir(); Reply(331, _T(Anonymous ok, send your email as password)); } else { m_strUser strUser; m_bWaitPass TRUE; Reply(331, _T(User name ok, need password)); } } else Reply(530, _T(Bad sequence)); }逻辑说明状态位m_nState控制命令是否乱序。FTP 客户端有时会快速连发命令如果服务器端不拦后面很容易出现“还没登录就 RETR”的错乱。m_bWaitPass表示正在等待密码此时再收到 USER 会被当成非法序列。参数说明m_strRootDir是每个会话的私有目录必须是成员变量而不能是全局变量否则两个用户会互相改对方的根目录。要支持匿名只读、实名可写还要加一个权限位比如m_bCanWrite在 STOR、DELE、MKD 这些命令分支里统一判断。3.3 传输模式TYPE I 与 TYPE A 的选择直接影响文件字节FTP 协议允许两种文件类型ASCIIA和二进制I。在系统默认本地代码页上ASCII 模式发送前会把换行符从\r\n转成\n接收时再转回来二进制模式完全不做转换。坑在于如果你用 ASCII 模式传一个 exe、图片或压缩包哪怕只有一个字节被“好心”转换整个文件就坏了。源码里通常在OnType命令里设置一个布尔变量后续 RETR/STOR 根据它决定要不要做字节转换。我一般建议服务器端收到TYPE A时照常回200但实际传输强行按二进制处理除非两端真的都是文本文件。这个默认策略在给别人提供接口时能救回不少“文件打不开”的抱怨。另外要区分传输模式和目录列表。LIST 命令返回的目录列表不受 TYPE 影响但它的编码和换行格式决定客户端能否正确解析这里牵扯到中文文件名细节放在第 5 章。3.4 RETR 与 STOR 的指令分发数据通道的一开一关下载和上传的流程是对称的收到 RETR/STOR 后先校验登录和文件权限再回 150建立数据连接传输完毕回 226最后关闭数据 socket。最容易被忽略的是异常分支文件打不开回 550数据连接建立失败回 425中途传输失败回 426而不是假装成功。// 处理 RETR 的核心代码块省去目录穿越检查 void CFtpSession::OnRetr(const CString strPath) { CString strFull BuildFullPath(strPath); CFile f; if (!f.Open(strFull, CFile::modeRead | CFile::shareDenyNone)) { Reply(550, _T(File not available)); return; } if (!m_dataSocket.CreateDataChannel(m_bPassive, m_pasvPort)) { Reply(425, _T(Cant open data connection)); f.Close(); return; } Reply(150, _T(File status ok; opening data connection)); ULONGLONG dwFileSize f.GetLength(); BOOL bRet TransferFile(f, m_bBinary, dwFileSize); Reply(bRet ? 226 : 426, bRet ? _T(Transfer complete) : _T(Transfer aborted)); m_dataSocket.Close(); }逻辑说明先回150再传文件是因为客户端收到 150 才进入等待接收状态顺序反了客户端还没准备好数据通道一开就丢包。TransferFile内部循环读文件、写数据 socket读多少写多少不要把整个文件一次性搬进内存。参数说明m_bPassive是会话级变量保存本次连接用的是 PASV 还是 PORTm_pasvPort是服务器为被动模式分配的端口号。CFile::shareDenyNone允许文件被其他进程同时读否则文件被占用时服务器会误报“文件不存在”。数据 socket 的关闭时机是传输完成的标志必须在回完226之后再关先关会导致客户端把成功误判成失败。4. 客户端落地MFC 界面线程与上传下载动作怎么串起来客户端这半部分界面操作与网络传输天然冲突。文件一大UI 就卡一卡用户就双击、就重复提交。第 4 章把线程模型、登录时序和断点续传讲清楚尤其适合那些以后要做桌面传输工具的人。4.1 界面不能卡传输必须交给工作线程MFC 对话框里的按钮事件如果直接做 socket 收发大文件传一次窗口就进入“未响应”状态鼠标转圈。原因很简单界面线程被阻塞在 Receive 调用里收不到 WM_PAINT。解决方法是把传输放到工作线程里线程每传一块就用PostMessage给主窗口发自定义消息主界面只负责更新进度条。// 客户端下载线程入口pParam 指向对话框对象 UINT DownloadThreadProc(LPVOID pParam) { CFtpClientDlg* pDlg (CFtpClientDlg*)pParam; CString strRemote pDlg-m_strRemoteFile; CString strLocal pDlg-m_strLocalFile; BOOL bOk pDlg-m_client.DownloadFile( strRemote, strLocal, pDlg-m_bResume, // 是否续传 pDlg-GetSafeHwnd()); // 用于向主窗口 PostMessage ::PostMessage(pDlg-GetSafeHwnd(), WM_USER_DONE, bOk, 0); return 0; }逻辑说明线程函数里不能直接操作对话框控件变量MFC 控件句柄所属线程是主线程跨线程调用会引发随机崩溃。这里的做法是只调网络层进度消息发回主窗口主窗口在消息处理里更新列表和进度条。m_bResume决定是否发送 REST 命令。参数说明GetSafeHwnd()返回的是 HWND 而不是 CWnd 指针目的是避免线程还没退出、对话框对象已被销毁造成悬垂指针。传 HWND 后即使对话框关闭PostMessage也只是静默失败不会崩。4.2 登录与列目录自实现客户端的两个细节如果客户端完全自己实现登录就是控制通道上的USER、PASS列目录则要解析 LIST 响应。两个细节最常出错。一是登录后服务器返回的欢迎横幅220 响应会在命令交互前先行到达客户端必须先把它读掉否则后续响应会对不上。二是 LIST 的响应到底以空行结尾还是以 226 响应结尾取决于服务器实现自写客户端最好两种都兼容。源码里比较稳的做法是列目录时先发TYPE A再用 PASV 或 PORT 开数据连接把 LIST 数据全部读完再去控制通道读 226。不要边收数据边读命令响应那样容易把头部留错导致下一次命令解析错乱。提示列目录返回的数据格式不是标准 JSON不同服务器有 Unix 风格和 Windows 风格解析文件名时建议从行尾往回找而不是按空格分隔。4.3 断点续传REST 命令和本地偏移量的三个参数断点续传不是客户端单方面重传而是服务器和客户端各记得一个偏移。下载续传时客户端先发REST offset服务器回 350然后发RETR file上传续传则用APPE file服务器直接把数据追加到文件末尾。实现时要传三个参数远程文件路径、本地文件路径、续传起始偏移。// 下载续传检查本地已有文件大小然后设置服务器端偏移 BOOL CFtpClient::DownloadWithResume(const CString strRemote, const CString strLocal, ULONGLONG nLocalSize) { CFileStatus st; if (!CFile::GetStatus(strLocal, st)) return FALSE; nLocalSize st.m_size; // 1. 读出本地已有大小 if (nLocalSize 0) { CString strCmd; strCmd.Format(_T(REST %I64u\r\n), nLocalSize); SendCommand(strCmd); // 2. 告诉服务器偏移 if (GetReplyCode() ! 350) return FALSE; } SendCommand(_T(RETR ) strRemote _T(\r\n)); // 3. 之后的 Receive 写入本地文件时以追加方式打开 return DownloadDataChannel(strLocal, TRUE); }逻辑说明DownloadDataChannel第二参数bAppend为 TRUE 时本地文件用追加方式打开否则会从头覆盖续传白做。服务器端也要配合收到 REST 后设置偏移量RETR 时跳过文件前面对应字节。参数说明CFileStatus::m_size是本地文件长度用它当续传起点有个前提远端文件没有变化。如果服务器端文件被整体替换过续传会拼出一个坏文件。可靠工具会先比较两端大小和修改时间但这份源码一般只做大小判断用来传日志归档问题不大。4.4 客户端核心代码下载文件的上层调用顺序把以上环节拼起来一个完整下载动作的调用顺序是连接控制通道、登录、发送 TYPE I、发送 PASV、发送 RETR、等 150、读数据通道、读 226。代码层面看起来是这样BOOL CFtpClient::DownloadFile(const CString strRemote, const CString strLocal, BOOL bResume, HWND hNotify) { if (!Connect(m_strServer, 21)) return FALSE; // 1. 控制连接 if (!Login(m_strUser, m_strPass)) return FALSE; // 2. 登录 SendCommand(_T(TYPE I\r\n)); // 3. 二进制模式 if (GetReplyCode() ! 200) return FALSE; if (bResume FileExists(strLocal)) return DownloadWithResume(strRemote, strLocal, m_nResumePos); SendCommand(_T(PASV\r\n)); // 4. 被动模式 if (ParsePortCommand(GetReplyText(), m_dataPort) 0) return FALSE; SendCommand(_T(RETR ) strRemote _T(\r\n)); if (GetReplyCode() ! 150) return FALSE; // 5. 必须 150 if (!ConnectDataChannel(m_dataPort, hNotify)) return FALSE; return GetReplyCode() 226; // 6. 结束标志 }逻辑说明细心的读者会发现 PASV 是在 RETR 之前发的顺序不能反。规范的服务器要求先确定数据模式再执行传输命令如果先发 RETR服务器可能按默认的 PORT 模式等待连接客户端再发 PASV 就会得到 500 语法错误。数据连接建立之后所有字节都在数据通道上走控制通道只剩 226 等着读。参数说明hNotify是给界面回传进度用的父窗口句柄网络层内部每传输满 64KB 调用一次::PostMessage。ParsePortCommand解析的是前面 2.4 节讲的 227 格式偏移计算错了整个下载连接就会指向错误地址。这个模块把 FTP 客户端最核心的时序全部固定下来值得多读两遍。5. 避坑实录调试这份源码时踩过的六个典型坑这份包本身能跑但放到真实网络环境里坑几乎一个都不会少。下面六条是我在自己工程和帮别人定位时遇过的按“现象 - 原因 - 解决”写每条都能对应到代码里的一个具体位置。5.1 坑一PASV 响应里的内网地址导致客户端连错地方现象服务器和客户端在同一台电脑上调试正常放到两台机器上客户端报连接失败抓包发现它连的是127.0.0.1的端口。原因源码在构造 227 响应时取了本机回环地址或者取的是多网卡中的第一块网卡地址这个地址既不是客户端可达的地址也不是数据 socket 实际监听的地址。解决227 响应里的 IP 应取数据 socket 本地绑定的地址用getsockname拿到的才是真正监听的那个 IP不要用gethostname再解析后者在多网卡机器上极易翻车。5.2 坑二ASCII 模式传输二进制文件文件字节被“修正”现象客户端传一个 zip目标端文件能打开但解压报错对比原始文件发现多处0x0A被替换成0x0D 0x0A。原因客户端默认 TYPE A服务器也按 ASCII 处理系统在发送时把换行符展开成 CRLF。对二进制文件而言任何原始字节0x0A都会被误转文件长度都变了。解决客户端在 RETR/STOR 前强制发TYPE I服务器端OnType里也要维护独立标志不要用默认值。我后来的习惯是服务器端除目录列表外一律按二进制传输客户端想传文本就自己处理换行。5.3 坑三中文文件名列出乱码且无法下载现象客户端列表显示成问号RETR 时服务器回 550。原因客户端用系统本地代码页发送命令服务器却按另一套编码解码LIST 返回的中文文件名用的又是服务器本地代码页两边对不上。解决统一走 UTF-8。客户端发送命令前把文件名从本地编码转成 UTF-8服务器端收到OPTS UTF8 ON后启用 UTF-8 路径解析命令解析前先把字节转成宽字符。这份源码如果初始写于较早年代多半没有这个开关改造时客户端和服务器两个类要同时改只改一边等于白改。5.4 坑四多个会话共享配置对象不加锁偶发崩溃现象两个客户端同时上传服务器日志偶发出现两行内容混在一起过一会儿进程直接退出。原因配置和日志对象是全局单例多个会话线程同时写同一个 CString 成员没有临界区保护。CString 的引用计数不是原子的两个线程同时赋值会发生内存释放两次。解决日志写入统一走静态方法方法内部用CCriticalSection的 Lock 和 Unlock 包住配置对象要么加锁要么每个会话创建一份副本。这个坑是网络服务器最常见的崩溃来源现象随机排查起来特别费时间我在一个项目里翻车两小时才定位。5.5 坑五CSocket 默认超时设置长任务被悄悄掐断现象下载一个几百兆文件传到一半连接断开服务器端日志显示连接已关闭客户端却还在傻等。原因CSocket 内部有阻塞超时在无数据阶段超过设定时间就自动断开源码里如果没重新设置长时间传输就会被中断。解决在网络层初始化时把收发超时调大比如SetSocketOption(SO_RCVTIMEO)和SO_SNDTIMEO设成 60 到 300 秒不要设 00 表示永不超时连接异常时线程再也收不回来。大文件传输还可以在循环里记录“最后收到字节的时间”超过阈值主动报错退出至少能稳定抛异常而不是挂死。5.6 坑六被动模式下高端口被系统防火墙拦截现象客户端连接控制端口正常USER 和 PASS 都通过但 PASV 之后数据连接一直建立不起来。原因系统防火墙只放行了 21 端口被动模式分配的高端口被拦。虽然数据连接是由客户端发起的但服务器端的入站策略没放行这些随机端口。解决把服务器被动端口限制在一个固定范围内比如 50000-50050然后在防火墙里放行这个段。改造后不仅安全调试时也能照着端口段去抓包不用再翻日志猜。6. 进阶把这份源码改造成可配置的 FTP 服务与断点续传工具箱到这一步源码能跑、坑也清楚剩下的就是把它用到自己的场景里。我通常做两件改造把账号和目录下沉到 ini 配置把被动端口范围固定下来。6.1 账号、根目录和被动端口写进 ini直接在代码里改账号密码每次部署都要重新编译非常痛苦。把可变化的部分抽到配置里服务器启动时读一次每个会话创建时按用户名取配置。一个最小配置长这样[Global] ListenPort21 PassivePortRange50000-50050 MaxConnections10 [User:admin] RootDirD:\FtpRoot Writable1 [User:anonymous] RootDirD:\FtpRoot\public Writable0服务器启动时用GetPrivateProfileString读ListenPort升迁为账号后读取对应用户的RootDir和Writable会话对象构造时传进去。这样新加账号不用动代码只改 ini 重启服务就生效。被动端口范围也从 ini 读生成数据端口时在范围内按顺序取避免随机端口每次不同导致防火墙规则失效。6.2 上传断点续传APPE 比 RESTSTOR 更稳下载续传用 REST 已经讲过。上传续传我一般用APPE而不是REST STOR因为不是所有服务器都支持 REST 后再 STOR 的偏移而 APPE 是协议里专门做追加的命令。客户端发APPE name.txt服务器直接把数据写到文件末尾本地端只需要把文件指针定位到已发送位置逐块读出来发过去即可。若要在服务器端实现就是收到 APPE 时用追加方式打开文件其余流程与 STOR 完全一致工作量和风险都小很多。从那以后我拿到任何网络通信源代码都会强制走一遍三查命令时序是否与服务器状态机一致、数据连接是否由正确的一方关闭、字符串编码是否统一。这次拆包也一样先把这三条过了再谈功能改造。希望帮到你。本文还有配套的精品资源点击获取