其他分享
首页 > 其他分享> > 以太网点对点协议之pppoe协议讲解

以太网点对点协议之pppoe协议讲解

作者:互联网

PPPoE是以太网点对点协议的首字母缩写(Point to Point Protocol over Ethernet)。PPPoE是从另一个称为PPP的旧协议派生的网络协议,即点对点协议。PPPoE的创建是为了管理如何通过以太网(有线网络)传输数据。这使我们可以使用以太网在多个客户端之间共享单个服务器连接。

IETF在1999年发布了PPPoE协议的工作标准。PPPoE的IETF规范是RFC2516。

在这里插入图片描述

下面开始介绍pppoe协议。

以太网点对点协议(PPPoE)

PPPoE的工作流程包含发现( Discovery) 和会话( Session) 两个阶段。

发现阶段
在这里插入图片描述

发现阶段有四个步骤。 完成后,两者对等方知道PPPoE SESSION_ID和对等方的以太网地址,一起共同定义PPPoE会话。 步骤包括广播发起数据包,一个或多个访问的主机的数量集中器发送报价包,主机发送单播会话请求数据包和选定的访问集中器发送确认包。 主机收到确认数据包后,它可以进入PPP会话阶段。 当访问集中器发送确认数据包,它可以进行到PPP会议阶段。所有发现以太网帧的ETHER_TYPE字段均设置为值0x8863。

在发现阶段可能有五个不同的值:

0x09:PPPoE主动发现启动(PADI)数据包
0x07:PPPoE主动发现提议(PADO)数据包
0x19:PPPoE主动发现请求(PADR)数据包
0x65:PPPoE Active Discovery会话确认(PADS)数据包
0xa7:PPPoE Active Discovery Terminate(PADT)数据包

PADI是由客户端发送的广播广播的初始化消息,以发现是否有任何服务器。PADO是单播提供给请求的客户端的服务器的答案。PADR是Client的选择消息。此消息发送到所选服务器。PADS是服务器发送的设置消息。在此最后阶段发送会话ID,并建立会话。

PPPoE主动发现启动(PADI)数据包
在这里插入图片描述
主机将DESTINATION_ADDR设置为PADI的数据包发送到广播地址。 CODE字段设置为0x09,SESSION_ID 必须设置为0x0000。PADI封包务必包含TAG_TYPE服务中的一个TAG,名称,指示主机正在请求的服务,以及任何数字其他TAG类型。 整个PADI数据包(包括PPPoE标头)不得超过1484个八位位组,以留出足够的空间让中继代理添加中继会话ID标记。

PPPoE主动发现提议(PADO)数据包

在这里插入图片描述

当访问集中器收到其可以服务的PADI时,它将通过发送PADO数据包进行回复。DESTINATION_ADDR是 发送PADI的主机的单播地址。 CODE字段是设置为0x07,并且SESSION_ID必须设置为0x0000。PADO数据包必须包含一个包含访问的AC名称标签集中器的名称,即服务名称TAG,与PADI,以及任何其他表示其他的服务名称TAG访问集中器提供的服务。 如果访问集中器不能为PADI服务,它一定不能以PADO响应。

PPPoE主动发现请求(PADR)数据包:

在这里插入图片描述

由于PADI已广播,主机可能会收到不止一个 PADO,浏览接收到的PADO数据包,选择一个。 该选择可以基于AC名称或服务提供。 然后,主机向访问服务器发送一个PADR数据包选择的集中器。 设置了DESTINATION_ADDR字段到发送的访问集中器的单播以太网地址PADO。 CODE字段设置为0x19,并且SESSION_ID必须为设置为0x0000。PADR数据包务必包含TAG_TYPE服务中的一个TAG名称,指示主机正在请求的服务,以及任何数字其他TAG类型。

PPPoE Active Discovery会话确认(PADS)数据包:

在这里插入图片描述

当访问集中器接收到PADR数据包时,它准备进行以下操作:开始PPP会话。 它为PPPoE生成唯一的SESSION_ID会话并使用PADS数据包回复主机。 的DESTINATION_ADDR字段是主机的单播以太网地址发送了PADR。 CODE字段设置为0x65,SESSION_ID必须设置为为此PPPoE会话生成的唯一值。PADS数据包仅包含一个TAG_TYPE服务名称的TAG,表明访问集中器已接受的服务PPPoE会话,以及任何其他数量的TAG类型。如果访问集中器不喜欢PADR,那么它必须使用包含TAG_TYPE TAG的PADS进行回复服务名称错误(以及任何其他数量的TAG类型)。 在这种情况下SESSION_ID必须设置为0x0000。

