Win32 Socket TCP通讯录实战:从WSAStartup到netstat验证

发布时间:2026/10/5 6:21:49
Win32 Socket TCP通讯录实战:从WSAStartup到netstat验证 简介本资源是一份面向计算机专业本科生的《基于TCP协议的通讯录网络应用》课程设计报告聚焦网络编程实践与Socket通信原理的落地应用。报告完整覆盖课程设计全流程从系统需求分析联系人增删查、数据健壮性校验、功能模块设计客户端菜单交互、服务端链表存储与收发逻辑到TCP套接字编程实现细节含服务端CSocket类方法、客户端Menu流程及C结构体定义并附有运行截图与代码片段如Student.h头文件、命令号枚举、端口定义等。压缩包为1个270KB的docx文档共14页含目录、需求说明、详细设计流程图、服务端/客户端函数清单及课设心得总结。目前已有537人学习下载适合网络编程初学者理解TCP连接模型、掌握C Socket开发规范并完成课程设计文档撰写与系统实现参考。1. 这不是个“Hello World”级课设一个用原生 Win32 Socket 实现的 TCP 通讯录真能跑通、真有健壮性校验、真存本地文件——但你得先绕过 MFC 的坑这不是一份写在 PPT 里的“概念演示”而是一份 2017 年在长江大学软件工程机房实打实跑起来的课程设计报告。它用纯 C MFC 的CSocket类实现了完整的 TCP 客户端/服务端通讯录系统客户端输入学号、姓名、年龄、性别、单位、手机号服务端接收后存入4.txt支持实时浏览全部条目、按学号删除、异常输入拦截、断连重试提示——所有逻辑都压在OnReceive()和Menu()两个核心函数里。它不依赖 Qt 或 Boost.Asio没用现代 C11 智能指针甚至没封装成类模板但恰恰因为这种“土法炼钢”的写法暴露了 TCP 网络编程最真实的断点WSAStartup初始化失败、Accept阻塞卡死、Send/Receive返回值未判零、结构体对齐导致跨平台读写错位……我当年在 4 教机房调试时光是解决system(cls)在服务端触发崩溃就花了半天——这东西不是玩具它是用 Winsock API 一砖一瓦垒出来的最小可行 TCP 应用适合想亲手摸清connect()到closesocket()全链路的新手也适合需要快速验证 TCP 基础交互逻辑的老手。如果你正被《计算机网络》教材里的抽象流程图搞晕这份代码就是那个能让你敲netstat -ano | findstr :10011看到 ESTABLISHED 状态的“后悔药”。2. 从零启动服务端监听与客户端连接的完整握手链路2.1 服务端初始化WSAStartup 与端口绑定的生死线服务端启动的第一步不是Listen()而是WSAStartup(0x0202, wd)。这个看似简单的调用实际决定了整个 Winsock 栈能否加载成功。0x0202表示请求 Winsock 2.2 版本若系统只装了 1.1 版本某些老旧教学机房仍存在WSAGetLastError()会返回WSASYSNOTREADY程序直接退出。更隐蔽的坑在于WSAStartup必须在socket()之前调用且每个进程只能成功调用一次——如果误在Start()函数里重复调用第二次会返回WSALASTERROR但错误码不直观容易误判为端口占用。void CStudent::Start() { WSADATA wd; int ret WSAStartup(0x0202, wd); // 关键必须检查返回值 if (ret ! 0) { cout WSAStartup 失败错误码 ret endl; // 不是 WSAGetLastError() return; } if (!m_sock.Create(PORT)) { // PORT10011硬编码端口 cout 端口创建失败 WSAGetLastError() endl; WSACleanup(); // 必须清理否则下次启动可能失败 return; } if (!m_sock.Listen()) { // Listen() 本身不返回错误码需结合 GetLastError cout Listen 失败 WSAGetLastError() endl; m_sock.Close(); WSACleanup(); return; } cout 服务端启动成功监听端口 PORT endl; }注意m_sock.Create(PORT)中的PORT是enum {PORT10011}这是典型的教学场景选择——避开 1024 以下特权端口又不与常见服务冲突。但实际部署时必须确认该端口未被防火墙拦截Windows Defender 防火墙默认阻止入站连接且netsh interface ipv4 show excludedportrange protocoltcp输出中不包含 10011。2.2 客户端连接超时控制与地址解析的双重保险客户端Connect()调用看似简单但原始代码中m_sock.Connect(10.201.1.55, PORT)直接使用 IP 地址这在实验室局域网可行但缺乏通用性。真实场景下应支持域名解析并加入连接超时机制——原生CSocket不提供setsockopt(SO_SNDTIMEO)必须用select()模拟非阻塞连接bool CStudent::ConnectWithTimeout(const char* ip, unsigned short port, int timeout_ms) { // 1. 创建 socket if (!m_sock.Create()) { cout 客户端 socket 创建失败 WSAGetLastError() endl; return false; } // 2. 设置非阻塞模式 u_long mode 1; ioctlsocket(m_sock.m_hSocket, FIONBIO, mode); // 3. 尝试连接 sockaddr_in addr; addr.sin_family AF_INET; addr.sin_port htons(port); addr.sin_addr.s_addr inet_addr(ip); if (addr.sin_addr.s_addr INADDR_NONE) { hostent* he gethostbyname(ip); if (he nullptr) { cout DNS 解析失败 ip endl; return false; } addr.sin_addr *(in_addr*)he-h_addr_list[0]; } int ret connect(m_sock.m_hSocket, (sockaddr*)addr, sizeof(addr)); if (ret SOCKET_ERROR WSAGetLastError() WSAEWOULDBLOCK) { // 4. 使用 select 等待连接完成 fd_set writefds; FD_ZERO(writefds); FD_SET(m_sock.m_hSocket, writefds); timeval tv { timeout_ms / 1000, (timeout_ms % 1000) * 1000 }; ret select(0, nullptr, writefds, nullptr, tv); if (ret 0) { cout 连接超时 timeout_ms ms endl; m_sock.Close(); return false; } else if (ret SOCKET_ERROR) { cout select 失败 WSAGetLastError() endl; m_sock.Close(); return false; } // 5. 检查连接结果 int error 0; socklen_t len sizeof(error); getsockopt(m_sock.m_hSocket, SOL_SOCKET, SO_ERROR, (char*)error, len); if (error ! 0) { cout 连接失败错误码 error endl; m_sock.Close(); return false; } } else if (ret SOCKET_ERROR) { cout connect 立即失败 WSAGetLastError() endl; m_sock.Close(); return false; } // 6. 恢复阻塞模式后续 Send/Receive 需要 mode 0; ioctlsocket(m_sock.m_hSocket, FIONBIO, mode); return true; }这段代码替换了原始Connect()解决了三个致命问题DNS 解析失败时不会静默退出连接超时可捕获而非无限等待getsockopt(SO_ERROR)确保连接真正建立而非仅connect()返回成功。2.3 TCP 三次握手的实证用 netstat 和 Wireshark 抓包验证光看代码不够必须用工具验证握手是否真实发生。在服务端启动后、客户端连接前执行# 查看服务端监听状态 netstat -ano | findstr :10011 # 输出应为TCP 0.0.0.0:10011 0.0.0.0:0 LISTENING 12345 # 客户端连接后再次执行 netstat -ano | findstr :10011 # 输出应出现 ESTABLISHED 状态如 # TCP 10.201.1.55:10011 10.201.1.56:51234 ESTABLISHED 12345更进一步用 Wireshark 过滤tcp.port 10011可清晰看到 SYN → SYN-ACK → ACK 的三次握手过程。特别注意原始代码中服务端Accept()后未设置SO_KEEPALIVE长连接空闲时可能被中间设备如校园网 NAT静默断开。若需保持连接应在Accept()后添加// 在 Accept() 成功后对 socka 设置 keepalive int keepalive 1; int keepidle 60; // 空闲 60 秒后开始探测 int keepinterval 5; // 每 5 秒发一次探测包 int keepcount 3; // 连续 3 次无响应则断开 setsockopt(socka.m_hSocket, SOL_SOCKET, SO_KEEPALIVE, (const char*)keepalive, sizeof(keepalive)); setsockopt(socka.m_hSocket, IPPROTO_TCP, TCP_KEEPIDLE, (const char*)keepidle, sizeof(keepidle)); setsockopt(socka.m_hSocket, IPPROTO_TCP, TCP_KEEPINTVL, (const char*)keepinterval, sizeof(keepinterval)); setsockopt(socka.m_hSocket, IPPROTO_TCP, TCP_KEEPCNT, (const char*)keepcount, sizeof(keepcount));3. 数据协议设计命令号 结构体二进制传输的边界对齐陷阱3.1 协议头设计REQ_ADD/REQ_DEL 等命令号的字节序与大小验证原始代码定义了enum { REQ_ADD0x1111, REQ_DEL, REQ_BROW }这是一个典型的“命令数据”二进制协议。关键点在于0x1111是小端序Intel x86 默认当客户端发送int nCmd REQ_ADD时内存中实际存储为0x11 0x11 0x00 0x00低字节在前。服务端Receive(nCmd, sizeof(nCmd))读取时必须确保两端 CPU 架构一致均为 x86否则需手动转换字节序。更稳妥的做法是使用htons()/ntohs()处理 2 字节命令号// 修改为 uint16_t 命令号避免 4 字节对齐问题 enum { REQ_ADD 0x0001, REQ_DEL 0x0002, REQ_BROW 0x0003 }; // 发送端客户端 uint16_t cmd htons(REQ_ADD); m_sock.Send(cmd, sizeof(cmd)); // 接收端服务端 uint16_t cmd; if (pSocka-Receive(cmd, sizeof(cmd)) 0) return FALSE; cmd ntohs(cmd); // 统一转为主机序 switch(cmd) { case REQ_ADD: ... }这样既节省带宽2 字节 vs 4 字节又规避了不同平台int大小差异如嵌入式系统int可能是 2 字节。3.2 DATA 结构体内存对齐导致的跨平台读写错位原始typedef struct SStud中字段顺序和大小隐含对齐规则typedef struct SStud { int nNumb; // 4 字节 char sName[20]; // 20 字节 char sAge[20]; // 20 字节 char sSex[4]; // 4 字节 → 此处编译器会插入 4 字节填充使下一个字段 4 字节对齐 char sUnit[20]; // 20 字节 char sAdd[20]; // 20 字节 } DATA;sizeof(DATA)在 Visual Studio 默认设置下为96 字节42020442020924 字节填充而非直觉的 88 字节。若客户端和服务端编译器对齐设置不同如 GCC 默认#pragma pack(1)Receive(d, sizeof(d))会读取错误字节数导致sSex后字段全乱。解决方案是强制指定紧凑对齐#pragma pack(push, 1) // 开始 1 字节对齐 typedef struct SStud { int nNumb; char sName[20]; char sAge[20]; char sSex[4]; char sUnit[20]; char sAdd[20]; } DATA; #pragma pack(pop) // 恢复默认对齐此时sizeof(DATA)稳定为88 字节且跨平台兼容。务必在.h文件中#include之前添加此 pragma否则std::listDATA存储时内存布局不一致。3.3 文件持久化fopen(4.txt, a) 的线程安全与编码问题服务端AddData()中fopen(4.txt, a)以追加模式写入看似安全实则埋雷线程安全CSocket的OnReceive在单线程中串行处理但若未来扩展为多线程Accept多个客户端同时写4.txt会导致内容交错编码问题fprintf(fp, %s, d.sName)直接写入char[]若用户输入中文GBK 编码4.txt用 UTF-8 编辑器打开会显示乱码原子性缺失fprintf不是原子操作断电时可能产生半截记录。生产级改进应使用fwrite()写二进制并添加 CRC 校验// 替代 fprintf写入二进制结构体 size_t written fwrite(d, sizeof(DATA), 1, fp); if (written ! 1) { cout 写入文件失败 endl; fclose(fp); return FALSE; } // 添加 4 字节 CRC32 校验简化版 uint32_t crc CalculateCRC32(d, sizeof(d)); fwrite(crc, sizeof(crc), 1, fp);但课程设计阶段只需确保4.txt用记事本ANSI 模式打开即可正确显示中文——这是 Windows 教学环境的现实妥协。4. 功能实现细节添加、浏览、删除的全流程闭环4.1 添加功能客户端输入校验与服务端结构体填充原始客户端Input()函数直接cin d.nNumb但未处理输入失败如用户输入字母。健壮做法是检查cin.fail()并清空缓冲区void CStudent::Input() { DATA d; cout 请输入学号整数 endl; while (!(cin d.nNumb)) { cout 输入错误请输入整数 endl; cin.clear(); // 清除错误标志 cin.ignore(10000, \n); // 忽略错误输入 } cout 请输入姓名20字以内 endl; cin.ignore(); // 清除上一个输入的换行符 cin.getline(d.sName, sizeof(d.sName)); // 防止溢出 // 其他字段同理... // 发送前校验长度 if (strlen(d.sName) sizeof(d.sName) - 1) { cout 姓名超长已截断 endl; d.sName[sizeof(d.sName)-1] \0; } int nCmd htons(REQ_ADD); m_sock.Send(nCmd, sizeof(nCmd)); m_sock.Send(d, sizeof(d)); Print(); }服务端AddData()接收后应校验nNumb是否为正整数、sName是否为空再写入文件BOOL CStudent::AddData(CSocket *pSocka) { DATA d; if (pSocka-Receive(d, sizeof(d)) 0) return FALSE; // 基础校验 if (d.nNumb 0 || strlen(d.sName) 0) { cout 数据校验失败学号必须 0姓名不能为空 endl; return FALSE; } // 写入文件二进制模式更安全 FILE* fp fopen(4.txt, ab); // 二进制追加 if (fp nullptr) { cout 打开文件失败 endl; return FALSE; } size_t written fwrite(d, sizeof(d), 1, fp); fclose(fp); if (written ! 1) { cout 写入文件失败 endl; return FALSE; } m_list.push_back(d); cout 添加成功学号 d.nNumb , 姓名 d.sName endl; return TRUE; }4.2 浏览功能服务端推送与客户端分页显示原始Browse()一次性发送全部联系人若数据量大100 条Send()可能因 TCP 缓冲区满而阻塞或失败。改进方案是分批发送客户端循环接收// 服务端 Browse() BOOL CStudent::Browse(CSocket *pSocka) { int count m_list.size(); pSocka-Send(count, sizeof(count)); // 先发总数 listDATA::iterator it m_list.begin(); while (it ! m_list.end()) { DATA d *it; // 每次发送前检查连接状态 if (pSocka-Send(d, sizeof(d)) 0) { cout 发送中断客户端可能已断开 endl; return FALSE; } } return TRUE; } // 客户端 Print() void CStudent::Print() { int nCmd htons(REQ_BROW); m_sock.Send(nCmd, sizeof(nCmd)); int count; m_sock.Receive(count, sizeof(count)); cout 学号\t姓名\t年龄\t性别\t单位\t手机号 endl; for (int i 0; i count; i) { DATA d; if (m_sock.Receive(d, sizeof(d)) 0) { cout 接收中断 endl; break; } cout d.nNumb \t d.sName \t d.sAge \t d.sSex \t d.sUnit \t d.sAdd endl; } }4.3 删除功能学号匹配与文件同步更新原始Delete()仅从内存链表删除未同步更新4.txt导致重启服务端后数据回滚。正确做法是重写整个文件BOOL CStudent::Delete(CSocket *pSocka) { int nNumb; if (pSocka-Receive(nNumb, sizeof(nNumb)) 0) return FALSE; // 从链表删除 listDATA::iterator it m_list.begin(); bool found false; while (it ! m_list.end()) { if (it-nNumb nNumb) { it m_list.erase(it); found true; break; } else { it; } } if (!found) { cout 未找到学号 nNumb 的联系人 endl; return FALSE; } // 重写 4.txt FILE* fp fopen(4.txt, wb); if (fp nullptr) { cout 重写文件失败 endl; return FALSE; } for (auto d : m_list) { fwrite(d, sizeof(d), 1, fp); } fclose(fp); cout 删除成功学号 nNumb endl; return TRUE; }5. 避坑指南那些让课设挂科的 5 个血泪错误5.1 现象服务端Accept()后OnReceive()无法触发客户端显示“连接服务器失败”原因CSocket的Accept()返回新 socket 对象socka但该对象未调用AsyncSelect()注册事件导致OnReceive()不被回调。原始代码中while(OnReceive(socka))是轮询方式但CSocket默认为阻塞模式Receive()会一直等待直到有数据或连接关闭若客户端未发送任何数据服务端线程将永久阻塞。解决在Accept()后显式设置socka为非阻塞并用select()轮询或改用CAsyncSocket类需重写OnReceive虚函数。课程设计简化方案是确保客户端连接后立即发送命令号// 服务端 Accept 后立即发送欢迎消息触发客户端响应 char welcome[] Connected to Address Book Server; socka.Send(welcome, strlen(welcome));5.2 现象中文姓名在4.txt中显示为乱码用 Excel 打开全是方块原因Windows 记事本默认以 ANSIGBK编码保存文本而fprintf(fp, %s, d.sName)写入的是 GBK 字节流。若用 VS Code 或 Sublime Text 以 UTF-8 打开会误读为 UTF-8 导致乱码。解决统一用二进制模式写入或在文本模式下指定编码。课程设计最简方案是告知学生用 Windows 自带记事本打开4.txt即可正常显示。5.3 现象多次添加联系人后4.txt文件末尾出现大量空行或乱码原因fopen(4.txt, a)每次打开文件时文件指针位于末尾但fprintf(fp, \n)在结构体前写入换行符导致每条记录前多一个空行更严重的是fprintf(fp, %s, d.sName)若sName未以\0结尾如用户输入 20 字符会继续输出后续内存垃圾。解决初始化DATA结构体确保字符串以\0结尾// 客户端 Input() 中 DATA d {}; // 全零初始化自动填充 \0 cin.getline(d.sName, sizeof(d.sName));服务端写入时改用fwrite()写二进制避免格式化问题。5.4 现象删除联系人后服务端m_list.size()正确但4.txt内容未更新原因原始Browse()函数中有一段错误代码fprintf(fp, %d, d.nNumb);等语句在while(it!m_list.end())循环内但fpfopen(4.txt,w)在循环外导致只写入最后一条记录。解决将文件打开移至循环外写入逻辑移至循环内并确保fclose()在循环后FILE* fp fopen(4.txt, wb); if (fp nullptr) return FALSE; for (auto d : m_list) { fwrite(d, sizeof(d), 1, fp); } fclose(fp);5.5 现象客户端选择“退出”后服务端Accept()仍阻塞无法响应新连接原因原始代码中客户端Menu()返回i当i0时while(Menu())退出但未调用m_sock.Close()和WSACleanup()导致 socket 句柄泄漏服务端Accept()因资源耗尽而失败。解决在客户端Menu()退出前显式关闭 socketint CStudent::Menu() { // ... 功能选择 if (i 0) { cout 正在退出... endl; m_sock.Close(); // 关键释放 socket WSACleanup(); // 关键释放 Winsock return 0; // 退出 while 循环 } return i; }6. 进阶验证与实战技巧用 telnet 手动模拟客户端把 TCP 协议变成黑匣子6.1 用 telnet 手动注入命令绕过客户端直击服务端协议层telnet是验证 TCP 协议最轻量的工具。启动服务端后在另一台机器执行telnet 10.201.1.55 10011此时telnet建立 TCP 连接但服务端OnReceive()会等待 4 字节命令号。我们用 Python 快速生成命令# generate_cmd.py import struct # REQ_ADD 0x0001 (小端序) cmd_bytes struct.pack(H, 0x0001) # H 表示小端 2 字节 print(cmd_bytes.hex()) # 输出 0100复制0100在 telnet 窗口按Ctrl]进入命令模式输入send 0100需 telnet 支持十六进制发送否则用xxd工具。更可靠的方式是用ncnetcat# 发送 ADD 命令2 字节 echo -ne \x01\x00 | nc 10.201.1.55 10011 # 发送完整 DATA 结构体88 字节需用 Python 构造这招能快速定位问题若telnet连接后服务端无日志说明Accept()未触发若发送命令后服务端崩溃说明OnReceive()未处理非法命令号。把协议变成可手动操控的“黑匣子”是排查网络问题的终极技巧。6.2 服务端日志增强记录每个连接的 IP、端口、操作流水原始代码仅cout 用户登录 inet_ntoa(sa.sin_addr)信息过于简略。增强版应记录时间戳、操作类型、结果#include time.h void LogConnection(const sockaddr_in sa, const char* action, bool success) { time_t now time(nullptr); char* time_str ctime(now); time_str[strlen(time_str)-1] \0; // 去掉换行 FILE* log fopen(server.log, a); if (log) { fprintf(log, [%s] %s %s:%d - %s\n, time_str, action, inet_ntoa(sa.sin_addr), ntohs(sa.sin_port), success ? SUCCESS : FAILED); fclose(log); } }在Accept()后、OnReceive()前调用LogConnection(sa, CONNECT, true)在AddData()等函数结尾记录操作结果。日志文件server.log是课设答辩时最硬核的证据。6.3 客户端健壮性升级断线重连与命令确认机制原始客户端无重连逻辑网络抖动即退出。添加简单重连bool CStudent::Reconnect() { int attempt 0; const int max_attempts 3; while (attempt max_attempts) { if (ConnectWithTimeout(10.201.1.55, PORT, 3000)) { cout 重连成功 endl; return true; } attempt; cout 重连失败 (max_attempts - attempt) 次重试机会 endl; Sleep(1000); // 等待 1 秒 } return false; } // 在 Menu() 中调用 if (i 1 || i 2 || i 3) { if (!m_sock.IsConnected()) { if (!Reconnect()) { cout 无法连接服务器请检查网络 endl; return 0; // 退出 } } }更进一步为关键操作如删除添加服务端确认// 客户端 Delete() void CStudent::Delete() { // ... 输入学号 int nCmd htons(REQ_DEL); m_sock.Send(nCmd, sizeof(nCmd)); m_sock.Send(nNumb, sizeof(nNumb)); // 等待服务端确认 char confirm[10]; int ret m_sock.Receive(confirm, sizeof(confirm)-1); if (ret 0) { confirm[ret] \0; cout 服务端响应 confirm endl; // 如 DELETED:123 } }服务端Delete()结尾添加send()响应。这种“请求-确认”模式虽增加延迟但杜绝了客户端误判操作成功的风险。从那以后我每次写网络课设都会在Start()里加一行cout Server PID: GetCurrentProcessId() endl;然后用taskkill /PID xxx /F强制结束残留进程——因为closesocket()未调用时端口会处于TIME_WAIT状态netstat -ano | findstr :10011能救你命。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询