iOS汇编教程:ARM(2)

感谢唐巧抽出时间对本文进行double-check。

本文是iOS汇编教程:ARM第二篇。

iOS汇编教程:ARM目录如下[共分为两篇]:

iOS汇编教程:ARM(1)

  • 开始:什么是汇编
  • 函数调用约定
  • 创建工程
  • 加法(addFunction)

iOS汇编教程:ARM(2)

  • 函数的调用
  • Objective -C 汇编
  • Obj-C 消息发给了谁
  • 你现在可以进行逆向工程了
  • 何去何从

——————————————————————–

iOS汇编教程:ARM(2)

函数的调用

首先,给函数addFunction函数添加一个属性(attribute) ,告诉编译器不要进行特定的优化处理。通过上一篇文章,你已经看到编译器可以对代码进行优化,移除掉不需要的指令,另外,编译器甚至可以移除掉函数的调用,直接把被调用函数的相关代码进行内嵌到调用函数中。

例如,编译器可能会在调用函数中适当的添加add指令,而不是调用addFunction本身。实际上,现如今的编译器已经非常的智能了,针对类似addFunction这样的函数,编译器本身就可以进行加法操作,而不用在代码中添加一条add指令。

本文中,我们不希望编译器对代码进行优化——把代码进行内嵌处理。现在回到工程的main.m文件中,并按照如下方式修改addFunction:

__attribute__((noinline))
int addFunction(int a, int b) {
    int c = a + b;
    return c;
}

紧接着,在该函数下面添加另外一个函数:

void fooFunction() {
    int add = addFunction(12, 44);
    printf("add = %i", add);
}

 

如上代码所示,fooFunction通过调用addFunction来计算12+44,然后将结果打印出来。这里使用C函数printf进行打印,而没有使用Objective-C的NSLog(NSLog要稍微复杂一点)。

接着再次选择Xcode中的Product\Generate Output\Assembly File,并确保输出设置为Archiving。然后搜索_fooFunction,会看到如下一些内容:

提醒:在Scheme中一定要选择iOS Device,不要选择模拟器。

_fooFunction:
@ 1:
    push    {r7, lr}
@ 2:
    movs    r0, #12
    movs    r1, #34
@ 3:
    mov r7, sp
@ 4:
    bl  _addFunction
@ 5:
    mov r1, r0
@ 6:
    movw    r0, :lower16:(L_.str-(LPC1_0+4))
    movt    r0, :upper16:(L_.str-(LPC1_0+4))
LPC1_0:
    add r0, pc
@ 7:
    blx _printf
@ 8:
    pop {r7, pc}

上面的代码中,涉及到了一些还没有介绍过的指令,不用担心,它们都不复杂。下面我们就分别来看看上面代码中的指令都做了什么操作:

1、这里的指令作用跟之前介绍的add sp, #12类似——r7和lr被“pushed”到栈中,也就是说栈指针(sp)被减去8(因为r7和lr都是4个字节)。需要注意,通过这条指令,栈指针被递减,两个值也被存储到栈中!需要存储r7是因为在这个函数中,该寄存器会被覆盖,而之后又需要还原最初的值;而存储lr寄存器中的值是因为在函数结束时,要使用。

注意:lr是寄存器(Link Register, LR——R14寄存器)。

2、这两个指令属于move(mov)指令集中的一个。有时候你会看到movs,而有时候则会看到mov,或者其它类似的名称。它们都是把一个值装载到寄存器中。你可以把数据从一个寄存器“mov”到另外一个寄存器,例如mov ro, r1指令,将把r1中的数据装载到r0中,而r1中的数据不会改变。

上面的两行汇编指令中,会将定义在函数中的两个常量装载到r0和r1中。注意,需要将这两个常量装载到r0和r1中,才能够被addFunction正确的使用。

3、 在调用函数的时候,应该将栈指针保存起来,而这里使用r7来保存栈指针(r7是可以用来存储局部变量存储器中的一个)。可能你已经注意到,在该函数中剩下的代码里面并没有再次使用到栈指针或者r7,所以这条指令在这里是多余的——有时候,即使开启了编译器的优化,但还是不能做到最佳优化

