【MFC编程】使用CAsyncSocket实现TFTP客户端

  • 写在前面
  • 效果展示
  • VS2022创建MFC对话框程序
  • 安装
  • 创建MFC程序
  • CAsyncSocket简介
  • 异步非阻塞模式
  • 常用函数简介
  • Create 创建套接字
  • Send 发送报文
  • SendTo 向特定目标发送报文
  • Receive 接收报文
  • ReceiveFrom 接收特定目标的数据包
  • OnSend
  • OnReceive
  • 用法介绍
  • TFTP客户端实现
  • 需求分析和设计
  • 要求
  • TFTP的数据包类型
  • 设计思路
  • 自定义Client类继承CAsyncSocket
  • 实现
  • sendAck 发送Ack响应报文
  • sendGet 发送请求报文
  • sendPut 发送写文件请求
  • sendData 发送数据包
  • resend 重传报文
  • 重写OnReceive
  • 计时器Timer类
  • 图形化界面实现
  • 编辑界面
  • 添加点击事件
  • 各组件的使用
  • 如何实现超时重传
  • 自定义消息处理函数
  • 发送消息
  • 处理消息
  • 其他问题
  • 小结



【华科网安计算机网络实验一】

写在前面

该实验要求编写一个TFTP客户端程序,并能够与服务器进行通信。

如果你和我一样,想编写一个图形化界面程序,并且对使用传统的socket编程感到麻烦。那么我们就来一起学习以下如何使用VS2022的MFC对话框程序利用CAsyncSocket编写TFTP客户端吧!

本文首先将介绍如何在VS2022中创建MFC程序,其次简单介绍CAsyncSocket的使用方法,最后是TFTP客户端的实现过程。

放心,实验指导手册中已经明确介绍了CAsyncSocket的使用,所以虽然是Windows封装好的类,但是是完全可以在实验中使用的哦

注:笔者代码菜鸟,如有不足请多多指正,友好交流哦

效果展示

CODESYS UDP通讯启动 codesys socket tcp_mfc


CODESYS UDP通讯启动 codesys socket tcp_CODESYS UDP通讯启动_02


是不是比命令行看起来舒服很多呢?

接下来我们从头开始,先创建一个MFC对话框程序吧!

VS2022创建MFC对话框程序

安装

VS2022默认不安装MFC编程的相关内容,需要我们手动进行安装。我也是在网上找了好久,最后才找到靠谱的安装方法。VS2019应该也适用。

  • 找到Visual Studio Installer

    点击修改
  • 安装相应内容
    在C++桌面开发中勾选MFC,如画线部分所示。

    接着在左侧滑到下面勾选扩展开发

    最后安装就可以啦!

创建MFC程序

安装完成后,我们就可以在新建项目的模板中找到MFC了。

CODESYS UDP通讯启动 codesys socket tcp_c++_03


命名后点击创建

CODESYS UDP通讯启动 codesys socket tcp_mfc_04


在这里选择基于对话框

CODESYS UDP通讯启动 codesys socket tcp_c++_05


高级功能中勾选Windows套接字

CODESYS UDP通讯启动 codesys socket tcp_c++_06


点击完成就可以了。

CAsyncSocket简介

CAsynSocket 类封装了 Windows Socket 的 API,因此我们可以用面向对象的方法调用 Sokcet。CAsynSocket 类提供了异步通信编程模式:在默认状态下由该类创建的套接字是非阻塞的套接字,在这个非阻塞套接字上进行的包括接受数据和建立连接等在内所有操作也都是非阻塞的。对于异步通信编
程(非阻塞式 Socket),该类将网络事件加入到 Windows 的消息循环机制当中,使得用户能像响应普通的键盘、鼠标、窗口重画等事件一样来响应网络事件,如收到连接请求、有数据到来等。

可以看一下这篇文章,讲得很详细。
CAsyncSocket和CSocket注解

异步非阻塞模式

什么是异步非阻塞模式呢?与之相对的是同步阻塞模式。
比如现在有100位同学要进行400米测试。如果作为老师的你每次只让一名同学测试;该同学跑完一圈后计时,再让下一位同学开始。这就是同步阻塞模式。
如果你每隔10秒让一位同学起跑,直到所有同学出发完毕;每有一个同学回到终点就记录成
绩,直到所有同学都跑完。这就是异步非阻塞模式。

常用函数简介

CAsynSocket 类在MSDN中有非常详细的文档说明。推荐大家去阅读。这里只简单介绍本实验中用到的几个函数,如果觉得有不懂的地方,可以去看官方文档,介绍的非常详细。
MSDN-CAsyncSocket类

