基于TCP的文件传输Socket应用
一、C语言中的Socket网络编程
1. 什么是Socket?
如果要用一句话来概括的话,我觉的是”Socket是一种抽象的概念,用来描述网络连接的重点“。
Socket(套接字)是一种网络通信的端点,是用于不同主机间进行网络通信的编程接口。在Unix系统中,Socket起源于“一切皆文件”的哲学,网络通信可以像文件操作一样,通过“打开-读写-关闭”的模式进行。Socket分为流套接字和数据报套接字,前者基于TCP协议,提供可靠的、面向连接的数据传输,适用于网页浏览和文件传输等应用;后者基于UDP协议,提供无连接的、尽最大努力的数据传输,适用于视频通话和在线游戏等实时应用。端口号用于区分同一主机上的不同应用程序,每个Socket在创建时需绑定一个特定端口号,以确保数据能正确传输到对应的应用。Socket编程包括创建套接字、绑定地址、监听连接、接受连接、建立连接、数据传输和关闭套接字等基本操作,使得应用程序能够在网络中进行高效通信。
2. 客户/服务器模式
在TCP/IP网络应用中,通信通常采用客户/服务器(Client/Server, C/S)模式。服务器等待并处理客户端请求,客户端发起服务请求并接收响应。
服务器端流程:
- 打开通信通道并在特定端口等待客户端请求。
- 接收到客户端请求后,处理请求并发送应答。
- 处理并发请求时,创建新进程处理每个客户端请求。
- 服务完成后,关闭通信通道并终止新进程。
客户端流程:
- 打开通信通道并连接到服务器的特定端口。
- 向服务器发送请求并接收应答。
- 请求结束后关闭通信通道并终止。
3. C语言中基本套接字库函数
-
创建套接字:
socket()
创建一个新的套接字。
-
绑定地址:
bind()
将套接字绑定到本地地址和端口。
-
监听连接:
listen()
使套接字进入监听状态,等待客户端连接。
-
接受连接:
accept()
接受客户端连接请求,返回新的套接字用于通信。
-
建立连接:
connect()
客户端使用该函数连接到服务器。
-
数据传输:
send()
和recv()
发送数据到已连接的套接字或从已连接的套接字接收数据。
-
关闭套接字:
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绘制的两个程序的执行流程图:
三、代码设计
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函数
功能:初始化客户端,连接服务器并请求文件。
-
指定服务器的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]
-
初始化 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; }
-
配置服务器地址信息,连接到制定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结构体这片地址内的内容置零清空以防止干扰。 -
将用户输入的文件名发送给服务器,构造一个新的文件名,并调用 receiveFile函数接收文件并保存到本地
send(clientSocket, filename, strlen(filename), 0); // 发送文件名到服务器 char newFilename[BUF_SIZE]; snprintf(newFilename, sizeof(newFilename), "received_%s", filename); // 构造接收文件的文件名 receiveFile(clientSocket, newFilename); // 接收文件
printf
、sprintf
和snprintf
都是用于格式化输出的 C 标准库函数,但它们在使用场景和功能上有一些区别。printf
:用于将格式化的输出写入标准输出(通常是屏幕),适用于需要在控制台显示输出的情况。sprintf
:用于将格式化的输出写入字符串缓冲区,但不会检查缓冲区大小,存在缓冲区溢出风险。snprintf
:用于将格式化的输出写入字符串缓冲区,并限制写入的最大字符数,以防止缓冲区溢出,是sprintf
的安全替代方案。在程序的代码中使用了
snprintf
,他接收了四个参数:newFilename
是缓冲区,用于存放生成的新文件名。sizeof(newFilename)
指定要写入的最大字符数,包括终止的空字符\0
。- 后面两个就是构造新的名字,在原本的文件名前面拼接一个
received_
。
-
关闭套接字并清理
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函数
功能:初始化服务器,监听客户端连接并处理文件传输请求。
-
初始化 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; }
-
将套接字绑定到本地地址。如果绑定失败,打印错误信息并退出程序
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; }
-
设置套接字为监听状态,最多允许 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
-
获取客户端的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); // 发送文件给客户端
-
关闭套接字并清理
closesocket(clientSocket); // 关闭客户端Socket closesocket(serverSocket); // 关闭服务器Socket WSACleanup(); // 清理Winsock printf("Press any key to exit...\n"); getchar(); // 等待用户输入以防止窗口立即关闭 return 0; }
四、效果演示
1. 文本文件传输
传输文件为server文件夹下的test.txt文件。
-
在命令行中执行server.exe,打开服务器端。
-
在命令行中执行client.exe,打开客户端,并指定服务器IP地址和要接收的文件。
-
文件传输成功后服务器端输出:
-
查看client文件夹,发现存在received_test.txt,文本文件传输成功。
2. 图片文件传输
传输文件为server文件夹下的test.jpg文件
-
在命令行中执行server.exe,打开服务器端。
-
在命令行中执行client.exe,打开客户端,并指定服务器IP地址和要接收的文件。
-
文件传输成功后服务器端输出:
-
查看client文件夹,发现存在received_test.jpg,图片文件传输成功。
3. 差错报告
若输入文件不存在
客户端差错报告:
服务器端差错报告:
其他差错报告在代码设计处已经给出。
五、问题与解决方案
1. 编译报错问题
问题描述:
VScode直接编译报错
解决方案:
通过查阅资料得知对于 gcc
编译器(特别是在 MinGW 上),需要使用 -lwsock32
参数来链接 Ws2_32.lib
库。这是因为 gcc
不自动链接该库,而 -l
选项用于指定链接器应链接的库文件。
在命令行输入上述指令后编译成功。
2. IP地址输出不一致问题
问题描述:
客户端指定的IP地址与服务器端输出的IP地址不一致
客户端指定IP:127.0.0.2
服务器端输出IP:127.0.0.1
解决方案:
通过查阅资料得知在大多数操作系统中,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。
六、实验收获和总结
通过本次实验,我学到了以下几点:
- TCP协议的实现:通过编写客户端和服务器端程序,我深入理解了TCP协议的工作原理,尤其是连接建立、数据传输和连接关闭的过程。
- Socket编程:我掌握了在C语言中使用Socket编程的基本方法,包括Socket的创建、绑定、监听、连接和数据传输等操作。
- 错误处理:在编写代码的过程中,我遇到了多种编译和运行时错误。通过查阅资料和调试代码,我提高了问题解决和调试能力。例如,编译报错需要链接特定的库文件,以及IP地址显示不一致的问题。
- 文件传输:我实现了文本文件和图片文件的传输,学会了如何处理二进制文件和文本文件的读取和写入操作,增强了对文件操作的理解。
本次实验让我在实践中加深了对TCP协议和Socket编程的理解。尽管在实现过程中遇到了一些困难,但通过不断地查阅资料和调试,最终成功实现了文件传输功能。同时,我意识到在网络编程中,处理各种可能的错误和异常情况是非常重要的,这对编写健壮的网络应用程序至关重要。总体来说,本次实验不仅提高了我的编程能力,还增强了我解决实际问题的能力,为以后深入学习网络编程打下了坚实的基础。
- 感谢你赐予我前进的力量