FPGA实现的SPI协议(二)—-基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

FPGA实现的SPI协议(二)—-基于SPI接口的FLASH芯片M25P16的使用「建议收藏」用flash芯片M25P16来验证下SPI的具体实现吧。

大家好,又见面了,我是你们的朋友全栈君。如果您正在找激活码,请点击查看最新教程,关注关注公众号 “全栈程序员社区” 获取激活教程,可能之前旧版本教程已经失效.最新Idea2022.1教程亲测有效,一键激活。

Jetbrains全系列IDE稳定放心使用

写在前面

        SPI协议系列文章:

                FPGA实现的SPI协议(一)—-SPI驱动

                FPGA实现的SPI协议(二)—-基于SPI接口的FLASH芯片M25P16的使用

        在上篇文章,简要介绍了SPI协议,编写了SPI协议的FPGA驱动,但是在验证环节,仅仅验证了发送时序,而没有与从机进行通信验证,未免测试不够周全。本文通过对FLASH芯片M25P16的仿真模型进行一系列测试,从而验证SPI驱动的代码的正确性,同时对M25P16进行一个了解。


1、M25P16芯片

1.1、概述

        M25P16是一款带有先进写保护机制和高速SPI总线访问的2MB串行Flash存储器,该存储器主要特点:

  • 2M字节的存储空间,分32个扇区,每个扇区256页,每页256字节;
  • 写入1页数据所需时间为0.64ms(典型值);能单块擦除和整块擦除:
  • 2.7~3.6 V单电源供电电压;
  • SPI总线和50 MHz数据传输时钟频率;
  • 每扇区擦写次数保证10万次、数据保存期限至少20年。

        该款器件特别适用于一体化打印机、PC主板、机顶盒、CD唱机和DVD视盘机、数字电视、数码相机、图形卡和平面显示器等各种应用的代码和数据存储需求。

1.2、引脚

        其引脚描述如下:

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

  • DQ1:数据输出,相当于SPI总线的主机输入、从机输出MISO
  • DQ0:数据输入,相当于SPI总线的主机输出、从机输入MOSI
  • C:时钟信号,相当于SPI总线的SCLK
  • S#:片选信号,相当于SPI总线的片选信号CS
  • HOLD:在选中期间期间输出高阻态,实际上比较像SDRAM的“掩码”
  • W#:写保护,低电平有效,在写保护有效时无法写入数据
  • VCC:电源信号
  • VSS :电源地

1.3、SPI模式

        M25P16根据SPI时钟信号的高低电平自适应的支持SPI通讯模式的模式0和模式3:

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

1.4、存储架构

        M25P16一共2MB字节的存储空间,分32个扇区(SECTOR),每个扇区256页(PAGE),每页256字节(BYTE)。每个字节的的存储地址由扇区地址(8bit)+页地址(8bit)+字节地址(8bit)构成,地址表如下:

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

1.5、指令表

        M25P16支持页写入,全擦除,扇区擦除,读取数据等一系列指令,具体指令表如下:

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

1.6、其他

        需要注意的是M25P16支持的频率如下,所以在我们的仿真实验中选择12.5M这个频率。

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

        此外,页写入, 全擦除,扇区擦除等指令在发出后,仍需要一定的时间才能真正执行完,各个指令所需的时间如下:

        FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

        最后需要注意的一点是,在两个指令之间需要间隔一定的时间(比如发送全擦除指令前需要发送写使能指令,在这两个指令之间就需要间隔一定的时间),具体时间如下:

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

2、指令测试

        在这一章针对集中常用的指令进行代码编写及仿真测试。

2.1、页写(PAGE PROGRAM)

2.1.1、时序

        页写(Page Program)操作,简称 PP,操作指令为 8’b0000_0010(02h)。页写指令是根据写入数据将存储单元中的“1” 置为“0”,实现数据的写入。在写入页写指令之前,需要先写入写使能(WREN)指令,将芯片设置为写使能锁存(WEL)状态;随后要拉低片选信号,写入页写指令、扇区地址、页 地址、字节地址,紧跟地址写入要存储在 Flash 的字节数据,在指令、地址以及数据写入过程中,片选信号始终保持低电平,待指令、地址、数据被芯片锁存后,将片选信号 高;片选信号拉高后,等待一个完整的页写周期(tPP),才能完成 Flash 芯片的页写操作。

        Flash 芯片中一页最多可以存储 256 字节数据,这也表示页写操作一次最多向 Flash 芯片写入 256 字节数据。如字节首地址为 8’0000_1111,字节首地址地址到末地址之间的存储单元个数为 241 个,即本页最多可写入 241 字节数据,若写入数据为 200 个字节,数据可以被正确写入; 若写入数据为 256 个字节,前 241 个字节的数据可以正确写入 Flash 芯片,而超出的 15 个字节就以本页的首地址 8’b0000_0000 为数据写入首地址顺序写入,覆盖本页原有的前 15 个字节的数据。

        页写时序如下:

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

2.1.2、Verilog代码

        Verilog代码分为3个模块:SPI驱动模块spi_drive、SPI读数据控制模块spi_read_ctrl和例化前面两个子模块的读数据顶层模块spi_read。

  • SPI驱动模块spi_drive:提供SPI模式0的读写驱动,具体可参见:  FPGA实现的SPI协议(一)—-SPI驱动
  • SPI页写控制模块spi_page_program_ctrl:该模块使用一段式状态机编写,功能就是调用SPI驱动模块,发送页写指令,然后发送扇区地址+页地址+字节地址,接着给SPI总线上发送一定量的数据(可设置)。
  • 页写顶层模块spi_page_program:例化前面两个子模块。

        SPI页写控制模块spi_page_program_ctrl代码如下:

