STM32(F103ZET6)第二十四课:IAP离线固件升级

开发需求

IAP介绍

IAP(In Application Programming)即在应用编程, IAP 是用户自己的程序在运行过程中对User Flash 的部分区域进行烧写,目的是为了在产品发布后可以方便地通过预留的通信接口对产品中的固件程序进行更新升级。通常实现 IAP 功能时,即用户程序运行中作自身的更新操作,需要在设计固件程序时编写两个项目代码,第一个项目程序不执行正常的功能操作,而只是通过某种通信方式(如 USB、 USART)接收程序或数据,执行对第二部分代码的更新;第二个项目代码才是真正的功能代码。这两部分项目代码都同时烧录在 User Flash 中,当芯片上电后,首先是第一个项目代码开始运行,它作如下操作:
1)检查是否需要对第二部分代码进行更新;
2)如果不需要更新则转到 4);
3)执行更新操作;
4)跳转到第二部分代码执行;

第一部分代码的烧入必须通过其它手段,如 JTAG 或 ISP;第二部分代码可以使用第一部分代码 IAP 功能烧入,也可以和第一部分代码一起烧入,以后需要程序更新时再通过第一部分 IAP代码更新。

我们将第一个项目代码称之为 Bootloader 程序,第二个项目代码称之为 APP(功能代码) 程序,他们存放在 STM32 FLASH 的不同地址范围,一般从最低地址区开始存放 Bootloader,紧跟其后的就是 APP 程序。这样我们就是要实现 3 个程序:Bootloader 和 APP1和APP2。
升级过程中问题:
1.程序升级实现的逻辑是什么样的?
Bootloader 引导加载程序
   (1).检测APP2区域有没有新代码的标志位,如果有,把APP2区域的代码拷贝到APP1区域,拷贝之前一定要保证APB1已经擦除过了。
  (2).如果APP2区域没有代码,执行APP1区域的代码
  (3).如果APP1区域也没有代码,执行bootloader
跳转的时候,一定要保证地址是否合法
App1 正常功能存放位置
   (1).接收升级文件,将程序写入到APP2区域,接收完成置标志位
   (2).开始运行之前,要修改中断向量表的地址偏移
App2升级程序存放位置
   (1).在原有基础上,修改一些问题
   (2).也要支持升级 也要修改中断向量表的地址偏移
2.如何确定是否有升级文件?
  (1)bootloader确定是否有升级程序 – 根据标志位
  (2)联网升级(OTA),请求平台询问版本号,如果自己运行的不是最新版本,确认是否需要更新

3.有文件的标志位什么时候写?
  APP1代码正常运行,获取到升级文件(串口中断接收),获取完成之后(100ms未接收到串口中断的数据),在APP2区域的最后几个字节写入标志位
4.IAP和OTA的区别?
原理是一样的,无非是升级文件的获取途径不同。
  IAP的升级文件是从串口或者SD卡或者U盘获取的,OTA的升级文件是从网络获取的(4G,ESP8266)。
5.内部FLASH空间不够,是否可以写入外部FLASH中?
   可以,看实际需求和习惯。
6.如果升级文件受损,程序宕机如何处理?
   1.升级文件在获取的过程中会加校验,校验通过之后才会写入到运行区域中。
   2.如果真的宕机,可以本地下载或者在存储空间中备份一份代码,通过引导程序检测按键状态,运行出厂时提前备份的程序。
具体升级过程示意图:在这里插入图片描述

内部的内存分区

1.内部FLASH划分

我们写的代码最终都会被编译成二进制文件并保存在Flash中,那么我们就可以进一步对我们程序进行分区。STM32的闪存模块由:主存储器、信息块和闪存存储器接口寄存器等 3 部分组成。
1)主存储器,该部分用来存放代码和数据常数(如 const 类型的数据)。对于大容量产品,其被划分为 256 页,每页 2K 字节。注意,小容量和中容量产品则每页只有 1K 字节。从上图可以看出主存储器的起始地址就是0X08000000, B0、B1 都接 GND 的时候,就是从 0X08000000开始运行代码的。
2)信息块,该部分分为 2 个小部分,其中启动程序代码,是用来存储 ST 自带的启动程序,用于串口下载代码,当 B0 接 V3.3,B1 接 GND 的时候,运行的就是这部分代码。
3)闪存存储器接口寄存器,该部分用于控制闪存读写等,是整个闪存模块的控制机构。对主存储器和信息块的写入由内嵌的闪存编程/擦除控制器(FPEC)管理;编程与擦除的高电压由内部产生。
那么我们分区就是主要对FLASH的主存储器进行分区,STM32F103ZE共512K的Flash大小,我们将它分成三个区,BootLoader区、App1区、App2区(备份区)具体划分如下表:

