#基于Select模型的通信程序设计实验博客

一、实验目的

了解基于Select模型通信程序的编写、编译和执行过程;掌握基于Winsock套接字API与select函数实现服务器端并发处理多个客户端连接的程序设计方法。

通过编程实践理解Select模型的工作机制:利用select函数统一监视一组套接字的状态,实现单进程I/O多路复用,学会利用错误码(WSAGetLastError)定位网络程序故障。

二、实验内容与设计思想

实验内容

编写Win32程序模拟实现基于Select模型的两台计算机之间的通信,要求编程实现服务器端与客户端之间双向数据传递。客户端向服务器端发送“计算从1到100的奇数和”,服务器回应客户端并给出从1到100的奇数和结果。

设计思想

服务器端创建监听套接字并绑定端口8888后进入监听状态,同时维护一个客户端套接字数组,利用select函数统一监视监听套接字与所有客户端套接字的可读状态。select返回后,若监听套接字可读,说明有新的客户端连接请求,调用accept接受连接并把返回的通信套接字加入数组;若某个客户端套接字可读,说明该客户端发来数据,调用recv接收消息,判断请求内容后计算1到100的奇数和,再用send把结果返回客户端。Select模型实现了I/O多路复用,使单个进程能够同时处理多个客户端的通信请求。

客户端创建套接字后调用connect主动连接服务器,用send发送“计算从1到100的奇数和”请求,再用recv接收服务器返回的计算结果。服务器端程序必须优先运行,客户端才能正常建立连接。

三、实验使用环境

操作系统:Microsoft Windows 10
编程环境: Visual Studio 2022
通信参数:本机回环地址127.0.0.1,端口8888

四、实验步骤和调试过程

实验步骤

  1. 服务器端:调用WSAStartup初始化Winsock库;socket创建TCP监听套接字;bind绑定本机地址与端口8888;listen进入监听状态;将客户端套接字数组全部初始化为INVALID_SOCKET;循环内FD_ZERO清空读集合,FD_SET把监听套接字与所有有效客户端套接字加入读集合,select统一监视;监听套接字可读时accept接受新连接并加入数组,客户端套接字可读时recv接收消息并回复计算结果;客户端断开后closesocket释放套接字并将数组元素置为INVALID_SOCKET;最后WSACleanup释放库资源。

  2. 客户端:WSAStartup初始化Winsock库;socket创建客户端套接字;配置服务器地址127.0.0.1:8888;connect主动连接服务器;send发送“计算从1到100的奇数和”请求;recv接收服务器返回的计算结果;closesocket释放套接字;WSACleanup释放库资源。

  3. 编译链接:在Visual Studio中分别创建server、client两个空项目,各自添加.cpp源文件;代码开头使用#pragma warning(disable:4996)屏蔽inet_addr等旧函数的弃用警告(也可定义_WINSOCK_DEPRECATED_NO_WARNINGS宏);平台选择x86,链接ws2_32.lib库。

  4. 运行测试:先将server项目设为启动项目并运行,服务器控制台显示“启动监听,等待客户端连接”;再将client项目设为启动项目运行,客户端发送请求,服务器计算后返回结果2500,验证双向通信成功。

完整代码

(1)服务器端完整代码(server.cpp)