//SPI页写控制模块
`timescale 1ns/1ns		//时间单位/精度
module spi_page_program_ctrl
#(
	parameter 	SECTOR_ADDR = 8'b0000_0000, 		//扇区地址
	parameter	PAGE_ADDR   = 8'b0000_0000,			//页地址
	parameter	BYTE_ADDR   = 8'b0000_0000			//字节地址
)
(
    input               sys_clk		, 				// 全局时钟50MHz
    input               sys_rst_n	, 				// 复位信号,低电平有效
		
    input  		        send_done	, 				// 主机发送一个字节完毕标志位
    output  reg         spi_start	,				// 发送传输开始信号,一个高电平
    output  reg        	spi_end		,				// 发送传输结束信号,一个高电平
    output  reg  [7:0]  data_send    				// 要发送的数据         
);

//指令定义
localparam 	WR_EN 		 = 8'b0000_0110, 			//写使能指令	
			PAGE_PROGRAM = 8'b0000_0010;			//页写指令
localparam	DATA_MAX 	 = 8'd10;					//最大数据写入个数
				
//reg define		
reg	[7:0]	flow_cnt;								//状态跳转计数器
reg	[7:0]	cnt_wait;								//上电等待计数器
reg	[7:0]	data_cnt;								//数据写入个数计数器

always@(posedge sys_clk or negedge sys_rst_n)begin
	if(!sys_rst_n)begin
		data_send <= 8'd0;
		spi_start <= 1'b0;	
		spi_end <= 1'b0;	
		flow_cnt <= 1'd0;
		cnt_wait <= 8'd0;
		data_cnt <= 8'd0;
	end
	else begin
		spi_start <= 1'b0;							//便于生成脉冲信号
		spi_end <= 1'b0;                            //便于生成脉冲信号
		case(flow_cnt)
			'd0:begin
				if(cnt_wait == 100)begin			//上电后等待稳定
					cnt_wait <= 8'd0;
					flow_cnt <= flow_cnt + 1'd1;
				end
				else begin
					cnt_wait <= cnt_wait + 1'd1;
					flow_cnt <= flow_cnt;								
				end
			end
			'd1:begin									
				data_send <= WR_EN;					//写使能指令
				spi_start <= 1'b1;					//拉高spi开始通讯信号
				flow_cnt <= flow_cnt + 1'd1;
			end
			'd2:begin
				if(send_done)begin					//主机一个字节数据被发送完成
					flow_cnt <= flow_cnt + 1'd1;
					spi_end <= 1'b1;				//结束第1次SPI通信
				end
				else
					flow_cnt <= flow_cnt;
			end	
			'd3:begin
				if(cnt_wait == 10)begin				//等待200ns,两次命令的间隔时间
					cnt_wait <= 8'd0;				//等待计数器清零
					flow_cnt <= flow_cnt + 1'd1;
				end
				else begin
					cnt_wait <= cnt_wait + 1'd1;
					flow_cnt <= flow_cnt;								
				end
			end
			'd4:begin									
				data_send <= PAGE_PROGRAM;          //页写指令
				spi_start <= 1'b1;					//拉高spi开始通讯信号
				flow_cnt <= flow_cnt + 1'd1;
			end				
			'd5:begin								//发送扇区地址
				if(send_done)begin					//指令被发送完成
					flow_cnt <= flow_cnt + 1'd1;
					data_send <= SECTOR_ADDR;		//数据为扇区地址
				end
				else begin
					flow_cnt <= flow_cnt;
					data_send <= data_send;							
				end
			end	
			'd6:begin								//发送页地址
				if(send_done)begin					//发送完成
					flow_cnt <= flow_cnt + 1'd1;
					data_send <= PAGE_ADDR;			//数据为页地址地址
				end
				else begin
					flow_cnt <= flow_cnt;
					data_send <= data_send;							
				end
			end	
			'd7:begin								//发送字节地址
				if(send_done)begin					//指令被发送完成
					flow_cnt <= flow_cnt + 1'd1;
					data_send <= BYTE_ADDR;			//数据为字节地址
				end
				else begin
					flow_cnt <= flow_cnt;
					data_send <= data_send;							
				end
			end	
			'd8:begin								//停留在这个状态
				if(send_done)begin					//指令被发送完成
					flow_cnt <= flow_cnt + 1'd1;
					data_send <= 8'd0;				//发送数据从0开始
				end	
				else 
					flow_cnt <= flow_cnt;										
			end				
			'd9:begin										//写入数据
				if(send_done)begin							//主机一个字节数据被发送完成
					if(data_cnt == DATA_MAX - 1'b1)begin	//数据全部写入
						flow_cnt <= flow_cnt + 1'd1;
						spi_end <= 1'b1;					//结束第1次SPI通信
						data_cnt <= 8'd0;
						data_send <= 8'd0;
					end
					else begin
						flow_cnt <= flow_cnt;
						data_cnt <= data_cnt + 8'd1;		//计数器累加1	
						// data_send <= data_send + 8'd2;	//数据累加2	
						data_send <= data_send + 8'd4;		//数据累加4	
					end
				end
				else begin
					flow_cnt <= flow_cnt;
					data_send <= data_send;
					data_cnt <= data_cnt;
				end
			end					
			'd10:begin										//停留在这个状态
					flow_cnt <= flow_cnt;
			end				
			default:;
		endcase
	end
end

endmodule

        页写顶层模块spi_page_program代码如下:

`timescale 1ns/1ns		//时间单位/精度
//页写
module spi_page_program(
// 系统接口
	input	sys_clk		,		//全局时钟50MHz
	input	sys_rst_n	,   	//复位信号,低电平有效
// SPI物理接口							
	input	spi_miso	,   	//SPI串行输入,用来接收从机的数据
	output	spi_sclk	,   	//SPI时钟
	output	spi_cs    	,   	//SPI片选信号,低电平有效
	output	spi_mosi	    	//SPI输出,用来给从机发送数据   
);
parameter 	SECTOR_ADDR = 8'b0000_0000; 		//扇区地址
parameter	PAGE_ADDR   = 8'b0000_0000;			//页地址
parameter	BYTE_ADDR   = 8'b0000_0000;			//字节地址

