ARM架构与编程——7.代码重定位

代码重定位

一、清除BSS段(ZI段)

1.1 C语言中的BSS段

程序里的全局变量,如果它的初始值为0,或者没有设置初始值,这些变量被放在BSS段里,也叫ZI段。

char g_Char = 'A';
const char g_Char2 = 'B';
int g_A = 0;  // 放在BSS段
int g_B;      // 放在BSS段

BSS段并不会放入bin文件中,也不会烧写到flash中,否则也太浪费空间了。
在使用BSS段里的变量之前,把BSS段所占据的内存清零就可以了。

  • 注意:对于keil来说,一个本该放到BSS段的变量,如果它所占据的空间小于等于8字节自己,keil仍然会把它放在data段里。只有当它所占据的空间大于8字节时,才会放到BSS段。
int g_A[3] = {0, 0};   // 放在BSS段			
char g_B[9];           // 放在BSS段

int g_A[2] = {0, 0};   // 放在data段
char g_B[8];           // 放在data段

在这里插入图片描述

打印数组的首项,显示结果是随机值,怎么修改才能让打印出的内存地址不是随机值呢?初始化BSS段,清零
所以在定义的变量值为0,或者未初始化时,将他们放到BSS段上,在使用前清零即可

1.2 BSS段在哪?多大?

在散列文件中,BSS段(ZI段)在可执行域RW_IRAM1中描述:

LR_IROM1 0x08000000 0x00080000  {    ; load region size_region
  ER_IROM1 0x08000000 0x00080000  {  ; load address = execution address
   *.o (RESET, +First)
   *(InRoot$$Sections)
   .ANY (+RO)
   .ANY (+XO)
  }
  RW_IRAM1 0x20000000 0x00010000  {  ; RW data
   .ANY (+RW +ZI)
  }
}

BSS段(ZI段)的链接地址(基地址)、长度,使用下面的符号获得:
在这里插入图片描述

1.3 怎么清除BSS段

1.汇编码

IMPORT |Image$$RW_IRAM1$$ZI$$Base|
IMPORT |Image$$RW_IRAM1$$ZI$$Length|

LDR R0, = |Image$$RW_IRAM1$$ZI$$Base|       ; DEST
MOV R1, #0									; VAL
LDR R1, = |Image$$RW_IRAM1$$ZI$$Length|     ; Length
BL memset

2.C语言

  • 方法1
    声明为外部变量,使用时需要使用取址符:
extern int Image$$RW_IRAM1$$ZI$$Base;
extern int Image$$RW_IRAM1$$ZI$$Length;
memset(&Image$$RW_IRAM1$$ZI$$Base, 0, &Image$$RW_IRAM1$$ZI$$Length);
  • 方法2
    声明为外部数组,使用时不需要使用取址符:
extern char Image$$RW_IRAM1$$ZI$$Base[];
extern int Image$$RW_IRAM1$$ZI$$Length[];
memset(Image$$RW_IRAM1$$ZI$$Base[], 0, Image$$RW_IRAM1$$ZI$$Length);

二、代码重定位

2.1 加载地址等于链接地址

在默认散列文件中,代码段的load address = execution address,(执行地址)
也就是加载地址和**执行地址(链接地址)**一致,所以无需重定位:

ROM对应的地址是0x08000000;RAM对应的地址是0x020000000

LR_IROM1 0x08000000 0x00080000  {    ; load region size_region			//加载域;加载域里面有两个执行域
  ER_IROM1 0x08000000 0x00080000  {  ; load address = execution address		//这个可执行域里面放的主要是代码;加载地址=执行地址,所以
   *.o (RESET, +First)				//所有.o 文件的RESET段					//不需要重定位
   *(InRoot$$Sections)
   .ANY (+RO)			//.o 文件,或者.h文件的只读数据段; .ANY表示所有.o文件、库文件
   .ANY (+XO)
  }
  RW_IRAM1 0x20000000 0x00010000  {  ; RW data
   .ANY (+RW +ZI)
  }
}

2.2 加载地址不等于链接地址

有时候,我们需要把程序复制到内存里运行,比如:

  • 想让程序执行得更快:需要把代码段复制到内存里
  • 程序很大,保存在片外SPI Flash中,SPI Flash上的代码无法直接执行,需要复制到内存里

