LLVM 目标无关代码生成器

转载来自P2Tree
十分感谢翻译成果。

LLVM 目标无关代码生成器

1 介绍:LLVM 目标无关代码生成器由 6 个主要的组件组成:

抽象的目标描述接口(include/llvm/Target/):用来维护那些重要的机器特性,而不需要考虑他们的使用。

用来做代码生成的类(include/llvm/CodeGen/):这些类是通用的,抽象地囊括了所有的后端机器,他们用来做代码生成工作。

用来描述目标文件的类和算法:在 MC 层下,这些类描述了汇编级别的概念,如 labels、 sections、 instructions。

目标无关的算法(lib/CodeGen/ ):提供了一些基础的后端算法,如寄存器分配、调度、栈帧表示等工作的算法。

抽象目标描述接口的实现(lib/Target/):针对特定后端目标,这部分代码实现了 LLVM 提供的抽象描述接口,并且可以根据需要自定义一些目标相关的 pass,从而来实现针对特定目标的代码生成器。

JIT 组件(lib/ExecutionEngine/JIT/):完全与目标无关的工具,实现 JIT 的功能。

1.1 代码生成

LLVM 代码生成器的两个最重要的组成部分分别是:代码生成器的高层接口、一系列可重用与构建目标相关后端的组件。有两个很重要的接口:TargetMachine 和 DataLayout.

1.2 代码生成器的高层设计

指令选择:会将 LLVM 的代码转换为目标相关的 DAG (有向无环图)。

指令调度和规范化:这个阶段对目标相关的 DAG 进行处理,对指令顺序进行调整,然后依据这种顺序发射一种叫做 MachineInstrs 的代码表示。

基于 SSA 的机器代码优化:这是一个可选的阶段,由一系列针对 SSA 结构的机器代码优化操作组成。

寄存器分配:将 SSA 结构下带有无限虚拟寄存器的机器代码转换为有限机器寄存器的结构。

Prolog/Epilog 代码插入:当需要生成带有函数的机器代码时,prolog 和 epilog 代码将会被插入到逻辑代码之间,并且抽象的栈引用会被消除。

机器代码优化:对“最终”的机器代码进行一次后优化

代码发射:真正的最后阶段,将输出适配对应机器汇编格式的汇编码或可执行的二进制机器码。

1.3 使用 TableGen 来描述目标平台

越来越多的目标描述性信息放到 .td 文件(TableGen 处理文件)中完成。

2 目标描述类

LLVM 目标描述的类提供了一套抽象的针对任意后端机器通用的描述。这些类被设计来管理一些抽象的目标属性(比如指令和寄存器),并且不夹杂任何代码生成算法的片段。

除了 DataLayout 以外,其他所有的目标描述类都需要作为基类被特定目标的描述类所继承,同时提供虚函数的实现。为了便于实现, TargetMachine 类提供了一些访问器(accessor),这些也同样需要在特定目标的描述类中实现。

2.1 TargetMachine 类

TargetMachine 类提供了一些虚函数用来访问与特定目标相关的访问器,被命名为 get*Info 方法,比如 getInstrInfo、getRegisterInfo、getFrameInfo 等。这个类需要被特定目标的类所继承,比如 X86 中继承 TargetMachine 实现的 X86TargetMachine,其中需要实现各种虚函数。唯一必须依赖的类是 DataLayout,但是如果其他功能类也被使用的话,它们同样也需要被实现。

2.2 DataLayout 类

DataLayout 类是唯一必须依赖的目标描述类,而且不能被继承。 DataLayout 指定了一些与目标 layout 有关的信息,比如内存结构,各种数据类型的对齐要求,指针占用空间,大小端的要求。

2.3 TargetLowering 类

TargetLowering 类被 SelectionDAG 组件所使用,在指令选择器中发挥功能,它描述了 LLVM IR 代码如何 lowering 到 SelectionDAG 结构。

2.4 TargetRegisterInfo 类

TargetRegisterInfo 类用于描述与目标相关的寄存器信息以及任何与寄存器的交互动作。

2.5 TargetInstrInfo 类

TargetInstrInfo 类被用于描述特定目标支持的机器指令码。会描述指定对应的助记符和操作码、操作数的数量、隐式寄存器 uses 和 defs、一些目标无关的属性(比如指令是否访问内存、是否可交换)、以及维护目标平台的 flag。

