嵌入式入门到入土

本文旨在说明嵌入式的相关知识和原理。将从C程序从撰写到被执行的入门到入土之路。了解清楚该文档,可以针对一些疑难杂症,对代码、工程、编译进行一定的定位排查。

图片有摘引,如有侵权,请联系删除。图片有私印,可以查找对应原文作者。

Written by: Zhai Xiufeng

目录

第一章:C之力一段-编译... 2

预编译:... 2

编译:... 2

汇编:... 2

链接:... 2

执行文件... 3

编译生成的执行文件包含的主要内容... 3

第二章:执行文件怎么被执行的... 4

MCU组成... 4

内核寄存器... 4

MCU怎么运行... 5

取指令流程... 6

指令是什么... 6

数据传输命令 MOV.. 6

状态寄存器访问 MRS 和 MSR. 7

存储器访问 LDR 和 STR. 8

压栈和出栈 PUSH 和 POP. 9

跳转指令 B 和 BL. 10

ADD.. 11

第三章:基础问题分析... 12

编译问题分析... 12

手动配置代码存储位置和Ram引导位置的流程... 13

DUMP问题分析... 14

手动排查问题的流程... 14

第一章:C之力一段-编译

编译可分四个阶段:预处理 -> 编译 -> 汇编 -> 链接

预编译:

将源文件hello.c和相关的头文件被预编译器编译成一个hello.ii文件(c语言的话.c文件预编译成.i文件)

1.将#define 删除,展开所有的宏定义

2.处理条件预编译指令,把包含的文件插入到预编译指令的位置(递归的过程)

3.删除注释

4.添加行号,文件名标示

5.保留#progma编译器指令

编译:

把预处理完的文件进行一系列的词法分析、语法分析以及优化后生成相应的汇编代码文件。

汇编:

汇编器将汇编代码转为机器可以执行的指令。每个汇编语句都对应一个机器指令。其实就是将.s文件转换为.o目标文件。

链接:

得到.out文件。用符号来实现。当一个程序很复杂的时候,我们把每个源代码模块独立的编译,然后按照要求进行“组装”(合并段表和符号表),这个组装的过程就是链接。

执行文件

链接完成后的文件就是执行文件,一般为Bin/Hex文件。

链接生成的Bin文件是一种原始的、未经处理的二进制数据文件,其中包含了机器码和数据,以及其他与执行文件相关的信息。它通常以二进制格式存储,每个字节直接对应着内存中的数据。通常IAP通过非Debug升级方式烧录的话,用的都是Bin文件。

链接生成的Hex文件是将二进制文件的内容以十六进制形式进行编码的文本文件。它包含原始的二进制数据,但以十六进制的方式进行了表示,通常以ASCII字符形式呈现。

需要强调的是Hex文件会包含有地址信息,以下是Hex每行具体包含的信息:

起始码(Start Code): 通常为冒号(:),表示一条Hex记录的开始。

字节数(Byte Count): 表示当前记录所包含的数据字节数。

地址(Address): 通常是两字节或四字节,表示数据在内存中的地址。

记录类型(Record Type): 指定数据记录的类型,例如数据记录、扩展线性地址记录等。

数据(Data): 表示实际的二进制数据,以十六进制形式表示。

校验和(Checksum): 用于校验数据的正确性。

例如::0400000500000000CD2E

:04:表示记录的长度(字节数),这里是4个字节。

0000:表示记录的地址,即数据应该存储的内存地址。在这里,地址为0x0000。

05:表示记录类型,这里是数据记录。

0000:表示数据本身,这里是两个字节的数据。

CD:表示校验和,即所有前面字段的和的补码。

2E:表示记录的结束符,即换行符。

编译生成的执行文件包含的主要内容

代码段(Text Segment): 包含程序的机器码指令,即可执行代码。这是程序的主要逻辑和功能所在。一般是自启动文件到main函数,直到main函数运行结束。

数据段(Data Segment): 包含程序中定义的全局变量和静态变量的初始值。这些数据在程序启动时将被加载到内存中。

堆栈段(Stack Segment): 包含程序的运行时堆栈,用于存储局部变量、函数调用信息等。堆栈段的具体实现可能受到操作系统和硬件平台的影响。

