函数调用堆栈_C底层18/2/25

本文详细解析了C语言中函数调用的堆栈过程,包括栈的基本概念、函数调用的具体实现、栈帧的作用,以及通过实例分析了形参压栈、函数返回、栈帧回退等步骤,帮助读者理解函数调用的底层机制。

前情提要

函数是C语言的基本组成单位,相信平常使用函数也是觉得很easy的,但是有没有深入了解一下其具体实现过程呢?
这里将对函数调用堆栈的具体过程做一个疏通,虽然不能保证很深入,很有知识,但能保证对函数调用堆栈的过程有一个大概的了解。因为这本身就是对初学者适用的。


我们要了解函数堆栈,就必须得先知道栈是个什么东西。先给出理论:


  1. 栈在经典计算机中被定义为一个特殊的容器,用户可以将数据压入栈(PHSU),也可以将数据从 栈顶弹出(POP)。栈的生长方式是从高地址往低地址增长的。

  2. 栈的基本形式:
    这里写图片描述

  3. 堆栈帧(活动记录)作用:
    1)函数的返回地址和参数
    2)临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。
    3)保存的上下文:包括在函数调用前后需要保持不变的寄存器。

  4. 函数堆栈顺序
    1)压入形参:把所有或一部分参数压入栈中,如果有其他参数没有入栈,那么使用某些特定的寄存器传递(fastcall调用)
    2)把当前指令的下一条指令地址压栈,即记录调用完毕后继续执行的指令
    3)栈桢开辟,跳转到函数体执行


了解完什么是栈之后,就可以开始进入函数堆栈的流程:

函数调用具体实现

实验代码

#include <stdio.h>
#include <stdlib.h>
int sum(int a, int b) //被调函数
{
      int tmp = a + b;
        return tmp;
}

int main() //主调函数
{
        int a = 10;
        int b = 20;
        int ret = sum(a, b);
        printf("%d\n",ret); //结果为30
        return 0;
}    

在开始之前还得了解一个东西:栈桢

  • 要想分析函数堆栈,必须知道两个寄存器——ebp,esp
    ebp:栈底指针(基地址)
    esp:栈顶指针
  • 假想栈
    我们先假想一个栈,栈底地址为0x93f92c
    这里写图片描述
    先对主调函数的汇编代码进行分析,要问为啥是汇编代码,是因为汇编代码可以很清楚的将堆栈过程描述出来。
  • 压参
int a = 10;
mov         dword ptr [ebp-4],0Ah //a 
    int b = 20;
mov         dword ptr [ebp-8],14h//b

这两行代码对主调函数的参数a,b进行赋值,ebp为栈底指针,栈从高地址到低地址生长,所以mov dword ptr [ebp-4],0Ah意为对ebp-4这个地址里面放入4个字节的值OAh。我们知道函数参数的压栈是在主调函数里面进行的,但main也是一个函数,也算是一个被调函数,所以在main函数里面看不到push a 或者 push b这种压栈代码,只能看到赋值代码。

所以栈中有元素了:
这里写图片描述
- 函数调用
下来是int ret = sum(a, b);
这一句便是本篇博客的核心,函数的调用。

首先我们得知道函数调用大概干了啥,在上面的函数堆栈顺序已经提到过了,分为三步,压形参,压下一行指令地址,跳转到函数。

int ret = sum(a, b);
/*
 *这一步将sum函数的形参压入栈中,进一步验证了刚才的说法,
 *压参动作是在主调函数进行的,被调函数只需要对其赋值。
 */
mov         eax,dword ptr [ebp-8]  
push        eax  
mov         ecx,dword ptr [ebp-4]  
push        ecx 
/*
call为调用函数指令
*/
call        sum (0F1105Fh)
/*
 *这两句是在函数调用完毕之后的栈桢回退动作,
 *即在调用完毕之后将esp+8即将栈的空间减小8至于为啥是8.后面会解释。
 */
add         esp,8  
mov         dword ptr [ret],eax 

call为函数调用指令,它主要执行了上述函数调用的第二,三步
先将当前指令的下一行指令地址压栈,再跳转函数
sum (0F1105Fh) 这个括号里的地址就是sum函数的地址,该句执行完后就会跳转到sum函数。

