一、C语言中的Socket网络编程

1. 什么是Socket?

如果要用一句话来概括的话,我觉的是”Socket是一种抽象的概念,用来描述网络连接的重点“。

Socket(套接字)是一种网络通信的端点,是用于不同主机间进行网络通信的编程接口。在Unix系统中,Socket起源于“一切皆文件”的哲学,网络通信可以像文件操作一样,通过“打开-读写-关闭”的模式进行。Socket分为流套接字和数据报套接字,前者基于TCP协议,提供可靠的、面向连接的数据传输,适用于网页浏览和文件传输等应用;后者基于UDP协议,提供无连接的、尽最大努力的数据传输,适用于视频通话和在线游戏等实时应用。端口号用于区分同一主机上的不同应用程序,每个Socket在创建时需绑定一个特定端口号,以确保数据能正确传输到对应的应用。Socket编程包括创建套接字、绑定地址、监听连接、接受连接、建立连接、数据传输和关闭套接字等基本操作,使得应用程序能够在网络中进行高效通信。

2. 客户/服务器模式

在TCP/IP网络应用中,通信通常采用客户/服务器(Client/Server, C/S)模式。服务器等待并处理客户端请求,客户端发起服务请求并接收响应。

服务器端流程

  1. 打开通信通道并在特定端口等待客户端请求。
  2. 接收到客户端请求后,处理请求并发送应答。
  3. 处理并发请求时,创建新进程处理每个客户端请求。
  4. 服务完成后,关闭通信通道并终止新进程。

客户端流程

  1. 打开通信通道并连接到服务器的特定端口。
  2. 向服务器发送请求并接收应答。
  3. 请求结束后关闭通信通道并终止。

3. C语言中基本套接字库函数

  1. 创建套接字socket()

    创建一个新的套接字。

  2. 绑定地址bind()

    将套接字绑定到本地地址和端口。

  3. 监听连接listen()

    使套接字进入监听状态,等待客户端连接。

  4. 接受连接accept()

    接受客户端连接请求,返回新的套接字用于通信。

  5. 建立连接connect()

    客户端使用该函数连接到服务器。

  6. 数据传输send()recv()

    发送数据到已连接的套接字或从已连接的套接字接收数据。

  7. 关闭套接字close()

    关闭套接字,释放资源。

有关每个函数的更多说明讲解,将在后续随代码附上。

二、实验概述

1. 实验内容

实现两个程序:服务器端和客户端

  • 客户端发送文件传输请求,服务器端将文件数据发送给客户端
  • 两个程序均在命令行方式下运行
  • 客户端在命令行指定服务器的IP地址和文件名
  • 为防止重名,客户端将收到的文件改名后保存在当前目录下
  • 要求至少能传输一个文本文件和一个图片文件

在服务器端,应输出:

  • 客户端的IP地址和端口号
  • 发送的文件数据总字节数或者差错报告
  • 必要的差错报告,如文件不存在

在客户端,应输出:

  • 新文件名
  • 传输总字节数
  • 差错报告

2. 实验环境

操作系统:Windows 11

开发工具:VScode

编程语言:C

编译器: GCC

3. 软件设计

3.1 数据结构

结构体

在这个程序中,我们没有自定的结构体,而是调用了很多库函数中的结构体,这里就取两个代表性的为例。

  • sockaddr_in:用于存储IP地址和端口号的结构体。

    struct sockaddr_in {
        short sin_family;       // 地址族 (程序中的AF_INET表示使用IPV4地址)
        unsigned short sin_port;// 端口号
        struct in_addr sin_addr;// IP地址
        char sin_zero[8];       // 填充,使结构与sockaddr结构大小相同
    };
    
  • WSADATA:用于存储Windows Sockets初始化信息的结构体。

    typedef struct WSAData {
        WORD wVersion;      // Winsock版本
        WORD wHighVersion;  // 支持的最高版本
        char szDescription[WSADESCRIPTION_LEN + 1]; // 描述字符串
        char szSystemStatus[WSASYS_STATUS_LEN + 1]; // 系统状态字符串
        unsigned short iMaxSockets;    // 最大套接字数
        unsigned short iMaxUdpDg;      // 最大UDP数据报大小
        char *lpVendorInfo;            // 供应商信息
    } WSADATA;
    

