GPIO模拟I2C通信协议(二)

版权声明:本文为博主原创文章,转载请附上博文链接! https://blog.csdn.net/ctyqy2015301200079/article/details/83857641


概要: 本博客是GPIO模拟I2C通信协议系列的第2篇,承接上一篇的内容,总结单片机通过用GPIO模拟的I2C和从设备E2PROM进行数据交换功能的实现。
关键字: 模拟I2C; E2PROM; 驱动开发

1 E2PROM简介

   E2PROM是Electrically Erasable Programmable Read Only Memory的缩写,中文为“电可擦除可编程只读程序存储器”,它的特点顾名思义:带点可擦除、可编程、只读存储器。虽然名为“只读”,但是用户可以更改其中的数据,可通过高于普通电压的作用来对E2PROM进行擦除和重编程,同时E2PROM也具有掉电不丢失的特点。另外,一般而言E2PROM的存储空间都比较小,因此只能存储简单的数据。

   串行E2PROM按总线形式分为三种,即I2C总线、Microwire总线及SPI总线三种。这里我们讨论的是带I2C总线接口的E2PROM,以AT24C08为例。

   AT24C02/04/08为ATMEL公司生产的系列E2PROM,其内存分别为2048/4096/8192比特,即256/512/1024字节。AT24C08的内部空间划分如下:

   内部共分为4个block(块),每个block里又有16个page(页),每个page的大小是16字节。这样,每个block的空间是256字节,每块AT24C08的空间就是1024字节。

   典型的双排直插式封装的E2PROM引脚图如图1所示。

图1 E2PROM引脚图

 
   其中A0、A1、A2决定了这块E2PROM在I2C总线上的地址,在单主控器单被控器的情况下无需考虑,WP可以视作无意义也无需考虑,VCC和GND分别连接3.3V直流电压源和接地即可,SDA和SCL分别表示数据线和时钟线。在单主单从的应用中只需连接VCC、GND、SDA和SCL4根线。

   如需详解请自行阅读相关芯片手册,推荐一个名为alldatasheet的网站,内有大量芯片手册可供免费下载,实为硬件工程师居家旅行烧板写码必备良药。

2 AT24C28的读写逻辑

   从数据手册上我们可以发现集成I2C串行总线的AT24C08具有5种不同的读写逻辑,可以完成单片机等主控器和24C08(被控器)之间单字节数据和多字节数据的读写操作。

2.1 单字节写入 (BYTE WRITE)

   单片机向被控器E2PROM中写入单个字节数据,逻辑如下。

   首先单片机在SDA总线上产生start信号,接着产生7bit的地址信号以及1bit读写标记为,其中地址的前4bit为固定在被控器内部不可更改的序列,每一种类的器件共享一个4bit地址代码,后3bit为确定具体某个芯片的代码,那1bit读写标记位规定为高电平表示“读”、低电平表示“写”。被控器识别SDA总线上的7bit地址和1bit标记位,在发现和自己的地址匹配后向SDA上发送ACK,主控器收到ACK后再向SDA总线上产生具体的8bit地址,也就是要把字节发送到E2PROM的具体哪个位置去。被控器接收到地址字节后再次发送ACK。此时,主控器接收到ACK后会发送1字节的数据,被控器从SDA上依次逐bit读取该字节数据,之后向主控器发送ACK,主控器在接收到ACK后向总线发送stop信号,结束本轮数据传输。

   为方便描述,我们把7bit地址和1bit读写标记位称为“控制字节(Control Byte)”,把标记芯片内部地址的字节称为“字地址(Word Address)”,发送的数据就是Data。

   主控器向被控器写入单字节数据的总线信号示意如图2所示。

图2 单字节写入总线信号示意图

 
   此处有一点需要注意,那就是对A0、A1、A2这3个bit的理解和使用。

   上文中说到这3bit表示对总线上芯片地址的识别,在单主单从的模式中无需考虑,而Control Byte的后3bit正好是用来在SDA上寻找从器件的。这样一来似乎在单主单从模式下后3bit可以随意填写,而24C08内部的block却无法区分,因此上述描述不能自洽。而我在实践中得到的信息是这样的:对于24C08而言,Control Byte的后3bit中的最后2bit决定了片内block的选择,Control Byte的后3bit中的第1bit决定了芯片的选择,因此,一条I2C总线上最多只能挂载2片24C08芯片,共计8个block。同理,一条I2C总线上最多只能挂载4片24C04(每片有2个block)或8片24C02(每片有1个block)。

   集成I2C总线的E2PROM地址为的前4bit按规定都是1010,这4bit数据是固定在其内部无法改变的。因此,对于24C08来说,不妨假设地址位后3bit的第1bit都是0,那么其中4个block的7bit地址代码分别为:1010000、1010001、1010010和1010011,转换成16进制就是:0x50、0x51、0x52和0x53。