PPPoE Active Discovery Terminate(PADT)数据包:

在这里插入图片描述

建立会话后,可以随时发送此数据包。表示PPPoE会话已终止。 它可能是由主机或访问集中器。 DESTINATION_ADDR字段是单播以太网地址,CODE字段设置为0xa7并且必须设置SESSION_ID来指示要进行哪个会话终止。 不需要TAG。 收到PADT时,不允许再发送PPP流量使用该会话。 即使是普通的PPP终止数据包也不得发送或接收PADT后发送。 PPP对等体应使用PPP协议本身可以降低PPPoE会话,但是PADT可以无法使用PPP时使用。

会话阶段

一旦PPPoE会话开始,便像其他任何PPP一样发送PPP数据封装。 所有以太网数据包都是单播的。 ETHER_TYPE字段设置为0x8864。 PPPoE代码必须设置为0x00。 SESSION_ID不得更改该PPPoE会话,且必须为发现阶段分配的值。 PPPoE有效负载包含一个PPP框架。 帧以PPP协议ID开头。
在这里插入图片描述
访问集中器可以在向客户端发送PADS数据包之后启动PPPoE会话,或者客户端可以在从访问集中器接收到PADS数据包之后开始PPPoE会话。一个设备在每个接口上支持多个PPPoE会话,但每个设备最多不超过256个PPPoE会话。

每个PPPoE会话由对等方的以太网地址和会话ID唯一标识。建立PPPoE会话后,将像在其他任何PPP封装中一样发送数据。PPPoE信息封装在以太网帧中,并发送到单播地址。

回显请求和所有其他PPP流量的行为与正常PPP会话中的行为完全相同。在此阶段,客户端和服务器都必须为PPPoE逻辑接口分配资源。

建立会话后,客户端或访问集中器可以随时发送PPPoE主动发现终止(PADT)数据包以终止会话。PADT数据包包含对等方的目标地址和要终止的会话的会话ID。发送此数据包后,会话将关闭PPPoE通信。

PPP使用链接控制协议(LCP)在用户计算机和ISP之间建立会话。LCP负责确定链路是否可接受数据传输。LCP数据包在多个网络点之间交换,以确定链路特征,包括设备标识,数据包大小和配置错误。

判断是不是PPPoE协议

static bool is_pppope(struct ether_header *pEther)
{
	
	printf("info pppoe\n");
	struct pppoe_hdr *pppoe_h;
	pppoe_h = (pppoe_hdr *)(pEther + 1);
    //PPPoED
    if( ntohs(pEther->ether_type) == 0x8863 && pppoe_h->code == PADT_CODE)
    {
        return true;
    }
    //PPPoES
    if( ntohs(pEther->ether_type) == 0x8864 )
    {
		return true;
    }
    return false;
	
}

PPPoE的以太网有效负载

以太网:

以太网帧的类型字段确定哪个阶段处于活动状态。在这里,您可以找到0x8863进行发现(Discovery)或0x8864进行会话(Session)。类型字段后跟PPPoE帧,该帧嵌入在以太网帧的数据字段中。

在这里插入图片描述
DESTINATION_ADDR字段包含单播以太网目标地址或以太网广播地址。对于发现数据包,该值为单播或广播地址在“发现”部分中定义。 对于PPP会话流量,此字段必须包含对等方的单播地址,如下所示:从发现阶段确定。SOURCE_ADDR字段必须包含源设备。ETHER_TYPE设置为0x8863(发现阶段)或0x8864(PPP会话阶段)。

在这里插入图片描述

在这里插入图片描述

VER字段为4位,对于此版本的VER字段,必须将其设置为0x1 。TYPE字段为4位,此版本必须设置为0x1,CODE字段是八位。下面为发现定义和PPP会话阶段。

SESSION_ID字段为16位。 这是一个无符号值网络字节顺序。 它的值在下面为发现定义
包。 该值对于给定的PPP会话是固定的,实际上,与以太网SOURCE_ADDR一起定义PPP会话,并且DESTINATION_ADDR。 LENGTH字段是16位。 该值(以网络字节顺序)指示PPPoE有效负载的长度。 它不包括以太网或PPPoE标头的长度。

