简介:这份《c++网络编程实例.pdf》面向具备一定 C++ 基础、希望入门 Windows 网络编程的开发者与在校学生,帮助读者理清网络通信的基本原理与编程模型。内容围绕网络编程概述、OSI 七层网络模型、TCP/IP 协议簇与 C/S 编程模型展开,并进一步讲解 MFC 封装的 CAsyncSocket、CSocket 类以及 Windows API 两种开发方式,同时涉及流式套接字与数据报套接字、网络字节顺序等关键概念,便于对照理解 TCP 与 UDP 的适用场景。资源包共 1 个 pdf 文件,大小约 114KB,轻量易读,适合作为随查随用的入门讲义。目前已有 564 人学习,可作为网络编程基础阶段的参考材料,帮助读者建立从协议分层到套接字编程的完整认知框架。
1. 从一份 VC 网络编程实例说起:MFC 套接字到底能不能打
翻到一份名为《C++ 网络编程实例.pdf》的资料,第一章就把 OSI 七层、TCP/IP 四层、C/S 模型、CAsyncSocket 和 CSocket 全铺开了。很多人看到“Visual C++ 网络编程”第一反应是这玩意儿是不是过时了,毕竟现在随手一个 Python 的 socket 几行就能跑通。但如果你手上维护的是工控上位机、老式 MES 客户端、或者某些只认 MFC 的产线软件,这套东西就是绕不过去的硬骨头。这份资料的价值不在于教你写一个能跑通的 demo,而在于把 Winsock 在 MFC 里的封装逻辑讲清楚了——CAsyncSocket 怎么异步收包、CSocket 怎么配合 CArchive 做串行化、网络字节序为什么必须转。它适合两类人:一是被派去接手老 VC 项目的维护岗,二是想搞明白阻塞/非阻塞在 Windows 消息循环里到底怎么落地的新手。下面我按自己拆包复现的节奏,把这份资料里能直接抄作业的部分和容易翻车的地方捋一遍。
2. 把 OSI 七层和 TCP/IP 四层映射到 MFC 代码:选型前先对齐模型
2.1 为什么 MFC 网络编程要先看模型再写代码
资料里花了不少篇幅讲 OSI 七层和 TCP/IP 四层,很多人直接跳过去看 CAsyncSocket 的成员函数。但实际排错的时候,模型是唯一的坐标系。比如客户端 Connect 返回 WSAECONNREFUSED,你如果不知道这是传输层 TCP 三次握手被 RST 打断,就会去应用层瞎找。OSI 七层里,物理层对应网卡、数据链路层对应驱动、网络层对应 IP 路由、传输层对应 TCP/UDP 端口、会话层到应用层在 Winsock 里基本揉在一起由你的 MFC 代码处理。TCP/IP 四层模型把会话层、表示层、应用层合并成应用层,所以你在 MFC 里调 Send 的时候,数据其实是从应用层一路往下压包头,到了物理层才变成电信号。
资料表 1.1 和表 1.2 把各层功能列得很清楚,但有个细节容易被忽略:数据链路层在原文里写的是“压缩与加压缩”,这个表述其实不太准确,实际做的是帧封装和差错校验。不过不影响理解,你只要记住每一层都在数据前面加自己的头,接收方再一层层剥掉。在 MFC 里,这个“加头剥头”的过程 Winsock 已经帮你做了,你只需要关心应用层的数据格式和传输层的 TCP/UDP 选择。
2.2 TCP 和 UDP 在 MFC 里的选型对照
资料里明确说了 TCP 面向连接可靠、UDP 不可靠但适合实时性要求高的场景。落到 MFC 代码里,这个选择直接决定你用哪个套接字类型。流式套接字 SOCK_STREAM 对应 TCP,数据报套接字 SOCK_DGRAM 对应 UDP。CAsyncSocket 和 CSocket 默认走的是流式,如果你要发 UDP,得在 Create 的时候显式传 SOCK_DGRAM。
下面这张表是我根据资料内容和实际项目经验整理的选型对照,参数部分补充了 MFC 里对应的设置方式:
| 对比项 | TCP(SOCK_STREAM) | UDP(SOCK_DGRAM) |
|---|---|---|
| 连接方式 | 三次握手,面向连接 | 无连接,直接发 |
| 可靠性 | 重发机制,数据不丢 | 不保证到达,可能乱序 |
| MFC 套接字类型 | CAsyncSocket/CSocket 默认 | Create 时传 SOCK_DGRAM |
| 适用场景 | 文件传输、指令下发 | 实时数据采集、心跳包 |
| 端口占用 | 服务器 Listen 后独占 | 可复用,多个客户端绑同一端口 |
| 典型 API | Listen/Accept/Connect | SendTo/ReceiveFrom |
选型的时候有个血泪经验:如果你的上位机需要同时处理几十个下位机的状态上报,用 TCP 每个连接一个 CAsyncSocket 对象,消息循环里 OnReceive 回调一多,界面卡成幻灯片。这时候要么换 UDP,要么上 IOCP。资料里没提 IOCP,但 CAsyncSocket 本身是基于消息的异步模型,量大了确实扛不住。
2.3 C/S 模型在 MFC 里的落地步骤
资料 1.1.3 节讲了 C/S 编程模型,服务器监听、客户端连接。在 MFC 里,这个流程被封装成了几个固定的函数调用。服务器端用 CAsyncSocket 的步骤是:构造对象、Create 指定端口、Bind 绑定本地 IP、Listen 开始监听、OnAccept 回调里新建一个套接字对象并 Accept。客户端则是:构造对象、Create、Connect 到服务器 IP 和端口。
这里有个容易翻车的点:Bind 的时候如果传的 IP 是 INADDR_ANY,表示绑定所有网卡;如果机器有多张网卡(比如同时接了内网和外网),客户端连的时候必须指定服务器实际可达的那个 IP,否则会出现“服务器明明在监听但客户端连不上”的玄学问题。资料里没展开讲这个,但实际部署的时候十有八九会撞上。
// 服务器端 CAsyncSocket 初始化核心代码 CAsyncSocket m_serverSocket; // 创建套接字,端口 8888,类型默认 SOCK_STREAM if (!m_serverSocket.Create(8888)) { DWORD err = GetLastError(); // 常见错误:端口被占用 WSAEADDRINUSE return; } // 绑定本地所有网卡地址 if (!m_serverSocket.Bind(8888, INADDR_ANY)) { // 绑定失败通常是端口已被其他进程 Listen return; } // 开始监听,第二个参数是等待队列长度 if (!m_serverSocket.Listen(5)) { return; } // 之后在 OnAccept 回调里处理新连接上面这段代码里,Create 的第二个参数如果不传,默认就是 SOCK_STREAM。Bind 的第二个参数 INADDR_ANY 等价于 0.0.0.0,表示接受任意网卡进来的连接。Listen 的参数 5 是 backlog,意思是还没被 Accept 的连接最多排 5 个,超了客户端会收到连接超时。这个值在并发稍高的场景下要调大,但 MFC 的消息机制决定了它扛不住太高的并发,一般调到 10 到 20 就差不多了。
3. CAsyncSocket 和 CSocket 的实操差异:异步回调与串行化怎么选
3.1 CAsyncSocket 的异步回调机制拆解
资料 1.3.1 节列出了 CAsyncSocket 的使用步骤,但没讲清楚“异步”到底异步在哪里。CAsyncSocket 底层还是 Winsock 的 WSAAsyncSelect 模型,它把网络事件(FD_READ、FD_WRITE、FD_ACCEPT、FD_CONNECT)转成 Windows 消息投递到窗口消息队列。所以你的 MFC 对话框或窗口必须有一个消息循环在跑,否则回调永远不会触发。这也是为什么在控制台程序里直接用 CAsyncSocket 会感觉“没反应”——没有消息泵,事件全堵着。
实际写代码的时候,你需要从 CAsyncSocket 派生一个类,重写 OnReceive、OnAccept、OnConnect 这些虚函数。OnReceive 里调 Receive 收数据,OnAccept 里调 Accept 接受新连接。注意 OnReceive 触发的时候不一定有完整的一包数据,TCP 是流式的,你可能只收到半包。资料里没强调这点,但这是新手最容易踩的坑:以为 OnReceive 一次就能收到完整消息,结果解析的时候数组越界。
// 派生类中重写 OnReceive void CMySocket::OnReceive(int nErrorCode) { if (nErrorCode != 0) { // 网络错误,通常直接关闭套接字 Close(); return; } char buf[4096]; int nRead = Receive(buf, sizeof(buf)); if (nRead > 0) { // 注意:nRead 可能小于你期望的完整包长度 // 需要自己维护缓冲区做粘包处理 m_recvBuffer.Append(buf, nRead); ParsePackets(); // 自定义的拆包函数 } else if (nRead == 0) { // 对端正常关闭 Close(); } else { // 出错,nRead == SOCKET_ERROR int err = GetLastError(); if (err != WSAEWOULDBLOCK) { Close(); } } CAsyncSocket::OnReceive(nErrorCode); }这段代码里,Receive 返回 0 表示对端关闭了连接,返回 SOCKET_ERROR 要检查 GetLastError。WSAEWOULDBLOCK 不是真错误,表示当前没有更多数据可读,直接忽略就行。m_recvBuffer 是我自己加的累积缓冲区,因为 TCP 不保证每次 OnReceive 都对应一个完整的应用层包。这个粘包处理逻辑资料里没给,但不做的话,只要发送方连续发两条消息,接收方大概率解析出错。
3.2 CSocket 配合 CArchive 的串行化用法
资料 1.3.2 节说 CSocket 派生于 CAsyncSocket,多了串行化功能。这个串行化是 MFC 的 CArchive 机制,你可以像读写文件一样读写网络流。步骤是:创建 CSocket 对象、连接或接受连接、创建 CSocketFile 对象并关联 CSocket、创建 CArchive 对象并关联 CSocketFile、然后用 << 和 >> 操作符收发数据。
这种方式的好处是代码简洁,不用手动处理缓冲区。坏处是 CArchive 是同步阻塞的,在 UI 线程里用会卡界面。而且 CSocket 内部虽然用了消息泵来模拟阻塞,但在某些场景下(比如同时收发大量数据)会出现死锁。我一般只在简单的请求-响应式通信里用 CSocket,比如客户端发一条查询指令,服务器回一条结果,这种一来一回的模式用 CArchive 很舒服。
// 客户端 CSocket + CArchive 发送数据 CSocket clientSocket; clientSocket.Create(); if (clientSocket.Connect(_T("192.168.1.100"), 8888)) { CSocketFile socketFile(&clientSocket); CArchive ar(&socketFile, CArchive::store); CString strCmd = _T("QUERY_STATUS"); ar << strCmd; // 序列化写入 ar.Flush(); // 必须 Flush,否则数据留在缓冲区 // 接收响应 CArchive arRecv(&socketFile, CArchive::load); CString strResp; arRecv >> strResp; }这里的关键点是 ar.Flush()。CArchive 有自己的缓冲区,不 Flush 的话数据不会立刻发出去,对端就一直等。另一个坑是 CArchive 的 store 和 load 模式不能同时在一个对象上用,收和发要分别建两个 CArchive 对象,或者用同一个 CSocketFile 但切换模式时要小心。资料里没提 Flush 和模式切换的细节,但不注意的话就是“程序不报错但对方收不到数据”的经典翻车现场。
3.3 网络字节序转换在 MFC 里的实际调用
资料 1.2.2 节讲了网络字节顺序,说 Winsock 提供了相关函数。具体就是 htons、htonl、ntohs、ntohl 这四个。h 代表 host,n 代表 network,s 代表 short(16 位),l 代表 long(32 位)。端口号是 16 位的,用 htons 转;IP 地址是 32 位的,用 htonl 转。在 MFC 里,CAsyncSocket 的 Create、Bind、Connect 这些函数内部已经帮你转了,所以你传 8888 进去不用自己 htons。但如果你要自己构造 IP 包头或者解析自定义协议里的整数字段,就必须手动转。
// 自定义协议头里的字段需要手动转字节序 struct MyHeader { int nMagic; // 4 字节,需要 htonl short nCmd; // 2 字节,需要 htons short nLen; // 2 字节,需要 htons }; MyHeader hdr; hdr.nMagic = htonl(0x12345678); hdr.nCmd = htons(1001); hdr.nLen = htons(dataLen); // 发送时直接发结构体,接收方按同样顺序 ntohl/ntohs 转回来注意结构体直接发的时候要考虑字节对齐问题。VC 默认按 4 字节对齐,MyHeader 里两个 short 连在一起占 4 字节,整个结构体是 8 字节。如果对方用 GCC 编译且按 2 字节对齐,结构体大小可能不一样,解析就全乱了。稳妥的做法是手动逐字段序列化到 char 数组,别直接发结构体。
4. 避坑与排查:MFC 网络编程里那些让人摔键盘的瞬间
4.1 现象:Connect 返回成功但 OnConnect 不触发
原因:CAsyncSocket 的 Connect 是异步的,返回 TRUE 只表示请求已提交,不代表连接已建立。OnConnect 回调才是真正连接成功的标志。如果你在 Connect 之后立刻 Send,数据会丢。
解决:所有发送逻辑放到 OnConnect 回调里,或者在 Connect 之前把状态置为“未连接”,OnConnect 里再置为“已连接”。别在 Connect 返回后直接操作套接字。
4.2 现象:OnReceive 里 Receive 返回 WSAEWOULDBLOCK
原因:FD_READ 事件触发不代表一定有数据可读,可能是之前的数据已经被读完了,但消息队列里还残留着 FD_READ 消息。这是 Winsock 的“水平触发”特性导致的。
解决:在 OnReceive 里循环 Receive 直到返回 WSAEWOULDBLOCK 或 0,把缓冲区读干净。但注意别死循环,设个最大读取次数。
4.3 现象:服务器关闭后端口立刻被占用,重启 Bind 失败
原因:TCP 的 TIME_WAIT 状态。主动关闭的一方会保持这个状态 2MSL(通常 1-4 分钟),期间端口不能被重新 Bind。
解决:在 Bind 之前设置 SO_REUSEADDR 选项。CAsyncSocket 里可以在 Create 之后调 SetSockOpt(SO_REUSEADDR, TRUE)。但注意这个选项在 Windows 上行为跟 Linux 不完全一样,Windows 上它允许端口被完全重复绑定,可能导致新连接被旧套接字抢走。更稳妥的做法是等 TIME_WAIT 过去,或者换端口。
4.4 现象:CSocket 在 UI 线程里导致界面卡死
原因:CSocket 的阻塞模拟依赖消息泵,但它在等待网络事件的时候会阻塞当前线程的消息循环,导致界面无法重绘。
解决:把 CSocket 放到工作线程里用,或者改用 CAsyncSocket 的纯异步模式。如果非要在 UI 线程用 CSocket,确保每次操作的数据量很小,且不要在一个操作里等太久。
4.5 现象:中文数据发过去变成乱码
原因:CString 在 Unicode 工程里是宽字符,直接 Send 发的是 UTF-16 的字节流,对方按 ANSI 解析就乱了。
解决:发送前用 WideCharToMultiByte 转成 UTF-8 或 GBK,接收方按同样编码转回来。或者统一约定用 UTF-8,在协议头里加一个编码标志位。
5. 从这份实例里榨出更多:把第一章的模型知识变成调试工具
资料第一章最后有个小结,说如果后面遇到基础疑问可以回来复习。我自己的习惯是,把 OSI 七层和 TCP/IP 四层做成一张排查清单,每次网络出问题就从上往下过一遍。物理层看网线灯亮不亮、网卡驱动有没有黄叹号;数据链路层看 ARP 表里有没有对方的 MAC;网络层 ping 一下通不通;传输层 telnet 对方端口看能不能连上;应用层再查代码里的协议解析。这套流程比盲目加日志快得多。
另外,资料里提到的端口号(HTTP 80、FTP 21)可以扩展成一张常用端口对照表,放在手边。写防火墙规则或者排查端口冲突的时候直接查。CAsyncSocket 的 OnConnect、OnReceive、OnClose 这几个回调,我一般会在里面加一行 TRACE 输出,用 DebugView 抓,比断点调试对时序的干扰小。CSocket 的 CArchive 序列化,如果遇到 Flush 之后对方还是收不到,检查一下 CSocketFile 的缓冲区大小,默认可能偏小,大数据量要分片发。
最后一个技巧:MFC 的网络错误码用 GetLastError 拿到之后,用 FormatMessage 转成可读文本,比对着 MSDN 查快得多。下面这个函数我每个项目都会拷一份:
CString GetNetErrorMsg(DWORD dwError) { LPVOID lpMsgBuf = NULL; FormatMessage( FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM, NULL, dwError, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), (LPTSTR)&lpMsgBuf, 0, NULL); CString strMsg = (LPCTSTR)lpMsgBuf; LocalFree(lpMsgBuf); return strMsg; }从那以后我每次接手 MFC 网络项目,第一件事就是把这份实例里的模型图和套接字步骤打印出来贴在显示器边上,第二件事就是把 GetNetErrorMsg 塞进公共头文件。希望帮到你。
本文还有配套的精品资源,点击获取