STM32F407+ESP8266+SD卡进行远程固件升级

这次带来的内容是STM32F407+ESP8266+SD卡进行远程固件升级,相信各位小伙伴在第一次接触到远程固件更新的时候都会很懵逼。没错,我也是,但是在经过了我一段时间的研究之后,终于弄完了这一部分,现在把它分享出来,给各位一些参考。

先说实现的流程,单片机通过串口与8266连接,然后8266通过MQTT与服务器连接,进行网络通信,手机app与服务器连接,对单片机下发一些指令。整个升级流程就是手机app先检测服务器对应目录下是否存在文件,如果存在则说明需要对单片机进行固件升级,此时app发送固件升级指令给8266,8266收到指令后通过串口发送给单片机,单片机收到后就开始解析指令并开启固件升级任务,然后再通过串口告诉8266发送GET请求,开始去对应的位置拿取固件,拿到后就通过串口将数据发送给单片机,单片机拿完数据并校验OK,就会将固件存入SD卡,然后重启进入bootloader进行升级操作。
画了一个草图:
草图
大概的流程就是这个样子的,后面详细介绍。

ESP8266与本地服务器部分

这次采用的固件是自己依据官方SDK进行开发的,因为公司产品的一些原因,不能把全部工程全部放出来,不过会放出一些会用到的部分,esp8266的工程留在末尾说明。esp8266这边,是采用MQTT方式和服务器进行连接,手机就可以通过app发送指令控制单片机了。8266主要作用就是去服务器拿取固件,在测试的时候,我们先搭建一个本地的服务器。
首先搭建本地服务器,我是使用的Tomcat这个东东,简单又好用。
tomcat软件是apache旗下的一个开源项目。软件下载链接:tomcat下载
打开链接,进入下载,如下页面:
tomcat下载
选择适合自己的版本,一步一步点,直到下载完毕,然后解压。
解压之后,在使用之前你必须确保你的电脑上有java环境,不然会跑不起来。
JAVA环境的配置可参考这篇文章:JAVA环境配置
下载好的
需要注意的是目录中不能有中文和空格哦,目录介绍如下:

bin:二进制执行文件。里面最常用的文件是startup.bat
conf:配置目录。里面最核心的文件是server.xml。可以在里面改端口号等。默认端口号是8080,也就是说,此端口号不能被其他应用程序占用。
lib:库文件。tomcat运行时需要的jar包所在的目录
logs:日志
temp:临时产生的文件,即缓存
webapps:web的应用程序。放在这个目录下的文件,可以通过网络直接访问到,我们的固件,就是放在这个文件夹下面的哦。
work:编译以后的class文件。
现在,打开bin文件夹,找到startup.bat文件,双击即可开启服务。如果一闪而退,那就用notepad++或者其他什么软件打开这个文件,在末尾添加“pause”来查看出什么什么问题,如下:
不运行
保存后,再双击运行,就会提示你错误原因了,对照度娘,即可解决。
服务器搭建完毕,就开始其他部分了。

STM32固件升级原理

这部分介绍stm32的固件升级原理及实现过程。
stm32固件自更新我们称为IAP,IAP是In Application Programming的缩写,即用户自己的程序在运行过程中对User Flash的部分区域进行烧写,目的是为了在产品发布后可以方便地通过预留的通信口对产品中的固件程序进行更新升级。要实现固件的自更新,就得满足一个条件,就是单片机内还需要一部分代码来提供更新支持,我们把这部分代码称为bootloader,它的主要作用就是开机引导,引导系统是进入应用程序部分还是进入更新固件部分。我们把应用程序部分称为app部分。所以,支持IAP固件更新stm32的代码结构应该为:
IAP
所以,单片机的flash部分也就被分成了两段,一段用来存放bootloader,另一段用来存放app,我们打开一个keil工程:
引导

APP程序段的工程配置