宏定义常量

BUF_SIZE:用于定义缓冲区的大小,我们将客户端跟服务端的缓冲区都设为了1024字节。

#define BUF_SIZE 1024 // 缓冲区大小

全局变量

两个程序都没有使用到全局变量,所有变量都在函数内部定义(包括main函数)。

局部变量

客户端程序 client.c

  • WSADATA wsaData;用于存储Windows Sockets初始化信息。通过调用 WSAStartup 函数初始化Winsock库,这是程序的第一步,以确保后续的Socket操作可以正常进行。
  • SOCKET clientSocket;用于创建客户端套接字。通过调用 socket 函数建立套接字。这个套接字用于连接服务器并进行文件传输。
  • struct sockaddr_in serverAddr;用于存储服务器的IP地址和端口号。通过设置地址族(AF_INET)、端口号和IP地址来初始化,然后用于连接到服务器。具体连接操作通过 connect 函数实现。
  • char buffer[BUF_SIZE];数据缓冲区,用于暂存从服务器接收到的数据。数据通过 recv 函数接收并存储在此缓冲区中,然后使用 fwrite 将其写入文件。
  • int bytesReceived;用于存储每次从服务器接收到的字节数。在接收文件数据时,通过 recv 函数更新此变量,用于记录接收进度。
  • size_t totalBytesReceived;用于存储从服务器接收到的总字节数。记录文件传输的总进度和完成情况。
  • char newFilename[BUF_SIZE];用于存储接收到文件的新文件名,以避免文件名冲突。构造格式为 received_<filename>

服务端程序 server.c

  • WSADATA wsaData;用于存储Windows Sockets初始化信息。通过调用 WSAStartup 函数初始化Winsock库,确保后续的Socket操作可以正常进行。
  • SOCKET serverSocket;用于创建服务器套接字。通过调用 socket 函数建立套接字,后续用于监听客户端连接。通过 bind 函数将套接字绑定到特定端口,并通过 listen 函数进入监听状态。
  • SOCKET clientSocket;用于与连接到服务器的客户端进行数据通信。通过调用 accept 函数获得客户端的连接套接字,然后与客户端进行数据传输。
  • struct sockaddr_in serverAddr;用于存储服务器的IP地址和端口号。通过设置地址族(AF_INET)、端口号和监听的所有可用IP地址来初始化,然后用于绑定到服务器套接字。
  • struct sockaddr_in clientAddr;用于存储连接到服务器的客户端的IP地址和端口号。通过 accept 函数获得,并用于标识和记录客户端连接。
  • int clientAddrSize;用于存储客户端地址结构的大小。传递给 accept 函数以获得客户端地址信息。
  • char buffer[BUF_SIZE];数据缓冲区,用于暂存从文件中读取的数据。通过 fopen 打开文件,读取数据到 buffer 中,再通过 send 函数将其发送给客户端。
  • int bytesRead;用于存储每次从文件中读取的字节数。在发送文件数据时,通过 fread 函数更新此变量,用于记录读取进度。
  • int totalBytesSent;用于存储发送给客户端的总字节数。记录文件传输的总进度和完成情况。

3.2 算法流程

以下是使用Visio绘制的两个程序的执行流程图:

image-20240611223221286

三、代码设计

1. 客户端(client.c)

导入库

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h>
#include <ws2tcpip.h>
#include <windows.h>
#pragma comment(lib, "ws2_32.lib") // 链接Ws2_32.lib库
#define BUF_SIZE 1024 // 缓冲区大小

receiveFile函数

功能:接收服务器发送的文件并保存到本地。