2.2 页写入 (PAGE WRITE)

   单片机向被控器E2PROM中整页写入数据,逻辑如下。

   在被控器向主控器发送第3个ACK表明自己收到1字节收据后,主控器继续发送下一字节数据而不是stop,直到某一时刻主控器发送stop。

   虽然页(Page)的大小是16Byte,但是主控器连续发送的数据量不一定非得是一页的数据量,理论上可以连续发送任意多字节的数据。但是有一点需要注意,就是对E2PROM的写入是按页循环的,即当地址超出某页末尾后,下一个待写入字节的地址不会自动转入下一页,而是会回到本页的开头,这样该字节就会覆盖本页开头原有的那一字节数据。

   主控器向被控器整页写入数据的总线信号示意如图3所示。

图3 页写入总线信号示意图

2.3 读取当前地址 (CURRENT ADDRESS READ)

   单片机读取被控器当前操作数据(读/写)的地址,逻辑如下。

   芯片24C28内部有一个地址计数器,记录了上一个数据访问的地址,不论是读取还是写入。因此,若上一个操作的地址是n,那么么下一步执行读取当前地址所得到的数据就是n+1。当24C08收到Control Byte后,它在SDA总线上生成ACK信号以及8bit地址n+1,主控制器则在SDA上产生not ACK信号示意24C08停止继续传输,最后主控制器产生stop结束此次任务。

   主控器读取被控器当前地址的总线信号示意如图4所示。

图4 读取当前地址总线信号示意图

2.4 随机读取 (RANDOM READ)

   随机读取并不是真的“随机”,而是读取指定地址是的数据,逻辑如下。

   随机读取允许主控器读取被控器任意地址的数据。首先主控器发送start,接着发送Control Byte,注意此时最后一位续写标记bit值应为0,即表示“写”。在被控器应答ACK后,主控器将地址字节发送出去,当被控器再次应答ACK后,主控器再次发送一个start信号以结束“写”任务,并紧跟着发送Control Byte开始“读”任务,并注意这里Control Byte的最后1bit值应当为1,即表示“读”。当被控器第三次应答ACK后,此时被控器向主控器发送刚刚接收到的地址处的数据字节。主控器接收完毕后向被控器发送not ACK示意被控器停止继续传输,最后主控器产生stop信号结束任务。

   主控器随机读取被控器数据的总线信号示意如图5所示。

图5 随机读取总线信号示意图

2.5 顺序读取 (SEQUENTIAL READ)

   顺序读取是主控器读取某一特定地址及其之后的若干个数据,逻辑如下。

   顺序读取前面的逻辑和随机读取一致,直到最后一步:主控器在接收到数据后并不发送not ACK而是发送ACK,这样被控器继续向主控器发送数据,直到主控器产生not ACK为止,最后主控器会在发送not ACK之后发送stop结束任务。

   主控器顺序读取被控器数据的总线信号示意如图6所示。

图6 顺序读取总线信号示意图

 
   理论上被控器可以向主控器无限次发送数据,但是和PAGE WRITE的情形一样,连续读取也存在循环,只不过读取时逐block循环的,显然比PAGE WEITE的逐页循环大了很多。也就是说,在读取到当前block的最后一个字节的数据后,如果继续读取,那么不会读到下个block首地址的数据,而是会读到本block首地址的数据。

3 实现代码

   在上一篇博客的成果——函数void i2c_write_single_byte(uint8_t i2c_buff)和uint8_t i2c_read_single_byte(void)——的基础上用C语言实现上述5种读写模式。

3.1 头文件

   首先罗列一下所有需要实现的函数。

#ifndef __E2PROM_24C08_H__
#define __E2PROM_24C08_H__

#include "i2c_master_sim.h"

