
简介这是一份计算机网络实验的Socket编程代码包涵盖TCP/UDP协议、一对多聊天与多人聊天室实现适合正在学习网络编程或完成课程实验的高校学生。压缩包共14个文件以C语言源码为主辅以Python脚本及编译生成的可执行文件整体仅34KB便于直接阅读和调试。已有1204人学习下载。代码按任务模块划分分别演示TCP一对多通信中的套接字创建、连接监听与多客户端并发处理以及UDP广播式多人聊天室的收发机制同时包含异常捕获等处理逻辑有助于理解非阻塞模式下的容错设计。通过阅读和运行这些代码能直观掌握Socket接口在不同传输层协议下的差异并可作为扩展实现登录校验、消息私聊或界面化的起点。 如果你在计算机网络实验群里问过“为什么我的服务端只能收到第一个客户端的消息”那你八成正在和实验三死磕。这个标题拆开就是三件事用 socket 编程跑通 TCP 和 UDP、处理一对多的连接、拼出一个能多人同时发言的聊天室。它不考算法考的是对连接生命周期的理解——谁先 close、谁阻塞了、消息该往哪些 fd 转发。下面我会直接给能跑通的服务端和客户端代码把关键参数讲清楚再列出最容易翻车的几个坑。实验报告要写的原理部分也顺带讲明白。适合正在赶计算机网络实验报告、或者想自建局域网聊天室的读者。2. 先分清TCP和UDP聊天室选型决定后面一半的坑TCP 和 UDP 的区别是计算机网络面试八股的开场白但实验里没人考你七层模型你只需要回答一个问题聊天室的消息该用哪种 socket 收选错了后面要么丢消息要么代码结构整个不同。这一章先把决策做掉。2.1 TCP和UDP在socket编程里的本质区别三次握手、字节流与数据报在 socket 编程层面TCP 和 UDP 的区别从 socket() 的第一个参数就开始了。TCP 对应 SOCK_STREAMUDP 对应 SOCK_DGRAM。stream 是流意味着数据像水管里的水一样没有间断datagram 是数据报每条消息是一个独立的包裹。这两句话直接解释了后面所有行为差异TCP 要三次握手建立连接UDP 不需要TCP 的 recv 读多少算多少UDP 的 recvfrom 一次取一个完整报文。服务端的接口差异更直观。TCP 服务端要走 socket、bind、listen、accept 这条链accept 返回的是一个专门和某个客户端通信的新 fd这个 fd 就是连接本身。UDP 服务端根本没有 acceptbind 完直接 recvfrom它不需要“等别人建立连接”因为每个发来数据的地址都是它的潜在客户端。两者的差异我直接列一张对照表后面写代码时会反复用到对比维度TCPUDPsocket 类型SOCK_STREAMSOCK_DGRAM连接过程三次握手面向连接无连接不握手数据边界无边界字节流有边界数据报可靠性可靠丢包重传可能丢包、乱序服务端入口listen accept只有 bind收发接口send / recvsendto / recvfrom辨识客户端accept 返回的 fd对方的 IP 端口我实际观察过一个容易误判的现象有同学在 accept 循环前面写了个很重的耗时初始化客户端 connect 立刻就成功了但消息发出去石沉大海。原因是内核在三次握手完成后已经把连接放进了队列accept 只是把它取出来服务端还在忙别的事自然没人处理消息。这不算是故障但能误导你排查很久知道握手和 accept 的关系就能避开。2.2 一对多聊天为什么默认选TCP三个理由标题里同时出现了 TCP/UDP 和一对多聊天但实验主线几乎都是 TCP原因有三个正好对应三个硬需求。第一聊天消息不能丢、顺序不能乱。TCP 的序列号和重传机制保证了“你发完消息它一定到”UDP 发出去之后服务端收不收得到全看命。你在实验报告里写“用 UDP 保证可靠传输”也不是不行但那就得自己实现序号、确认和重传工作量比聊天室本身还大。第二TCP 的连接状态让成员管理变得便宜。服务端 accept 得到一个 fd这个 fd 一直代表同一个客户端。客户端关程序操作系统发 FIN服务端 recv 返回 0立刻知道这个人下线了可以从客户端数组里移除。UDP 没有这个信号客户端崩了服务端还傻等只能靠超时踢人实验里很容易漏。第三教材和常见实验模板都以 TCP 为主线。谢希仁《计算机网络》面向连接的传输层讲得最细实验课也按这个顺序来UDP 通常作为对比项。你要是交一份纯 UDP 方案得先说服老师为什么不用默认做法风险大于收益。所以我的建议是先把 TCP 版跑通再拿 UDP 版做对比两份代码一起交原理部分也最好讲。2.3 什么场景才该用UDP广播、实时与IGMPUDP 不是没用它把控制权交给你适合三类场景。第一类是实时性压倒一切语音对讲、视频会议、游戏同步晚到不如不到丢一帧可以接受。第二类是广播和组播IP 组播用 IGMP 管理成员关系组播数据本身几乎只能由 UDP 承载因为 TCP 是点对点连接天生不支持一对多发送。第三类是极简请求响应DNS 查询、NTP 校时一次一问一答没必要握手。如果你拿到的实验题目写的是“UDP广播聊天室”那是另一套玩法客户端直接往 255.255.255.255 或网段广播地址发数据局域网内所有人的 socket 都能收到不需要服务端中转。结构上更简单但没人做成员管理也没人记聊天记录老师通常把它放在扩展题里。我们第 4 章写的是更常见的 UDP 服务器中转模式和 TCP 版本做对照这样实验报告里的对比才有实质内容。3. 用TCP搞掂一对多聊天室服务端代码与参数设置3.1 服务端骨架socket、bind、listen、accept 的调用顺序TCP 服务端的生命周期是四个系统调用串起来的。socket(AF_INET, SOCK_STREAM, 0) 创建套接字返回一个 fdbind() 把这张 socket 绑定到某个端口端口是客户端找你的门牌号实验里固定写死比如 8888listen() 把 socket 变为监听状态内核开始为这个端口排队进来的连接请求最后进入 while 循环每来一个客户端就 accept 一次accept 返回的 fd 才是真正用来收消息的通道。这四个调用缺一个都不行顺序更不能反。常见错误是写完 socket 直接 accept报错 ENOTCONN 或者直接段错误。bind 的地址里有两个参数必须说清楚sin_addr.s_addr 设成 INADDR_ANY 表示监听本机所有网卡 IP这样局域网里其他人也能连如果只想本机联调改成 127.0.0.1 的地址也行。sin_port 必须用 htons() 转成大端字节序直接填 8888 的后果是端口变成另一个数值客户端永远连不上。listen() 的第二个参数 backlog 表示内核为还没被 accept 的连接排队的长度实验里给 10 够用写 5 也行。真正上线的话要按并发估但选课实验一般超不过 20 个客户端不必纠结。面试时如果被追问答“backlog 是已完成三次握手但还没被 accept 的连接队列长度”就够了。3.2 一对多管理的两条路select 与 pthread实验选哪个accept 一次只能拉进来一个连接怎么同时服务几十个客户端是实验的关键考点。两条常见路线多线程和事件驱动。多线程思路是一个客户端分一个 pthread线程里阻塞 recv主线程继续 accept。代码直观但客户端数组被多个线程同时访问加锁解锁写起来容易出错线程数一多调度开销也上来。select 思路是单线程里把“所有关心的 fd”丢给内核内核告诉你哪些 fd 有数据你逐个处理。实验客户端就二三十个select 完全够用而且不用处理线程同步。我建议实验报告里写 select 方案理由很简单代码短、逻辑线性、答辩时讲“事件驱动”比讲“线程池”更好讲。对比项pthread 多线程select 单线程代码量多要处理锁少线性逻辑客户端上限受线程数和内存限制受 fd_set 限制默认 1024同步问题需要加锁维护客户端数组不需要可移植性POSIX 线程Windows 下要适配Windows/Linux 语法基本一致实验答辩难度需要解释锁和临界区解释 fd_set 和事件循环即可选 select 还有一个隐藏好处它强迫你把“哪些 fd 可读”和“怎么处理”分开这个思维后面学 epoll、理解 Reactor 模式都是同一个底子不算白学。3.3 服务端完整代码select 事件循环下面是完整可编译的服务端代码主流的 Linux 环境直接 gcc server.c -o server 就能编译。// server.c —— TCP 一对多聊天室服务端select 版本 #include stdio.h #include stdlib.h #include string.h #include unistd.h #include arpa/inet.h #include sys/socket.h #include sys/select.h #define MAX_CLIENTS 50 #define BUFFER_SIZE 1024 int main() { int server_fd, client_fds[MAX_CLIENTS]; int client_count 0; // 1. 创建监听 socket server_fd socket(AF_INET, SOCK_STREAM, 0); if (server_fd 0) { perror(socket); exit(1); } // 端口复用服务端频繁重启时不至于被 TIME_WAIT 占住端口 int opt 1; setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); // 2. 绑定地址和端口 struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(8888); if (bind(server_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); exit(1); } // 3. 进入监听 listen(server_fd, 10); printf(聊天室已启动端口 8888\n); while (1) { fd_set read_set; FD_ZERO(read_set); FD_SET(server_fd, read_set); int max_fd server_fd; for (int i 0; i client_count; i) { FD_SET(client_fds[i], read_set); if (client_fds[i] max_fd) max_fd client_fds[i]; } // 阻塞等待至少一个 fd 可读 if (select(max_fd 1, read_set, NULL, NULL, NULL) 0) { perror(select); continue; } // 有新客户端连入 if (FD_ISSET(server_fd, read_set)) { struct sockaddr_in cli_addr; socklen_t cli_len sizeof(cli_addr); int cfd accept(server_fd, (struct sockaddr *)cli_addr, cli_len); if (cfd 0 client_count MAX_CLIENTS) { client_fds[client_count] cfd; printf(客户端加入: %s:%d当前 %d 人\n, inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port), client_count); } else if (cfd 0) { close(cfd); // 满员时直接拒绝 } } // 逐个检查旧客户端是否有消息 for (int i 0; i client_count; i) { if (!FD_ISSET(client_fds[i], read_set)) continue; char buf[BUFFER_SIZE]; memset(buf, 0, sizeof(buf)); int n recv(client_fds[i], buf, BUFFER_SIZE - 1, 0); // n 0 表示客户端关闭或连接出错必须清理 if (n 0) { printf(客户端断开: fd%d\n, client_fds[i]); close(client_fds[i]); client_fds[i] client_fds[client_count - 1]; client_count--; continue; } buf[n] \0; printf(来自 fd%d: %s, client_fds[i], buf); // 一对多转发发给除自己之外的所有客户端 for (int j 0; j client_count; j) { if (client_fds[j] ! client_fds[i]) { send(client_fds[j], buf, n, 0); } } } } }这段代码里有三个参数值得说明。select 的第一个参数是“最大 fd 编号 1”不是 fd 的数量因为内核要用它确定轮询范围在新客户端没加入之前max_fd 就是 server_fd1。recv 的返回值是重点返回 0 是对端正常关闭收到 FIN返回 -1 是出错这两种情况都必须从数组里移除这个 fd否则下次 select 会一直报告它可读循环就死在这里。移除时我用“尾元素覆盖当前位置”而不是 memmove 挪动整个数组这样是 O(1) 的代价是客户端顺序会变化实验里无所谓。客户端数组的上限 MAX_CLIENTS 设成 50这是 select 方案最现实的边界fd_set 默认只能管理 1024 个 fd扣掉标准输入输出和监听 socket留给客户端的不到 1021 个。50 对课堂实验早就够了真要撑上千并发那是 epoll 的事实验报告里写一句“更大规模应改用 epoll”反而是加分项。参数改法的优先级是端口改 htons(8888) 那里容量改 MAX_CLIENTS缓冲区按消息长度调一般不用动。提示如果实验环境是 WindowsC 语言 socket 代码要加 #include winsock2.h启动时调用 WSAStartup关闭 socket 用 closesocketselect 的第一个参数传 0 会被忽略。其余逻辑一样Linux 上跑通再改 Windows 成本很低。3.4 客户端代码收发分离避免阻塞客户端这边最大的坑是收发阻塞互相卡死。如果你在主线程里先 recv程序就停在那儿等消息你在键盘上打的字永远不会被发送反过来先 fgets 再 recv别人消息来了你也看不到。解决办法是开一个线程专门负责收主线程只管发互不干扰。// client.c —— TCP 聊天室客户端 #include stdio.h #include stdlib.h #include string.h #include unistd.h #include pthread.h #include arpa/inet.h #define BUFFER_SIZE 1024 // 收消息线程独立循环永远盯着 socket void *recv_loop(void *arg) { int fd *(int *)arg; char buf[BUFFER_SIZE]; while (1) { memset(buf, 0, sizeof(buf)); int n recv(fd, buf, BUFFER_SIZE - 1, 0); if (n 0) { printf(与服务器的连接已断开\n); break; } buf[n] \0; printf(%s, buf); } return NULL; } int main(int argc, char *argv[]) { const char *ip (argc 1) ? argv[1] : 127.0.0.1; int port (argc 2) ? atoi(argv[2]) : 8888; int fd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in srv; memset(srv, 0, sizeof(srv)); srv.sin_family AF_INET; srv.sin_port htons(port); inet_pton(AF_INET, ip, srv.sin_addr); if (connect(fd, (struct sockaddr *)srv, sizeof(srv)) 0) { perror(connect); exit(1); } printf(已连接 %s:%d输入消息回车发送\n, ip, port); pthread_t tid; pthread_create(tid, NULL, recv_loop, fd); // 主线程只负责读键盘、发消息 char buf[BUFFER_SIZE]; while (fgets(buf, sizeof(buf), stdin) ! NULL) { if (buf[0] \n) continue; send(fd, buf, strlen(buf), 0); } close(fd); return 0; }编译命令是 gcc client.c -o client -lpthread然后开三个终端窗口一个跑 ./server两个各跑 ./client 127.0.0.1 8888任意一个客户端发言另一个客户端就能收到。这就是一对多聊天的完整闭环。fgets 会把回车也读进 buf所以发送的内容自带换行服务端转发后所有客户端 print 出来就是一行一条消息如果你不想让消息自己换行用 buf[strcspn(buf, \n)] 0 把末尾的回车去掉。不想用 pthread 的话客户端也可以改用 select 同时监听 stdin 和 socket把两个 fd 都加进 read_set有输入就 sendsocket 可读就 recv代码量差不多原理和服务端一样。实验报告如果不想写线程选这个替代方案也拿得出手。4. UDP版多人聊天室服务器中转与广播模式TCP 版跑通之后UDP 版的差异就能看得透透的。大多数实验要求 UDP 和 TCP 做对照下面这个版本走的是“服务器中转”模式所有客户端只跟服务器通信服务器拿到消息再转发给其他人成员列表由服务器维护。4.1 UDP服务器中转模式核心代码// udp_server.c —— UDP 群聊服务端中转模式 #include stdio.h #include string.h #include stdlib.h #include unistd.h #include arpa/inet.h #define BUFFER_SIZE 1024 #define MAX_CLIENTS 50 int main() { int fd socket(AF_INET, SOCK_DGRAM, 0); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(8888); bind(fd, (struct sockaddr *)addr, sizeof(addr)); struct sockaddr_in clients[MAX_CLIENTS]; int count 0; printf(UDP 中转服务已启动端口 8888\n); while (1) { char buf[BUFFER_SIZE]; memset(buf, 0, sizeof(buf)); struct sockaddr_in from; socklen_t len sizeof(from); int n recvfrom(fd, buf, sizeof(buf) - 1, 0, (struct sockaddr *)from, len); // 用 IP 端口判断是不是新成员 int known 0; for (int i 0; i count; i) { if (clients[i].sin_port from.sin_port clients[i].sin_addr.s_addr from.sin_addr.s_addr) { known 1; break; } } if (!known count MAX_CLIENTS) { clients[count] from; printf(客户端加入: %s:%d当前 %d 人\n, inet_ntoa(from.sin_addr), ntohs(from.sin_port), count); } buf[n] \0; printf(收到消息: %s, buf); // 转发给除发送者外的所有人 for (int i 0; i count; i) { if (clients[i].sin_port from.sin_port clients[i].sin_addr.s_addr from.sin_addr.s_addr) continue; sendto(fd, buf, n, 0, (struct sockaddr *)clients[i], sizeof(clients[i])); } } }recvfrom 的后两个参数是“发送者地址”这是 UDP 服务的唯一线索——没有连接、没有 fd只有 IP 和端口。客户端第一次发消息时地址会被收进 clients 数组。注意判重用的是 sin_port 和 sin_addr.s_addr 两个字段同时相等因为不同机器可能用同一个端口只比端口会误判。代码里 sendto 的目标地址是 sockaddr_in这一步不用 connect是真正的无连接发送。UDP 套接字也可以调 connect但含义完全不同它不是建立连接而是给内核绑定一个默认收件地址之后就能直接用 send/recv 收发内核自动填上目的地址。下面客户端的写法用的就是这个技巧。// udp_client.c —— UDP 群聊客户端 #include stdio.h #include string.h #include stdlib.h #include unistd.h #include pthread.h #include arpa/inet.h void *recv_loop(void *arg) { int fd *(int *)arg; char buf[1024]; while (1) { memset(buf, 0, sizeof(buf)); int n recv(fd, buf, sizeof(buf) - 1, 0); if (n 0) break; buf[n] \0; printf(%s, buf); } return NULL; } int main(int argc, char *argv[]) { const char *ip (argc 1) ? argv[1] : 127.0.0.1; int port (argc 2) ? atoi(argv[2]) : 8888; int fd socket(AF_INET, SOCK_DGRAM, 0); struct sockaddr_in srv; memset(srv, 0, sizeof(srv)); srv.sin_family AF_INET; srv.sin_port htons(port); inet_pton(AF_INET, ip, srv.sin_addr); connect(fd, (struct sockaddr *)srv, sizeof(srv)); pthread_t tid; pthread_create(tid, NULL, recv_loop, fd); char buf[1024]; while (fgets(buf, sizeof(buf), stdin) ! NULL) { send(fd, buf, strlen(buf), 0); } return 0; }UDP 客户端比 TCP 短一截没有 connect 失败检查UDP connect 只会做地址校验不会真正发握手包也没有断线处理。recv_loop 里的 recv 用的是 UDP connect 之后的默认对端。整体上代码量小但这是用“不知道谁在线、谁退了”换来的。4.2 消息边界、丢包与成员管理UDP的三个代价TCP 粘包问题在 UDP 里不存在因为 UDP 是数据报协议一次 recvfrom 正好取一个完整的 sendto 报文内核不会把两条消息拼在一起。这是 UDP 唯一让你省心的点另外三个坑你得自己填。丢包。UDP 不保证送达局域网里实验丢包概率低但进程卡顿、缓冲区满时照样丢。老师答辩问“丢了怎么办”老实的回答是“实验阶段不处理生产环境要加序号和重传”这比嘴硬说 UDP 不会丢靠谱得多。成员管理。TCP 靠 recv 返回 0 识别下线UDP 完全没有这个信号。上面服务端的 clients 数组只会加人永远不会减人。一个客户端崩了它留在数组里的地址还会被持续 sendto内核不报错但消息都发给了空气。真要维护在线状态得让客户端每隔几秒发心跳包服务端超过 N 秒没收到就踢掉这又是一个小工程。乱序。两条消息走不同网络路径可能后发先至聊天场景里显得很怪。TCP 的序号保证顺序UDP 需要自己在消息里带序号接收端排序。这也是为什么“可靠聊天”默认选 TCP 的根本原因。把 TCP 和 UDP 版服务端的差别列成一张表实验报告里可以直接用关注点TCP 版服务端UDP 版中转服务新成员加入accept 返回专属 fdrecvfrom 拿到对方地址下线检测recv 返回 0 立即踢出没有信号要心跳超时消息边界字节流无边界一个数据报一条消息可靠性内核负责重传和排序自己加序号和确认典型代码量100 行左右70 行左右如果你想做的不是中转而是真广播也可以把 sendto 的目标换成子网广播地址并启用 SO_BROADCAST 选项但那样客户端之间就不需要服务器了和标题里的“一对多聊天室”语义不太一样。实验里先交出中转版本再在报告里提一句广播模式的差异老师会觉得你确实把协议想透了。5. 避坑指南从连不上到粘包实验里最常见的5个翻车现场下面五个问题是我见同学踩得最多、以及在实验课上帮人排查时重复率最高的。每条按“现象、原因、解决”来写踩到可以直接对号入座。5.1 connect 报 Connection refused服务端没启动还是端口错了现象客户端一启动就退出终端打印 connect: Connection refused。原因目标端口上没有程序在 listen。要么服务端没起来要么服务端端口不是 8888要么客户端连的不是同一台机器的 8888。防火墙也会产生类似错误但本地实验优先查前三个。解决先确认服务端进程在跑在服务端机器上执行 netstat -tlnp | grep 8888有输出才说明监听建立。客户端和服务端在同一台机器时用 127.0.0.1 联调跨机器联调才填对方的局域网 IP。还有个小坑代码里端口写死 8888你以为改了命令行参数就行多半是忘了源码里 htons(8888) 还是旧值。这个我先改源码后改命令行能少困惑很久。5.2 bind 报 Address already in use上次的服务端没退干净现象服务端第二次启动直接报错 bind: Address already in use第一次明明好好的。原因上一个服务端进程还活着或者刚被 CtrlC 杀掉但端口还处于 TIME_WAIT 状态。TCP 连接关闭后主动关闭方要等 2MSL 才能释放端口快速重启就会撞上。解决先 ps -ef | grep server 把残留进程杀掉。代码层面加一行 setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt))让端口在 TIME_WAIT 期间也能被重新绑定。两个一起做之后重启用 CtrlC 也不会被端口卡住了。注意这个选项要在 bind 之前设置放在 bind 后面不生效。5.3 消息乱码或者粘成一团TCP 字节流没有消息边界现象两个客户端同时发消息收到的一方看到两条消息的尾巴和脑袋接在一起或者一条消息被拆成两半print 出来乱套。原因TCP 是字节流recv 不保证一次取回一个 send 的内容内核按自己的节奏把数据拼给应用连续 send 的数据可能合并大数据可能拆分。这不是 bug是流的本质。解决应用层自己划分边界。最常用的是“长度前缀”每条消息前先放 4 字节的大端长度发送端先发长度再发内容。// 发送端先发长度再发内容 uint32_t msg_len htonl((uint32_t)strlen(buf)); send(fd, msg_len, 4, 0); send(fd, buf, strlen(buf), 0);// 接收端先收 4 字节算出长度后再收正文 uint32_t net_len; recv(fd, net_len, 4, 0); int msg_len ntohl(net_len); recv(fd, buf, msg_len, 0); // 实验里一条消息 1024 内一次能收完 buf[msg_len] \0;如果不想大改实验里也可以约定“消息以换行符结尾”接收端攒到换行才算一条完整消息。但长度前缀更好讲答辩因为它一次解决了粘包和拆包两个问题。收 4 字节长度时也可能只收到 2 字节严谨的写法要循环 recv实验里这条消息很短先不用钻牛角尖。5.4 能收到服务器消息但发不出去主线程被 recv 卡住了现象客户端能显示别人发的消息自己一打字按回车界面毫无反应消息像发给了黑洞。原因主线程里直接调了 recv或者 while 循环里 recv 在前、fgets 在后程序一直阻塞在 recv 上等消息键盘输入根本没被处理。解决收发分离。要么学第 3 章开一个接收线程主线程循环 fgets要么用 select 同时监听 stdin 和 socket。线程方案代码直白选它。如果你看到这里发现自己的代码就是这么写的别觉得奇怪这是整个实验里翻车率最高的一处。5.5 客户端退出后服务端崩溃数组没有及时清理现象某个客户端关了终端服务端立刻打印一堆乱码或者直接段错误有时不是当场崩而是下一个客户端加入时才崩。原因客户端关闭后服务端对它的 fd 继续 recv 返回 0 或 -1但你没把这个 fd 从 client_fds 数组移除select 会一直认为它可读形成死循环更糟的是数组里留着无效 fd后续循环访问到了已经关闭的 fd行为全看内核状态有的版本直接崩。解决把 recv 返回的 n 0 当成清理信号close 掉 fd用数组最后一个元素覆盖当前位置client_count 减一。第 3 章服务端代码里就是这么处理的删除后最好打印剩余人数方便确认清理真的发生了。这是我经常强调的一段逻辑也是我自己的血泪教训——当年就是没删干净跑到第 12 个客户端才崩查了一晚上。6. 验证与进阶用抓包确认三次握手再给聊天室加三个功能能跑通只是第一步实验答辩和后续改造才是拿分的重点。这一章讲两个方向怎么证明你的程序真的按教科书在工作以及加哪些功能能低成本拉开差距。6.1 用 Wireshark 验证三次握手与消息转发Wireshark 抓包是验证 socket 程序最直观的手段。打开 Wireshark选 Loopback: lo 或类似环回接口过滤器输入 tcp.port 8888然后正常启动服务端和两个客户端。过滤列表里能看到经典的 TCP 三次握手客户端发 SYN服务端回 SYNACK客户端再回 ACK。两次 connect 就有两组三次握手能对得上。发一条 hello会出现带 PSH、ACK 的包payload 里能看到 hello 明文同时另一个客户端对应的转发包也在这条连接上。这一页截图放进实验报告比用一百句话解释“TCP 面向连接”都管用。UDP 版抓包更简单过滤 udp.port 8888看到的是两条独立的 UDP 包一条客户端到服务端一条服务端转发给另一个客户端中间没有任何握手过程。两张图并排贴TCP 和 UDP 的区别在报告里直接成立。6.2 三个低成本改造昵称、私聊、退出通知如果交实验报告的时间还算充裕这三个改造挑一两个做性价比很高而且都不动服务端的 select 架构。昵称消息格式约定为 [昵称] 内容服务端收到后原样广播所有客户端 print 时自然显示“谁说的”。改造量一行字符串拼接。私聊给消息加一个类型字段比如 TO:目标昵称:内容。服务端解析后查一下 client 列表里的昵称表和 fd 的对应关系只往那一个 fd 转发而不是广播给所有人。这需要维护一张昵称到 fd 的映射表实验里用数组加结构体就够。退出通知TCP 版客户端断开时 recv 返回 0服务端在这个分支里组一条消息“某某已下线”转发给剩下的人。注意先广播再清理数组顺序反了会漏掉一个接收者。做改造前先定好消息格式的分隔符我最常用的就是冒号和方括号简单且不会跟中文内容冲突。刚踩过一次坑是拿空格切分昵称昵称一带空格就全乱后来一律改成冒号切分。希望你做这个实验时少走弯路一次跑通。本文还有配套的精品资源点击获取