LLVM指令选择过程理解

LLVM基本概念入门 网上关于LLVM的中文资料一直也不多,即使能找到的一些,也都是停留在怎么使用的层面,对于编译器开发工程师入门来说,感觉完全不够,所以我写写基础概念,能帮到大家也挺好的。 阅读详情

指令选择(instruction selection)是将中间语言转换成汇编或机器代码的过程。在LLVM后端中具体表现为模式匹配,目标指令选择阶段会把后端td文件里面的DAG模式和selection DAG的节点相匹配,如果找到一个匹配,则匹配的节点会被有具体机器指令(或者伪指令)的节点代替。

1. LLVM IR基本概念

LLVM IR是一门中间语言,向上承接C、JAVA等高级语言,向下可以被翻译成面向具体目标的汇编语言,在编译器中的位置如下图所示。其一个主要优点是LLVM项目已经构建了多种针对LLVM IR的优化方法,因此很多高级语言在转换为LLVM IR后可以使用现有的基础设施进行优化。

  • 前端:将程序源代码转换为LLVM IR的编译器步骤,包括词法分析器、语法分析器、语义分析器、LLVM IR生成器。Clang执行了所有与前端相关的步骤,并提供了一个插件接口和一个单独的静态分析工具来进行更深入的分析

  • 中间表示:LLVM IR可以以可读文本代码和二进制代码两种形式呈现。LLVM库中提供了对IR进行构造、组装和拆卸的接口。LLVM优化器也在IR上进行操作,并在IR上进行了大部分优化。

  • 后端:负责汇编码或机器码生成的步骤,将LLVM IR转换为特定机器架构的汇编代码或而二进制代码,包括寄存器分配、循环转换、窥视孔优化器、特定机器架构的优化和转换等步骤。

2bc3ae670f6ced760f780df7dab29b78.jpeg

编译器的三段式设计

根据上文所述,可以看出支持一种新的编程语言只需重新实现一个前端,支持一种新的目标架构只需重新实现一个后端,前端和后端连接枢纽就是LLVM IR。下面将主要介绍LLVM IR的基础表达指令:

  • 终结指令 Terminator Instructions

    • ret指令函数返回指令,对应C/C++中的return。

    • br指令,br是“分支”的英文branch的缩写,分为非条件分支和条件分支,对应C/C++的if语句无条件分支类似有x86汇编中的jmp指令,条件分支类似于x86汇编中的jnz,je等条件跳转指令

  • 比较指令

    • icmp指令,整数或指针的比较指令,条件cond可以是eq(相等),ne(不相等),ugt(无符号相等)

    • fcmp指令,浮点数的比较指令,条件cond可以是oeq(ordered and equal),ueq(unordered or equal)

    • switch指令,分支指令,可看做是br指令的升级版,支持的分支更多,但使用也更复杂,对应C/C++中的switch

  • 二元运算

    • add指令

    • sub指令

    • mul指令

    • udiv指令,无符号整数除法指令

    • sdiv指令,有符号整数除法指令

    • urem指令,无符号整数取余指令

    • srem指令,有符号整数取余指令

  • 按位二元运算

    • shl指令,整数左移操作指令

    • lshr指令,整数右移指令

    • ashr指令,整数算数右移指令

    • and指令,整数按位与运算指令

    • or指令,整数按位或运算指令

    • xor指令,整数按位异或运算指令

  • 内存访问和寻址操作

    • alloca指令,内存分配指令,在栈中分配一块空间并获得指向该空间的指针,类似与C/C++中的malloc函数

    • store指令,内存存储指令,向指针指向的内存中存储数据,类似与C/C++中的指针引用后的赋值操作

    • load指令, 内存加载指令,从内存中读取数值

  • 数据类型转换操作

    • trunc..to指令,截断指令,将一种类型的变量截断为另一种类型的变量。

    • zext..to指令,零扩展指令,将一种类型的变量拓展为另一种类型的变量,高位补0。

    • sext..to指令,符号位拓展指令,通过复制符号位(最高位)将一种类型的变量拓展为另一种类型的变量

  • 其他操作 Other Operations

    • phi指令,由静态单赋值引起的问题

    • select指令,? : 三元运算符

    • call指令,call指令用来调用某个函数,对应C/C++中的函数调用,与x86汇编中的call指令类似

    • intrinsic函数,非标准函数操作,例如abs、min、max、sqrt等