Create 创建套接字

BOOL Create(
	UINT nSocketPort = 0,
	int nSocketType = SOCK_STREAM,
	long lEvent = FD_READ | FD_WRITE | FD_OOB | FD_ACCEPT | FD_CONNECT | FD_CLOSE,
	LPCTSTR lpszSocketAddress = NULL);

参数:

  • nSocketPort
    该套接字创建在哪个端口上
  • nSocketType
    套接字类型。SOCK_DGRAM代表UDP,SOCK_STREAM代表TCP。
  • lEvent
    位掩码,指定应用程序感兴趣的网络事件的组合。
    FD_READ:想要接收准备读取的通知。
    FD_WRITE:想要接收准备写入的通知。
    FD_OOB:想要接收带外数据的到达通知。
    FD_ACCEPT:想要接收传入连接的通知。
    FD_CONNECT:想要接收已完成连接的通知。
    FD_CLOSE:想要接收套接字关闭的通知。
  • lpszSockAddress
    一个指向字符串的指针,该字符串为套接字要连接的网络地址(以点分隔的数字,如“128.56.22.8”)。 传递此参数的 NULL 字符串表示 CAsyncSocket 实例应侦听所有网络接口上的客户端活动。

Send 发送报文

该函数用于向已经建立连接的套接字发送报文。在这个实验中,我们使用UDP来实现,因此会使用接下来介绍的SendTo。

virtual int Send(
	const void* lpBuf,
	int nBufLen,
	int nFlags = 0);
  • lpBuf
    一个包含要传输的数据的缓冲区。
  • nBufLen
    lpBuf 中数据的长度(以字节为单位)。
  • nFlags
    指定进行调用的方式。 此函数的语义取决于套接字选项和 nFlags 参数。 后者是通过将以下任何值与 C++ 按位或运算符 (|) 组合使用来构造的:
    MSG_DONTROUTE:指定数据不应受到路由的约束。 Windows 套接字供应商可以选择忽略此标志。
    MSG_OOB:发送带外数据(仅限 SOCK_STREAM)。
  • 返回值
    如果未发生错误,SendTo 会返回发送的字符总数。 (请注意,这可能小于 nBufLen 指示的数字。)否则会返回值 SOCKET_ERROR。可以通过调用 GetLastError 来检索特定错误代码。

SendTo 向特定目标发送报文

该函数有两个重载

int SendTo(
	const void* lpBuf,
	int nBufLen,
	UINT nHostPort,
	LPCTSTR lpszHostAddress = NULL,
	int nFlags = 0);

int SendTo(
	const void* lpBuf,
	int nBufLen,
	const SOCKADDR* lpSockAddr,
	int nSockAddrLen,
	int nFlags = 0);

使用第一个重载会方便一些。第二个的SOCKADDR结构体太麻烦了。这里的参数比较容易理解,就不再介绍了。

  • 返回值
    如果未发生错误,SendTo 会返回发送的字符总数。 (请注意,这可能小于 nBufLen 指示的数字。)否则会返回值 SOCKET_ERROR。可以通过调用 GetLastError 来检索特定错误代码。

Receive 接收报文

同send一样,receive用于接收已经建立连接的套接字的报文。我们主要使用receivefrom。

virtual int Receive(
	void* lpBuf,
	int nBufLen,
	int nFlags = 0);
  • lpBuf
    传入数据的缓冲区。
  • nBufLen
    lpBuf 的长度(以字节为单位)。
  • nFlags
    指定进行调用的方式。 此函数的语义取决于套接字选项和 nFlags 参数。 后者是通过将以下任何值与 C++ 按位或运算符 (|) 组合使用来构造的:
    MSG_PEEK:速览传入数据。 数据会复制到缓冲区中,但不会从输入队列中删除。
    MSG_OOB:处理带外数据。
  • 返回值
    如果未发生错误,Receive 会返回收到的字节数。 如果连接已关闭,则会返回 0。 否则会返回值 SOCKET_ERROR。可以通过调用 GetLastError 来检索特定错误代码。

ReceiveFrom 接收特定目标的数据包

将源地址存储在 SOCKADDR 结构或 rSocketAddress 中。

int ReceiveFrom(
    void* lpBuf,
    int nBufLen,
    CString& rSocketAddress,
    UINT& rSocketPort,
    int nFlags = 0);