wire			spi_start	;	//发送传输开始信号,一个高电平
wire			spi_end		;   //发送传输结束信号,一个高电平
wire	[7:0]  	data_send   ;   //要发送的数据
wire	[7:0]  	data_rec   	;   //接收到的数据
wire         	send_done	;   //主机发送一个字节完毕标志
wire         	rec_done	;   //主机接收一个字节完毕标志

//------------<例化模块>----------------------------------------------------------------
//页写模块
spi_page_program_ctrl
#(
	.SECTOR_ADDR 	(SECTOR_ADDR),
	.PAGE_ADDR   	(PAGE_ADDR  ),
    .BYTE_ADDR   	(BYTE_ADDR  )
)	
spi_sector_erase_ctrl_inst
(
    .sys_clk		(sys_clk	), 			
    .sys_rst_n		(sys_rst_n	), 			

	.send_done		(send_done	), 			
    .spi_start		(spi_start	),			
    .spi_end		(spi_end	),			
    .data_send    	(data_send	)						
);
//SPI驱动
spi_drive	spi_drive_inst(
	.sys_clk		(sys_clk	), 			
	.sys_rst_n		(sys_rst_n	), 			
		
	.spi_start		(spi_start	), 			
	.spi_end		(spi_end	), 			
	.data_send		(data_send	), 			
	.data_rec  		(data_rec	), 			
	.send_done		(send_done	), 			
	.rec_done		(rec_done	), 			
				
	.spi_miso		(spi_miso	), 			
	.spi_sclk		(spi_sclk	), 			
	.spi_cs    		(spi_cs		), 			
	.spi_mosi		(spi_mosi	)			
);

endmodule

2.1.3、Testbench及仿真结果

        Testbench比较简单直接例化SPI页写模块和仿真模型m25p16即可,需要注意的是SPI的的页写操作需要一定的时间(前面已经提到过–5ms)。

        仿真结果如下:

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」  

        从地址24’h0开始,一次写入数据0x00,0x02,0x04···0x12一共10个数据,可以看到在MOSI上,依次出现了上述10个数据,说明符合SPI协议规范。

        命令窗口打印内容如下(单位:ps):

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

        在约12us处开始进行页写操作,5ms后页写操作完成,同样符合芯片参数。

2.1.4、上板验证

        同读数据操作一同验证,详见2.2.4章节。 

2.2、读数据(READ DATA BYTES)

2.2.1、时序

        读数据操作,操作指令为 8’b0000_0011(03h),要执行数据读指令,首先拉低片选信号选中 Flash 芯片,随后写入数据读(READ)指 令,紧跟指令写入 3 字节的数据读取首地址,指令和地址会在串行时钟上升沿被芯片锁存。随后存储地址对应存储单元中的数据在串行时钟下降沿通过串行数据总线输出。 数据读取首地址可以为芯片中的任何一个有效地址,使用数据读(READ)指令可以对芯 片内数据连续读取,当首地址数据读取完成,会自动对首地址的下一个地址进行数据读取。若最高位地址内数据读取完成,会自动跳转到芯片首地址继续进行数据读取,只有再次拉高片选信号,才能停止数据读操作,否者会对芯片执行无线循环读操作。具体时序如下:

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

2.2.2、Verilog代码

        Verilog代码分为3个模块:SPI驱动模块spi_drive、SPI读数据控制模块spi_read_ctrl和例化前面两个子模块的读数据顶层模块spi_read。

  • SPI驱动模块spi_drive:提供SPI模式0的读写驱动,具体可参见:  FPGA实现的SPI协议(一)—-SPI驱动
  • SPI读数据控制模块spi_read_ctrl:该模块使用一段式状态机编写,功能就是调用SPI驱动模块,发送读数据指令,然后发送扇区地址+页地址+字节地址,接着从SPI总线上接收一定量的数据(可设置)。
  • 读数据顶层模块spi_read:例化前面两个子模块。

        SPI读数据控制模块spi_read_ctrl代码如下:

//FLASH读数据控制模块:合适的调用SPI驱动模块
module spi_read_ctrl
#(
	parameter	BYTE_MAX 	= 8'd10			,		//一共读取多少个BYTE的数据
				SECTOR_ADDR = 8'b0000_0000	,		//扇区地址
				PAGE_ADDR   = 8'b0000_0000	,		//页地址
				BYTE_ADDR   = 8'b0000_0000			//字节地址
)
(
    input               sys_clk		, 				// 全局时钟50MHz
    input               sys_rst_n	, 				// 复位信号,低电平有效
	
    input		[7:0]	data_rec  	, 				// 接收到的数据
    input				rec_done	, 				// 主机接收一个字节完毕标志位	
    input  		        send_done	, 				// 主机发送一个字节完毕标志位
    output  reg         spi_start	,				// 发送传输开始信号,一个高电平
    output  reg        	spi_end		,				// 发送传输结束信号,一个高电平
    output  reg  [7:0]  data_send    				// 要发送的数据         
);	
	
//指令定义	
localparam	READ	 	= 8'h03; 					//读数据指令
			
//reg define		
reg	[7:0]	flow_cnt;								//状态跳转计数器
reg	[7:0]	data_cnt;								//数据接收计数器
reg	[7:0]	cnt_wait;								//上电等待计数器