从LLVM IR的指令可以看出,其表达抽象程度比汇编语言要高,例如add二元加法运算,所有的i8、i16、i32的加法都要用其进行表示,甚至vector的加法也要使用该运算符表示,因此具有一定抽象能力。但是要想运行到目标硬件需要将LLVM IR转换为基于目标的汇编指令,该过程主要有LLVM的后端完成,一个主要工作就是进行指令选择,为抽象的LLVM运算符、内存操作符等选择对应的目标硬件指令,其主要依赖于DAG图的模式匹配技术。

2. 指令选择的总体流程

指令选择的输入是LLVM IR,输出是使用无限寄存器组的指令序列,整个过程可以如下几个阶段:

  1. 创建初始化DAG

  2. 优化

  3. 合法化Types

  4. 优化

  5. 合法化Operations

  6. 优化

  7. 指令选择

  8. 调度并格式化

本文将以一个简单的示例解释整个指令选择的过程,此函数的C程序如下,其实现了一个无符号的64-bit参数x和32-bit的参数y的乘积,返回值以32-bit无符号整形返回。

unsigned int MUL(unsigned long long int x, unsigned int y)
{
    return x * y;
}

2.1 创建DAG

指令选择的第一阶段是以LLVM IR作为输入创建DAG(有向无环图),后序的每个阶段都是对这个DAG进行处理,指导发射称为指令序列。上述C程序对应的LLVM IR表示如下,可由Clang工具生成IR表示。

define dso_local i32 @MUL(i64 %x, i32 %y) local_unnamed_addr #0 {
entry:
  %0 = trunc i64 %x to i32
  %conv1 = mul i32 %0, %y
  ret i32 %conv1
}

一个Selection DAG表示一个basic block,每个basic block是一个没有分支的指令连续指令序列。上述MUl函数只有一个entry block,但是一般函数可能具有多个basic block,下面的hello函数则具有4个basic block:entry、if.then、if.else、return四个基本块,将每个基本块分别创建一个DAG。

define dso_local i32 @hello(i32 %x) local_unnamed_addr #0 {
entry:
  %cmp = icmp eq i32 %x, 100
  br i1 %cmp, label %if.then, label %if.else

if.then:                                          ; preds = %entry
  %call = tail call i32 bitcast (i32 (...)* @hello100 to i32 (i32)*)(i32 100) #2
  br label %return

if.else:                                          ; preds = %entry
  %call1 = tail call i32 bitcast (i32 (...)* @helloOther to i32 (i32)*)(i32 %x) #2
  br label %return

return:                                           ; preds = %if.else, %if.then
  %retval.0 = phi i32 [ %call, %if.then ], [ %call1, %if.else ]
  ret i32 %retval.0
}

SelectionDAG 简单理解:

  • 每个node 代表一个SDNode实例表示一个操作,例如add、sub等

  • 每个node有一个或多个输入操作数,每个输入是一个SDvalue实例,其由每个节点的出边表示,定义了使用的输入数据来自哪个节点。

  • 每个节点的输出值,具有一个数据类型,例如 i1、i8

  • chain value既是一个输入数据也是一个输出数据,其类型为MTV::Other 一般load、return等具有副作用的节点上有该值,其保证在chain上各个节点的指令先后顺序

  • ISD::EntryToken作为入口节点

  • 根节点一般是最后一个具有副作用的并且有chain操作数的节点

下面就是MUL函数的初始化SelectionDAG:

4edd4393f1e4e0a441589d38a37f593a.png

初始化DAG

从上图中可以看出entry node在一个DAG的底部,蓝边代表chain操作数的传递过程,黑边代表数据的流动过程,上图的等效文本形式如下,可通过图的后序遍历得到:

t0: ch = EntryToken
        t2: i32,ch = CopyFromReg t0, Register:i32 %0
        t4: i32,ch = CopyFromReg t0, Register:i32 %1
      t7: i64 = build_pair t2, t4
    t8: i32 = truncate t7
    t6: i32,ch = CopyFromReg t0, Register:i32 %2
  t9: i32 = mul t8, t6
t11: ch,glue = CopyToReg t0, Register:i32 $x0, t9
t12: ch = RISCWISD::Ret t11, Register:i32 $x0, t11:1