void receiveFile(SOCKET serverSocket, const char* newFilename) {
    FILE *file = fopen(newFilename, "wb"); // 打开文件(以二进制写模式)
    if (file == NULL) {
        printf("Error creating file: %s\n", newFilename); 
// 如果文件创建失败,输出错误信息
        return;
    }
    char buffer[BUF_SIZE]; // 数据缓冲区
    int bytesReceived = 0;
    size_t totalBytesReceived = 0;
    // 循环接收数据直到没有数据可接收
    while ((bytesReceived = recv(serverSocket, buffer, BUF_SIZE, 0)) > 0) {
        fwrite(buffer, 1, bytesReceived, file); // 将接收到的数据写入文件
        totalBytesReceived += bytesReceived; // 记录接收到的总字节数
    }
    fclose(file); // 关闭文件
    if (totalBytesReceived == 0) {
        printf("No such file.\n"); // 如果没有接收到任何数据,提示文件不存在
        remove(newFilename); // 删除创建的空文件
} else {
    		printf("New filename: %s\n", newFilename);
        printf("File received successfully!\n New file name: %s\n Total bytes received: %zu\n", newFilename, totalBytesReceived); 
        // 提示文件接收成功并显示接收的总字节数
    }}

main函数

功能:初始化客户端,连接服务器并请求文件。

  1. 指定服务器的IP地址和文件名

    int main(int argc, char* argv[]) {
        if (argc != 3) {
            printf("Usage: %s <Server IP> <File Name>\n", argv[0]);
            return 1;
        }
    
        const char* serverIp = argv[1];
        const char* filename = argv[2]
    
  2. 初始化 Windows Sockets 环境

        WSADATA wsaData;
        WSAStartup(MAKEWORD(2, 2), &wsaData); // 初始化Winsock
    SOCKET clientSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); 
    // 创建客户端Socket
        if (clientSocket == INVALID_SOCKET) {
            printf("Socket creation failed: %d\n", WSAGetLastError()); 
    // 创建Socket失败
            WSACleanup();
            return 1;
        }
    
  3. 配置服务器地址信息,连接到制定IP,端口1145。如果连接失败,打印错误信息并退出程序

    	struct sockaddr_in serverAddr;
        memset(&serverAddr, 0, sizeof(serverAddr)); // 清空结构体
        serverAddr.sin_family = AF_INET; // 设置地址族为IPv4
        serverAddr.sin_addr.s_addr = inet_addr(serverIp); // 设置IP地址
        serverAddr.sin_port = htons(1145); // 设置端口号
        // 连接到服务器
        if (connect(clientSocket, (struct sockaddr*)&serverAddr, 			 sizeof(serverAddr)) < 0) {
            printf("Connection failed: %d\n", WSAGetLastError()); // 连接失败
            closesocket(clientSocket);
            WSACleanup();
            return 1;
        }
    

    memset 是 C 语言标准库中的一个函数,用于将一块内存中的所有字节设置为指定的值。它通常用于初始化内存区域,比如设置数组或结构体的所有字节为零。我们先将serverAddr结构体这片地址内的内容置零清空以防止干扰。

  4. 将用户输入的文件名发送给服务器,构造一个新的文件名,并调用 receiveFile函数接收文件并保存到本地

        send(clientSocket, filename, strlen(filename), 0); // 发送文件名到服务器
        char newFilename[BUF_SIZE];
    snprintf(newFilename, sizeof(newFilename), "received_%s", filename); 
    // 构造接收文件的文件名
        receiveFile(clientSocket, newFilename); // 接收文件
    

    printfsprintfsnprintf 都是用于格式化输出的 C 标准库函数,但它们在使用场景和功能上有一些区别。printf:用于将格式化的输出写入标准输出(通常是屏幕),适用于需要在控制台显示输出的情况。sprintf:用于将格式化的输出写入字符串缓冲区,但不会检查缓冲区大小,存在缓冲区溢出风险。snprintf:用于将格式化的输出写入字符串缓冲区,并限制写入的最大字符数,以防止缓冲区溢出,是 sprintf 的安全替代方案。

    在程序的代码中使用了snprintf,他接收了四个参数:

    • newFilename 是缓冲区,用于存放生成的新文件名。
    • sizeof(newFilename)指定要写入的最大字符数,包括终止的空字符 \0
    • 后面两个就是构造新的名字,在原本的文件名前面拼接一个received_
  5. 关闭套接字并清理

        closesocket(clientSocket); // 关闭客户端Socket
        WSACleanup(); // 清理Winsock
        return 0;
    }
    

