C/C++用socket实现简单的TCP文件传输

网络中进程之间如何进行通信

进程通信的首要问题是如何唯一标识一个进程

  • 本地可以通过进程PID来唯一标识一个进程
  • 网络中ip可以唯一标识一台主机;“协议 + 端口”可以唯一标识主机中的进程

因此,通过三元组(ip地址,协议,端口)就可以唯一标识网络中的进程了

使用TCP/IP协议的应用程序通常采用应用程序接口UNIX BSD的套接字(socket)来实现网络进程之间的通信,本文中的程序便是采用socket来实现客户端与服务器之间的通信

socket是什么

socket指一种在两端(一般是服务器-客户端)之间建立连接、传输数据的一种方式或一种约定(接口)
socket起源于Unix,Unix/Linux的特点是把文件作为不分任何记录的字符流进行存取,文件、文件目录和设备具有相同的语法语义和相同的保护机制,即“一切皆文件”,因此可以把socket看成是一种特殊的文件,socket函数则是对这种文件进行的操作(读写I/O、开启、关闭)

socket的基本操作

socket()函数

用于创建一个唯一标识的socket描述字,后续的操作以其为参数,通过它来进行一些读写操作,对应文件的打开操作

#include<WinSock2.h>
int socket(int domain,int type,int protocol);
/*domain:协议族,决定了socket的地址类型
常用的协议族有AF_INET(IPV4)、AFINET6(IPV6)、AF_LOCAL(本地通信)等
*/
/*
type:指定socket类型
常用的socket类型有SOCK_STREAM(面向连接的套接字,基于TCP协议)、SOCK_DGRAM(无连接的套接字,基于UDP协议)
*/
/*
protocol:指定协议
常用的协议有IPPROTO_TCP、IPPROTO_UDP、IPPRPTO_SCTP、IPPRPTO_TIPC
protocol为0时会自动选择type对应的默认协议
*/

socket()创建的socket描述字存在于协议族空间中,但没有一个具体地址,需要使用bind()函数给它赋一个地址

bind()函数

服务器需要通过调用bind()函数来绑定一个地址(如ip地址+端口号)使其能够被客户端连接并向客户端提供服务
客户端不需要指定,系统自动分配一个端口号和自身的ip地址结合

#include<WinSock2.h>
int bind(SOCKET sockfd,const struct sockaddr *addr,socklen_t addrlen);
/*
sockfd:socket描述字
addr:指向要绑定给sockfd的协议地址
addrlen:对应地址的长度
*/

其中需要注意,bind()中的addr需要转换成网络字节序

/*   bind()之前对协议地址的设定   */
struct sockaddr_in addr:
	addr.sin_family = AF_INET;
	addr.sin_port = htons(PORT);// htons():把本地字节序改成网络字节序
	addr.sin_addr.S_un.S_addr = ADDR_ANY;

不同的CPU有不同的字节序类型,分为大端字节序和小端字节序,网络字节序则为大端字节序,如果字节序类型不对应而未进行转换则会出现不可预知的问题,因此为保险起见建议本地字节序通过htons()转换成网络字节序后再赋给socket

listen()、connect()函数

#include<WinSock2.h>
int listen(int sockfd,int backlog);
int connect(SOCKET sockfd,const struct sockaddr *addr,socklen_t addr);
/*
backlog:相应socket可以排队的最大连接个数
*/

socket()创建的socket默认为主动类型的,listen()将socket变为被动类型的,等待客户的连接请求
客户端通过调用connect()来建立与TCP服务器的连接

accept()函数

connect()之后客户端向TCP发送了一个连接请求,TCP服务器监听到该请求后调用accept()接收请求,连接建立完成,之后可以进行网络I/O操作,类似文件的读写I/O操作

#include<WinSock2.h>
int accept(int sockfd,struct sockaddr *addr,socklen_t addrlen);

如果accept()成功,则返回值为一个由内核自动生成的一个全新的描述字,代表返回客户的TCP连接0

注意:accept()中的sockfd参数为服务器的socket描述字,是服务器调用socket()生成的,称为监听socket描述字,在服务器的生命周期中一直存在;accept()返回的socket描述字则是内核自动生成的已连接的描述字,仅仅在该客户端与服务器之间的连接过程中存在,当服务器完成了对该客户的服务之后就被关闭