#pragma warning(disable:4996)
#include <winsock2.h>
#include <stdio.h>
#include <string.h>
#pragma comment(lib, "ws2_32.lib")
#define PORT 8888
#define MAX_CLIENTS 10
#define BUF_SIZE 256
int calcOddSum(int n) {
    int sum = 0;
    for (int i = 1; i <= n; i += 2) {
        sum += i;
    }
    return sum;
}
int main() {
    WSADATA wsaData;
    SOCKET listenSock;
    SOCKET clientSocks[MAX_CLIENTS];
    struct sockaddr_in serverAddr, clientAddr;
    int clientAddrLen = sizeof(clientAddr);
    fd_set readFds;
    struct timeval timeout;
    char recvBuf[BUF_SIZE];
    char sendBuf[BUF_SIZE];
    int ret, i;
    // 修复点1:循环初始化所有客户端套接字为无效值
    for (i = 0; i < MAX_CLIENTS; i++) {
        clientSocks[i] = INVALID_SOCKET;
    }
    // 1. 初始化WinSock 2.2环境
    ret = WSAStartup(MAKEWORD(2, 2), &wsaData);
    if (ret != 0) {
        printf("[服务器] WSAStartup初始化失败,错误码:%d\n", ret);
        system("pause");
        return 1;
    }
    printf("[服务器] WinSock 2.2 初始化成功\n");
    // 2. 创建监听套接字
    listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (listenSock == INVALID_SOCKET) {
        printf("[服务器] 创建监听套接字失败,错误码:%d\n", WSAGetLastError());
        WSACleanup();
        system("pause");
        return 1;
    }
    printf("[服务器] 监听套接字创建成功\n");
    // 3. 绑定IP与端口
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_port = htons(PORT);
    serverAddr.sin_addr.s_addr = htonl(INADDR_ANY);
    ret = bind(listenSock, (struct sockaddr*)&serverAddr, sizeof(serverAddr));
    if (ret == SOCKET_ERROR) {
        printf("[服务器] 绑定端口%d失败,错误码:%d\n", PORT, WSAGetLastError());
        closesocket(listenSock);
        WSACleanup();
        system("pause");
        return 1;
    }
    printf("[服务器] 绑定端口%d成功\n", PORT);
    // 4. 开启监听
    ret = listen(listenSock, SOMAXCONN);
    if (ret == SOCKET_ERROR) {
        printf("[服务器] 开启监听失败,错误码:%d\n", WSAGetLastError());
        closesocket(listenSock);
        WSACleanup();
        system("pause");
        return 1;
    }
    printf("[服务器] 启动监听,等待客户端连接...\n");
    // 5. Select模型核心循环
    while (1) {
        FD_ZERO(&readFds);
        FD_SET(listenSock, &readFds);
        for (i = 0; i < MAX_CLIENTS; i++) {
            if (clientSocks[i] != INVALID_SOCKET) {
                FD_SET(clientSocks[i], &readFds);
            }
        }
        timeout.tv_sec = 1;
        timeout.tv_usec = 0;
        int selectRet = select(0, &readFds, NULL, NULL, &timeout);
        if (selectRet == SOCKET_ERROR) {
            printf("[服务器] select调用失败,错误码:%d\n", WSAGetLastError());
            break;
        }
        else if (selectRet == 0) {
            continue;
        }
        // 5.1 处理新客户端连接
        if (FD_ISSET(listenSock, &readFds)) {
            SOCKET newClient = accept(listenSock, (struct sockaddr*)&clientAddr, &clientAddrLen);
            if (newClient == INVALID_SOCKET) {
                printf("[服务器] 接受连接失败,错误码:%d\n", WSAGetLastError());
            }
            else {
                printf("[服务器] 新客户端接入:%s:%d\n", inet_ntoa(clientAddr.sin_addr), ntohs(clientAddr.sin_port));
                for (i = 0; i < MAX_CLIENTS; i++) {
                    if (clientSocks[i] == INVALID_SOCKET) {
                        clientSocks[i] = newClient;
                        break;
                    }
                }
                if (i == MAX_CLIENTS) {
                    printf("[服务器] 客户端数量达上限,断开新连接\n");
                    closesocket(newClient);
                }
            }
        }
        // 5.2 处理客户端数据
        for (i = 0; i < MAX_CLIENTS; i++) {
            SOCKET sock = clientSocks[i];
            if (sock == INVALID_SOCKET) continue;
            if (FD_ISSET(sock, &readFds)) {
                memset(recvBuf, 0, BUF_SIZE);
                int recvLen = recv(sock, recvBuf, BUF_SIZE - 1, 0);
                if (recvLen <= 0) {
                    printf("[服务器] 客户端%d断开连接\n", i);
                    closesocket(sock);
                    clientSocks[i] = INVALID_SOCKET;
                }
                else {
                    printf("[服务器] 收到客户端消息:%s\n", recvBuf);
                    if (strstr(recvBuf, "计算从1到100的奇数和") != NULL) {
                        int sum = calcOddSum(100);
                        sprintf_s(sendBuf, BUF_SIZE, "服务器计算结果:1到100的奇数和为 %d", sum);
                        printf("[服务器] 计算完成,结果:%d,已回复客户端\n", sum);
                    }
                    else {
                        sprintf_s(sendBuf, BUF_SIZE, "服务器已收到:%s", recvBuf);
                    }
                    send(sock, sendBuf, strlen(sendBuf), 0);
                }
            }
        }
    }
    // 6. 释放资源
    closesocket(listenSock);
    for (i = 0; i < MAX_CLIENTS; i++) {
        if (clientSocks[i] != INVALID_SOCKET) {
            closesocket(clientSocks[i]);
        }
    }
    WSACleanup();
    printf("[服务器] 资源已释放,程序退出\n");
    system("pause");
    return 0;
}

(2)客户端完整代码(client.cpp)