剖析发现PPPoE协议标签

下面是协议标签的宏定义:

#define PPPOE_TAG_EOL         0x0000
#define PPPOE_TAG_SVC_NAME    0x0101 /*Service-Name*/
#define PPPOE_TAG_AC_NAME     0x0102 /*AC-Name*/
#define PPPOE_TAG_HOST_UNIQ   0x0103
#define PPPOE_TAG_AC_COOKIE   0x0104
#define PPPOE_TAG_VENDOR      0x0105
#define PPPOE_TAG_CREDITS     0x0106
#define PPPOE_TAG_METRICS     0x0107
#define PPPOE_TAG_SEQ_NUM     0x0108
#define PPPOE_TAG_CRED_SCALE  0x0109
#define PPPOE_TAG_RELAY_ID    0x0110
#define PPPOE_TAG_HURL        0x0111
#define PPPOE_TAG_MOTM        0x0112
#define PPPOE_TAG_MAX_PAYLD   0x0120
#define PPPOE_TAG_IP_RT_ADD   0x0121
#define PPPOE_TAG_SVC_ERR     0x0201
#define PPPOE_TAG_AC_ERR      0x0202
#define PPPOE_TAG_GENERIC_ERR 0x0203 /*Generic-Error*/

几个常见的标签

0x0101 Service-Name

在这里插入图片描述

该标签指示跟随服务名称。 TAG_VALUE是一个不为NULL终止的UTF-8字符串。 当TAG_LENGTH为零时,此TAG用于指示任何服务可以接受的。 使用服务名称TAG的示例包括:指出ISP名称或服务等级或质量。

0x0102 AC-Name

在这里插入图片描述
此TAG表示紧随其后的字符串可唯一标识,所有其他特定的访问集中器单元。 有可能是商标,型号和序列号信息的组合,或者只是包装盒MAC地址的UTF-8格式。 该字符串不能以NULL终止。

0x0203 Generic-Error

在这里插入图片描述

该标签表示错误。 可以将其添加到PADO,PADR或发生不可恢复的错误且没有其他错误时的PADS数据包TAG是合适的。 如果有数据,则必须为UTF-8 解释错误性质的字符串。 这个字符串必须NOT NULL终止。

标签实现:

/* Dissect discovery protocol tags */
static void dissect_pppoe_tags(u_char *pppoe_data,int offset,int payload_length)
{
	int tagstart = 0;
	
	tagstart = offset;
	
	/*循环遍历,直到看到所有数据或找到列表结束标记*/
	while (tagstart <= payload_length - 2)
	{
		uint16_t poe_tag = ntohs(*(uint16_t*)(pppoe_data + tagstart));
		//printf("poe_tag 0x%.2X\n",poe_tag);
		tagstart += 2;
		uint16_t poe_tag_length = ntohs(*(uint16_t*)(pppoe_data + tagstart));
		//printf("poe_tag_length 0x%.2X\n",poe_tag_length);
		tagstart += 2;
		switch(poe_tag)
		{
			case  PPPOE_TAG_SVC_NAME:
			{
				if (poe_tag_length > 0)
				{
					char *pszSerName = (char*)malloc(1024);
					if (pszSerName != 0)
					{
						memcpy(pszSerName, pppoe_data + tagstart,poe_tag_length);
						pszSerName[poe_tag_length] = '\0';
						printf("Service-Name: %s\n",pszSerName);
					}						
				}

			}
			break;
			
			case  PPPOE_TAG_AC_NAME:
			{
				char *pszAcName  = (char*)malloc(1024);
				if (pszAcName != 0)
				{
					memcpy(pszAcName, pppoe_data + tagstart,poe_tag_length);
					pszAcName[poe_tag_length] = '\0';
					printf("AC-Name: %s\n",pszAcName);
				}
							
			}
			break;
				
			case  PPPOE_TAG_GENERIC_ERR:
			{
				char *pszGenError  = (char*)malloc(1024);
				if (pszGenError != 0)
				{
					memcpy(pszGenError, pppoe_data + tagstart,poe_tag_length);
					pszGenError[poe_tag_length] = '\0';
					printf("Generic-Error: %s\n",pszGenError);
				}
							
			}
			break;
			/*
				...
			*/	
			default:
				break;
			
		}
		
		tagstart += poe_tag_length;
	}
	
}