int ReceiveFrom(
    void* lpBuf,
    int nBufLen,
    SOCKADDR* lpSockAddr,
    int* lpSockAddrLen,
    int nFlags = 0);
  • lpBuf
    传入数据的缓冲区。
  • nBufLen
    lpBuf 的长度(以字节为单位)。
  • rSocketAddress
    对接收以点分隔的数字 IP 地址的 CString 对象的引用。
  • rSocketPort
    对存储端口的 UINT 的引用。
  • lpSockAddr
    一个指向 SOCKADDR 结构的指针,该结构保存返回时获得的源地址。
  • lpSockAddrLen
    一个指针,指向 lpSockAddr 中的源地址的长度(以字节为单位)。

OnSend

virtual void OnSend(int nErrorCode);

该函数由系统调用Send后自动调用。可以将其作用理解为Send函数的善后工作。我们不会在本实验中用到它。

OnReceive

在有数据到来时,会自动调用该函数。我们可以在该函数中调用receive

virtual void OnReceive(int nErrorCode);

用法介绍

首先,我们需要从CAsyncSocket类派生出属于我们自己的类。与socket类似,我们在使用之前需要先创建套接字,而这只需要调用create函数就可以了。由于是UDP协议,因此不需要进行connect操作。接下来就是报文的收发了。

由于CAsyncSocket为异步非阻塞模式。因此你不知道应该在何时调用receive接收报文。因此我们把这一任务交给OnReceive。我们将在自己的类中重写虚函数OnReceive。我们可以将OnReceive理解为一个消息相应函数。当有可接收的报文到达时,会自动调用该函数。我们并不需要主动地调用receive函数,而是应该将receive写在OnReceive中。在OnReceive函数中,我们首先调用receive接收报文,然后可以编写自己的逻辑去处理收到的报文,并在最后回调OnReceive。比如在这个实验中,我们会判断接收到的报文是TFTP的何种类型,并进行相应的处理。

相反地,我们可以在任何时候主动地调用send函数。而OnSend函数,只在send之后被调用,负责帮我们处理一些善后工作。

TFTP客户端实现

需求分析和设计

要求

完成一个TFTP协议客户端程序,实现以下要求:
(1) 严格按照TFTP协议与标准TFTP服务器通信;
(2) 能够实现两种不同的传输模式netascii和octet;
(3) 能够将文件上传到TFTP服务器;
(4) 能够从TFTP服务器下载指定文件;
(5) 能够向用户展现文件操作的结果:文件传输成功/传输失败;
(6) 针对传输失败的文件,能够提示失败的具体原因;
(7) 能够显示文件上传与下载的吞吐量;
(8) 能够记录日志,对于用户操作、传输成功,传输失败,超时重传等行为记录日志;
(9) 人机交互友好(图形界面/命令行界面均可);
(10) 额外功能的实现,将视具体情况予以一定加分。

TFTP的数据包类型

TFTP 共定义了五种类型的包,包的类型由数据包前两个字节确定,我们称之为 Opcode(操作码)字段。这五种类型的数据包分别是:

  • 读文件请求包:Read request,简写为 RRQ,对应 Opcode 字段值为 1
  • 写文件请求包:Write requst,简写为 WRQ,对应 Opcode 字段值为 2
  • 文件数据包:Data,简写为 DATA,对应 Opcode 字段值为 3
  • 回应包:Acknowledgement,简写为 ACK,对应 Opcode 字段值为 4
  • 错误信息包:Error,简写为 ERROR,对应 Opcode 字段值为 5

设计思路

  • 正常收发
    在了解TFTP的工作原理和CAsyncSocket之后,我们会发现要做的事很简单。由客户端首先向服务器发起请求,也就是根据我们要做的操作(上传或下载)向服务器发送第一个数据包。服务器收到请求后,会做出相应回答。我们只需在OnReceive中接收该报文,并做出适当应答。
    例如,如果是RRQ下载文件请求,服务器在接收到请求后,会向我们发送响应。有可能是Error包或者是正常的数据包。如果是数据包,我们就判断其编号是否为我们想要收到的编号。如果是,则发送该编号的Ack报文;否则,发送之前的Ack。
  • 丢包和延时处理
    对于可能出现的丢包或延时现象,我们将额外编写一个Timer类,用于在发出报文后判断是否在超时范围内接收到相应。Timer类的实现将使用到多线程和MFC的消息处理机制,我们接下来再详细介绍。
  • GUI
    图形化界面的设计,我们只需要用到MFC提供的现成组件就可以完成。弹出文件对话框让用户选择文件;发送按钮;消息的实时显示框,以便于我们跟踪和调试。