2.2 优化

在指令选择过程中共有3次优化,第一次优化是在创建初始化DAG后,其余两次在合法化后,优化的目的主要是简化复杂的SelectionDAG。下图是第一次优化的结果,其变化是使用t13节点替代了t3和t4,优化器将64-bit数据的高32位删除,因为函数中乘法只要求返回32-bit整数,因此可以删除t3和t4。LLVM的第一次优化过程中只优化于目标独立的算子,例如add、sub、load等。LLVM后端开发者可以通过复写 PerformDAGCombine函数实现依赖于自定义目标的优化。

d80b6fb51355068966ff1dc16c92aef2.png

第一阶段优化

2.3 合法化 Types 和 Operations

完成第一阶段优化后,可以看到t7节点产生64-bit的结果,但是后端RISCW目标硬件只支持32-bit数据,本阶段将解决这个问题。首先进行Types合法化,将所有的数据转化为目标机器支持的数据类型,一般需要进行数据的转换、提升、扩展,例如将i1->i32,i64->i32。下图中的DAG就是合法的,将t3和t7删除将保证DAG使用i32整数。

3aacddfc26d73d085b91491d08d3941f.png

合法化DAG

Operations合法化在第二次优化后,将DAG中的operations转换为目标支持的操作,例如DAG中有 rotl node 但是目标指令集中不支持,因此可以将其转换为bit shift 和 or两个操作进行。有3种方法进行是opertions合法化。第一种方法,complier将其展开为支持的操作。第二种可以将其数据类型进行提升。第三种通过手写C++编码定制器合法化过程。

2.5 指令选择

在指令选择前,大多数DAG的node是目标无关的operations,例如add、sub,同时也会有依赖于目标的操作节点例如RISCWISD::Ret,所以我们需要将这些抽象的节点映射到具体的依赖于目标机器的指令,LLVM中在指令选择阶段实现。其主要思想也比较简单,就是通过模式将node匹配到机器指令。指令的描述和模式由 LLVM 后端开发者进行编写,主要使用TableGen语法在td文件中进行描述。同时复杂的模式也可以通过直接C++编码实现。下图是指令选择完成后的DAG图,可以看出t9使用MUL代替了原来的mul,t12节点使用PseudoRET代替了RISCWISD::Ret。

b27f44c6b763879438462f3dd73e9b65.png

指令选择完DAG

指令选择过程就是DAG的模式匹配过程,模式的定义其主要在td文件中进行描述。当一个匹配找到后,将其DAG中的Node替换为具体的机器指令或伪指令。所以td文件中的Pattern的定义对于治病选择起到至关重要的作用。

每一个 Pattern 记录继承子 Pat class,其有两个参数,第一个参数DAG图中待匹配的模式,第二个参数是一个由机器指令组成DAG,当一个 Pattern 匹配后,将使用第二个参数替换第一个参数。示例如下:

class PatGprGpr<SDPatternOperator OpNode, RWInst Inst>
    : Pat<(OpNode GPR:$rs1, GPR:$rs2), (Inst GPR:$rs1, GPR:$rs2)>;
class PatGprSimm12<SDPatternOperator OpNode, RWInstI Inst>
    : Pat<(OpNode GPR:$rs1, simm12:$imm12), (Inst GPR:$rs1, simm12:$imm12)>;

PatGprGpr用于替换两个通用寄存器操作数的指令,Pattern定义方法如下,其中add为llvm ir中操作, ADD为依赖于机器的指令操作。

def : PatGprGpr<add, ADD>;

PatGprSimm12 替换一个操作数为通用寄存器,另一个操作数为立即数的指令。其中simm12是一个用于匹配12bit立即数的模式。

def : PatGprSimm12<add, ADDI>;

2.6 调度和格式化

在这个阶段是将指令选择完成的DAG转化为指令序列,该过程中可以设置符合目标硬件的调度策略。如下是MUL函数的指令序列,在此序列中仍然是使用虚拟寄存器表示,后续会有寄存器分配阶段来完成,下面序列中有部分寄存器活性信息。

bb.0.entry:
  liveins: $x0, $x2
  %2:gpr = COPY $x2
  %0:gpr = COPY $x0
  %3:gpr = MUL %0:gpr, %2:gpr
  $x0 = COPY %3:gpr
  PseudoRET implicit $x0