BootLoader区存放启动代码
App1区存放应用代码
App2区存放暂存的升级代码 0x08000000—0x08007FFFF
在这里插入图片描述

2.内部数据读取

想要从地址addr,读取一个半字(半字为 16 位,全字为 32 位),通过如下的语句读取:
data=(vu16)addr;//0x0800 0100
将 addr 强制转换为 vu16 指针,然后取该指针所指向的地址的值,即得到了 addr 地址的值。类似的,将上面的 vu16 改为 vu8,即可读取指定地址的一个字节。

3.数据写入与擦除

STM32 的闪存编程是由 FPEC(闪存编程和擦除控制器)模块处理的,这个模块包含 7 个32 位寄存器,他们分别是:
⚫ FPEC 键寄存器(FLASH_KEYR)
⚫ 选择字节键寄存器(FLASH_OPTKEYR)
⚫ 闪存控制寄存器(FLASH_CR)
⚫ 闪存状态寄存器(FLASH_SR)
⚫ 闪存地址寄存器(FLASH_AR)
⚫ 选择字节寄存器(FLASH_OBR)
⚫ 写保护寄存器(FLASH_WRPR)
其中 FPEC 键寄存器总共有 3 个键值:
RDPRT 键=0X000000A5
KEY1=0X45670123
KEY2=0XCDEF89AB
RDPRT 键(0x000000A5):
功能: RDPRT键用于解锁Flash读保护寄存器。它是用于解除Flash读保护的一个密钥。
使用: 在某些操作中(如解除读保护),需要写入这个键值到相关寄存器中,以解除对Flash区域的读保护。
KEY1(0x45670123):
功能: KEY1是FPEC(Flash Program Erase Controller)的第一个解锁密钥,用于解锁Flash控制寄存器,以允许对Flash进行编程或擦除。
使用: 要进行Flash编程或擦除,必须首先写入KEY1到Flash控制寄存器。
KEY2(0xCDEF89AB):
功能: KEY2是FPEC的第二个解锁密钥。它与KEY1一起使用,以完成Flash控制寄存器的解锁过程。
使用: 在写入KEY1之后,必须写入KEY2,以成功解锁Flash控制寄存器,允许后续的编程或擦除操作。

STM32 复位后, FPEC 模块是被保护的,不能写入 FLASH_CR 寄存器;通过写入特定的序列到 FLASH_KEYR 寄存器可以打开 FPEC 模块(即写入 KEY1 和 KEY2),只有在写保护被解除后,我们才能操作相关寄存器。
STM32 闪存的编程每次必须写入 16 位(不能单纯的写入 8 位数据哦!),当 FLASH_CR 寄存器的 PG 位为’ 1’时,在一个闪存地址写入一个半字将启动一次编程;写入任何非半字的数据, FPEC 都会产生总线错误。在编程过程中(BSY 位为’ 1’ ),任何读写闪存的操作都会使 CPU暂停,直到此次闪存编程结束。
同样, STM32 的 FLASH 在编程的时候,也必须要求其写入地址的 FLASH 是被擦除了的(也就是其值必须是 0XFFFF),否则无法写入,在 FLASH_SR 寄存器的 PGERR 位将得到一个警告。
STM23 的 FLASH 编程过程如图所示:
在这里插入图片描述
从上图可以得到闪存的编程顺序如下:
⚫ 检查 FLASH_CR 的 LOCK 是否解锁,如果没有则先解锁
⚫ 检查 FLASH_SR 寄存器的 BSY 位,以确认没有其他正在进行的编程操作
⚫ 设置 FLASH_CR 寄存器的 PG 位为’ 1’
⚫ 在指定的地址写入要编程的半字
⚫ 等待 BSY 位变为’ 0’
⚫ 读出写入的地址并验证数据
前面提到,我们在 STM32 的 FLASH 编程的时候,要先判断缩写地址是否被擦除了,所以,我们有必要再介绍一下 STM32 的闪存擦除, STM32 的闪存擦除分为两种:页擦除和整片擦除。
页擦除过程如图所示:
在这里插入图片描述
从上图可以看出, STM32 的页擦除顺序为:
⚫ 检查 FLASH_CR 的 LOCK 是否解锁,如果没有则先解锁
⚫ 检查 FLASH_SR 寄存器的 BSY 位,以确认没有其他正在进行的闪存操作
⚫ 设置 FLASH_CR 寄存器的 PER 位为’ 1’
⚫ 用 FLASH_AR 寄存器选择要擦除的页
⚫ 设置 FLASH_CR 寄存器的 STRT 位为’ 1’
⚫ 等待 BSY 位变为’ 0’
⚫ 读出被擦除的页并做验证