2.6 TargetFrameLowering 类

TargetFrameLowering 类被用于提供一些目标栈帧相关的信息。比如栈的生长方向,进入函数的栈对齐要求和对局部数据的偏移。对局部数据的偏移是针对进入函数的栈指针的偏移,这里还存储一些处理函数的数据,如局部变量、 spill 位置等。

2.7 TargetSubtarget 类

TargetSubtarget 类被用于提供一些与目标特定芯片相关的信息。

2.8 TargetJITInfo 类

TargetJITInfo 类提供了一个抽象的接口,用于支持特定目标的 Just-In-Time 代码生成器的工作

3 机器代码描述类

LLVM 代码会翻译成由 MachineFunction、MachineBasicBlock 和 MachineInstr 实例组成的特定机器的表示。这种表示完全是目标无关的,以最抽象的方式描述指令:操作码和操作数。

3.1 MachineInstr 类

目标机器指令(Machine Instruction,LLVM 后端的一种中间表示,缩写为MI )通过 MachineInstr 的实例来描述。

操作码是一个简单的无符号整型数,只在特定后端下才有效。所有的指令都是在 *InstrInfo.td 文件中定义的,操作码的枚举值仅仅是依据这份描述自动生成。MachineInstr 类没有任何有关于指令意义的信息(比如这条指令的意义是什么),必须依赖于 TargetInstrInfo 类来了解。

操作数可以有多种不同类型,比如寄存器引用、常量值、basic block 引用等。另外,操作数应该被标记为 def 或 use (仅寄存器可以标记为 def )。

3.1.1 使用 MachineInstrBuilder.h 函数

机器指令(MI)由 BuildMI 函数来创建,这个函数定义在 include/llvm/CodeGen/MachineInstrBuilder.h 文件中。BuildMI 函数可以很容易的创建任意 MI。

3.1.2 固定寄存器(预分配寄存器)

代码生成器需要注意那些固定寄存器。特别是在指令流中,寄存器分配器必须针对一些指令将特定的寄存器安排到特定的位置上。这可能发生于一些有限制的指令(比如,X86 中只允许 EAX / EDX 这两个寄存器完成 32 位除法操作)或者一些扩展功能,如调用约定。任何情况下,指令选择器都应该在指令需要时,发出将虚拟寄存器传入或传出物理寄存器的操作代码。

比如,在如下这段 LLVM 代码中:

define i32 @test(i32 %X, i32 %Y) {
%Z = sdiv i32 %X, %Y
ret i32 %Z
}
X86 的指令选择器可能会生成以下代码:

;; Start of div
%EAX = mov %reg1024 ;; Copy X (in reg1024) into EAX
%reg1027 = sar %reg1024, 31 ;; Sign extend X
%EDX = mov %reg1027 ;; Sign extend X into EDX
idiv %reg1025 ;; Divide by Y (in reg1025)
%reg1026 = mov %EAX ;; Read the result (Z) out of EAX

;; Start of ret
%EAX = mov %reg1026 ;; 32-bit return value goes in EAX
ret
寄存器分配器会合并寄存器,并删除冗余寄存器并产生以下代码:

;; X is in EAX, Y is in ECX
mov %EAX, %EDX
sar %EDX, 31
idiv %ECX
ret
这种操作非常通用,它允许任何目标平台实现这种将指令流中信息在指令选择器中独立出来的操作。需要注意对于物理寄存器来说,应当尽可能缩短其生命周期,并且,在进入和退出一个 basic blocks 时(寄存器分配前),所有物理寄存器都会被假设是 dead 状态。因此,如果你需要一个能跨越 basic block 边界的值,你应该使用虚拟寄存器。

3.1.3 Call-clobbered 寄存器

3.1.4 SSA 形式下的机器码

MI 代码在指令选择时被创建为 SSA 形式,并直到寄存器分配发生。大多数情况下,这种形式都是非常简单的,因为 LLVM 代码也是 SSA 形式,LLVM 的 PHI 节点会被转换为 MI 代码的 PHI 节点,虚拟寄存器也只需要通过简单的方法来描述。

在寄存器分配之后,MI 代码不再是 SSA 形式,因为虚拟寄存器不存在了。