2. 服务器端(server.c)

导入库

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h>
#include <ws2tcpip.h>
#include <windows.h>
#pragma comment(lib, "ws2_32.lib") // 链接Ws2_32.lib库
#define BUF_SIZE 1024 // 缓冲区大小

sendFile函数

功能:从指定文件中读取数据并发送到客户端。

void sendFile(SOCKET clientSocket, const char* filename) {
    FILE *file = fopen(filename, "rb"); // 打开文件(以二进制模式)
    if (file == NULL) {
        printf("File not found: %s\n", filename); 
// 如果文件不存在,输出错误信息
return;
}
char buffer[BUF_SIZE]; // 数据缓冲区
    int bytesRead = 0;
    int totalBytesSent = 0;
    while ((bytesRead = fread(buffer, 1, BUF_SIZE, file)) > 0) {
        // 读取文件内容并发送给客户端
        int bytesSent = send(clientSocket, buffer, bytesRead, 0);
        if (bytesSent == SOCKET_ERROR) {
            printf("Send failed: %d\n", WSAGetLastError()); // 发送失败
            fclose(file); // 关闭文件
            return;
        }
        totalBytesSent += bytesSent;
    }
    fclose(file); // 关闭文件
    printf("Total bytes sent: %d\n", totalBytesSent); // 输出发送的总字节数
}

main函数

功能:初始化服务器,监听客户端连接并处理文件传输请求。

  1. 初始化 Windows Sockets 环境

    int main() {
        WSADATA wsaData;
        if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { // 初始化Winsock
            printf("WSAStartup failed: %d\n", WSAGetLastError());
            return 1;
        }
    
    SOCKET serverSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); 
    // 创建服务器Socket
        if (serverSocket == INVALID_SOCKET) {
            printf("Socket creation failed: %d\n", WSAGetLastError()); 
    // 创建Socket失败
            WSACleanup();
            return 1;
        }
    
  2. 将套接字绑定到本地地址。如果绑定失败,打印错误信息并退出程序

        struct sockaddr_in serverAddr;
        memset(&serverAddr, 0, sizeof(serverAddr)); // 清空结构体
        serverAddr.sin_family = AF_INET; // 设置地址族为IPv4
        serverAddr.sin_addr.s_addr = INADDR_ANY; // 监听所有可用IP地址
        serverAddr.sin_port = htons(1145); // 设置端口号
        if (bind(serverSocket, (struct sockaddr*)&serverAddr, sizeof(serverAddr)) 		== SOCKET_ERROR) {
            printf("Bind failed: %d\n", WSAGetLastError()); // 绑定Socket失败
            closesocket(serverSocket);
            WSACleanup();
            return 1;
        }
    
  3. 设置套接字为监听状态,最多允许 1 个客户端连接等待。如果监听失败,打印错误信息并出程序

        if (listen(serverSocket, SOMAXCONN) == SOCKET_ERROR) {
            printf("Listen failed: %d\n", WSAGetLastError()); // 监听Socket失败
            closesocket(serverSocket);
            WSACleanup();
            return 1;
        }
        printf("Server is listening on port 1145\n"); // 服务器正在监听端口1145
    
  4. 获取客户端的IP地址和端口号。从客户端接收文件名。如果接收成功,打印请求的文件名并调用 sendFile 函数发送文件。否则,打印错误信息

        struct sockaddr_in clientAddr;
        int clientAddrSize = sizeof(clientAddr);
        SOCKET clientSocket = accept(serverSocket, (struct sockaddr*)&clientAddr, 								  &clientAddrSize); // 接受客户端连接
        if (clientSocket == INVALID_SOCKET) {
            printf("Accept connection failed: %d\n", WSAGetLastError()); 
    // 接受连接失败
            closesocket(serverSocket);
            WSACleanup();
            return 1;
        }
        char* clientIp = inet_ntoa(clientAddr.sin_addr);
        printf("Client connected: IP = %s, Port = %d\n", clientIp, 			 ntohs(clientAddr.sin_port)); // 打印客户端的IP地址和端口号
        sendFile(clientSocket, filename); // 发送文件给客户端
    
  5. 关闭套接字并清理

        closesocket(clientSocket); // 关闭客户端Socket
        closesocket(serverSocket); // 关闭服务器Socket
        WSACleanup(); // 清理Winsock
        printf("Press any key to exit...\n");
        getchar(); // 等待用户输入以防止窗口立即关闭
        return 0;
    }
    