pppoe协议代码解析:

#include <sys/stat.h>
#include <sys/types.h>
#include <netinet/tcp.h>
#include <netinet/udp.h>
#include <netinet/ip.h>
#include <netinet/ip6.h>
#include <net/ethernet.h>
#include <sys/socket.h>
#include <netinet/in.h>
//pppoe
#include <linux/if.h>
#include <linux/if_pppox.h>
#include <linux/ppp_defs.h>

#include <pcap.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>


#define PPP_LCP		0xc021	/* Link Control Protocol */
#define PPP_PAP		0xc023	/* Password Authentication Protocol */
#define PPP_IPCP	0x8021	/* Internet Protocol Control Protocol */
#define PPP_IPV6CP	0x8057	/* IPv6 Control Protocol */

#define PPPOE_TAG_EOL         0x0000
#define PPPOE_TAG_SVC_NAME    0x0101 /*Service-Name*/
#define PPPOE_TAG_AC_NAME     0x0102 /*AC-Name*/
#define PPPOE_TAG_HOST_UNIQ   0x0103
#define PPPOE_TAG_AC_COOKIE   0x0104
#define PPPOE_TAG_VENDOR      0x0105
#define PPPOE_TAG_CREDITS     0x0106
#define PPPOE_TAG_METRICS     0x0107
#define PPPOE_TAG_SEQ_NUM     0x0108
#define PPPOE_TAG_CRED_SCALE  0x0109
#define PPPOE_TAG_RELAY_ID    0x0110
#define PPPOE_TAG_HURL        0x0111
#define PPPOE_TAG_MOTM        0x0112
#define PPPOE_TAG_MAX_PAYLD   0x0120
#define PPPOE_TAG_IP_RT_ADD   0x0121
#define PPPOE_TAG_SVC_ERR     0x0201
#define PPPOE_TAG_AC_ERR      0x0202
#define PPPOE_TAG_GENERIC_ERR 0x0203 /*Generic-Error*/

/* Dissect discovery protocol tags */
static void dissect_pppoe_tags(u_char *pppoe_data,int offset,int payload_length)
{
	int tagstart = 0;
	
	tagstart = offset;
	
	/*循环遍历,直到看到所有数据或找到列表结束标记*/
	while (tagstart <= payload_length - 2)
	{
		uint16_t poe_tag = ntohs(*(uint16_t*)(pppoe_data + tagstart));
		//printf("poe_tag 0x%.2X\n",poe_tag);
		tagstart += 2;
		uint16_t poe_tag_length = ntohs(*(uint16_t*)(pppoe_data + tagstart));
		//printf("poe_tag_length 0x%.2X\n",poe_tag_length);
		tagstart += 2;
		switch(poe_tag)
		{
			case  PPPOE_TAG_SVC_NAME:
			{
				if (poe_tag_length > 0)
				{
					char *pszSerName = (char*)malloc(1024);
					if (pszSerName != 0)
					{
						memcpy(pszSerName, pppoe_data + tagstart,poe_tag_length);
						pszSerName[poe_tag_length] = '\0';
						printf("Service-Name: %s\n",pszSerName);
					}						
				}

			}
			break;
			
			case  PPPOE_TAG_AC_NAME:
			{
				char *pszAcName  = (char*)malloc(1024);
				if (pszAcName != 0)
				{
					memcpy(pszAcName, pppoe_data + tagstart,poe_tag_length);
					pszAcName[poe_tag_length] = '\0';
					printf("AC-Name: %s\n",pszAcName);
				}
							
			}
			break;
				
			case  PPPOE_TAG_GENERIC_ERR:
			{
				char *pszGenError  = (char*)malloc(1024);
				if (pszGenError != 0)
				{
					memcpy(pszGenError, pppoe_data + tagstart,poe_tag_length);
					pszGenError[poe_tag_length] = '\0';
					printf("Generic-Error: %s\n",pszGenError);
				}
							
			}
			break;
			/*
				...
			*/	
			default:
				break;
			
		}
		
		tagstart += poe_tag_length;
	}
	
}