typedef struct address_to_ctrl_byte
{
	uint8_t ctrl_byte;
	uint8_t word_addr;
}addr_ctrl_byte_struct;

void i2c_byte_write(uint8_t ctrl_byte,uint8_t word_addr,uint8_t data_byte);
void i2c_page_write(uint8_t ctrl_byte,uint8_t word_addr,uint8_t *source_data_addr,uint8_t data_len);
void i2c_write_within_block(uint8_t ctrl_byte,uint8_t word_addr,uint8_t *source_data_addr,uint16_t data_len);
uint8_t i2c_current_addr_read(uint8_t ctrl_byte);
uint8_t i2c_rand_read(uint8_t ctrl_byte,uint8_t word_addr);
void i2c_sequential_read(uint8_t ctrl_byte,uint8_t word_addr,uint16_t data_len,uint8_t *data_addr_in_master_mem);

addr_ctrl_byte_struct get_eigenbytes(uint16_t address_in_chip);

void i2c_write_within_chip(uint16_t address_in_chip,uint8_t *source_data_addr,uint16_t data_len);
void i2c_read_within_chip(uint16_t address_in_chip,uint8_t *data_addr_in_master_mem,uint16_t data_len);

#endif

   我想我有必要对该文件的内容做些讲解。文件中除了5个已经提到的函数外还有4个函数和1个结构体,这里也包含了上一篇博客中提到的“分层”思想。

   事实上 Chapter 2 所提到的5种读写方法是不能直接提交给用户的。当用户需要往一块内存中读写数据时,他才不管什么Control Byte、Word Address之类的呢,用户关心的参数只有:起始地址、长度、目标地址三样而已。所以应当将这5种读写方式进一步抽象,抽象成2个API:Read函数和Write函数,而将内部的一些细节全部隐藏,这就是头文件中最后两个函数的功能。

void i2c_write_within_chip(uint16_t address_in_chip,uint8_t *source_data_addr,uint16_t data_len);
void i2c_read_within_chip(uint16_t address_in_chip,uint8_t *data_addr_in_master_mem,uint16_t data_len);

   头文件中剩余的函数就是为了将5个API进一步抽象成更高一层的2个API所需要的辅助代码。

3.2 源文件

   基本的5个读写函数已有说明,2个更抽象的API所需要当心的内容也只是每一个Page和Block的首末位置,防止循环、防止覆写和重读、注意内存越界等等,本质上只是二维数组的操作,也很简单。

   下面就直接上代码了。

#include "stdlib.h"
#include "math.h"
#include "e2prom_24C08.h"
#include "i2c_master_sim.h"

#define SDA IO_CONFIG_PB0
#define SCL IO_CONFIG_PB1

addr_ctrl_byte_struct get_eigenbytes(uint16_t address_in_chip)
{
	addr_ctrl_byte_struct cbs;
	if((address_in_chip<0x00) || (address_in_chip>0x3FF))
	{
		printf("Cross-border error! The range of address_in_chip is 0x000-0x3FF.\n");
		exit(EXIT_FAILURE);
	}
	else
	{
		if((address_in_chip>=0x00) && (address_in_chip<0x100))
		{
			cbs.ctrl_byte  = 0xA0;
			cbs.word_addr  = address_in_chip;
		}
		else if((address_in_chip>=0x100) && (address_in_chip<0x200))
		{
			cbs.ctrl_byte  = 0xA2;
			cbs.word_addr  = address_in_chip%0x100;
		}
		else if((address_in_chip>=0x200) && (address_in_chip<0x300))
		{
			cbs.ctrl_byte  = 0xA4;
			cbs.word_addr  = address_in_chip%0x200;
		}
		else
		{
			cbs.ctrl_byte  = 0xA6;
			cbs.word_addr  = address_in_chip%0x300;
		}
	}
	return cbs;
}

// write one byte to e2prom
void i2c_byte_write(uint8_t ctrl_byte,uint8_t word_addr,uint8_t data_byte)
{
	i2c_start();
	i2c_write_single_byte(ctrl_byte);
	if(i2c_read_ack() == 0)
		i2c_write_single_byte(word_addr);
	else 
		return;
	if(i2c_read_ack() == 0)
		i2c_write_single_byte(data_byte);
	else
		return;
	if(i2c_read_ack() == 0)
		i2c_stop();
	else
		return;
}