recv()/send()函数

#include<WinSock2.h>
int recv(SOCKET sockfd,char *buf,int len,int flags);
//buf:指向用于接收传入数据的缓冲区的指针,一般设定大小为1500字节(网络协议规定传输的最大单元)
//len:buf参数指向的缓冲区的长度(以字节为单位)
//flags:一组影响此函数行为的标志,一般置为0

若未发生错误,则recv()函数返回收到的字节数,buf指向的缓冲区将包含接收到的数据,如果连接正常关闭,则返回值为0,否则返回SOCKET_ERROR值,可以通过调用WSAGetLastError来检索特定的错误代码

#include<WinSock2.h>
int send(SOCKET sockfd,char *buf,int len,int flags);
//buf:指向包含要传输数据的缓冲区的指针
//len:buf参数指向的缓冲区的长度(以字节为单位)

若未发生错误,则send()函数将返回发送的总字节数,该字节数可能小于在len参数中请求发送的数量,否则将返回SOCKET_ERROR值,同样可以通过调用WSAGetLastError检索指定的错误代码

close()函数

完成读写操作后,使用close()函数关闭socket描述字,对应文件的关闭操作

#include<unistd.h>
int close(int fd);

注意:close操作只是使相应socket描述字的引用次数减1,只有当引用计数器为0的时候,才会触发TCP客户端向服务器发送终止连接请求

TCP的“三次握手”

TCP连接中的“三次握手”:

  • 第一次握手:客户端向服务端发送SYN J,此时客户端处于SYN_Send状态
  • 第二次握手:服务器向客户端相应一个SYN K,并对J进行确认ACK J = J + 1,此时服务器处于SYN_Revd状态
  • 第三次握手:客户端向服务器发送确认ACK K = K + 1,此时客户端处于established状态

socket中“三次握手”发生的位置

  • 第一次握手:客户端调用connect(),触发连接请求,向服务器发送SYN J包,connect()进入阻塞状态
  • 第二次握手:服务器监听到连接请求,调用accept()接受请求并向客户端发送SYN K、ACK J,accept()进入阻塞状态
  • 第三次握手:客户端收到服务器发送的SYN K、ACK J后connect()返回(return);服务器收到ACK K后accept()返回

“三次握手”的作用

  • 确认双方的接收能力、发送能力是否正常
  • 指定自己的初始化序列号,为后面的可靠传送做准备
  • http协议中“三次握手”的过程中会进行数字证书的验证和加密秘钥的生成

注意:第三次握手,由于客户端已经处于established状态,因此可以携带数据,前两次握手是不能携带数据的

TCP的“四次挥手”

TCP连接中的“四次挥手”:

  • 第一次挥手:客户端发送FIN报文,包含序列号seq N = 初始值 + N(N:已传数据字节数),此时客户端处于FIN_WAIT1状态
  • 第二次挥手:服务端发送确认报文,包含ACK N = N + 1,seq M = 初始值 + M(M:已传数据字节数),此时服务端处于CLOSE_WAIT状态
  • 第三次挥手:服务器向客户端发送FIN报文,包含ACK N = N + 1,seq W = 初始值 + W(W:已传数据字节数),此时服务器处于LAST_ACK状态
  • 第四次挥手:客户端收到FIN报文后发送ACK报文作为应答,ACK W = W + 1,此时客户端处于TIME_WAIT状态,确保服务器收到确认报文后进入CLOSE状态;服务器收到ACK报文后则关闭,处于CLOSE状态
    注意:“四次挥手”过程中可能仍在传输数据,因此,N、M、W的值可能不同

socket中的“四次握手”发生的位置:

  • 第一次挥手:应用进程调用close()主动关闭连接,TCP发送FIN N
  • 第二次挥手:服务器接收到N后执行被动关闭,对N进行确认,该接收也作为文件结束符传递给服务器
  • 第三次挥手:执行被动关闭后,服务端调用close()关闭其socket,TCP向客户端发送FIN W
  • 第四次挥手:客户端接收到FIN报文后对其进行确认报文,确保服务器收到确认报文后进入CLOSE状态

