简介:一套基于Visual C++的大文件传输与即时通信示例源码,面向学习Windows网络编程、MFC开发或进行相关课程设计/毕业设计的开发者。工程同时提供client1与server1双端,演示了多线程同时传输多个文件、断点续传、传输过程中打开聊天窗口实时收发消息等核心场景。资源共76个文件,包含29个头文件、27个C++源文件以及图标、位图、光标、工作区等工程资源,压缩包仅85KB,结构清晰,客户端与服务端目录分离,便于直接打开编译学习。项目使用Winsock自定义传输协议,涵盖文件分块、进度记录、套接字连接管理、多线程协调等关键实现点,可完整对应文件传输、断点续传、及时通信三大需求;同时是学习MFC文档视图结构、套接字编程、多线程同步与文件操作的综合范例,断点续传的进度保存与恢复逻辑尤其值得研读。已有251人学习,对想快速上手VC++网络通信和文件传输系统搭建的读者具有直接借鉴价值。
1. 一个 VC++ 工具背后的硬骨头:大文件传输、断点续传和传输中聊天
在网上搜“visual c++ vc在电脑间传送大文件”,大概率会下到这样的压缩包:一个 exe、一份说明 txt,运气好的话还有整套源码。真把这个工具放进内网环境里用,你会发现它表面上是个文件传输器,骨子里却卡着三个硬点:多文件并发不把线程打满、断点续传不把文件写坏、文件传着的时候聊天消息不能卡在半路。这三个点恰好把 Winsock、多线程、文件 IO 和窗口消息全串进一个进程里,也正是这类工具最值得拆开看的部位。
需要它的场景非常具体:办公室同一网段里,同事要拿走你手上一份 4GB 的工程素材,不想动 U 盘,也不想临时搭 FTP;或者运维要在几十台 Windows 机器上分发安装包,对方机器没有公网访问权限。这类工具的价值不在于“传输速度比 FTP 快”,而在于它把“发文件”和“传文件时顺便说句话”合并成一次双击就能完成的动作。适合读这篇文章的人有两类:一类是把现成工具当效率工具用的使用者,另一类是想知道用 VC++ 怎么写“文件传输 + IM”的桌面工程师。
2. 传输架构先立住:Winsock 上把文件流和消息流分开
2.1 为什么 Windows 共享不能满足这类传输工具
先回答一个绕不开的问题:Windows 自带共享和“发送到”功能,为什么还要自己写一个?在同一个工作组、同一网段下,SMB 共享确实能用,但换到跨 VLAN、无域环境、对方机器没开共享目录、或者 445 端口被安全策略封掉的场景,SMB 的一次连通成本往往比写代码还高。自定义 TCP 端口的传输工具把这些外部依赖全部干掉,只要 Windows 防火墙放行一个端口,两端就能直连。
真正让 SMB 不合适的是断点续传。共享拷贝从设计上就没有“文件传输进度”这个概念,网络断开后只能从头再来。对于一个 10GB 的文件,失败重传的代价远超写一个小工具的代价。自己做传输层的收益就在这里:对传输过程有完整控制权,从偏移量到校验值都由自己定义。所以这类工具通常选择 Winsock 的 TCP 流式套接字,保证字节顺序和可靠性,再把“断点续传、多文件并行、聊天”作为应用层能力加在上面。
2.2 双通道设计:一条连接管聊天,一条连接管文件
刚开始写这类工具,最容易犯的错是把所有数据塞进同一条 TCP 连接。聊天消息只有几十字节,文件数据包却有 64KB 甚至更大。当大文件正在满带宽传输时,聊天消息会排在文件包后面,表现出来就是“对方回一句话要等好几秒”。常见的工程做法是拆成两条通道:
- 控制/消息通道:只承载聊天文本、心跳、文件控制信令,数据量小,要求低延迟。
- 数据通道:只跑文件内容,允许大包、允许占用带宽。
这两条通道可以同时建立,各自维护独立的 socket 和收发线程。控制通道还需要关闭 Nagle 算法,让几十字节的小消息不攒包、立即发出:
int nodelay = 1; setsockopt(hControlSocket, IPPROTO_TCP, TCP_NODELAY, (const char*)&nodelay, sizeof(nodelay));这段代码的意义在于,TCP 默认的 Nagle 算法会把短时间内多个小包合并后再发,对于聊天场景这种“发一条、等一次回”的交互,合并策略会引入肉眼可见的延迟。关闭之后,每条聊天消息都会立刻被推入网络。对文件通道则不必关闭,保持 Nagle 反而能减少小包数量,提升吞吐。
2.3 消息帧格式:给 TCP 流画出边界
TCP 是流协议,recv 到的数据没有天然边界。你需要自定义一个帧头,告诉接收方“这条消息是什么类型、多少字节”。这类 VC++ 工程的帧头通常长这样:
#pragma pack(push, 1) typedef struct _VC_FRAME_HEAD { DWORD dwMagic; // 固定为 'V','C','F','M' WORD wType; // 1=聊天文本 2=文件头 3=文件数据 // 4=续传请求 5=心跳 9=文件完成 DWORD dwSeq; // 帧序号,重传和乱序排查用 DWORD dwCrc32; // 对载荷做 CRC32,损坏重传依据 ULONGLONG ullLen; // 载荷字节数 } VC_FRAME_HEAD, *PVC_FRAME_HEAD; #pragma pack(pop)这里#pragma pack(push, 1)是必要的。如果不指定单字节对齐,结构体会在 WORD 和 ULONGLONG 之间插入填充字节,sizeof(VC_FRAME_HEAD)会大于 21 字节,收发双方只要有一边编译环境不同,帧头就会错位。这也是很多“两台机器传文件总是断”的隐藏原因之一:发送端在 VC6.0 下编译,接收端用 VS2019 重新编译,结构体对齐方式不一致。
接收端需要一个累积缓冲区,读一次 socket 就 append 一次,再按帧头长度切包,代码骨架如下:
std::vector<char> g_buf; // 每轮 recv 后把数据追加进 g_buf,然后循环拆包 while (g_buf.size() >= sizeof(VC_FRAME_HEAD)) { VC_FRAME_HEAD* pHead = (VC_FRAME_HEAD*)g_buf.data(); if (pHead->dwMagic != 0x4D464356) { // 'V','C','F','M' 按小端读成整数 g_buf.erase(g_buf.begin()); // 数据错位,丢一个字节重新对齐 continue; } if (g_buf.size() < sizeof(VC_FRAME_HEAD) + pHead->ullLen) break; // 整个帧还没收完,等下一轮 DispatchFrame(pHead, g_buf.data() + sizeof(VC_FRAME_HEAD)); g_buf.erase(g_buf.begin(), g_buf.begin() + sizeof(VC_FRAME_HEAD) + pHead->ullLen); }拆包逻辑的关键是:先凑够帧头长度,校验 magic;再判断累积数据是否已经有 ullLen 字节的载荷;够则回调处理函数,然后从缓冲区头部删掉已经消费的部分。magic 不匹配时只丢一个字节而不是清空缓冲区,能容忍偶发的半包错位,不至于因为一个坏包中断整个连接。
3. 同时传多个文件与断点续传:任务队列与字节偏移
3.1 多文件并发:不要一个文件一个线程
标题里的“同时多文件传输”很容易被实现成“选中十个文件就开十个线程”。线程不是问题,问题在于文件 IO 和 socket 发送共享的是同一块磁盘和同一张网卡。十个线程同时读一个大机械盘的不同文件,磁头会在文件之间来回跳,吞吐反而低于两个线程顺序读。常见的做法是维护一个发送任务队列,固定开 2 到 4 个工作线程,线程数参照本机 CPU 核心数取上限。
struct VCFILE_TASK { wstring wsSrcPath; // 源文件完整路径 wstring wsDstName; // 对方保存的文件名 ULONGLONG ullSize; // 文件总字节数 ULONGLONG ullOffset; // 本次传输起始偏移,续传时不从 0 开始 }; // 工作线程:循环从队列取任务,取不到就阻塞等待 DWORD WINAPI WorkerThread(LPVOID pParam) { VCFILE_TASK task; while (DequeueTask(task)) { // 队列空且收到退出通知时返回 FALSE SendOneFile(task); } return 0; } // 主窗口点“发送”后:往队列塞任务,然后开 2~4 个线程 g_queue.Push(task); CreateThread(NULL, 0, WorkerThread, &g_queue, 0, NULL);对于 1Gbps 内网,两个并发文件线程通常就能跑满带宽。线程数可以这样取:min(4, 环境变量NUMBER_OF_PROCESSORS)。这样既能并行传多个文件,又不会因为线程太多吃掉 CPU 时间片。注意队列要加锁,VC6.0 下用临界区,VS2010 之后可以用std::mutex + std::condition_variable。
3.2 断点续传的元数据:接收端才是权威
断点续传的本质,是双方对“已经传到哪里”达成一致。这个“哪里”不能只存在内存里,否则进程一退就丢。发送端和接收端各自持有一部分信息,但最终权威是接收端:它才知道数据真正落盘了多少字节。所以接收端要为每个未完成文件维护一个伴生的元数据文件。表格里的字段基本是标配:
| 字段 | 含义 | 用途 |
|---|---|---|
| fileName | 原始文件名 | 传输完成后把 .part 改名成它 |
| totalSize | 文件总字节数 | 进度百分比计算和完成判断 |
| receivedSize | 已写入 .part 的字节数 | 续传时告诉发送端从哪继续 |
| blockSize | 分块字节数 | 续传时的偏移对齐依据 |
| lastWriteTime | 元数据最后刷新时间 | 崩溃后判断 meta 是否陈旧 |
接收端的实际文件写成<原文件名>.vcpart,元数据写成<原文件名>.vcinfo。传输过程中每收完一个数据块,就把 receivedSize 刷进 vcinfo。这个文件虽然会有写入开销,但换来的收益是:传输进程被强杀、断网、电脑断电,下次启动扫描到 vcinfo 就能弹窗询问“是否续传未完成的文件”。
3.3 发送端与接收端的最小续传逻辑
续传时,接收端读 vcinfo 里的 receivedSize,把它装进续传请求帧发给发送端。发送端拿到偏移后打开文件、跳过已传部分、继续读取。核心代码在发送端如下:
FILE* fp = _wfopen(task.wsSrcPath.c_str(), L"rb"); _fseeki64(fp, task.ullOffset, SEEK_SET); // 跳到断点偏移 char buf[64 * 1024]; size_t n; while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) { SendFrame(3, buf, (DWORD)n); // 3 = 文件数据帧 task.ullOffset += n; UpdateMetaFile(task); // 边发边把进度写进 vcinfo } SendFrame(9, NULL, 0); // 9 = 文件完成帧 fclose(fp);对应接收端,收到文件数据帧后直接追加写:
FILE* fp = _wfopen(wsPartPath.c_str(), L"ab"); // append binary // 协议循环里,收到 wType==3 的帧: fwrite(pPayload, 1, (size_t)pHead->ullLen, fp); g_receivedSize += pHead->ullLen; // 收到 wType==9 的帧:关闭文件,改名,删掉 vcinfo fclose(fp); MoveFile(wsPartPath.c_str(), wsFinalPath.c_str());这里用“追加写”模式是刻意的:从 offset 续传以后,发送端发来的是完整的剩余数据流,ab 模式保证新数据永远接在旧数据后面,不会覆盖已落盘的部分。续传请求里带上的是接收端本地 .part 的实际字节数,而不是 vcinfo 里记录的数字,以防 vcinfo 没来得及刷新导致记录偏旧。
3.4 续传时的对齐与文件变更处理
断点续传最容易踩的坑是“续传后的文件哈希对不上”。常见原因是发送端和接收端对“偏移”的理解不一致。稳妥的做法是数据帧按固定块大小对齐,比如每帧 64KB,续传时把 offset 对齐到 64KB 边界重新发送,接收端本地 .part 的实际大小也向块边界对齐处理。这样即使 vcinfo 里丢了几帧记录,双方也能靠文件实际大小统一到同一个位置。
另一个必须处理的场景是源文件在传输过程中被修改。断点续传前,接收端把 vcinfo 里的 totalSize 和源文件元数据做一次比对,发送端在续传请求响应中回传源文件的最后修改时间。如果发现源文件大小或修改时间变了,直接废弃旧 .part,从头重传,而不是继续在损坏的基础上拼接。判断逻辑一句话可以概括:续传是对“同一个文件”的缓存,不是对不同版本文件的拼接。
4. 传输过程中聊天:消息路由、在线状态与 UI 刷新
4.1 聊天消息和文件帧怎么区分
控制通道和数据通道分离之后,聊天消息在控制通道里走,文件内容在数据通道里走,它们之间本来就不该混在同一条流里。但聊天、心跳、文件控制信令这三者会共享控制通道,所以仍然需要一个分发函数:
// 收到完整控制帧后,按 wType 路由 switch (pHead->wType) { case 1: { // 聊天文本 // 载荷是 UTF-8 编码的文本,这里转成宽字符 wchar_t* wMsg = new wchar_t[pHead->ullLen + 1]; MultiByteToWideChar(CP_UTF8, 0, pPayload, -1, wMsg, (int)pHead->ullLen); wMsg[pHead->ullLen] = L'\0'; ::PostMessage(g_hMainWnd, WM_APP + 101, (WPARAM)wMsg, 0); break; } case 5: // 心跳,更新在线列表时间戳 UpdatePeerAlive(pHead->dwSeq); break; case 4: // 续传请求,回复文件头并跳转偏移 ResumeFromOffset(payload); break; }聊天消息使用 UTF-8 编码而不是本地代码页,原因是两台机器的系统区域设置可能不一致,GBK 发送、繁体系统接收会乱码。UTF-8 在消息帧里带上显式长度,也就绕开了字符串截断问题。帧头发送方的dwSeq在这里还有一重用途:聊天消息按序号显示,接收端如果发现序号跳变,说明有消息在传输中丢失,可以提示“历史消息可能缺失”,这在弱网环境下比无声无息地丢消息体面得多。
4.2 在线用户列表:用广播发现,用心跳保活
“传送过程中可聊天”的前提是两端互相认识。对于这种内网小工具,常见的做法是启动时往子网广播地址发一个 UDP“我在线”报文,其他机器收到后回一个单播“我也在”。之后每 30 秒互发一次 TCP 心跳,超过 3 个周期没有收到心跳就判定对方掉线,把它从在线列表里置灰。
UDP 广播的代码很简短:
SOCKET s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); BOOL bBroadcast = TRUE; setsockopt(s, SOL_SOCKET, SO_BROADCAST, (const char*)&bBroadcast, sizeof(bBroadcast)); sockaddr_in addr = {0}; addr.sin_family = AF_INET; addr.sin_port = htons(8989); addr.sin_addr.s_addr = htonl(INADDR_BROADCAST); // 255.255.255.255 sendto(s, "VC_FILE|HELLO", 13, 0, (sockaddr*)&addr, sizeof(addr));在实际内网环境里路由器可能隔离广播域,跨网段的机器发现不了彼此。此时在 UI 里保留“手动输入 IP 连接”的入口是必要兜底,运营同学在弱隔离网络里分发工具时,手动添加对方地址的效率往往比依赖广播高。UDP 广播发现适合的是同一二层网络内的临时组网,不适合当作唯一的连接方式。
4.3 UI 刷新:工作线程绝不能直接碰窗口控件
聊天消息到达时,接收线程正处于 recv 阻塞中,但 MFC 或 Win32 窗口的编辑框、列表控件都属于 UI 线程,跨线程直接调用SetWindowText或CListCtrl::InsertItem会引发随机崩溃。常见做法是像上面代码那样,用PostMessage把消息数据投递到 UI 线程的消息队列里,UI 线程在处理 WM_APP+101 时再真正刷界面。
这里有一个容易被忽略的细节:用new wchar_t[]分配的消息内存,UI 线程处理完必须delete[],否则内存只增不减。控制通道同时承载心跳和聊天,高频时一分钟几十条消息,泄漏量虽小但运行数小时也能看出来。另一个工程细节是自定义消息号使用WM_APP + n而不是WM_USER + n,因为 MFC 控件内部消息占用 WM_USER 附近的区间,冲突时调试起来极其痛苦。
5. 发布到别的电脑:Visual C++ Redistributable、静态链接与完整性校验
5.1 运行库缺失与 Redistributable 的对应关系
用 VC++ 写的工具,最常遇到的发布问题是目标机弹窗“缺少 MSVCP140.dll”或“无法启动此程序,因为计算机中丢失 VCRUNTIME140.dll”。这是因为开发机上的动态运行库没有随 exe 一起发布。Visual C++ Redistributable 就是这些 DLL 的官方安装包,不同编译器版本对应不同的包:
| 编译器/Runtime 版本 | 典型依赖 DLL | 对应 Redistributable |
|---|---|---|
| VC++ 6.0 | msvcrt.dll(系统自带) | 通常无需额外安装 |
| VS2005 / VC++ 8.0 | msvcp80.dll, msvcr80.dll | Visual C++ 2005 Redistributable |
| VS2008 / VC++ 9.0 | msvcp90.dll, msvcr90.dll | Visual C++ 2008 Redistributable |
| VS2010 / VC++ 10.0 | msvcp100.dll, msvcr100.dll | Visual C++ 2010 Redistributable |
| VS2015 及以上 | vcruntime140.dll, msvcp140.dll | Visual C++ 2015-2022 Redistributable |
安装日志里出现“已检测到匹配的 Visual C++ Redistributable”是正常提示,表示目标机已经存在相同或更高的版本,安装程序直接跳过,不是失败。反复安装失败时可以检查是否残留了损坏的旧版本,卸载后重装一遍能解决多数问题。公司内部批量部署时,静默安装命令为/install /quiet /norestart,和 install shield 的参数保持了一致,但别把它当成唯一的万能方案。
5.2 用 /MT 静态链接把运行库打进 exe
如果不想到每台机器上装运行库,直接改用静态链接更省事。工程配置里右键项目属性,找 C/C++ -> 代码生成 -> 运行库,把“多线程 DLL (/MD)”改成“多线程 (/MT)”。命令行编译时对应写法是:
cl /O2 /MT /EHsc main.cpp net.cpp ws2_32.lib user32.lib/MT 会把 CRT 静态链接进 exe,产物不依赖 msvcp140.dll 这类动态库,目标机哪怕没有任何运行库也能直接跑。代价是 exe 体积增加 1~2MB,且有极少数 Windows API 若从 CRT 导出的行为会变,需要在发布前到无运行库的干净虚拟机里做一次冒烟测试。VC6.0 时代的工具默认就是静态 CRT,反而没有这类烦恼,这也是老版工具常被评价为“好用的绿色版”的原因之一。
5.3 传输完整性校验与静默核对技巧
内网传输大文件,TCP 本身的校验保证的是数据在传输途中不变,但不能保证源文件在读写时没坏。文件传完后的完整性校验不能省。用 hash 工具两边比对一下是最直接的做法:
certutil -hashfile "C:\Received\project.zip" MD5 powershell Get-FileHash "C:\Received\project.zip" -Algorithm SHA256进阶做法是把 hash 计算集成到工具内部:发送端在“文件完成帧”之后附上源文件的 SHA256 摘要,接收端收到后计算本地 .part 文件的哈希,不一致就自动发重传请求并保留现场文件备查。这样“断点续传完成后校验”就不再依赖人工对比,用户看到的状态栏直接显示“校验通过”或“校验失败”。
本文还有配套的精品资源,点击获取