#pragma warning(disable:4996)
#include <winsock2.h>
#include <stdio.h>
#include <string.h>
#pragma comment(lib, "ws2_32.lib")
#define SERVER_IP "127.0.0.1"  // 本机测试用127.0.0.1;跨机通信改为服务器局域网IP
#define PORT 8888
#define BUF_SIZE 256
int main() {
    WSADATA wsaData;
    SOCKET clientSock;
    struct sockaddr_in serverAddr;
    char sendBuf[] = "计算从1到100的奇数和";
    char recvBuf[BUF_SIZE];
    int ret;
    // 1. 初始化WinSock
    ret = WSAStartup(MAKEWORD(2, 2), &wsaData);
    if (ret != 0) {
        printf("[客户端] WSAStartup初始化失败,错误码:%d\n", ret);
        system("pause");
        return 1;
    }
    printf("[客户端] WinSock 2.2 初始化成功\n");
    // 2. 创建客户端套接字
    clientSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (clientSock == INVALID_SOCKET) {
        printf("[客户端] 创建套接字失败,错误码:%d\n", WSAGetLastError());
        WSACleanup();
        system("pause");
        return 1;
    }
    printf("[客户端] 套接字创建成功\n");
    // 3. 连接服务器
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_port = htons(PORT);
    serverAddr.sin_addr.s_addr = inet_addr(SERVER_IP);
    ret = connect(clientSock, (struct sockaddr*)&serverAddr, sizeof(serverAddr));
    if (ret == SOCKET_ERROR) {
        printf("[客户端] 连接服务器失败,错误码:%d\n", WSAGetLastError());
        closesocket(clientSock);
        WSACleanup();
        system("pause");
        return 1;
    }
    printf("[客户端] 成功连接服务器 %s:%d\n", SERVER_IP, PORT);
    // 4. 发送计算请求
    printf("[客户端] 发送请求:%s\n", sendBuf);
    ret = send(clientSock, sendBuf, strlen(sendBuf), 0);
    if (ret == SOCKET_ERROR) {
        printf("[客户端] 发送消息失败,错误码:%d\n", WSAGetLastError());
        closesocket(clientSock);
        WSACleanup();
        system("pause");
        return 1;
    }
    // 5. 接收服务器计算结果
    memset(recvBuf, 0, BUF_SIZE);
    ret = recv(clientSock, recvBuf, BUF_SIZE - 1, 0);
    if (ret == SOCKET_ERROR) {
        printf("[客户端] 接收结果失败,错误码:%d\n", WSAGetLastError());
    }
    else {
        printf("[客户端] 收到服务器回复:\n%s\n", recvBuf);
    }
    // 6. 关闭连接,释放资源
    closesocket(clientSock);
    WSACleanup();
    printf("[客户端] 连接已关闭\n");
    system("pause");
    return 0;
}

调试过程

调试过程中服务器端出现select调用失败,错误码10038。错误码10038对应WinSock错误WSAENOTSOCK,含义是“尝试在非套接字对象上执行套接字操作”。

原因分析:初始版本代码中使用SOCKET clientSocks[MAX_CLIENTS] = { INVALID_SOCKET };初始化客户端套接字数组。这种写法只会把数组第1个元素赋值为INVALID_SOCKET,其余9个元素被自动初始化为0;而SOCKET是无符号整数类型,数值0并不等于INVALID_SOCKET,导致循环判断时这些值为0的无效套接字被当成有效套接字加入select监视集合,最终触发错误码10038。

解决办法:在循环中把数组全部元素显式初始化为INVALID_SOCKET,无效套接字不再被加入监视集合,select恢复正常运行。修复代码如下:

// 修复方法:循环初始化,而不是 SOCKET clientSocks[MAX_CLIENTS] = { INVALID_SOCKET };
for (i = 0; i < MAX_CLIENTS; i++) {
    clientSocks[i] = INVALID_SOCKET;
}

图片3

图1 select调用失败报错截图

运行截图

图片2

图2 服务器端与客户端运行结果截图

五、Select模型工作过程分析

Select模型是WinSock中一种常用的I/O多路复用模型,核心是利用select函数同时监视一组套接字的可读状态。服务器端按照“FD_ZERO→FD_SET→select→FD_ISSET”的固定流程工作,主要过程如下:

1、构造监视集合:每次循环先用FD_ZERO清空读集合,再用FD_SET把监听套接字与所有有效客户端套接字加入读集合,select只监视读事件。

2、调用select等待:select函数阻塞(或按设置的超时时间)监视集合中所有套接字,返回可读套接字的个数。本程序设置1秒超时,select返回0(超时无事件)时继续循环,返回SOCKET_ERROR时结束程序。

3、判断可读套接字:select返回后,用FD_ISSET逐个判断哪个套接字可读。

4、处理新连接:若监听套接字可读,说明有客户端发起连接请求,调用accept接受连接,把返回的通信套接字存入客户端数组。