/* Discovery protocol, i.e. PPP session not yet established */
static int dissect_pppoed(u_char *pppoe_data)
{
	int offset = 0;
	
	offset += 1;
	
	/* Start Decoding Here. */
	
	uint8_t pppoe_code = *(uint8_t*)pppoe_data+offset;
	offset += 1;
	
	/*skip Session ID*/
	offset += 2;
	/*Read length of payload*/
	uint16_t payload_len = ntohs(*(uint16_t*)(pppoe_data+offset));
	
	printf("payload_len %d\n",payload_len);
	offset += 2;
	/*Now dissect any tags*/
	
	if (payload_len > 0)
	{		
		dissect_pppoe_tags(pppoe_data,offset,payload_len);
	}
	
}


/* Session protocol, i.e. PPP session established */
static int dissect_pppoes(u_char *pppoe_data)
{
	int tagstart = 0;
	u_char code = 0;
	tagstart += 1;
		
	uint8_t pppoe_code = *(uint8_t*)pppoe_data+tagstart;
	
	tagstart += 1;
	
	
	tagstart += 2;/*skip Session ID*/
	/*Read length of payload*/
	uint16_t payload_len = ntohs(*(uint16_t*)(pppoe_data+tagstart));
	
	printf("payload_len %d\n",payload_len);
	tagstart += 2;/*Payload Length*/
	
	/*检索控制代码*/
	uint16_t cp_code = ntohs(*(uint16_t*)(pppoe_data+tagstart));
	printf("Protocol: 0x%X\n",cp_code);
	tagstart += 2;/*Point-to-Point Protocol*/
	/*构造一个包含ppp数据包*/

	switch(cp_code)
	{
		case PPP_LCP:
			
			break;
		case PPP_PAP:
			code = pppoe_data[tagstart];
			printf("auth_request 0x%x\n",code);
			if (code == 1)
			{
				tagstart += 1; /*code*/
				
				tagstart += 1;/*Identifier*/
				tagstart += 2;/*Length*/
				/*Data*/
				u_char peer_id_len = pppoe_data[tagstart];
				printf("Peer-ID-Lnegth: 0x%x\n",peer_id_len);
				tagstart += 1; /*Peer-ID-Lnegth*/
				char *pszPeer  = (char*)malloc(peer_id_len + 1);
				if (pszPeer != 0)
				{
					memcpy(pszPeer, pppoe_data + tagstart,peer_id_len);
					pszPeer[peer_id_len] = '\0';
					printf("Peer-ID: %s\n",pszPeer);
				}
				tagstart += peer_id_len; /*Peer-ID*/
				u_char Password_Length = pppoe_data[tagstart];
				printf("Password-Length: 0x%x\n",Password_Length);
				tagstart += 1; /*Password-Length*/
				char *pszPassword  = (char*)malloc(Password_Length + 1);
				if (pszPassword != 0)
				{
					memcpy(pszPassword, pppoe_data + tagstart,Password_Length);
					pszPassword[Password_Length] = '\0';
					printf("Password: %s\n",pszPassword);
				}
				
			}
			break;
		case PPP_IPCP:
			code = pppoe_data[tagstart];
			printf("Configuration request 0x%x\n",code);
			if (code == 1)
			{
				tagstart += 1; /*code*/
				
				tagstart += 1;/*Identifier*/
				tagstart += 2;/*Length*/
				/*Options*/
				
				u_char type = pppoe_data[tagstart];
				printf("type 0x%x\n",type);
				tagstart += 1; /*type*/
				u_char length = pppoe_data[tagstart];
				printf("length 0x%x\n",length);
				tagstart += 1; /*length*/
				
				in_addr ip;
				memcpy(&ip.s_addr, pppoe_data + tagstart, sizeof(ip.s_addr));
				printf("IP Address: %s\n",inet_ntoa(ip));
				
			}
			break;
		case PPP_IPV6CP:
			
			code = pppoe_data[tagstart];
			printf("Interface Identifier 0x%x\n",code);
			if (code == 1)
			{
				tagstart += 1; /*code*/
				
				tagstart += 1;/*Identifier*/
				tagstart += 2;/*Length*/
				/*Options*/
				
				u_char type = pppoe_data[tagstart];
				printf("type 0x%x\n",type);
				tagstart += 1; /*type*/
				u_char length = pppoe_data[tagstart];
				printf("length 0x%x\n",length);
				tagstart += 1; /*length*/
				
				struct in6_addr ip_v6;
				
				memcpy(&ip_v6.s6_addr, pppoe_data + tagstart, sizeof(ip_v6.s6_addr));
				char ip6[128] = {0};
				printf("Interface Identifier: %s\n",inet_ntop(AF_INET6, (void *)&ip_v6, ip6, 128));
			}
			
			
			break;	
			
		default:
			break;	
		
	}
	
}