四次挥手的一些注意事项

  • TCP双向通道相互独立,提供了连接的一方在结束它的发送后还能接收来自另一端的数据,因此服务端确认报文和FIN报文中seq的值可能会不同
  • 在socket网络编程中,执行close()会触发内核发送FIN报文,由用户态决定什么时候调用close(),如果服务器有大量数据需要处理,则服务器会等数据全部处理完后再调用close();ACK报文则是由系统内核来决定的,过程比较快,因此ACK报文和FIN报文不能合并发送
  • TIME_WAIT的等待时长是2MSL(Maximum Segment Life Time,报文最大生成时间),即数据报一来一回需要的最大时间,从客户端发送ACK报文开始计时。如果期间内服务器没有收到ACK报文则会重发FIN报文段,FIN报文段在TIME_WAIT期间被接收到,则客户端重置等待时间;若TIME_WAIT等待时间内未收到数据报,则客户端进入CLOSE状态

代码实现

本文中代码采用C++语法进行编写,但未采用面向对象的编程思想,因此代码结构仍然是封装成一个个功能函数,并未封装成对象,后续会自行改进,本文中就不贴出来了

文件结构

fileTransfer
fileTransfer/clien
fileTransfer/server
fileTransfer/tcpSocket

项目结构

在VS中创建解决方案,客户端和服务器为两个不同的项目用以分别运行进行测试
VS项目结构

socket连接建立

/*------------------------------tcpSocket.h------------------------------*/
#ifndef _TCPSOCKET_H_
#define _TCPSOCKET_H
#include<stdbool.h>
#include<iostream>
#include<WinSock2.h>	//头文件
#pragma comment(lib,"ws2_32.lib")	//库文件
#pragma warning(disable:4996)//兼容问题,用于让VS忽略安全检查

#define err(errMsg)	cout<<errMsg<<"failed,code "<<WSAGetLastError()<<" line:"<<__LINE__<<endl;
#define PORT 8401	//0-1024为系统保留


//初始化网络库
bool init_Socket();

//关闭网络库
bool close_Socket();

//服务器:创建服务器socket
SOCKET create_serverSocket();

//客户端:创建客户端socket
SOCKET create_clientSocket(const char *ip);
#endif
/*------------------------------tcpSocket.cpp------------------------------*/
#include<iostream>
#include"tcpSocket.h"
#include<WinSock2.h>

using namespace std;

bool init_Socket()
{
    
    
	//初始化代码
	WORD wVersion = MAKEWORD(2, 2);
	//MAKEWORD:将两个byte型合成一个word型,一个在高八位,一个在低八位
	//MAKEWORD(1,1)只能一次接收一次,不能马上发送,只支持TCP/IP协议,不支持异步
	//MAKEWORD(2,2)可以同时接收和发送,支持多协议,支持异步
	WSADATA wsadata;
	if (0 != WSAStartup(wVersion, &wsadata))	//WSA:widows socket ansyc	windows异步套接字
	{
    
    
		err("WSAStartup");
		return false;
	}
	//return true;
}

bool close_Socket()//反初始化操作
{
    
    
	if (0 != WSACleanup())
	{
    
    
		err("WSACleanup");
	}
	return true;
}

SOCKET create_serverSocket()
{
    
    
//1.创建一个空的socket
	//socket()无错误发生则返回引用新套接口的描述字,否则返回INVALID_SOCKET错误
	SOCKET fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);	
	if (INVALID_SOCKET == fd)
	{
    
    
		err("socket");
		return INVALID_SOCKET;
	}
	//AF_INET:指定地址协议族,INET指IPV4
	//SOCK_STREAM:代表流式套接字
	//IPPROTO_TCP:指定使用TCP/IP中的协议,此处指定使用TCP协议

//2.给socket绑定本地ip地址和端口号
	struct sockaddr_in addr;
		addr.sin_family = AF_INET;
		addr.sin_port = htons(PORT);	//htons():把本地字节序转成网络字节序
		addr.sin_addr.S_un.S_addr = ADDR_ANY;	//绑定本地任意ip
//3.bind绑定端口
		if (SOCKET_ERROR == bind(fd, (struct sockaddr*)&addr, sizeof(addr)))
		{
    
    
			err("bind");
			return INVALID_SOCKET;
		}