这时候,需要修改散列文件,把代码段的可执行域放在内存里
那么程序运行时,需要尽快把代码段重定位到内存
散列文件示例:

LR_IROM1 0x08000000 0x00080000  {    ; load region size_region
  ER_IROM1 0x20000000   {  ; load address != execution address
   *.o (RESET, +First)
   .ANY (+RO)
   .ANY (+XO)
  }
  RW_IRAM1 +0   {  ; RW data
   .ANY (+RW +ZI)
  }
}

上面的散列文件中:

  • 可执行域ER_IROM1
    • 加载地址为0x08000000,可执行地址为0x20000000,两者不相等
    • 板子上电后,从0x08000000处开始运行,需要尽快把代码段复制到0x20000000
  • 可执行域RW_IRAM1
    • 加载地址:紧跟着ER_IROM1的加载地址
    • 可执行地址:紧跟着ER_IROM1的可执行地址
    • 需要尽快把数据段复制到可执行地址处

数据段的重定位我们做过实验,
如果代码段不重定位的话,会发生什么事?

2.3 代码段不重定位的后果

不能使用链接地址来调用函数

  • 汇编中
ldr  pc, =main   ; 这样调用函数时,用到main函数的链接地址,如果代码段没有重定位,则跳转失败
  • C语言中
void (*funcptr)(const char *s, unsigned int val);
funcptr = put_s_hex;
funcptr("hello, test function ptr", 123);

2.4 代码段重定位

①code:代码段,存放程序的所有代码

②RO:只读数据段,存放程序中定义的常量

③RW:读写数据段,存放初始化为非0值的全局变量

④ZI:零数据段,存放未初始化的全局变量,以及初始化为0的变量

ROM:存放指令代码和一些固定数值,程序运行后不可改动。

凡是c文件及h文件中所有代码、全局变量、局部变量、’const’限定符定义的常量数据、startup.asm文件中的代码(类似ARM中的bootloader或者X86中的BIOS,一些低端的单片机是没有这个的)通通都存储在ROM中。

RAM:用于程序运行中数据的随机存取,掉电后数据消失。

凡是整个程序中,所用到的需要被改写的量,都存储在RAM中,“被改变的量”包括全局变量、局部变量、堆栈段。

程序经过编译、汇编、链接后,生成hex文件。用专用的烧录软件,通过烧录器将hex文件烧录到ROM中,这个时候的ROM中,包含所有的程序内容:无论是一行一行的程序代码,函数中用到的局部变量,头文件中所声明的全局变量,const声明的只读常量,都被生成了二进制数据,包含在hex文件中,全部烧录到了ROM里面,此时的ROM,包含了程序的所有信息,正是由于这些信息,“指导”了CPU的所有动作。

可能有人会有疑问,既然所有的数据在ROM中,那RAM中的数据从哪里来?什么时候CPU将数据加载到RAM中?

回答这个问题,首先必须明确一条:ROM是只读存储器,CPU只能从里面读数据,而不能往里面写数据,掉电后数据依然保存在存储器中;

RAM是随机存储器,CPU既可以从里面读出数据,又可以往里面写入数据,掉电后数据不保存,这是条永恒的真理,始终记挂在心。

RAM中的数据不是在烧录的时候写入的,同时也说明,在CPU运行时,RAM中已经写入了数据。关键就在这里:这个数据不是人为写入的,CPU写入的,那CPU又是什么时候写入的呢?

这里所做的工作是为整个程序的顺利运行做好准备,或者说是对RAM的初始化(注:ROM是只读不写的),工作任务有几项:
1、为全局变量分配地址空间—如果全局变量已赋初值,则将初始值从ROM中拷贝到RAM中,如果没有赋初值,则这个全局变量所对应的地址下的初值为0或者是不确定的。当然,如果已经指定了变量的地址空间,则直接定位到对应的地址就行,那么这里分配地址及定位地址的任务由“连接器”完成。
2、设置堆栈段的长度及地址-–用C语言开发的单片机程序里面,普遍都没有涉及到堆栈段长度的设置,但这不意味着不用设置。堆栈段主要是用来在中断处理时起“保存现场”及“现场还原”的作用,其重要性不言而喻。而这么重要的内容,也包含在了编译器预设的内容里面,确实省事,可并不一定省心。
3、分配数据段data,常量段const,代码段code的起始地址。代码段与常量段的地址可以不管,它们都是固定在ROM里面的,无论它们怎么排列,都不会对程序产生影响。但是
数据段的地址就必须得关心
。数据段的数据时要从ROM拷贝到RAM中去的,而在RAM中,既有数据段data,也有堆栈段stack,还有通用的工作寄存器组。通常,工作寄存器组的地址是固定的,这就要求在绝对定址数据段时,不能使数据段覆盖所有的工作寄存器组的地址。必须引起严重关注。

