
简介南京信息工程大学计算机网络课程设计成果包含基于套接字Socket的局域网通信软件完整实现与配套报告面向网络编程初学者及需要完成类似课程设计的高校学生。项目覆盖一对一私聊、群聊和文件发送三大功能深入实践TCP/IP协议栈应用层编程重点涉及多线程并发控制、套接字连接管理、文件流读写与分包重组等关键知识。实现中服务器监听客户端连接并为每对通信会话建立独立套接字以保障私聊隐私群聊场景通过线程池维护在线客户端列表并广播消息文件传输则将大文件分割为多个数据包依次发送接收端按序重组。压缩包约5.09MB主要包含课程设计报告与程序相关文件报告完整记录了需求分析、系统设计、编码实现、测试调试等环节并针对连接速度、消息延迟、文件传输效率等给出性能分析与优化建议。已有335人学习下载适合希望快速理解套接字编程框架、借鉴课程设计报告结构并动手实践的学习者。1. 课程设计为什么选 Socket这份作业到底在考什么“Socket 局域网通信软件”是计算机网络课程设计里出现频率最高的一类题目也是很多人第一次被要求把协议栈、端口、IP 地址这些概念从课本里拽出来、变成能双击运行的代码。标题里的三个功能——一对一、群聊、发送文件——恰好覆盖了 TCP 面向连接通信、多线程并发、字节流拆包和文件 IO 这几块硬骨头。做完这套东西你等于亲手走了一遍 C/S 架构从建连到断连的完整生命周期计算机网络实验里那些“三次握手”“粘包”“半包”的名词才真正有了落点。这篇笔记适合正在做课程设计、想用最短路径把 Socket 跑通并且把报告写到答辩不虚的人。2. 跑通第一版一对一会话Winsock 初始化到消息收发的闭环第一版别急着上群聊和文件先把两台机器或者本机两个进程之间的点对点通信打通。这一步决定了后面所有功能的骨架也决定了你调试时是“玄学问题”还是“逻辑问题”。2.1 先建服务端WSAStartup 与 bind/listen 的最小骨架Windows 下做 Socket 编程基本绕不开 Winsock。无论是 C、C# 还是 Python 的底层调用最终都要过这一层。用 C 写课程设计最稳因为老师看到的是一个实实在在的控制台程序不依赖额外运行时。下面是最小的服务端骨架#include winsock2.h #include ws2tcpip.h #pragma comment(lib, ws2_32.lib) int main() { WSADATA wsa; // 初始化 Winsock 2.2返回 0 表示成功 if (WSAStartup(MAKEWORD(2, 2), wsa) ! 0) { printf(WSAStartup failed\n); return -1; } // 创建 TCP 套接字 SOCKET listenSock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listenSock INVALID_SOCKET) { printf(socket failed: %d\n, WSAGetLastError()); WSACleanup(); return -1; } sockaddr_in addr {0}; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); // 监听所有网卡 addr.sin_port htons(8888); // 端口固定为 8888 // bind 把端口和套接字绑定失败多半是端口被占 if (bind(listenSock, (sockaddr*)addr, sizeof(addr)) SOCKET_ERROR) { printf(bind failed: %d\n, WSAGetLastError()); closesocket(listenSock); WSACleanup(); return -1; } // 进入监听状态backlog 10 表示等待队列里最多排 10 个连接 listen(listenSock, 10); // accept 会阻塞在这里直到有客户端连进来 SOCKET clientSock accept(listenSock, NULL, NULL); if (clientSock INVALID_SOCKET) { printf(accept failed: %d\n, WSAGetLastError()); closesocket(listenSock); WSACleanup(); return -1; } // 在这里用 clientSock 收发数据 closesocket(clientSock); closesocket(listenSock); WSACleanup(); return 0; }几个参数要解释清楚。htonl(INADDR_ANY)表示监听本机所有网卡地址这样无论客户端连的是有线 IP 还是无线 IP 都能进得来htons(8888)把端口从主机字节序转成网络字节序端口号在 1024 到 65535 之间随便选但别用 80、443 这类可能被本机其他程序占用的端口。listen的第二个参数是 backlog课程设计规模下填 10 就够了填大了没有意义反而会让操作系统多留资源。accept返回一个新的套接字这个套接字才是真正和客户端通信的通道listenSock继续监听其他连接——这个“监听套接字”和“通信套接字”的区分是后面群聊设计的基础。2.2 再写客户端connect 与 recv/send 的调用关系客户端代码比服务端短很多核心就四步创建套接字、填服务端地址、connect、收发。一个常见错误是客户端和服务端用同一个端口或者同一个 IP导致 connect 失败。#include winsock2.h #include ws2tcpip.h #pragma comment(lib, ws2_32.lib) int main() { WSADATA wsa; WSAStartup(MAKEWORD(2, 2), wsa); SOCKET sock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (sock INVALID_SOCKET) { printf(socket failed\n); return -1; } sockaddr_in server {0}; server.sin_family AF_INET; // 服务端 IP先用回环地址 127.0.0.1 做本机测试 inet_pton(AF_INET, 127.0.0.1, server.sin_addr); server.sin_port htons(8888); // connect 失败时用 WSAGetLastError() 区分是网络不通还是被拒绝 if (connect(sock, (sockaddr*)server, sizeof(server)) SOCKET_ERROR) { printf(connect failed: %d\n, WSAGetLastError()); closesocket(sock); WSACleanup(); return -1; } const char* msg hello server; send(sock, msg, (int)strlen(msg), 0); // 把字符串发出去 char buf[1024] {0}; int len recv(sock, buf, sizeof(buf) - 1, 0); // 阻塞等待服务端回话 if (len 0) { buf[len] \0; printf(received: %s\n, buf); } closesocket(sock); WSACleanup(); return 0; }inet_pton是 IPv4/IPv6 通用的地址转换函数把“127.0.0.1”这种点分十进制字符串转成二进制的sin_addr。老一点的环境用inet_addr也可以但inet_pton对非法输入返回 0排查问题更方便。调试时先用127.0.0.1回环地址跑通再改成局域网 IP。recv是阻塞的服务端没回数据它会一直停在那里这是后续多线程要解决的核心问题。2.3 收尾时注意close 与 shutdown 的差别第一版最容易翻车的地方不在建连而在关闭。很多人直接closesocket了事结果第二次运行服务端时 bind 报 10048地址已被占用或者客户端那边recv返回 0 但连接其实还没断干净。closesocket只是把套接字描述符释放掉它不会通知对端“我要断开”也不保证发送缓冲区里的数据已经全部送出去。正确做法是先shutdown(sock, SD_SEND)告诉对端“我不再发送数据了”然后再closesocket。如果两端互相收发就要协商好谁先关。服务端主动关闭时客户端再recv会收到 0表示对端已经关闭这时候该做的不是继续读而是退出循环。另一个值得说的问题是 TIME_WAIT。主动关闭连接的一方端口会进入 TIME_WAIT 状态持续约 2 分钟期间该端口不能被重新 bind。所以服务端每次启动都重新 bind 同一个端口时第二次启动容易报 10048。课程设计阶段最简单的解法是先关客户端、再关服务端让客户端做主动关闭那一方服务端端口不进入 TIME_WAIT。如果实在没法控制顺序在 bind 之前设置SO_REUSEADDR套接字选项也能绕过但这只是权宜之计。3. 从一对一扩到群聊线程、消息分发与客户端列表一对一会话跑通后群聊的难点不是“怎么把消息发给所有人”而是“服务端同时和多人保持连接时代码怎么组织”。这是 socket 网络编程里线程模型最典型的应用场景。3.1 每客户端一线程阻塞模型下群聊的常规解法recv是阻塞的一个服务端主线程如果卡在某个客户端的recv上其他人发消息就没人处理。最常见的解法是一个客户端对应一个工作线程主线程循环accept每接进来一个连接就创建一个线程线程内循环recv处理这个客户端的收发。Windows 下用_beginthreadex而不是CreateThread因为CreateThread不初始化 CRT 的线程局部状态用printf、strtok这类函数时可能出错。#include process.h // 每个客户端一个工作线程参数是 accept 返回的套接字 unsigned int __stdcall ClientWorker(void* param) { SOCKET sock (SOCKET)param; char buf[1024]; int len; while ((len recv(sock, buf, sizeof(buf), 0)) 0) { // 处理收到的数据比如转发给其他客户端 buf[len] \0; printf(%s\n, buf); } closesocket(sock); return 0; } // 主线程 accept 循环 SOCKET listenSock ...; while (true) { SOCKET clientSock accept(listenSock, NULL, NULL); // 线程句柄用完后要 CloseHandle不然句柄会泄漏 HANDLE hThread (HANDLE)_beginthreadex(NULL, 0, ClientWorker, (void*)clientSock, 0, NULL); if (hThread NULL) { closesocket(clientSock); // 线程创建失败要回收套接字 } else { CloseHandle(hThread); // 不等待线程结束只释放句柄 } }注意线程入口函数的参数void* param把套接字转成SOCKET类型时课程设计规模下用强制转换是安全的。如果要传入多个参数就得定义一个结构体指针这里面最容易踩的坑是循环创建线程时传了一个局部结构体的地址出了这轮循环地址就失效了。正确做法是new一个结构体在线程函数内部最后delete。3.2 服务端转发 vs P2P 直连选型理由与实现代价群聊消息分发有两条路线。一种是服务端中转每个客户端只管跟服务端通信收到消息后由服务端广播给所有人另一种是 P2P 直连客户端之间直接建立连接服务端只负责维护地址列表。课程设计选服务端中转理由很实在实现量少一半而且可靠性更容易控制。服务端中转的代价是带宽翻倍一条消息要从发送者到服务端、再从服务端到所有接收者。但局域网环境下完全无所谓。P2P 直连还要面对 NAT 穿透问题在一个课程设计里引入 NAT 穿透工作量直接失控。还有一个现实因素答辩时老师大概率会问“为什么不用 P2P”你的答案如果是“服务端中转便于统一管理客户端状态、便于做消息记录”比“P2P 太难”好听得多。方案实现成本消息可达性适合场景服务端中转低只需要一套广播逻辑消息必达只要服务端在线课程设计、小规模聊天室P2P 直连高需要穿透和地址协商依赖双方网络可达性有公网 IP 或同一局域网的小型工具3.3 列表维护与线程安全锁、通知与掉线清理群聊服务端必然要维护一个“当前在线客户端”列表广播消息时要遍历这个列表。这里有个经典竞态条件线程 A 正在向某个客户端send消息线程 B 发现该客户端掉线并把它从列表里删掉A 还在用一个已经失效的套接字轻则 send 失败重则访问越界。课程设计不需要上复杂的读写锁一个CRITICAL_SECTION就够了。所有对列表的读和写都包在同一个锁里广播时不要让锁持有时间太长——把要发送的数据拷贝出来、在锁内遍历列表时直接send如果某个客户端send失败就把它的套接字标记为待删除锁释放后再统一清理。CRITICAL_SECTION g_cs; // 保护客户端列表的锁 SOCKET g_clients[64]; // 简单数组容量看设计需求 int g_clientCount 0; void BroadcastMessage(SOCKET from, const char* data, int len) { EnterCriticalSection(g_cs); for (int i 0; i g_clientCount; i) { if (g_clients[i] from) continue; // 不发回给发送者 if (send(g_clients[i], data, len, 0) SOCKET_ERROR) { // 对端已断开这里只做标记稍后统一清理 g_clients[i] INVALID_SOCKET; } } LeaveCriticalSection(g_cs); }数组容量这里直接写 64和FD_SETSIZE的默认值一致意思是单台服务端最多同时维持 64 个客户端连接。如果题目要求支持更多客户端就得用链表或动态数组但 64 对课程设计来说通常够用。掉线清理不能只靠 send 失败——一个客户端强行拔网线时对端可能很长时间都收不到 FIN 包recv会一直阻塞。常规做法是给recv设置超时SO_RCVTIMEO或者加心跳机制客户端每隔一段时间发一个固定格式的心跳包服务端连续几次没收到就判定掉线并清理列表。3.4 群聊的 3 个必调参数缓冲区大小、最大连接数、心跳间隔第一个参数是缓冲区大小。recv的缓冲区设 1024 还是 4096直接影响一次能收到的消息长度。局域网聊天场景下 1024 字节够用但后面的文件传输必须加大到 4096 以上否则大文件传输效率会明显变差。第二个是最大连接数。用数组实现列表时这个数写死即可比如上面代码里的 64。如果设得过大每个客户端线程默认栈大小 1MB64 个线程就是 64MB 虚拟内存32 位程序下折腾不起。课程设计规模下 20 到 64 是合理区间。第三个是心跳间隔。客户端每 5 到 10 秒发一个心跳包服务端记录最后一次收到数据的时间超过 3 个心跳周期没动静就判定掉线。间隔太短会频繁发包占带宽太长又会让“谁掉线了”这个问题暴露得慢。局域网环境下 5 秒是经验值。4. 加上发送文件功能分块、校验与进度反馈文件传输是这份设计里最容易被验收组较真的功能因为它不再是“发一条消息”而是要处理“不确定长度的二进制流”。很多人的文件传输实现出来能用但一传大文件就卡、一传图片就损坏问题都出在协议设计上。4.1 发送文件的最小协议文件名、大小、分块与结束标志TCP 是字节流没有“消息边界”所以文件传输协议必须自己定义边界。最朴素的做法是分两阶段先发一个文本格式的头部告诉接收端文件名和文件大小再按固定大小分块发送文件内容。// 发送端先发头部再分块发送文件内容 char head[128] {0}; sprintf_s(head, FILE:%s:%lld, filename, fileSize); send(sock, head, (int)strlen(head), 0); FILE* fp fopen(filename, rb); char buf[4096]; int n; long long totalSent 0; while ((n (int)fread(buf, 1, sizeof(buf), fp)) 0) { retry_send(sock, buf, n); // 这里必须处理 send 只发一半的情况 totalSent n; // 按 totalSent / fileSize 刷新进度条 } fclose(fp);头部格式里FILE:是协议类型标记后面跟文件名和大小。接收端读到这一行后先创建文件再循环recv直到累计字节数等于头部里声明的大小文件才算接收完整。结束标志在这个方案里不必要因为文件大小本身就是边界。这里最容易翻车的点是send返回值不等于传入长度。TCP 的send可能只把部分数据放进发送缓冲区就返回了剩下的要你自己重发。上面代码里的retry_send是必须写的void retry_send(SOCKET sock, const char* buf, int len) { int sent 0; while (sent len) { int ret send(sock, buf sent, len - sent, 0); if (ret 0) return; // 连接已断无法继续 sent ret; } }这个函数解决的是“半发送”问题很多文件传着传着少几百字节就是忽略了 send 不保证一次发完。4.2 二进制文件的可靠性长度前缀而不是靠 recv 一次读满文本文件的坑在recv一次能读到多少是不确定的。发送端发了一个 4096 字节的块接收端recv可能只读到 1024也可能一次读到两块的拼接内容。如果接收端天真地假设“读一次就是一整块”图片文件传到一半就会损坏这就是典型的粘包和半包。解法是给每块数据加上长度前缀。文件内容按 4096 字节分块每块前面跟一个 4 字节的整数表示块的实际长度接收端先读 4 字节得到长度再循环读到这个长度为止。// 发送端每个数据块前加 4 字节长度前缀 char buf[4096 4]; int n (int)fread(buf 4, 1, 4096, fp); memcpy(buf, n, 4); // 小端序写入块长度 retry_send(sock, buf, n 4); // 整个块加前缀一起发接收端对应的逻辑是先recv4 字节解析出 n再循环recv直到收满 n 字节写入文件。这个过程加上 CRC 校验就是可靠的文件传输了。CRC32 的查表实现网上很容易找到课程设计里直接在接收端校验每个块、不匹配就让发送端重发这个块实现成本低而且验收时很加分。如果你不想在核心逻辑里塞 CRC 代码也可以在 head 里带一个总长度和一个简单的和校验值接收端验总字节数和校验值。4.3 进度反馈与界面别在主线程收发文件传输期间如果代码写在主线程里recv会一直卡住窗口拖不动、点关闭没反应在别人看来就是“死机”。进度条功能必须独立于主线程运行。常见的做法是UI 主线程只负责刷新界面文件收发放在工作线程里工作线程每收完一块数据往一个全局变量里累加已接收字节数UI 按定时器周期去读这个数。// 工作线程刷新进度信息 volatile long long g_received 0; // 已接收字节数主线程读取 volatile long long g_total 0; // 文件总大小头部解析后赋值 // 主线程定时刷新进度条 SetTimer(hWnd, 1, 200, NULL); // 每 200ms 刷新一次 // WM_TIMER 处理 int percent (int)(g_received * 100 / max(g_total, 1)); SendDlgItemMessage(hDlg, IDC_PROGRESS, PBM_SETPOS, percent, 0);这里的volatile在多线程下只是保底更好的做法是直接用InterlockedExchange64更新。课程设计里数据量不大、读写冲突概率低写清楚注释说明并发模型即可。另一个容易被忽略的是接收端必须检查文件名路径是否合法客户端那边发来一个“..\..\config.ini”或者超长文件名接收端直接拼接路径就会出问题。最稳妥的做法是只取文件名、强制保存到指定目录不信任客户端传来的路径。5. 局域网通信避坑高频问题的排查顺序socket 网络编程的报错信息大多是数字代号第一次接触时看到10061、10048完全不知道哪里出了问题。这一章按现象、原因、解决的顺序写几条最常遇到的坑。5.1 现象客户端 connect 报 10061 或 1004810061 是 WSAECONNREFUSED字面意思是“连接被拒绝”绝大多数情况是服务端程序没有启动或者服务端监听的端口和客户端连接的端口不一致。10048 是 WSAEADDRINUSE地址已被占用通常出现在服务端第二次启动时上一次运行的进程还没退出。排查顺序是先确认服务端进程在跑再用netstat -ano | findstr 8888看端口有没有真正进入 LISTENING 状态最后确认防火墙没有拦掉入站连接。很多人在本机回环测试时一切正常换到局域网就连不上问题基本都出在防火墙或网段上。5.2 现象消息收发错乱收方一次收到两条消息或一条消息被分成两半这是 TCP 粘包和半包。TCP 不做消息定界发送方调两次send发出 A 和 B接收方可能一次recv就把 A 和 B 都读出来了也可能一次只读到 A 的一半。单靠增大缓冲区解决不了根本问题。标准解法是前面 4.2 写的长度前缀协议每个应用层消息统一格式为“4 字节长度 消息体”接收方先读长度再按长度读消息体。这个规则必须贯彻到聊天消息、文件头部、心跳包所有场景只在文件传输块上做、聊天消息不做依然会串包。调大SO_RCVBUF只能缓解不能根治。5.3 现象中文消息显示乱码服务端和客户端的字符编码不一致。Visual Studio 默认源码是 GBK控制台显示也是 GBK但如果传输层用了 UTF-8 编码另一侧按 GBK 解码就会出现乱码。还有一种情况是wchar_t和char混用宽字符转多字节时没有指定代码页。一致的做法是全部使用 UTF-8发送方把std::wstring_convert转成 UTF-8 的std::string再发接收方收到后把char*转回宽字符显示。如果嫌转换麻烦就保证两端项目都保持多字节字符集、代码页一致不要混用。最容易出问题的是“发送端用 Unicode 编译接收端用多字节编译”这两者互发文本基本必乱。5.4 现象关掉聊天窗口后进程不退出CPU 占用飙高原因多半是工作线程还阻塞在recv上。主线程一退出进程的唯一主线程没了但由于工作线程还活着进程不会结束表现为“关不掉”。这在调试器里看线程窗口能清楚看到某个线程停在recv的调用处。解决方法是主窗口发送一个关闭命令给工作线程工作线程recv到特殊命令后主动跳出循环、closesocket、线程正常结束主线程再WaitForSingleObject等待工作线程退出。另一个常见坑是线程函数里接了数据、分配了堆内存退出前忘记释放就会造成每次收发都漏一点内存运行时间长之后内存持续增长。5.5 现象局域网内两台电脑互相连不上但各自回环都正常先确认两端 IP 在同一网段比如都在 192.168.1.x。不在一个网段时先看是不是路由或子网掩码问题。排除网段后重点查 Windows 防火墙的入站规则服务端程序第一次启动时弹出的“允许访问”对话框如果点了取消后面所有局域网客户端都会连接超时。这里有个课程设计里的高频场景一台电脑开了虚拟机的桥接模式虚拟机拿到了一个真实局域网 IP但宿主机还在 NAT 网段两边都不通。排查时用ipconfig把真机、虚拟机的 IP 列出来对照不要凭印象猜测。还有一个小技巧客户端连不上时在服务端机器上抓包或临时关闭防火墙验证比对着错误码猜有效率得多。6. 把设计写进报告、把演示做进答辩验收自测表与追问点报告要体现的不是“我用了多少行代码”而是“我知道自己为什么这么设计”。三张图就能撑起报告的剩余部分。功能架构图把三个模块画清楚服务端的监听线程、每个客户端的读写线程、共享的客户端列表和锁连接时序图画出客户端建连、发送消息、服务端广播、断开的完整过程标注每一步调用的是哪个函数线程状态图画出主线程和工作线程各自的生命周期这是答辩时老师最爱看的一张图。答辩前的自测按这个顺序过一遍先本机回环测一对一再两台机器测群聊接着传一个 50MB 的文件看进度条和字节数最后在文件传输进行中直接关掉发送端或接收端看另一端能不能正常报错退出而不是卡死。每一轮测试记录时间和结果写进报告的测试章节这个习惯在答辩时能救你测试场景操作预期结果实际结果一对一消息本机客户端连服务端消息双向可达记录批次与耗时群聊广播三个客户端同时在线非发送者都能收到记录是否丢消息文件传输发送 50MB 二进制文件接收端字节数与大小一致记录耗时校验 CRC异常断开传输中关闭一端另一端日志打印连接断开记录清理是否完整老师问“为什么用 TCP 不用 UDP”时你要说“TCP 提供可靠字节流适合聊天消息和文件传输UDP 适合语音视频这类允许丢包实时性优先的场景”问“粘包怎么解决”时直接答“应用层加长度前缀协议”。这两问答上来这门课设计的分数基本就稳了。我自己的习惯是代码里每处关键调用边上都留一行注释写明为什么这么做而不是怎么做这份注释最后直接变成报告里“关键技术”一节的第一手素材。希望帮到你。本文还有配套的精品资源点击获取