4.具体升级函数

下面我们讲解使用 STM32 的官方固件库操作 FLASH 的几个常用函数。 这些函数和定义分布在文件 stm32f10x_flash.c 以及 stm32f10x_flash.h 文件中。
锁定与解锁:
上面讲解到在对 FLASH 进行写操作前必须先解锁,解锁操作也就是必须在 FLASH_KEYR 寄存器写入特定的序列(KEY1 和 KEY2),固件库函数实现很简单:
void FLASH_Unlock(void);
同样的道理,在对 FLASH 写操作完成之后,我们要锁定 FLASH,使用的库函数是:
void FLASH_Lock(void);
写操作:
固件库提供了三个 FLASH 写函数:
FLASH_Status FLASH_ProgramWord(uint32_t Address, uint32_t Data);
FLASH_Status FLASH_ProgramHalfWord(uint32_t Address, uint16_t Data);
FLASH_Status FLASH_ProgramOptionByteData(uint32_t Address, uint8_t Data);

顾名思义分别为: FLASH_ProgramWord 为 32 位字写入函数,其他分别为 16 位半字写入和用户选择字节写入函数。这里需要说明, 32 位字节写入实际上是写入的两次 16 位数据,写完第一次后地址+2,这与我们前面讲解的 STM32 闪存的编程每次必须写入 16 位并不矛盾。 写入 8位实际也是占用的两个地址了,跟写入 16 位基本上没啥区别。
擦除操作:
固件库提供三个 FLASH 擦除函数:
FLASH_Status FLASH_ErasePage(uint32_t Page_Address);
FLASH_Status FLASH_EraseAllPages(void);
FLASH_Status FLASH_EraseOptionBytes(void);

这三个函数顾名思义, 第一个函数是页擦除函数,根据页地址擦除特定的页数据,第二个函数是擦除所有的页数据,第三个函数是擦除用户选择字节数据。这三个函数使用非常简单。
获取FLASH状态:
主要是用的函数是:**FLASH_Status FLASH_GetStatus(void);**返回值是通过枚举类型定义的,从这里面我们可以看到 FLASH 操作的 5 个状态,每个代表的意思我们在后面注释了。
typedef enum
{
FLASH_BUSY = 1,//忙
FLASH_ERROR_PG,//编程错误
FLASH_ERROR_WRP,//写保护错误
FLASH_COMPLETE,//操作完成
FLASH_TIMEOUT//操作超时
}FLASH_Status;

等待上一次操作完成:
在执行闪存写操作时,任何对闪存的读操作都会锁住总线,在写操作完成后读操作才能正确地进行;既在进行写或擦除操作时,不能进行代码或数据的读取操作。所以在每次操作之前,我们都要等待上一次操作完成这次操作才能开始。使用的函数是:
FLASH_Status FLASH_WaitForLastOperation(uint32_t Timeout)
入口参数为等待时间, 返回值是 FLASH 的状态, 这个很容易理解,这个函数本身我们在固件库中使用得不多,但是在固件库函数体中间可以多次看到。
FLASH读取函数:
有写就必定有读,而读取 FLASH 指定地址的半字的函数固件库并没有给出来,这里我们自己写的一个函数:
u16 STMFLASH_ReadHalfWord(u32 faddr)
{
return (vu16)faddr;
}