通常的做法是:普通的flashMCU是在上电时或复位时,c指针里面的存放的是“0000”,表示CPU从ROM的0000地址开始执行指令,在该地址处放一条跳转指令,使程序跳转到_main函数中,然后根据不同的指令,一条一条的执行,当中断发生时(中断数量也很有限,2~5个中断),按照系统分配的中断向量表地址,在中断向量里面,放置一条跳转到中断服务程序的指令,如此如此,整个程序就跑起来了。决定CPU这样做,是这种ROM结构所造成的。

2.5 代码段在哪?多大?

在散列文件中,代码段在可执行域ER_IROM1中描述:

LR_IROM1 0x08000000 0x00080000  {    ; load region size_region
  ER_IROM1 0x08000000 0x00080000  {  ; load address = execution address
   *.o (RESET, +First)
   *(InRoot$$Sections)
   .ANY (+RO)
   .ANY (+XO)
  }
  RW_IRAM1 0x20000000 0x00010000  {  ; RW data
   .ANY (+RW +ZI)
  }
}

代码段的链接地址(基地址)、长度,使用下面的符号获得:
在这里插入图片描述
代码段的加载地址,使用下面的符号获得:
在这里插入图片描述

2.6 怎么重定位

1.汇编代码

IMPORT |Image$$ER_IROM1$$Base|
IMPORT |Image$$ER_IROM1$$Length|
IMPORT |Load$$ER_IROM1$$Base|

LDR R0, = |Image$$ER_IROM1$$Base|    ; DEST
LDR R1, = |Load$$ER_IROM1$$Base|     ; SORUCE
LDR R2, = |Image$$ER_IROM1$$Length|  ; LENGTH
BL memcpy

2.C语言代码

  • 方法1
    声明为外部变量,使用时需要使用取址符:
extern int Image$$ER_IROM1$$Base;
extern int Load$$ER_IROM1$$Base;
extern int Image$$ER_IROM1$$Length;

memcpy(&Image$$ER_IROM1$$Base, &Image$$ER_IROM1$$Length, &Load$$ER_IROM1$$Base);
  • 方法2
    声明为外部数组,使用时不需要使用取址符:
extern char Image$$ER_IROM1$$Base[];
extern char Load$$ER_IROM1$$Base[];
extern int Image$$ER_IROM1$$Length;

memcpy(Image$$ER_IROM1$$Base, Image$$ER_IROM1$$Length, &Load$$ER_IROM1$$Base);&Load$$ER_IROM1$$Base);

2.7 为什么重定位之前的代码也可以正常运行?

因为重定位之前的代码是使用位置无关码写的:

  • 只使用相对跳转指令:B、BL

相对跳转,新的PC值 = 老的PC值 + 事先算好的偏移值
两条指令是放在一块的,中间差个offset(个人理解,就是只能在ROM上跳转,不能从ROM上跳转到内存中)
相对跳转程序只会在flash上执行main函数,如果想要跳到内存上去执行main函数,就需要执行以下指令:

  • 不只用绝对跳转指令:
LDR R0, =main
BLX R0
  • 不访问全局变量、静态变量、字符串、数组
  • 重定位完后,使用绝对跳转指令跳转到XXX函数的链接地址去
BL main         ; bl相对跳转,程序仍在Flash上运行

LDR R0, =main   ; 使用链接地址,绝对跳转,跳到链接地址去,就是跳去内存里执行
BLX R0

反汇编代码解析:

; Vector Table Mapped to Address 0 at Reset
                AREA    RESET, DATA, READONLY
				EXPORT  __Vectors
					