3 总结

上文主要讲述了LLVM IR的语法基础及将IR的抽象表达指令转换到底层的汇编指令。LLVM IR作为编译器的中间组件,起到了承上启下的作用,给上层开发新语言提供了接口,也给下层支持新硬件提供了开发框架,同时LLVM提供了多种优化模块及数据分析模块帮助编译器开发者实现优化,极大的降低了编译器开发的门槛。同时文中也详细阐述了LLVM指令的选择过程,展示如何将ISD的SDNode转换为汇编指令操作,帮助编译器开发者理解LLVM后端的工作机制,以方便开发者添加自己的芯片后端。

在AI芯片行业蓬勃发展的当下,不但要求有高水准的芯片设计能力和高水平的芯片制造能力,同时也需要更强的软件能力,拥有一个优化水平高的编译器是一款成功芯片的关键,它可以在相同成本的情况获得更快更好的AI服务,因此编译器的研究在当前的芯片行业变得尤为重要。

参考文献

  • https://sourcecodeartisan.com/2020/11/17/llvm-backend-4.html

  • https://www.cnblogs.com/Five100Miles/p/12822190.html

  • https://llvm.org/docs/LangRef.html#abstract

  • https://apsarasx.com/docs/llvm-tutorials

639710c38c82a1b0b172da71f5030f75.png

LLVM简介(从入门到精通---最精炼介绍) LLVM和GCC都是编译器框架,llvm由C++编写,被Apple、Google、Facebook等广泛采用1987年诞生 GCC: 为整体应用程序设计,新平台需定制化实现,开发难度和成本高2000年诞生 LLVM: 三段式结构(前、中、后端),模块化的架构更灵活和强大的优化能力可以为任何编程语言独立编写前端,或为任意硬件架构独立编写后端LLVM的中间语言与具体的语言、指令集、类型系统无关,其中每条指令都是静态单赋值形式静态单赋值形式)(SSA), 即每个变量只能被赋值一次。 阅读详情

相关推荐

LLVM | 编译器框架

介绍 LLVM 背景以及传统编译器架构,同时对 LLVM 项目中的 Clang 和 GCC 进行了简单的介绍和分析。

qq_38844835的博客 5841

简介LLVM

  LLVM到底是什么? 维基百科解释 LLVM是一个自由软件项目,它是一种编译器基础设施,以C++写成。它是为了任意一种编程语言而写成的程序,利用虚拟技术创造出编译时期、链接时期、运行时期以及“闲置时期”的最优化。它最早以C/C++为实现对象,而目前它已支持包括ActionScript、Ada、D语言、Fortran、GLSL、Haskell、Java字节码、Objective-C、Swi...

一个iOS开发者的小窝 4129

跟随一条指令来看LLVM的基本结构

LLVM是一个很复杂的软件,了解LLVM的工作原理不是很容易,然而,对于刚开始接触LLVM整个框架的工作原理来说,详细而深入,不如广泛而浅显,所以有了这一篇文章。

SiberiaBear的专栏 4921

深度学习AI编译器-LLVM简介

LLVM是一个编译器框架。LLVM作为编译器框架,是需要各种功能模块支撑起来的,你可以将clang和lld都看做是LLVM的组成部分,框架的意思是,你可以基于LLVM提供的功能开发自己的模块,并集成在LLVM系统上,增加它的功能,或者就单纯自己开发软件工具,而利用LLVM来支撑底层实现。LLVM由一些库和工具组成,正因为它的这种设计思想,使它可以很容易和IDE集成(因为IDE软件可以直接调用库来实现一些如静态检查这些功能),也很容易构建生成各种功能的工具(因为新的工具只需要调用需要的库就行)。

wsq_zqfl的博客 5933

LLVM传统编译器介绍

LLVM框架基础

m0_72676337的博客 2222

LLVM实战之模块化设计

Pass之间耦合很小,而Pass之间的依赖信息由LLVM Pass管理器(Pass Manager)来统一管理,在Pass运行的时候会进行解析。它允许自定义选择各个Pass(遍)的执行顺序,也能够自定义选择哪些优化Pass——也就是说,有一些优化对目前的系统是没有帮助的,只有部分优化会针对目前系统。从结果中可以看出,运行deadargelim pass,对第一个函数没有任何影响,但优化对第2个函数有所影响——前一次优化中没有修改的部分代码在本次优化中被改变,无用的参数被消除了。(1)准备测试case。