always@(posedge sys_clk or negedge sys_rst_n)begin
	if(!sys_rst_n)begin	                            //复位状态
		data_send <= 8'd0;
		spi_start <= 1'b0;	
		spi_end <= 1'b0;	
		flow_cnt <= 1'd0;
		cnt_wait <= 8'd0;
		data_cnt <= 8'd0;
	end
	else begin
		spi_start <= 1'b0;							//便于生成脉冲信号
		spi_end <= 1'b0;                            //便于生成脉冲信号
		case(flow_cnt)
			'd0:begin
				if(cnt_wait == 100)begin			//上电后等待稳定
					cnt_wait <= 8'd0;
					flow_cnt <= flow_cnt + 1'd1;
				end
				else begin
					cnt_wait <= cnt_wait + 1'd1;
					flow_cnt <= flow_cnt;								
				end
			end
			'd1:begin								//发送读数据指令	
				data_send <= READ;					//读数据指令
				spi_start <= 1'b1;					//拉高spi开始通讯信号
				flow_cnt <= flow_cnt + 1'd1;
			end	
			'd2:begin								//发送扇区地址
				if(send_done)begin					//指令被发送完成
					flow_cnt <= flow_cnt + 1'd1;
					data_send <= SECTOR_ADDR;		//数据为扇区地址
				end
				else begin
					flow_cnt <= flow_cnt;
					data_send <= data_send;							
				end
			end	
			'd3:begin								//发送页地址
				if(send_done)begin					//发送完成
					flow_cnt <= flow_cnt + 1'd1;
					data_send <= PAGE_ADDR;			//数据为页地址
				end
				else begin
					flow_cnt <= flow_cnt;
					data_send <= data_send;							
				end
			end	
			'd4:begin								//发送字节地址
				if(send_done)begin					//指令被发送完成
					flow_cnt <= flow_cnt + 1'd1;
					data_send <= BYTE_ADDR;			//数据为字节地址
				end
				else begin
					flow_cnt <= flow_cnt;
					data_send <= data_send;							
				end
			end				
			'd5:begin
				if(send_done)begin					//字节地址被发送完成
					flow_cnt <= flow_cnt + 1'd1;
					data_send <= 8'd0;				//清空发送数据
				end
				else
					flow_cnt <= flow_cnt;
			end	
			'd6:begin
				if(rec_done)						//这个发送最后一个字节的接收完成标志
					flow_cnt <= flow_cnt + 1'd1;				
				else
					flow_cnt <= flow_cnt;
			end				
			'd7:begin								//读取数据阶段
				if(rec_done)begin					//接收到了一个BYTE数据
					if(data_cnt == BYTE_MAX - 1'd1)begin	//接收到了指定长度个数据
						data_cnt <= 8'd0;			//计数器清零
						spi_end <= 1'b1;			//结束SPI传输
						flow_cnt <= flow_cnt + 1'd1;
					end
					else begin						//没有接收到指定长度的数据则继续接收
						data_cnt <= data_cnt + 1'd1;
						flow_cnt <= flow_cnt;								
					end				
				end
				else begin							//一个BYTE数据接收未完成
						data_cnt <= data_cnt;
						flow_cnt <= flow_cnt;								
				end				
			end
			'd8:begin								//停留在这个状态
					flow_cnt <= flow_cnt;
			end				
			default:;
		endcase
	end
end

endmodule

        读数据顶层模块spi_read代码如下:

//FLASH读取数据顶层模块
module spi_read
#(
	parameter	BYTE_MAX 	= 8'd10			,		//一共读取多少个BYTE的数据
				SECTOR_ADDR = 8'b0000_0000	,		//扇区地址
				PAGE_ADDR   = 8'b0000_0000	,		//页地址
				BYTE_ADDR   = 8'b0000_0000			//字节地址
)
(
// 系统接口
	input	sys_clk		,			//全局时钟50MHz
	input	sys_rst_n	,   		//复位信号,低电平有效
// SPI物理接口								
	input	spi_miso	,   		//SPI串行输入,用来接收从机的数据
	output	spi_sclk	,   		//SPI时钟
	output	spi_cs    	,   		//SPI片选信号,低电平有效
	output	spi_mosi	    		//SPI输出,用来给从机发送数据   
);	
	
wire			spi_start	;		//发送传输开始信号,一个高电平
wire			spi_end		;   	//发送传输结束信号,一个高电平
wire	[7:0]  	data_send   ;   	//要发送的数据
wire	[7:0]  	data_rec   	;   	//接收到的数据
wire         	send_done	;   	//主机发送一个字节完毕标志
wire         	rec_done	;   	//主机接收一个字节完毕标志

//------------<例化模块>----------------------------------------------------------------
//读数据控制模块
spi_read_ctrl
#(
	.BYTE_MAX		(BYTE_MAX		),
	.SECTOR_ADDR	(SECTOR_ADDR	),
	.PAGE_ADDR		(PAGE_ADDR		),
	.BYTE_ADDR		(BYTE_ADDR		)
)	
spi_read_ctrl_inst(
    .sys_clk		(sys_clk	), 			
    .sys_rst_n		(sys_rst_n	), 			

	.send_done		(send_done	), 			
    .spi_start		(spi_start	),			
    .spi_end		(spi_end	),			
    .data_send    	(data_send	),			
    .data_rec    	(data_rec	),			
    .rec_done    	(rec_done	)			
);
//SPI驱动
spi_drive	spi_drive_inst(
	.sys_clk		(sys_clk	), 			
	.sys_rst_n		(sys_rst_n	), 			
		
	.spi_start		(spi_start	), 			
	.spi_end		(spi_end	), 			
	.data_send		(data_send	), 			
	.data_rec  		(data_rec	), 			
	.send_done		(send_done	), 			
	.rec_done		(rec_done	), 			
				
	.spi_miso		(spi_miso	), 			
	.spi_sclk		(spi_sclk	), 			
	.spi_cs    		(spi_cs		), 			
	.spi_mosi		(spi_mosi	)			
);

endmodule

2.2.3、Testbench及仿真结果

        Testbench比较简单直接例化读数据模块和仿真模型m25p16即可,同时让命令窗口打印读取到的数据。