自定义Client类继承CAsyncSocket

class Client :
    public CAsyncSocket
{
private:
	enum operation_code { Zero, Get, Put, Data, Ack, Error };
	
	// 服务器和客户端的参数信息
	wchar_t ServerIP[20];
	LPTSTR clientIP;
	UINT clientPort;
	UINT ServerDefaultPort;
	UINT ServerDialogPort;
	
	// 记录开始结束时间和各个操作的时间
	SYSTEMTIME time;
	SYSTEMTIME begin;
	SYSTEMTIME end;
	int fileSize;
	
	std::ofstream log;
	std::ofstream fout;		/*写入文件*/
	std::ifstream fin;		/*读文件*/
	struct LastPacket		/*保存上一个包用于重发*/
	{
		char content[517];
		int len;
	}last_packet;
	
	int current_block;		// 记录当前包的序号
	bool isEnd;				// 是否完成传输

	void calcSpeed();
	virtual void OnReceive(int nErrorCode);
	virtual void OnSend(int nErrorCode);
	void printLog();
	LPWSTR ConvertCharToLPWSTR(const char* srcString);

public:
	Client();
	~Client();
	bool initSocket(wchar_t*);
	void sendAck(int block_num);
	void sendGet(char*);
	void sendPut(char*, char*);
	bool sendData(int block_num, int mode);
	void resend();
	void shutdown();
	int which_mode;
	CListBox* list;
	CEdit* speed_display;
	Timer timer;
	HWND hWnd;
	//Request request;
	

};

实现

sendAck 发送Ack响应报文

根据传入的参数block_num,发送Ack。同时保存该发送的包用于重发。在发送报文后,启动计时器。

void Client::sendAck(int block_num)
{	
	// 记录日志
	GetSystemTime(&time);
	char temp[200] = { '\0' };
	sprintf_s(temp, "[%04d-%02d-%02d %02d:%02d:%02d.%03d]    Send Ack: [block number:%d]\n",
		time.wYear, time.wMonth, time.wDay, 
		time.wHour, time.wMinute, time.wSecond, time.wMilliseconds, block_num);
	
	LPWSTR record = ConvertCharToLPWSTR(temp);
	list->AddString(record);
	delete record;

	// 构造报文
	char content[4] = { 0 };
	content[1] = Ack;
	content[3] = block_num & 0x00ff;
	block_num = block_num >> 8;
	content[2] = block_num & 0x00ff;
	// 保存该包,用于重发
	memcpy_s(last_packet.content, 517, content, 4);
	last_packet.len = 4;

	// 发送报文
	SendTo(content, 4, this->ServerDialogPort, this->ServerIP);
	log.write(temp, strlen(temp));
	timer.startTimer(hWnd);
}
sendGet 发送请求报文

在该函数中,首先打开文件流,以便于接收到数据后写入文件。随后构造RRQ数据包并发送,mode: 1为netascii,2为octet。记录日志,并启动计时器。

void Client::sendGet(char* fileName)
{
	fout.open(fileName,ios_base::binary);
	
	// 记录日志
	GetSystemTime(&begin);
	GetSystemTime(&time);
	char temp[200] = { '\0' };
	sprintf_s(temp, "[%04d-%02d-%02d %02d:%02d:%02d.%03d]    Send Get: [FileName:%s] [mode:%d]\n",
		time.wYear, time.wMonth, time.wDay,
		time.wHour, time.wMinute, time.wSecond, time.wMilliseconds, fileName, which_mode);
	log.write(temp, strlen(temp));
	LPWSTR record = ConvertCharToLPWSTR(temp);
	list->AddString(record);
	delete record;
	
	// send RRQ
	char context[100] = { '\0' };
	context[1] = Get;
	int len = 2;
	
	strcpy_s(context + 2, 100-len, fileName);
	len = len + strlen(fileName) + 1;
	if (which_mode == 1)
	{
		strcpy_s(context + len, 100 - len, "netascii\0");
		len = len + strlen("netascii\0") + 1;
	}
	else
	{
		strcpy_s(context + len, 100 - len, "octet\0");
		len = len + strlen("octet\0") + 1;
	}
	// 保存该包,用于重发
	memcpy_s(last_packet.content, 517, context, len);
	last_packet.len = len;
	
	SendTo(context, len, 69, this->ServerIP, 0);
	timer.startTimer(hWnd);
	return;
}
sendPut 发送写文件请求