panpan_jiang1的博客 1795

LLVM 指令选择 笔记

ZhiqianXia 664

LLVM系列第二十七章:理解IRBuilder

简单地分析一下LLVM API中的IRBuilder的作用和用法

Zhanglin_Wu的专栏, C++, Scrum, LLVM, 编译器 4608

LLVM入门-章1

编译器LLVM入门

TomOfJerry的博客 706

LLVM IR学习记录(1) GetElementPtr指令

GetElementPtr指令是一条指针计算语句,本身并不进行任何数据的访问或修改,只进行指针的计算。使用语法如下: <result> = getelementptr <ty>, <ty>* <ptrval>{, [inrange] <ty> <idx>}* <result> = getelementptr inbounds <ty>, <ty>* <ptrval>{, [inra.

许你一片梁城的博客 7773

编译器架构的王者LLVM——(11)深入理解GetElementPtr

LLVM平台,短短几年间,改变了众多编程语言的走向,也催生了一大批具有特色的编程语言的出现,不愧为编译器架构的王者,也荣获2012年ACM软件系统奖 —— 题记 LLVM平台,和C语言极为类似,强类型,需要复杂的指针操作,基于系统的符号调用等。而LLVM的指针操作指令,GetElementPtr,几乎是所有指针计算的关键,而理解它个运作原理,正确的使用,非常的重要。

西风世界 1万+

从零构建类LLVM IR框架:C语言实现编译器后端核心原理与实践

中间表示(IR)是现代编译器架构的核心组件,它作为高级语言与机器码之间的桥梁,承担着程序分析与优化的关键任务。其设计通常基于静态单赋值形式(SSA),通过基本块和控制流图(CFG)等数据结构,实现对程序逻辑的抽象表示。从技术价值看,IR使得编译器前端与后端解耦,为跨平台代码生成和多种优化算法提供了统一的基础。在应用场景上,无论是传统CPU的代码生成,还是新兴硬件架构的定制优化,都离不开高效、灵活的IR系统。本文以C语言手动实现一个简化版LLVM-like IR框架为例,深入探讨了IR的数据结构设计、内存管理

angou6476的博客 308

嵌入式开发必备:手把手教你用DB-9连接器搞定RS-232串口通信(附万用表测试技巧)

本文详细解析了嵌入式开发中RS-232串口通信的硬件连接核心。从DB-9连接器的引脚定义入手,深入讲解DTE与DCE设备角色,明确直连线与交叉线的选择法则,并重点分享了使用万用表进行线缆连通性测试、电压测量及故障诊断的实战技巧,帮助开发者彻底掌握串口通信的硬件连接与排查方法。

tgb345678的博客 1166

LLVM是如何编译指令的

本文将会通过一条指令在LLVM中经过不同阶段的变化,从源程序语言中的语义结构到成为机器二进制码来研究LLVM的工作原理。 本文不会介绍LLVM是如何工作的,这需要理解LLVM的设计以及code以及各种细节。 输入代码 我们从一段C代码开始探险,如下: int foo(int aa, int bb, int cc) { int sum = aa + bb; return sum / cc; } 本文将会重点关注除法操作。 Clang Clang是作为LLVM的前端使用的,负责将C,C++,以及Obj

Keep Fighting! 1275

SAP开发顾问简历模板范文(精选篇)

SAP开发顾问简历该怎么写呢?

Huanzhucv的博客 1568

【OpenCV】手眼标定实战:从原理到calibrateHandEye的完整实现

本文详细解析了机器人视觉中的手眼标定原理与实战,重点介绍了如何使用OpenCV的calibrateHandEye函数实现从数据采集到结果验证的完整流程。文章深入剖析了AX=XB核心方程,区分了眼在手上与眼在手外两种配置,并提供了详细的代码示例与避坑指南,帮助开发者精准建立相机与机械臂末端的坐标转换关系,是机器人精准抓取的关键技术。

weixin_29046611的博客 508

LLVM指令选择中的模式定义方法

目标指令选择 目标指令选择阶段会把后端td文件里面的DAG模式和selection DAG的节点相匹配 如果找到一个匹配,则匹配的节点会被有具体机器指令(或者伪指令)的节点代替。 因此你的td模式的指令对于生成好的代码很重要,因此你需要话费很多时间来调试 这些模式。 理解pat用法 模式的record记录从llvm的pat类继承,这个pat类有两个参数,第一个参数是要匹配模式 的dag,第二个参数是机器指令的dag,当模式匹配了dag中的一些节点时,匹配的节点会被第二个参数中的dag替换。 接下来要使用一些

zgl07的专栏 1907

【Unity】NGUI下载与导入

Unity3D插件NGUI的下载和导入。

Get your hands dirty 3万+

LLVM Pass 开发实战:从 IR 遍历到指令级优化的编译器扩展

LLVM Pass 开发为编译器扩展提供了统一的接口和工具支持,使得领域特定的优化逻辑可以嵌入到编译管线中。通过 ScalarEvolution 的归纳变量分析与支配树的条件推理,冗余边界检查消除 Pass 能够在编译期安全地消除运行时冗余检查,减少分支预测失败,提升循环密集型代码的执行效率。落地建议:新 Pass 开发优先使用 New Pass Manager API;Pass 注册时显式声明对前置分析(LoopInfo、ScalarEvolution、DominatorTree)的依赖;

2301_81410839的博客 6133

26版SPSS操作教程(高级教程第十章)

#大道之行,在于坚持 #本期内容:二分类Logistic回归模型 #由于导师最近布置了学习SPSS这款软件的任务,因此想来平台和大家一起交流下学习经验,这期推送内容接上一次高级教程第九章的学习笔记,希望能得到一些指正和帮助~

你好! 1599

[PCIE5.0] 4.2.3.2 Encoding of Presets

• 发射器预设:发射器预设值用4位二进制编码,表示不同的预设设置(P0 到 P10),这些设置定义了信号的传输特性。• 接收器预设提示:接收器预设提示是通过3位二进制编码告诉接收器建议的增益值,用于调整接收器的灵敏度(适用于8.0 GT/s数据速率)。

系统固件工程师 574

【AI系统】LLVM IR 详解

在上一篇文章中,我们已经简要介绍了 LLVM 的基本概念和架构,我们现在将更深入地研究 LLVM 的 IR(中间表示)的概念。了解 LLVM IR 的重要性是为了能够更好地理解编译器的运作原理,以及在编译过程中 IR 是如何被使用的。LLVM IR 提供了一种抽象程度适中的表示形式,同时能够涵盖绝大多数源代码所包含的信息,这使得编译器能够更为灵活地操作和优化代码。本文将进一步探究 LLVM IR 的不同表示形式,将有助于我们更好地理解代码在编译器中是如何被处理和转换的。

ZOMI酱 1374

基于sklearn实现LDA主题模型(附实战案例)

目录LDA主题模型1.LDA主题模型原理2.LDA主题模型推演过程3.sklearn实现LDA主题模型(实战)3.1数据集介绍3.2导入数据3.3分词处理 3.4文本向量化3.5构建LDA模型3.6LDA模型可视化 3.7困惑度 其实说到LDA能想到的有两个含义,一种是线性判别分析(Linear Discriminant Analysis),一种说的是概率主题模型:隐含狄利克雷分布(Latent Dirichlet Allocation,简称LDA)。 现在讨论的是主题模型

m0_64336780的博客 2万+

LLVM——从入门到入土

LLVM

weixin_62387234的博客 3922

[QMT量化交易小白入门]-十二、QMT回测龙头打首板和连板的策略

class a():passA = a() #创建空的类的实例 用来保存委托状态field_list定义了需要的市场数据字段,而类a用于保存交易状态。

木头大左的博客 4757

温度PID控制PLC程序

温度PID稳定控制程序,适用于欧姆龙PLC,对其他PLC也具有很大参考价值

LLVM IR指令简介及上手教程

LLVM IR指令及运行

weixin_47100480的博客 2921

锂电池充电器电路设计 里面有电路

锂电池充电器电路设计 里面有电路 3.7V升压到5V电路设计。

上一篇: Adlik新版本发布,增加一种芯片支持
下一篇: 致力人工智能应用落地 推动数字经济技术创新——Adlik荣获两个开源奖项
Linux基金会AI&Data基金会
博客等级 码龄6年 100粉丝 47原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值