//4.开始监听
		listen(fd, 10);	//同时允许10个用户进行访问
	return fd;
}


SOCKET create_clientSocket(const char *ip)
{
    
    
	//1.创建一个空的socket
	SOCKET fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
	if (INVALID_SOCKET == fd)
	{
    
    
		err("socket"); 
		return INVALID_SOCKET;
	}

//2.给socket绑定服务端ip地址和端口号
	struct sockaddr_in addr;
	addr.sin_family = AF_INET;
	addr.sin_port = htons(PORT);	//htons():把本地字节序转成网络字节序
	addr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");	//绑定服务器ip,此处与本机另一进程通信,故ip为本机地址127.0.0.1
	if (INVALID_SOCKET == connect(fd, (struct sockaddr*)&addr, sizeof(addr)))
	{
    
    
		err("connect");
		return INVALID_SOCKET;
	}

	return fd;
}

文件传输

/*------------------------------fileOperation.h------------------------------*/
#ifndef __FILEOP_H_
#define __FILEOP_H_

#include"tcpSocket.h"
#define _CRT_SECURE_NO_WARNINGS//兼容问题可能会报错,使用该行让VS忽略安全检测

/***服务端***/
//发送文件
bool sendFile(SOCKET s, const char* fileName);

/***客户端***/
//接受文件
bool recvFile(SOCKET s, const char* fileName);

#endif
/*------------------------------fileOperation.cpp------------------------------*/
#include"fileOperation.h"
#include<iostream>


using namespace std;

long bufSize = 10*1024;	//缓冲区大小
char* buffer;	//缓冲区保存文件数据
//long recvSize = 10000000;
//char* recvBuf;

bool sendFile(SOCKET s, const char* fileName)
{
    
    
	FILE* read = fopen(fileName, "rb");
	if (!read)
	{
    
    
		perror("file open failed:\n");//输出描述性错误信息
		return false;
	}

//获取文件大小
	fseek(read, 0, SEEK_END);	//将文件位置指针移动到最后
	bufSize = ftell(read);	//ftell(FILE *stream):返回给定流stream的当前文件位置,获取当前位置相对文件首的位移,位移值等于文件所含字节数
	fseek(read, 0, SEEK_SET);	//将文件位置指针移动到开头
	cout<<"filesize:"<<bufSize<< endl;

//把文件读到内存中来
	buffer= new char[bufSize];
	cout << sizeof(buffer) << endl;
	if (!buffer)
	{
    
    
		return false;
	}

	int nCount;
	int ret = 0;
	while ((nCount = fread(buffer, 1, bufSize, read)) > 0)	//循环读取文件进行传送
	{
    
    
		ret += send(s, buffer, nCount, 0);
		if (ret == SOCKET_ERROR)
		{
    
    
			err("sendFile");
			return false;
		}
	}
	shutdown(s, SD_SEND);
	recv(s, buffer, bufSize, 0);
	fclose(read);

	cout << "send file success!"<<" Byte:"<<ret << endl;
	system("pause");
	return true;
}

bool recvFile(SOCKET s, const char* fileName)
{
    
    

	if (buffer == NULL)
	{
    
    
		buffer = new char[bufSize];
		if (!buffer)
			return false;
	}
//	创建空文件
	FILE* write = fopen(fileName, "wb");
	if (!write)
	{
    
    
		perror("file write failed:\n");
		return false;
	}

	int ret = 0;
	int nCount;
	while ((nCount = recv(s, buffer, bufSize, 0)) > 0)	//循环接收文件并保存
	{
    
    
		ret += fwrite(buffer,nCount, 1, write);
	}
	if (ret == 0)
	{
    
    
		cout << "server offline" << endl;
	}
	else if(ret < 0)
	{
    
    
		err("recv");
		return false;
	}
	cout << "receive file success!" << endl;

	fclose(write);
	cout << "save file success! Filename:"<<fileName << endl;
	system("pause");
	return true;
}

服务器主程序


#include"../tcpSocket/tcpSocket.h"
#include"../tcpSocket/fileOperation.h"
#include<iostream>

#include<string>

using namespace std;
char* sendBuf;