//程序从向量表这里开始执行					
__Vectors       DCD     0         ; //这个数据用来设置栈,但我们是自己手工设置的       
                DCD     0x08000009; //cpu来这里取出值,把它作为一个函数地址跳转过去           ; Reset Handler

				AREA    |.text|, CODE, READONLY

; Reset handler
Reset_Handler   PROC
				EXPORT  Reset_Handler             [WEAK]
                IMPORT  mymain
                
				IMPORT |Image$$ER_IROM1$$Base|
				IMPORT |Image$$ER_IROM1$$Length|
				IMPORT |Load$$ER_IROM1$$Base|
				
				; relocate text section
				LDR R0, = |Image$$ER_IROM1$$Base|    ; DEST
				LDR R1, = |Load$$ER_IROM1$$Base|     ; SORUCE
				LDR R2, = |Image$$ER_IROM1$$Length|  ; LENGTH
				BL memcpy

在这里插入图片描述

在这里插入图片描述
CPU一上电,会用第一个值设置栈,但现在我们在后面手动设置栈了,所以这里值可以为0,然后在下一个位置,也就是2000,0009,取出这个位置的值,把它作为函数的地址跳转过去执行,bit0并不使用,只是用来表示说是Thumb指令集,实际地址是pc = 2000,0008;但是在2000,0008这个位置上,内存这里,还没有放任何东西,程序就崩掉了
所以我们需要在2000,0009这个位置放0800,0009;把代码改为DCD 0x08000009;

三、重定位纯C函数的实现

3.1 难点

难点在于,怎么得到各个域的加载地址、链接地址、长度。

  • 方法1
    声明为外部变量,使用时需要使用取址符:
extern int Image$$ER_IROM1$$Base;
extern int Load$$ER_IROM1$$Base;
extern int Image$$ER_IROM1$$Length;

memcpy(&Image$$ER_IROM1$$Base, &Image$$ER_IROM1$$Length, &Load$$ER_IROM1$$Base);
  • 方法2
    声明为外部数组,使用时不需要使用取址符:
extern char Image$$ER_IROM1$$Base[];
extern char Load$$ER_IROM1$$Base[];
extern int Image$$ER_IROM1$$Length;

memcpy(Image$$ER_IROM1$$Base, Image$$ER_IROM1$$Length, &Load$$ER_IROM1$$Base);

3.2 怎么理解上述代码

对于这样的C变量:

int g_a;

编译的时候会有一个符号表(symbol table),如下:

NameAddress
g_axxxxxxxx

对于散列文件中的各类Symbol,有2中声明方式:

extern int Image$$ER_IROM1$$Base;     // 声明为一般变量
extern char Image$$ER_IROM1$$Base[];  // 声明为数组

不管是哪种方式,它们都会保存在符号表里,比如:

NameAddress
g_axxxxxxxx
Image$$ER_IROM1$$Baseyyyyyyyy
  • 对于int g_a变量
    • 使用&g_a得到符号表里的地址。
  • 对于extern int Image$$ER_IROM1$$Base变量
    • 要得到符号表中的地址,也是使用&Image$$ER_IROM1$$Base
  • 对于extern char Image$$ER_IROM1$$Base[]变量
    • 要得到符号表中的地址,直接使用Image$$ER_IROM1$$Base,不需要加&
    • 为什么?mage$$ER_IROM1$$Base本身就表示地址

3.3 代码实现

  • start.s

                PRESERVE8
                THUMB


; Vector Table Mapped to Address 0 at Reset
                AREA    RESET, DATA, READONLY
				EXPORT  __Vectors
					
					
__Vectors       DCD     0                  
                DCD     0x08000009; //Reset_Handler        ; Reset Handler 对应ROM上的地址,从ROM上开始执行程序

				AREA    |.text|, CODE, READONLY

; Reset handler
Reset_Handler   PROC
				EXPORT  Reset_Handler             [WEAK]
                IMPORT  mymain

				IMPORT SystemInit

				LDR SP, =(0x20000000+0x10000)
				
				BL SystemInit

				;BL mymain
				LDR R0, =mymain
				BLX R0

                ENDP
                
                 END



  • init.c