与sendGet函数类似,都是打开文件流、记录日志、构造数据包并发送、保存该包用于重发、打开计时器这些步骤。

void Client::sendPut(char* filePath, char* fileName)
{
	fin.open(filePath,ios_base::binary);
	// 记录日志
	GetSystemTime(&begin);
	GetSystemTime(&time);
	char temp[200] = { '\0' };
	sprintf_s(temp, "[%04d-%02d-%02d %02d:%02d:%02d.%03d]    Send Put: [FileName:%s] [mode:%d]\n",
		time.wYear, time.wMonth, time.wDay,
		time.wHour, time.wMinute, time.wSecond, time.wMilliseconds, fileName, which_mode);
	log.write(temp, strlen(temp));
	LPWSTR record = ConvertCharToLPWSTR(temp);
	list->AddString(record);
	delete record;
	// send WRQ
	char context[100] = { '\0' };
	context[1] = Put;
	int len = 2;
	strcpy_s(context + 2, 100 - len, fileName);
	len = len + strlen(fileName) + 1;
	if (which_mode == 1)
	{
		strcpy_s(context + len, 100 - len, "netascii\0");
		len = len + strlen("netascii\0") + 1;
	}
	else
	{
		strcpy_s(context + len, 100 - len, "octet\0");
		len = len + strlen("octet\0") + 1;
	}
	// 保存该包,用于重发
	memcpy_s(last_packet.content, 517, context, len);
	last_packet.len = len;
	SendTo(context, len, 69, this->ServerIP, 0);
	timer.startTimer(hWnd);
}
sendData 发送数据包
  • block_num 记录数据包的编号
  • fileSize 记录累计发送的数据大小,用于计算发送速率
  • resend 重发标志,为1则重发上一个数据包
  • 返回值:true发送了最后一个文件数据包,fasle未发送完毕

基本流程为:记录日志、构造数据包并发送、开启计时器。

当不需要重发时,使用fstream读取文件。fin.gcount()会返回读取的数据大小。如果从指定文件中成功读取512bit数据,返回true,标志着已经发送完毕;否则返回false,标志未发送完毕。

bool Client::sendData(int block_num, int resend)
{
	GetSystemTime(&time);
	char temp[200] = { '\0' };
	
	if (resend == 1)
	{
		sprintf_s(temp, "[%04d-%02d-%02d %02d:%02d:%02d.%03d]    Resend Data: [block number:%d]\n",
			time.wYear, time.wMonth, time.wDay,
			time.wHour, time.wMinute, time.wSecond, time.wMilliseconds, block_num);
		log.write(temp, strlen(temp));
		LPWSTR record = ConvertCharToLPWSTR(temp);
		list->AddString(record);
		delete record;
		//
		fileSize += last_packet.len;
		SendTo(last_packet.content, last_packet.len, this->ServerDialogPort, this->ServerIP);
		timer.startTimer(hWnd);
		if (last_packet.len < 512)
		{
			return true;
		}
		return false;
	}
	else
	{
		sprintf_s(temp, "[%04d-%02d-%02d %02d:%02d:%02d.%03d]    Send Data: [block number:%d]\n",
			time.wYear, time.wMonth, time.wDay,
			time.wHour, time.wMinute, time.wSecond, time.wMilliseconds, block_num);
		log.write(temp, strlen(temp));
		LPWSTR record = ConvertCharToLPWSTR(temp);
		list->AddString(record);
		delete record;
		//
		char content[517] = { '\0' };
		content[0] = 0;
		content[1] = Data;
		content[3] = block_num & 0x00ff;
		block_num = block_num >> 8;
		content[2] = block_num & 0x00ff;
		// 读文件
		fin.read(content + 4, 512);
		int data_len = fin.gcount();
		//int data_len = strlen(content + 4);
		fileSize += data_len;
		// 保存该包,用于重发
		memcpy_s(last_packet.content, 517, content, data_len+4);
		last_packet.len = data_len + 4;
		
		SendTo(content, 4 + data_len, this->ServerDialogPort, this->ServerIP);
		timer.startTimer(hWnd);
		// 最后一组包
		if (data_len < 512)
		{
			return true;
		}
		return false;
	}
}
resend 重传报文

此函数用于当计时器判断超时后,会调用此函数重传上一个报文。仍然需要记录日志,并在发送后打开计时器。

结构体LastPacket中保存着上一个报文的内容和长度。

