简介:这是一份计算机网络实验的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_STREAM,UDP 对应 SOCK_DGRAM。stream 是流,意味着数据像水管里的水一样没有间断;datagram 是数据报,每条消息是一个独立的包裹。这两句话直接解释了后面所有行为差异:TCP 要三次握手建立连接,UDP 不需要;TCP 的 recv 读多少算多少,UDP 的 recvfrom 一次取一个完整报文。
服务端的接口差异更直观。TCP 服务端要走 socket、bind、listen、accept 这条链,accept 返回的是一个专门和某个客户端通信的新 fd,这个 fd 就是连接本身。UDP 服务端根本没有 accept,bind 完直接 recvfrom,它不需要“等别人建立连接”,因为每个发来数据的地址都是它的潜在客户端。
两者的差异我直接列一张对照表,后面写代码时会反复用到:
| 对比维度 | TCP | UDP |
|---|---|---|
| socket 类型 | SOCK_STREAM | SOCK_DGRAM |
| 连接过程 | 三次握手,面向连接 | 无连接,不握手 |
| 数据边界 | 无边界,字节流 | 有边界,数据报 |
| 可靠性 | 可靠,丢包重传 | 可能丢包、乱序 |
| 服务端入口 | listen + accept | 只有 bind |
| 收发接口 | send / recv | sendto / 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:广播、实时与IGMP
UDP 不是没用,它把控制权交给你,适合三类场景。第一类是实时性压倒一切:语音对讲、视频会议、游戏同步,晚到不如不到,丢一帧可以接受。第二类是广播和组播: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) 创建套接字,返回一个 fd;bind() 把这张 socket 绑定到某个端口,端口是客户端找你的门牌号,实验里固定写死,比如 8888;listen() 把 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_fd+1。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,缓冲区按消息长度调,一般不用动。
提示:如果实验环境是 Windows,C 语言 socket 代码要加 #include <winsock2.h>,启动时调用 WSAStartup,关闭 socket 用 closesocket,select 的第一个参数传 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,有输入就 send,socket 可读就 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 返回专属 fd | recvfrom 拿到对方地址 |
| 下线检测 | 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,第一次明明好好的。
原因:上一个服务端进程还活着,或者刚被 Ctrl+C 杀掉但端口还处于 TIME_WAIT 状态。TCP 连接关闭后,主动关闭方要等 2MSL 才能释放端口,快速重启就会撞上。
解决:先 ps -ef | grep server 把残留进程杀掉。代码层面加一行 setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)),让端口在 TIME_WAIT 期间也能被重新绑定。两个一起做,之后重启用 Ctrl+C 也不会被端口卡住了。注意这个选项要在 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,服务端回 SYN+ACK,客户端再回 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,服务端在这个分支里组一条消息“某某已下线”,转发给剩下的人。注意先广播再清理数组,顺序反了会漏掉一个接收者。
做改造前先定好消息格式的分隔符,我最常用的就是冒号和方括号,简单且不会跟中文内容冲突。刚踩过一次坑是拿空格切分昵称,昵称一带空格就全乱,后来一律改成冒号切分。希望你做这个实验时少走弯路,一次跑通。
本文还有配套的精品资源,点击获取