#if 0
void SystemInit_bak_ok(void)
{
	extern int Image$$ER_IROM1$$Base;
	extern int Image$$ER_IROM1$$Length;
	extern int Load$$ER_IROM1$$Base;
	extern int Image$$RW_IRAM1$$Base;
	extern int Image$$RW_IRAM1$$Length;
	extern int Load$$RW_IRAM1$$Base;
	extern int Image$$RW_IRAM1$$ZI$$Base;
	extern int Image$$RW_IRAM1$$ZI$$Length;	
	
	/* text relocate */
	memcpy(&Image$$ER_IROM1$$Base, &Load$$ER_IROM1$$Base, &Image$$ER_IROM1$$Length);
	
	/* data relocate */
	memcpy(&Image$$RW_IRAM1$$Base, &Load$$RW_IRAM1$$Base, &Image$$RW_IRAM1$$Length);
	
	/* bss clear */
	memset(&Image$$RW_IRAM1$$ZI$$Base, 0, &Image$$RW_IRAM1$$ZI$$Length);
}

void SystemInit(void)
{
	extern int Image$$ER_IROM1$$Base[];
	extern int Image$$ER_IROM1$$Length[];
	extern int Load$$ER_IROM1$$Base[];
	extern int Image$$RW_IRAM1$$Base[];
	extern int Image$$RW_IRAM1$$Length[];
	extern int Load$$RW_IRAM1$$Base[];
	extern int Image$$RW_IRAM1$$ZI$$Base[];
	extern int Image$$RW_IRAM1$$ZI$$Length[];	
	
	/* text relocate */
	memcpy(Image$$ER_IROM1$$Base, Load$$ER_IROM1$$Base, Image$$ER_IROM1$$Length);
	
	/* data relocate */
	memcpy(Image$$RW_IRAM1$$Base, Load$$RW_IRAM1$$Base, Image$$RW_IRAM1$$Length);
	
	/* bss clear */
	memset(Image$$RW_IRAM1$$ZI$$Base, 0, Image$$RW_IRAM1$$ZI$$Length);
}

#endif

void SystemInit(void)
{
	extern int * Image$$ER_IROM1$$Base;
	extern int * Image$$ER_IROM1$$Length;
	extern int * Load$$ER_IROM1$$Base;
	extern int * Image$$RW_IRAM1$$Base;
	extern int * Image$$RW_IRAM1$$Length;
	extern int * Load$$RW_IRAM1$$Base;
	extern int * Image$$RW_IRAM1$$ZI$$Base;
	extern int * Image$$RW_IRAM1$$ZI$$Length;	
	
	/* text relocate */
	memcpy(&Image$$ER_IROM1$$Base, &Load$$ER_IROM1$$Base, &Image$$ER_IROM1$$Length);
	
	/* data relocate */
	memcpy(&Image$$RW_IRAM1$$Base, &Load$$RW_IRAM1$$Base, &Image$$RW_IRAM1$$Length);
	
	/* bss clear */
	memset(&Image$$RW_IRAM1$$ZI$$Base, 0, &Image$$RW_IRAM1$$ZI$$Length);
}

  • main.c

#include "uart.h"
#include "string.h"

char g_Char = 'A';
const char g_Char2 = 'B';
int g_A[3] = {0, 0};
char g_B[9];

void delay(volatile int d)
{
	while(d--);
}

int mymain()
{
	char c;
	void (*funcptr)(const char *s, unsigned int val);

	static int s_C[16] = {0, 0};
	
	funcptr = put_s_hex;
	
	uart_init();
	
	delay(1);
	
	putchar('1');
	putchar('0');
	putchar('0');
	putchar('a');
	putchar('s');
	putchar('k');
	putchar('\n');
	putchar('\r');
	
	funcptr("test for text relocate ", 123);
	
	put_s_hex("g_Char's addr  = ", &g_Char);
	put_s_hex("g_Char2's addr = ", &g_Char2);
	put_s_hex("g_A[0]'s val = ", g_A[0]);
	put_s_hex("g_B[0]'s val = ", g_B[0]);
	put_s_hex("s_C[0]'s val = ", s_C[0]);
	
	putchar(g_Char);
	putchar(g_Char2);
	
	while (1)
	{
		c = getchar();
		putchar(c);
		putchar(c+1);
	}
	
	return 0;
}

  • string.c