运用代码:
在这里插入图片描述

IAP更新升级步骤

1.系统启动流程

单片机是如何启动的:
在这里插入图片描述
正常流程:boot 0是低电平 :直接从内部FLASH启动
FLYMCU串口下载软件:boot1 :0 boot0 :1 会执行官方提前烧录到信息块内部的启动程序。该程序作用就是把串口1的数据搬运到内部FLASH里
正常代码启动流程
STM32 的内部闪存(FLASH)地址起始于 0x08000000,一般情况下,程序文件就从此地址开始写入。此外 STM32 是基于 Cortex-M3 内核的微控制器,其内部通过一张“中断向量表”来响应中断,程序启动后,将首先从“中断向量表”取出复位中断向量执行复位中断程序完成启动,而这张“中断向量表”的起始地址是 0x08000004,当中断来临,STM32 的内部硬件机制亦会自动将 PC 指针定位到“中断向量表”处,并根据中断源取出对应的中断向量执行中断服务程序。
STM32 在复位后,先从 0X08000004 地址取出复位中断向量的地址,并跳转到复位中断服务程序,如图标号①所示;在复位中断服务程序执行完之后,会跳转到我们的main 函数,如图标号②所示;而我们的 main 函数一般都是一个死循环,在 main 函数执行过程中,如果收到中断请求(发生重中断),此时 STM32 强制将 PC 指针指回中断向量表处,如图标号③所示;然后,根据中断源进入相应的中断服务程序,如图标号④所示;在执行完中断服务程序以后,程序再次返回 main 函数执行,如图标号⑤所示。
在这里插入图片描述
在这里插入图片描述

2.IAP启动流程详解

加入 IAP 之后程序运行流程图中,STM32 复位后,还是从 0X08000004 地址取出复位中断向量的地址,并跳转到复位中断服务程序,在运行完复位中断服务程序之后跳转到 IAP 的 main 函数,如图标号①所示,在执行完 IAP 以后(即将新的 APP 代码写入 STM32的 FLASH,灰底部分。新程序的复位中断向量起始地址为 0X08000004+N+M),跳转至新写入程序的复位向量表,取出新程序的复位中断向量的地址,并跳转执行新程序的复位中断服务程序,随后跳转至新程序的 main 函数,如图标号②和③所示,同样 main 函数为一个死循环,并且注意到此时 STM32 的 FLASH,在不同位置上,共有两个中断向量表。
在 main 函数执行过程中,如果 CPU 得到一个中断请求,PC 指针仍强制跳转到地址0X08000004 中断向量表处,而不是新程序的中断向量表,如图标号④所示;程序再根据我们设置的中断向量表偏移量,跳转到对应中断源新的中断服务程序中,如图标号⑤所示;在执行完中断服务程序后,程序返回 main 函数继续运行,如图标号⑥所示。
通过以上两个过程的分析,我们知道 IAP 程序必须满足两个要求:
1) 新程序必须在 IAP 程序之后的某个偏移量为 x 的地址开始;
2) 必须将新程序的中断向量表相应的移动,移动的偏移量为 x;

在这里插入图片描述

3.整体设计流程

先执行BootLoader程序, 先去检查APP2区有没有程序,如果有程序就将App2区(备份区)的程序拷贝到App1区, 然后再跳转去执行App1的程序。
然后执行App1程序, 因为BootLoader和App1这两个程序的向量表不一样, 所以跳转到App1之后第一步是先去更改程序的向量表,然后再去执行其他的应用程序。
在应用程序里面会加入程序升级的部分,这部分主要工作是拿到升级程序,然后将他们放到App2区(备份区),以便下次启动的时候通过BootLoader更新App1的程序。
流程图如下图所示:
在这里插入图片描述

4.Boot Loader的代码编写