//------------------------------------------------
//--SPI驱动仿真--读数据仿真
//------------------------------------------------
`timescale 1ns/1ns		//时间单位/精度

//------------<模块及端口声明>----------------------------------------
module tb_spi_read();

reg		sys_clk		;
reg		sys_rst_n	;
					
wire	spi_miso	;
wire	spi_sclk	;
wire	spi_cs    	;
wire	spi_mosi	;

parameter	BYTE_MAX 	= 8'd10			,		//一共读取多少个BYTE的数据
			SECTOR_ADDR = 8'b0000_0000	,		//扇区地址
			PAGE_ADDR   = 8'b0000_0000	,		//页地址
			BYTE_ADDR   = 8'b0000_0000	;		//字节地址
//------------<例化被测试模块>----------------------------------------
//读数据模块
spi_read	
#(
	.BYTE_MAX		(BYTE_MAX		),
	.SECTOR_ADDR	(SECTOR_ADDR	),
	.PAGE_ADDR		(PAGE_ADDR		),
	.BYTE_ADDR		(BYTE_ADDR		)
)
spi_read_inst(
	.sys_clk	(sys_clk	),
	.sys_rst_n	(sys_rst_n	),

	.spi_miso	(spi_miso	),
	.spi_sclk	(spi_sclk	),
	.spi_cs    	(spi_cs		),
	.spi_mosi	(spi_mosi	)
);
//m25p16仿真模型
m25p16  memory (
    .c          (spi_sclk	), 
    .data_in    (spi_mosi   ), 
    .s          (spi_cs   	), 
    .w          (1'b1		), 
    .hold       (1'b1   	), 
    .data_out   (spi_miso   )
);	

//------------<设置初始测试条件>----------------------------------------
initial begin
	sys_clk = 1'b0;					//初始时钟为0
	sys_rst_n <= 1'b0;				//初始复位
	#20								//20个时钟周期后
	sys_rst_n <= 1'b1;				//拉高复位,系统进入工作状态	
end

//打印数据
always@(*)begin
	if(spi_read_inst.rec_done && spi_read_inst.spi_read_ctrl_inst.flow_cnt == 'd7)
		$display("READ	:%h",spi_read_inst.data_rec);		//打印读取的数据
end

//重定义初始化数值
defparam memory.mem_access.initfile = "initM25P16_test.txt";	//其中的每页数据是从00累加到FF	

//------------<设置时钟>----------------------------------------------
always #10 sys_clk = ~sys_clk;		//系统时钟周期20ns

endmodule

        需要注意的是:

  • 地址设置为24‘b0,这样比较好操作一点
  • 读取数据长度设定为10
  • 仿真模型在仿真进行前会载入文件initM25P16_test中的数据作为初值,而该文件将m25p16的每页的256个字节的数据从00累加到FF,也就是说扇区00的页00的字节00的数据为00,扇区00的页00的字节33的数据为33

        所以我们仿真的预期结果应该是读取的数据结果为00~09(共10个数据),仿真结果如下:

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

        命令窗口打印如下:

 FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

可以看到读取的数据分别为0x00~0x09,与初始化的数据一致。

2.2.4、上板验证

        使用使用一块Cyclone IV E的开发板上板验证,该开发板板载了一个M25P16芯片作为上电后读取程序的FLASH。需要注意的是,该FLASH的管脚需要从专用下载管脚,配置成普通的IO管脚,如下:

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

        首先验证写模块:从地址24’d0开始分别写入10个数据:0x00~0x12。使用Signal Tap II抓取的波形如下:

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」  

        可以看到抓取的波形与仿真波形一致。

        接下来使用读数据模块从地址 24’d0开始分别读取数据11次,比较读取的数据与写入的数据是否一致(第十一次用来对比)。使用Signal Tap II抓取的波形如下:

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

         可以前10个读取的数据分别为0x00~0x12,第11个数据因为前面没有写,所以是默认的0xFF,符合预期结果。

2.3、扇区擦除(Sector Erase)

2.3.1、时序

        扇区擦除(Sector Erase)操作,简称 SE,操作指令为 8’b1101_0000(D8h),扇区擦除指令是将 Flash 芯片中的被选中扇区的所有存储单元设置为全 1,在 Flash 芯片写入扇区擦出指令之前,需要先写入写使能 (WREN)指令;随后要拉低片选信号,写入扇区擦除指令、扇区地址、页地址和字节地址,在指令、地址写入过程中,片选信号始终保持低电平,待指令、地址被芯片锁存后,将片选信号拉高;扇区擦除指令、地址被锁存并执行后,需要等待一个完整的扇区擦除周期(tSE),才能完成 Flash 芯片的扇区擦除操作。时序图如下:

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

2.3.2、Verilog代码

        Verilog代码分为3个模块:SPI驱动模块spi_drive、SPI读数据控制模块spi_read_ctrl和例化前面两个子模块的读数据顶层模块spi_read。

  • SPI驱动模块spi_drive:提供SPI模式0的读写驱动,具体可参见:  FPGA实现的SPI协议(一)—-SPI驱动
  • SPI扇区擦除控制模块spi_sector_erase_ctrl:该模块使用一段式状态机编写,功能就是调用SPI驱动模块,发送扇区擦除指令,然后发送扇区地址+页地址+字节地址
  • SPI扇区擦除顶层模块spi_sector_erase:例化前面两个子模块

        SPI扇区擦除控制模块spi_sector_erase_ctrl代码如下:

//SPI扇区擦除控制模块
`timescale 1ns/1ns		//时间单位/精度
module spi_sector_erase_ctrl
#(
	parameter 	SECTOR_ADDR = 8'b0000_0000, 		//扇区地址
	parameter	PAGE_ADDR   = 8'b0000_0000,			//页地址
	parameter	BYTE_ADDR   = 8'b0000_0000			//字节地址
)
(
    input               sys_clk		, 				// 全局时钟50MHz
    input               sys_rst_n	, 				// 复位信号,低电平有效
		
    input  		        send_done	, 				// 主机发送一个字节完毕标志位
    output  reg         spi_start	,				// 发送传输开始信号,一个高电平
    output  reg        	spi_end		,				// 发送传输结束信号,一个高电平
    output  reg  [7:0]  data_send    				// 要发送的数据         
);

//指令定义
localparam 	WR_EN 		 = 8'b0000_0110, 			//写使能指令	
			SECTOR_ERASE = 8'b1101_1000;			//扇区擦除指令
				
//reg define		
reg	[7:0]	flow_cnt;								//状态跳转计数器
reg	[7:0]	cnt_wait;								//上电等待计数器