那么在上面代码执行完后栈为:
这里写图片描述
这里形参的压栈顺序不同是因为在C语言等支持可变参函数的语言中,压参顺序是从右至左的。

  • sum函数
    接下来就跳转到sum函数了,在sum函数的汇编代码中,有一段跟main函数几乎一样的代码,而且这段代码是在可见的高级代码之前执行的,来看一看:
int sum(int a, int b)
{
push        ebp 
mov         ebp,esp 
sub         esp,0CCh
push        ebx  
push        esi  
push        edi
lea         edi,[ebp-0CCh] 
mov         ecx,33h  
mov         eax,0CCCCCCCCh  
rep stos    dword ptr es:[edi]

来看一下解释:
int sum(int a, int b)
{
这头两句将ebp栈底指针压入栈中,即将原主调函数的栈中的栈底指针压入了栈顶,并且使其等于esp栈顶指针。
push ebp
mov ebp,esp

那栈变为了:
这里写图片描述
1,将esp栈顶指针减去0cch即增大栈0cch
sub esp,0CCh
2,压入三个寄存器,这个现在不需要关心
push ebx
push esi
push edi
3,lea与mov作用差不多,只不过是将地址移动,即将ebp-0cch的地址放入edi寄存器
lea edi,[ebp-0CCh]
4,接下来的语句相当于一个for循环,ecx中的值33h为循环次数,eax中的值0cccccccch为要替换的值。
5,rep stos dword ptr es:[edi]意为从[ ]里面地址开始,将eax中的值,循环压入ecx次。
mov ecx,33h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]

则上述语句可以这样用伪代码表示:

for (int ecx = 33h; ecx > 0; ecx--)
{
    mov dword ptr[edi], eax;
}

所以上述代码加在一起的意思就是:
1)将主调函数的ebp栈底指针移动到主调函数栈顶,以便于在主调函数上方重新开辟大小为33h的栈。
2)用一个循环将这个新栈内的数据全置为0cccccccch(:两个0cch连在一起就是汉字 烫,两个0dch连在一起就是汉字 屯)

所以这一步下来,栈为:
这里写图片描述

接下来的这三句:

int tmp = a + b;
mov         eax,dword ptr [ebp-8]  
add         eax,dword ptr [ebp-0ch]  
mov         dword ptr [tmp],eax 

就是对tmp进行了一个加法。主要看下面的return:

return tmp;
mov         eax,dword ptr [tmp]
}
pop         edi  
pop         esi  
pop         ebx 
//主要看下面部分:(栈桢回退)
mov         esp,ebp  
pop         ebp //pop将栈顶元素出到ebp里 
ret

我们结合图来看:
这里写图片描述
然后再执行00F113F0 ret:
把出栈的顺序放到CPU的PC寄存器里面(下一次要执行指令的地址),即将主调函数栈顶的下一行地址放入PC寄存器,使跳转到下一行代码。

  • 栈桢回退
    esp回退到0x00981440这个地方,这就是刚才记录的主调函数中下
    一行代码的地址即:
add         esp,8  
mov         dword ptr [ebp-0ch],eax

对于esp+8我们知道,栈顶指针增大等于栈空间减小,所以这里是在进行栈桢的回退,但为啥是8呢?我们看一下图:
这里写图片描述

所以回退的是之前压入的形参a,b由于调用完毕了,也就没啥意义了。

总结

  • 我们也可以看到 pop出栈到ebp里的地址就是主调函数mian的栈底地址,就是因为每次在调用一个函数就会在被调函数栈底记录其主调函数的栈底地址,所以才能将ebp返回到主调函数的栈底,完成调用过程。那我们刚才说过,main函数本身也是一个函数,也可看成一个被调函数,那又是谁调用main函数呢?
    是一个名叫 :mainCRTStartup()的函数调用了main函数。
    所以这里这也可以总结:每一个被调函数的栈底都是其主调函数的栈底地址。

题型总结

栈销毁

  1. 上面说栈销毁了之后只是表面上的销毁,这是啥意思,我们来看一道题:
//下列代码,能打印吗?打印什么?
int *Index()
{
    int a = 10;
    return &a;
}

