简介面向MFC开发者的VC Socket编程示例以CSocket为主线演示客户端与服务端的双向通讯在VS2019下完成聊天和大型文件不丢包传输作者因集群功能预研而编写适合学习MFC网络编程或准备开发文件传输功能的读者。压缩包仅262KB共39个文件以C头文件(.h)与源文件(.cpp)为主体另含VS解决方案文件(.sln/.vcxproj)、资源脚本(.rc)和说明文档(.txt)client与server两个工程独立划分便于直接编译学习。已有1320人浏览学习示例覆盖了CSocket连接管理、数据打包与拆包、大文件分块传输及丢包重传等关键点并修复了内存泄露隐患。读者可从中获得一套可运行的MFC网络通讯框架了解消息格式设计、收发缓冲控制和避免数据粘连为后续扩展集群与多客户端管理打下基础。1. 在 VS2019 里用 VC MFC 做 socket 聊天与文件传输还值得学吗很多开发者一听说 MFC 就觉得是上个时代的东西但真到做局域网内部工具、设备联调小助手、课程演示系统这类需求时VC 加 MFC 加 socket 编程依然是出活最快的组合。最近某开发者把一个老旧的 MFC 对话框程序交给我加需求同事之间能互发消息、能往对方机器传文件要求用 VS2019 编译、不开新框架。我最后就是在原有对话框工程里嵌了一套 TCP socket 收发逻辑两天跑通闭环。这篇文章就把这套方案的选型、协议设计、代码骨架和踩坑记录完整拆开新手能照着建工程熟手可以直接拿走参数和边界。2. 动手前先定三件事TCP 选型、socket 封装选型、连接模型2.1 聊天和文件传输共用一条 TCP 连接不要拆成两套通道常见的错误想法是聊天走 TCP、文件走 UDP理由是文件大、怕占聊天通道。实际上在局域网场景里文件传输对可靠性的要求远高于对实时性的要求UDP 丢包后要自己补序号、重传、重组工作量全落在业务代码上。聊天虽然用 UDP 也能跑但一个工程里混用两套传输层协议会让消息分发、错误处理、调试难度翻倍。我的习惯是所有业务数据都走同一条 TCP 连接在应用层用消息类型字段区分文本消息和文件数据。这样做的好处有三个第一TCP 保证字节流按序到达文件不会出现乱序第二连接数只需维护一份第三断线检测只需看一条 socket 的状态变化。局域网内 TCP 的延迟差异几乎感知不到没必要为极致性能牺牲工程复杂度。2.2 CSocket 还是 CAsyncSocket我选阻塞模式加工作线程MFC 对 socket 的封装有两层CAsyncSocket 是异步封装收发事件通过消息回调触发比如 OnReceive、OnAcceptCSocket 派生自 CAsyncSocket内部改成了阻塞模式可以配合 CArchive 做序列化收发。桌面工具场景下我更推荐 CSocket 加工作线程Accept、Receive、Send 都是阻塞调用代码执行顺序和人的直觉一致出问题时也容易定位。CAsyncSocket 的回调模式适合一个线程处理大量连接的高性能场景但 MFC 的消息回调会把业务逻辑拆散到多个函数里状态一多就难维护。对比下来CSocket 的代码量更小调试时用断点可以看到完整的调用栈符合“内部工具快速可用”的定位。对比项CAsyncSocketCSocket阻塞收发方式回调通知阻塞调用调试难度回调栈分散调用栈直观并发处理可支撑较多连接每连接需独立线程适合场景高性能异步服务局域网小工具、桌面端 C/S2.3 连接模型一个监听线程加每连接独立接收线程服务端结构通常是主对话框启动一个监听线程线程里循环 Accept每接受一个客户端就创建一个 CSocket 对象并启动一个专门的接收线程处理这个连接的数据。客户端结构简单得多一个连接线程负责接收发送操作直接从 UI 线程调用 Send。为了把数据送到界面接收线程解析完消息后通过 PostMessage 向对话框窗口投递自定义消息。线程数量等于连接数加一这个模型在几十个连接内完全够用。某公司内部工具最多同时挂过二十多个客户端没有出现瓶颈。超过这个量级就该考虑完成端口或非阻塞模型但那已经不是 MFC 对话框应用的典型范围了。连接管理上用一个指针数组或 CList 保存所有 CSocket 指针关闭时统一清理。3. 聊天功能先定协议头再写收发线程3.1 消息协议头12 字节定长包头避免粘包和半包所有 socket 通信程序的第一步都是设计消息边界。TCP 是字节流协议它会粘包也会把一个消息拆成两次到达。解决办法是给每条消息固定一个包头包头里声明这包数据的长度接收端先收满包头再按声明的长度收数据体。#pragma pack(push, 1) // 取消结构体内存对齐保证包头在内存中紧凑排列 typedef struct { UINT nMagic; // 魔数固定为 0x5A5A用来校验包头合法性 UINT nType; // 消息类型0文本聊天1文件头2文件数据3文件结束 UINT nLen; // 数据区字节数不含包头自身 } MSG_HEAD; // 共 12 字节 #pragma pack(pop)这个包头的关键参数是 nMagic。接收端每次先读 12 字节如果 nMagic 对不上说明数据流已经错位直接关闭连接不做强行纠错。nType 用来区分聊天和文件数据nLen 告诉接收端接下来要读多少字节。用 pragma pack 是为了让结构体在内存里恰好是 12 字节否则默认对齐可能变成 12 字节之上的怪异值导致解析错位。文本消息的数据区直接放 UTF-8 编码的字符串长度不够 4 字节对齐也没关系TCP 流不需要按块对齐。3.2 服务端监听线程和接收线程的骨架服务端对话框启动时在主窗口初始化函数里创建监听 socket然后启动监听线程。监听线程不接受任何业务数据只负责 Accept每接受一个客户端就把它加入客户端列表并启动一个新的接收线程。UINT CServerDlg::ListenThread(LPVOID pParam) { CServerDlg* pDlg (CServerDlg*)pParam; CSocket srvSocket; if (!srvSocket.Create(pDlg-m_nPort)) { // GetLastError() 返回 10048 说明端口被占用10013 说明权限不足 return 1; } srvSocket.Listen(10); // 接受队列长度 10超过后客户端连接会失败 while (!pDlg-m_bStopListening) { CSocket* pClient new CSocket; if (srvSocket.Accept(*pClient)) { pDlg-m_clientList.AddTail(pClient); // 存入指针链表 pDlg-PostMessage(WM_ONLINE_COUNT, pDlg-m_clientList.GetCount()); AfxBeginThread(RecvThread, pClient); // 每连接一个接收线程 } else { delete pClient; } } srvSocket.Close(); return 0; }这段代码要注意两点。第一Accept 是阻塞的所以退出循环前必须通过 m_bStopListening 标记让监听线程退出否则对话框关闭时线程会挂在 Accept 上无法结束。第二Listen 的 backlog 参数设 10 就够了这是一个队列深度参数超过这个数的连接请求会被拒绝。接收线程里循环调用 Receive拿到数据后用 PostMessage 投递到 UI 线程不在工作线程里直接操作控件。UINT CServerDlg::RecvThread(LPVOID pParam) { CSocket* pClient (CSocket*)pParam; char buf[4096]; while (true) { int nRet pClient-Receive(buf, sizeof(buf)); if (nRet 0) break; // 0 表示对端关闭SOCKET_ERROR 表示异常 // 这里把 buf 追加到按连接维护的接收缓冲区然后拆包 // 具体拆包逻辑在第 4 章文件传输部分统一讲 } // 清理逻辑从客户端列表移除、发送下线通知、关闭 socket return 0; }Receive 的返回值有三类情况必须区分处理正数表示实际收到的字节数0 表示对端有序关闭此时应主动结束接收线程SOCKET_ERROR 表示连接异常断开常用 WSAGetLastError 查具体错误码。CSocket 的阻塞 Receive 在收到错误时不会自动关闭 socket也没用但线程必须退出否则泄漏。3.3 客户端连接服务器、发送消息与编码统一客户端对话框比服务端简单。UI 上放一个 IP 地址输入框、一个消息输入框、一个发送按钮和一个接收显示区。连接时调用 CSocket::Create 和 Connect注意 Create 不指定端口传 0让系统分配本地临时端口服务端才有入站连接的接收对象。void CClientDlg::OnBnClickedConnect() { m_clientSocket.Close(); // 防止重复连接 if (!m_clientSocket.Create()) { // 0 端口随机本地端口 AfxMessageBox(L创建 socket 失败); return; } CString strIp L127.0.0.1; // 从界面控件读取目标地址 UINT nPort 8888; // 从界面控件读取服务端端口 if (!m_clientSocket.Connect(strIp, nPort)) { AfxMessageBox(L连接失败确认服务端已启动); return; } AfxBeginThread(RecvThread, m_clientSocket); // 启动接收线程 }发送消息时把 MSG_HEAD 和数据体拼接成一个缓冲区一次 Send 发出。注意界面输入框拿到的 CString 在 Unicode 字符集下是 UTF-16而协议约定数据区用 UTF-8发送前必须转换。// 消息输入框内容转成 UTF-8 后打包发送 CStringW strMsg; m_editMessage.GetWindowTextW(strMsg); CStringA strUtf8 CW2A(strMsg, CP_UTF8); // Unicode 转 UTF-8 BYTE* pBuf new BYTE[sizeof(MSG_HEAD) strUtf8.GetLength()]; MSG_HEAD* pHead (MSG_HEAD*)pBuf; pHead-nMagic 0x5A5A; pHead-nType 0; // 文本消息 pHead-nLen strUtf8.GetLength(); memcpy(pBuf sizeof(MSG_HEAD), strUtf8.GetBuffer(), strUtf8.GetLength()); m_clientSocket.Send(pBuf, sizeof(MSG_HEAD) strUtf8.GetLength(), 0); delete[] pBuf;这里最容易翻车的是编码。VS2019 新建 MFC 默认使用 Unicode 字符集而网络字节流没有编码语义双方必须约定统一编码。我习惯统一用 UTF-8因为 UTF-8 对 ASCII 兼容且不存在大小端问题。如果收发两端有一端直接发了 GBK 或本地代码页字符串接收端用 UTF-8 解出来就是乱码。CW2A 的第三个参数指定目标代码页这是多字节字符集工程里最常被忽略的参数。4. 文件传输把 TCP 字节流还原成有边界的数据块4.1 文件头结构文件名、大小、分块参数一次说清文件传输的协议设计思路是先发一个文件头消息告知文件名和大小然后循环发送文件数据块每块带序号最后发一个结束消息。接收端先收文件头再按序号写数据收完校验大小。#pragma pack(push, 1) typedef struct { char szFileName[256]; // 文件名UTF-8 编码不足补 0 DWORD dwFileSize; // 文件总字节数用于接收端校验完整性 DWORD dwBlockSize; // 数据块大小固定 8192 DWORD dwBlockCount; // 总块数用于进度条计算 } MSG_FILE_HEAD; // 268 字节 #pragma pack(pop)dwBlockCount 是进度条计算的关键。发送端在发送前先计算文件大小除以块大小向上取整。接收端每收到一块就把进度加一用收到的块数除以总块数就是进度百分比。块大小选 8192 字节在局域网内是性价比比较高的值太大则接收端内存占用高太小则系统调用次数多Send 和 Receive 都是系统调用次数太多会拉低吞吐。超过 1MB 的文件也无需调整块大小保持固定值简化协议。4.2 发送端循环读文件、封装数据块、上报进度发送端逻辑按顺序执行发送文件头、循环发送数据块、发送结束标记。文件头发送后要短暂等一下对端避免数据块快速涌入导致接收端还没处理完文件头就开始写文件。实际问题中TCP 的流式缓冲区天然会给发送端施加背压所以一般不用主动 sleep直接循环发送即可。// 计算文件基本信息 FILE* fp fopen(szFilePath, rb); fseek(fp, 0, SEEK_END); DWORD dwFileSize ftell(fp); fseek(fp, 0, SEEK_SET); DWORD dwBlockSize 8192; DWORD dwBlockCount (dwFileSize dwBlockSize - 1) / dwBlockSize; // 先发文件头消息 MSG_FILE_HEAD fileHead; memset(fileHead, 0, sizeof(fileHead)); strcpy_s(fileHead.szFileName, szFileName); fileHead.dwFileSize dwFileSize; fileHead.dwBlockSize dwBlockSize; fileHead.dwBlockCount dwBlockCount; SendMessage(fileHead, MSG_TYPE_FILE_HEAD); // 循环读文件并发送 char buf[8192]; for (DWORD i 0; i dwBlockCount; i) { size_t nRead fread(buf, 1, dwBlockSize, fp); if (nRead 0) break; SendFileData(buf, (UINT)nRead, i); // 发送完成后用 PostMessage 把进度值抛给 UI 线程 m_pMainWnd-PostMessage(WM_FILE_PROGRESS, i 1, dwBlockCount); } fclose(fp); SendFileEnd();这段代码里 SendMessage 和 SendFileData 都是往 socket 写数据的封装。要注意最后一块可能不足 8192 字节接收端必须按 nRead 的实际长度写入文件不能假设每块都是满的。进度上报使用 PostMessage 而不是直接操作进度条控件因为发送循环如果放在 UI 线程里直接操作控件没问题但如果放在工作线程里直接操作控件会跨线程访问界面MFC 下不安全。4.3 接收端缓冲区累积和半包处理是文件传输的核心TCP 是流协议接收线程调用一次 Receive 拿到的数据可能包含多条消息的一部分也可能一条消息跨多次 Receive 才凑齐。正确做法是每个连接维护一个字节缓冲区把 Receive 到的数据追加进去然后循环尝试从缓冲区里拆出完整的 MSG_HEAD 和数据体。std::vectorBYTE m_recvBuffer; // 每个连接一份追加式累积 void OnDataArrive(const char* data, int len) { m_recvBuffer.insert(m_recvBuffer.end(), data, data len); while (true) { if (m_recvBuffer.size() sizeof(MSG_HEAD)) return; // 包头还没凑齐 MSG_HEAD head; memcpy(head, m_recvBuffer.data(), sizeof(MSG_HEAD)); if (head.nMagic ! 0x5A5A) { // 魔数错误说明数据流错位直接断开连接 return; } if (m_recvBuffer.size() sizeof(MSG_HEAD) head.nLen) return; // 半包 // 按类型分发完整消息 const BYTE* pBody m_recvBuffer.data() sizeof(MSG_HEAD); if (head.nType MSG_TYPE_FILE_DATA) { WriteFileBlock(pBody, head.nLen); } // 处理完后从缓冲区头部移除已消费的数据 m_recvBuffer.erase(m_recvBuffer.begin(), m_recvBuffer.begin() sizeof(MSG_HEAD) head.nLen); } }这段代码是整个文件传输最核心的部分。erase 操作在数据量大时会有拷贝开销但 8192 的块大小下性能完全可接受。接口是整套系统的协议地基所有消息类型都复用这一套拆包流程聊天和文件数据只是分发后的不同处理路径。文件数据块里要保存块序号和文件路径接收线程在收到文件头后记录文件名收到数据块后按块序号做位移写入文件fseek 到 序号乘以块大小 的位置再 fwrite 实际长度。5. 避坑与常见问题socket 程序最容易翻车的五个地方5.1 现象发送大文件时界面假死拖动窗口无响应原因把发送循环直接写在了按钮点击事件里UI 线程被阻塞无法处理窗口消息。100MB 文件按 8192 字节分块要循环一万多次每次 Send 都是一次系统调用耗时从几十毫秒到几百毫秒不等界面必然卡死。解决发送循环移到工作线程里执行UI 线程只负责发起线程和接收进度消息。进度更新通过 PostMessage 投递不要在工作线程里直接操作进度条控件。如果坚持在 UI 线程发送至少要改成非阻塞或异步模式但那会带来状态机复杂度不如线程方案直观。5.2 现象接收端解析出的文件名只有一半或文件数据写入后内容错位原因TCP 粘包和半包。发送端连续发送文件头和数据块时接收端一次 Receive 可能同时收到文件头和后几个字节的数据块。如果代码只做一次 Receive 就当作完整消息处理必然错位。解决必须引入累积缓冲区按 MSG_HEAD 的长度声明循环拆包。先凑齐 12 字节包头再按包头里的 nLen 等数据体数据体没凑够就退出循环等待下一次 Receive。这个逻辑在项目里是所有消息处理的前置公共函数聊天和文件共用不能只对文件传输做特殊处理。5.3 现象关闭程序时偶发崩溃有时候是堆栈溢出有时候是 HEAP CORRUPTION原因对话框关闭时接收线程还在阻塞在 Receive 上此时关闭 socket 并释放指针接收线程返回后继续访问已被释放的 CSocket 对象或者从客户端列表里删除元素时迭代器已经失效。解决关闭流程要按“先停线程再关 socket最后删对象”的顺序执行。用事件对象通知接收线程退出然后等待线程结束再调用 Close 和 delete。监听线程同样要设置退出标志并等待结束。列表操作加关键段保护避免 UI 线程遍历列表时工作线程同时在增删。5.4 现象服务端重启时报错错误码 10048原因上一次运行的服务端没有完全释放端口。CSocket 析构时如果没有显式调用 Close端口会在 socket 对象销毁后才释放而 Windows 下端口释放后还会进入一段时间等待状态立即重启就会绑定失败。解决程序退出前显式调用每个 socket 实例的 Close并在对话框的 OnDestroy 里做统一清理。调试期间服务端重启频繁也可以在 Create 之前调用 setsockopt 设置 SO_REUSEADDR允许端口复用。这个设置对 TCP 服务端尤其重要不然每次改代码重新编译运行都要等半分钟。5.5 现象MFC 程序里 CSocket::Create 一直失败GetLastError 伴随误报或者另一端收到全是乱码原因忘记调用 AfxSocketInit 初始化 MFC socket 层或者收发两端字符编码不一致。MFC 对话框程序必须在 InitInstance 早期调用 AfxSocketInit否则 WSAStartup 没有被正确初始化所有 socket 调用都会失败。解决InitInstance 里加一句 AfxSocketInit放在创建窗口之前。编码混乱问题严格遵照协议约定文本消息使用 UTF-8文件名字段也使用 UTF-8所有 Windows 宽字符转出前都经过 CW2A 指定 CP_UTF8接收端反过来用 CA2W 转回 UTF-16 再显示。两端同时执行这个约定乱码现象不会出现。6. 上线前加心跳和超时以及怎么验证这套程序真的可靠6.1 心跳与超时断开TCP 的接收线程只能感知到对端主动关闭和网络错误但客户端断电、网线被拔掉、机器休眠这类情况不会触发任何事件接收线程会永远阻塞在 Receive 上。所以要么不做要做就做完整每 3 秒发一个心跳消息对端收到后原样回一个心跳应答如果连续 10 秒没收到任何数据不只是心跳任何业务数据都行就判定连接失效主动关闭 socket 并清理连接资源。// 常用参数心跳间隔 3 秒超时阈值 10 秒 #define HEARTBEAT_INTERVAL 3000 #define TIMEOUT_LIMIT 10000 // 接收线程里更新最后活跃时间戳 m_lastActiveTime GetTickCount64(); // 定时器检查线程里判断 if (GetTickCount64() - m_lastActiveTime TIMEOUT_LIMIT) { // 清理连接不再等待对方 }设置这两个参数时要考虑业务特点。心跳太频繁会增加局域网流量太稀疏会拉长断线发现时间。3 秒和 10 秒适合绝大多数内部工具场景。文件传输过程中由于数据流不断最后活跃时间戳会持续刷新大文件传输不会因为心跳间隔被误判超时。6.2 验证方法与迭代习惯不要等到程序写完再测。我的习惯是每加一个协议消息类型就用两个本地窗口做回环验证一个服务端一个客户端跑在同一台机器上先发文本再传一个 10MB 的随机文件比对接收端文件的 MD5 哈希值。局域网联调时再扩大到两台机器排查网卡驱动的缓冲区设置和防火墙放行规则。用抓包工具看 TCP 层是排查疑难问题的最有效手段。确认连接建立时的三次握手、断开时的四次挥手、发送方实际发送的数据块大小是否和代码一致。如果看到连续几个数据包都小于 8192 字节说明发送方的 Send 被拆包了需要回到接收端的累积缓冲区找问题而不是怀疑网络环境。之前有一次我自认为协议设计没问题直接把文件传输丢给同事联调结果从 Windows 到 Windows 跑通了换了系统环境就出乱码。从那之后我养成了习惯协议头里的每一个字段都必须有一个对应的解析函数和测试用例编码转换这类最容易出问题的逻辑先写好再往下走。这套 VC MFC socket 方案做内部工具足够可靠把它当黑匣子用的人省事想知道边界在哪的人已经能看到端口、线程、缓冲区这些关键参数了。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?