简介:一份基于TCP协议的网络通信编程课程设计文档,面向计算机专业学生及需要完成TCP/IP大作业的开发者。内容以C语言实现客户/服务器通信程序为主线,完整覆盖注册、登录、单聊、群聊、私聊、在线人数统计、退出等功能,并围绕“有连接服务为主、无连接服务为辅”的事件对象I/O管理方式展开。文档包含总体设计、TCP协议选取依据、通信过程设计、数据包格式与checksum校验、程序流程图、客户端与服务器端程序清单及运行结果截图,便于读者从原理到编码逐层理解。资源为单一doc文档,约1.54MB,共1个文件。除可直接查阅外,完整程序清单还能作为基础框架,复用于类似聊天室项目;数据包设计部分则对网络编程中的报文格式与完整性校验学习颇有参考价值。目前已有973人学习浏览,适合在校学生快速把握课程设计结构、关键代码实现与报告撰写思路。
1. 基于TCP的C/S聊天程序:一份直接能跑通的C语言大作业
这份资源是一份TCPIP课程大作业,用C语言实现了一个基于TCP的网络通信编程客户端/服务器聊天程序。它不是那种只有伪代码的示例,而是完整可编译的WinSock工程,注册、登录、群聊、单聊、在线人数、退出这些功能全都有,还配了课程设计报告。最值得看的地方是它的通信架构:业务数据走TCP,控制应答却绕了一圈UDP——这个设计初看别扭,跑起来才发现是为了绕开阻塞recv的坑。正在写网络编程大作业、或者想找一个能跑通全流程C/S范本的读者,这份资源可以直接复现、拆解、改造。
2. 总体设计:为什么TCP配UDP,数据包格式怎么定
2.1 协议选型:TCP保证可靠,UDP做信令
程序主体是有连接服务,选TCP是理所当然的。TCP提供数据流传送、可靠性、有效流控、全双工操作和多路复用,面向连接、端到端、可靠交付,适合聊天这种不能丢消息的场景。UDP则不为IP提供可靠性、流控或差错恢复功能,只适合可靠性要求低、传输经济的应用。
但这个项目里UDP不是配角,它承担了控制应答的传输。这是整套设计里最关键的一个决定。如果所有消息全走TCP,客户端主循环里一旦调用阻塞recv,整个菜单就卡死在那里——用户还没输入聊天内容,程序已经在等服务器数据了。单线程模型下,TCP的可靠性和它的阻塞特性是一对矛盾。
解决方案是双通道:TCP通道专门跑聊天数据、注册登录请求这类必须可靠的业务;UDP通道跑服务器的控制应答(注册成功、登录失败、单聊对象在线与否)。应答丢一两个不致命,用户重试一次就行,但聊天消息不能丢。这个分工明确之后,整个程序的结构就清晰了。
2.2 数据包协议:首字符区分类型,@和*做分隔
自定义协议没有用二进制结构体,而是纯文本格式,用首字符区分消息类型,@和*做字段分隔。这样做的好处是调试方便——抓包一看就知道是什么消息,不需要解包。缺点是没做长度帧,后面会讲到粘包问题。
客户端发往服务器的数据包格式:
| 用途 | 格式 | 示例 |
|---|---|---|
| 注册 | '0' + 用户名 + '@' + 密码 | 0zhangsan@123456 |
| 登录 | '1' + 用户名 + '@' + 密码 | 1zhangsan@123456 |
| 群聊 | '2' + 用户名 + '@' + 内容 | 2zhangsan@大家好 |
| 退出登录 | '3' + 用户名 + '@' | 3zhangsan@ |
| 在线人数 | '4' + 用户名 + '@' | 4zhangsan@ |
| 单聊 | '5' + 对象名 + '@' + 用户名 + '*' + 内容 | 5lisi@zhangsan*你好 |
注意最后一行,单聊用了两个分隔符,@前面是接收对象,@后面是发送者,*后面才是消息内容。
服务器返回的应答码是两位字符,客户端接收端根据前两位判断结果:
| 应答码 | 含义 |
|---|---|
| 00 | 注册成功 |
| 01 | 注册失败,用户名已存在 |
| 10 | 登录成功 |
| 11 | 密码不正确 |
| 12 | 用户名不存在 |
| 1@ | 该用户已在其他地方登录 |
| 30 | 群聊广播消息 |
| 31 | 非正确用户的消息,不广播 |
| 40 | 在线用户列表 |
| 50 | 单聊对象在线 |
| 60 | 单聊对象不存在 |
| 61 | 单聊对象不在线 |
这里有个细节值得注意:'1@'这个应答码,@在协议里是分隔符,所以客户端判断登录结果时不能只检查recv_buf[0],必须同时看recv_buf[0]和recv_buf[1]两个字符。后面的代码里全是这种双字符判断,这是这个项目最容易踩坑的地方。
2.3 三个端口的分工:5050、5051、5053
整个通信过程涉及三个端口,这是理解这个程序的关键。初次看代码时很容易绕晕,我直接把端口逻辑画成表格:
| 端口 | 绑定方 | 协议 | 用途 |
|---|---|---|---|
| 5050 | 服务器 | TCP | 监听客户端连接,收发聊天和注册登录请求 |
| 5051 | 客户端本机的接收器 | UDP | 接收服务器发来的控制应答,打印并转发到5053 |
| 5053 | 客户端主程序 | UDP | 主程序recvfrom接收应答,判断登录/聊天状态 |
数据流是这样的:客户端主程序通过TCP把请求发给服务器的5050端口,服务器处理完之后不走TCP回包,而是用UDP把应答发到客户端IP的5051端口。客户端那边有个独立的接收器程序在监听5051,收到应答后打印到屏幕,然后转发给本机的5053端口。主程序在需要时调用一次recvfrom(5053),拿到转发过来的应答。
绕这一圈的核心原因还是阻塞问题:主程序的菜单交互不能卡在recv上,所以应答走UDP异步通道,主程序在合适的时机主动去取。这个设计不算优雅,但在单线程C语言程序里是实用的土办法,而且确实能跑通。
2.4 通信过程与状态机
登录前的流程是短连接:客户端连接服务器,发注册或登录请求,收到应答后,如果登录失败就断开;登录成功则保持TCP连接不断开,进入聊天循环。
聊天循环里有四个选项:群聊、单聊、在线用户、退出。群聊是发送'2'开头的包,服务器广播给所有在线客户端;单聊是发送'5'开头的包,服务器查在线表后决定转发还是返回错误应答;在线用户是发'4'开头的包,服务器返回'40'加用户列表;退出登录发'3'开头的包,但注意——退出登录只是退出聊天模式,TCP连接不断开,可以再次登录。只有选择退出程序时才真正断开。
登录状态用hasLogin这个bool变量管理,整个程序的状态机其实就三条分支:未登录(只能注册/登录/退出)、登录成功(进入聊天子循环)、聊天中(根据输入切换群聊/单聊/在线/退出)。这个状态机写得很直白,适合大作业答辩时讲清楚。
3. 客户端实现:注册登录、群聊单聊与退出
3.1 WinSock初始化与TCP连接
客户端主程序第一步是初始化WinSock和建立TCP连接。注意代码开头的#pragma comment(lib, "Ws2_32.lib"),这个不能漏,漏了链接阶段直接报错。
WSADATA wsaData; sockaddr_in ser; SOCKET sClient; char szServer[256]; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { printf("WSAStartup()\n"); return 0; } printf("手动连接模式中....\n"); printf("请输入即将连接的服务器 IP 地址:"); gets(szServer); ser.sin_family = AF_INET; ser.sin_port = htons(5050); ser.sin_addr.s_addr = inet_addr(szServer); sClient = socket(AF_INET, SOCK_STREAM, 0); // TCP套接字 if (connect(sClient, (sockaddr*)&ser, sizeof(ser)) == INVALID_SOCKET) { printf("connect() failed\n"); return 0; }WSAStartup(MAKEWORD(2,2))是请求加载2.2版本的WinSock库,几乎所有的WinSock程序都是这个写法。ser.sin_port=htons(5050)把5050从主机字节序转成网络字节序,htons是必须的,漏掉会在不同字节序的机器上连错端口。connect是阻塞调用,连不上会等一段时间才返回,这是TCP三次握手的正常表现——SYN发出后要等服务器ACK,超时才报错。
3.2 注册与登录:拼包、发送、等待应答
注册和登录的代码结构几乎一样,都是拼字符串、send发送、然后recvfrom等待UDP应答。我把注册的完整逻辑拆出来看:
case '0': // 注册 printf("用户名:"); scanf("%s", user); printf("密 码:"); scanf("%s", password); strcpy(send_buf, "0"); // 首字符标记注册 strcat(send_buf, user); strcat(send_buf, "@"); // 用户名和密码的分隔符 strcat(send_buf, password); MySendMessage(sClient, send_buf, sizeof(send_buf)); break;这里有个隐患,后面的避坑章节会单独讲:MySendMessage发送的是sizeof(send_buf),也就是1000字节,不是strlen(send_buf)。这意味着对方会收到大量空字节填充,解析时如果不按'\0'截断,strcmp和strcpy都会出玄学问题。
登录的代码比注册多了应答等待:
case '1': // 登录 printf("用户名:"); scanf("%s", user); printf("密 码:"); scanf("%s", password); strcpy(send_buf, "1"); strcat(send_buf, user); strcat(send_buf, "@"); strcat(send_buf, password); MySendMessage(sClient, send_buf, sizeof(send_buf)); // 从5053端口接收服务器经接收器转发来的应答 iRecv = recvfrom(wchysClient, recv_buf, BUFFER_SIZE, 0, (SOCKADDR*)&cli, &wchyiLen); if (recv_buf[0] == '#' && recv_buf[1] == '#') { hasLogin = 1; // 登录成功标记 system("cls"); printf("登录成功^_^\n"); } else if (recv_buf[0] == '@' && recv_buf[1] == '@') { hasLogin = 0; // 登录失败 system("cls"); printf("登录失败!请重新登录或注册后登录^_^\n"); } else if (recv_buf[0] == '*' && recv_buf[1] == '*') { hasLogin = 0; system("cls"); printf("已经在其他地方登陆\n"); }recvfrom的阻塞在这里是合理的——登录动作本来就是等待服务器结果的操作,用户不会觉得卡。但要注意,如果服务器意外崩溃或者UDP应答丢了,recvfrom会一直等下去,没有任何超时机制。这是单机演示没问题、多人环境容易翻车的地方。改成带超时的WSAEventSelect或者SO_RCVTIMEO是加分项,第6章会给出改法。
3.3 聊天循环:群聊、单聊、在线人数
登录成功后进入聊天子循环,四个选项对应四种包。群聊的循环体是典型结构:
case '0': // 群聊 wchyhasLogin = 1; printf("输入 exit 退出。\n"); while (wchyhasLogin) { printf(">"); scanf("%s", &str); if (strcmp(str, "exit") != 0) { strcpy(send_buf, "2"); // 群聊标记 strcat(send_buf, user); strcat(send_buf, "@"); strcat(send_buf, str); MySendMessage(sClient, send_buf, sizeof(send_buf)); } else { system("cls"); wchyhasLogin = 0; // 退出当前聊天模式 } } break;群聊的退出条件是输入exit,退出后回到二级菜单,TCP连接保持不断开。这个设计对应需求里的「退出聊天但不断开连接」。
单聊比群聊多一个步骤:先指定聊天对象,发送'5'开头的握手包,等服务器确认对方在线,再进入循环。确认成功(收到%%)才开始发消息,收到^^说明对象不存在,收到&&说明对方不在线,都会退回菜单。这个先握手再聊天的流程能避免消息发到离线用户那里,逻辑是对的,只是握手包的内容硬编码成了"__Welcome__To__Single__Chat...",有点凑数,答辩时可能被问到。
3.4 UDP接收通道与接收器程序
客户端目录下还有第二个程序——客户端接收器(代码里注释叫「本地服务器」),它独立于主程序运行,监听5051端口,职责是接收服务器的UDP应答、打印、再转发到5053:
sSocket = socket(AF_INET, SOCK_DGRAM, 0); // UDP套接字 ser.sin_family = AF_INET; ser.sin_port = htons(5051); // 监听5051 ser.sin_addr.s_addr = htonl(INADDR_ANY); bind(sSocket, (LPSOCKADDR)&ser, sizeof(ser)); while (1) { iRecv = recvfrom(sSocket, recv_buf, BUFFER_LENGTH, 0, (SOCKADDR*)&cli, &iLen); switch (recv_buf[0]) { case '0': if (recv_buf[1] == '0') printf("注册成功\n"); else printf("注册失败\n"); break; case '1': if (recv_buf[1] == '0') { printf("登陆成功\n"); // 构造UDP包发送到本机5053,通知主程序 wchy.sin_port = htons(5053); wchy.sin_addr.s_addr = inet_addr("127.0.0.1"); strcpy(send_buf, "##"); MySendMessage(wchysSocket, send_buf, sizeof(send_buf), (structsockaddr*)&wchy, wchyiLen); } break; } }接收器的switch里判断的是服务器发来的两位应答码(00、01、10、11、12、1@、30、31等),然后转换成##、@@、**这种简码转发给主程序。也就是说,主程序收到的不是服务器原始应答,而是经过接收器二次编码的信号。这套「服务器 → 5051接收器 → 5053主程序」的链路就是整个程序的消息总线。
部署时要先启动接收器,再启动主程序,顺序反了服务器应答没人收,主程序会一直卡在recvfrom。这个坑在演示前必踩,我因为顺序问题翻过两次车。
4. 服务器端实现:事件对象I/O与用户管理
4.1 事件对象I/O模型:处理多客户端的关键
服务器端采用事件对象I/O管理模式,用WSAEventSelect把多个socket的事件关联到事件对象上。核心思路是:主线程创建事件,注册监听套接口的FD_ACCEPT事件和FD_CLOSE事件;一旦accept到新连接,就为这个新socket创建独立事件,注册FD_READ和FD_CLOSE;发生可读事件后,调用recv接收数据,并按协议处理。
// 服务器主循环的典型结构 WSAEVENT hEvent = WSACreateEvent(); WSAEventSelect(listenSocket, hEvent, FD_ACCEPT | FD_CLOSE); while (1) { DWORD dwIndex = WSAWaitForMultipleEvents(1, &hEvent, FALSE, WSA_INFINITE, FALSE); WSANETWORKEVENTS events; WSAEnumNetworkEvents(listenSocket, hEvent, &events); if (events.lNetworkEvents & FD_ACCEPT) { SOCKET clientSock = accept(listenSocket, NULL, NULL); // 为新连接创建事件,注册读事件 WSAEVENT hClientEvent = WSACreateEvent(); WSAEventSelect(clientSock, hClientEvent, FD_READ | FD_CLOSE); // 把clientSock和hClientEvent保存到数组里 } }WSAWaitForMultipleEvents是阻塞等待,一次可以等最多WSA_MAXIMUM_WAIT_EVENTS(64)个事件。事件对象I/O相比select的好处是回调驱动,不用每次遍历所有socket查状态;相比完成端口(IOCP)的好处是入门门槛低,适合课程设计这个体量。
服务器代码里还有一个细节:deal函数处理消息时同时接收ip参数,这个ip用于识别消息来自哪个客户端,也是UDP应答回传时填目标地址用的。服务器保存每个客户端的socket、IP、登录状态,形成一个在线用户表。
4.2 用户注册与登录校验:config.txt的读写逻辑
用户信息存在当前目录的config.txt里。注册流程是:接收'0'开头的包,调用findUser在已有用户数组里查重,如果用户名不存在,就把用户名和密码追加写入config.txt,返回00;否则返回01。
// deal.h 中的查找函数 int findUser(UserData **data, int num, char *name) { int i; for (i = 0; i <= num; i++) { if (strcmp(name, data[i]->UserName) == 0) return i; } return -1; }登录流程查用户合法性:用户名查不到返回12,密码不对返回11,用户已在其他客户端登录返回1@,全部通过返回10。这里注意区别:12是不存在,11是密码错,这两个应答码是客户端区分错误类型的依据。
config.txt是纯文本,每行一组用户名和密码,格式跟发送的注册包类似,用@分隔。这套方案的优点是直观,答辩时能现场打开文件展示注册结果;缺点是密码明文存储,没有MD5处理。如果在报告里写一句「生产环境应使用哈希存储」,是加分的。
4.3 消息转发:群聊广播与单聊定向
服务器收到聊天消息后,根据首字符决定转发策略:
- '2'开头的群聊消息:向在线用户表中的所有客户端转发,转发内容保留原始包(首字符+用户名+@+内容),接收端用recv_buf+2跳过前两个字符再打印,所以群聊显示格式是「[群聊]用户名@内容」。
- '5'开头的单聊消息:先解析出目标用户名(第一个@之前的部分),查在线表。目标在在线表中且已登录,转发消息并返回50;目标不在在线表中,返回60;目标在表中但状态是离线,返回61。
- '4'开头的在线查询:遍历在线表,把在线用户名拼到应答包里,返回40加列表内容。
- '3'开头的退出登录:把该用户从在线表中标记为离线,TCP连接保持;注意应答是30还是31,取决于该用户是否在合法登录状态。
这里有一个值得看的点是,单聊消息在服务器端直接查表转发,不落盘不缓存。目标离线时消息直接丢弃,只返回61错误码。这意味着离线消息是不支持的——大作业层面这个简化没问题,但如果需求里写了「单聊」,答辩老师可能会追问离线消息策略,提前想好怎么答。
4.4 在线用户维护与多客户端状态管理
服务器的在线表数据结构是UserData指针数组,每个节点保存用户名、密码、socket句柄、IP、登录标志。注册和登录操作都会刷新这个表:注册成功新增节点、登录成功更新登录标志、退出清标志。
typedef struct UserData { char UserName[UserNameLen]; char Password[PasswordLen]; SOCKET sock; char ip[16]; int isLogin; } UserData;这个结构体的设计把用户信息和网络信息绑在一起,好处是查一次表就能同时拿到socket和IP,转发消息不需要二次查找。坏处是表是数组实现的,容量固定,在线用户上限就是数组长度,而且没有做socket错误后的自动清理——某个客户端异常断开,如果FD_CLOSE事件没处理干净,这个节点会一直占着在线表,后面的登录会被误判为「已在其他地方登录」。
服务器还有一个独立的UDP线程或socket处理5051端口的应答发送。它把处理结果打包成两位应答码,通过sendto发到客户端的5051端口。这个UDP通道和TCP业务通道在服务器端是并行的,互不干扰。
5. 避坑记录:端口绑定、粘包、缓冲区这些坑
5.1 登录状态标记放错位置,陷入死循环
现象:输入用户名密码后,程序反复弹登录菜单,登录成功也进不了聊天界面。
原因:原始代码里hasLogin被定义在while循环内部,每次循环重新初始化为false,登录成功的标记被覆盖。代码注释里那句「只能绑定一次,开始放在了循环里,555」就是作者自己的血泪经验。
解决:把bool hasLogin=false提到while循环外面,只在程序开始时初始化一次,登录成功后置true,退出登录时置false。
5.2 UDP应答丢失,recvfrom永久阻塞
现象:输入登录信息后,程序卡在recvfrom那一行,菜单也不刷新,像死机一样。
原因:UDP是不可靠传输,服务器的应答包如果丢了,主程序的recvfrom没有超时机制,会一直等下去。有时候是本机防火墙拦了5053端口的入站包,有时候是网络拥塞丢包。
解决:给recvfrom加超时,用setsockopt设置SO_RCVTIMEO,或者改用WSAEventSelect配合WSAWaitForMultipleEvents做超时等待。最省事的改法是先单机演示(服务器和客户端都在127.0.0.1),绕开本机防火墙问题。
5.3 粘包和半包:没有长度帧的文本协议迟早出事
现象:群聊消息偶尔出现两条内容拼接在一起,或者消息末尾多出一串乱码。
原因:TCP是流协议,send(1000字节)和send(500字节)可能被接收端一次recv全部收走,也可能一个数据包要分两次recv才能收完。这个项目用sizeof(send_buf)=1000固定长度发送,接收端也是按固定缓冲区收,短消息后面全是'\0'填充,长消息可能被截断或拼接。
解决:在自定义协议里加长度帧。最简单的改法是包头用4字节存消息体长度,recv先收4字节得知长度,再收完整消息体。大作业不改也能跑,因为演示消息都很短,缓冲区足够大;但如果想把这套代码扩展成真正能用的工具,长度帧是必须补的。
5.4 sizeof(send_buf)发送,坑到解析端
现象:服务器收到的注册包解析异常,strcmp用户名永远不相等,或者密码后面跟着一串空格。
原因:MySendMessage(sClient, send_buf, sizeof(send_buf))传的是1000,不是消息实际长度。send会把整个1000字节缓冲区都发出去,没有数据的地方是上次残留的内容或'\0'。接收端如果按'\0'截断还能救回来,如果直接strcmp整段就会出玄学。
解决:发送前把send_buf清空(memset),发送长度用strlen(send_buf)+1而不是sizeof(send_buf)。这是C语言字符串协议最常见的翻车点,没有之一。
5.5 scanf和gets混用,缓冲区残留导致跳菜单
现象:输入密码后,程序没等用户选择就直接跳进了下一个switch分支,或者连续打印菜单。
原因:scanf("%s")读入字符串后,回车键留在输入缓冲区里;后面的gets或者下一个scanf读到残留的换行符,直接返回空内容。
解决:每次scanf后调用fflush(stdin),或者在读字符串之前用while(getchar()!='\n')把缓冲区清空。注意fflush(stdin)在Windows下的Visual Studio是有效的,但严格说是未定义行为;用getchar循环更稳妥。
5.6 先启动客户端导致连接拒绝
现象:客户端启动后提示connect失败,错误码是WSAECONNREFUSED(10061)。
原因:服务器没启动或者没监听5050端口。TCP连接被主动拒绝,说明目标端口没有服务在监听。更隐蔽的情况是服务器崩溃后端口没释放,处于TIME_WAIT状态,新监听失败。
解决:演示顺序固定为——先启动服务器,再启动客户端接收器,最后启动客户端主程序。服务器崩溃后如果端口被占用,等一两分钟让TIME_WAIT过期,或者用setsockopt设置SO_REUSEADDR再重新监听。
6. 验证与改进:抓包验证协议,三个能加分的改动
6.1 用Wireshark验证三次握手和消息包
代码能跑通不等于协议对,用Wireshark抓包验证是最直观的手段。启动捕获后过滤tcp.port==5050,客户端登录时会看到三条TCP握手包:SYN、SYN+ACK、ACK,这就是三次握手。登录成功后发一条群聊消息,能看到服务器返回ACK确认。再过滤udp.port==5051,能看到服务器发往客户端5051端口的应答包,内容就是两位应答码加消息体。抓包验证的key是确认UDP应答通道确实在工作,而不只是代码看起来在工作。
6.2 三个加分改动,答辩前值得做
第一个是给UDP应答加序号和确认。客户端每收到一个应答包,回一个ACK,服务器超时没收到ACK就重发。这样UDP通道就从不可靠变成「尽力可靠」,能堵住「应答丢了卡死」这个最大的坑。
第二个是把send_buf发送长度从sizeof改成strlen(send_buf)+1。这个改动只有一行,但能彻底解决粘包残渣问题。如果时间充裕,再配合长度帧就是完整的消息完整度方案。
第三个是把config.txt里的密码做哈希存储。不要自己发明哈希算法,直接用MD5或者SHA-256,存哈希值而不是明文。答辩时主动说一句「这是为了演示方便,生产环境密码不能明文存储」,比等老师问到再解释要好得多。
从那以后,我每次拿到这种带报告的大作业资源,第一步永远是先在地上把端口表和协议格式画出来,再去看代码。端口绕两圈、应答码十几位,不先理清这张表,代码看再多遍都是黑匣子。这份资源的协议设计和双通道思路,拆一遍下来对WinSock编程的认识会扎实不少,希望帮到你。
本文还有配套的精品资源,点击获取