4、这条指令(bl)对函数进行调用。请记住被调用函数需要的参数已经存储到相关的寄存器中了(r0和r1)。这条指令的执行一般被当做一个分支(branch)。可以理解为执行带链接的分支,也就是说,在跳转到分支之前,会将lr(link register)的值设置为当前函数中将要执行的下一条指令,当从分支(被调函数)中返回时,通过lr中的值可以知道当前函数执行到哪里了。

5、当addFunction函数执行完毕,返回后,执行的第一条指令——将addFunction的返回值(存储在r0中)保存起来,以供后续的printf使用。也就是利用mov将r0中的值存储到r1中。

6、printf的的第一个参数是一个字符串。这里使用了3条指令将指向字符串首地址的指针装载到r0寄存器中。这个字符串存储在二进制文件的数据段“data segment”中,不过该字符串的准确位置在二进制文件被链接之前是不知道的。

字符串其实是在由main.m文件生成的目标文件(object file)中的数据段里。如果你在汇编代码中搜索L_.str,就能找到这个字符串。这三个指令中的前两个作用是装载这个常量的地址(减去本地标签加4后的地址)。

第三条指令中将程序计数器(pc)的值加到r0中。因此,现在r0已保存着字符串的地址,也不用考虑L_.str在二进制文件中的确切位置。

下面的这个图演示了内存的布局。其中L_.str – (LPC1_0 + 4)的改变并不用对r0进行改动。

04-PC-relative-string-480x26404-PC-relative-string-480x264

7、这条指令(blx)调用printf函数。这跟bl指令有明显的区别。blx中的x标示交换“exchange”,意思是如果有必要,处理器将对指令集模式进行切换。

现在的ARM处理器有两种模式:ARMThumb。Thumb指令是16位的宽度,而ARM指令是32位的宽度。Thumb指令比较少,不过使用Thumb指令意味着代码容量更小,以及更利于CPU缓存。

因此,使用Thumb尺寸得到的好处就是让你的代码更少。这里可以看到更多的Thumb信息:Wikipedia

8、最后一条指令是将在第一条指令中push到栈里面的值pop出来。这里列出来的寄存器将被从栈中pop出来的值填充,然后栈指针递增。回想一下第一条指令是这样的:r7和lr寄存器中的值被push到栈中,但是为什么这里的指令是将栈中的值pop到r7和pc寄存器中,而不是r7和lr寄存器中呢?

相信你还记得之前说过的:lr寄存器保存的是从一个函数返回时,下一条将被执行指令的地址。所以,如果将push到栈中的这个地址pop给pc(program counter),那么将继续从函数被调用的地方执行——这也是从一个被调用函数返回调用函数的常规做法,而不是像调用addFunction一样返回到调用函数fooFunction中。

现在我们来看看ARM中的一些指令的简短总结。ARM中又许多指令,不过下面列出来的指令对于初步了解ARM指令非常重要。现在就通过伪代码(pseudo-code)和相关描述来快速的回顾一下这些指令都做有什么作用:

  • mov r0, r1 => r0 = r1
  • mov r0, #10 => r0 = 10
  • ldr r0, [sp] => r0 = *sp
  • str r0, [sp] => *sp = r0
  • add r0, r1, r2 => r0 = r1 + r2
  • add r0, r1 => r0 = r0 + r1
  • push {r0, r1, r2} => 将 r0, r1 和 r2push到栈中.
  • pop {r0, r1, r2} => 将3个值从栈中pop出来,并存放到r0, r1 和 r2中.
  • b _label => pc = _label
  • bl _label => lr = pc + 4; pc = _label

 现在是时候恭喜你了:你能够阅读一些ARM汇编代码了!

06-Read-ARM-meme06-Read-ARM-meme

Objective -C 汇编

到现在为止,我们涉及到的函数都是用C语言来写的。Objective-C在C语言的基础上稍微增加了一点复杂度。下面我们就来看看用Objective-C代码编译出来的汇编指令。打开ViewController.m文件,然后将下面的方法添加到类的实现中:

- (int)addValue:(int)a toValue:(int)b {
    int c = a + b;
    return c;
}

同样,通过这样的步骤来查看汇编代码:Product\Generate Output\Assembly File。记得将output类型设置为Archiving,然后搜索addValue:toValue: ,你会发现类似如下的汇编代码:

