简介:面向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 而不是 CAsyncSocket
MFC 自带两个网络封装类: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_cast<sockaddr*>(&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…… // 完整包收齐后,交给业务处理,再回到 pos=0 } } 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::vector<SOCKET> 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_ERROR,WSAGetLastError 得到 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 name="TCP9000" dir=in action=allow protocol=TCP localport=9000这条命令只放行 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 timestamps=enabled netsh interface tcp show globaltimestamps 开启后可以用时间戳消除 PAWS 问题,但这是全局配置,不影响正常场景。我一般只在确认“代码没错、网络环境异常”时开它,开完观察一两个星期再决定是否保留。还有一点,提交 MFC 工程时别用默认的共享 DLL 运行时,改成静态链接(/MT),否则目标机器缺 msvcp140.dll 又得折腾半天安装环境。
从那以后,我每搭一个 C/S 工程,启动前都强制走一遍编译清零、端口预检、抓包复看的流程。这套 MFC TCP 工程你拿下来跑一遍就会发现,socket 本身不难,真正的复杂度在线程和消息之间的配合——把日志打好、把状态机理清,多数故障一眼就能定位。希望帮到你。
本文还有配套的精品资源,点击获取