3.2 MachineBasicBlock 类

MachineBasicBlock 类包含有一系列的 MI ( MachineInstr 的实例 ),它大致等效于输入到指令选择器的 LLVM 代码,但也可能存在一对多的映射(一个 LLVM basic block 对应多个 machine basic block )。MachineBasicBlock 类中有个 getBasicBlock 方法,可以返回对应的 LLVM basic block。

3.3 MachineFunction 类

MachineFunction 类包含有一系列的 machine basic block ( MachineBasicBlock 的实例 ),它与 输入指令选择器的 LLVM 函数是一对一的映射。除了一系列的 machine basic block 以外,它还包括一个 MachineConstantPool,一个 MachineFrameInfo,一个 MachineFunctionInfo,和一个 MachineRegisterInfo。这部分代码可参见 include/llvm/CodeGen/MachineFunction.h。

3.4 MachineInstr Bundles

LLVM 代码生成器可以将一些指令序列看做是 MachineInstr bundles。 一个 MI bundle 可以模型化 VLIW 中包含能够并行化执行的 组/包 结构。它也可以被用来模型化无法合法分离的顺序指令序列(可能带有数据依赖)。

概念上讲,一个 MI bundle 就是将一个 MI 和其他一些 MIs 打包在一起:
Bundle —
|
| MI
| |
| MI
| |
| MI
|
Bundle —
|
… …
不同后端可能会在这里有不同的实现,但在 LLVM 看来,一个 bundle 会被当做一个 MI 一样的行为来处理。

4 MC 层

MC 层被用来表示机器编码级别的代码,相比于更高层的表示形式,它去除了像常量池、跳转表、全局变量等概念。在这一层上,LLVM 处理如 Label 名称、机器指令、目标文件段信息。这一层的代码在一些阶段非常重要,比如代码生成的最后阶段(也就是写入 .s 或 .o 文件时),或者被 llvm-mc 工具用来作为独立汇编器和反汇编器使用。

4.1 MCStreamer API

MCStreamer API 被认为是汇编器中设计最好的 API。它作为一个抽象的接口,被实现为不同的功能(比如输出 .s 文件、输出 ELF 格式的 .o 文件等),但是它的 API 直接对应于你在 .s 文件中对应的内容。MCStreamer 类中,对于每一类指示符(译注:directive,和 instruction 有区分)都有一个对应的方法来处理,比如 EmitLabel,EmitSymbolAttribute,SwitchSection,EmitValue 等,这些都是汇编级别的指示符。它还提供一个 EmitInstruction 方法,用来将 MCInst 结构的代码输出到流。

对于 llvm-mc 独立汇编器和代码生成器来说,这个类都非常重要。llvm-mc 工具实际上就是一个汇编解析器加 MCStreamer 的输出;而在代码生成器中,Code Emission 阶段利用 MCStreamer 将高级别的 LLVM IR 和 Machine* 结构构造成低层次的 MC 层结构,同时也是通过 MCStreamer 类发出指令。

从 MCStreamer 的实现角度看,有两大块非常重要的部分:一个是写出 .s 文件(子类 MCAsmStreamer),另一个是写出 .o 文件(子类 MCObjectStreamer)。MCAsmStreamer 通过每一个 Emit* 方法直接输出指令,但对于 MCObjectStreamer,还需要实现一个完整的汇编器的功能。

对于目标相关的指令,MCStreamer 会依赖于一个 MCTargetStreamer 实例。每一个目标都会继承这个类来实现一个子类,每一个方法都会有两种多态的继承实现,也就是一个输出 object 的 streamer 和一个输出 asm 的 streamer。通过后者发射指示符(比如 EmitFnStart 发射 .fnstart),通过前者发射汇编的逻辑代码。

为了让 LLVM 顺利使用这个类,在目标初始化时,必须通过 TargetRegistry::RegisterAsmStreamer 和 TargetRegistry::RegisterMCObjectStreamer 两个回调函数来分配 MCStreamer 的 streamer 基类对象,并将其传递给 createAsmStreamer 等位置来构造目标 streamer 子对象。

4.2 MCContext 类

MCContext 类拥有 MC 层很多唯一性的数据结构,包括 symbols、sections 等。所以,你可以与这个类来交互,实现创建 symbols 和 sections 的目的。这个类没有子类继承。

