☰
MFC网络编程实验:CAsyncSocket实现文件传输与UI线程通信
2026/10/9 6:47:41 网站建设 项目流程

简介:这份资源面向学习网络编程与MFC框架的C++开发者,尤其是需要完成课程实验或想理解Socket通信原理的学生。它提供了一套完整的文件传输示例,包含客户端与服务端两个独立程序,演示如何用CSocket类完成连接建立、文件读取、数据收发与本地保存等核心流程,帮助读者把TCP/IP协议栈、异步Socket接口与MFC封装结合起来理解。压缩包共63个文件,约109.68MB,以h头文件、cpp源文件、vcxproj工程文件、sln解决方案为主,同时包含exe可执行文件、pdb调试符号、obj中间文件及rc资源脚本等,工程结构完整,可直接编译运行。目前已有316人学习下载。通过研读源码,读者能掌握CAsyncSocket的Bind、Listen、Accept与CSocket的Send、Receive等关键调用,理解文件I/O与网络传输的配合方式,并借鉴MFC工程的组织与调试配置,为后续网络应用开发打下基础。

1. 网络编程实验:MFC实现文件传输,从Socket到落地的完整路径

很多同学做网络编程实验时,第一反应是用控制台写一个 TCP 回声程序,几十行代码就能跑通。但一旦要求用 MFC 做出带界面的文件传输工具,问题就来了:阻塞的recv把 UI 线程卡死,进度条不动,点关闭窗口直接无响应。这个实验的核心难点从来不是 socket 网络编程本身,而是怎么把阻塞式 IO 和 Windows 消息循环揉在一起,让客户端和服务端既能稳定传文件,又不让界面变成黑匣子。

这篇笔记面向正在做网络编程实验、需要交付一个可演示的 MFC 文件传输程序的读者。我会按「先跑通最小模型,再补进度反馈,最后处理边界」的顺序,把客户端和服务端的实现拆开讲清楚。涉及的知识点包括CAsyncSocket与CSocket的选型、文件分块传输协议的设计、UI 线程与工作线程的通信方式,以及大文件传输时最容易翻车的几个地方。如果你已经会写控制台版 socket 程序,但不知道怎么做成 MFC 界面,这篇可以直接照着复现。

2. 选型与协议设计:MFC 下怎么组织客户端和服务端

2.1 CAsyncSocket 还是 CSocket,先想清楚线程模型

MFC 提供了两个 socket 封装类:CAsyncSocket和CSocket。很多人直接抄示例代码用CSocket,结果发现接收大文件时界面卡死,这是因为CSocket内部是阻塞模式,它在等待网络事件时会进入一个消息泵,虽然不像纯阻塞那样完全冻结界面,但在传输大文件时仍然会造成明显的卡顿和消息延迟。

我的建议是:服务端用CAsyncSocket,客户端也用CAsyncSocket,把文件读写放到独立工作线程里。CAsyncSocket基于 WSAAsyncSelect 模型,所有网络事件通过窗口消息通知,天然和 MFC 消息循环兼容。你只需要在派生类里重写OnReceive、OnAccept、OnConnect这几个虚函数,就能在消息回调里处理网络数据。

具体来说,服务端需要两个类:一个CListenSocket负责监听,一个CClientSocket负责和每个接入的客户端通信。客户端只需要一个CFileTransferSocket继承CAsyncSocket,负责连接和收发。

// ListenSocket.h - 服务端监听类 class CListenSocket : public CAsyncSocket { public: virtual void OnAccept(int nErrorCode); }; void CListenSocket::OnAccept(int nErrorCode) { if (nErrorCode != 0) return; // 有新客户端接入,创建通信 socket CClientSocket* pClient = new CClientSocket(); if (Accept(*pClient)) { // 把新客户端加入管理列表,交给主对话框维护 theApp.m_pMainDlg->AddClient(pClient); } else { delete pClient; } CAsyncSocket::OnAccept(nErrorCode); }

这段代码的关键点是Accept之后必须把新 socket 对象交给某个长期存活的管理者,否则局部变量析构后连接就断了。参数nErrorCode为 0 表示没有错误,非 0 时直接返回,不要继续处理。

2.2 自定义文件传输协议:头部 + 数据块

直接裸传文件字节流是不行的,接收端不知道文件多大、叫什么名字、什么时候传完。常见做法是设计一个简单的应用层协议:先发固定长度的头部,再发文件数据。

头部结构我一般这样定义:

字段长度说明
文件名长度4 字节网络字节序
文件名变长UTF-8 编码
文件总大小8 字节网络字节序,支持大于 4GB
数据块大小4 字节每次发送的块大小,固定 64KB