static bool is_pppope(struct ether_header *pEther)
{
	
	printf("info pppoe\n");
	struct pppoe_hdr *pppoe_h;
	pppoe_h = (pppoe_hdr *)(pEther + 1);
    //PPPoED
    if( ntohs(pEther->ether_type) == 0x8863 && pppoe_h->code == PADT_CODE)
    {
        return true;
    }
    //PPPoES
    if( ntohs(pEther->ether_type) == 0x8864 )
    {
		/*
        if(ntohs(pppoe_h->type) == PPP_PAP || ntohs(pppoe_h->type) == PPP_CHAP)
        {
            return true;
        }
        if(ntohs(pppoe_h->type) == PPP_IPCP || ntohs(pppoe_h->type) == PPP_IPV6CP)
        {
            return true;
        }
		*/
		return true;
    }
    return false;
	
}

int pkt_number = 0;
void ace_pcap_hand(u_char *par, struct pcap_pkthdr *hdr, u_char *data)
{	

	int i = 0;
    pkt_number++;
    struct ether_header *pEther = (struct ether_header *)data;/*以太网帧头*/
	u_char *pppoe_data =  (u_char *)(pEther + 1);
	
	printf("\n");
	printf("pkt_number %d\n",pkt_number);
	printf("Destination: ");
	for (i = 0; i <6; i++)
	{	
		printf("%.2X",pEther->ether_dhost[i]);
			if (i != 5)
				printf(":");
	}
	printf("\n");
	printf("Source: ");
	for (i = 0; i <6; i++)
	{	
		printf("%.2X",pEther->ether_shost[i]);
			if (i != 5)
				printf(":");
	}
	printf("\n");
	printf("type 0x%X\n",ntohs(pEther->ether_type));
	
	//printf("0x%.2X,0x%.2X,0x%.2X \n",pppoe_data[0],pppoe_data[1],pppoe_data[2]);
	if (!is_pppope(pEther))
		return ;
	
	if (ntohs(pEther->ether_type) == 0x8864)			
		dissect_pppoes(pppoe_data);
	else if (ntohs(pEther->ether_type) == 0x8863)	
		dissect_pppoed(pppoe_data);
	else
	{
		
	}

}

int main(int argc, char* argv[])
{
    char errbuf[1024];
    pcap_t *desc = 0;

    char *filename = argv[1];
    if (argc != 2)
    {
        printf("usage: ./dissect_pppoe [pcap file]\n");
        return -1;
    }

    printf("ProcessFile: process file: %s\n", filename);
    if ((desc = pcap_open_offline(filename, errbuf)) == NULL)
    {   
        printf("pcap_open_offline: %s error!\n", filename);
        return -1; 
    }   

    pcap_loop(desc, pkt_number, (pcap_handler)ace_pcap_hand, NULL);
    pcap_close(desc);
    return 0;
}

pppoe:
在这里插入图片描述
ppp:

在这里插入图片描述

总结

点对点协议(PPP)和以太网点对点协议(PPPoE)是允许两个网络实体或点之间进行数据通信的网络协议。

在两种协议的整个文档中,点都称为节点,计算机或主机。协议的设计相似,但主要区别在于PPPoE封装在以太网帧中。两种协议都存在于支持包括IPv4和IPv6在内的网络层协议的网络访问层(也称为数据链路层)。

参考:https://www.rfc-editor.org/rfc/rfc2516.txt

在这里插入图片描述

欢迎关注微信公众号【程序猿编码】,添加本人微信号(17865354792),回复:领取学习资料。或者回复:进入技术交流群。共同学习!网盘资料有如下:

在这里插入图片描述

标签:协议,tagstart,以太网,TAG,printf,PPPOE,pppoe,PPPoE
来源: https://blog.csdn.net/chen1415886044/article/details/112119992