#include "uart.h"
int puts(const char *s)
{
	while (*s)
	{
		putchar(*s);
		s++;
	}
	return 0;
}

void puthex(unsigned int val)
{
	/* 0x76543210 */
	int i, j;

	puts("0x");
	for (i = 7; i >= 0; i--)
	{
		j = (val >> (i*4)) & 0xf;
		if ((j >= 0) && (j <= 9))
			putchar('0' + j);
		else
			putchar('A' + j - 0xA);
	}	
}

void put_s_hex(const char *s, unsigned int val)
{
	puts(s);
	puthex(val);
	puts("\r\n");
}

void memcpy(void *dest, void *src, unsigned int len)
{
	unsigned char *pcDest = dest;
	unsigned char *pcSrc  = src;
	
	while (len --)
	{
		*pcDest = *pcSrc;
		pcSrc++;
		pcDest++;
	}
}

void memset(void *dest, unsigned char val, unsigned int len)
{
	unsigned char *pcDest = dest;
	
	while (len --)
	{
		*pcDest = val;
		pcDest++;
	}
}

  • uart.c
#include "uart.h"

typedef unsigned int uint32_t;
typedef struct
{
  volatile uint32_t SR;    /*!< USART Status register, Address offset: 0x00 */
  volatile uint32_t DR;    /*!< USART Data register,   Address offset: 0x04 */
  volatile uint32_t BRR;   /*!< USART Baud rate register, Address offset: 0x08 */
  volatile uint32_t CR1;   /*!< USART Control register 1, Address offset: 0x0C */
  volatile uint32_t CR2;   /*!< USART Control register 2, Address offset: 0x10 */
  volatile uint32_t CR3;   /*!< USART Control register 3, Address offset: 0x14 */
  volatile uint32_t GTPR;  /*!< USART Guard time and prescaler register, Address offset: 0x18 */
} USART_TypeDef;


void uart_init(void)
{
	USART_TypeDef *usart1 = (USART_TypeDef *)0x40013800;
	volatile unsigned int *pReg;
	/* 使能GPIOA/USART1模块 */
	/* RCC_APB2ENR */
	pReg = (volatile unsigned int *)(0x40021000 + 0x18);
	*pReg |= (1<<2) | (1<<14);
	
	/* 配置引脚功能: PA9(USART1_TX), PA10(USART1_RX) 
	 * GPIOA_CRH = 0x40010800 + 0x04
	 */
	pReg = (volatile unsigned int *)(0x40010800 + 0x04);
	
	/* PA9(USART1_TX) */
	*pReg &= ~((3<<4) | (3<<6));
	*pReg |= (1<<4) | (2<<6);  /* Output mode, max speed 10 MHz; Alternate function output Push-pull */

	/* PA10(USART1_RX) */
	*pReg &= ~((3<<8) | (3<<10));
	*pReg |= (0<<8) | (1<<10);  /* Input mode (reset state); Floating input (reset state) */
	
	/* 设置波特率
	 * 115200 = 8000000/16/USARTDIV
	 * USARTDIV = 4.34
	 * DIV_Mantissa = 4
	 * DIV_Fraction / 16 = 0.34
	 * DIV_Fraction = 16*0.34 = 5
	 * 真实波特率:
	 * DIV_Fraction / 16 = 5/16=0.3125
	 * USARTDIV = DIV_Mantissa + DIV_Fraction / 16 = 4.3125
	 * baudrate = 8000000/16/4.3125 = 115942
 	 */
#define DIV_Mantissa 4
#define DIV_Fraction 5
	usart1->BRR = (DIV_Mantissa<<4) | (DIV_Fraction);
	
	/* 设置数据格式: 8n1 */
	usart1->CR1 = (1<<13) | (0<<12) | (0<<10) | (1<<3) | (1<<2);	
	usart1->CR2 &= ~(3<<12);
	
	/* 使能USART1 */
}
	
int getchar(void)
{
	USART_TypeDef *usart1 = (USART_TypeDef *)0x40013800;
	while ((usart1->SR & (1<<5)) == 0);
	return usart1->DR;
}

int putchar(char c)
{
	USART_TypeDef *usart1 = (USART_TypeDef *)0x40013800;
	while ((usart1->SR & (1<<7)) == 0);
	usart1->DR = c;
	
	return c;
}


  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值