// write bytes to e2prom (page write)
void i2c_page_write(uint8_t ctrl_byte,uint8_t word_addr,uint8_t *source_data_addr,uint8_t data_len)
{
	uint8_t i;
	if (data_len<=0)
	{
		printf("i2c_page_write: data_len should be a positive number.\n");
		return;
	}
	else
	{
		i2c_start();
		i2c_write_single_byte(ctrl_byte);
		if(i2c_read_ack() == 0)
			i2c_write_single_byte(word_addr);
		else 
			return;
		for(i=0;i<data_len;i++)
		{
			if(i2c_read_ack() == 0)
				i2c_write_single_byte(*(source_data_addr+i));
			else
				return;
		}
		if(i2c_read_ack() == 0)
			i2c_stop();
		else
			return;
	}
	printf("i2c_page_write finished.\n");
}

// read current address
uint8_t i2c_current_addr_read(uint8_t ctrl_byte)
{
	uint8_t data;
	i2c_start();
	i2c_write_single_byte(ctrl_byte);
	if(i2c_read_ack() == 0)
	{
		data = i2c_read_single_byte();
		i2c_send_nack();
		i2c_stop();
		return data;
	}
	else 
		return 0;
}

// read one byte from eeprom
uint8_t i2c_rand_read(uint8_t ctrl_byte,uint8_t word_addr)
{
	uint8_t data;
	i2c_start();
	i2c_write_single_byte(ctrl_byte);
	
	if(i2c_read_ack() == 0)
		i2c_write_single_byte(word_addr);
	else 
		return 0;
	
	if(i2c_read_ack() == 0)
		i2c_start();
	else 
		return 0;
	
	i2c_write_single_byte((ctrl_byte+1));
	
	if(i2c_read_ack() == 0)
	{
		data = i2c_read_single_byte();
		i2c_send_nack();
		i2c_stop();
		return data;
	}
	else 
		return 0;
}

// read sequential bytes from eeprom 
// it is the ADDRESS that is transferred, but not DATA!
//uint32_t i2c_sequential_read(uint8_t ctrl_byte,uint8_t word_addr,uint16_t data_num)
void i2c_sequential_read(uint8_t ctrl_byte,uint8_t word_addr,uint16_t data_len,uint8_t *data_addr_in_master_mem)
{
	uint16_t i=0;
	
	if(data_len<=0)
	{
		printf("i2c_sequential_read: data_len should be a positive number.\n");
		return;
	}
	else
	{
		i2c_start();
		i2c_write_single_byte(ctrl_byte);
		
		if(i2c_read_ack() == 0)
			i2c_write_single_byte(word_addr);
		else 
			return;
		
		if(i2c_read_ack() == 0)
			i2c_start();
		else 
			return;
		
		i2c_write_single_byte((ctrl_byte+0x01));
		
		if(i2c_read_ack() == 0)
			*data_addr_in_master_mem = i2c_read_single_byte();
		else 
			return;
		
		for(i=1;i<data_len;i++)
		{
			i2c_send_ack();		// master send ACK
			*(data_addr_in_master_mem + i) = i2c_read_single_byte();
		}
		
		i2c_send_nack();		// master send NACK
		i2c_stop();
	}
	printf("i2c_sequential_read finished.\n");
}

