资讯动态

基于VC++和MFC的局域网聊天与文件传输工具实战

发布时间:2026/9/7 11:13:51 来源:尧图企业网站定制
简介面向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()做客户端然后专门开两个线程分别处理接收和发送。这样做的理由很直接原生socket是Windows网络编程的地基出了问题我能自己控制每一层。聊天和文件传输本质上都是“数据流”我需要完全掌控收发边界CAsyncSocket反而多了一层我不想要的消息转发。原生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等文件加入中文注释就报错”说的就是这个。解决办法有两个推荐第二个每一个文件手动“文件 - 高级保存选项 - 编码UTF-8带签名”。在项目里加一个文本编辑器配置或者干脆在源文件开头加#pragma execution_character_set(utf-8)但这个指令只对VS2015以后有效稳妥起见我还是推荐统一文件编码。我自己的习惯是全项目文件统一设为UTF-8 with BOM同时在代码里涉及中文的地方尽量用资源字符串或_T()宏包裹避免裸的中文字面量。2.3 初始化socket库和MFC的配合MFC程序里初始化socket库有两种方式。一种是老式的WSAStartup一种是MFC自带的AfxSocketInit。实测下来两者都可以但如果你在对话框的OnInitDialog里初始化用AfxSocketInit更省心它本质就是封装了WSAStartup和WSACleanup。// 在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数据长度int4字节后面跟随数据的字节数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)是为了避免结构体对齐在跨进程传输时产生多出来的空洞字节。如果忘记设置结构体里的szFileName和llFileSize之间可能被编译器填充多余字节两端程序一旦有一边不同接收就全乱了。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 文件传输的总体流程文件传输不能像聊天那样简单地“一次性把全部字节塞进去”否则大文件会占满内存、把界面卡死而且万一中途断线就全功尽弃。我采用的做法是分块发送发送方点击“发送文件”按钮弹出文件选择对话框。发送方先发送一个文件传输请求包类型0x02包含文件名和文件总大小。等待接收方回一个确认包类型0x05表示“准备接收”。确认后发送方循环读文件每读一块比如64KB就组包发送类型为0x03。文件发完发送类型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以内的文件来说进度条依然足够顺滑。进度条的更新同样通过PostMessage传WPARAM为当前百分比主线程只负责设位置LRESULT CMyChatDlg::OnUpdateProgress(WPARAM wParam, LPARAM lParam) { int nPercent (int)wParam; m_ProgressCtrl.SetPos(nPercent); return 0; }4.4 断点续传和文件校验第二期再考虑的事最终版本我没有做断点续传和MD5校验只做了接收端的文件完整性判断——比较收到的字节数和请求包里的llFileSize不相等就弹“文件接收不完整”的提示。这个设计是有意为之断点续传会让协议复杂程度翻倍接收端要维护文件偏移和状态发送端要支持从某块重发局域网内偶发的传输中断概率不高用了续传反而不值得为它搭进去更多编码和调试时间。如果你确实需要断点续传核心思路是发送请求包时带上起始偏移接收方以“追加写”模式打开文件确认包里回应“已存在字节数”双方从断点继续。这个扩展点我留在了协议里协议头的llFileSize字段改成llStartOffset和llTotalSize两个字段即可不影响旧协议。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。同时设置socket的SO_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连接里互不干扰即使是传大文件的过程中聊天消息依然能发出去。能做到这一点靠的就是协议头里的类型区分和收发线程各司其职。后续如果再迭代我会优先把断点续传和目录批量传输补上协议头里预留的扩展位正好可以派上用场。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价