第一次使用,需要手动烧录到开发板。
在BootLoader程序中,我们主要实现对APP2区域的中的标志检查,读取APP2内部的程序,写入APP1地址程序,代码执行跳转程序。
具体的代码执行过程,我们参考示例代码来掌握,整个执行流程如下图所示:
在这里插入图片描述
bootloader中的跳转,固件升级等函数。

#include "iap.h"
#include "stmflash.h"
#include "stdio.h"

//采用如下方法实现执行汇编指令WFI  
void WFI_SET(void)
{
	__ASM volatile("wfi");		  
}
//关闭所有中断
void INTX_DISABLE(void)
{		  
	__ASM volatile("cpsid i");
}
//开启所有中断
void INTX_ENABLE(void)
{
	__ASM volatile("cpsie i");		  
}
//设置栈顶地址
//addr:栈顶地址
__asm void MSR_MSP(u32 addr) 
{
    MSR MSP, r0 			//set Main Stack value
    BX r14
}

//用iapfun表示一个无参数无返回值的函数
typedef  void (*iapfun)(void);				//定义一个函数类型的参数.
iapfun jump2app; //void  jump2app(void),定义一个函数指针

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


//Flash中用户代码执行
void UserFlashAppRun(void)
{
	printf("开始执行FLASH用户代码!!\r\n");
	//判断复位中断函数的地址是否在0X08XXXXXX之间
	if(((*(vu32*)(0x08003000+4))&0xFF000000)==0x08000000)//判断是否为0X08XXXXXX.
	{	 
		iap_load_app(FLASH_APP1_ADDR);//执行FLASH APP代码
	}else 
	{
		printf("APP程序加载失败!\r\n");   
	}									 
}

uint16_t readBuff[STM_SECTOR_SIZE/2] = {0};
//固件更新函数
void UpdateFun(void)
{
	uint16_t app2FlagBuff[2] = {0xFFFF, 0xFFFF};
	printf("开始更新固件...\r\n");
	//搬运数据
	for(uint16_t i=0; i<APP_SIZE; i++) {
		printf("正在更新固件%d...\r\n", i);
		STMFLASH_Read(FLASH_APP2_ADDR+i*STM_SECTOR_SIZE, readBuff, STM_SECTOR_SIZE/2);
		STMFLASH_Write(FLASH_APP1_ADDR+i*STM_SECTOR_SIZE, readBuff, STM_SECTOR_SIZE/2);	
	}
	printf("固件更新完成!\r\n");	
	STMFLASH_Write(APP2_FLAG_ADDR, app2FlagBuff, 2);
	UserFlashAppRun();
}

bootloader主函数:

#include "delay.h"
#include "led.h"
#include "key.h"
#include "usart.h"
#include "stdio.h"
#include "stmflash.h"
#include "iap.h"

int main(void)
{
	uint16_t data=0;
	uint16_t buff[2]={0};
	//中断优先级分组
	NVIC_SetPriorityGrouping(5);//2位抢占 两位响应
	LED_Config();
	BEEP_Config();
	KEY_Config();
	USART_Config();
	UART1_SendStr((uint8_t *)"IAP Bootloader Runing\r\n");
	STMFLASH_Read(APP2_FLAG_ADDR,buff,2);
	printf("更新标志:%x %x\r\n",buff[0],buff[1]);
	if(buff[0] == 0xAAAA && buff[1] == 0xAAAA)
	{
		//有更新
		UpdateFun();
	}else
	{
		//无更新
		UserFlashAppRun();
	}
  while (1)
  {
		printf("BOOTLOADER RUN\r\n");
		LED1_Toggle();
		Delay_nms(100);
	}
}

bootloader烧录的地址:
在这里插入图片描述
在这里插入图片描述

5.APP1代码编写(目前)

接收更新代码,把更新的 代码保存一下,设置更新标志为。然后提示一个接收到更新代码。第一次使用时必须通过下载器。
在整个APP代码的编写上,我们首先修改向量表, 因为本程序是由BootLoader跳转过来的, 不修改向量表后面会出现问题;需要在APP的基本功能上加入串口接收数据并保存到APP2(备份区)的功能代码。
具体的代码执行过程,我们参考示例代码来掌握,整个执行流程如下图所示:
在这里插入图片描述
在APP程序的编译时,我们需要修改地址属性,具体如下图所示:
在这里插入图片描述
如果APP的代码使用下载器下载,那么我们就需要修改下载的属性,如下图所示:
在这里插入图片描述
注意:App起始时要偏移中断向量表