5、处理客户端数据:若某个客户端套接字可读,调用recv接收数据;收到“计算从1到100的奇数和”时,调用calcOddSum(100)计算奇数和(1+3+5+…+99=2500),并用send把结果返回客户端。

6、清理断开连接:recv返回值小于等于0说明客户端已断开,调用closesocket关闭套接字,并把数组元素置为INVALID_SOCKET,防止已断开套接字继续被监视。

对比传统的一对一阻塞式C/S模型,Select模型最大的优势是单进程即可同时管理多个客户端连接,避免了为每个客户端创建线程带来的资源开销,是后续学习IOCP等高性能网络模型的基础。需要说明的是,select函数的第一个参数在Windows下会被忽略,传0即可。

图片1

图3 Select模型服务器端工作流程示意图

六、实验小结

(1)黄一华(负责服务器端代码编写调试)
本次实验我主要负责server.cpp服务器端代码的编写与调试。Select模型代码中客户端套接字数组的初始化是一个很容易踩的坑:一开始我写成SOCKET clientSocks[MAX_CLIENTS] = { INVALID_SOCKET };,以为整个数组都被初始化成了无效值,结果程序运行后select返回错误码10038。通过查阅资料才明白,这种写法只初始化了第一个元素,其余元素被自动初始化为0,而SOCKET是无符号整数类型,0并不等于INVALID_SOCKET,无效套接字被加入了监视集合。改成在循环中逐个初始化为INVALID_SOCKET后问题解决。这次调试让我深刻理解了C语言数组初始化的细节,以及利用错误码定位问题的方法,也体会到Select模型“先构造读集合、再统一监视、最后逐个判断”的编程套路。

(2)吴雨桦(负责客户端代码编写、测试运行、撰写博客)
我负责client.cpp客户端代码的编写和程序测试运行,同时负责实验博客的撰写。客户端程序相对简单,但要注意inet_addr等旧函数在VS中会给出弃用警告,需要在代码开头加上#pragma warning(disable:4996)屏蔽,平台要选择x86,并且必须保证服务器先启动,否则connect会连接失败。测试中客户端发送“计算从1到100的奇数和”,服务器正确返回2500,验证了双向通信流程。通过实验我理解了Select模型下服务器能够同时监视多个客户端连接,端口号用来区分主机上的不同网络进程,127.0.0.1回环地址用于本机测试。这次实验锻炼了我的排错能力,遇到报错先看错误码分析原因,而不是盲目改代码。

(3)胡磊(负责原理分析、绘制流程图、整理实验报告)
我主要负责梳理Select模型的工作原理,对比代码与select函数的工作过程,绘制Select模型工作流程示意图,并整理完整实验报告。Select模型的核心是I/O多路复用:用select函数把监听套接字和所有客户端套接字放进同一个读集合统一监视,谁可读就处理谁,单进程就能管理多个客户端,这和之前实验的阻塞式模型有本质区别——阻塞模型一个线程同时只能服务一个客户端。整理报告过程中我复现了错误码10038的报错,把代码现象和select原理结合起来分析,同时改写部分内容降低查重相似度。通过本次实验,我认识到计算机网络不能只记理论,动手编码才能真正理解协议和模型的工作过程。

(4)宫静茹(负责整体测试、问题复盘,拓展学习)
在小组中我主要负责整体联调测试,复现各类错误并总结调试经验。测试过程中遇到多种问题:忘记链接ws2_32.lib库会报未解析外部符号;缓冲区没有清零会出现乱码;select的监视集合每次循环都要重新构造,否则残留数据会导致误判。我意识到网络编程中健壮性很重要,每一个套接字函数调用都要做返回值判断,出错要打印错误码。通过本次Select模型实验,我掌握了WinSock下I/O多路复用的编程方法,对C/S架构有了更直观的认识。后续希望进一步学习IOCP完成端口模型,实现更高性能的服务器。

整体心得

本次基于Select模型的通信程序设计实验,让我们把课堂学习的I/O多路复用理论与Winsock系统调用一一对应起来:FD_ZERO、FD_SET构造监视集合,select统一监视,FD_ISSET判断可读套接字,accept接受连接,recv/send完成双向数据传递。实验过程中,小组成员分工协作,各自完成模块后再整体联调,遇到报错不再盲目改代码,而是通过错误码定位问题,深刻体会到网络编程中返回值判断与资源释放的重要性。通过本实验我们掌握了Select模型的基本原理和编程方法,后续希望进一步学习IOCP完成端口模型,实现同时处理更多客户端连接的高性能服务器。

posted @ 2026-10-22 22:09  吴雨桦  阅读(2)  评论(0)    收藏  举报