"-[ViewController addValue:toValue:]":
    adds    r0, r3, r2
    bx  lr

首先看到的是一个标签(label)名称——”–[ViewController addValue:toValue:]“,这个名称包含类名和完整的Objective-C方法名称。

把上面的汇编代码与之前的addFunction相关汇编代码进行比较,你会发现这里是将r2和r3进行加法运算,而不是r0与r1相加——这意味着传递给addValue:toValue:方法的参数使用了r2和r3寄存器(没有使用r0和r1),这是为什么呢?

这是因为:在调用Objective-C方法时,除了传递明确指定的参数外,还会在明确参数之前传递两个隐含的参数(implicit parameter)。addValue:toValue:方法跟下面的C函数是等价的:

int ViewController_addValue_toValue(id self, SEL _cmd, int a, int b) {
    int c = a + b;
    return c;
}

这就是为什么a和b两个参数分别存储到r2和r3的原因。可能你之前已经听说过前两个参数了(经常使用self吧)。

提醒:self和_cmd占用了r0和r1寄存器。

可能之前你还没有见过_cmd。其实跟self一样,在Objective-C函数中,_cmd是可以直接使用的,它存储着当前执行方法的selector。一般来说,你并不需要使用_cmd(这也可能是为什么你从来没有听说过_cmd的原因)。

为了观察Objective-C方法是如何被调用的,现在将如下方法添加到ViewController中:

- (void)foo {
    int add = [self addValue:12 toValue:34];
    NSLog(@"add = %i", add);
}

重新生成汇编文件,然后寻找“–[ViewController foo]“:,应该能看到类似如下的代码:

"-[ViewController foo]":
@ 1:
    push    {r7, lr}
@ 2:
    movw    r1, :lower16:(L_OBJC_SELECTOR_REFERENCES_-(LPC1_0+4))
    movt    r1, :upper16:(L_OBJC_SELECTOR_REFERENCES_-(LPC1_0+4))
LPC1_0:
    add r1, pc
@ 3:
    ldr r1, [r1]
@ 4:
    movs    r2, #12
    movs    r3, #34
@ 5:
    mov r7, sp
@ 6:
    blx _objc_msgSend
@ 7:
    mov r1, r0
@ 8:
    movw    r0, :lower16:(L__unnamed_cfstring_-(LPC1_1+4))
    movt    r0, :upper16:(L__unnamed_cfstring_-(LPC1_1+4))
LPC1_1:
    add r0, pc
@ 9:
    blx _NSLog
@ 10:
    pop {r7, pc}

同样,这与之前C语言产生的汇编代码非常相似,我们也来看看具体都做了些什么:

1、将r7和lr push到栈中。

2、利用pc(program counter)将标签L_OBJC_SELECTOR_REFERENCES_对应的值装载到r1寄存器中。这个标签引用到一个selector。实际上selector就是一个字符串,并且存储在数据段中(data segment)。

3、如果在汇编文件中搜索L_OBJC_SELECTOR_REFERENCES_,会看到如下内容:


L_OBJC_SELECTOR_REFERENCES_:
    .long   L_OBJC_METH_VAR_NAME_

r1会指向这里(L_OBJC_SELECTOR_REFERENCES_),这个标签包含了另外一个标签:L_OBJC_METH_VAR_NAME_。在文件中查找这个标签(L_OBJC_METH_VAR_NAME_),会找到这样的字符串:addValue:toValue:。

而指令ldr r1, [r1]的作用:对r1中存储的地址进行解引用(dereferencing),然后将得到的值放到r1中。如果用C伪代码看起来应该是这样的:r1 = *r1。仔细想想的话,可能你应该知道r1将会存储着指向字符串addValue:toValue: 的指针。

4、将常量装载到r2和r3中。

5、 将sp保持到r7寄存器中。

6、这是一个分支(branch),以带链接跳转和根据情况切换指令集的模式来调用objc_msgSend方法。这是Objective-C runtime中非常重要的一个方法——它根据传递的参数找到并调用相关的函数。

该方法使用到了4个参数(r0-r3)。因此,在上面的代码中,将selector装载到r1中,另外两个参数(12和34)装载到r2和r3中。注意:在此并没有明确的装载r0,因为r0已经存储着self变量了。

