
简介面向Windows平台开发者的MFC TCP通信示例基于Socket接口实现标准C/S架构适合初学网络编程或需要快速搭建客户端-服务器模型的读者。程序采用CSocket封装Winsock完整演示从初始化、绑定端口、监听、接受连接到客户端发起连接、发送与接收数据、关闭套接字的流程有助于理解TCP协议下可靠通信的建立与释放机制。压缩包共4个文件含2个C源文件与2个头文件分别对应客户端连接对话框和服务端监听对话框两端代码相互独立、结构清晰压缩后大小约6KB便于直接阅读、导入工程或对照修改。该项目已有1261人学习浏览适合用作课程设计、毕业设计或MFC入门练习的参考底座也可在此基础上扩展多线程、消息分包、断线重连等进阶功能。1. 用 MFC 写 TCP 通信为什么这套 C/S 源码值得花一下午拆开看做过上位机的都知道串口调通了不叫通TCP 能连上并把数据完整送出去才算“活”。TCP 通信的麻烦在于服务端要监听、多客户端要区分、recv 拿到的是没有边界的字节流中间还可能被防火墙拦一下表现出来就是莫名其妙的超时。这套基于 socket 通信的 MFC TCP C/S 架构程序正好把这几件事都串了起来——MFC 负责窗口和消息循环WinSock 负责收发业务逻辑放在独立线程UI 通过自定义消息更新。适合正在做上位机、需要跟下位机或第三方程序做 TCP 协议对接的开发者也适合刚接触 MFC 网络编程、想找一份能改的工程模板的人。你不需要背代码关键是看它怎么组织连接、线程和消息然后往自己的业务里填。2. C/S 架构与线程模型先约定好协议再谈 socket 收发2.1 为什么用原生 socket 而不是 CAsyncSocketMFC 自带两个网络封装类CAsyncSocket 和 CSocket。CAsyncSocket 是异步事件模型靠窗口消息触发网络事件CSocket 在它上面加了阻塞语义简单封装了个“像文件一样读写 socket”的假象。这两类在实际工程里用起来都不顺手。CAsyncSocket 把网络事件全压到窗口消息里事件回调中做耗时操作会把主线程拖垮调试时错误码还要绕一层封装CSocket 的发数据操作是阻塞的一个 connect 卡住整个 UI 就冻住了。所以我一般的选择是MFC 只用来创建窗口、跑消息循环、刷新控件网络层面直接用原生 WinSock API收发逻辑放到独立线程。原生 socket 的每一步都看得见socket 创建、bind 绑端口、listen 开始监听、accept 接收连接、recv/send 收发数据任何一步失败都能用 WSAGetLastError 拿到明确错误码。CAsyncSocket 把这些细节包进黑匣子真出了问题反而难定位。用原生 API 还有个好处这套代码如果以后要移植到 Win32 控制台或者封装成 DLL 给别的界面层用网络部分一行都不用改。线程模型我建议这样定一个主窗口线程跑界面一个 accept 循环线程接连接每个客户端再开一个接收线程。客户端数量在几十路以内时每连接一线程是代码最简单、最容易维护的方案如果单机要撑上千路连接才需要考虑 select 或者 IOCP。2.2 协议先行长度前缀与粘包半包处理TCP 是字节流没有消息边界。你 Send 一个 100 字节的包对端可能分两次 recv 才能收全也可能跟后面的包粘在一起到达这就是所谓的粘包和半包。跟串口不同TCP 的收发缓冲和滑动窗口会把边界问题藏得很隐蔽。所以写 socket 程序第一步不是写收发代码而是定协议。这个 MFC 工程采用的方案是“长度前缀”每个数据包前固定 2 字节或 4 字节放长度比如前 2 字节是小端序的 short表示后续数据体有多少字节。接收端先收满 2 字节长度头再按长度收数据体收完一个完整包后回到收头状态。这个状态机几乎能解决所有粘包半包问题——核心思想不是“recv 了多少就消费多少”而是“攒够一个完整包再交给业务层”。如果收到的数据不足包头长度就留在缓冲区里等下一轮 recv如果一次性收到了两个包的数据就按解析完第一个包、剩下的部分从参数第二位开始继续解析第二个包。多字节数值字段还有一个老坑字节序。超过 1 字节的 int、short、float 在网络传输时要统一转成网络字节序发送端用 htons/htons接收端用 ntohs/htons 还原。如果数据全是 ASCII 文本可以偷懒一旦带数值字段不做转换高位低位对调的“玄学 bug”就会不定期出现。协议定好之后再写收发代码多半能一次跑通。3. 服务端实现监听流程、接受循环与回显处理3.1 WinSock 初始化与监听端口服务端第一件事是初始化 WinSock 库。版本建议用 2.2也就是 MAKEWORD(2,2)这是现在 Windows 上最通用的版本。然后创建监听 socket绑定地址。绑定时 INADDR_ANY 表示监听所有网卡的随机IP如果只想让特定网卡接入可以把 inet_addr 指定成一个具体的 IP。// 服务端初始化创建监听套接字并开始监听 #include winsock2.h #include ws2tcpip.h #pragma comment(lib, ws2_32.lib) WSADATA wsa; if (WSAStartup(MAKEWORD(2, 2), wsa) ! 0) { // 返回非 0 说明 WinSock 初始化失败直接退出 return -1; } SOCKET hListen socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (hListen INVALID_SOCKET) { // 用 WSAGetLastError() 查具体原因常见 10047协议不支持 WSACleanup(); return -1; } sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); // 监听本机所有网卡 addr.sin_port htons(9000); // 端口固定 9000按需改 if (bind(hListen, reinterpret_castsockaddr*(addr), sizeof(addr)) SOCKET_ERROR) { // 如果 WSAGetLastError() WSAEADDRINUSE(10048)说明端口被占 closesocket(hListen); WSACleanup(); return -1; } if (listen(hListen, SOMAXCONN) SOCKET_ERROR) { closesocket(hListen); WSACleanup(); return -1; }bind 失败的场景我见过太多次了不是端口被上一轮调试残留进程占着就是 TIME_WAIT 状态还没释放。遇到 10048先查端口归属bash netstat -ano | findstr 9000找到 PID 再去任务管理器确认是什么进程如果是自己之前的调试程序残留直接结束进程如果确认无进程占用那多半是 TIME_WAIT 状态解决办法在第 5 章展开。listen 的第二个参数 backlog 表示排队等待 accept 的连接队列长度MFC 上层应用写SOMAXCONN就够了系统会按当前配置取一个合理上限。3.2 接受连接与收包线程监听建立之后accept 循环通常单独起一个线程避免 accept 阻塞住 UI。每接到一个客户端就为它创建一个接收线程。这个接收线程的任务很简单循环 recv把字节流按协议解析成完整数据包再交给业务处理。回显示例里收到什么就原样 send 回去。// 每个客户端独立的收包上下文 typedef struct { SOCKET s; char recvBuf[8192]; // 该连接专属接收缓冲 } ClientCtx; DWORD WINAPI ClientThread(LPVOID param) { ClientCtx* ctx (ClientCtx*)param; int pos 0; // 当前已收字节数 int need 2; // 先收 2 字节长度头 for (;;) { int n recv(ctx-s, ctx-recvBuf pos, need - pos, 0); if (n 0) { pos n; if (pos need) { // 收满长度头读取数据体长度 short bodyLen (short)((ctx-recvBuf[0]) | (ctx-recvBuf[1] 8)); if (bodyLen 8192 - 2) { // 长度异常协议不匹配断开连接 break; } // 状态推进到“收 body”继续 recv…… // 完整包收齐后交给业务处理再回到 pos0 } } else if (n 0) { break; // 对端正常关闭 } else { int err WSAGetLastError(); if (err WSAECONNRESET) break; // 对端强拆 if (err WSAEWOULDBLOCK) continue; // 非阻塞时使用 break; } Sleep(5); // 给其他线程让时间片避免 CPU 打满 } closesocket(ctx-s); delete ctx; return 0; }这里有两个关键点必须注意。第一每个客户端必须用自己的 recvBuf绝对不能用一个全局缓冲多个线程共用否则两个客户端同时来数据会互相覆盖出现“串包”。第二recv 返回值的语义要分清大于 0 是收到字节数等于 0 是对端正常关闭SOCKET_ERROR 要再取错码判断区分对端 RESET、超时、非阻塞重试。很多初学者把n 0当超时处理结果对端正常关闭后服务端永远停不了线程。accept 线程伪代码如下注意对 hClient 的保存和关闭都必须在临界区里做因为客户端线程退出时也要从列表移除std::vectorSOCKET g_clients; CRITICAL_SECTION g_cs; DWORD WINAPI AcceptLoop(LPVOID) { for (;;) { sockaddr_in clientAddr{}; int addrLen sizeof(clientAddr); SOCKET hClient accept(hListen, (sockaddr*)clientAddr, addrLen); if (hClient INVALID_SOCKET) { if (WSAGetLastError() WSAEINTR) continue; break; // 监听套接字本身出错退出循环 } EnterCriticalSection(g_cs); g_clients.push_back(hClient); LeaveCriticalSection(g_cs); ClientCtx* ctx new ClientCtx; ctx-s hClient; HANDLE hThread CreateThread(NULL, 0, ClientThread, ctx, 0, NULL); if (hThread) CloseHandle(hThread); } return 0; }g_clients 列表的用途不只是登记你以后要做群发、心跳检测、连接数量统计都从这个列表遍历。临界区必须锁住增删操作否则新客户端接入的同时另一个客户端退出vector 内部数据错乱就会崩溃。4. 客户端实现超时 connect、收发线程与状态栏提示4.1 connect 的三类失败结果与超时控制客户端的 connect 有几种典型失败经验不足时见到哪个都懵。最常见的是 10061目标计算机积极拒绝原因是服务端没启动、端口写错、或者服务端 listen 的 IP 和客户端访问的 IP 不一致。其次是 10060连接超时多半是网络不通或者防火墙静默丢包。还有 10065网络不可达常见于跨网段但路由没配好。默认的阻塞 connect 很坑服务端没起或者被防火墙丢包时可能要卡几十秒才返回错误界面直接“未响应”。我一般不用裸 connect而是把它切成非阻塞再用 select 做超时判断// 带 3 秒超时的 connect避免连不上时卡死界面 u_long mode 1; ioctlsocket(hSocket, FIONBIO, mode); // 先切非阻塞 int ret connect(hSocket, (sockaddr*)addr, sizeof(addr)); if (ret SOCKET_ERROR) { int err WSAGetLastError(); if (err WSAEWOULDBLOCK) { // 连接动作已发起等待 select 结果 fd_set wset; FD_ZERO(wset); FD_SET(hSocket, wset); timeval tv{3, 0}; if (select(0, NULL, wset, NULL, tv) 0) { // 可写说明 connect 成功调回阻塞模式 mode 0; ioctlsocket(hSocket, FIONBIO, mode); } else { int err2 WSAGetLastError(); // err2 WSAETIMEDOUT - 网络不通 / 防火墙拦截 // err2 WSAECONNREFUSED - 服务端未启动 / 端口错误 closesocket(hSocket); return -1; } } else { // 其他错误地址无效、协议错误等 closesocket(hSocket); return -1; } }这段代码把超时从“几十秒不可控”变成“3 秒定死”。select 的第一个参数在 Windows 上要写 0因为它跟 Linux 不同不按 fd 值排序。select 返回可写不代表 connect 一定成功还得检查 SO_ERROR 确认没有异常不过对大多数上位机场景这种程度已经够用。如果你不想切非阻塞也可以用 setsockopt 设 SO_RCVTIMEO但我试下来 connect 阶段 select 的方式最直观。4.2 UI 回调与断线重连客户端连接成功之后收发最好也放到独立线程里界面只管显示和接收用户输入。接收线程拿到数据一定不能直接在 worker 里操作控件——窗口句柄跟创建它的线程绑定跨线程直接 SetWindowText 轻则闪烁重则触发 ASSERT 崩溃。正确姿势是把数据通过 PostMessage 丢回 UI 线程// 接收线程收到数据后 PostMessage 通知 UI DWORD WINAPI ClientRecvThread(LPVOID param) { SOCKET hCli (SOCKET)param; char buf[4096]; for (;;) { int n recv(hCli, buf, sizeof(buf), 0); if (n 0) { // 拷贝一份新内存随消息传给 UI 线程 char* pData new char[n]; memcpy(pData, buf, n); PostMessage(hMainWnd, WM_MY_SOCKET_DATA, (WPARAM)pData, (LPARAM)n); } else if (n 0 || WSAGetLastError() ! WSAEWOULDBLOCK) { break; // 连接断开线程结束 } } PostMessage(hMainWnd, WM_MY_SOCKET_CLOSED, 0, 0); return 0; }UI 侧用 ON_MESSAGE 映射处理函数把收到的字节转成 CString 显示到编辑框。注意 PostMessage 传过去的指针接收方处理完之后要负责 delete否则每次收包都泄漏一块内存。ON_MESSAGE(WM_MY_SOCKET_DATA, CMainDlg::OnSocketData) void CMainDlg::OnSocketData(WPARAM wParam, LPARAM lParam) { char* pData (char*)wParam; int len (int)lParam; CString str((LPCSTR)pData, len); m_editRecv.SetWindowText(str); delete[] pData; }断线重连是客户端比服务端多出来的活儿。接收线程遇到 recv 返回 0 或错误就置一个连接状态标志PostMessage 通知 UI 显示“连接已断开”。重连我一般用定时器实现UI 线程里 SetTimer 每 5 秒触发一次检查状态标志如果还是断开就自动发起 connect。这样实现简单逻辑也好控制——重连期间用户还能正常操作界面不会出现“程序卡死等重连”的假死感。状态栏显示也是这个套路。MFC 状态栏要显示连接信息直接在 UI 线程调用m_wndStatusBar.SetPaneText(0, strMsg)。如果你当前是用 CMainFrame 里的状态栏记得先把m_wndStatusBar.SetIndicators准备好分区否则 SetPaneText 的索引会越界报错。很多人在状态栏翻车不是因为 SetPaneText 不会用而是指示器数组和索引没对齐。5. 常见问题与避坑五个高频故障的排查记录这一章写的是我在真实项目里踩过的坑每一条都是现象、原因、解决三步走照着排查能省不少时间。5.1 端口被占bind 报 10048现象服务端启动时 bind 返回 SOCKET_ERRORWSAGetLastError 得到 10048提示“通常每个套接字地址(协议/网络地址/端口)只允许使用一次”。原因端口号没换过上一轮运行的进程还没退出或者 socket 处于 TIME_WAIT 状态没有释放。解决先netstat -ano | findstr 9000查端口归属找到 PID 去任务管理器确认是残留就杀掉。如果确认没有进程监听但仍然绑不上八成是 TIME_WAIT可以用 SO_REUSEADDR。Windows 下设置 SO_REUSEADDR 的语义跟 Linux 不完全一样但对付这个场景够用BOOL bReuse TRUE; setsockopt(hListen, SOL_SOCKET, SO_REUSEADDR, (const char*)bReuse, sizeof(BOOL));优先建议换一个端口测试确认问题来源再回来处理 TIME_WAIT。调完 SO_REUSEADDR记得测试“服务端闪退后立刻重启”这个场景。5.2 客户端连不上先分清是防火墙还是服务端没起现象客户端 connect 超时10060或者被拒绝10061服务端那边 netstat 看端口确实在监听。原因10061 通常是服务端进程没起来、IP 写错、端口写错10060 则很可能是 Windows 防火墙把入站连接拦了connect 请求发出去就被静默丢弃。解决先确认服务端 listen 地址。如果 bind 的是 127.0.0.1那局域网其他机器自然连不进必须 bind INADDR_ANY。然后检查防火墙入站规则开发阶段可以直接加一条放行netsh advfirewall firewall add rule nameTCP9000 dirin actionallow protocolTCP localport9000这条命令只放行 TCP 9000 端口不用把整个防火墙关掉。加了规则还连不上再用 telnet 做一次裸测试telnet 192.168.1.100 9000能通说明 socket 代码有问题退回代码排查不通说明网络路径或防火墙还有问题不用在代码里浪费时间。5.3 多客户端“串包”共享接收缓冲的坑现象两个客户端同时发数据服务端收到的内容出现“A 的数据头 B 的数据体”解析出来的包乱七八糟。原因接收线程共用了同一个全局 char buff。线程 A recv 写了一半线程 B 也往里写数据互相覆盖。解决这是最典型的多线程共享资源问题。每个客户端连接分配独立的收包缓冲就是我第 3 章代码里 ClientCtx 的做法。缓冲要跟着连接走不能跟着 recv 调用走。如果你的服务端要支持多客户端连接上下文里除了 SOCKET还要带上解析状态——当前收的是头还是 body、已收多少、期望多少。这些状态放全局就是串包和崩的根源。5.4 界面卡死工作线程直接碰控件的后果现象运行一段时间后界面无响应或者 Debug 版弹出断言“ASSERTION FAILED”程序直接崩。原因工作线程里直接 SetDlgItemText、SetWindowText、操作 CListCtrl跨线程操作 UI 控件。MFC 控件不是线程安全的底层窗口句柄关联创建线程的消息队列跨线程操作轻则显示错乱重则崩溃。解决所有 UI 更新一律走 PostMessage 回 UI 线程。我现在写这类程序会定一个死规矩worker 线程里禁止出现任何控件的名字。数据要上界面就是 new 一块内存塞进消息参数PostMessage 出去UI 线程处理完负责 delete。这个规矩从源头杜绝了“顺手在收包线程里刷新一下”的冲动。5.5 服务重启后 bind 失败TIME_WAIT 与 SO_REUSEADDR现象服务端正常退出后立刻重启bind 报 10048等一两分钟再启动又正常了。原因TCP 连接关闭后主动关闭方服务端的 socket 会进入 TIME_WAIT 状态持续约 4 分钟期间端口被占用bind 无法复用。这就是所谓的“后悔药窗口”——关了就立刻开不了。解决监听 socket 上设置 SO_EXCLUSIVEADDRUSE 或 SO_REUSEADDR前者更严格要求端口不能被其他进程占用。我实际开发中习惯两个一起配合先设 SO_EXCLUSIVEADDRUSE失败再回退 SO_REUSEADDR。另外程序退出时要保证 closesocket 调用完整、WSACleanup 执行别用“杀进程”代替正常退出否则 TIME_WAIT 只会更频繁出现。6. 串成最小 Demo端口检测、静默验证与 netsh 调优6.1 单进程自测协议逻辑把服务端和客户端拆成两个工程测试太慢了。我复现这个方案时习惯先拿一个控制台进程把协议逻辑跑通进程里自己起一个服务端接收线程主线程当客户端去连它一来一回验证收发。// 单进程自测服务端线程 客户端逻辑 #include winsock2.h #include ws2tcpip.h #include thread #include cstdio #pragma comment(lib, ws2_32.lib) int main() { WSADATA wsa; WSAStartup(MAKEWORD(2, 2), wsa); SOCKET hListen socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in addr{AF_INET, htons(9000), {0}}; addr.sin_addr.s_addr htonl(INADDR_LOOPBACK); bind(hListen, (sockaddr*)addr, sizeof(addr)); listen(hListen, 5); std::thread([] { SOCKET c accept(hListen, NULL, NULL); char buf[128]; int n recv(c, buf, sizeof(buf), 0); send(c, buf, n, 0); // 回显 closesocket(c); }).detach(); SOCKET c socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); connect(c, (sockaddr*)addr, sizeof(addr)); send(c, ping, 4, 0); char buf[128]; int n recv(c, buf, sizeof(buf), 0); printf(recv: %.4s\n, buf); closesocket(c); closesocket(hListen); WSACleanup(); return 0; }这段代码几分钟就能跑完主要验证三件事WSAStartup 版本配置对不对、收发能否回环、字节流行为是否正常。单进程自测通过后再拆成两个 MFC 工程做真实联调排查范围就小得多。注意我这里用了 INADDR_LOOPBACK 只连本机联调时改成目标机器 IP。6.2 上线前的三件套检查联调通过不叫完事我每次提交 C/S 工程前都会强制走三遍检查。第一端口预检服务端启动前netstat -ano | findstr 9000确保端口干净再用。第二防火墙规则确认跟现场环境不一致就按 5.2 的命令补一条入站规则别指望现场工程师帮你关防火墙。第三TCP 参数确认如果你发现 TCP 在虚拟网卡或 NAT 容器环境下偶发高延迟看下时间戳和自动调优级别netsh int tcp set global timestampsenabled netsh interface tcp show globaltimestamps 开启后可以用时间戳消除 PAWS 问题但这是全局配置不影响正常场景。我一般只在确认“代码没错、网络环境异常”时开它开完观察一两个星期再决定是否保留。还有一点提交 MFC 工程时别用默认的共享 DLL 运行时改成静态链接/MT否则目标机器缺 msvcp140.dll 又得折腾半天安装环境。从那以后我每搭一个 C/S 工程启动前都强制走一遍编译清零、端口预检、抓包复看的流程。这套 MFC TCP 工程你拿下来跑一遍就会发现socket 本身不难真正的复杂度在线程和消息之间的配合——把日志打好、把状态机理清多数故障一眼就能定位。希望帮到你。本文还有配套的精品资源点击获取