需要注意的是,如果是WRQ或RRQ报文需要重传,则发送的目标端口号应该是服务器的默认工作端口号,即69。其他类型的报文则向服务器的服务端口发送。这里需要额外进行判断。

void Client::resend()
{
	GetSystemTime(&time);
	char temp[200] = { '\0' };
	sprintf_s(temp, "[%04d-%02d-%02d %02d:%02d:%02d.%03d]    Resend Packet!\n",
		time.wYear, time.wMonth, time.wDay,
		time.wHour, time.wMinute, time.wSecond, time.wMilliseconds);
	log.write(temp, strlen(temp));
	LPWSTR record = ConvertCharToLPWSTR(temp);
	list->AddString(record);
	delete record;
	//
	if (last_packet.content[1] == Get || last_packet.content[1] == Put){
		SendTo(last_packet.content, last_packet.len, this->ServerDefaultPort, this->ServerIP);
	}
	else { 
		SendTo(last_packet.content, last_packet.len, this->ServerDialogPort, this->ServerIP); 
	}
	timer.startTimer(hWnd);
}
重写OnReceive

在该函数中,我们将处理接收到的报文,并根据报文类型,调用上面提到过的函数。接收到报文后要停止计时器,并获取报文首部的操作码类型。

  • Data:数据报文
    判断序号是否正确,并发送ack;
    累加文件大小;
    如果收到的报文小于512B,则结束。在结束时获取时间,与开始时间和文件大小一起计算传输速率
  • Ack: 接收到响应报文。
    判断序号是否正确,如果正确则发送下一个报文;错误则重发
    发送Data报文时,如果发送的报文小于512B,则将isEnd标志位置1
    当接收到正确报文时,如果isEnd为1,说明结束,停止发送;否则,继续发送。
  • Error: 错误报文
    停止发送并打印错误信息。