符号表(Symbol Table): 包含程序中定义的全局变量和函数的信息,以及它们在内存中的地址。这有助于调试和链接过程。

第二章:执行文件怎么被执行的

MCU组成

首先需要了解MCU的大致组成:

中央处理器(CPU): MCU的核心是中央处理器,它执行指令并控制程序的运行。不同的MCU可能使用不同的CPU架构,如ARM、RISC-V等,一般也会叫做内核。

存储器(Memory): MCU包含两种主要类型的存储器:

闪存(Flash Memory): 用于存储程序代码。闪存是非易失性存储器,主要用于存放程序代码。

随机存储器(RAM): 用于存储程序的运行时数据。RAM是易失性存储器,其内容在断电后丢失。

内核寄存器

内核中具有以下寄存器。

https://img2018.cnblogs.com/blog/333765/201907/333765-20190724151831662-1336067145.jpg

R0通常用于存储函数返回值。当运行函数结束时,用作返回值寄器

R0-3用作传入函数参数,传出函数返回值。在子程序调用之间,可以将 r0-r3 用于任何用途。被调用函数在返回之前不必恢复 r0-r3。如果调用函数需要再次使用 r0-r3 的内容,则它必须保留这些内。

R4-11:被用来存放函数的局部变量。如果被调用函数使用了这些寄存器,它在返回之前必须恢复这些寄存器的值。

R12是内部调用暂时寄存器ip。它在过程链接胶合代码(例如,交互操作胶合代码)中用于此角色。在过程调用之间,可以将它用于任何用途。被调用函数在返回之前不必恢复 r12。

R13: 两个堆栈指针(SP)

R14连接寄存器(LR)

R15程序计数寄存器(PC)

R0‐R12 都是 32 位通用寄存器,用于数据操作。但是注意:绝大多数 16 位 Thumb 指令只能访 问 R0‐R7,而 32 位 Thumb‐2 指令可以访问所有寄存器(指令集跟MCU和编译器有关系)。

MCU怎么运行

了解了MCU有什么东西,我们就可以了解程序是怎么被MCU已有的一些“工具”运行起来的。

首先我们烧录程序,烧录是将程序代码存储在Flash存储器中。当MCU上电或复位时,它会从Flash存储器中加载程序代码并开始执行。具体流程如下:

  1. 上电/复位: 当MCU上电或发生复位时,硬件初始化阶段开始。芯片内部的电源管理模块激活并初始化内部电路并使 MCU 准备好运行(必要的Flash上电,Ram上电,内核上电,恢复成默认状态,不会全部打开),并且基础寄存器被初始化。
     
  2. 启动向量表: 在ARM Cortex-M架构中,通常有一个启动向量表(Vector Table),其中包含了在不同的中断和异常条件下的处理函数入口地址。其中一个入口是复位向量,指示复位后开始执行的地址,一般STM32中断名字叫做Reset_IRQ。MCU会从启动向量表中获取初始执行地址(PC直接设置成复位中断的函数起始地址,该函数处于.s的启动文件中)。最后然后跳转到复位处理函数。
     
  3. 复位处理: MCU执行复位处理,这包括设置堆栈指针、初始化寄存器等。在这个阶段,会加载Flash存储器中的启动代码,也就是S启动文件。会通过汇编跳转到对应的执行接口中去,主要包含对应 ①不同系的内核的功能(浮点计算,交换机矩阵读取发布,双核配置)使能,内核内特殊寄存器(比较寄存器)的初始化(复位) ②主频的锁相环的初始化(复位)③MCU内部的Ram和Flash时钟使能。期间还会伴有不同的软件的补充初始化,比如__iar_init_vfp就是iar的向量浮点处理器初始化和设置PSP的值为MSP的值(因为程序初始化时,都会保持PSP和MSP一致。启动时,还处于单线程中,保持一致的值能够简化初始化流程)。步骤1和步骤2加载代码的功能由MCU厂家出厂就会烧录,储存在ROM内,一次性烧录,一般无法更改。步骤3一般是由启动文件提供,整个流程步骤1、2,3整个是个启动的BootLoader,也就是一级BootLoader。如果我们功能开发时,有IAP功能,则我们开发的BootLoader是二级BootLoader。一级 BootLoader 通常由芯片厂商烧录死或提供,用于基本的启动和初始化和跳转到main函数,而二级 BootLoader 则可以由开发者自行编写,提供更多的自定义功能,比如支持OTA固件更新。
     
  4. 初始化:步骤3中跳出到启动文件中运行环境的初始化。例如初始化RAM(初始化数据段(从Flash内读取出来),清零BSS段,其中还有C++需要调用静态对象的构造函数)、配置时钟、初始化外设等。
     
  5. Flash存储器读取:代码通常包含在Flash存储器中。MCU从Flash中读取程序代码到内部RAM或直接执行。一般是Nor Flash(MCU的内部Flash一般为Nor Flash)就会直接执行。
     
  6. 跳转到主程序: 执行咱们自己的程序业务和逻辑。
     