7、调用addValue:toValue:的返回值被存放在r0中。这里的指令将这个结果值保持到r1中。在接下来调用NSLog函数时会用到这个值。

8、将NSLog用到的第一个字符串参数装载到r0中。这跟之前介绍的用C函数里面调用printf一样。

9、这是一个分支(branch),带链接跳转和根据情况切换指令集的模式来调用NSLog方法。

10、从栈中pop出两个值,并放入r7和pc寄存器中。这跟之前一样,从foo方法中返回。

如上所见,由C和Objective-C代码生成汇编指令,区别不是太大。只不过在Objective-C生成的汇编指令中,会隐示的给方法传递两个参数,以及使用到的selector以字符串的形式存放在数据段中(data segment)。

Obj-C 消息发给了谁

上面我们看到了objc_msgSend方法。可能你在crash log中已经看到过这个方法。该方法是Objective-C runtime中的一个核心方法。runtime包含了内存管理以及类的相关处理。

每次调用Objective-C方法时,都由objc_msgSend方法(这是一个C方法)处理消息的派送(dispatching)。该方法根据传递的消息类型在类的方法列表中查找被调用方法的实现。objc_msgSend方法的签名(signature)看起来是这样的:

id objc_msgSend(id self, SEL _cmd, ...)

在方法执行期间,第一个参数是self。在方法中写的一些代码,例如self.someProperty,其中self就是来自自objc_msgSend方法中的self参数。

第二个参数很少人会知道,这也是一个被隐藏的参数(hidden parameter)。如果在Objective-C方法中,写这类似这样的代码:NSLog(@”%@”, NSStringFromSelector(_cmd)); ,会看到控制台输出了当前的selector。

剩下的参数一般就是开发者传递给该方法的参数了。所以如果一个方法携带两个参数,例如上面的addValue:toValue:,那么还会携带额外的两个参数。因此,我们也可以用下面的代码来代替通过Objective-C方式的调用:

- (void)foo {
    int add = (int)objc_msgSend(self, NSSelectorFromString(@"addValue:toValue:", 12, 34);
    NSLog(@"add = %i", add);
}

注意:虽然objc_msgSend的返回值类型是id,不过在上面的代码中将其转换为int类型了。因为它们 的size是相同的,所以转换为int不会有问题。如果该方法返回的是不同的size,那么实际上是别的函数被调用了,更多内容请看这里:here。同样,如果返回的是一个floating指针,那么则是objc_msgSend的另一个变种被调用了,更多内容请看这里:here

当一个Objective-C方法被编译的时候,上面用C写的等效方法签名应该是这样的:

int ViewController_addValue_toValue(id self, SEL _cmd, int a, int b)

对此为什么会这样,现在应该不会感觉到奇怪——这样的签名是为了与objc_msgSend相匹配!也就是说当objc_msgSend在查找并跳转到对应方法时,所有的这些参数都应该在正确的地方。

这里可以看到更多关于objc_msgSend相关内容:文章1文章2

你现在可以进行逆向工程了

根据上面对ARM汇编的介绍,你应该可以能够知道为什么有些代码被breaking、crashing或者没有正确的执行。

通过观察相关的汇编代码,可以更加清楚的获知到引起bug的详细步骤。

interrogationinterrogation

有时候,你可能无法查看源代码——例如,你遇到的bug是发生在第三方库或者系统的framework中。此时,通过汇编指令进行分析可以帮助你迅速的找到问题。下面的目录存放着iOS SDK中所有的framework:

<Path_to_Xcode>/Contents/Developer/Platforms/iPhoneOS.platform/Developer/ SDKs/iPhoneOS6.1.sdk/System/Library/Frameworks

我建议使用HopperApp对这些库进行分析。该软件能够对二进制文件进行反汇编——这样你就可以看库中的内容了——这样做是没有问题的!!!例如,打开UIKit,就可以看到每个方法都做了什么。如下图所示:

05-HopperApp-105-HopperApp-1

上图中的汇编代码是[UINavigationController shouldAutorotateToInterfaceOrientation] 方法相关的。结合之前介绍的ARM汇编知识,相信上面的汇编代码具体做了些什么你应该能看出来。

首先是将一个selector引用装载到ri寄存器中,以供后续调用objc_msgSend使用。然后可以看到,别的寄存器并没有做任何改动,所以我们可以知道传递给objc_msgSend方法的self指针(存储在r0中),跟传递给shouldAutorotateToInterfaceOrientation方法的self是同一个。

同理,我们可以知道被调用方法携带一个参数(代码中有一列是用来显示相关名称的)。由于r2寄存器没有改动过,所以这个参数就是从shouldAutorotateToInterfaceOrientation方法传入的。

最后,函数调用之后,r0没有改动过,所以被调用函数的返回值就是调用函数的返回值。

这样一来,就可以推断出这个方法的实现应该是这样的了:

- (BOOL)shouldAutorotateToInterfaceOrientation: (UIInterfaceOrientation)interfaceOrientation {
    return [self _doesTopViewControllerSupportInterfaceOrientation:interfaceOrientation];
}

cool!很容易吧!虽然大多数方法都比上面的这个要复杂,不过你可以根据汇编指令拼凑出一些代码,进而快速的确定这些代码做了些什么。

何去何从

这篇关于iOS汇编的教程向你介绍了一些运行在iOS设备中的ARM汇编指令核心概念。你应该学习到了C和Objective-C相关的一些调用约定。

通过本文介绍的知识,当你的程序在使用系统库crash时,你可以对所有能看到的随机代码进行分析。当然,你也可以通过汇编指令来准确的分析你自己写的方法。

如果你希望更加深入的了解ARM,请看这里:Raspberry Pi。这里的涉及到的小型设备都拥有ARM处理器,跟iOS设备非常相似,同时也有许多教程可以教你如何对这些设备进行编程。

另外,NEON也值得去学习了解。这是另外扩展的一套指令集,自iPhone 3GS以来设备中的所有处理器,都支持NEON指令集。该指令集提供了SIMD(单指令,多数据——Single Instruction Multiple Data)指令,对数据的处理非常高效,例如,图片的处理。如果你需要对数据进行高效的处理,那么最好学习一下如何直接写NEON指令,并结合使用内联汇编(inline assembly)。这个指令集非常的先进!

这应该够你忙乎一阵了:]。