4.3 MCSymbol 类

MCSymbol 类用来表示一个汇编文件中 symbol 的结构(也就是 label)。有两种类型,一种是汇编器临时的符号,另一种是普通符号。汇编器临时符号被汇编器用来做一些处理,但会在输出目标文件时被删去。通常这种符号会在 label 开头名字加一个前缀符号,比如在 MachO 平台下,会有一个 L 前缀符号的 label 用来表示临时符号。

MCSymbols 只被 MCContext 使用来创建符号。这意味着可以通过指针运算来检查两个符号是否是同一个。但需要注意,两个指针不相等并不表示两个符号被放在不同的地址(译注:两个符号可能在同一个目标文件地址),比如在汇编文件中有如下结构:

foo:
bar:
.byte 4
我们看到,foo 和 bar 这两个 label 是在同一个目标文件地址的(但他们是不同符号)。

4.4 MCSection 类

MCSection 类用来表示目标文件中的 section(译注:翻译为段),对于不同的目标文件,这个类会被继承为不同的子类(比如 MCSectionMachO,MCSectionCOFF,MCSectionELF)。同样的,它也只被 MCContext 使用来创建段。MCStreamer 有一个对于当前段的标记,可以通过 SwitchToSection 来改变段标记。

4.5 MCInst 类

MCInst 类是目标无关的指令表示,相比于 MachineInstr,它更为简单,它维护着每一条机器指令的指令码和指令操作数的 vector。MCOperand(指令操作数)有三种不同的类型:一个简单立即数、一个目标寄存器的 ID 或一个符号表示(MCExpr 类型,比如 “Lfoo-Lbar+42”)。

MCInst 现在通常被用来表示 MC 层的机器指令。它被指令编码、指令输出、汇编 parser 的类型生成和反汇编器使用。

5 目标无关的代码生成算法

5.1 指令选择

指令选择是 LLVM 代码生成中的一个重要步骤,它将高层的 LLVM 代码表示翻译为目标相关的机器指令。在学术界有多种不同的实现方法,LLVM 使用了基于 SelectionDAG 的指令选择器。

DAG 指令选择器的大部分内容是通过 tablegen 依赖 .td 文件生成的。我们的目标便是将这些 .td 文件生成一个完整的指令选择器,不过,当前仍然有一些工作必须依赖于 C++ 来完成。

5.1.1 SelectionDAG 的介绍

SelectionDAG 提供了一种抽象的代码表示方式来通过自动化技术完成指令选择构建(比如基于动态编程的最优模式匹配选择器,dynamic-programming based optimal pattern matching selectors)。

SelectionDAG 是一个有向无环图(Directed-Acyclic-Graph),其中的节点由 SDNode 类来定义。SDNode 最主要的内容是操作码(Opcode),操作码表示节点的一种操作。在 include/llvm/CodeGen/ISDOpcodes.h 文件中定义了很多节点操作码类型。

SelectionDAG 有两种不同的值类型:表示数据流和表示控制流依赖。数据流的值是简单的边,它们的类型是整形或浮点型。控制流的值用来表示一个链(chain),它们的类型是 MVT::Other。这些边(控制流的边)提供了约束拥有 side-effect 的节点的顺序(比如 load、store、call、return 等)。所有拥有 side-effect 的节点都应该有一个链的边(token chain)作为输入,并输出到一个新的节点。约定 token chain 的输入总是 0,输出总是一个操作的最后一个结果。不过,在指令选择之后(译注:之后全部为 machine node),machine node 在指令操作数最后还会有一个 chain,可能跟随有一个 glue 节点。

一个 SelectionDAG 会有一个 Entry 和一个 Root 节点。Entry 节点用来标记入口节点,其操作码是 ISD::EntryToken。Root 节点是最后一个节点(译注:最后的最后,side-effect 的最后),比如一个 basic block 的 return 节点。

一个非常重要的概念是合法 DAG 和不合法 DAG。一个合法的 DAG 是指只使用目标机器支持的操作数和类型构建的 DAG。比如,在 32 位 PowerPC 上,拥有 i1,i8,i16,i64 值类型的 DAG 是不合法的,使用 SREM 或 UREM 操作的 DAG 也是不合法的。合法化类型和合法化操作阶段负责将一个非法的 DAG 逐步转变为一个合法的 DAG。