以上就是程序执行的流程,其中第4步是比较重要的一部,关乎一些疑难杂症的排查。

取指令流程

芯片的Flash指令被Cortex内核读取,其中取指令(Instruction Fetch)具体操作为: 微处理器根据程序计数器(PC)从存储器中(通常是Flash存储器或RAM)读取下一条指令。

译码(Decode): 取得的指令被解释为实际的操作,这包括操作码(opcode)以及操作数的地址或立即数。解释操作码的过程被称为指令译码。

执行(Execute): 根据指令的操作码和操作数,微处理器执行相应的操作。这可能涉及对寄存器、内存或其他外设的读写、算术运算、逻辑运算等操作。

访存(Memory Access): 如果指令需要访问内存(例如,从内存读取数据或将数据写入内存),则进行相应的内存访问操作,汇编中也叫出栈操作。

写回(Write Back): 如果指令产生了结果,并且需要将结果写回寄存器或内存,则进行写回操作,汇编中也叫压栈操作。

更新程序计数器(Update Program Counter): 程序计数器被更新为下一条指令的地址,以准备执行下一条指令,ARM指令一般是+= 4,比如目前为0x0800018e,执行一个指令后,下一步就为0x08000192,内核就会去取该地址的指令进行下一步运行操作。

指令是什么

指令:指令是能被计算机中央处理器(CPU)的控制单元解释和执行的语句。

汇编是我们程序员能够读懂的机器指令,所以一般也叫汇编指令。常见的汇编指令 汇编指令可以分为几大类:数据处理、内存访问、跳转、饱和运算、其他指令。

以下讲解部分基础指令,工作中如果遇到不包含的指令可以通过查找汇编指令表进行了解学习。

数据传输命令 MOV

MOV指令,用于将数据从一个寄存器拷贝到另外一个寄存器,或者将一个立即数传递到寄存器。

指令格式:

MOV <destination>, <source>

destination:表示数据传送的目标操作数,通常是寄存器、内存地址或其他数据存储位置。

source:表示数据传送的源操作数,通常也是寄存器、内存地址或立即数。

例如:

MOV R0R1 ;将寄存器R1中的数据传递给R0,即R0=R1

MOV R0, #0X12 ;将立即数0X12传递给R0寄存器,即R0=0X12

状态寄存器访问 MRS 和 MSR

MRS

MRS指令,用于将特殊寄存器(如CPSR和SPSR)中的数据传递给通用寄存器。

指令格式:

MRS <destination>, <system_register>

destination:表示数据传送的目标通用寄存器。

system_register:表示要读取的系统寄存器的名称。

举例:

MRS R0, APSR ;单独读APSR

MRS R0, PSR ; 读组合程序状态

MSR

MSR指令,和MRS相反,用来将普通寄存器的数据传递给特殊寄存器。

指令格式:

MSR <system_register>, <source>

system_register:表示数据传送的目标系统寄存器。

source:表示要传送到系统寄存器的通用寄存器的值。

举例:

MRS R0, CPSR ; 读组合程序状态

MSR CPSR,R0 ;传送R0的内容到CPSR

存储器访问 LDR 和 STR

LDR

LDR 指令用于从存储器中将一个32位的字数据传送到目的寄存器中。该指令通常用于从存储器中读取32位的字数据到通用寄存器,然后对数据进行处理。

指令格式:

LDR <destination>, <source>

destination:表示数据加载的目标寄存器。