上图可以看到,f407单片机的起始地址是从0x8000000开始的,既然要分为两段flash区域,就必定要修改app部分的起始地址,而起始地址一旦被修改,单片机上电之后就无法映射中断向量表了,所以我们还需要修改app代码部分的偏移地址,以映射中断向量表。
玩单片机的都知道单片机上电后,首先进入复位中断 Reset_Handler,即汇编文件的复位中断处理函数处,然后开始初始化sp指针和中断向量表,然后进入初始化函数:
过程
初始化函数 void SystemInit(void) 位于system_stm32f4xx.c文件下,其修改中断向量表的代码如下所示:
初始化
第452行代码: SCB->VTOR = SRAM_BASE | VECT_TAB_OFFSET; 通过看后面注释可知,该行是用于app程序从SRAM启动时,来设置中断向量表的。
第454行代码: SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET;通过看后面注释可知,该行是用于app程序从FLASH启动时,来设置中断向量表的。
由于我的程序都是从FLASH启动的,就主讲FLASH启动这块,SRAM启动这块和FLASH启动是可以互相参考的。
我们看到第454行中有两个变量,FLASH_BASEVECT_TAB_OFFSET,我们跳转到它们定义的地方,可以看到:
#define FLASH_BASE ((uint32_t)0x08000000)
#define VECT_TAB_OFFSET 0x00

可以看到,这两个变量都是宏定义的,其中FLASH_BASE刚好对应STM32 M4的程序起始地址0x08000000;而VECT_TAB_OFFSET对应的值是0,这是因为app段是从0x8000000开始的,所以未设置偏移,我们现在只需要修改它的值为为bootloader段预留的flash空间大小即可。
然后,在我们的app程序里面还需要修改一个地方,就是设置,我们点击keil软件的魔术棒,进入option设置,如下图:
软件设置
我们需要修改的地方就是上图两个蓝框中的值。
第一个Start的值:在未设置bootloader的时候,它的值为 0x8000000,现在设置了bootloader,它的值变为了 0x8010000,为什么呢? 讲解一下, 0x8010000 - 0x8000000 = 0x10000,换算成大小,就是64kb,也就是说我为bootloader段预留了64kb的flash空间,用来存放bootloader段的代码,结合上面讲解的VECT_TAB_OFFSET,就能理解修改中断向量表这一块了。
第二个Size的值:查看F407ZET6的芯片手册可知,该芯片的flash大小为512kb,故size的原始值应为0x80000,由于bootloader段占用了64kb,所以Size的当前值应为0x80000 - 0x10000 = 0x70000。
改完这两个地方,IAP就已经设置好了。我们只需要对代码进行编译和链接,就可以修改原本的地址了,编译之后,我们打开生成map查看链接地址,首先找到工程对应的map文件,我的在如下路径:
map
打开这个文件,用keil或其他工具打开都行,打开后,我们往下拉,找到它的链接地址的复位地址的位置,如下所示:
map地址
从上图可以看到,其地址已被修改为我们设定的地址。我之前刚弄这部分时,在网上找资料。有人说map设置页面也需要调整,如下所示:
map
有人说红色箭头所指的复选框需要选中,而我的工程没有选它是对的,选中了就有问题,无法连接到指定位置。我也不知道什么原因,各位大佬可以指点一下。还有就是关于sct文件的配置,我的因为加入了外部SRAM所以会和大家的不太一样,如下:

SCT
我的APP部分就是这样子配置的,所以app这部分的设置就已经完成了。接下来讲解bootloader段的内容了。

BootLoader段程序配置

由之前的内容可知,bootloader段的大小被我们设置成了64kb,打开我的bootloader程序,打开keil的配置面板,如下所示:
bootloader
如上图所示,因为单片机上电,就会先调用bootloader段的代码,所以bootloader的 Start 地址为单片机的运行地址0x8000000;而关于 Size 的大小,是0x80000,有些朋友就会提出疑惑,这个大小不应该为bootloader段的大小64kb吗,为什么还是512kb呢,其实这个是无所谓的,可以修改也可以不修改,因为从0x8010000段开始就会被APP段的内容覆盖了。bootloader段需要修改的,就只有这个地方了。
关于keil的IAP配置就弄好了,接下来就开始讲解bootloader的写法了。
bootloader其实就只有两个功能,一个是引导程序跳转到APP段,另一个就是引导程序进入固件升级部分。我们一个一个讲解。

先说如何判断应该往哪里跳转的问题:

单片机上电后,进入bootloader段,此时需要进行判断,即判断是否需要更新固件。可从两方面入手,第一就是判断APP段的FLASH地址的内容是否全为0xFFFFFFFF,如果为0xFFFFFFFF则表示APP段的FLASH地址的内容是空的,APP段没有内容,要进行固件更新。
第二就是判断SD卡里面存放的关于更新的配置文件,比如我们在APP段设置了需要更新就置该配置文件的值为1,然后保存。进入bootloader段之后,就读取该配置文件的值,如果为1,就需要进入更新。如下所示:

int main(void){
	
	u8 i;
	u8 err_code_sd;
	u32 flashchack;      
	
	FSMC_SRAM_Init();
	my_mem_init(SRAMEX);
	
	while(SD_Init()){
		i++;
		if(i > 5){
			NVIC_SystemReset();
		}
	}
  exfuns_init();							    //为fatfs相关变量申请内存	
	f_mount(fs[0],"0:",1); 					//挂载SD卡 

	err_code_sd = mf_open("0:updata.ini",FA_READ | FA_WRITE);
	if(err_code_sd == 0){
		mf_read(1);	
	}
	mf_close();
	err_code_sd = mf_open("0:robot.bin",FA_OPEN_EXISTING);
	mf_close();
	flashchack = STMFLASH_ReadWord(FLASH_APP1_ADDR);
	
	while(1){
		
		if((*fatbuf == 1 && err_code_sd == 0) || flashchack == 0xFFFFFFFF){      //需更新
			updata_firmware();
		}
		else{                                                                    //无需更新
			myfree(SRAMEX,fatbuf);
			iap_load_app(FLASH_APP1_ADDR);
		}
	}
}

1、引导程序跳转到APP段
从上面代码可以看到,跳转app段用到的函数为 iap_load_app(FLASH_APP1_ADDR); 该函数主要的实现过程大体就是重置栈顶指针,强制跳转app的reset复位中断。

if(((*(vu32*)appxaddr)&0x2FFE0000) == 0x20000000){	          //检查栈顶地址是否合法.0x20000000
		jump2app = (iapfun) * (vu32*)(appxaddr + 4);		  //用户代码区第二个字为程序开始地址(复位地址)		
		MSR_MSP(*(vu32*)appxaddr);					          //初始化APP堆栈指针(用户代码区的第一个字用于存放栈顶地址)
		jump2app();									          //跳转到APP.
}

主要由上面几行代码实现该过程。
第一行的判段语句主要是判断栈顶地址的合法性,可以这样理解:
在程序里#define FLASH_APP1_ADDR 0x08010000 //第一个应用程序起始地址(存放在FLASH)
定义了app段的起始地址,即取0x8010000开始到0x8010003 的4个字节的值, 因为我们的应用程序APP中设置把中断向量表放置在0x08010000开始的位置,而中断向量表里第一个放的就是栈顶地址的值,也就是说,这句话即通过判断栈顶地址值是否正确(是否在0x2000 0000 - 0x 2000 2000之间) 来判断是否应用程序已经下载了,因为应用程序的启动文件刚开始就去初始化化栈空间,如果栈顶值对了,就说明应用程已经下载了,而且启动文件的初始化也执行了。

第二行是设置app程序的开始地址,这样理解:
我们定义了typedef void (*iapfun)(void); //定义一个函数类型的参数.
这是一个函数类型的参数,是声明一个函数指针,加上一个typedef 之后iapfun只不过是类型 void (*)(void) 的一个别名。appxaddr + 4 即为0x08010004 ,里面放的是中断向量表的第二项“复位地址”。此时就能拿到app程序的开始地址。

第三行是用来初始化APP堆栈指针,MSR_MSP函数的现过程如下:

//设置栈顶地址
//addr:栈顶地址
__asm void MSR_MSP(u32 addr) 
{
	MSR MSP, r0 			//set Main Stack value
	BX r14
}

第四行jump2app();就如同第二行所说的,调用之后,直接跳转到了用户APP程序。

2、引导程序进入固件升级部分
经过前面的判断,bootloader进入了固件升级部分。固件升级主要涉及到的就是对FLASH的擦除和重新写入,F407的flash结构如下所示:
flash
我们需要保留前4页,从第4页开始擦除和写入,FLASH写入过程如下:
1、解锁
2、页擦除
3、写入
4、上锁
具体代码如下:

/***********************************************************************************************
**     name: STMFLASH_Write
** function: 从指定地址开始写入指定长度的数据 
             特别注意:因为STM32F4的扇区实在太大,没办法本地保存扇区数据,所以本函数
             写地址如果非0XFF,那么会先擦除整个扇区且不保存扇区数据.所以
             写非0XFF的地址,将导致整个扇区数据丢失.建议写之前确保扇区里
             没有重要数据,最好是整个扇区先擦除了,然后慢慢往后写.
             该函数对OTP区域也有效!可以用来写OTP区!  OTP区域地址范围:0X1FFF7800~0X1FFF7A0F
**parameter: WriteAddr:起始地址(此地址必须为4的倍数!!) 
             pBuffer:数据指针
             NumToWrite:字(32位)数(就是要写入的32位数据的个数.)
**   return: void
**     date: 2020/01/14
**   create: @曼珠沙华
************************************************************************************************/
void STMFLASH_Write(u32 WriteAddr,u32 *pBuffer,u32 NumToWrite){ 
	
  FLASH_Status status = FLASH_COMPLETE;
	u32 addrx = 0;
	u32 endaddr = 0;	
	
  if(WriteAddr < STM32_FLASH_BASE || WriteAddr % 4){
		return;	                                                                    //非法地址
	}
  FLASH_Unlock();									                                              //解锁 
  FLASH_DataCacheCmd(DISABLE);                                                  //FLASH擦除期间,必须禁止数据缓存
 		
	addrx = WriteAddr;				                                                    //写入的起始地址
	endaddr = WriteAddr + NumToWrite * 4;	                                        //写入的结束地址
	if(addrx < 0X1FFF0000){			                                                  //只有主存储区,才需要执行擦除操作!!
		while(addrx < endaddr){		                                                  //扫清一切障碍.(对非FFFFFFFF的地方,先擦除)
			if(STMFLASH_ReadWord(addrx) != 0XFFFFFFFF){                               //有非0XFFFFFFFF的地方,要擦除这个扇区 
				status = FLASH_EraseSector(STMFLASH_GetFlashSector(addrx),VoltageRange_3); //VCC=2.7~3.6V之间!!
				if(status != FLASH_COMPLETE){
					break;	                                                              //发生错误了
				}
			}
			else{
				addrx += 4;
			}
		} 
	}
	if(status == FLASH_COMPLETE){
		while(WriteAddr < endaddr){                                                 //写数据
			if(FLASH_ProgramWord(WriteAddr,*pBuffer) != FLASH_COMPLETE){              //写入数据
				break;	                                                                //写入异常
			}
			WriteAddr += 4;
			pBuffer++;
		} 
	}
  FLASH_DataCacheCmd(ENABLE);	                                                 //FLASH擦除结束,开启数据缓存
  FLASH_Lock();                                                                //上锁
} 

bootloader段的讲解主要就是这些了。bootloader工程我会全部上传,需要的可以自行下载。需要bootloader工程的点这里下载:我是bootloader工程
接下来开始介绍SD卡的使用部分。

升级过程中SD卡起到的作用

众所周知,F407ZET6的RAM大小只有约192kb,而一个稍微大点的工程编译成为bin文件之后,其大小都不会很小,我的工程编译出来的大小约为140kb,通过网络升级肯定是不能一次性就可以拿取完整个bin文件的,因为我还跑了RTOS,这个时候,SD卡的作用就体现出来了。我们可以在SD卡里面跑一个文件系统,比如常用的FAT文件系统,用来管理SD卡中的文件。我们将固件分割成单片机能接收的最大大小,然后一个一个的拿取,校验无误后存入SD卡中,当然存放的过程我们采用追加的方式,即将后面接收到的固件放在前一个的尾巴后面,直到接收完成,此时我们就拿到了一个完整的bin文件。这样就可以非常方便的完成bin文件的拿取,可能各位也有更好的方式,我这个只是起到一个借鉴的作用。

如何通过keil生成bin文件

