简介:这份资源面向学习网络编程与MFC框架的C++开发者,尤其是需要完成课程实验或想动手实践Socket通信的初学者。内容围绕基于TCP/IP的文件传输展开,客户端负责选择本地文件并发送,服务端负责监听连接、接收数据并写入本地文件,完整覆盖CSocket类的创建、连接、绑定、监听、收发与资源释放等关键环节,帮助读者理解网络通信原理与MFC封装机制。压缩包共63个文件,约109.68MB,包含cpp与h源码、vcxproj与sln工程文件、exe可执行程序,以及obj、pdb、tlog等编译中间产物和rc、ico等资源文件,工程结构完整,可直接编译运行。目前已有316人学习下载。通过研读源码,读者能掌握文件I/O与Socket结合的实现思路,理解客户端与服务端双工程的组织方式,并积累网络编程调试与排错经验,适合作为课程设计或自学练手的参考案例。
1. 从一次课堂实验说起:MFC 文件传输到底在练什么
很多人第一次接触 socket 网络编程,是在控制台里敲send和recv,跑通了却总觉得隔了一层——数据到底怎么从界面按钮走到网卡,又怎么落到对面磁盘上,心里没底。MFC 实现文件传输(客户端 + 服务端)这个题目,恰恰是把这层窗户纸捅破的练手项目:用 Windows 原生框架把 TCP 通信、文件读写、界面交互串成一条完整链路。它解决的不是"能不能传",而是"怎么把网络字节流可靠地变成磁盘上的文件",适合刚学完 socket API、想找一个能看见进度条、能手动断线重连的实战场景的在校生和转行工程师。热词里 socket 网络编程、客户端和服务端这些词,在这个项目里全都能落到具体代码行上,不是空概念。
2. 动手前先把 TCP 文件传输的骨架搭清楚
2.1 为什么选 TCP 而不是 UDP 传文件
文件传输的第一决策是传输层协议。UDP 无连接、不保证顺序、不重传,用它传文件意味着你得在应用层自己实现序号、确认、超时重传和乱序重组——这本身就是另一个大工程。TCP 把这些全包了,代价是延迟略高、有拥塞控制,但对文件这种"完整性优先于实时性"的场景,TCP 是默认答案。常见做法是服务端listen在一个固定端口,客户端connect上来后先发一个定长文件头(文件名长度、文件名、文件大小),再发文件体。这个"先头后体"的约定是整个项目的地基,后面所有进度计算、断点判断都依赖它。
2.2 最小可跑的通信骨架
先不碰 MFC 界面,用纯 Win32 socket 把收发跑通,确认链路没问题再往对话框里搬。下面这段是服务端接收的核心逻辑,客户端对称处理即可。
// 服务端:接收文件头 + 文件体 SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(9527); // 端口选 1024 以上,避开系统保留段 addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡 bind(listenSock, (sockaddr*)&addr, sizeof(addr)); listen(listenSock, SOMAXCONN); SOCKET clientSock = accept(listenSock, nullptr, nullptr); // 先收 8 字节文件大小,网络字节序转主机字节序 uint64_t fileSize = 0; recv(clientSock, (char*)&fileSize, sizeof(fileSize), 0); fileSize = ntohll(fileSize); // 再收 4 字节文件名长度,然后收文件名 uint32_t nameLen = 0; recv(clientSock, (char*)&nameLen, sizeof(nameLen), 0); nameLen = ntohl(nameLen); std::string fileName(nameLen, '\0'); recv(clientSock, fileName.data(), nameLen, 0); // 循环收文件体,直到收满 fileSize std::ofstream out(fileName, std::ios::binary); char buf[8192]; uint64_t received = 0; while (received < fileSize) { int n = recv(clientSock, buf, sizeof(buf), 0); if (n <= 0) break; // 0 表示对端关闭,负数表示出错 out.write(buf, n); received += n; }逻辑说明:recv不保证一次收满你想要的长度,所以文件体必须用循环累加,直到received == fileSize。参数上,缓冲区 8192 是经验值,太小系统调用频繁,太大栈上分配有风险;端口 9527 只是示例,实际选一个没被占用的即可。ntohll不是标准库函数,Windows 下需要自己用ntohl拼高低位,或者直接用htonll的对称实现,这是新手第一个容易翻车的点。
2.3 把 socket 逻辑搬进 MFC 对话框
MFC 里网络操作不能放在 UI 线程,否则recv阻塞时界面直接假死。标准做法是开一个工作线程跑收发,通过PostMessage把进度回传给对话框更新进度条。下面是在对话框类里启动线程的骨架。
// 对话框头文件里声明线程函数和消息 static UINT RecvThread(LPVOID pParam); // 线程入口必须是静态或全局 #define WM_UPDATE_PROGRESS (WM_USER + 100) // 点击"开始接收"按钮 void CFileTransferDlg::OnBnClickedRecv() { m_bRunning = TRUE; AfxBeginThread(RecvThread, this); // this 传进去,线程里能拿到对话框指针 } UINT CFileTransferDlg::RecvThread(LPVOID pParam) { CFileTransferDlg* pDlg = (CFileTransferDlg*)pParam; // ... 上面那套 accept/recv 逻辑 ... // 每收一块就通知界面 pDlg->PostMessage(WM_UPDATE_PROGRESS, (WPARAM)percent, 0); return 0; }逻辑说明:AfxBeginThread是 MFC 封装的工作线程创建接口,比裸CreateThread更安全,因为它会正确处理 MFC 的线程状态。PostMessage是异步投递,不会阻塞工作线程,界面在消息映射里处理WM_UPDATE_PROGRESS更新进度条即可。参数上,WM_USER + 100是自定义消息的惯用起点,避免和系统消息冲突。注意线程里绝对不能直接调用SetDlgItemText之类的 UI 函数,跨线程操作控件是 MFC 的经典崩溃来源。
3. 文件头协议设计与粘包处理
3.1 定长头 + 变长体的协议格式
TCP 是字节流,没有消息边界。如果客户端连发两次send,服务端可能一次recv全收到,这就是粘包。解决办法是让接收方知道"要收多少"。本项目的协议设计成:固定 8 字节文件大小 + 4 字节文件名长度 + 变长文件名 + 变长文件体。接收方先读满 12 字节定长头,解析出文件名长度和文件大小,再按长度精确读取后续内容。这样无论 TCP 怎么合并或拆分,接收逻辑都不会错。
| 字段 | 长度 | 说明 |
|---|---|---|
| 文件大小 | 8 字节 | 网络字节序,uint64 |
| 文件名长度 | 4 字节 | 网络字节序,uint32 |
| 文件名 | 变长 | UTF-8 编码,不含路径 |
| 文件体 | 变长 | 二进制原始字节 |
3.2 封装一个可靠的 recvAll 函数
裸recv只保证收到"至少 1 字节",要收满指定长度必须自己循环。把这件事封装成一个函数,后面所有读取都调它,能省掉大量重复判断。
// 收满 len 字节才返回,返回实际收到的字节数 int recvAll(SOCKET s, char* buf, int len) { int total = 0; while (total < len) { int n = recv(s, buf + total, len - total, 0); if (n <= 0) return n; // 对端关闭或出错,直接返回 total += n; } return total; }逻辑说明:这个函数是文件传输可靠性的核心。参数len是期望收满的长度,返回值等于len表示成功,小于len说明连接中断。有了它,读文件头就是recvAll(s, header, 12),读文件名就是recvAll(s, nameBuf, nameLen),读文件体就是循环调recvAll每次收一块。注意recv返回 0 表示对端正常关闭,返回SOCKET_ERROR才是出错,两者要区分处理,否则断线时日志会误导你。
3.3 发送端的分块与进度计算
发送端相对简单,但进度计算有个坑:send的返回值是"本次实际发出的字节数",可能小于你请求发送的长度,所以发送也要循环。
// 发送文件体并更新进度 uint64_t sent = 0; while (sent < fileSize) { int chunk = (int)min((uint64_t)8192, fileSize - sent); int n = send(clientSock, buf + sent, chunk, 0); if (n <= 0) break; sent += n; int percent = (int)(sent * 100 / fileSize); pDlg->PostMessage(WM_UPDATE_PROGRESS, percent, 0); }逻辑说明:min保证最后一块不会越界读取。进度用sent * 100 / fileSize计算,注意先乘后除避免整数除法丢精度。参数 8192 和接收端保持一致,方便对照调试。如果文件很大(超过 4GB),sent * 100可能溢出 32 位,要强制转成uint64_t再算。
4. 避坑与排查:那些让实验卡住的真实问题
4.1 现象:客户端显示发送完成,服务端文件却少一截
原因:发送端send返回后数据只是进了内核发送缓冲区,不代表对端已收到。如果发送完立刻closesocket,缓冲区里没发完的数据会被丢弃。解决:发送完成后调用shutdown(s, SD_SEND)通知对端"我没数据了",然后等对端关闭或自己recv到 0 再closesocket。这个顺序是血泪经验,少了shutdown大文件必丢尾巴。
4.2 现象:界面进度条卡住不动,点关闭直接无响应
原因:网络操作写在了 UI 线程,recv阻塞时消息循环停转。解决:所有阻塞 socket 调用放进AfxBeginThread开的工作线程,UI 线程只负责响应消息和刷新控件。如果非要在 UI 线程用 socket,就设成非阻塞模式配合WSAAsyncSelect,但那样代码复杂度翻倍,实验项目没必要。
4.3 现象:中文文件名传过去变成乱码
原因:MFC 默认用CString的TCHAR,在 Unicode 工程里是宽字符,直接send宽字符缓冲区,对端按单字节解析必然乱码。解决:发送前统一转成 UTF-8。用WideCharToMultiByte(CP_UTF8, ...)转换,接收端再用MultiByteToWideChar(CP_UTF8, ...)转回来显示。协议里约定死编码,别一边 GBK 一边 UTF-8。
4.4 现象:本机测试正常,两台机器连不上
原因:Windows 防火墙默认拦截入站连接,或者服务端bind用了127.0.0.1只监听回环。解决:bind时地址用INADDR_ANY,首次运行服务端时在防火墙弹窗里允许专用网络访问。如果还不行,用netstat -ano | findstr 9527确认端口确实在监听,再看客户端connect的返回值,WSAECONNREFUSED说明对面没监听,WSAETIMEDOUT多半是防火墙。
4.5 现象:传大文件时内存暴涨
原因:一次性new出整个文件大小的缓冲区再收发。解决:固定 8KB 或 64KB 缓冲区循环读写,内存占用与文件大小无关。这个习惯在实验阶段就要养成,否则以后做视频传输会吃大亏。
5. 进阶技巧:断点续传与传输校验
5.1 用文件偏移实现断点续传
基础版传完就结束,但真实场景经常断线。断点续传的思路是:接收端先检查目标文件是否已存在,存在就取当前大小作为偏移量,把这个偏移发给发送端,发送端seek到对应位置继续发。协议头里加一个 8 字节的"起始偏移"字段即可。
// 接收端:检查已存在文件,算出偏移 uint64_t offset = 0; std::ifstream exist(fileName, std::ios::binary | std::ios::ate); if (exist.is_open()) { offset = exist.tellg(); // ate 模式打开,tellg 直接给文件大小 exist.close(); } // 把 offset 发给发送端,发送端从该位置继续 uint64_t netOffset = htonll(offset); send(clientSock, (char*)&netOffset, sizeof(netOffset), 0);逻辑说明:std::ios::ate让文件指针初始就在末尾,tellg返回的就是文件大小,比先seekg(0, end)再tellg简洁。发送端收到偏移后seekg(offset)再开始读。注意续传的前提是文件内容没被改动过,实验里够用,生产环境还得加 MD5 校验。
5.2 传输完成后做一次哈希校验
进度条到 100% 不代表文件正确,网络抖动、磁盘写满都可能导致内容损坏。最省事的校验是在发送端算好整个文件的 MD5,放在协议头里一起发,接收端收完后本地也算一遍对比。
// 接收端收完后校验 std::string localHash = CalcMD5(fileName); // 自己实现的 MD5 封装 if (localHash != expectedHash) { AfxMessageBox(_T("文件校验失败,请重传")); // 删除损坏文件,避免下次续传基于错误数据 DeleteFile(CString(fileName.c_str())); }逻辑说明:MD5 计算可以边收边算,不必等文件落盘后再读一遍,省一次 IO。参数上,校验失败一定要删掉半成品文件,否则下次续传会基于损坏数据继续,越传越错。这个"后悔药"机制在实验报告里也是加分项。
5.3 几个值得固化的习惯
第一,所有 socket 操作检查返回值,SOCKET_ERROR时用WSAGetLastError()打日志,别让程序静默失败。第二,WSAStartup和WSACleanup成对出现,放在InitInstance和ExitInstance里。第三,端口、缓冲区大小、超时时间这些魔法数字抽成常量或配置项,调参时不用满代码找。第四,测试顺序永远是先本机回环、再局域网两台机、最后跨网段,每步确认再往下走,能省掉大量"到底是代码问题还是网络问题"的纠结。
我自己带实验时最常看到的一幕,是学生盯着进度条 100% 却打不开文件,最后发现是忘了shutdown导致尾部数据丢失。这个坑我当年也踩过,后来养成的习惯是:只要涉及"发完就关",先想清楚对端怎么知道发完了。希望帮到你。
本文还有配套的精品资源,点击获取