// memory_write within one single block
void i2c_write_within_block(uint8_t ctrl_byte,uint8_t word_addr,uint8_t *source_data_addr,uint16_t data_len)
{
	uint8_t page_size = 0x10;
	uint16_t bolck_size = 0x100;
	uint8_t extra_page = 0;
	uint8_t i = 0;
	uint8_t page_offset = word_addr % page_size;
	uint8_t len_left = page_size - page_offset;
	
	// beyond the scope of the current block
	if( (word_addr + data_len) > bolck_size )
	{
		printf("(B1)i2c_write_within_block:beyond the scope of the current block, JUST RETURN.");
		return;
	}
	// within the current block
	else
	{
		if(data_len <= len_left)
		{
			printf("(B2)i2c_write_within_block:within the current page.\n");
			i2c_page_write(ctrl_byte,word_addr,source_data_addr,data_len); // pointer as function parameter?
			while(i2c_ack_check(ctrl_byte));
		}
		else
		{
			printf("(B3)i2c_write_within_block:within the current block but beyond the current page.\n");
			if( (data_len - len_left)%page_size != 0 )
				extra_page = floor( (data_len - len_left)/(float)page_size+1 );
			else
				extra_page = (data_len - len_left)/(float)page_size;
			
			printf("extra_page = %d.\n data_len = %d.\n len_left = %d.\n",extra_page,data_len,len_left);
			// first, write the current page
			i2c_page_write(ctrl_byte,word_addr,source_data_addr,len_left);
			while(i2c_ack_check(ctrl_byte));
			// then, write the following complete page except the last maybe-incomplete page
			for (i=1;i<extra_page;i++)
			{
				i2c_page_write(ctrl_byte,(word_addr+len_left+(i-1)*page_size),(source_data_addr+len_left+(i-1)*page_size),page_size);
				while(i2c_ack_check(ctrl_byte));
			}
			// finally, write the last maybe-incomplete page
			i2c_page_write(ctrl_byte,(word_addr+len_left+(extra_page-1)*page_size),(source_data_addr+len_left+(extra_page-1)*page_size),
			(data_len-len_left-(extra_page-1)*page_size));
			while(i2c_ack_check(ctrl_byte));
		}
	}
	printf("i2c_write_within_block finished.\n");
}

/******************** the following are 2 highest API:Write/Read in chip********************/

// memory_write within one 24c08 Chip
void i2c_write_within_chip(uint16_t address_in_chip,uint8_t *source_data_addr,uint16_t data_len)
{
	uint16_t block_size = 0x100;
	uint8_t extra_block = 0;
	uint8_t i = 0;
	
	addr_ctrl_byte_struct Scb;
	Scb = get_eigenbytes(address_in_chip);
	uint8_t ctrl_byte = Scb.ctrl_byte;
	uint8_t word_addr = Scb.word_addr;
	uint8_t left_block_num = 4 - ((ctrl_byte & 0x06) >> 1);
	uint16_t total_mem_left = 1024-256*(4-left_block_num)-word_addr;
	uint16_t current_block_mem_left = block_size-word_addr;
	
	// do not beyond current block
	if ( (word_addr+data_len) <= block_size )
	{
		printf("(C1)i2c_write_within_chip:do not beyond current block.\n");
		i2c_write_within_block(ctrl_byte,word_addr,source_data_addr,data_len);
	}
	// the chip itself is not large enough
	// just write the chip full and abandon the left part
	else if ( data_len > total_mem_left )
	{	
		printf("(C2)i2c_write_within_chip:the chip itself is not large enough.\n");
		data_len = total_mem_left;
		
		if( (data_len-current_block_mem_left)%block_size != 0 )
			extra_block = floor((data_len-current_block_mem_left)/(float)block_size + 1);
		else
			extra_block = (data_len-current_block_mem_left)/(float)block_size;
		
		// first, write the current block
		i2c_write_within_block(ctrl_byte,word_addr,source_data_addr,current_block_mem_left);
		// just write the chip full and abandon the left part
		for (i=1;i <= extra_block;i++)
		{
			i2c_write_within_block(ctrl_byte+0x02*i,0x00,source_data_addr+current_block_mem_left+(i-1)*block_size,block_size);
		}
	}
	// occupy more than one block of memory but not beyond chip's memory range
	else
	{
		printf("(C3)i2c_write_within_chip:occupy more than one block of memory but not beyond chip's memory range.\n");
		
		// judge whether extra_block is intergals or float
		if( (data_len-current_block_mem_left)%block_size != 0 )
			extra_block = floor((data_len-current_block_mem_left)/(float)block_size + 1);
		else
			extra_block = (data_len-current_block_mem_left)/(float)block_size;
		
		printf("extra_block = %d.\n",extra_block);
		// first, write the current block
		i2c_write_within_block(ctrl_byte,word_addr,source_data_addr,current_block_mem_left);
		// then, write the following complete block except the last maybe-incomplete block
		for (i=1;i<extra_block;i++)
		{
			i2c_write_within_block(ctrl_byte+0x02*i,0x00,source_data_addr+current_block_mem_left+(i-1)*block_size,block_size);
		}
		// finally, write the last maybe-incomplete block
		i2c_write_within_block(ctrl_byte+0x02*extra_block,0x00,
			source_data_addr+current_block_mem_left+(extra_block-1)*block_size,
			data_len-current_block_mem_left-(extra_block-1)*block_size);
	}
	printf("i2c_write_within_chip finished.\n");
}