我们使用keil进行编译链接之后,会生成一个hex文件,而我们直接进行固件更新是需要bin文件(二进制文件)的。hex文件和bin文件最大的区别就是hex文件是带地址信息的,而bin文件只有数据。如何转换这两种不同的格式呢?当然还是用keil了,我们打开app工程,点击魔术棒,进入设置,再进入如下界面:
bin
初次打开,如上图所示,我们先选中黑色箭头1所指的复选框,再点击2指向的文件夹图标,这时会弹出路径索引,让你选择生成bin文件需要的工具,这时便去keil的安装路径寻址fromelf.exe即可,路径基本如下(安装路径肯定不一致哦):
for
选中该文件,这时会跳回keil窗口:
bin
然后,我们在绿框所示位置E:\Keil_v5\ARM\ARMCC\bin\fromelf.exe的后面添加如下内容:--bin -o 欲生成的bin文件的路径与名字 编译后生成的axf文件的路径
例如:
--bin -o F:\robot\robot_407\OBJ\robot.bin F:\robot\robot_407\OBJ\robot.axf
其中的F:\robot\robot_407\OBJ\robot.bin就是我生成的bin文件的存放路径与名字
F:\robot\robot_407\OBJ\robot.axf就是该工程编译后生成的axf文件的路径,这里的编译是指未选择生成bin文件之前编译的。我的工程中,其路径为:F:\robot\robot_407\OBJ
一切准备就绪,点击编译按钮,即可生成bin文件到指定目录下。如下图:
bin
然后我们打开设置的路径,就可以找到生成的bin文件了,如下:
bin
到此,bin文件的生成方式讲解结束。当然网上还是其他方式生成,大家可以取参考,我只是觉得这个方式很简单而已。

ESP8266 SDK的简单讲解

本次ESP8266用到的SDK是根据官方和网上找到的一些资源,进行整合和适应性编写的。最开始是打算用AT指令来实现这一块的,但是用STM32去跑MQTT太麻烦了,首先你需要自己参照MQTT协议写一个链接文件出来,然后链接好TCP之后,按照MQTT协议进行发报文,这一步是走完了,但是后面发现心跳设置太麻烦了,在跑RTOS的基础上,心跳包老是发送出问题,导致MQTT断线重连。后面就放弃了STM32跑MQTT这块,转入到ESP8266去跑,这个就非常简单了,乐鑫官方提供了很多的demo,大家可以去参照。我的SDK里面包含了智能配网、链接MQTT、链接HTTP发送GET和POST请求、支持JSON格式传输和解析等功能,我会删除我的业务逻辑部分,提供整个SDK框架给大家,希望大家能理解。
我的SDK工程目录如下:
8266
这个工程是基于乐鑫提供的2.0版本nonos-sdk的和安信可提供的IDE编写的,大家如果需要的话就要去下载安信可提供的IDE了,点我下载:安信可一体化开发环境
安装好后参考安信可提供的教程,打开工程,接下来说一下主要的部分:
1、智能配网:
这里就不介绍智能配网的原理了,大家可以去官网查看或者下载开发文档查看。我的SDK设置是首次上电自动进入配网模式,配网成功后,如果需要再次进入就需要按键了。键位设置是IO14脚连接GND脚,一旦按下按键,IO14拉低,则进入配网模式。

LOCAL void ICACHE_FLASH_ATTR keyShortPress(void) {
	os_printf("按键触发 ,开始配网 \r\n");
	smartconfig_init();
}

//按键初始化 该按键链接IO14和GND 用于按键配网功能
LOCAL void ICACHE_FLASH_ATTR keyInit(void) {
	singleKey[0] = keyInitOne(KEY_0_IO_NUM, KEY_0_IO_MUX, KEY_0_IO_FUNC,
			keyLongPress, keyShortPress);
	keys.singleKey = singleKey;
	keyParaInit(&keys);
}

2、MQTT连接
MQTT连接时移植的官方提供的demo,可靠性和稳定性较高,但是没有自动重连功能,我是通过STM32实现对8266的监管进行掉线、断网或重新配网后自动重连的,大家可以集思广益,通过对SDK的修改进行自动重连功能。

	//开始MQTT连接
	MQTT_InitConnection(&mqttClient, sysCfg.mqtt_host, sysCfg.mqtt_port,
			sysCfg.security);
	MQTT_InitClient(&mqttClient, mac_string, sysCfg.mqtt_user,
			sysCfg.mqtt_pass, sysCfg.mqtt_keepalive, 1);
	MQTT_InitLWT(&mqttClient, "/lwt", "offline", 0, 0);
	MQTT_OnConnected(&mqttClient, mqttConnectedCb);
	MQTT_OnDisconnected(&mqttClient, mqttDisconnectedCb);
	MQTT_OnPublished(&mqttClient, mqttPublishedCb);
	MQTT_OnData(&mqttClient, mqttDataCb);

