基于VC++和MFC的局域网聊天与文件传输工具实战
2026/9/7 11:13:42 网站建设 项目流程

简介:面向VC++/MFC开发者,这是一份基于CSocket实现双向聊天与文件传输的完整示例工程,适合在集群通信或局域网通信前做基础通讯验证。资源包含服务端与客户端两个独立项目,通过Mysocket类封装套接字核心逻辑,演示了信息收发、大型文件的分包发送与接收端重组,并重点解决打包、拆包过程中的数据完整性与内存泄露隐患,所有工程在VS2019下编译运行通过。资源共39个文件,以.h头文件、.cpp源文件、.sln/.vcxproj工程文件和.rc资源文件为主,并附有说明文档,压缩包仅262KB,体量轻但结构完整。已有1320人学习,适合需要快速理解MFC下CSocket编程套路的开发者。读者可直接打开工程对照学习套接字初始化、连接建立、收发线程、文件流读写以及数据封包/解包的实现细节,也可基于这份代码快速搭建自己的局域网聊天与文件传输原型,为后续集群功能开发提供可复用的通讯底座。 先说一个可能有点反共识的结论:在2024年这个时间点,我仍然觉得用VC++和MFC写桌面级socket通讯工具是一件很划算的事。别急着反驳,我手上这个项目是典型的“既有界面又要网络”的Windows工具类需求——一个供内部使用的即时聊天与文件传输程序,需要在VS2019环境下编译运行。我试想过好几个替代方案,最后兜兜转转还是回到了MFC加原生socket的路子上。原因后面细说,但如果你经常要跟这类需求打交道——比如工控上位机、局域网小工具、课程设计——那这篇实战记录大概率对你有用。

1. 这个项目为什么选MFC + 原生socket,我的选型逻辑

1.1 先说清楚:什么样的场景才适合这个组合

MFC是个被骂了很多年的框架,但它不是没有生存空间。我做这个项目时,需求方给了三句话:要能在局域网里用,要能一对一聊天和发文件,要打包出来体积小、部署简单。没有Web端要求,没有跨平台要求,没有高并发要求。

这就是典型的Windows桌面工具场景。在这个场景下,C#用WinForms或WPF能做得更舒服,Qt也能做得不错,但存在两个现实问题:

  • 目标机器很可能没装对应版本的.NET运行时,或者IT策略不允许随便装运行时。
  • Qt的部署链相对长,而且很多人手上的Qt版本和VS版本匹配起来会踩一堆编译坑。

而VC++编译出来的MFC程序,选“静态链接MFC”和“静态链接运行时库”后,单个exe直接拷到目标机器就能跑,不需要任何额外环境。这是纯C++的原生优势,而MFC在VS2019里依然被维护,用来写聊天窗口这类界面绰绰有余。

1.2 为什么不用CAsyncSocket,非要自己梳理socket逻辑

MFC自己封装了两套网络类:CAsyncSocket和CSocket。我见过不少教程让你直接用CAsyncSocket,但我的实际感受是——它在VS2019里用起来反而别扭。CAsyncSocket的消息驱动模型依赖窗口消息,什么时候收数据、什么时候可写,都靠消息通知,逻辑稍微复杂一点就散落在各个消息响应函数里,调试起来很费劲。

我这次用的是最朴素的方案:WSAStartup初始化,socket()创建,bind()、listen()、accept()做服务端,connect()做客户端,然后专门开两个线程分别处理接收和发送。这样做的理由很直接:

  1. 原生socket是Windows网络编程的地基,出了问题我能自己控制每一层。
  2. 聊天和文件传输本质上都是“数据流”,我需要完全掌控收发边界,CAsyncSocket反而多了一层我不想要的消息转发。
  3. 原生socket的调试经验可以平滑迁移到其它语言场景,比如Python的socket、C#的Socket,遇到问题我能一眼看出对应关系。

1.3 整体架构是怎么设计的