6.APP2代码编写(待升级)

APP2的代码,理论上来说和APP1的代码除了基本功能不同外,在代码上级上的功能一样。只是APP2的代码,我们可以直接将编译后的二进制文件使用串口等方式发送(也可以无线远程发送,SD卡等方式存储)到芯片内部,并存储在APP2的地址区域内。事实上接收和存储的过程是运行的APP1的程序,然后将接收到的数据存放到APP2的地址区域内。
APP2的编译地址和APP1保持一致,但是我们发送的文件并不是hex文件,而是编译后的bin文件,那如何生成bin文件呢?
需要修改配置属性:
在这里插入图片描述
D:\KEIL\ARM\ARMCC\bin\fromelf.exe --bin -o .\Objects\AirProject.bin .\Objects\weather.axf
在这里插入图片描述
其中**:D:\Keil_v5\Arm\ARMCC\bin\fromelf.exe** 是一个keil自带的生成bin文件的工具绝对路径。通过这一步设置,我们就可以在 MDK 编译成功之后,调用 fromelf.exe(注意,我的 MDK 是安装在 D盘文件夹下,如果你是安装在其他目录,请根据你自己的目录修改fromelf.exe 的路径),根据当前工程的 weather.axf(如果是其他的名字,请记住修改,这个文件存放在 Objects 目录下面,格式为 xxx.axf),生成一个 RTC.bin 的文件。并存放在 axf 文件相同的目录下,即工程的 Objects 文件夹里面。在得到.bin 文件之后,我们只需要将这个 bin 文件传送给单片机,即可执行 IAP 升级。(我们也可以将bin文件无线发送,存放在SD卡内,存放在外部FLASH内等等方式进行代码升级,其中无线发送的形式叫OTA)
在这里插入图片描述
我们把APP2生成的bin文件,通过串口,发送到APP1的运行设备上,就会自动的保存APP2的代码数据到对应的Flash地址下,那么按下复位按键后(也可以软件复位),再次运行bootloader代码,就会加载APP2的数据到APP1的地址下,并运行新的程序。此时升级完成。

总结

a)Bootloader 如何加载程序的,如何跳转的?
在STM32中 的闪存模块由主存储器、信息块和闪存存储器接口寄存器等 3 部分组成。其中启动程序代码时是从主存储器的0X08000000开始运行代码的。其中Bootloader 占有12k的字节,程序运行时Bootloader 先指到栈顶,位置是0X08000000至0X08000004,之后从“中断向量表”取出复位中断向量执行复位中断程序完成启动,而这张“中断向量表”的起始地址是 0x08000004当中断来临,STM32 的内部硬件机制亦会自动将 PC 指针定位到“中断向量表”处,并根据中断源取出对应的中断向量执行中断服务程序。之后Bootloader 会检测备份区是否有更新标志,如果有更新标志,则Bootloader 会先把备份区的更新程序搬到APP中,之后擦除备份区的代码,然后Bootloader 再跳转到APP区。如果没有更新的标志,则意味着不需要更新,Bootloader 会直接跳转到APP区。
b) App如何接收新的程序?接收到新程序后如何跳转到bootloader
APP接受到来自串口或者其他方式的新的代码程序时,首先会将接收到的需要更新的代码通过APP放入备份区中,并且要更新标志位。之后Bootloader 会检测备份区中是否有标志位,如果有标志位时,Bootloader 会先把备份区的更新程序搬到APP中,之后擦除备份区的代码,然后Bootloader 再跳转到APP区。此时是指向于APP的栈顶位置,即为0x08003000,接着指向新中断地址的位置,由于在查找中断时,会从头开始查找,所以中断向量表需要进行偏移,偏移到新中断向量表的位置,即为0x08003004的地址。之后开始运行新的程序代码。

  • 23
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值