always@(posedge sys_clk or negedge sys_rst_n)begin
	if(!sys_rst_n)begin
		data_send <= 8'd0;
		spi_start <= 1'b0;	
		spi_end <= 1'b0;	
		flow_cnt <= 1'd0;
		cnt_wait <= 8'd0;
	end
	else begin
		spi_start <= 1'b0;							//便于生成脉冲信号
		spi_end <= 1'b0;                            //便于生成脉冲信号
		case(flow_cnt)
			'd0:begin
				if(cnt_wait == 100)begin			//上电后等待稳定
					cnt_wait <= 8'd0;
					flow_cnt <= flow_cnt + 1'd1;
				end
				else begin
					cnt_wait <= cnt_wait + 1'd1;
					flow_cnt <= flow_cnt;								
				end
			end
			'd1:begin									
				data_send <= WR_EN;					//数据为写使能指令
				spi_start <= 1'b1;					//拉高spi开始通讯信号
				flow_cnt <= flow_cnt + 1'd1;
			end
			'd2:begin
				if(send_done)begin					//主机一个字节数据被发送完成
					flow_cnt <= flow_cnt + 1'd1;
					spi_end <= 1'b1;				//结束第1次SPI通信
				end
				else
					flow_cnt <= flow_cnt;
			end	
			'd3:begin
				if(cnt_wait == 10)begin				//等待200ns,两次命令的间隔时间
					cnt_wait <= 8'd0;				//等待计数器清零
					flow_cnt <= flow_cnt + 1'd1;
				end
				else begin
					cnt_wait <= cnt_wait + 1'd1;
					flow_cnt <= flow_cnt;								
				end
			end
			'd4:begin									
				data_send <= SECTOR_ERASE;          //扇区擦除指令
				spi_start <= 1'b1;					//拉高spi开始通讯信号
				flow_cnt <= flow_cnt + 1'd1;
			end				
			'd5:begin								//发送扇区地址
				if(send_done)begin					//指令被发送完成
					flow_cnt <= flow_cnt + 1'd1;
					data_send <= SECTOR_ADDR;		//数据为扇区地址
				end
				else begin
					flow_cnt <= flow_cnt;
					data_send <= data_send;							
				end
			end	
			'd6:begin								//发送页地址
				if(send_done)begin					//发送完成
					flow_cnt <= flow_cnt + 1'd1;
					data_send <= PAGE_ADDR;			//数据为页地址地址
				end
				else begin
					flow_cnt <= flow_cnt;
					data_send <= data_send;							
				end
			end	
			'd7:begin								//发送字节地址
				if(send_done)begin					//指令被发送完成
					flow_cnt <= flow_cnt + 1'd1;
					data_send <= BYTE_ADDR;			//数据为字节地址
				end
				else begin
					flow_cnt <= flow_cnt;
					data_send <= data_send;							
				end
			end				
			'd8:begin
				if(send_done)begin					//主机一个字节数据被发送完成
					flow_cnt <= flow_cnt + 1'd1;
					spi_end <= 1'b1;				//结束第1次SPI通信
				end
				else
					flow_cnt <= flow_cnt;
			end					
			'd9:begin								//停留在这个状态
					flow_cnt <= flow_cnt;
			end				
			default:;
		endcase
	end
end

endmodule

        SPI扇区擦除顶层模块spi_sector_erase代码如下:

`timescale 1ns/1ns		//时间单位/精度
//扇区擦除
module spi_sector_erase(
// 系统接口
	input	sys_clk		,						//全局时钟50MHz
	input	sys_rst_n	,   					//复位信号,低电平有效
// SPI物理接口											
	input	spi_miso	,   					//SPI串行输入,用来接收从机的数据
	output	spi_sclk	,   					//SPI时钟
	output	spi_cs    	,   					//SPI片选信号,低电平有效
	output	spi_mosi	    					//SPI输出,用来给从机发送数据   
);
parameter 	SECTOR_ADDR = 8'b0000_0000; 		//扇区地址
parameter	PAGE_ADDR   = 8'b0000_0000;			//页地址
parameter	BYTE_ADDR   = 8'b0000_1000;			//字节地址

wire			spi_start	;					//发送传输开始信号,一个高电平
wire			spi_end		;   				//发送传输结束信号,一个高电平
wire	[7:0]  	data_send   ;   				//要发送的数据
wire	[7:0]  	data_rec   	;   				//接收到的数据
wire         	send_done	;   				//主机发送一个字节完毕标志
wire         	rec_done	;   				//主机接收一个字节完毕标志

//------------<例化模块>----------------------------------------------------------------
//扇区擦除模块
spi_sector_erase_ctrl
#(
	.SECTOR_ADDR 	(SECTOR_ADDR),
	.PAGE_ADDR   	(PAGE_ADDR  ),
    .BYTE_ADDR   	(BYTE_ADDR  )
)	
spi_sector_erase_ctrl_inst
(
    .sys_clk		(sys_clk	), 			
    .sys_rst_n		(sys_rst_n	), 			

	.send_done		(send_done	), 			
    .spi_start		(spi_start	),			
    .spi_end		(spi_end	),			
    .data_send    	(data_send	)						
);
//SPI驱动
spi_drive	spi_drive_inst(
	.sys_clk		(sys_clk	), 			
	.sys_rst_n		(sys_rst_n	), 			
		
	.spi_start		(spi_start	), 			
	.spi_end		(spi_end	), 			
	.data_send		(data_send	), 			
	.data_rec  		(data_rec	), 			
	.send_done		(send_done	), 			
	.rec_done		(rec_done	), 			
				
	.spi_miso		(spi_miso	), 			
	.spi_sclk		(spi_sclk	), 			
	.spi_cs    		(spi_cs		), 			
	.spi_mosi		(spi_mosi	)			
);

endmodule

2.3.3、Testbench及仿真结果

        Testbench比较简单直接例化扇区擦除模块和仿真模型m25p16即可。需要注意的是m25p16的扇区擦除需要等待的时间较长(3s),为了尽快完成仿真,我把这个等待参数改成了1s。

//------------------------------------------------
//--SPI驱动仿真--扇区擦除仿真
//------------------------------------------------
`timescale 1ns/1ns		//时间单位/精度

//------------<模块及端口声明>----------------------------------------
module tb_spi_sector_erase();