聊天和文件传输,我没有拆成两个独立程序,而是放在同一个exe里,用一个简单的控制消息来区分当前这条数据是“文本聊天内容”还是“文件传输内容”。程序分为两种角色:

  • 服务端:启动后监听某个固定端口,等待客户端连接,可以同时处理连接建立和后续收发。
  • 客户端:填入服务器IP和端口,发起连接,连接成功后进入同一个聊天与文件传输界面。

服务端和客户端在代码上共用了同一个对话框界面,只在初始化逻辑上有所不同。这样设计的好处是开发和测试都方便——我开两个程序实例,一个选“服务端”,一个选“客户端”,本机就能完成全部联调。

2. 搭建VS2019下的MFC工程,先处理掉几个恼人的环境问题

2.1 工程创建和基础配置

新建项目时选“MFC应用”,在向导里“应用程序类型”选“基于对话框”,这样最省事,聊天界面天然就是一上一下两块编辑框加一个发送按钮。如果后续要扩展,再改成“单文档”也不迟,但对这个项目来说对话框已经够了。

关键配置有两处:

  • 项目属性 -> 高级 -> 字符集:选“使用多字节字符集”。如果你用VS2019默认的Unicode字符集,后续字符串转换会多一堆坑,尤其是从socket缓冲区拿到的原始字节转CString的时候。
  • 项目属性 -> C/C++ -> 代码生成 -> 运行库:选“多线程(/MT)”或“多线程调试(/MTd)”。这是为了实现单exe分发。

2.2 中文注释导致编译报错的问题

这是VS2019里非常隐蔽的一个坑。项目中如果新建的.cpp文件保存格式不是“UTF-8带BOM”,而是默认的“UTF-8无BOM”,一旦在代码里写中文注释或中文字符串,编译时很可能会出现类似“警告C4819”或者干脆报一堆语法错误。热词里那条“vs2019中的.cpp等文件加入中文注释就报错”说的就是这个。

解决办法有两个,推荐第二个:

  1. 每一个文件手动“文件 -> 高级保存选项 -> 编码UTF-8带签名”。
  2. 在项目里加一个文本编辑器配置,或者干脆在源文件开头加#pragma execution_character_set("utf-8"),但这个指令只对VS2015以后有效,稳妥起见我还是推荐统一文件编码。

我自己的习惯是:全项目文件统一设为UTF-8 with BOM,同时在代码里涉及中文的地方尽量用资源字符串或_T()宏包裹,避免裸的中文字面量。

2.3 初始化socket库和MFC的配合

MFC程序里初始化socket库有两种方式。一种是老式的WSAStartup,一种是MFC自带的AfxSocketInit。实测下来两者都可以,但如果你在对话框的OnInitDialog里初始化,用AfxSocketInit更省心,它本质就是封装了WSAStartupWSACleanup

// 在CWinApp的InitInstance或者对话框OnInitDialog里 if (!AfxSocketInit()) { AfxMessageBox(_T("Windows Socket初始化失败")); return FALSE; }

有一点要提醒:如果你决定在对话框里初始化,那关闭对话框时记得调用WSACleanup,否则程序退出时偶尔会有莫名其妙的卡顿。

3. 聊天核心实现:从TCP连接到协议设计,再到UI刷新

3.1 建立连接:服务端和客户端的分工

服务端这边的核心代码,流程比较固定。我建立了一个独立的监听线程,避免阻塞主线程的界面消息循环。大致框架如下:

// 服务端监听线程 UINT ListenThreadProc(LPVOID pParam) { SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); SOCKADDR_IN addr; addr.sin_family = AF_INET; addr.sin_port = htons(8888); addr.sin_addr.S_un.S_addr = INADDR_ANY; bind(listenSock, (SOCKADDR*)&addr, sizeof(addr)); listen(listenSock, 5); SOCKET clientSock = accept(listenSock, NULL, NULL); // 保存clientSock到全局或对话框成员变量 // 启动接收线程 }

客户端这边就简单多了:

SOCKET clientSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); SOCKADDR_IN serverAddr; serverAddr.sin_family = AF_INET; serverAddr.sin_port = htons(8888); inet_pton(AF_INET, ipStr, &serverAddr.sin_addr); if (connect(clientSock, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR) { // 连接失败,处理错误 }

端口我选了8888作为默认值,凡是局域网内的机器都可以通过设置服务器IP来连接。这里有一个常见问题是:做测试时bind端口报错,提示“only one usage of each socket address”,大部分情况是上一次运行的程序没有完全退出,或者用了TIME_WAIT占着端口。调试期间我一般把服务端端口设成可以手动改的编辑框值,这样切换起来方便。

3.2 消息协议:聊天和文件传输怎么区分

聊天和文件传输混在同一个连接里,最大的风险是接收端分不清当前收的是文字还是文件块。我的设计是自定义一个轻量协议头,格式如下:

字节偏移字段名类型说明
0消息类型BYTE0x01表示文本消息,0x02表示文件传输请求,0x03表示文件数据块,0x04表示文件传输结束
1数据长度int(4字节)后面跟随数据的字节数
5数据区BYTE数组实际内容

文本消息的数据区就是UTF-8或GBK编码的字符串;文件传输请求的数据区是一个简单结构体,含文件名和文件总大小;文件数据块的数据区则是原始字节。

#pragma pack(push, 1) struct FileTransferHeader { BYTE byType; int nDataLen; char szFileName[256]; LONGLONG llFileSize; }; #pragma pack(pop)

#pragma pack(push, 1)是为了避免结构体对齐在跨进程传输时产生多出来的空洞字节。如果忘记设置,结构体里的szFileNamellFileSize之间可能被编译器填充多余字节,两端程序一旦有一边不同,接收就全乱了。

3.3 接收线程和UI刷新的正确姿势

socket接收线程负责循环调用recv,把收到的字节先放入一个环形缓冲区,然后按协议头拆包。这里涉及两个网络编程的基础问题:

粘包与半包:TCP是字节流,它不保证一次send对应一次recv。可能你发了一个完整的包,recv却只收到一半;也可能两次发送的内容被一次recv全部收下。处理办法就是按协议头里的nDataLen字段去“凑够”一整条消息再消费:

// 伪代码,示意收包逻辑 char buffer[4096]; int totalReceived = 0; while (totalReceived < headerLen) { int ret = recv(sock, buffer + totalReceived, headerLen - totalReceived, 0); totalReceived += ret; } // 此时buffer里有完整协议头 // 再根据nDataLen收完整的数据区

跨线程刷新UI:接收线程是工作线程,绝对不能在它里面直接调用SetDlgItemText这类UI函数,否则界面随时可能崩溃。标准做法是用PostMessage往主窗口发送自定义消息。我定义了一个WM_UPDATE_CHAT_MSG消息,用::PostMessage(GetSafeHwnd(), WM_UPDATE_CHAT_MSG, (WPARAM)msgType, (LPARAM)dataString)把文本内容传回主线程,在主窗口的消息响应函数里再更新编辑框。

我见过很多新手在这里图省事直接调UI,短时间可能没事,多跑一会儿就随机崩溃,而且难以复现。这个问题必须从一开始就避免。

3.4 收发两端的最终效果

完成这部分之后,聊天功能就能跑起来了。两端的表现是:服务端启动监听,客户端连接进来,任意一方在底部编辑框输入文字点发送,对方的聊天记录区就多出一行。这个阶段我把文件传输还没做进去,所以协议头里只有0x01文本消息。

测试时我习惯先本机联调,开两个exe实例,一个服务端一个客户端,IP写127.0.0.1。本机通了再换两台局域网机器测,因为本机测试常常掩盖掉一些MTU和防火墙层面的问题。

4. 文件传输模块:分块发送、进度显示、大文件不卡界面

4.1 文件传输的总体流程

文件传输不能像聊天那样简单地“一次性把全部字节塞进去”,否则大文件会占满内存、把界面卡死,而且万一中途断线就全功尽弃。我采用的做法是分块发送:

  1. 发送方点击“发送文件”按钮,弹出文件选择对话框。
  2. 发送方先发送一个文件传输请求包(类型0x02),包含文件名和文件总大小。
  3. 等待接收方回一个确认包(类型0x05),表示“准备接收”。
  4. 确认后,发送方循环读文件,每读一块(比如64KB)就组包发送,类型为0x03。
  5. 文件发完,发送类型0x04的结束包,同时更新两边界面上的进度信息。

在确认步骤上加一个“握手”,看起来多了一步,但很有必要。接收方如果还没准备好写入文件,发送方就开始灌数据,很容易丢包或者写文件失败。尤其目标是网络路径时,提前创建文件、判断磁盘空间,能避免很多尴尬。

4.2 代码结构:传输逻辑单独封装一个线程

文件传输如果放在按钮消息响应里同步发送,界面会直接进入“未响应”状态。我另开了SendFileThread,用两个Globals控制状态:

volatile BOOL g_bSendingFile = FALSE; volatile BOOL g_bCancelSend = FALSE;

核心发送逻辑:

UINT SendFileThread(LPVOID pParam) { CFile file; if (!file.Open(filePath, CFile::modeRead)) return 0; // 先发文件传输请求包 SendFileRequest(fileName, file.GetLength()); // 等待接收方确认(这里用WaitForSingleObject等事件) ::WaitForSingleObject(g_hFileRecvConfirm, 5000); // 分块发送 char block[64 * 1024]; UINT nRead = 0; while (g_bSendingFile && (nRead = file.Read(block, sizeof(block))) > 0) { SendFileDataBlock(block, nRead); // 进度条更新 } // 发送结束包 SendFileEnd(); file.Close(); return 0; }

这里我特别把块大小定为64KB。为什么是64KB而不是1MB?因为一次send的数据太大,底层socket缓冲区可能填满,send会阻塞或者部分返回,反而增加复杂度。64KB是一个比较稳定的折中值,既保留了较高的吞吐,又不容易触发TCP发送缓冲区瓶颈。

4.3 进度条更新和界面卡顿的优化

进度显示不能每发一个块就PostMessage一次,否则消息队列会被刷爆,界面还是会卡。我采用节流策略:每发完8个块才发送一次进度消息,算下来64KB * 8 = 512KB才更新一次进度条,对100MB以内的文件来说,进度条依然足够顺滑。

进度条的更新同样通过PostMessageWPARAM为当前百分比,主线程只负责设位置:

LRESULT CMyChatDlg::OnUpdateProgress(WPARAM wParam, LPARAM lParam) { int nPercent = (int)wParam; m_ProgressCtrl.SetPos(nPercent); return 0; }

4.4 断点续传和文件校验:第二期再考虑的事

最终版本我没有做断点续传和MD5校验,只做了接收端的文件完整性判断——比较收到的字节数和请求包里的llFileSize,不相等就弹“文件接收不完整”的提示。这个设计是有意为之:断点续传会让协议复杂程度翻倍,接收端要维护文件偏移和状态,发送端要支持从某块重发,局域网内偶发的传输中断概率不高,用了续传反而不值得为它搭进去更多编码和调试时间。

如果你确实需要断点续传,核心思路是:发送请求包时带上起始偏移,接收方以“追加写”模式打开文件,确认包里回应“已存在字节数”,双方从断点继续。这个扩展点我留在了协议里,协议头的llFileSize字段改成llStartOffsetllTotalSize两个字段即可,不影响旧协议。

5. 联调排错阶段:我把典型的坑集中梳理一遍

5.1 bind报错的定位思路

热词里那条bind: only one usage of each socket address是网络编程里非常常见的报错。它出现的原因无非这么几种:

  • 端口被占用:上一次运行的exe没退出,或者服务端还开着监听。
  • 端口处于TIME_WAIT:连接关闭后,端口不会立刻释放,需要等几十秒到几分钟。
  • 程序崩溃后socket没关闭。

排查方法我用的是命令行工具:

netstat -ano | findstr 8888

看到占用端口的进程PID后,再去任务管理器里看对应进程是不是自己之前运行的残留实例,是的话直接结束,不是的话看看是什么程序抢了端口。如果确认是TIME_WAIT导致的,可以在bind之前调用setsockopt设置SO_REUSEADDR来允许端口复用:

BOOL bReuse = TRUE; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, (const char*)&bReuse, sizeof(bReuse));

5.2 send和recv返回值处理:永远要循环

我第一次写完整版时,误以为一次send就能把缓冲区全部发完。后来在传一个几百MB文件时发现进度条走到一半就停了,排查后发现是send在缓冲区满时只发出了部分字节。正确做法是循环发送:

int SendAll(SOCKET sock, const char* data, int len) { int totalSent = 0; while (totalSent < len) { int ret = send(sock, data + totalSent, len - totalSent, 0); if (ret == SOCKET_ERROR) return SOCKET_ERROR; totalSent += ret; } return totalSent; }

同理,recv也要循环接收,并根据返回值判断对端是否正常关闭。recv返回0表示对端关闭连接,返回SOCKET_ERROR才走错误处理,这两个语义要分清楚。

5.3 closesocket和linger设置:防止数据没发完就丢失

这是文件传输最容易踩的隐形坑。发送完最后一块数据后直接调用closesocket,是有可能丢数据的——因为closesocket默认行为是立即关闭socket,底层还可能残留没发送完的数据。

我的做法是发送结束后,等对端回一个收到结束包的确认,发送端收到确认后才调用closesocket。同时设置socketSO_LINGER选项:

LINGER lingerStruct; lingerStruct.l_onoff = 1; // 开启 lingerStruct.l_linger = 5; // 最多等5秒 setsockopt(sock, SOL_SOCKET, SO_LINGER, (const char*)&lingerStruct, sizeof(lingerStruct));

这样即使有一方强制关闭,也会给底层最多5秒时间把缓冲数据推出去。实测下来,文件传输结束时丢尾块的问题基本绝迹。

5.4 打包发布时要注意的两点

VS2019默认生成的是Debug版,依赖调试运行库,拷到其它机器大概率报“缺少VCRUNTIME140D.dll”。发布前务必切换成Release + x86或x64,并确认运行库选的是/MT

还有一点是MFC程序在目标机器上如果报“mfc140u.dll找不到”,说明没有静态链接MFC。需要在“项目属性 -> 常规 -> MFC的使用”里选“在静态库中使用MFC”。加上/MT,最终生成的exe大小大概在几MB到十几MB之间,对于这个量级的工具完全可接受。

注意:静态链接会增大exe体积,但如果接入的是纯文本聊天和常规文件传输,体积增加幅度可以忽略。你要是用MFC的WebBrowser控件一类功能,静态链接体积会明显变大,那再考虑动态库分发。

在项目收尾阶段,我想多说几句实际体会

这个项目从搭骨架到跑通,我前前后后用了大约两三个晚上,大部分时间不是耗在写代码上,而是耗在排查那些“你以为对但实际不对”的细节上。比如字符集选错导致的乱码、发送缓冲区没循环导致的丢数据、工作线程直接刷UI导致的偶发崩溃。这些坑每一个单独拎出来都不算难,但它们串在一起,就足以劝退一个刚开始接触MFC socket的人。

如果让我总结一条最核心的经验,那就是:协议先行。不要在界面上先铺控件,先把消息类型、帧结构、收包流程想清楚,界面只是这些协议的展示层。协议一旦乱了,界面做得再好也是空中楼阁。

这个版本目前最让我满意的一点是,文件传输和聊天在同一个socket连接里互不干扰,即使是传大文件的过程中,聊天消息依然能发出去。能做到这一点,靠的就是协议头里的类型区分和收发线程各司其职。后续如果再迭代,我会优先把断点续传和目录批量传输补上,协议头里预留的扩展位正好可以派上用场。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询