void i2c_read_within_chip(uint16_t address_in_chip,uint8_t *data_addr_in_master_mem,uint16_t data_len)
{
	uint16_t block_size = 0x100;
	uint8_t extra_block = 0;
	uint8_t i = 0;
	
	addr_ctrl_byte_struct Scb;
	Scb = get_eigenbytes(address_in_chip);
	uint8_t ctrl_byte = Scb.ctrl_byte;
	uint8_t word_addr = Scb.word_addr;
	uint8_t left_block_num = 4 - ((ctrl_byte & 0x06) >> 1);
	uint16_t total_mem_left = 1024-256*(4-left_block_num)-word_addr;
	uint16_t current_block_mem_left = block_size-word_addr;
	
	// donot beyond current block
	if ( (word_addr+data_len) <= block_size )
	{
		printf("(C1)i2c_read_within_chip:do not beyond current block.\n");
		i2c_sequential_read(ctrl_byte,word_addr,data_len,data_addr_in_master_mem);
	}
	// the chip itself is not large enough
	// just read the chip full and abandon the left part
	else if (data_len > total_mem_left)
	{
		printf("(C2)i2c_read_within_chip:the chip itself is not large enough.\n");
		data_len = total_mem_left;
		
		if( (data_len-current_block_mem_left)%block_size != 0 )
			extra_block = floor((data_len-current_block_mem_left)/(float)block_size + 1);
		else
			extra_block = (data_len-current_block_mem_left)/(float)block_size;
		
		printf("data_len=%d\n current_block_mem_left=%d\n extra_block=%d\n",data_len,current_block_mem_left,extra_block);
		
		// first, read the current block
		i2c_sequential_read(ctrl_byte,word_addr,current_block_mem_left,data_addr_in_master_mem);
		// then, read the following complete block till the end
		for (i = 1;i <= extra_block;i++)
		{
			i2c_sequential_read(ctrl_byte+0x02*i,0x00,block_size,(data_addr_in_master_mem+current_block_mem_left+(i-1)*block_size));
		}
	}
	// occupy more than one block of memory but not beyond chip's memory range
	else
	{
		printf("(C3)i2c_read_within_chip:occupy more than one block of memory but not beyond chip's memory range.\n");
		
		if( (data_len-current_block_mem_left)%block_size != 0 )
			extra_block = floor((data_len-current_block_mem_left)/(float)block_size + 1);
		else
			extra_block = (data_len-current_block_mem_left)/(float)block_size;
		
		printf("data_len=%d\n current_block_mem_left=%d\n extra_block=%d\n",data_len,current_block_mem_left,extra_block);
		// first, read the current block
		i2c_sequential_read(ctrl_byte,word_addr,current_block_mem_left,data_addr_in_master_mem);
		// then, read the following complete block till the end
		for (i = 1;i < extra_block;i++)
		{
			i2c_sequential_read(ctrl_byte+0x02*i,0x00,block_size,(data_addr_in_master_mem+current_block_mem_left+(i-1)*block_size));
		}
		// finally, write the last maybe-incomplete block
		i2c_sequential_read(ctrl_byte+0x02*extra_block,0x00,data_len-current_block_mem_left-(extra_block-1)*block_size,
		(data_addr_in_master_mem+current_block_mem_left+(extra_block-1)*block_size));
	}
	printf("i2c_read_within_chip finished.\n");
}
	

4 效果展示

   实验成功,单片机可以从E2PROM中读取数据或向其中写入数据。

   向E2PROM中连续写入15字节数据的总线波形图如图7所示:

图7 向E2PROM连续写入15字节波形图

 
   从E2PROM中连续读出256字节数据的串口打印结果(波形图太长无法展示)如图8所示:

图8 从E2PROM连续读出256字节串口打印结果

 
   注意,E2PROM中的数据位在默认情况下都是高电平,即每个字节都是0xFF,这里是先按照0x00-0xFF的顺序往一个block里写256字节,再全部读出来。该实验可以说明面向用户的2个API是有效的。

5 后记

   转载时务必注明来源及作者。尊重知识产权从我做起。

   包含上一篇博客在内的4个C代码文件已上传至网络,欢迎下载,密码是gwd2

猜你喜欢

转载自blog.csdn.net/ctyqy2015301200079/article/details/83857641