reg		sys_clk		;
reg		sys_rst_n	;
					
wire	spi_miso	;
wire	spi_sclk	;
wire	spi_cs    	;
wire	spi_mosi	;

//------------<例化被测试模块>----------------------------------------
//扇区擦除模块
spi_sector_erase	spi_sector_erase_inst(
	.sys_clk	(sys_clk	),
	.sys_rst_n	(sys_rst_n	),

	.spi_miso	(spi_miso	),
	.spi_sclk	(spi_sclk	),
	.spi_cs    	(spi_cs		),
	.spi_mosi	(spi_mosi	)
);
//m25p16仿真模型
m25p16  memory (
    .c          (spi_sclk	), 
    .data_in    (spi_mosi   ), 
    .s          (spi_cs   	), 
    .w          (1'b1		), 
    .hold       (1'b1   	), 
    .data_out   (spi_miso   )
);	

//------------<设置初始测试条件>----------------------------------------
initial begin
	sys_clk = 1'b0;					//初始时钟为0
	sys_rst_n <= 1'b0;				//初始复位
	#20								//20个时钟周期后
	sys_rst_n <= 1'b1;				//拉高复位,系统进入工作状态	
end

//重定义初始化数值
defparam memory.mem_access.initfile = "initM25P16_test.txt";	//其中的数据是从0累加	
//------------<设置时钟>----------------------------------------------
always #10 sys_clk = ~sys_clk;		//系统时钟周期20ns

endmodule

        仿真结果如下:

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

        命令窗口打印内容如下(单位:ps):约5us处开始进行扇区擦除操作,1s后扇区擦除操作完成。与预期结果一致。

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

2.3.4、上板验证

        首先使用扇区擦除模块24‘b0000_0000_0000_0000_0000_1000,实际上就是擦除扇区0,和后面的页地址和字节地址没有关系。在2.2节做页写操作的验证时,我们给地址扇区0的页0的地址0x00~0x0a分别写入了数据0x00、0x02、···、0x12,我们只要再使用读数据模块对这10个地址读取一遍,根据读出的内容就可以判断扇区擦除操作是否成功。

        使用signal tap对读数据操作抓取的波形如下:可以看到连续读取的数据均为0XFF,说明扇区擦除操作成功。

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

2.4、全擦除(Bulk Erase)

2.4.1、时序

        全擦除(Bulk Erase)操作,简称 BE,操作指令为 8’b1100_0111(C7h),全擦除指令是将 Flash 芯片中的所有存储单元设 置为全 1,在 Flash 芯片写入全擦出指令之前,需要先写入写使能(WREN)指令;随后要拉低片选信号,写入全擦除指令,在指令写入过程中,片选信号始终保持低电平,待指令被芯片锁存后,将片选信号拉高;全擦除指令被锁存并执行后,需要等待一个完整的全擦除周期(tBE),才能完成 Flash 芯片的全擦除操作。时序图如下:

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

2.4.2、Verilog代码

        Verilog代码分为3个模块:SPI驱动模块spi_drive、SPI读数据控制模块spi_read_ctrl和例化前面两个子模块的读数据顶层模块spi_read。

  • SPI驱动模块spi_drive:提供SPI模式0的读写驱动,具体可参见:  FPGA实现的SPI协议(一)—-SPI驱动
  • SPI全擦除控制模块spi_bulk_erase_ctrl:该模块使用一段式状态机编写,功能就是调用SPI驱动模块,发送全擦除指令
  • SPI全擦除顶层模块spi_bulk_erase:例化前面两个子模块

        SPI全擦除控制模块spi_bulk_erase_ctrl代码如下:

//全擦除指令控制模块
module spi_bulk_erase_ctrl
(
    input               sys_clk		, 			// 全局时钟50MHz
    input               sys_rst_n	, 			// 复位信号,低电平有效
	
	input         		send_done	, 			// 主机发送一个字节完毕标志位      
    output  reg         spi_start	,			// 发送传输开始信号,一个高电平
    output  reg        	spi_end		,			// 发送传输结束信号,一个高电平
    output  reg  [7:0]  data_send    			// 要发送的数据         
);

//指令定义
parameter 	WR_EN 		= 8'b0000_0110, 			//写使能指令
			BULK_ERASE 	= 8'b1100_0111, 			//全擦除指令
			READ 		= 8'h0000_0011;				//读数据指令
			
//reg define	
reg	[7:0]	flow_cnt;							//状态跳转计数器
reg	[31:0]	cnt_wait;							//等待计数器

always@(posedge sys_clk or negedge sys_rst_n)begin
	if(!sys_rst_n)begin
		data_send <= 8'd0;
		spi_start <= 1'b0;	
		flow_cnt <= 1'd0;
		cnt_wait <= 'd0;
	end
	else begin
		spi_start <= 1'b0;							//便于生成脉冲信号
		spi_end <= 1'b0;                            //便于生成脉冲信号
		case(flow_cnt)
			'd0:begin
				if(cnt_wait == 100)begin			//上电后等待稳定
					cnt_wait <= 8'd0;
					flow_cnt <= flow_cnt + 1'd1;
				end
				else begin
					cnt_wait <= cnt_wait + 1'd1;
					flow_cnt <= flow_cnt;								
				end
			end
			'd1:begin									
				data_send <= WR_EN;					//数据为写使能指令
				spi_start <= 1'b1;					//拉高spi开始通讯信号
				flow_cnt <= flow_cnt + 1'd1;
			end	
			'd2:begin
				if(send_done)begin					//主机一个字节数据被发送完成
					flow_cnt <= flow_cnt + 1'd1;
					spi_end <= 1'b1;				//结束第1次SPI通信
				end
				else
					flow_cnt <= flow_cnt;
			end	
			'd3:begin
				if(cnt_wait == 10)begin				//等待200ns,两次命令的间隔时间
					cnt_wait <= 8'd0;				//等待计数器清零
					flow_cnt <= flow_cnt + 1'd1;
				end
				else begin
					cnt_wait <= cnt_wait + 1'd1;
					flow_cnt <= flow_cnt;								
				end
			end
			'd4:begin									
				data_send <= BULK_ERASE;            //全擦除指令
				spi_start <= 1'b1;					//拉高spi开始通讯信号
				flow_cnt <= flow_cnt + 1'd1;
			end	
			'd5:begin
				if(send_done)begin					//主机一个字节数据被发送完成
					flow_cnt <= flow_cnt + 1'd1;
					spi_end <= 1'b1;				//结束第2次SPI通信
				end
				else
					flow_cnt <= flow_cnt;
			end					
			'd6:begin								//停留在这个状态
					flow_cnt <= flow_cnt;
			end				
			default:;
		endcase
	end
end

endmodule

         SPI全擦除顶层模块spi_bulk_erase代码如下:

//全擦除指令模块
module spi_bulk_erase(
// 系统接口
	input	sys_clk		,		//全局时钟50MHz
	input	sys_rst_n	,   	//复位信号,低电平有效
// SPI物理接口							
	input	spi_miso	,   	//SPI串行输入,用来接收从机的数据
	output	spi_sclk	,   	//SPI时钟
	output	spi_cs    	,   	//SPI片选信号,低电平有效
	output	spi_mosi	    	//SPI输出,用来给从机发送数据   
);

wire			spi_start	;	//发送传输开始信号,一个高电平
wire			spi_end		;   //发送传输结束信号,一个高电平
wire	[7:0]  	data_send   ;   //要发送的数据
wire         	send_done	;   //主机发送一个字节完毕标志

//------------<例化模块>----------------------------------------------------------------
//全擦除控制模块
spi_bulk_erase_ctrl	spi_bulk_erase_ctrl_inst
(
    .sys_clk		(sys_clk	), 			
    .sys_rst_n		(sys_rst_n	), 			

	.send_done		(send_done	), 			
    .spi_start		(spi_start	),			
    .spi_end		(spi_end	),			
    .data_send    	(data_send	)			
);
//SPI驱动
spi_drive	spi_drive_inst(
	.sys_clk		(sys_clk	), 			
	.sys_rst_n		(sys_rst_n	), 			
		
	.spi_start		(spi_start	), 			
	.spi_end		(spi_end	), 			
	.data_send		(data_send	), 			
	.data_rec  		(			), 			
	.send_done		(send_done	), 			
	.rec_done		(			), 			
				
	.spi_miso		(spi_miso	), 			
	.spi_sclk		(spi_sclk	), 			
	.spi_cs    		(spi_cs		), 			
	.spi_mosi		(spi_mosi	)			
);

endmodule

2.4.3、Testbench及仿真结果

        Testbench比较简单直接例化全擦除模块和仿真模型m25p16即可。需要注意的是m25p16的全擦除需要等待的时间较长(40s),为了尽快完成仿真,我把这个等待参数改成了1s。

//------------------------------------------------
//--SPI驱动仿真--全擦除仿真
//------------------------------------------------
`timescale 1ns/1ns		//时间单位/精度

//------------<模块及端口声明>----------------------------------------
module tb_spi_bulk_erase();

reg		sys_clk		;
reg		sys_rst_n	;
					
wire	spi_miso	;
wire	spi_sclk	;
wire	spi_cs    	;
wire	spi_mosi	;

//------------<例化被测试模块>----------------------------------------
//全擦除模块
spi_bulk_erase	spi_bulk_erase_inst(
	.sys_clk	(sys_clk	),
	.sys_rst_n	(sys_rst_n	),

	.spi_miso	(spi_miso	),
	.spi_sclk	(spi_sclk	),
	.spi_cs    	(spi_cs		),
	.spi_mosi	(spi_mosi	)
);
//m25p16仿真模型
m25p16  memory (
    .c          (spi_sclk	), 
    .data_in    (spi_mosi   ), 
    .s          (spi_cs   	), 
    .w          (1'b1		), 
    .hold       (1'b1   	), 
    .data_out   (spi_miso   )
);	

//------------<设置初始测试条件>----------------------------------------
initial begin
	sys_clk = 1'b0;					//初始时钟为0
	sys_rst_n <= 1'b0;				//初始复位
	#20								//20个时钟周期后
	sys_rst_n <= 1'b1;				//拉高复位,系统进入工作状态	
end

	
//------------<设置时钟>----------------------------------------------
always #10 sys_clk = ~sys_clk;		//系统时钟周期20ns

endmodule

        仿真结果如下:

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

        命令窗口打印内容如下(单位:ps):约3us处开始进行扇区擦除操作,1s后扇区擦除操作完成。与预期结果一致。 

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

2.4.4、上板验证

        我们首先使用写模块往区域1(扇区0x00页0x00地址0x00~0x09)写入10个数据(从0x00开始累加2),然后往区域2(扇区0x10页0x10地址0x10~0x19)写入10个数据(从0x00开始累加4)。

        接着调用全擦除模块,接着再使用读数据模块读取这两个区域的数据,根据读出的数据来验证数据是否被全部擦除了。

        区域1写数据波形图(依次写入0x00、0x02““0x12): 

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

        区域2写数据波形图(依次写入0x00、0x04““0x24): 

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

         全擦除模块波形图:与仿真波形图一致。 

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

        接着调用读数据模块读取区域1的数据:读取的数据全部为0xFF,说明数据被擦除了。

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

        接着调用读数据模块读取区域2的数据:读取的数据全部为0xFF,说明数据被擦除了。

FPGA实现的SPI协议(二)----基于SPI接口的FLASH芯片M25P16的使用「建议收藏」

        以上就证明我们的全擦除模块是成功擦除了所有扇区。

3、其他

  • 还有一些其他指令,如读ID,读写状态寄存器就不列出来了,参考上述模块应该很容易就改出来了
  • 想要整个工程的朋友可以在评论区留下邮箱 

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/181578.html原文链接:https://javaforall.cn

【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛

【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...

(0)


相关推荐

发表回复

您的电子邮箱地址不会被公开。

关注全栈程序员社区公众号