四、效果演示

1. 文本文件传输

传输文件为server文件夹下的test.txt文件。

img

  1. 在命令行中执行server.exe,打开服务器端。

    img

  2. 在命令行中执行client.exe,打开客户端,并指定服务器IP地址和要接收的文件。

    img

  3. 文件传输成功后服务器端输出:

    img

  4. 查看client文件夹,发现存在received_test.txt,文本文件传输成功。

    img

2. 图片文件传输

传输文件为server文件夹下的test.jpg文件

img

  1. 在命令行中执行server.exe,打开服务器端。

    img

  2. 在命令行中执行client.exe,打开客户端,并指定服务器IP地址和要接收的文件。

    img

  3. 文件传输成功后服务器端输出:

    img

  4. 查看client文件夹,发现存在received_test.jpg,图片文件传输成功。

    img

3. 差错报告

若输入文件不存在

客户端差错报告:

img

服务器端差错报告:

img

其他差错报告在代码设计处已经给出。

五、问题与解决方案

1. 编译报错问题

问题描述:

VScode直接编译报错

img

解决方案:

通过查阅资料得知对于 gcc 编译器(特别是在 MinGW 上),需要使用 -lwsock32 参数来链接 Ws2_32.lib 库。这是因为 gcc 不自动链接该库,而 -l 选项用于指定链接器应链接的库文件。

image-20240611231209878

在命令行输入上述指令后编译成功。

2. IP地址输出不一致问题

问题描述:

客户端指定的IP地址与服务器端输出的IP地址不一致

客户端指定IP:127.0.0.2

img

服务器端输出IP:127.0.0.1

img

解决方案:

通过查阅资料得知在大多数操作系统中,127.0.0.1 被认为是环回地址(loopback address),也就是本地回环地址(localhost),而任何 127.0.0.x 地址都属于回环地址范围。即使客户端指定了 127.0.0.2 作为目标 IP 地址,连接建立时,服务器端看到的 IP 地址可能会被视为 127.0.0.1,因为所有 127.0.0.x 地址在网络层面上都等同于 127.0.0.1。

六、实验收获和总结

通过本次实验,我学到了以下几点:

  1. TCP协议的实现:通过编写客户端和服务器端程序,我深入理解了TCP协议的工作原理,尤其是连接建立、数据传输和连接关闭的过程。
  2. Socket编程:我掌握了在C语言中使用Socket编程的基本方法,包括Socket的创建、绑定、监听、连接和数据传输等操作。
  3. 错误处理:在编写代码的过程中,我遇到了多种编译和运行时错误。通过查阅资料和调试代码,我提高了问题解决和调试能力。例如,编译报错需要链接特定的库文件,以及IP地址显示不一致的问题。
  4. 文件传输:我实现了文本文件和图片文件的传输,学会了如何处理二进制文件和文本文件的读取和写入操作,增强了对文件操作的理解。

本次实验让我在实践中加深了对TCP协议和Socket编程的理解。尽管在实现过程中遇到了一些困难,但通过不断地查阅资料和调试,最终成功实现了文件传输功能。同时,我意识到在网络编程中,处理各种可能的错误和异常情况是非常重要的,这对编写健壮的网络应用程序至关重要。总体来说,本次实验不仅提高了我的编程能力,还增强了我解决实际问题的能力,为以后深入学习网络编程打下了坚实的基础。