int main()
{
    
    
	init_Socket();

	SOCKET serfd = create_serverSocket();//创建服务器socket(该socket仅用于监听)
	cout << "server create success,wait client connect..." << endl;

	//等待客户端连接
	sockaddr_in caddr;
	caddr.sin_family = AF_INET;
	int caddrlen = sizeof(sockaddr_in);

	SOCKET clifd = accept(serfd, (sockaddr*)&caddr, &caddrlen);	//该socket用于与客户端进行连接
	if (clifd == INVALID_SOCKET)
	{
    
    
		err("accept");
	}
	cout << "connect success" << endl;
	//可以与客户端进行通信
	char fileName[100] = {
    
     0 };
	cout << "please input the full path of the file: "<<endl;
	cin >> fileName;
		sendFile(clifd,fileName);


	closesocket(clifd);
	closesocket(serfd);
	close_Socket();
	
	return 0;
}

客户端主程序

#include"../tcpSocket/tcpSocket.h"
#include"../tcpSocket/fileOperation.h"
#include<iostream>


using namespace std;

int main()
{
    
    
	init_Socket();
	SOCKET fd = create_clientSocket("127.0.0.1");
	cout << "connect success!" << endl;

//接收服务器传输的数据
	char fileName[100] = {
    
     0 };
	cout << "input filename to save:" << endl;
	cin>>fileName;
	recvFile(fd, fileName);

	closesocket(fd);
	close_Socket();
	
	return 0;
}

一些注意事项

防止重复包含和重复定义

  • 头文件被重复包含
    在较大型项目中,#include文件时一个头文件可能被多次包含,例如文件A中include了1.h,文件B中include了1.h和2.h,而文件C中include了文件A和文件B,则头文件1.h被重复包含
    解决方法:
  1. 使用 ifndef-define-endif
#ifndef xxx.h
#define xxx.h
...
#endif

  1. windows平台下可以使用如下代码:
#pragma once
  • 变量被重复定义
    工程中每个cpp文件都是独立解释的,每个cpp文件生成独立的标识符,在编译器链接时会将工程中所有的符号整合在一起,由于文件中有重名变量,会出现重复定义的错误
    解决方法:

在cpp文件中声明变量,然后建一个头文件在所有变量的声明前加上extern(此处不要对变量进行初始化),然后在其他需要使用全局变量的cpp文件中包含该头文件

文件传输时超出缓冲区大小

单次传输文件时如果只使用一次send/recv函数,一旦文件大小超出缓冲区大小,则传输的文件会不全出现错误
解决方法:

发送和接收文件的时候将TCP传输的字节流分成多个TCP报文传输

while ((nCount = fread(buffer, 1, bufSize, read)) > 0)	//循环读取文件进行传送
	{
    
    
		ret += send(s, buffer, nCount, 0);
		if (ret == SOCKET_ERROR)
		{
    
    
			err("sendFile");
			return false;
		}
	}
while ((nCount = recv(s, buffer, bufSize, 0)) > 0)	//循环接收文件并保存
	{
    
    
		ret += fwrite(buffer,nCount, 1, write);
	}

运行及结果

首先运行server.cpp,等待客户端程序连接
server.cpp_WaitForConnect
再运行客户端程序,服务器与客户端建立连接,此时在服务器进程选择server.cpp所处文件夹下的1.jpg作为发送文件
server/1.jpg
server.cpp_ChooseSendFile
客户端程序client.cpp运行并连接成功后询问接收到的文件命名成什么,此处我选择命名成2.jpg
client.cpp_ChooseFileName
命名完文件后回车运行,文件保存成功后返回文件名
在这里插入图片描述
查看client文件夹中确实存在2.jpg且完整接收保存
client/2.jpg
本文中的代码也保存到了github上,代码链接

结尾

我对于socket网络编程这一块还只能算稍有了解,在学习过程中借鉴了许多文章,许多问题也都是网上查询找到的解决方案,实际的相关原理可能只是一知半解,撰写本文也主要是为了对于网络编程这一块有更深的理解,对项目内容有更全面的认识,故有一些理解不清晰或者理解存在问题的还望各位读者大佬不吝赐教

猜你喜欢

转载自blog.csdn.net/qq_44184756/article/details/129542348