做个网络编程实验,最让人上头的不是写代码,而是代码明明编译通过、界面也弹出来了,点下发送按钮却毫无反应。MFC 加 Winsock 这套组合在网络课程的实验四里出现频率极高,几乎成了检验一个人有没有真正搞懂 socket 生命周期的分水岭。它要求你在 Windows 桌面环境里,用 MFC 搭出一个能看的界面,用 Winsock 打通底层的 TCP 通信,最终交付一个可以双机互发消息的小系统,外加一份说得清设计思路的实验报告。听起来不难,真正动手才会发现坑全藏在细节里:阻塞式 recv 把界面卡成假死、CString 和 char* 之间转来转去变成乱码、客户端刚关掉服务端就抛异常、粘包导致消息串成一锅粥。这篇内容适合三类人看:正在赶这个实验、想一次跑通不返工的同学;学过理论但没写过一行 socket 代码、想把概念落地的自学者;以及需要写一份不像流水账的设计报告的人。我会按实际动手的顺序,把架构选择、界面层与通信层的衔接、协议设计、联调排查这几个环节拆开讲,代码给关键片段,参数给计算依据,坑给现场记录。
1. 实验目标拆解与整体架构设计
动手之前先想清楚一件事:这个实验的评分点从来不是"功能多花哨",而是"通信链路是否稳、异常是否被处理、设计是否有话说"。很多人一上来就猛堆界面控件,结果底层 socket 写成一坨,验收时对方一断线程序直接崩,前面做的全白费。所以第一部分先把需求和架构对齐,后面写代码才有章法。
1.1 这个实验真正要你交付的东西
把实验指导书翻译成人话,核心需求其实就四条。第一,有一个基于 MFC 的图形界面,能显示消息列表、能输入文本、有连接和断开按钮,这是"可演示"的门面。第二,用 Winsock 建立 TCP 连接,服务端能监听端口、接受多个客户端接入,客户端能主动连接指定地址和端口。第三,全双工收发,任意一端发消息,其他端能实时看到,不需要手动刷新。第四,异常可处理,包括连接失败、对方主动断开、端口被占用这些场景,程序不能直接退出或者卡死。
这四条里,最容易拿分也最容易被忽略的是第四条。我见过不少同学的 Demo 在本地两个窗口互通一切正常,一到验收环节,把服务端进程关掉,客户端立刻弹出一堆断言框,或者干脆无响应。原因很简单:他们只写了"正常路径",没写"异常路径"。socket 函数几乎每一个都有返回值,而很多人写完send就当它一定成功。
还有一条隐形需求:报告。这个实验通常要求交一份设计报告,包含架构图、流程图、关键代码说明、测试记录。报告不是把代码贴一遍,而是要说清"你为什么这么设计"。后面第 5 章我会专门讲怎么把调试过程写成加分项。
1.2 三种通信模型的取舍:阻塞、select、消息驱动
这是整个实验最重要的技术决策,直接决定后面所有代码的形态。Winsock 在 Windows 上处理多客户端,主流做法有三种,我先把对比摆出来,再说我的选择。
| 模型 | 核心机制 | 实现难度 | 界面响应 | 适用场景 |
|---|---|---|---|---|
| 阻塞 socket + 每连接一线程 | accept 后AfxBeginThread起工作线程,线程内死循环 recv | 低 | 好(因为 recv 在工作线程) | 课程实验首选,客户端数量少 |
| select 多路复用 | 一个循环里select监听可读集合,逐个处理 | 中 | 需要自己开线程跑循环 | 客户端数量中等、想练 IO 模型 |
| WSAAsyncSelect 消息驱动 | 把 socket 事件绑到窗口句柄,转成自定义 Windows 消息 | 中 | 天然好(事件在消息循环里派发) | MFC 环境下最"原生"的写法 |
我最终选的是第一种:阻塞 socket 加工作线程。理由有三点。其一,语义最直白,recv返回 0 就是对端关闭,返回 SOCKET_ERROR 就是出错,不需要维护 fd_set 集合,出问题好定位。其二,课程实验并发量极小,撑死十几个客户端,每连接一线程的开销完全可以忽略。其三,也是最关键的,这个方案把"网络 IO"和"界面"彻底隔开了,recv 阻塞在哪个线程,就只有那个线程停住,主线程的消息循环照常跑,界面不会假死。
WSAAsyncSelect确实更贴合 MFC 的风格,一个窗口过程处理所有网络事件,不需要线程同步。但它有个硬伤:所有事件都在主线程的消息循环里派发,如果你在事件处理函数里做耗时操作,界面照样卡。而且当你要处理"半包"这种需要暂存缓冲区的情况时,消息驱动的状态机写起来反而更绕。对这个实验而言,线程模型的可读性更重要。
注意:不管你选哪种模型,绝对不要在 UI 线程里直接调阻塞的
recv。这是这个实验里最高频的翻车点,界面会整个失去响应,连关闭按钮都点不动,只能去任务管理器结束进程。
1.3 工程结构与编译环境清单
环境这块没什么玄学,但版本对不上会浪费大量时间。我用的是 Visual Studio 的 MFC 桌面应用模板,工程类型选"基于对话框",字符集这一项建议直接选"使用多字节字符集",能省掉后面一大半的编码转换麻烦。如果你出于课程要求必须用 Unicode,也没问题,第 2 章会专门讲怎么干净地做转换。
关键配置清单如下:
- 项目属性 → 链接器 → 输入 → 附加依赖项,确认有
ws2_32.lib。多数模板会自动带上,但自己新建空工程时经常漏。 - 项目属性 → C/C++ → 预处理器,确认
_WINSOCK_DEPRECATED_NO_WARNINGS是否要加。inet_addr这类老函数在新版 SDK 里会报弃用警告,加宏抑制或者改用inet_pton都行,后者更规范。 - 平台工具集保持默认即可,x86 和 x64 都能跑,但要注意
SOCKET类型在 64 位下是UINT_PTR(无符号 64 位整数),不要用int去存,否则句柄被截断,后面所有操作都会返回 10038(非套接字)。
工程文件组织上,我习惯拆成三层:ChatClientDlg/ChatServerDlg负责界面和消息映射,NetCore封装 socket 的创建、连接、收发、关闭,Protocol放报文打包解包的纯逻辑函数。这么分的好处是调试时能快速定位问题出在哪一层——是界面没刷新,还是网络没收着,还是协议解包错了。很多人把所有代码堆在对话框类里,一个 cpp 文件两千行,出问题只能靠打印日志硬猜。
目录结构大概是这样:
ChatDemo/ ├── ChatDemo.sln ├── Client/ │ ├── ChatClientDlg.cpp / .h 界面与消息映射 │ ├── NetCore.cpp / .h 客户端 socket 封装 │ └── Protocol.cpp / .h 报文打包解包 └── Server/ ├── ChatServerDlg.cpp / .h 界面与服务端管理 ├── NetCore.cpp / .h 监听、accept、广播 └── Protocol.cpp / .h 与服务端共享的协议实现服务端和客户端可以是两个独立工程,也可以做成一个工程里的两套对话框。我倾向于两个工程,编译产物清晰,验收时双击两个 exe 就能演示,不用来回切换模式。
2. MFC界面层的关键实现细节
界面层看起来只是拖控件,实际上这个实验里一半的坑都在这儿。MFC 是个有年代感的框架,它的字符串类型、线程规则、消息机制都跟现代 C++ 差别不小,硬套网上抄来的 console 版 socket 代码,八成会在类型转换上卡住。
2.1 对话框工程与控件消息映射
界面需要的控件不多,但每个都有讲究。消息显示区用CListBox或者CRichEditCtrl,前者简单、性能好,后者支持富文本但初始化麻烦(要调AfxInitRichEdit2)。我建议用CListBox,加个垂直滚动条,每来一条消息调一次AddString再SetCurSel到末行,视觉上就是自动滚到底。
输入区用CEdit,记得把属性里的Multiline勾上但Want Return别勾,否则回车会被输入框吃掉,发不出去。发送按钮和连接按钮各一个,服务端的界面还要加一个客户端列表CListCtrl用来显示在线连接和选中的目标。
消息映射用类向导生成就行,但有一处要手动确认:自定义消息的映射宏。因为工作线程收到网络数据后,不能直接操作控件(MFC 的控件不是线程安全的),得通过PostMessage把数据"寄"回主线程。所以要在对话框头文件里定义消息 ID:
// ChatClientDlg.h #define WM_MSG_RECV (WM_USER + 100) #define WM_MSG_CLOSED (WM_USER + 101) #define WM_MSG_ERROR (WM_USER + 102) afx_msg LRESULT OnMsgRecv(WPARAM wParam, LPARAM lParam);然后在 cpp 里用ON_MESSAGE宏挂上处理函数,用向导加的话经常加错位置,手动写反而更稳:
BEGIN_MESSAGE_MAP(CChatClientDlg, CDialogEx) ON_BN_CLICKED(IDC_BTN_CONNECT, &CChatClientDlg::OnBnClickedBtnConnect) ON_BN_CLICKED(IDC_BTN_SEND, &CChatClientDlg::OnBnClickedBtnSend) ON_MESSAGE(WM_MSG_RECV, &CChatClientDlg::OnMsgRecv) ON_MESSAGE(WM_MSG_CLOSED, &CChatClientDlg::OnMsgClosed) ON_MESSAGE(WM_MSG_ERROR, &CChatClientDlg::OnMsgError) END_MESSAGE_MAP()WM_USER + 100这样的偏移量选多少随意,但要避开框架自己用的范围,习惯上从WM_USER + 100开始比较保险。
2.2 工作线程回传UI的正确姿势
线程回传是整个实验里最需要讲清楚的一环。工作线程的职责很单纯:死循环调recv,收到数据就打包成消息发回主线程。写法上有个陷阱——很多人直接在PostMessage里传CString*指针,然后在处理函数里delete。如果PostMessage失败(比如窗口已经销毁),这个指针就泄漏了;更糟的是如果发完消息主线程还没处理,工作线程已经退出,指针悬空,程序随机崩。
稳妥的做法是用new一块堆内存,PostMessage成功后由接收方负责释放,失败则由发送方自己释放,责任划分清楚:
UINT CClientWorker::Run() { char buf[4096]; while (m_bRunning) { int n = recv(m_sock, buf, sizeof(buf), 0); if (n > 0) { // 拷贝一份,所有权转移给主线程 CString* pMsg = new CString(Utf8ToCString(buf, n)); if (!::PostMessage(m_hWnd, WM_MSG_RECV, 0, (LPARAM)pMsg)) delete pMsg; // 投递失败,自己回收 } else if (n == 0) { ::PostMessage(m_hWnd, WM_MSG_CLOSED, 0, 0); // 对端正常关闭 break; } else { int err = WSAGetLastError(); if (err == WSAEWOULDBLOCK) continue; ::PostMessage(m_hWnd, WM_MSG_ERROR, err, 0); break; } } closesocket(m_sock); return 0; }对应的消息处理函数里delete:
LRESULT CChatClientDlg::OnMsgRecv(WPARAM, LPARAM lParam) { CString* pMsg = reinterpret_cast<CString*>(lParam); if (pMsg) { m_listMsg.AddString(*pMsg); m_listMsg.SetCurSel(m_listMsg.GetCount() - 1); delete pMsg; } return 0; }这套写法的关键在于"谁 new 谁不管,谁收谁 delete,投递失败自己兜底"。看着绕,但这是多线程环境下唯一能保证不泄漏、不悬空的分配规则。
注意:
AfxBeginThread创建工作者线程时,线程函数必须是UINT (AFX_CDECL *)(LPVOID)签名,且要用static成员函数或全局函数,普通成员函数因为隐含this指针参数,签名对不上,编译会报错。
2.3 CString、char*与编码的三角关系
编码问题在这个实验里造成的困扰,仅次于界面卡死。根源在于三个地方用的编码不一样:MFC 的CString在 Unicode 工程下是宽字符(UTF-16),send和recv只认字节数组,而控制台打印、文件写入又常常按 GBK 来。三层一错位,中文就变成乱码方块。
我的处理原则是:网络传输统一用 UTF-8,界面显示统一用 CString,中间做一次干净的双向转换。不要在send里直接丢CString,也不要在收包后直接SetWindowText。
// CString(宽) -> UTF-8 字节流,用于发送 std::string CStringToUtf8(const CString& str) { int len = WideCharToMultiByte(CP_UTF8, 0, str, str.GetLength(), nullptr, 0, nullptr, nullptr); std::string out(len, '\0'); WideCharToMultiByte(CP_UTF8, 0, str, str.GetLength(), &out[0], len, nullptr, nullptr); return out; } // UTF-8 字节流 -> CString(宽),用于显示 CString Utf8ToCString(const char* data, int n) { int len = MultiByteToWideChar(CP_UTF8, 0, data, n, nullptr, 0); CString out; wchar_t* p = out.GetBuffer(len); MultiByteToWideChar(CP_UTF8, 0, data, n, p, len); out.ReleaseBuffer(len); return out; }如果工程本来就是多字节字符集,那CString本身就是窄字符,直接(LPCSTR)强制转换就能喂给send,但要注意这时系统默认 ANSI 代码页通常是 GBK,双机联调时两侧的代码页必须一致,否则中文还是花。所以从"一次写对、以后复用"的角度,UTF-8 方案更省心。
还有一个隐藏坑:转换函数里MultiByteToWideChar的第五个参数如果用-1,会把结尾的\0也算进去,长度对不上;如果用n(实际收到的字节数),那就是精确解码。收到半包数据时绝不能提前解码,必须等整包拼完再转,否则一个汉字被切成两半,转出来的就是问号。
3. Winsock通信层的核心实现
把界面搭起来之后,真正决定程序能不能跑通的是通信层。这部分代码量不大,但每一行都有讲究,尤其是生命周期管理和收发边界这两块,写错一个地方,表现就是"有时能跑有时不能跑"这种最难查的问题。
3.1 初始化、套接字生命周期与资源回收
Winsock 的使用有个固定仪式:进程启动时调一次WSAStartup,退出前调一次WSACleanup,中间所有 socket 操作都在这两句话之间。位置选在对话框的OnInitDialog和OnDestroy最合适,因为这两个函数保证各执行一次。有人把它放在按钮响应里,点两次就调两次WSAStartup,引用计数乱了,最后WSACleanup收不干净。
BOOL CChatClientDlg::OnInitDialog() { CDialogEx::OnInitDialog(); WSADATA wsa; int ret = WSAStartup(MAKEWORD(2, 2), &wsa); if (ret != 0) { AfxMessageBox(_T("网络库初始化失败,错误码:") + CString(ret)); return TRUE; } // 校验版本,实际协商到的版本可能低于请求版本 if (LOBYTE(wsa.wVersion) != 2 || HIBYTE(wsa.wVersion) != 2) { WSACleanup(); AfxMessageBox(_T("当前环境不支持 Winsock 2.2")); return TRUE; } m_sock = INVALID_SOCKET; return TRUE; }MAKEWORD(2, 2)是请求 2.2 版本,这个版本号不是随便填的,2.2 才支持getaddrinfo、WSAPoll这些现代接口,2.0 和 1.1 属于历史包袱。
socket 的生命周期要严格遵循"创建、使用、关闭"三段式,且关闭必须成对。每一次成功的socket()都要对应一次closesocket(),每一次成功的accept()也要对应一次closesocket()。我习惯在类里维护一个连接列表std::vector<SOCKET>,断开时遍历关闭并清空,退出时再来一遍兜底。漏关的后果不是立刻报错,而是句柄泄漏,跑几百次连接之后socket()开始返回INVALID_SOCKET,错误码 10024(打开文件过多)。
void CChatServerDlg::CloseAllClients() { for (size_t i = 0; i < m_clients.size(); ++i) { if (m_clients[i] != INVALID_SOCKET) { shutdown(m_clients[i], SD_BOTH); // 先断收发方向 closesocket(m_clients[i]); m_clients[i] = INVALID_SOCKET; } } m_clients.clear(); }这里用了shutdown,它和closesocket的区别值得说一句:shutdown只关闭数据流方向,套接字本身还活着,对方会立刻收到 FIN,recv返回 0;closesocket才是真正释放句柄。先shutdown再closesocket能让对端及时感知断开,避免半开连接挂在那儿。当然直接closesocket也能断,只是对端的感知会有延迟,服务端还可能残留 TIME_WAIT 状态。
3.2 收发函数的语义差异与边界处理
send和recv的返回值语义是必须刻在脑子里的,这是排查一切通信问题的起点。
| 返回值 | recv 含义 | send 含义 | 处理方式 |
|---|---|---|---|
| > 0 | 实际收到的字节数 | 实际发出的字节数 | 继续,注意可能只是部分 |
| 0 | 对端已正常关闭连接 | 极少出现 | recv 返回 0 就关闭套接字 |
| SOCKET_ERROR | 出错,看WSAGetLastError() | 出错,看错误码 | 分错误码处理 |
最容易踩的是send的"部分发送"。TCP 发送缓冲区满了的时候,send只发出一部分就返回了,返回值小于你传入的长度。很多人写完send(sock, buf, len, 0)就完事,根本不看返回值,数据量大或者网络慢的时候就会丢数据。正确写法是循环发到发完为止:
bool SendAll(SOCKET s, const char* data, int len) { int sent = 0; while (sent < len) { int n = send(s, data + sent, len - sent, 0); if (n == SOCKET_ERROR) { int err = WSAGetLastError(); if (err == WSAEWOULDBLOCK) { Sleep(1); continue; } return false; } sent += n; } return true; }recv这边则是"可能收不全"。你以为发了一条 100 字节的消息,对端一次recv就能收到 100 字节,实际上可能只收到 30 字节,剩下 70 字节在下一次或下下次才到。这就是 TCP 是字节流协议的本质,它不保留消息边界。有些同学在本地测试时因为数据量小、走回环网卡,永远是一次收全,程序看着没问题,一换到局域网或者数据量变大就出乱码,原因就在这里。
注意:判断
recv出错时,WSAEWOULDBLOCK这个错误码在阻塞模式下一般不会出现,但如果将来把套接字设成非阻塞,它就变成了"暂时没数据"的正常状态,不能当错误处理,否则会误断连接。
3.3 自定义长度头协议解决粘包
既然 TCP 不保证消息边界,那就在应用层自己划边界。这是网络编程里最基础也最实用的技能,报告里把这部分写清楚,技术含量立刻上一个台阶。
常见方案有三种:定长报文、分隔符(比如\r\n)、长度前缀。定长最傻,消息长度变化就废;分隔符简单但内容里不能出现分隔符,聊天消息里打个换行就崩;长度前缀最通用,我推荐这个。
协议格式定义成:4 字节网络字节序的消息体长度 + 消息体本身。
+--------+--------+--------+--------+------------------+ | len (4 字节,大端) | body (len 字节) | +--------+--------+--------+--------+------------------+打包函数:
std::string PackMessage(const std::string& body) { uint32_t len = htonl(static_cast<uint32_t>(body.size())); std::string packet(reinterpret_cast<const char*>(&len), 4); packet += body; return packet; }htonl把主机字节序转成网络字节序(大端)。这一步不能省,x86 是小端,直接发uint32_t出去,对端按大端解析就是完全不同的数字。同样的道理,端口号要用htons,IP 地址用inet_addr或者inet_pton转。
解包需要一个缓冲区和一个状态:缓冲区里攒数据,只要还剩 4 字节以上就先读长度,长度字段加 4 不超过缓冲区现有数据量,就说明整包到了,取出来处理,然后从缓冲区里删掉已消费的部分,继续循环。
void OnDataReceived(const char* data, int n) { m_buffer.append(data, n); // 攒进缓冲区 while (m_buffer.size() >= 4) { uint32_t bodyLen = ntohl(*reinterpret_cast<const uint32_t*>(m_buffer.data())); if (bodyLen > MAX_BODY_LEN) // 防御:非法长度直接断开 { CloseConnection(); return; } if (m_buffer.size() < 4 + bodyLen) break; // 半包,等下一次数据 std::string body = m_buffer.substr(4, bodyLen); m_buffer.erase(0, 4 + bodyLen); DispatchMessage(body); // 派发给业务处理 } }这段代码有两个细节值钱。一是MAX_BODY_LEN的防御,如果不设上限,万一收到一个畸形报文说长度是 4GB,程序会直接去申请 4GB 内存然后挂掉,这是典型的协议攻击面,报告里提一句能加分。二是erase的性能,数据量小无所谓,如果要做优化可以用读指针偏移代替删除,减少内存搬移。
3.4 多客户端的并发处理方案
服务端要同时伺候多个客户端,这是实验里"多客户端"要求的落地点。结合前面选的"每连接一线程"模型,服务端的主线程只做一件事:死循环accept。accept 到一个新连接,就为它创建一个工作线程,把 socket 句柄传进去。
UINT CServerAcceptThread::Run() { sockaddr_in addrClient; int addrLen = sizeof(addrClient); while (m_bRunning) { SOCKET s = accept(m_listenSock, (sockaddr*)&addrClient, &addrLen); if (s == INVALID_SOCKET) { if (WSAGetLastError() == WSAEWOULDBLOCK) continue; break; } // 通知界面新增一个在线用户 CString* pInfo = new CString(FormatClientInfo(addrClient)); ::PostMessage(m_hWnd, WM_MSG_NEWCLIENT, (WPARAM)s, (LPARAM)pInfo); CClientThread* pThread = new CClientThread(s, m_hWnd); pThread->Start(); } return 0; }广播消息时有个经典陷阱:遍历连接列表挨个send,如果某个客户端已经断开,send会失败并返回错误码 10054(连接被对端重置)。如果这时直接在循环里break,后面的客户端就收不到消息了。正确做法是把失败的连接标记出来,循环结束后统一清理,绝不能在遍历过程中修改容器。
void Broadcast(const std::string& packet) { std::vector<SOCKET> failed; for (size_t i = 0; i < m_clients.size(); ++i) { if (!SendAll(m_clients[i], packet.data(), (int)packet.size())) failed.push_back(m_clients[i]); } for (size_t i = 0; i < failed.size(); ++i) RemoveClient(failed[i]); // 循环结束后再移除 }还有个必须解决的问题:多个工作线程同时往界面投递消息,也同时访问连接列表,这就是竞态。访问共享容器必须加锁,MFC 里用CRITICAL_SECTION或者CCriticalSection包一层。锁的粒度要小,只包住容器操作的那几行,别把send也包进去,否则一个慢客户端会把所有线程阻塞住。
4. 从零跑通的实操过程
理论说完,接下来是我真实的搭建顺序。这个顺序本身值得参考,因为按它走,每一步都能验证,出问题能立刻定位在哪一环,而不是写完两千行一起调,错了不知道从哪查。
4.1 服务端启动顺序与端口占用处理
服务端的启动流程是严格串行的四步:socket、bind、listen、accept。少一步都不行,顺序错了就报错。
bool CNetServer::Start(int port) { m_listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (m_listenSock == INVALID_SOCKET) return false; // 关键:允许地址复用,避免重启时端口还在 TIME_WAIT BOOL bReuse = TRUE; setsockopt(m_listenSock, SOL_SOCKET, SO_REUSEADDR, (const char*)&bReuse, sizeof(bReuse)); sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons((u_short)port); addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡 if (bind(m_listenSock, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { closesocket(m_listenSock); m_listenSock = INVALID_SOCKET; return false; } if (listen(m_listenSock, SOMAXCONN) == SOCKET_ERROR) { closesocket(m_listenSock); m_listenSock = INVALID_SOCKET; return false; } return true; }SO_REUSEADDR这条是我强烈建议加的。不加的后果是:你启动服务端、连一次、关掉进程、再立刻启动,会报 10048(地址已被占用)。原因不是端口真被占着,而是上一次的连接还处在 TIME_WAIT 状态,内核要等几分钟才释放。加了SO_REUSEADDR就能立刻重绑,调试效率差好几倍。
INADDR_ANY表示监听本机所有网卡,这样局域网内其他机器能连进来。如果只想本机测试,绑inet_addr("127.0.0.1")更安全。注意 IPv6 环境下INADDR_ANY不管用,得用in6addr_any,课程实验一般不用管。
listen的第二个参数是等待队列长度,SOMAXCONN让系统自己决定,比硬写 5 要合理。这个值只影响"还没被 accept 的连接能排多长队",跟最大并发数没关系,别理解错。
真遇到端口占用,命令行两句话能查清楚:
netstat -ano | findstr :8888 taskkill /PID 12345 /F第一条列出占用该端口的进程 PID,第二条强杀。注意别杀错进程,看 PID 对应的是不是你自己上次没退干净的实例。
4.2 客户端连接、超时与重连
客户端流程简单些:socket、connect、收发。但connect有个特性必须知道——它在阻塞模式下会一直等,如果目标主机不存在或者防火墙丢包,可能要等几十秒才返回失败,界面照样卡死。所以客户端的connect也该丢进工作线程。
bool CNetClient::Connect(const CString& ip, int port, int timeoutMs) { m_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (m_sock == INVALID_SOCKET) return false; sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons((u_short)port); addr.sin_addr.s_addr = inet_addr(CStringToUtf8(ip).c_str()); // 设置发送超时,避免长时间阻塞 DWORD tv = timeoutMs; setsockopt(m_sock, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tv, sizeof(tv)); if (connect(m_sock, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { closesocket(m_sock); m_sock = INVALID_SOCKET; return false; } return true; }严格来说,阻塞connect的超时不能靠SO_SNDTIMEO控制,那个只对send生效。真要做可控超时的连接,标准做法是把套接字设成非阻塞,调connect后立刻返回WSAEWOULDBLOCK,然后用select等在可写集合上,超时时间自己定,之后再用getsockopt(SO_ERROR)检查连接是否真的成功。这套写法代码多,实验里其实用不上,把connect放进线程、界面加个"正在连接"的状态提示就够了。我把这点写出来是因为报告里如果提到"连接超时处理",用SO_SNDTIMEO是错的,会被懂行的老师看出来。
重连逻辑建议做成一个简单的状态机:连接断开后,界面上的连接按钮恢复可用,用户点一下重新走一遍socket+connect全流程。别想着"复用旧 socket 重连",那个句柄在断开后已经不可用了,必须重新创建。我在早期版本里试过复用,connect一直返回 10038,查了半天才发现是句柄已经关掉了。
4.3 联调验证清单
代码写完,别急着点"验收"。按下面这个清单逐条走一遍,能挡掉绝大多数现场翻车。
| 序号 | 验证项 | 预期结果 | 常见异常 |
|---|---|---|---|
| 1 | 本机 127.0.0.1 双开互通 | 消息双向即时到达 | 防火墙拦截、端口写错 |
| 2 | 局域网两台机互通 | 同上 | IP 填了 127.0.0.1、服务端绑了回环地址 |
| 3 | 服务端先关,客户端反应 | 提示"连接已断开",界面不崩 | 未处理 recv 返回 0 |
| 4 | 客户端先关,服务端反应 | 在线列表移除该用户,广播不报错 | 遍历中删元素、send 失败未处理 |
| 5 | 连续发 100 条长消息 | 全部完整显示,无乱码无截断 | 未做粘包处理、部分发送未循环 |
| 6 | 中文与英文混合 | 显示正常 | 编码不统一、半包提前解码 |
| 7 | 重启服务端 | 能立刻重新监听 | 未加 SO_REUSEADDR |
| 8 | 非法 IP 连接 | 提示失败,程序不卡 | connect 在 UI 线程执行 |
第 1 项如果就不通,先别怀疑代码,去看 Windows 防火墙。第一次运行监听程序时系统会弹窗问"是否允许访问网络",很多人手快点了个"取消",之后所有外部连接都被静默丢弃,查半天代码也没用。控制面板里把该程序的规则删掉重加一次就好。
5. 常见问题与排查技巧实录
这一章是我这两年帮人看代码攒下来的东西,基本都是文档里不会写、但实际操作一撞就中的问题。我把它们分成编译期、运行期、文档期三类,方便对照。
5.1 编译链接期问题速查
错误 LNK2019:无法解析的外部符号__imp__WSAStartup@8。这是ws2_32.lib没链接上。除了检查附加依赖项,还要注意#pragma comment(lib, "ws2_32.lib")是很省事的写法,一行搞定,不用改工程设置。
错误 C2664:无法将参数 1 从CString转换为const char*。Unicode 工程下CString是宽字符,直接传send当然不行。要么按 2.3 节做转换,要么临时用CT2A宏,但要清楚CT2A返回的是栈上的临时对象,别把它的指针存起来跨作用域用。
警告 C4996:inet_addr被声明为已弃用。加_WINSOCK_DEPRECATED_NO_WARNINGS宏抑制,或者换inet_pton(AF_INET, str, &addr.sin_addr),后者还顺便做了格式校验,输入非法 IP 会返回 0,比inet_addr的INADDR_NONE语义更清晰。
AfxBeginThread报"无法将参数从重载函数转换为 UINT (__cdecl)(LPVOID)"。* 线程函数必须是静态成员或者全局函数,且签名严格是UINT Func(LPVOID)。写成普通成员函数会因为隐含this参数而签名不符,静态化就好。
还有一类诡异问题是工程字符集和第三方代码不匹配导致的,症状是编译过了但运行时中文乱码。检查一下项目属性里的"字符集"设置,如果引用了别人的源文件,两边设置必须一致。
5.2 运行期故障的排查顺序
网络程序出问题,最忌讳瞎改。我总结了一个固定顺序,从下往上查,命中率很高。
第一步,先确认网络层通不通。命令行ping一下对端 IP,telnet IP 端口或者Test-NetConnection测一下端口是否可达。ping 通但端口不通,问题在服务端没监听或者防火墙;ping 都不通,那是网络层的事,改代码没用。
第二步,看错误码。Winsock 的错误码是可查的,几个高频的必须记住:
| 错误码 | 宏名 | 典型原因 | 处理方向 |
|---|---|---|---|
| 10048 | WSAEADDRINUSE | 端口被占用 | 换端口或加 SO_REUSEADDR |
| 10049 | WSAEADDRNOTAVAIL | 绑定的 IP 不属于本机 | 改 INADDR_ANY 或本机实际 IP |
| 10054 | WSAECONNRESET | 对端强制关闭 | 正常现象,清理连接即可 |
| 10061 | WSAECONNREFUSED | 目标端口无人监听 | 确认服务端已启动、端口对 |
| 10038 | WSAENOTSOCK | 套接字句柄无效 | 检查是否已关闭、是否被截断 |
| 10093 | WSANOTINITIALISED | 没调 WSAStartup | 补初始化调用 |
第三步,看数据是否到了工作线程。在最底层的recv后面打日志,把收到的字节数和前几个字节打出来。如果这里就没数据,问题在一端发送;如果这里有数据但界面没显示,问题在投递或解包。这一步能直接把问题范围砍一半。
第四步,看解包。把缓冲区内容按十六进制打出来,对照协议格式逐字节核对长度字段对不对。粘包问题几乎都能在这一步现形。
第五步,看界面。前四步都正常,那就只剩 UI 更新逻辑了,检查自定义消息是否注册、处理函数是否真的被调、控件的m_hWnd是否有效。
注意:调试期间把日志写到一个追加模式的文本文件里,比
OutputDebugString好用,因为多线程环境下调试输出容易丢,而且文件能留着事后翻。我习惯每个连接建一个日志文件,命名带上端口号和线程 ID,出问题一眼能看出是哪个连接。
另外分享一个定位"偶发卡死"的技巧:程序假死时,别急着重启,打开 Visual Studio 的"附加到进程",点"全部中断",看所有线程的调用栈。如果主线程停在recv或者connect,那就实锤了——你把阻塞调用放到了 UI 线程。这个操作十秒钟就能定位,比加十个断点强。
5.3 报告撰写:把调试过程写成加分项
最后说报告,因为这块的投入产出比其实很高。很多人把报告写成代码说明书,"这个函数的作用是发送消息",读起来像 API 文档,毫无信息量。真正有分量的报告应该回答三个问题:为什么这么设计、遇到什么问题、怎么解决的。
结构上我建议这么排:需求分析说清楚要实现哪些功能以及边界在哪;总体设计放一张模块划分图,讲清界面层、通信层、协议层各自的职责和接口;详细设计挑两三个关键点深入,比如粘包处理、线程同步、编码转换,配上核心代码和注释;测试与问题分析是重点,把第 4 章的验证清单和遇到的典型问题写进去,每个问题写清现象、排查过程、根因、解决方案。
有一类内容特别容易加分,就是"被否决的方案"。比如我最初考虑过在 UI 线程直接recv,因为代码最短,但实测发现界面会卡死,于是改用工作线程;又比如我试过用分隔符\r\n做消息边界,结果用户输入里带换行就出错,于是换成 4 字节长度前缀。把这些"试错、否定、再选型"的过程写出来,比单说"我用了长度前缀协议"有说服力得多,因为它展示了真实的工程判断过程。
还有个小细节:测试截图别只截成功的那一张。截一张正常收发的、一张中文混合的、一张断线提示的,三张图放在一起,说明你考虑过异常场景。评审的人看到断线提示那一张,心里的评价立刻不一样。
我个人在做这类实验时最深的体会是:真正拉开差距的不是功能多少,而是当对面那个人把网线拔掉的时候,你的程序会怎么反应。功能堆得再花哨,一个异常就崩,说明你写的只是"能跑的代码",不是"能用的程序"。所以如果你时间有限,我建议把精力优先投在两件事上——把所有阻塞调用赶出 UI 线程,把所有send和recv的返回值都老老实实处理一遍。这两件事做完,你的 Demo 在验收时的表现会稳定得让你自己都意外。剩下的界面美化、多房间、文件传输那些扩展,等基础链路稳了再往上叠。