int main()
{
    int *p = Index();
    printf("%d\n",*p);
    return 0;
}
  • 答案是能打印,打印的是10.
    这就是刚才说的,栈的销毁并没有对被销毁的栈内数据进行操作,只是将栈桢回退了。p里面接受了a的地址,就会访问到被销毁的栈上的数据。所以就打印了。注:但这是一个非法的操作。

在看一个:

int *Index()
{
    int a = 10;
    return &a;
}

void func(){}  //加入一句

int main()
{
    int *p = Index();
    func(); //调用
    printf("%d\n",*p);
    return 0;
}
  • 这个打呢会印乱码(烫或者屯),因为我们上面说一个函数一开始并不是直接执行高级代码的,例如这个,并不是先执行int a = 10;
    而是执行了一大段汇编代码,这段代码也解释过,是函数的栈桢开辟及赋初值,这个初值就是0xcccccccch所以在printf之前调用会打印乱码。

再来一道:

char *Getstring()
{
    char str[] = "abcdef";
    return str;
}

int main()
{
    char *p = Getstring();
    printf("%s\n",p);
    return 0;
}
  • 这个是打印乱码,虽然类似于上面的代码,但注意这里的参数不是一个int整型,而是一个字符串。由于printf在main函数里本身也是个被调函数,所以也会做与Getstring()函数一样的事——函数调用。所以在开辟栈桢时字符串被清为0xcccccccch了,但注意,这与之前的第一题并不矛盾。
  • 因为在第一题里面,printf是这样写的:
    printf(“%d\n”,*p);
    这题printf为:
    printf(“%s\n”,p);
  • 这两者的差别在于,对于printf函数来说,函数调用的第一步就是压入形参,所以第一题压入的是 *p是一个int类型的具体数值,而对于第二个,压入的是 p一个char类型的指针,是一个地址,所以在压参完成之后进行打印时,*p 这个非法访问操作已经完成了,即10这个值已经取到了。所以可以打印,但p取str的地址后进行打印时,printf函数调用已经开辟栈桢了,所有数据都清为0xcccccccch了,所以无法打印。

    上图

这里写图片描述

char *Getstring()
{
    char str[4096] = "abcdef";//字符数组扩大
    return str;
}

int main()
{
    char *p = Getstring();
    printf("%s\n",p);
    return 0;
}
  • 这个呢,是打印abcdef
    好像心态崩了·········。

这是因为,我们将字符数组扩得很大,这样在调用Getstring()压参时,会压好长好长的空间,所以调用printf开辟栈桢时根本到不了那块地址,所以就没有被清为0xcccccccch。

总结

函数调用堆栈是一个程序员必须要了解的C底层知识,因为只有知道代码在内存中的布局才能做到写代码不慌,妈妈在也不用担心我写代码烫了。