3、POST和GET请求的实现
POST和GET请求是前往服务器拿去数据和上传数据的最简洁手段,我们的固件就是通过GET请求获取的,具体实现也是先建立了TCP连接,在进行HTTP传输。

//定义Get请求的实现
void ICACHE_FLASH_ATTR startHttpQuestByGET(char *URL){

	err_t err;
	struct ip_info info;
	struct ip_addr addr;

	memset(buffer,0,(1024 * 2));
	http_parse_request_url(URL,host,filename,&port);
	os_sprintf(buffer,GET,filename,host);
	err = espconn_gethostbyname(&user_tcp_conn,host, &addr,user_esp_dns_found);
	if(err == 0){
		wifi_get_ip_info(STATION_IF, &info);
	    my_station_init1(&info.ip, port);
	}
}

//定义Post请求的实现
void ICACHE_FLASH_ATTR startHttpQuestByPOST(char *URL,char *method,char *postdata){
	struct ip_addr addr;
	memset(buffer,0,1024 * 2);
	http_parse_request_url(URL,host,filename,&port);
	os_sprintf(buffer,POST,filename,strlen(postdata),host,postdata);
	espconn_gethostbyname(&user_tcp_conn,host, &addr,user_esp_dns_found);
}

关于其他的我就不详细说明了,主要再说下拿取固件这里,我是通过STM32发送指令给8266,在8266的串口任务中,解析到了拿取固件的命令,8266就会调用GET请求,前往指定服务器进行拿取:
固件
看图,蓝色框中的部分http://192.168.2.153/updata/robot.bin%d.bin
其中,192.168.2.153就是我们通过文章首页搭建的服务器地址,也就是本机的IP,后面跟的/updata/robot.bin%d.bin就是一些参数,updata/是存放固件的文件夹,robot.bin%d.bin是固件的名字,还有就是因为固件分段了的,所以要跟上一个页码标识up_page,以确定需要拿哪一页,拿到之后就会通过串口发送给STM32进行存储。
所以我们的固件在服务器上存放的位置为:E:\apache-tomcat-9.0.30\webapps\updata,如下图:
固件
对应文章开头对tomcat的讲解。关于网络这一部分就讲解到这里了,SDK的下载点击这里哦:
我是ESP8266工程哦
该工程是基于乐鑫提供的2.0版本nonos-sdk的和安信可提供的IDE编写的,里面包含了智能配网、链接MQTT、链接HTTP发送GET和POST请求、支持JSON格式传输和解析等功能,由于删除了部分业务逻辑,故不能直接编译,因为有些变量的定义被删除了,会报错的,需要修改一下。不会修改的,你别下了又喷我哦,功能都是全的。

最后的整理

主要的内容都讲解完了,后面说一些我在弄远程升级时遇到的问题及解决思路。

1、bootloader跳转之后,程序直接进入HardFault。
造成这个问题的原因一般都是app程序的地址出问题了,比如是map文件里面的链接地址没能修改过来,还是指向了0x8000000,这个问题的解决办法第一是确保你的keil设置和中断向量偏移都已修改无误,第二就是去map的设置页面,即keil魔术棒的Linker设置页面取消掉在这里插入图片描述这个复选框的勾选,然后再手动修改sct文件里面的IROM地址试试。

2、升级时发现单片机内存不够用
这个的解决办法就是尽可能把bin文件分割小一点,多拿取几次,只要校验无误,拼接上去就好了。我再上传一个分割bin文件的工具,简单好用,直接按照大小分,查看bin文件可以直接下载flexhex,我就不直接上传了,云盘分享吧:bin文件分割与阅读 提取码qkkd

其他的暂时没想起来,想起来了再添加。以上都是我个人的一些总结,可能还会存在一些问题,欢迎各位指正。

-----------------------------------------------------------------------------------------------------------------2020/3/24
--------------------------------------------------------------------------------------------------------- -------@曼珠沙华

  • 4
    点赞
  • 68
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值