本文由破船翻译●转载请注明出处●2013-06-20
课程介绍 第1章 预备知识  1.1 汇编语言的由来及其特点   1 机器语言   2 汇编语言   3 汇编程序   4 汇编语言的主要特点   5 汇编语言的使用领域  1.2 数据的表示和类型   1 数值数据的表示   2 非数值数据的表示   3 基本的数据类型  1.3 习题 第2章 CPU资源和存储器  2.1 寄存器组   1 寄存器组   2 通用寄存器的作用   3 专用寄存器的作用  2.2 存储器的管理模式   1 16位微机的内存管理模式   2 32位微机的内存管理模式  2.3 习题 第3章 操作数的寻址方式  3.1 立即寻址方式  3.2 寄存器寻址方式  3.3 直接寻址方式  3.4 寄存器间接寻址方式  3.5 寄存器相对寻址方式  3.6 基址加变址寻址方式  3.7 相对基址加变址寻址方式  3.8 32位地址的寻址方式  3.9 操作数寻址方式的小结  3.10 习题 第4章 标识符和表达式  4.1 标识符  4.2 简单内存变量的定义   1 内存变量定义的一般形式   2 字节变量   3 字变量   4 双字变量   5 六字节变量   6 八字节变量   7 十字节变量  4.3 调整偏移量伪指令   1 偶对齐伪指令   2 对齐伪指令   3 调整偏移量伪指令   4 偏移量计数器的值  4.4 复合内存变量的定义   1 重复说明符   2 结构类型的定义   3 联合类型的定义   4 记录类型的定义   5 数据类型的自定义  4.5 标号  4.6 内存变量和标号的属性   1 段属性操作符   2 偏移量属性操作符   3 类型属性操作符   4 长度属性操作符   5 容量属性操作符   6 强制属性操作符   7 存储单元别名操作符  4.7 表达式   1 进制伪指令   2 数值表达式   3 地址表达式  4.8 符号定义语句   1 等价语句   2 等号语句   3 符号名定义语句  4.9 习题 第5章 微机CPU的指令系统  5.1 汇编语言指令格式   1 指令格式   2 了解指令的几个方面  5.2 指令系统   1 数据传送指令   2 标志位操作指令   3 算术运算指令   4 逻辑运算指令   5 移位操作指令   6 位操作指令   7 比较运算指令   8 循环指令   9 转移指令   10 条件设置字节指令   11 字符串操作指令   12 ASCII-BCD码调整指令   13 处理器指令  5.3 习题 第6章 程序的基本结构  6.1 程序的基本组成   1 段的定义   2 段寄存器的说明语句   3 堆栈段的说明   4 源程序的结构  6.2 程序的基本结构   1 顺序结构   2 分支结构   3 循环结构  6.3 段的基本属性   1 对齐类型   2 组合类型   3 类别   4 段组  6.4 简化的段定义   1 存储模型说明伪指令   2 简化段定义伪指令   3 简化段段名的引用  6.5 源程序的辅助说明伪指令   1 模块名定义伪指令   2 页面定义伪指令   3 标题定义伪指令   4 子标题定义伪指令  6.6 习题 第7章 子程序和库  7.1 子程序的定义  7.2 子程序的调用和返回指令   1 调用指令   2 返回指令  7.3 子程序的参数传递   1 寄存器传递参数   2 存储单元传递参数   3 堆栈传递参数  7.4 寄存器的保护与恢复  7.5 子程序的完全定义   1 子程序完全定义格式   2 子程序的位距   3 子程序的语言类型   4 子程序的可见性   5 子程序的起始和结束操作   6 寄存器的保护和恢复   7 子程序的参数传递   8 子程序的原型说明   9 子程序的调用伪指令   10 局部变量的定义  7.6 子程序库   1 建立库文件命令   2 建立库文件举例   3 库文件的应用   4 库文件的好处  7.7 习题 第8章 输入输出和中断  8.1 输入输出的基本概念   1 I/O端口地址   2 I/O指令  8.2 中断   1 中断的基本概念   2 中断指令   3 中断返回指令   4 中断和子程序  8.3 中断的分类   1 键盘输入的中断功能   2 屏幕显示的中断功能   3 打印输出的中断功能   4 串行通信口的中断功能   5 鼠标的中断功能   6 目录和文件的中断功能   7 内存管理的中断功能   8 读取和设置中断向量  8.4 习题 第9章 宏  9.1 宏的定义和引用   1 宏的定义   2 宏的引用   3 宏的参数传递方式   4 宏的嵌套定义   5 宏与子程序的区别  9.2 宏参数的特殊运算符   1 连接运算符   2 字符串整体传递运算符   3 字符转义运算符   4 计算表达式运算符  9.3 与宏有关的伪指令   1 局部标号伪指令   2 取消宏定义伪指令   3 中止宏扩展伪指令  9.4 重复汇编指令   1 伪指令REPT   2 伪指令IRP   3 伪指令IRPC  9.5 条件汇编指令   1 条件汇编指令的功能   2 条件汇编指令的举例  9.6 宏的扩充   1 宏定义形式   2 重复伪指令REPEAT   3 循环伪指令WHILE   4 循环伪指令FOR   5 循环伪指令FORC   6 转移伪指令GOTO   7 宏扩充的举例   8 系统定义的宏  9.7 习题 第10章 应用程序的设计  10.1 字符串的处理程序  10.2 数据的分类统计程序  10.3 数据转换程序  10.4 文件操作程序  10.5 动态数据的编程  10.6 COM文件的编程  10.7 驻留程序  10.8 程序段前缀及其应用   1 程序段前缀的字段含义   2 程序段前缀的应用  10.9 习题 第11章 数值运算协处理器  11.1 协处理器的数据格式   1 有符号整数   2 BCD码数据   3 浮点数  11.2 协处理器的结构  11.3 协处理器的指令系统   1 操作符的命名规则   2 数据传送指令   3 数学运算指令   4 比较运算指令   5 超越函数运算指令   6 常数操作指令   7 协处理器控制指令  11.4 协处理器的编程举例  11.5 习题 第12章 汇编语言和C语言  12.1 汇编语言的嵌入  12.2 C语言程序的汇编输出  12.3 一个具体的例子  12.4 习题 附录
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值