实验目的
熟悉TCP协议下通信程序的编写、编译和执行
实验内容要求与设计思想
模拟实现TCP协议通信过程,要求编程实现服务器端与客户端之间双向数据传递。客户端向服务器端发送“我是集美大学网络空间安全专业学生”,服务器回应“我也是集美大学网络空间安全专业学生”。
【提示:服务器端创建监听套接字,并为它关联一个本地地址(指定IP地址和端口号),然后进入监听状态准备接受客户的连接请求。为了接受客户端的连接请求,服务器端必须调用accept函数。客户端创建套接字后即可调用connect函数去试图连接服务器监听套接字。当服务器端的accept函数返回后,connect函数也返回。此时客户端使用socket函数创建的套接字,服务器端使用accept函数创建的套接字,双方实现通信。】
实验使用环境
操作系统:Windows 11 专业版
编程环境:vscode 2022
实验步骤和调试过程
源码
服务器端 server.c
/* server.c - TCP 服务器端:监听套接字 bind/listen/accept,与客户端双向传递数据 */#include<winsock2.h>/* 必须放在 windows.h 之前 */#include<windows.h>#include<stdio.h>#include<string.h>#defineSERVER_PORT8888#defineBUF_LEN256#defineBACKLOG5staticvoidshowWinsockError(constchar*what){interr=WSAGetLastError();printf("%s 调用失败,错误码: %d\n",what,err);if(err==WSAEADDRINUSE)printf("端口 %d 已被占用,可用 netstat -ano | findstr :%d 查看占用进程\n",SERVER_PORT,SERVER_PORT);}intmain(void){WSADATA wsaData;SOCKET listenSock,connSock;structsockaddr_insrvAddr,peerAddr;intpeerLen=(int)sizeof(peerAddr);charbuf[BUF_LEN];constchar*reply="我也是集美大学网络空间安全专业学生";intn;intsent;SetConsoleOutputCP(CP_UTF8);setvbuf(stdout,NULL,_IONBF,0);if(WSAStartup(MAKEWORD(2,2),&wsaData)!=0){printf("WSAStartup 调用失败,错误码: %d\n",WSAGetLastError());return1;}if(LOBYTE(wsaData.wVersion)!=2||HIBYTE(wsaData.wVersion)!=2){printf("本机不支持 Winsock 2.2\n");WSACleanup();return1;}/* 1. 创建监听套接字 */listenSock=socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);if(listenSock==INVALID_SOCKET){showWinsockError("socket");WSACleanup();return1;}/* 2. 为监听套接字关联本地地址(INADDR_ANY + 端口号) */memset(&srvAddr,0,sizeof(srvAddr));srvAddr.sin_family=AF_INET;srvAddr.sin_port=htons(SERVER_PORT);srvAddr.sin_addr.s_addr=htonl(INADDR_ANY);if(bind(listenSock,(structsockaddr*)&srvAddr,sizeof(srvAddr))==SOCKET_ERROR){showWinsockError("bind");closesocket(listenSock);WSACleanup();return1;}/* 3. 进入监听状态,准备接受连接请求 */if(listen(listenSock,BACKLOG)==SOCKET_ERROR){showWinsockError("listen");closesocket(listenSock);WSACleanup();return1;}printf("服务器已启动,监听端口 %d,等待客户端连接...\n",SERVER_PORT);/* 4. accept 阻塞等待;返回时 TCP 连接已建立,并得到新的连接套接字 */connSock=accept(listenSock,(structsockaddr*)&peerAddr,&peerLen);if(connSock==INVALID_SOCKET){showWinsockError("accept");closesocket(listenSock);WSACleanup();return1;}printf("已接受连接,对端地址: %s:%d\n",inet_ntoa(peerAddr.sin_addr),ntohs(peerAddr.sin_port));/* 5. 用 accept 返回的套接字接收客户端数据(recv 只保证收到已到达的字节) */n=recv(connSock,buf,BUF_LEN-1,0);if(n>0){buf[n]='\0';printf("接收数据(%d 字节): %s\n",n,buf);/* 6. 服务器回应,实现双向传递 */sent=send(connSock,reply,(int)strlen(reply),0);if(sent==SOCKET_ERROR)showWinsockError("send");elseprintf("发送数据(%d 字节): %s\n",sent,reply);}elseif(n==0){printf("连接已被客户端关闭,未收到数据\n");}else{showWinsockError("recv");}/* 7. 关闭连接套接字、监听套接字,释放 Winsock 库资源 */closesocket(connSock);closesocket(listenSock);WSACleanup();return(n>0)?0:1;}客户端 client.c
/* client.c - TCP 客户端:键盘读入内容,connect 服务器发送,再接收服务器回应 */#include<winsock2.h>/* 必须放在 windows.h 之前 */#include<windows.h>#include<stdio.h>#include<string.h>#defineSERVER_IP"127.0.0.1"#defineSERVER_PORT8888#defineBUF_LEN256staticvoidstripNewline(char*s){size_tlen=strlen(s);while(len>0&&(s[len-1]=='\n'||s[len-1]=='\r'))s[--len]='\0';}staticvoidreleaseAll(SOCKET sock){shutdown(sock,SD_BOTH);closesocket(sock);WSACleanup();}intmain(void){WSADATA wsaData;SOCKET sock;structsockaddr_insrvAddr;charsendBuf[BUF_LEN];charrecvBuf[BUF_LEN];size_tinputLen;intn;intsent;interr;/* 输入、输出代码页要分别设置:只管输出会让键盘读进来的中文仍是 GBK 字节 */SetConsoleOutputCP(CP_UTF8);SetConsoleCP(CP_UTF8);setvbuf(stdout,NULL,_IONBF,0);if(WSAStartup(MAKEWORD(2,2),&wsaData)!=0){printf("WSAStartup 调用失败,错误码: %d\n",WSAGetLastError());return1;}if(LOBYTE(wsaData.wVersion)!=2||HIBYTE(wsaData.wVersion)!=2){printf("本机不支持 Winsock 2.2\n");WSACleanup();return1;}/* 1. 创建套接字(不调用 bind,本地地址与端口号由系统内核自动选定) */sock=socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);if(sock==INVALID_SOCKET){printf("socket 调用失败,错误码: %d\n",WSAGetLastError());WSACleanup();return1;}/* 2. 填写要连接的服务器地址信息 */memset(&srvAddr,0,sizeof(srvAddr));srvAddr.sin_family=AF_INET;srvAddr.sin_port=htons(SERVER_PORT);srvAddr.sin_addr.s_addr=inet_addr(SERVER_IP);/* 已是网络字节序 *//* 3. 请求连接服务器监听套接字;服务器 accept 返回后 connect 才返回 */printf("正在连接 %s:%d ...\n",SERVER_IP,SERVER_PORT);if(connect(sock,(structsockaddr*)&srvAddr,sizeof(srvAddr))==SOCKET_ERROR){err=WSAGetLastError();printf("connect 调用失败,错误码: %d\n",err);if(err==WSAECONNREFUSED)printf("对方端口无监听进程,请先运行 server.exe 再运行本程序\n");elseif(err==WSAETIMEDOUT)printf("连接超时,请检查 IP、端口与防火墙设置\n");closesocket(sock);WSACleanup();return1;}/* 4. 从键盘读入待发内容 */printf("请输入要发送给服务器的内容: ");if(fgets(sendBuf,BUF_LEN,stdin)==NULL){printf("未能读到键盘输入\n");releaseAll(sock);return1;}inputLen=strlen(sendBuf);if(inputLen==(size_t)(BUF_LEN-1)&&sendBuf[inputLen-1]!='\n')printf("提示: 输入超过 %d 字节,本次只发送截断后的内容\n",BUF_LEN-1);stripNewline(sendBuf);if(sendBuf[0]=='\0'){printf("输入为空,不发送数据\n");releaseAll(sock);return1;}/* 5. 客户端向服务器发送数据 */sent=send(sock,sendBuf,(int)strlen(sendBuf),0);if(sent==SOCKET_ERROR){printf("send 调用失败,错误码: %d\n",WSAGetLastError());releaseAll(sock);return1;}printf("发送数据(%d 字节): %s\n",sent,sendBuf);/* 6. 接收服务器的回应,实现双向传递 */n=recv(sock,recvBuf,BUF_LEN-1,0);if(n>0){recvBuf[n]='\0';printf("接收数据(%d 字节): %s\n",n,recvBuf);}elseif(n==0){printf("连接已被服务器关闭,未收到回应\n");}else{printf("recv 调用失败,错误码: %d\n",WSAGetLastError());}/* 7. 关闭读写通道与套接字,释放 Winsock 库资源 */releaseAll(sock);return(n>0)?0:1;}实现效果截图
- 编译server.c与client.c,编译命令为
gcc -Wall -Wextra -g server.c -o server.exe -lws2_32、gcc -Wall -Wextra -g client.c -o client.exe -lws2_32 - 先启动服务器端:创建流式套接字,bind关联INADDR_ANY与8888端口,listen之后阻塞在accept(),等待客户端的连接请求:
- 再启动客户端:客户端只创建套接字并connect,不调用bind,本地端口号由内核自动选定;按提示键入作业要求的内容,回车后发送
- 服务器端用accept返回的连接套接字收到48字节后回应,客户端接收并显示,一次请求—响应完成双向数据传递
- 调试阶段还对异常分支做了验证
- 服务器未启动时客户端connect() 返回错误码10061并提示先运行server.exe
- 重复启动服务器时bind()返回10048端口被占用
- 只按回车时输入为空、不发送数据
- 服务器未启动时客户端connect() 返回错误码10061并提示先运行server.exe
实验小结
202421336056
我负责服务器端主流程:WSAStartup 校验版本后用 socket 创建 AF_INET、SOCK_STREAM、IPPROTO_TCP的监听套接字,bind关联INADDR_ANY与8888端口,listen设backlog为5,accept阻塞等待连接请求;连接建立之后,用accept返回的新套接字recv收取数据、send回应,最后closesocket关闭两个套接字并WSACleanup。收获最大的是分清了两类套接字:监听套接字只参与bind、listen、accept,不负责收发,收发必须走accept返回的连接套接字;对照笔记的五元组表,服务端的本地地址与端口号由bind确定,远程地址与端口号由accept确定。过程中遇到的问题是MinGW的winsock2.h没有定义socklen_t,accept的第三个参数要声明为int而不是socklen_t。
202421336055
我负责客户端:socket创建流式套接字后不调用bind,直接connect到127.0.0.1:8888,send发送内容、recv接收回应,最后shutdown(SD_BOTH)、closesocket、WSACleanup。客户端没有bind,本地IP与端口号由内核自动选定,实测每次运行时服务器端打印的对端端口都不相同(14859、59089、62483),正好印证了五元组表中“由系统内核自动确定”那一行。按要求把写死的字符串改成键盘输入之后,问题集中在fgets上:一是它会把行尾换行符留在缓冲区里,不剥掉服务器端收到的字节数就多一个,因此加了stripNewline去掉\r与\n;二是给数据的长度上限只能是BUF_LEN-1再单独补\0,否则填满时终止符就是越界写入;三是空串与超过255字节这两种输入要提前拦下并提示,不然发0字节会让服务器端recv返回0,把一个输入问题伪装成“连接被关闭”这样的协议问题。
202421336059
我负责编译配置与中文显示调试。踩的坑有三个。一是winsock2.h必须放在windows.h之前,否则会触发winsock2.h应在windows.h之前包含的告警。二是MinGW下WSAGetLastError()返回int,用%lu打印被-Wformat报警,统一改成%d才做到零告警。三是中文乱码:控制台的输入代码页与输出代码页是两个独立设置,只调SetConsoleOutputCP(CP_UTF8)时输出正常,但键盘输入的中文会以GBK字节发出、服务器端就成了乱码;用探针程序打印GetConsoleCP与GetConsoleOutputCP,确认本机真实控制台默认是936,补上SetConsoleCP(CP_UTF8)之后才变为65001。补齐后又核对出一个汉字在UTF-8下占3字节,两端48与51字节的输出完全对得上。
202421336060
我负责测试验证:先跑通正常路径并比对两端字节数与原文,客户端发出48字节的“我是集美大学网络空间安全专业学生”,服务器回应51字节的“我也是集美大学网络空间安全专业学生”;再逐条验证异常分支,服务器没启动时客户端connect报10061、退出码为1且不会卡死,重复启动服务器时bind报10048端口被占用,输入为空时不发送,客户端连上却不发数据就关闭时服务器端走recv返回0的分支并明确打印“连接已被客户端关闭”。两点经验印象最深。一是必须把recv的返回值按大于0、等于0、SOCKET_ERROR三种情况分开处理,否则“对端关闭”与“收到空数据”在日志里长得一模一样,排查时会误导人。二是打印网络数据要用printf(“%s”,buf)而不是printf(buf),后者把网络数据当成格式串,数据里一旦出现%就会越界读栈,是典型的格式化字符串漏洞,我们作为网安专业的代码更要注意这一点。
小组总结
本次实验我们把一个TCP连接的两端拆成“监听套接字—连接套接字”和“socket—connect”两组对应关系来编写,主流程一次跑通,时间几乎全部花在边界与显示上:字节序转换、缓冲区边界、recv的三种返回值、控制台的输入输出代码页。由此也体会到TCP只向上交付无结构的字节流、不保证消息边界,本实验这句话恰好落在一条报文之内所以没有暴露粘包问题,换成长文本就必须自己加长度前缀或分隔符。