source:表示数据的来源,可以是内存地址、寄存器或者偏移量。

注:当程序计数器PC作为目的寄存器时,指令从存储器中读取的字数据被当作目的地址,从而可以实现

LDRB: 字节操作

LDRH: 半字操作

举例:

LDR Rd, [Rn , #offset] ;从存储器Rn+offset的位置读取数据存放到Rd中。

LDR R0, =0X02077004 ;伪指令,将寄存器地址 0X02077004 加载到 R0 中,即R0=0X02077004

LDR R1, [R0] ;读取地址 0X02077004 中的数据到 R1 寄存器中

LDR R0,[R1R2] ;将存储器地址为R1+R2的字数据读入寄存器R0

LDR R0,[R1,#8] ;将存储器地址为R1+8的字数据读入寄存器R0

LDR R0,[R1R2LSL2]! ;将存储器地址R1R2×4的字数据读入寄存器R0,并将新地址R1R2×4 R1

LDR R0,[R1]R2LSL2 ;将存储器地址R1的字数据读入寄存器R0,并将新地址R1R2×4写入R1

LDRH R0,[R1] ;将存储器地址为R1的半字数据读入寄存器R0,并将R0的高16位清零。

STR:

STR 指令用于从源寄存器中将一个32位的字数据传送到存储器中。该指令在程序设计中比较常用, 且寻址方式灵活多样,使用方式可参考指令LDR。

指令格式:

STR <source>, <destination>

source:表示要存储的数据的来源,可以是寄存器或者立即数。

destination:表示存储的目标位置,通常是内存地址。

注:

STRB: 字节操作,从源寄存器中将一个8位的字节数据传送到存储器中。该字节数据为源寄存器中的 低8位。

STRH: 半字操作,从源寄存器中将一个16位的半字数据传送到存储器中。该半字数据为源寄存器中 的低16位。程序流程的跳转。

例如:

STR Rd, [Rn, #offset] ;Rd中的数据写入到存储器中的Rn+offset位置。

LDR R0, =0X02077004 ;将寄存器地址 0X02077004 加载到 R0 中,即 R0=0X02077004 LDR R1, =0X2000060c ;R1 保存要写入到寄存器的值,即 R1=0X2000060c

STR R1, [R0] ; R1 中的值写入到 R0 中所保存的地址中

STR R0[R1],#8 ;R0中的字数据写入以R1为地址的存储器中,并将新地址R18写入R1

STR R0[R1,#8] ;R0中的字数据写入以R18为地址的存储器中。

压栈和出栈 PUSH 和 POP

PUSH :

PUSH压栈,将寄存器中的内容,保存到堆栈指针指向的内存上面,将寄存器列表存入栈中。

指令格式:

PUSH < operand>

operand:表示要推送到堆栈的操作数,可以是寄存器、内存地址或者立即数。

例如:

push {R0, R1} ;保存R0,R1

push {R0~R3,R12} ;保存 R0~R3 R12,入栈

POP :

POP出栈,从栈中恢复寄存器列表

指令格式:

POP <destination>

destination:表示数据弹出后的目标操作数,可以是寄存器或者内存地址。

例如:

pop {R0~R3} ;恢复R0 R3 ,出栈

跳转指令 B 和 BL

B

ARM 处理器将立即跳转到指定的目标地址,不再返回原地址。

指令格式:

B < label>

label:表示代码中的标签,用于标记某个位置。

注意存储在跳转指令中的实际值是相对当前PC值的一个偏移量,而不是一个绝对地址,它的值由汇编器来计算。

例如:

//设置栈顶指针后跳转到C语言

_start: ldr sp,=0X80200000 ;设置栈指针

b main ;跳到 main 函数

BL

BL 跳转指令,在跳转之前会在寄存器LR(R14)中保存当前PC寄存器值,所以可以通过将LR 寄存器中 的值重新加载到PC中来继续从跳转之前的代码处运行,是子程序调用的常用的方法。

指令格式:

BL <subroutine>

subroutine:表示代码中的标签,用于标记子程序或函数的起始位置。

例如:

BL my_function   ; 调用名为 "my_function" 的子程序或函数

BLX

该跳转指令是当子程序使用Thumb指令集,而调用者使用ARM指令集时使用。BLX指令从ARM指令集跳转到指令中所指定的目标地址,并将处理器的工作状态有ARM状态切换到 Thumb状态,该指令同时将PC的当前内容保存到寄存器R14中。

命令格式:

BLX <destination>

destination:表示代码中的标签或直接指定的内存地址,用于标记或指定跳转的目的地。

BX

BX指令跳转到指令中所指定的目标地址,目标地址处的指令既可以是ARM指令,也可以是Thumb 指令。

命令格式:

BX <destination>

destination:表示代码中的标签或直接指定的内存地址,用于标记或指定跳转的目的地。

ADD

算数运算指令,加法运算。

命令格式:

ADD destination, operand1, operand2

destination:目标寄存器,用于存储加法结果。

operand1:第一个操作数,可以是寄存器、内存地址或立即数。

operand2:第二个操作数,可以是寄存器、内存地址或立即数。

例如:

add r7, sp, #0;r7 = sp + 0sp指向的栈指针偏移0

以下以一串代码,和编译生成的汇编指令代码进行举例,编译环境为GCC。

C语言代码:

int lelouch(int num)

{

    int i = num + 1;

    return i;

}

int main(void) {

    int j = 100;

    j = lelouch(j);

    return j * j;

}

GCC编译后对应的汇编代码:

lelouch(int):

        push    {r7}//保存寄存器r7的值到栈上

        sub     sp, sp, #20//分配20字节的栈空间

        add     r7, sp, #0//r7指向当前栈的顶部

        str     r0, [r7, #4]// 将函数参数存储到栈上(r0保存到[r7+4]的位置)

        ldr     r3, [r7, #4]// 从栈中加载函数参数到r3寄存器

        adds    r3, r3, #1//r3 += 1

        str     r3, [r7, #12]// 将结果存储到栈上([r7+12]位置)

        ldr     r3, [r7, #12]// 从栈中加载结果到r3寄存器

        mov     r0, r3//将结果传递给函数返回值

        adds    r7, r7, #20//回收栈空间

        mov     sp, r7//恢复栈指针

        ldr     r7, [sp], #4//弹出栈顶的值到r7

        bx      lr//函数返回

main://MCU经过前面的启动准备工作,最终会跳转到main函数。

        push    {r7, lr}//r7 lrR14)寄存器存入栈中,防止数据丢失。

        sub     sp, sp, #8//sp(堆栈指针) = sp – 8,分配8字节的栈空间,储存r7lr两个寄存器内的值。注意:栈是大到小的。

        add     r7, sp, #0//r7指向当前栈的顶部

        movs    r3, #100//将立即数100加载到r3

        str     r3, [r7, #4]// r3存储到栈上([r7+4]位置)

        ldr     r0, [r7, #4]//从栈中加载值到r0寄存器

        bl      lelouch(int)// 调用lelouch函数

        str     r0, [r7, #4]// lelouch函数的返回值存储到栈上([r7+4]位置)

        ldr     r3, [r7, #4]// 从栈中加载值到r3寄存器

        mul     r3, r3, r3   //r3 *= r3

        mov     r0, r3     //r3传递给函数返回值

        adds    r7, r7, #8  //回收栈空间

        mov     sp, r7      //恢复栈指针

        pop     {r7, pc}    //弹出栈顶的值到r7,并跳转到r7保存的地址(lr

第三章:基础问题分析

编译问题分析

我们知道编译的流程和操作是固定的,当编译出现问题。按照平时开发习惯都会检查语法错误,这个语法错误一般都可以在网络上找到对应的问题和解决办法。

   当新建工程和因为配置文件不明确的时候,会出现一些编译问题。或者在开发中需要打包lib文件时,需要对编译问题进行分析。

  调试GD32项目时,由于配置和IDE是由官方魔改的STM32,无法用CubeMx自动生成工程那么当问题发生时就需要对配置文件或者工程进行修改,首先当出现配置的Flash和Ram不对时会报以下错误:分段放置失败。

  该问题通常涉及到堆栈、代码段、数据段等在内存中的分布。手动配置这些分配可能包括修改链接脚本(Linker Script)中的相关设置。

根据MCU的构成,与代码运行原理,可以了解到堆栈和代码段存放时有固定位置的。这些位置一般都是有配置文件在编译时去划分,在MCU启动时被执行的。在配置文件不会被自动配置的情况下,需要自己手动配置。

手动配置代码存储位置和Ram引导位置的流程

举例:

根据查阅的芯片手册,我们可以对工程进行配置

IAR在工程中,可以通过软件对编译项进行配置。编译生成的文件就能符合芯片启动引导程序。

DUMP问题分析

我们知道程序运行的逻辑是按照流程进行的。一般调试工具都会提供程序调用的流程供开发者进行问题分析,但是偶然会出现程序异常,特别出现程序指针跑飞的时候,调试工具无法给出调用流程,我们如何根据已有的知识去解决问题。以下问题就可以用上前面的原理知识。

  第二章程序怎么被运行起来的能了解到,一切都是有迹可循的。

当问题发生,程序会陷入到异常中断中。一般为HardFault,问题可能是由于空指针解引用、栈溢出等原因引起。那么我们如何去根据前面了解的程序运行的轨迹或者流程来定位问题呢。

  内核会根据程序计数器(PC)和堆栈指针(SP)从Flash存储器中提取指令,并随着程序的执行不断进行出栈和压栈操作。这两个寄存器提供了关键的上下文信息,有助于确定异常发生的位置和函数调用关系。因此,我们可以根据这些寄存器的特殊作用和值,以及与之对应的栈的存储情况,还原出问题发生时的现场。

https://pic2.zhimg.com/v2-e4efa7625d0b18c58a274852e2a0e731_r.jpg

手动排查问题的流程

  1. 首先我们可以根据PC寄存器定位到当前出现问题的函数名称和出现问题的指令节点。
  2. 根据LR寄存器可以定位到调用出现问题的函数的地址。
  3. 查找出所有的调用关系,根据PUSH和LR对出现的地址进行一个一个比照,查找出对应的调用关系。一般该步骤可以通过脚本或者软件(trace32)完成。

举例:

// 内联汇编获取寄存器的值

#define GET_REGISTER(reg, value) \

    __asm__ __volatile__("mov %0, " #reg : "=r"(value));

void print_registers() {

    // 定义变量存储寄存器的值

    int r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12;

    int lr,pc,sp;

    // 获取寄存器的值

    GET_REGISTER(r0, r0);

    GET_REGISTER(r1, r1);

    GET_REGISTER(r2, r2);

    GET_REGISTER(r3, r3);

    GET_REGISTER(r4, r4);

    GET_REGISTER(r5, r5);

    GET_REGISTER(r6, r6);

    GET_REGISTER(r7, r7);

    GET_REGISTER(r8, r8);

    GET_REGISTER(r9, r9);

    GET_REGISTER(r10, r10);

    GET_REGISTER(r11, r11);

    GET_REGISTER(r12, r12);

    // 获取指针的值

    __asm__ __volatile__("mov %0, lr" : "=r"(lr));

    __asm__ __volatile__("mov %0, pc" : "=r"(pc));

    __asm__ __volatile__("mov %0, sp" : "=r"(sp));

    // 打印寄存器的值

    printf("r0-r12 values: %08x, %08x,%08x, %08x,%08x, %08x,%08x, %08x,%08x, %08x, %08x, %08x, %08x\n",

            r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12);

    printf("lr value: %08x\n", lr);

    printf("pc value: %08x\n", pc);

    for(int i = 0;i ++;i <= 10)

    {

        printf("Stack value: %08x\n", *((int *)sp + i));

    }

   

    while(1);

}

将以上函数移植放入异常中断内,打印出dump信息如下。

r0-r12 values: 0x00000000,0x00000000,0x00000001,0xe0000000,0xe000ed14,0x00000000,0x00000000,0x00000000,0x00000000, 0x00000000,0x00000000,0x00000000,0x00000000

lr value: 0x08000263

pc value: 0x0800028c

Stack value: 0xe000ed14

Stack value: 0x 08000235

Stack value: 0xe000ed14

Stack value: 0x0800042d

Stack value: 0x00000000

Stack value: 0x080009d3

Stack value: 0x00000000

Stack value: 0x080002d7

Stack value: 0x00000000
Stack value: 0x00000000

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值