void Client::OnReceive(int nErrorCode)
{
	unsigned char content[516] = { '\0' };
	CString ip = L"192.168.43.13";
	UINT port = 0;
	
	int size = this->ReceiveFrom(content, 516, ip, this->ServerDialogPort);
	timer.stopTimer();
	
	int opcode = content[1];
	switch (opcode)
	{
	// 从服务器Get文件到本地
	case Data: 
	{
		int recv_block = content[2];
		recv_block = recv_block << 8;
		recv_block += content[3];
		// 正常接收
		if (recv_block == current_block + 1)
		{
			current_block++;
			fout.write((char*)content + 4, size - 4);
			//fout << content + 4;
			fileSize += size - 4;
			sendAck(current_block);
			// complete
			if (size - 4 < 512)
			{
				fout.close();
				GetSystemTime(&end);
				timer.stopTimer();
				calcSpeed();
				current_block = 0;
				fileSize = 0;
			}
		}
		// 只要接收到的数据号不是期望号码,则重传current block
		else 
		{
			sendAck(current_block);
		}
		break;
	}
	// 从本地Put文件到服务器
	case Ack: 
	{
		int recv_block = content[2];
		recv_block = recv_block << 8;
		recv_block += content[3];
		// 正常接收
		if (recv_block == current_block)
		{
			current_block++;
			// 未结束则继续发送
			if (!isEnd)
			{
				// 当最后一个小于512B的包被发送后,isEnd = true
				isEnd = sendData(current_block, 0);
			}
			// 结束则关闭,并恢复原状态
			else
			{
				fin.close();
				GetSystemTime(&end);
				calcSpeed();
				current_block = 0;
				isEnd = false;
				fileSize = 0;
			}
		}
		else
		{
			isEnd = sendData(current_block, 1);
		}
		break;
	}
	// 处理错误
	case Error:
	{
		int errCode = content[2];
		errCode = errCode << 8;
		errCode += content[3];
		int data_len = strlen((char*)content + 4);
		// stop
		if (fin.is_open()) fin.close();
		if (fout.is_open()) fout.close();
		current_block = 0;
		fileSize = 0;
		isEnd = false;

		switch (errCode){
		case 0: {
			speed_display->SetWindowTextW(L"未定义 Not defined, see error message");
			break;
		}
		// 这里省略余下的case……
		}
		break;
	}
	default:
	{
		break;
	}
	}

	CAsyncSocket::OnReceive(nErrorCode);

计时器Timer类

这里利用MFC的消息处理机制和多线程,实现一个简易的计时器。

Timer类设有两个标志endTimer和endAlready。成员函数startTimer,stopTimer和count。

在开启计时器后,即调用startTimer后,创建另一个线程执行count函数。子线程在循环时间内持续判断结束标志是否为1。重复判断的实现就是在一个循环中每隔一定时间sleep一下,再进行判断。

如果结束标志为1则跳出循环,正常返回。如果结束标志始终为0,则在执行完该循环,也就是经过超时时间后,函数向主线程发送一个消息,提示主线程该报文已经超时。主线程接收到消息后,调用消息处理函数,在消息处理函数中调用resend()重发报文。

当手动关闭计时器,也就是调用stopTimer后,会将标志位endTimer置为1,并等待子线程结束。设置endAlready的原因是为了防止在当前子线程仍在sleep时,主线程又开启了计时器,这样会导致另一个新的子线程被创建从而将endTimer重置为0,导致原子线程无法正常关闭。

Timer.h

#pragma once
#include <thread>
#include <Windows.h>

#define WM_MY_TIMEOUT WM_USER+100

class Timer
{
private:
	bool endTimer;
	bool endAlready;

	int timeout;
	int per_sleep;
	void count(HWND hWnd);		// 计时函数
	void test();
public:
	Timer();
	~Timer();
	void startTimer(HWND hWnd);	// 开启计时器
	void stopTimer();			// 关闭计时器
};

Timer.cpp

#include "pch.h"
#include "Timer.h"

Timer::Timer()
{
	endTimer = false;
	endAlready = false;
	timeout = 750;
	per_sleep = 10;
}

Timer::~Timer()
{

}

void Timer::count(HWND hWnd)
{
	int n = timeout / per_sleep;
	for (int i = 0; i < n; i++)
	{
		Sleep(per_sleep);
		if (endTimer == true)
		{
			break;
		}
	}
	if (endTimer == false)
	{
		::PostMessage(hWnd, WM_MY_TIMEOUT, NULL, NULL);
	}
	endAlready = true;
}

void Timer::startTimer(HWND hWnd)
{
	endTimer = false;
	endAlready = false;
	std::thread th(&Timer::count,this,hWnd);
	//std::thread th(&Timer::test, this);
	th.detach();
	
}

void Timer::stopTimer()
{
	endTimer = true;
	while (!endAlready)
	{
		Sleep(10);
	}
}

那么startTimer和stopTimer我们应该如何调用,又该何时调用resend呢?我们将在自定义消息处理函数一节中讲到。

图形化界面实现

编辑界面

在视图->其他窗口中可以找到 资源视图

CODESYS UDP通讯启动 codesys socket tcp_mfc_07


在资源视图中打开IDD_MFCAPPLICATION2_DIALOG

CODESYS UDP通讯启动 codesys socket tcp_CODESYS UDP通讯启动_08


可以在工具箱中拖拽组件到我们的窗口中,并对他们进行布局。找不到工具箱可以直接在搜索中搜索。

CODESYS UDP通讯启动 codesys socket tcp_CODESYS UDP通讯启动_09

添加点击事件

在我们拖拽好组件后,就可以为它们添加响应事件了。

右键点击组件,选择添加事件处理程序

CODESYS UDP通讯启动 codesys socket tcp_mfc_10


注意这里的类要选择xxxDlg的类!!!

CODESYS UDP通讯启动 codesys socket tcp_c++_11


我们就可以在xxxDlg.cpp中找到VS自动为我们生成的函数。

以下载按钮为例,函数内部代码如下:

void CMFCApplication2Dlg::OnBnClickedGetButton()
{
	// TODO: 在此添加控件通知处理程序代码
	// 获取文件名
	CString filePath;
	GetDlgItem(IDC_EDIT_DOWNLOAD)->GetWindowTextW(filePath);
	if (filePath.IsEmpty())
	{
		MessageBoxA(NULL, "未选择文件", "提示", MB_OK);
		return;
	}
	// 将文件名转为单字符char类型
	int len = WideCharToMultiByte(CP_ACP, 0, filePath, -1, NULL, 0, NULL, NULL);
	char* ptxtTemp = new char[len + 1];
	WideCharToMultiByte(CP_ACP, 0, filePath, -1, ptxtTemp, len, NULL, NULL);
	ptxtTemp[len] = 0;
	int i;
	for (i = len; i >= 0; i--)
	{
		if (ptxtTemp[i] == '\\') break;
	}
	int fileName_len = len - i;
	char* fileName = new char[fileName_len];
	strcpy_s(fileName, fileName_len, ptxtTemp + i + 1);
	fileName[fileName_len - 1] = 0;
	// 发送Get请求
	client.sendGet(fileName);
	status.SetWindowTextW(L"正在下载");
	delete[] ptxtTemp;
	delete[] fileName;

}

各组件的使用

  • 每个组件都是封装好的一个类,每个类都提供了各种接口供大家调用,它们的成员函数在MSDN中都有非常详细的说明。这里就不详细介绍了。
  • 在编辑界面中,我们右键点击组件,还会发现一个“添加变量”的选项,我们可以在里面添加一个该组件的变量。可以选择变量类型为该组件的类类型,这样我们就得到了一个该类的对象。我们就可以在代码中调用该对象的成员函数了。添加的变量可以在xxxDlg.h中找到。
  • CFileDialog类可以实现文件对话框
  • 可以使用GetDlgItem()函数获得一个指向该组件的指针,括号中的参数为编辑界面中该组件的ID。因此可以通过如 GetDlgItem(IDC_EDIT_DOWNLOAD)->SetWindowTextW(filePath); 这种形式直接调用该类的方法,而不必去“添加变量”。

如何实现超时重传

自定义消息处理函数

我们上面提到的事件处理函数,本质上就是消息处理函数,只不过它们使用的是MFC已经定义好的一些消息,如鼠标单击、内容被修改等等。当收到这些消息后,就会自动调用事件处理函数。

我们Timer类超时重传的实现,也是类似的原理。只不过我们需要自己定义一个消息。在Timer类的头文件中,我们声明了一个自定义消息。+100的原因是为了防止与系统消息重复。

#define WM_MY_TIMEOUT WM_USER+100

发送消息

::PostMessage(hWnd, WM_MY_TIMEOUT, NULL, NULL);

我们借助系统函数::PostMessage来实现。

第一个参数为接收消息的窗口句柄,也就是我们的对话框窗口。对话框窗口,也就是MFCApplicationDlg类,有一个成员变量 m_hWnd ,记录着该窗口的句柄。我们在xxxDlg.cpp中将 m_hWnd 作为参数传入我们的client类中就可以了。
第二个参数就是我们自己定义的消息。

处理消息

在xxxDlg.h头文件中,public下添加如下函数声明

afx_msg LRESULT OnTimeOut(WPARAM wParam, LPARAM lParam);

在cpp文件中编写该函数定义即可

LRESULT CMFCApplication2Dlg::OnTimeOut(WPARAM wParam, LPARAM lParam)
{
	client.resend();
	return 0;
}

(这一块我也不太懂,都是在网上查的,也许有更好的写法 QAQ)

其他问题

  • netascii和oct模式。
    这两个模式的区别可能只在于文件流的打开方式是否使用二进制了。但反正用二进制模式打开肯定不会有问题。只要对数据大小的计算正确、读写正确,是可以传输任何类型的文件的。
  • 宽字符与单字符的转换。MFC中的函数涉及到的字符串类型都是宽字符的。在使用时需要进行转换。可以使用MultiByteToWideChar和WideCharToMultiByte来转换。
  • BOOL CMFCApplication2Dlg::OnInitDialog() 中进行初始化操作。如创建套接字、设置初始值等。
  • 关于超时重传。
    在服务器绝对可靠的前提下,我们是可以不需要在客户端实现超时检测的。因为无论是上传文件还是下载文件,当服务器检测到超时后,都会重发上一个包,我们只需要根据收到的这个包进行相应回答即可。也就是说,只需要服务器和客户端任意一方实现超时重传机制就可以保证顺利传输,尽管效率可能下降。(我的代码在没有加入超时重传之前确实是可以成功传输的,而且也没慢多少)
  • 小bug
    在开启丢包模拟后,在写文件时,如果最后一个服务器返回的Ack丢失,服务器会直接关闭该端口,停止服务。而客户端会一直等待服务器返回的Ack,所以会一直超时重传最后一个Data包。为解决这个问题,我加入了一个强行停止按钮。
  • 类中成员函数创建线程。
    需要注意传入类名+函数名,指向该类对象的指针。
std::thread th(&Timer::count,this,hWnd);

小结

截止到这里,我们就实现了一个简单的TFTP客户端程序。

通过这次实验,我们掌握了如何编写简单的MFC对话框程序,了解了TFTP的工作原理,学会了CAsyncSocket的简单使用(虽然感觉用CSocket也行,我忘了当时为啥要用CAsyncSocket了)

欢迎大家指正错误,并提出意见。如果觉得这篇文章帮助到了你的话,就请点个赞吧!

(完整代码整理好后会放在评论区)