基于TCP协议的通讯录课设:CSocket实现与避坑指南
发布时间:2026/9/30 15:48:03 作者:尧图编辑部 阅读量:1,286

简介计算机网络课程设计配套资源中的一份完整报告文档围绕基于TCP协议的通讯录网络应用展开面向希望以实际项目巩固Socket编程与网络通信知识的计算机、网络专业学生。压缩包内共1个文件为docx格式大小仅270KB内容集中、无冗余附件可直接打开阅读或复制整理。报告依次涵盖课程目的与意义、系统需求及功能分析明确通讯录应支持联系人录入、浏览、添加、删除、退出保存并强调对非法输入进行校验以保证程序健壮性详细设计部分从套接字作为应用层与TCP/IP交互接口入手梳理客户端与服务端主要函数包括创建套接字、链表存储、收发数据、查看/添加/删除联系人、菜单交互等配有功能结构图、流程图和运行结果截图最后附有设计总结与核心代码能帮助读者从整体上理解TCP网络应用的实现思路。已有537人学习该资源对需要快速搭建课设框架或补全TCP编程细节的读者具有较高的参考价值。1. 基于TCP协议的通讯录网络应用两周课设的真实形态凡是做过计算机网络课设的人看到“基于TCP协议的通讯录”这个题目基本都能猜到成品长什么样一个C控制台程序客户端敲菜单服务端收数据联系人存到文本文件里跑通了就算交差。这份课设报告就是典型样本——用MFC的CSocket封装了TCP通信服务端监听固定端口客户端连接写死的IP协议层用一个枚举命令字加一个结构体数据用std::list暂存并用txt持久化功能只做添加、浏览、删除三件事。它不惊艳但结构完整适合拿来当起步模板你想要一个能跑通TCP客户端/服务端交互、能演示socket通信全流程、代码量又控制在几百行之内的参考这份东西比网上那些半成品工程靠谱得多。2. 先定协议再写代码命令字、DATA结构体与存储格式2.1 自定义应用层协议为什么先发命令字再发数据的顺序不能乱CSocket底层是TCP流式传输本身不识别消息边界。服务端收到一段字节流时它不知道这段数据是“要添加一个联系人”还是“要浏览全部联系人”。所以这版课设在应用层自己定了一套微型协议每次交互先发一个int型命令字再发命令对应的数据。// 服务端 Student.h 中的协议定义 enum { PORT10011 }; enum { REQ_ADD0x1111, // 添加联系人请求 REQ_DEL, // 删除联系人请求值为 0x1112 REQ_MOD, // 修改联系人请求值为 0x1113预留未实现 REQ_FIND, // 查找联系人请求值为 0x1114预留未实现 REQ_BROW // 浏览联系人请求值为 0x1115 };这里有个容易被忽略的细节REQ_ADD赋值0x1111后后面的REQ_DEL、REQ_MOD依次自动递增所以实际值是0x1112、0x1113、0x1114、0x1115。如果你不记得这个递增规则调试时抓包看到命令字是0x1113会以为客户端发了非法数据。客户端和服务端的枚举定义必须严格一致否则服务端switch根本匹配不上直接返回FALSE并断开连接。命令分发逻辑在服务端的OnReceive里它先收一个int作为命令字再根据命令字跳转到不同处理函数。这就是“协议命令附加数据”的完整含义。实际写的时候可以再压缩一层把命令字和数据合成一次Send减少网络往返次数但课设阶段分开Send更容易理解和排查。2.2 DATA结构体六字段固定字节的经典形态typedef struct SStud { int nNumb; // 学号int 类型4 字节 char sName[20]; // 姓名定长字符串 char sAge[20]; // 年龄注意这里是 char 数组而不是 int char sSex[4]; // 性别定长字符串 char sUnit[20]; // 单位定长字符串 char sAdd[20]; // 手机号码字段名叫 add 但实际存的是电话 } DATA;这个结构体的设计有几个非常“课设特色”的决定。年龄用char[20]而不是int好处是客户端cin d.sAge直接读字符串不用做类型转换服务端打印也更省事代价是不能对年龄做数值运算。sAdd命名是“地址”的缩写存的却是手机号码说明写代码时字段设计没一开始定清楚后面将错就错。这些不影响跑通但答辩时可能被老师问“为什么字段名和实际内容不符”。结构体直接通过Send(d, sizeof(d))发送是从共享内存模型平移过来的做法。它隐含了一个前提通信双方的平台字节序一致、结构体对齐一致、编译器对char数组成员的填充方式一致。同一个机房、同一版本VC编译器下没问题但换一台机器或者用MinGW编译就可能翻车这一点后面避坑章细说。2.3 文件持久化txt格式里的反直觉断行逻辑服务端收到添加请求后把联系人明文写入4.txt追加模式打开每条记录前面额外写一个\n。// 服务端 Student.cpp 中的添加落盘逻辑 fp fopen(4.txt,a); if(fp NULL) printf(打开文件失败); fprintf(fp,\n); // 每条记录前先写一个换行 fprintf(fp,%d,d.nNumb); fprintf(fp,\t); fprintf(fp,%s,d.sName); fprintf(fp,\t); fprintf(fp,%s,d.sAge); fprintf(fp,\t); fprintf(fp,%s,d.sSex); fprintf(fp,\t); fprintf(fp,%s,d.sUnit); fprintf(fp,\t); fprintf(fp,%s,d.sAdd); fprintf(fp,\t); fprintf(fp,\n); fclose(fp);这样写的实际效果是文件开头第一行是空行之后每条记录前面都有一个空行。这种方式能跑但读文件时要注意跳过空行或者用fscanf按格式化读取让它自动忽略空白字符。Browse里更新文件时用的是w模式只写了链表里第一个元素的字段——而且用的是上面闭包里的d变量不是遍历变量。这个写法明显是临时代码没细究逻辑就直接提交了后面会看到它的实际影响。3. 服务端实现套接字初始化、监听循环与命令分发3.1 WSAStartup 与监听端口SO_REUSEADDR 的缺失问题服务端入口Start()做了四件事初始化Winsock、创建监听套接字、绑定端口、循环Accept。典型的CSocket阻塞模式。// 服务端 Student.cpp Start 函数 void CStudent::Start() { WSADATA wd; WSAStartup(0x0202,wd); // 请求 WinSock 2.2 版本 if(!m_sock.Create(PORT)) // 创建套接字并绑定 10011 端口 { cout 端口创建失败 WSAGetLastError() endl; return; } m_sock.Listen(); // 进入监听状态 CSocket socka; sockaddr_in sa; int nLen sizeof(sa); while(m_sock.Accept(socka,(sockaddr*)sa,nLen)) { cout 用户登录inet_ntoa(sa.sin_addr) htons(sa.sin_port)endl; while(OnReceive(socka)); // 循环处理请求直到对方断开 socka.Close(); } }WSAStartup(0x0202,wd)请求的是Winsock 2.2版本第二个参数返回实际的Winsock实现细节。Create(PORT)做了socket创建和bind两步Listen()把套接字变成被动监听默认监听队列长度在MSVC的CSocket实现里是5。Accept每次接受一个客户端连接返回一个新的CSocket对象之后所有数据收发都走这个新套接字。这条while循环是顺序处理模型一个客户端连上来之后服务端就卡在while(OnReceive(socka))里直到该客户端断开才回到外层Accept。第二个客户端只能排队等着。课设答辩时大家各连各的问题不明显一旦有两个客户端同时操作能看到明显的卡顿。3.2 OnReceive 命令分发一个入口管四个功能// 服务端 Student.cpp OnReceive 命令分发 BOOL CStudent::OnReceive(CSocket *pSocka) { int nCmd 0; // 先收 4 字节命令字返回 0 表示连接已断开 if(pSocka-Receive(nCmd, sizeof(nCmd)) 0) return FALSE; switch(nCmd) { case REQ_ADD: return AddData(pSocka); // 接收 DATA 结构体并落盘 case REQ_DEL: return Delete(pSocka); // 按学号删除链表节点 case REQ_BROW: return Browse(pSocka); // 把链表全部记录发给客户端 // REQ_MOD 和 REQ_FIND 预留给后续扩展 } return FALSE; }这里的Receive(nCmd, sizeof(nCmd))意味着每次循环至少等到4字节才能继续。TCP是流协议如果客户端只发来2字节这个Receive会一直阻塞等满4字节。CSocket的Receive在阻塞模式下必须收满指定字节数才返回。一个值得注意的边界行为如果客户端发来一个未定义的命令字比如0xFFFFswitch没有匹配项函数返回FALSE外层while(OnReceive(...))退出服务端直接关闭与这个客户端的连接。参数校验只有“命令字是否在枚举范围内”这一层实际上枚举里的有效值也没校验全。3.3 添加与浏览链表和txt的双写困局// 服务端 AddData接收联系人并同时写入链表和文件 BOOL CStudent::AddData(CSocket *pSocka) { DATA d; FILE *fp; if(pSocka-Receive(d, sizeof(d)) 0) return FALSE; fp fopen(4.txt,a); if(fp NULL) printf(打开文件失败); // 逐字段 fprintf 写入文件... fclose(fp); m_list.push_back(d); // 同时压入内存链表 return TRUE; }// 服务端 Browse遍历链表逐条发给客户端 BOOL CStudent::Browse(CSocket *pSocka) { system(cls); int n m_list.size(); pSocka-Send(n, sizeof(n)); // 先发记录条数 listDATA::iterator it m_list.begin(); while(it ! m_list.end()) { DATA d *it; pSocka-Send(d, sizeof(d)); // 逐条发送记录 } return TRUE; }这段代码的业务逻辑清楚Browse先发一个int表示“总共有多少条”再循环把结构体逐条发出。客户端接收时先收int再循环收结构体。这是最简单的“先告诉对方有多少数据再逐块发数据”的约定。但注意Browse函数体里只做了Send没有把文件重新加载到链表。如果服务端跑在内存里的链表和4.txt不一致浏览看到的是内存数据重启后内存清空又从文件加载——而Load()函数在代码里是空的压根没实现。这导致整个课程设计最尴尬的一个现状删除操作不会同步到文件重启之后删除的联系人又回来了。后面避坑章会说这个事。服务端函数清单速查函数职责主要调用对象CStudent::Start()Winsock初始化、创建监听、循环AcceptOnReceive()CStudent::OnReceive()收命令字并分发到具体处理函数AddData()/Delete()/Browse()CStudent::AddData()收DATA结构体、追加写入4.txt、压入链表fopen/fprintfCStudent::Browse()返回链表大小、逐条发送DATAm_listCStudent::Delete()按学号从链表erase节点m_list.erase()4. 客户端实现菜单循环、发送与接收4.1 连接配置写死的IP与端口客户端Start()同样先初始化Winsock但Create()不带端口参数表示让系统自动分配一个临时端口。然后主动Connect到服务端的IP和端口。// 客户端 Student.cpp Start 函数 void CStudent::Start() { WSADATA wd; WSAStartup(0x0202, wd); if(!m_sock.Create()) // 客户端不需要固定端口 { cout 网络启动失败 WSAGetLastError() endl; return; } // 10.201.1.55 是课程设计机房的服务端 IP换环境必须改这行 if(!m_sock.Connect(10.201.1.55, PORT)) { cout 连接网络服务器时失败错误号码 WSAGetLastError() endl; return; } while(Menu()); // 循环显示菜单输入0退出 }Connect(10.201.1.55, PORT)是纯写死的硬编码。机房机器IP一变这边就要重新编译。而且这里用的是字符串IP内部还要做DNS解析和字符串转in_addr的转换比直接用inet_addr更慢但课设里无所谓。Create()不指定端口是因为客户端端口由系统在1024-5000范围内动态分配不需要你自己绑定。服务端通过Accept返回的sockaddr_in能看到客户端的IP和端口这就是为什么服务端能打印“用户登录ip port”。4.2 添加、浏览、删除三次网络交互的完整链路// 客户端 Input添加联系人 void CStudent::Input() { DATA d; cout 请输入学号endl; cin d.nNumb; cout 请输入姓名endl; cin d.sName; // 年龄、性别、单位、手机号同理... int nCmd REQ_ADD; m_sock.Send(nCmd, sizeof(nCmd)); // 先发命令字 m_sock.Send(d, sizeof(d)); // 再发结构体数据 Print(); // 发送完顺手刷新浏览 }// 客户端 Delete按学号删除 void CStudent::Delete() { cout请输入要删除的学号; int nNumb; cin nNumb; cout你确定要删除这个学号吗[y/n]; char c getch(); if(c Y || c y) { int nCmd REQ_DEL; m_sock.Send(nCmd, sizeof(nCmd)); // 命令字 m_sock.Send(nNumb, sizeof(nNumb)); // 要删除的学号 } Print(); }// 客户端 Print浏览联系人 void CStudent::Print() { int nCmd REQ_BROW; m_sock.Send(nCmd, sizeof(nCmd)); // 发浏览命令 DATA d; int n 0; m_sock.Receive(n, sizeof(n)); // 先收条数 system(cls); cout学号\t 姓名\t 年龄\t 性别\t 单位\t 手机号码endl; while(n--) { m_sock.Receive(d, sizeof(d)); // 循环收结构体 cout d.nNumb \td.sName \td.sAge \td.sSex \td.sUnit \td.sAddendl; } system(pause); }三个操作收发方向各有不同。添加是客户端单向发送不发接收浏览是客户端发一个请求服务端回一堆数据收方向的顺序跟服务端发送顺序完全对应先int条数再条数个结构体删除是客户端发命令字和学号服务端处理完不回复任何确认客户端直接刷新浏览列表这样用户能通过列表变化看到“删除是否生效”。删除功能有个细节原代码cin nNumb后直接判断getch()返回值如果用户输入了非数字字符cin进入错误状态后续所有cin 都会失效主菜单会无限空转。这正好对应了报告需求里说的“对输入值的类型进行正确性检查”——但代码里实际没有做。各操作收发方向汇总操作客户端发送客户端接收添加命令字 DATA结构体无浏览命令字int条数 N个DATA结构体删除命令字 int学号无随后主动刷新浏览列表5. 避坑指南CSocket阻塞模式的真实翻车现场5.1 粘包为什么Receive会一次收两条命令现象客户端连续执行添加操作服务端偶尔把第二条命令当成nCmd解析或者跳进错误的处理函数甚至直接断连。原因TCP是流式协议Send和Receive之间没有消息边界。客户端先Send(nCmd)再Send(d)这两个包可能被系统合并成一个TCP段服务端第一次Receive(nCmd, 4)可能一次读到8字节命令字DATA结构体的前4字节命令字对上了但DATA结构体整体没收到第二次Receive再从数据中间开始读整个解析就错位了。解决最省事的方法是像原代码一样每个交互之间让服务端主动回应一个确认帧一来一回把两个包拆开。常见做法是把命令字和数据塞进同一个缓冲区一次Send发完服务端按“命令字长度 结构体长度”精确读取定长包。5.2 结构体直接Send的坑字节对齐与缓冲区越界现象客户端在VC6下编译能跑换成更高版本Visual Studio或者MinGW编译后服务端收到的结构体字段错位、中文姓名显示乱码。原因DATA结构体成员是int 多个char[20]/char[4]默认字节对齐是4字节或8字节。不同编译器、不同对齐选项下结构体内部填充字节不同sizeof(DATA)也不一样直接用sizeof(d)在网上发收发双方对“哪个字节属于哪个字段”的认知就不一致。另一个更隐蔽的问题char sName[20]是固定缓冲cin d.sName时不检查长度输入超过19个字符就把栈写穿了运气好程序没崩运气不好当场报错。解决结构体定义前加#pragma pack(push, 1)强制单字节对齐发送前对字符串做截断或者干脆把结构体字段挨个序列化到一块缓冲区里再发。5.3 删除不同步文件重启后“删掉的人又回来了”现象客户端删除一条联系人界面上看列表确实少了重启服务端进程再浏览删除的那条又出现在列表里。原因Delete()只做了m_list.erase(it)没有同步重写4.txt而Load()函数是空的服务端每次启动直接把链表变成空表根本不读文件。实际上只有添加操作往文件追加了数据删除操作对文件的唯一影响是Browse里那段写文件代码而且它只写了链表第一个元素——删除任何一个人文件都会被覆盖成只剩一条记录其它记录全部丢失。解决删除后遍历链表把剩余记录全部重写进4.txt用w模式覆盖。每次操作结束后都强制Save()让内存和文件保持一致。这个坑课设报告看不出来因为运行演示时服务端不重启。5.4 单线程Accept第二个用户卡住不动现象第一个客户端连接后第二个客户端连接时菜单能显示但一切操作都无响应过一会儿第一个客户端断开第二个立刻恢复正常。原因服务端的while(m_sock.Accept(...))是单线程顺序模式把一个连接交给OnReceive循环处理后整个服务端就阻塞在这个函数里外层的Accept根本不会被调用。第二个客户端的连接请求一直积压在Listen队列里直到第一个断开才能进来。解决最直接的办法是Accept之后为每个连接创建独立线程线程内跑各自的OnReceive循环主线程继续Accept。CSocket对象在线程间传递要注意对象生命周期socka不能在栈上创建一个就丢掉否则线程还没跑完对象就析构了。5.5 端口用完的TIME_WAIT第二次启动“端口创建失败”现象服务端程序异常退出或者连着启动两次第二次执行m_sock.Create(PORT)失败WSAGetLastError()返回WSAEADDRINUSE。原因TCP断开的主动方会进入TIME_WAIT状态端口保持占用约2分钟Windows下默认是4分钟。服务端退出时主动关闭了监听套接字但之前的连接还在TIME_WAIT里端口没释放干净。解决在服务端Create(PORT)前调用SetSockOpt(SO_REUSEADDR, TRUE)允许端口重用。如果用的裸socket接口这句是setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, ...)。课设排错时遇到“上次能启动这次不能”九成是这个原因。6. 进阶改造把课设从Demo做成能答辩加分的系统6.1 用配置文件替代写死IPConnect(10.201.1.55, PORT)是客户端里最影响演示效果的一行。答辩时换台电脑跑客户端第一件事就是改IP重新编译。用argv传参更省事// 客户端启动入口把 IP 从命令行传入 int main(int argc, char* argv[]) { const char* server_ip (argc 1) ? argv[1] : 127.0.0.1; CStudent app; app.Start(server_ip); // Start 内部用 server_ip 替代写死的字符串 return 0; }本地演示时输入client.exe 127.0.0.1直接回环测试不用依赖机房局域网环境。6.2 把限制最多的单线程Accept改成多线程// 服务端 Accept 循环改造成每连接一线程 while(m_sock.Accept(socka, (sockaddr*)sa, nLen)) { // 这里不要直接调 OnReceive而是把 socka 传给线程函数 CStudent* pWorker new CStudent(); pWorker-AttachSocket(socka); // 转移套接字所有权 _beginthread(WorkerThreadProc, 0, pWorker); // 每个连接一个线程 }注意CSocket对象不能跨线程随便共享AttachSocket内部要把MFC的socket句柄从原对象分离再附加到新对象线程函数里跑原来的while(OnReceive(...))循环结束后关闭句柄并释放对象。这么改后端并发处理能力明显提升第二台客户端不会再干等。6.3 协议加个“长度字段”彻底告别粘包比“命令字定长结构体”更通用的做法是“命令字数据长度数据载荷”三段式。客户端发送时先填好四字节命令字、四字节载荷长度然后把DATA序列化进字节流服务端先收8字节包头再按包头里的长度收完剩余数据。这样无论发多长的联系人备注、改了多少次字段都不会出现解析错位。文件存储也顺势从txt升级成CSV或二进制文件配合fstream读写比裸FILE*省心。课设做完之后的小半年里我帮人调的socket项目几乎都踩在同一批坑上不校验输入、不规划协议边界、不处理文件同步。后来我每看一个C/S课设第一件事就是找两端各有什么数据要发、几字节、顺序是什么理清这三件事程序基本就能跑通。希望这篇拆解能让你少折腾几个通宵。本文还有配套的精品资源点击获取