5.1.2 SelectionDAG 指令选择过程

SelectionDAG 的指令选择分为以下几个步骤:

(1)构造初始化的 DAG:这个阶段将输入的 LLVM IR 翻译为一个非法的 SelectionDAG;
(2)优化 SelectionDAG:这个阶段完成一些在 SelectionDAG 上的简单优化,试图简化它,并识别一些目标机器支持的元指令(比如 rotate 和 div/rem 组合)。这样可以让代码更加高效,使后边的工作简单一些;
(3)合法化 SelectionDAG 类型:这个阶段消除 SelectionDAG 中那些目标平台不支持的类型;
(4)优化 SelectionDAG:类型合法化后,再次做一些优化,清理由前者产生的冗余(译注:也被称为 combine 操作);
(5)合法化 SelectionDAG 操作:这个阶段消除 SelectionDAG 中那些目标平台不支持的操作;
(6)优化 SelectionDAG:同理,再做一些优化,清理由前者产生的低效的实现 (译注:也被称为 combine 操作);
(7)通过 DAG 做指令选择:最后,目标指令选择器将 DAG 中的操作匹配为目标指令。这个阶段将目标无关的指令全部翻译为目标相关的指令;
(8)SelectionDAG 指令调度和规范化:最后一步,为 DAG 形式下的目标指令做线性顺序调度,并将它们发射到 MachineFunction 结构中。这一步骤中用到了传统的调度技术。

在所有这些步骤结束后,SelectionDAG 结构就会被销毁,并继续后续的步骤。

有一种可视化的技巧,可以通过一些简短的 llc 命令行参数来检查 DAG 在不同阶段的形式。

5.1.3 初始化 SelectionDAG 构造

初始化 SelectionDAG 是通过 SelectionDAGBuilder 类将 LLVM 类直接通过窥孔展开。这一阶段的目的就是在 SelectionDAG 中暴露尽可能多的低层次的、目标相关的细节。这一部分大多数代码都是手动编码的(比如 LLVM 的 add 指令会翻译为一个 SDNode 的 add 节点,而 getelementptr 指令会被展开成多条算术指令)。这个阶段需要一些目标相关的 hooks(译注:俗称钩子函数)来下降 call、return、varargs 等。TargetLowering 接口用来完成这些工作。

5.1.4 SelectionDAG 合法化类型阶段

具体参考

5.1.5 SelectionDAG 合法化操作阶段

5.1.6 SelectionDAG 优化阶段:DAG Combiner

5.1.7 SelectionDAG 指令选择阶段

5.1.8 SelectionDAG 调度和规范化阶段

5.1.9 SelectionDAG 的未来方向

5.2 基于 SSA 的机器代码优化(原文未完成)

5.3 活动区间(Live Intervals)

5.3.1 活动变量分析

5.3.2 活动区间分析

5.4 寄存器分配

5.4.1 LLVM 中如何表示寄存器

5.4.2 将虚拟寄存器映射到物理寄存器

5.4.3 处理双地址指令

5.4.4 SSA 解构阶段

5.4.5 指令折叠

5.4.6 内建的寄存器分配器

5.5 Prolog/Epilog 代码插入(原文未完成)

5.6 Compact Unwind(未完成)

5.7 后端 Machine Code 优化(原文未完成)

5.8 代码发射

5.8.1 发射函数栈信息

5.9 VLIW 打包

5.9.1 将指令映射到功能单元

5.9.2 打包表如何生成和使用

6 实现一个原生汇编器

6.1 指令解析(原文未完成)

6.2 指令别名分析

指令解析之后,会进入到 MatchInstructionImpl 函数。该函数实现了别名处理,之后会做真正的匹配工作。

别名分析是为了识别同样一条指令的不同文本形式,并下降到同一种指令表示。在 TableGen 中有几种不同类型的别名描述,将会在后续小节阐述,它们的顺序是从简单到复杂排好序的,一般情况下,只需要使用第一种描述方法即可,这通常是最简洁的描述方法。

6.2.1 助记符别名

助记符别名的描述比较简单,它是一个 TableGen 的类 MnemonicAlias,接受两个指令类。它将一个输入的助记符映射到一个输出的助记符。