发送端先发头部,再循环发送文件数据块。接收端先收头部,解析出文件名和总大小,然后按块接收并写入本地文件,直到收满总大小为止。

// 发送文件头部的核心逻辑 void CClientSocket::SendFileHeader(const CString& strFileName, ULONGLONG ullFileSize) { // 文件名转 UTF-8 CT2A utf8Name(strFileName, CP_UTF8); int nNameLen = (int)strlen(utf8Name); // 组装头部缓冲区 BYTE header[256] = {0}; int offset = 0; // 文件名长度(网络字节序) DWORD dwNameLen = htonl(nNameLen); memcpy(header + offset, &dwNameLen, 4); offset += 4; // 文件名 memcpy(header + offset, utf8Name, nNameLen); offset += nNameLen; // 文件总大小(网络字节序,8字节) ULONGLONG ullSizeNet = htonll(ullFileSize); memcpy(header + offset, &ullSizeNet, 8); offset += 8; // 发送头部 Send(header, offset); }

这里htonll不是标准函数,需要自己实现或者用_byteswap_uint64。参数ullFileSize用ULONGLONG是为了支持超过 4GB 的文件,虽然实验场景下不太可能传这么大的文件,但养成习惯没坏处。

注意:Send不保证一次发送完所有字节,返回值是实际发送的字节数。小数据量下通常没问题,但严格来说需要循环发送直到全部发完。

3. 客户端实现:从连接服务端到分块发送文件

3.1 连接服务端与 UI 状态同步

客户端界面上一般有一个「连接」按钮、一个 IP 输入框、一个端口输入框、一个「选择文件」按钮和一个进度条。点击连接时,调用Create和Connect:

void CFileTransferDlg::OnBnClickedBtnConnect() { if (m_pSocket != nullptr) { m_pSocket->Close(); delete m_pSocket; } m_pSocket = new CFileTransferSocket(); m_pSocket->SetParentDlg(this); // 用于回调更新 UI // 创建 socket,不指定端口由系统分配 if (!m_pSocket->Create()) { AfxMessageBox(_T("创建 Socket 失败")); delete m_pSocket; m_pSocket = nullptr; return; } // 发起连接 CString strIP; m_editIP.GetWindowText(strIP); int nPort = GetDlgItemInt(IDC_EDIT_PORT); if (!m_pSocket->Connect(strIP, nPort)) { int nErr = GetLastError(); if (nErr != WSAEWOULDBLOCK) { AfxMessageBox(_T("连接失败")); return; } // WSAEWOULDBLOCK 表示连接正在进行中,等待 OnConnect 回调 } }

Connect返回FALSE且错误码为WSAEWOULDBLOCK是正常情况,说明连接请求已发出,正在等待服务端响应。真正的连接结果在OnConnect回调里处理。

void CFileTransferSocket::OnConnect(int nErrorCode) { if (nErrorCode == 0) { m_pParentDlg->PostMessage(WM_USER_CONNECTED, 0, 0); } else { m_pParentDlg->PostMessage(WM_USER_CONNECT_FAILED, nErrorCode, 0); } CAsyncSocket::OnConnect(nErrorCode); }

这里用PostMessage而不是直接调用 UI 更新函数,是因为OnConnect在消息循环中被调用,直接操作控件虽然通常没问题,但用自定义消息更清晰,也方便后续把网络处理移到工作线程。

3.2 分块发送文件与进度反馈

发送文件时,如果直接在 UI 线程里循环读文件、调Send,大文件会让界面卡住。我的做法是开一个工作线程专门做文件读取和发送,通过自定义消息把进度回传给 UI。

// 工作线程函数 UINT CFileTransferDlg::SendFileThread(LPVOID pParam) { CFileTransferDlg* pDlg = (CFileTransferDlg*)pParam; CFile file; if (!file.Open(pDlg->m_strFilePath, CFile::modeRead | CFile::typeBinary)) { pDlg->PostMessage(WM_USER_SEND_ERROR, 0, 0); return 1; } ULONGLONG ullFileSize = file.GetLength(); pDlg->m_pSocket->SendFileHeader(pDlg->m_strFileName, ullFileSize); const int nBlockSize = 64 * 1024; // 64KB BYTE* pBuffer = new BYTE[nBlockSize]; ULONGLONG ullSent = 0; while (ullSent < ullFileSize) { UINT nRead = file.Read(pBuffer, nBlockSize); if (nRead == 0) break; int nRet = pDlg->m_pSocket->Send(pBuffer, nRead); if (nRet == SOCKET_ERROR) { int nErr = GetLastError(); if (nErr == WSAEWOULDBLOCK) { // 发送缓冲区满,等待可写事件 Sleep(1); continue; } break; } ullSent += nRet; int nPercent = (int)(ullSent * 100 / ullFileSize); pDlg->PostMessage(WM_USER_PROGRESS, nPercent, 0); } delete[] pBuffer; file.Close(); pDlg->PostMessage(WM_USER_SEND_DONE, 0, 0); return 0; }

参数nBlockSize设为 64KB 是一个经验值。太小会导致Send调用次数过多,效率下降;太大则单次发送耗时增加,进度更新不及时。WSAEWOULDBLOCK的处理是必须的,CAsyncSocket的Send在缓冲区满时会返回这个错误,简单Sleep(1)重试虽然不够优雅,但在实验场景下足够可靠。

提示:如果传输的文件超过几百 MB,建议把Sleep(1)换成等待OnSend通知,否则 CPU 占用会偏高。

4. 服务端实现:接收连接、解析头部与写文件

4.1 监听端口与多客户端管理

服务端主对话框在初始化时创建监听 socket:

void CServerDlg::OnBnClickedBtnStartListen() { m_pListenSocket = new CListenSocket(); m_pListenSocket->SetParentDlg(this); int nPort = GetDlgItemInt(IDC_EDIT_LISTEN_PORT); if (!m_pListenSocket->Create(nPort, SOCK_STREAM)) { AfxMessageBox(_T("监听端口创建失败")); delete m_pListenSocket; m_pListenSocket = nullptr; return; } if (!m_pListenSocket->Listen(5)) { AfxMessageBox(_T("Listen 失败")); return; } m_listLog.AddString(_T("服务端已启动,等待客户端连接...")); }

Listen的参数 5 是等待队列长度,实验场景下够用。每个接入的客户端由一个CClientSocket对象管理,主对话框维护一个CList或std::vector来保存这些对象。

4.2 接收文件头与数据落盘

接收端的逻辑比发送端稍复杂,因为要处理「头部可能分多次到达」的情况。OnReceive回调触发时,你不知道当前收到的是头部还是数据,所以需要一个状态机。

void CClientSocket::OnReceive(int nErrorCode) { if (nErrorCode != 0) return; if (m_state == STATE_WAIT_HEADER) { // 尝试接收头部 BYTE buf[256]; int nRet = Receive(buf, sizeof(buf)); if (nRet <= 0) return; // 解析头部(简化处理,假设一次收全) DWORD dwNameLen; memcpy(&dwNameLen, buf, 4); dwNameLen = ntohl(dwNameLen); char szName[256] = {0}; memcpy(szName, buf + 4, dwNameLen); ULONGLONG ullFileSize; memcpy(&ullFileSize, buf + 4 + dwNameLen, 8); ullFileSize = ntohll(ullFileSize); // 打开本地文件准备写入 m_strRecvFileName = CString(szName); m_ullFileSize = ullFileSize; m_ullReceived = 0; CString strSavePath = _T(".\\recv\\") + m_strRecvFileName; m_file.Open(strSavePath, CFile::modeCreate | CFile::modeWrite | CFile::typeBinary); m_state = STATE_RECV_DATA; } else if (m_state == STATE_RECV_DATA) { BYTE buf[64 * 1024]; int nRet = Receive(buf, sizeof(buf)); if (nRet <= 0) return; m_file.Write(buf, nRet); m_ullReceived += nRet; // 通知 UI 更新进度 int nPercent = (int)(m_ullReceived * 100 / m_ullFileSize); m_pParentDlg->PostMessage(WM_USER_CLIENT_PROGRESS, nPercent, (LPARAM)this); if (m_ullReceived >= m_ullFileSize) { m_file.Close(); m_state = STATE_WAIT_HEADER; m_pParentDlg->PostMessage(WM_USER_RECV_DONE, 0, (LPARAM)this); } } CAsyncSocket::OnReceive(nErrorCode); }

这段代码里STATE_WAIT_HEADER和STATE_RECV_DATA是两个自定义状态常量。实际项目中头部可能不会一次收全,严格的做法是维护一个接收缓冲区,累积到足够长度再解析。实验场景下头部只有几十字节,TCP 通常一次就能送达,但如果你发现解析出的文件名是乱码,大概率就是头部没接收完整。

注意:Receive返回 0 表示对方关闭了连接,返回SOCKET_ERROR需要检查GetLastError()。如果是WSAEWOULDBLOCK,说明当前没有数据可读,直接返回即可。

5. 避坑与排查:MFC 文件传输实验里最容易翻车的 5 个地方

5.1 现象:界面卡死,进度条不动

原因:在 UI 线程里直接循环调用Send或Receive,阻塞了消息循环。CAsyncSocket虽然是非阻塞的,但如果你在OnReceive里写了一个while循环等待数据收完,同样会卡住。

解决:把耗时的文件读写和循环发送放到工作线程,UI 线程只负责响应消息和更新控件。工作线程通过PostMessage通知进度,不要用SendMessage,后者会等待 UI 处理完毕,在某些情况下可能造成死锁。

5.2 现象:文件传完了但大小不对,或者内容损坏

原因:Send和Receive的返回值没有正确处理。Send可能只发送了部分数据,Receive也可能只收到部分数据。如果发送端一次Send了 64KB,接收端一次Receive只收到 32KB,剩下的 32KB 会在下一次OnReceive中到达。

解决:发送端循环发送直到所有字节发出;接收端不要假设一次Receive就能收完一个完整的数据块,按实际收到的字节数写入文件,用累计字节数判断是否收完。

5.3 现象:服务端只能接受一个客户端,第二个连接不上

原因:OnAccept里创建的CClientSocket对象是局部变量,函数返回后析构,socket 句柄被关闭。

解决:用new在堆上创建客户端 socket 对象,并把它加入一个全局或主对话框维护的列表中。连接断开时再从列表中移除并delete。

5.4 现象:中文文件名变成乱码

原因:MFC 默认使用CString(Unicode 或 MBCS),直接memcpy到char数组再发送,接收端按char解析,编码不一致。

解决:发送前统一转成 UTF-8,接收端按 UTF-8 解析后再转回CString。用CT2A和CA2T配合CP_UTF8参数做转换。

5.5 现象:传输大文件时程序内存暴涨

原因:每次OnReceive都new一个缓冲区但没有释放,或者把整个文件读入内存再发送。

解决:使用固定大小的栈缓冲区或复用一个堆缓冲区,发送端分块读取文件,接收端分块写入文件,任何时候内存中只保留一个数据块。

6. 进阶技巧:用哈希校验和断点续传让实验更接近真实工具

基础版本跑通之后,如果你想让这个实验看起来更完整,有两个方向可以加:文件完整性校验和断点续传。

完整性校验最简单的方式是在文件传完后,发送端再发一个 32 字节的 MD5 或 SHA-256 哈希值,接收端算完本地文件的哈希后比对。MFC 本身没有内置哈希函数,可以用 Windows CryptoAPI:

CString CalculateFileMD5(const CString& strFilePath) { CFile file; if (!file.Open(strFilePath, CFile::modeRead | CFile::typeBinary)) return _T(""); HCRYPTPROV hProv = 0; HCRYPTHASH hHash = 0; CryptAcquireContext(&hProv, NULL, NULL, PROV_RSA_FULL, CRYPT_VERIFYCONTEXT); CryptCreateHash(hProv, CALG_MD5, 0, 0, &hHash); const int nBufSize = 64 * 1024; BYTE* pBuf = new BYTE[nBufSize]; UINT nRead = 0; while ((nRead = file.Read(pBuf, nBufSize)) > 0) { CryptHashData(hHash, pBuf, nRead, 0); } delete[] pBuf; file.Close(); BYTE rgbHash[16] = {0}; DWORD cbHash = 16; CryptGetHashParam(hHash, HP_HASHVAL, rgbHash, &cbHash, 0); CString strResult; for (int i = 0; i < 16; i++) { CString strByte; strByte.Format(_T("%02x"), rgbHash[i]); strResult += strByte; } CryptDestroyHash(hHash); CryptReleaseContext(hProv, 0); return strResult; }

这个函数返回 32 个字符的十六进制字符串。发送端在文件数据发完后,把哈希值作为一条独立消息发过去;接收端收完后计算本地文件哈希,比对一致就在日志里显示「校验通过」,不一致就提示重新传输。

断点续传的思路是:接收端在写文件之前先检查本地是否已有同名文件,如果有,把已存在文件的大小作为偏移量发给发送端,发送端从该偏移量开始读取和发送。这需要在协议头部增加一个「起始偏移」字段。实现起来不复杂,但要注意偏移量必须和发送端确认一致,否则文件会错位。

这两个功能加上之后,你的实验作品就不只是一个「能传文件」的 demo,而是一个有校验、有恢复能力的工具雏形。我自己的习惯是每次做网络编程实验都至少加一个哈希校验,因为传输过程中出问题太常见了,没有校验就只能靠肉眼比对文件大小,那才是真正的玄学调试。希望帮到你。

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

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

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

立即咨询