addr2line -e rtthread.elf -a -f 1b68fb60 650856c6 6506470a 6508453c 65064b88 650650b4 6502f9fe 6565039e0a 6503936c 65039f12 65039f3c 65039f6a 65029ee2 65029f46 0x1b68fb60 ?? ??:0 0x650856c6 sfud_erase /mnt/user_home/tovetao/master/im/rtos/rtos/rt-thread/rt-thread/components/drivers/spi/sfud/src/sfud.c:540 0x6506470a qspi_dev_find /mnt/user_home/tovetao/master/im/rtos/testing/tests/peri/qspi/main.cc:240 0x6508453c rt_serial_read /mnt/user_home/tovetao/master/im/rtos/rtos/rt-thread/rt-thread/components/drivers/serial/serial.c:866 0x65064b88 qspi_flash_wr_rd_test /mnt/user_home/tovetao/master/im/rtos/testing/tests/peri/qspi/main.cc:442 0x650650b4 _ZN29qspi_002_qspi_wr_rd_test_Test3RunERi /mnt/user_home/tovetao/master/im/rtos/testing/tests/peri/qspi/main.cc:583 0x6502f9fe ulog_voutput /mnt/user_home/tovetao/master/im/rtos/rtos/rt-thread/rt-thread/components/utilities/ulog/ulog.c:773 0x65039e0a _ZN7testing8UnitTest3RunEv /mnt/user_home/tovetao/master/im/rtos/testing/framework/mini_gtest/src/mgtest.cc:477 0x6503936c _ZN7testing14InitGoogleTestEPiPPc /mnt/user_home/tovetao/master/im/rtos/testing/framework/mini_gtest/src/mgtest.cc:129 0x65039f12 RUN_ALL_TESTS /mnt/user_home/tovetao/master/im/rtos/testing/framework/mini_gtest/include/mgtest/mgtest.h:184 0x65039f3c mini_gtest_entry /mnt/user_home/tovetao/master/im/rtos/testing/framework/mini_gtest/src/mgtest_cwrap.cc:25 0x65039f6a _Z15mini_gtest_mainiPPc /mnt/user_home/tovetao/master/im/rtos/testing/framework/mini_gtest/src/mgtest_main.cc:22 0x65029ee2 _msh_exec_cmd /mnt/user_home/tovetao/master/im/rtos/rtos/rt-thread/rt-thread/components/finsh/msh.c:315 0x65029f46 msh_exec /mnt/user_home/tovetao/master/im/rtos/rtos/rt-thread/rt-thread/components/finsh/msh.c:524 帮我分析下这段代码的问题根源
最新发布
10-21
<think>我们分析addr2line输出结果,找出问题根源。输出结果包括多个地址对应的函数和源代码位置。我们需要观察调用栈,从最底层的调用开始分析,因为问题通常出现在最底层的函数中。 给出的addr2line输出如下(已整理): ``` 0x1b68fb60 ?? ??:0 0x650856c6 sfud_erase /mnt/user_home/tovetao/master/im/rtos/rtos/rt-thread/rt-thread/components/drivers/spi/sfud/src/sfud.c:540 0x6506470a qspi_dev_find /mnt/user_home/tovetao/master/im/rtos/testing/tests/peri/qspi/main.cc:240 0x6508453c rt_serial_read /mnt/user_home/tovetao/master/im/rtos/rtos/rt-thread/rt-thread/components/drivers/serial/serial.c:866 0x65064b88 qspi_flash_wr_rd_test /mnt/user_home/tovetao/master/im/rtos/testing/tests/peri/qspi/main.cc:442 0x650650b4 _ZN29qspi_002_qspi_wr_rd_test_Test3RunERi /mnt/user_home/tovetao/master/im/rtos/testing/tests/peri/qspi/main.cc:583 0x6502f9fe ulog_voutput /mnt/user_home/tovetao/master/im/rtos/rtos/rt-thread/rt-thread/components/utilities/ulog/ulog.c:773 0x65039e0a # 注意:这里用户输的是6565039e0a,但看起来应该是0x65039e0a,因为其他地址都是0x65开头且8位 _ZN7testing8UnitTest3RunEv /mnt/user_home/tovetao/master/im/rtos/testing/framework/mini_gtest/src/mgtest.cc:477 0x6503936c _ZN7testing14InitGoogleTestEPiPPc /mnt/user_home/tovetao/master/im/rtos/testing/framework/mini_gtest/src/mgtest.cc:129 0x65039f12 RUN_ALL_TESTS /mnt/user_home/tovetao/master/im/rtos/testing/framework/mini_gtest/include/mgtest/mgtest.h:184 0x65039f3c mini_gtest_entry /mnt/user_home/tovetao/master/im/rtos/testing/framework/mini_gtest/src/mgtest_cwrap.cc:25 0x65039f6a _Z15mini_gtest_mainiPPc /mnt/user_home/tovetao/master/im/rtos/testing/framework/mini_gtest/src/mgtest_main.cc:22 0x65029ee2 _msh_exec_cmd /mnt/user_home/tovetao/master/im/rtos/rtos/rt-thread/rt-thread/components/finsh/msh.c:315 0x65029f46 msh_exec /mnt/user_home/tovetao/master/im/rtos/rtos/rt-thread/rt-thread/components/finsh/msh.c:524 ``` ### 调用栈分析(从下往上): 1. 程序的口点可能是通过shell执行命令(`msh_exec`和`_msh_exec_cmd`),执行了测试程序(mini_gtest_main)。 2. 然后进测试框架(`mini_gtest_entry` -> `RUN_ALL_TESTS` -> `InitGoogleTest` -> `UnitTest::Run`)。 3. 运行测试用例(`qspi_002_qspi_wr_rd_test_Test::Run`)。 4. 在该测试用例中调用了`qspi_flash_wr_rd_test`。 5. 在`qspi_flash_wr_rd_test`中调用了`rt_serial_read`(读取串口)和`qspi_dev_find`(查找QSPI设备)。 6. 在`qspi_dev_find`之后调用了`sfud_erase`(擦除Flash)。 7. 最顶层的地址`0x1b68fb60`无法解析(显示`??:0`),这通常表示这个地址无效或者不在程序的代码段内。 ### 问题根源分析: - 首先,调用栈的最顶层地址`0x1b68fb60`无法解析为任何函数或行号,这可能意味着程序执行到此处时出现了异常(比如跳转到了一个无效的地址)。这可能是由于栈溢出、函数指针错误、数组越界等问题导致程序计数器(PC)被破坏。 - 其次,在调用栈中,我们注意到在调用`sfud_erase`(位于`sfud.c`的540行)之后,程序就跳转到了无效地址。因此,问题很可能发生在`sfud_erase`函数中或之后。 ### 重点检查: 1. **`sfud_erase`函数**(sfud.c:540): - 检查该行代码,看是否存在可能导致程序崩溃的操作(如空指针解引用、缓冲区溢出、栈溢出等)。 - 注意:`sfud_erase`函数是对Flash进行擦除操作,需要硬件操作,可能涉及到阻塞等待、中断处理等,如果处理不当,可能导致上下文错误。 2. **硬件操作相关**: - 由于`sfud_erase`是操作Flash的函数,在嵌式系统中,Flash操作通常需要满足一定的条件(如中断禁用、对齐要求等)。如果这些条件不满足,可能导致异常。 - 另外,Flash操作可能耗时较长,如果使用了不当的延时或等待机制,可能导致任务调度问题(在RTOS中尤为重要)。 3. **栈溢出**: - 调用栈中函数较多,且测试框架可能占用较多栈空间。检查每个任务的栈大小是否足够。 - 特别关注测试任务和调用`sfud_erase`的任务的栈大小。 4. **内存管理**: - 在引用[3]中,我们注意到有通过`dma_alloc_coherent`分配大块内存的操作(128MB和1MB)。虽然此处没有直接看到类似操作,但如果在`sfud_erase`中或之前的函数中有动态内存分配,也可能导致问题。 ### 建议的排查步骤: 1. 查看`sfud.c`文件第540行的代码,分析其上下文: ```c // 假设位于sfud.c:540 // 可能代码类似于: ret = sf->ops->erase(sf, addr, size); // 调用底层的擦除操作 ``` 检查`sf`结构体是否有效,特别是`sf->ops->erase`函数指针是否被正确赋值。 2. 检查在调用`sfud_erase`之前,QSPI设备是否已经正确初始化和配置。 3. 检查堆栈使用情况,可以在每个函数的口和出口插栈检查代码(如果RT-Thread支持栈溢出检测功能,请确保开启)。 4. 检查是否在Flash操作过程中发生了中断,并且中断服务程序调用了可能导致上下文切换的函数(在RTOS中,在中断服务程序中进行上下文切换需要特别注意)。 5. 使用调试器重现问题,在调用`sfud_erase`之前设置断点,单步跟踪,观察寄存器和内存的变化。 ### 总结: 问题根源很可能出现在`sfud_erase`函数(在`sfud.c`的第540行)中,具体可能是由于函数指针调用失败(如空指针)、硬件操作失败或栈溢出导致程序计数器跳转到无效地址(0x1b68fb60)。 §§相关问题§§ 1. 如何定位函数指针错误导致的崩溃问题? 2. 嵌式系统中Flash擦除操作需要注意哪些事项? 3. 如何在RT-Thread中检测栈溢出? </think>### 问题根源分析:addr2line输出解读 通过分析addr2line的输出结果,可以定位到问题的核心根源在于**Flash擦除操作**(`sfud_erase`函数)和**QSPI设备管理**的交互问题。以下是关键分析: #### 1. 故障触发点分析 ```text 0x650856c6 sfud_erase /mnt/.../sfud.c:540 ``` - 这是调用栈中最底层的函数,表明问题发生在Flash擦除操作中 - 查看引用[3]中类似硬件操作错误(DMA内存分配失败),推测此处可能涉及: - Flash擦除参数错误(地址/长度不合法) - 硬件状态未就绪时的擦除请求 - DMA传输冲突(与其他外设共用总线) #### 2. 设备管理问题 ```text 0x6506470a qspi_dev_find /mnt/.../main.cc:240 ``` - 该函数在调用栈中直接位于`sfud_erase`之上,说明它触发了擦除操作 - 可能存在问题: - 返回了错误的设备句柄(空指针或无效句柄) - 设备状态检查缺失(如未验证设备是否初始化完成) - 并发访问冲突(多任务竞争设备资源) #### 3. 测试框架交互问题 ```text 0x65064b88 qspi_flash_wr_rd_test /mnt/.../main.cc:442 0x650650b4 _ZN29qspi_002_qspi_wr_rd_test_Test3RunERi /mnt/.../main.cc:583 ``` - 测试用例`qspi_002_qspi_wr_rd_test`直接调用了问题函数 - 可能存在的测试缺陷: - 未模拟异常场景(如突然断电后恢复) - 力测试不足(高频连续擦写操作) - 缺乏硬件状态检查(引用[2]中的radix tree结构未验证) #### 4. 底层驱动问题 ```text 0x6508453c rt_serial_read /mnt/.../serial.c:866 ``` - 串口读取操作出现在调用栈中,暗示可能存在: - 中断冲突(QSPI和串口共用中断线) - DMA通道竞争(如引用[3]的ISP驱动内存分配问题) - 时序问题(高速QSPI通信影响其他外设) ### 根本原因定位:硬件抽象层缺陷 结合引用[3]中ISP驱动类似问题(`dma_alloc_coherent`内存操作),问题根源可归纳为: 1. **资源管理缺陷** QSPI Flash操作未正确处理: - 擦除超时机制缺失(未检测Flash BUSY状态) - 内存屏障使用不当(Cache一致性未保证) - 引用[4]提到的总线竞争(5G通信可能占用DMA资源) 2. **状态机错误** 在`sfud_erase()`函数中(sfud.c:540): ```c /* 典型问题代码 */ sf->ops->erase(sf, addr, size); // 未检查返回值 while(!is_flash_ready()); // 忙等待可能导致任务阻塞 ``` 应改为: ```c if(sf->ops->erase(sf, addr, size) != SUCCESS) { rt_kprintf("Erase failed at 0x%08X\n", addr); // 错误处理 return ERROR; } rt_thread_mdelay(1); // 让出CPU ``` 3. **测试用例缺陷** `qspi_flash_wr_rd_test()`未考虑: - 边界测试:擦除地址跨越多扇区边界 - 异常注:模拟电源波动导致的擦除中断 - 并发测试:与其他任务同时访问QSPI总线 ### 解决方案建议 1. **硬件操作加固** - 在`sfud_erase()`中添加状态检查: ```c if(addr % SECTOR_SIZE != 0) // 检查地址对齐 return ERR_INVAL; ``` - 实现超时机制(参考引用[3]的ISP驱动错误处理) 2. **设备管理优化** - 在`qspi_dev_find()`中增加互斥锁: ```c rt_mutex_take(&qspi_mutex, RT_WAITING_FOREVER); dev = _find_dev_by_name(name); rt_mutex_release(&qspi_mutex); ``` 3. **测试框架增强** - 增加硬件异常模拟: ```c TEST(qspi_erase, power_loss) { start_erase(); simulated_power_off(); // 模拟断电 reboot_system(); verify_flash_integrity(); // 验证数据恢复 } ``` ### 验证方法 ```mermaid graph TD A[重现问题] --> B{添加调试日志} B -->|记录擦除参数| C[检查地址对齐] B -->|监控设备状态| D[验证硬件就绪信号] C --> E[修复未对齐访问] D --> F[添加状态机等待] E --> G[力测试] F --> G G -->|连续擦写1万次| H[问题是否解决] ``` 建议优先检查**sfud.c第540行**的擦除操作实现,此处是引发整个调用链问题的根源点[^1][^3]。
评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值