6.2.2 指令别名

通常对指令别名的解析是在指令匹配过程时完成的,需要提供新的别名映射关系和特定要生成的指令。所以,一个指令别名的描述分为两部分:要匹配的指令字符串名以及要生成的指令。比如:

def : InstAlias<"movsx $src, d s t " , ( M O V S X 16 r r 8 W G R 16 : dst", (MOVSX16rr8W GR16: dst",(MOVSX16rr8WGR16:dst, GR8 :$src)>;
def : InstAlias<"movsx $src, d s t " , ( M O V S X 16 r m 8 W G R 16 : dst", (MOVSX16rm8W GR16: dst",(MOVSX16rm8WGR16:dst, i8mem:$src)>;
def : InstAlias<"movsx $src, d s t " , ( M O V S X 32 r r 8 G R 32 : dst", (MOVSX32rr8 GR32: dst",(MOVSX32rr8GR32:dst, GR8 :$src)>;
def : InstAlias<"movsx $src, d s t " , ( M O V S X 32 r r 16 G R 32 : dst", (MOVSX32rr16 GR32: dst",(MOVSX32rr16GR32:dst, GR16 :$src)>;
def : InstAlias<"movsx $src, d s t " , ( M O V S X 64 r r 8 G R 64 : dst", (MOVSX64rr8 GR64: dst",(MOVSX64rr8GR64:dst, GR8 :$src)>;
def : InstAlias<"movsx $src, d s t " , ( M O V S X 64 r r 16 G R 64 : dst", (MOVSX64rr16 GR64: dst",(MOVSX64rr16GR64:dst, GR16 :$src)>;
def : InstAlias<"movsx $src, d s t " , ( M O V S X 64 r r 32 G R 64 : dst", (MOVSX64rr32 GR64: dst",(MOVSX64rr32GR64:dst, GR32 :$src)>;
这个例子成功的将相同的指令别名映射为不同的指令(仅依赖于操作数在汇编上的类型)。对应生成的指令可以与指令字符串名中的操作数顺序不同,也可以多次使用字符串名中的输入操作数,比如:

def : InstAlias<"clrb r e g " , ( X O R 8 r r G R 8 : reg", (XOR8rr GR8 : reg",(XOR8rrGR8:reg, GR8 :$reg)>;
def : InstAlias<"clrw r e g " , ( X O R 16 r r G R 16 : reg", (XOR16rr GR16: reg",(XOR16rrGR16:reg, GR16:$reg)>;
def : InstAlias<"clrl r e g " , ( X O R 32 r r G R 32 : reg", (XOR32rr GR32: reg",(XOR32rrGR32:reg, GR32:$reg)>;
def : InstAlias<"clrq r e g " , ( X O R 64 r r G R 64 : reg", (XOR64rr GR64: reg",(XOR64rrGR64:reg, GR64:$reg)>;
这个例子展示了多次使用在字符串名中的同一个操作数。在 X86 后端中,XOR8rr 有两个输入的 GR8 寄存器和一个输出的 GR8 寄存器(其中一个输入和输出共用同一个寄存器)。InstAlias 的操作数是直接对应的,不需要特殊指定重复的寄存器。生成的指令还可以直接使用立即数或固定寄存器作为操作数,如:

// 固定的立即数操作数
def : InstAlias<“aad”, (AAD8i8 10)>;

// 固定的寄存器
def : InstAlias<“fcomi”, (COM_FIr ST1)>;

// 简单的别名描述
def : InstAlias<"fcomi r e g " , ( C O M F I r R S T : reg", (COM_FIr RST: reg",(COMFIrRST:reg)>;
指令别名的描述可以使用 Requires 来约束它针对特殊 subtarget 的所属性。

如果后端支持,指令输出器可以自动的发射指令别名,而不是指令别名的映射名,这样可以让输出的汇编代码更清晰可读。如果希望这样,在 InstAlias 中的第三个参数传入 0。

6.3 指令匹配(原文未完成)

原文待完成状态

第七章是目标相关的一些实现细节。

这一部分和硬件特性相关,暂时不太关心,就不翻译了。需要提示的是,这部分内容很可能不再更新,其中部分内容我发现已经与代码不符,真实情况要结合代码熟悉。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值