编译原理与优化

一、语言处理器

我们都知道,计算机只能处理0和1构造的机器码,但人们通常只能通过高级语言或者中低级语言(C/C++/Python),再或者是汇编语言去永久记忆一些自己的逻辑想法,无法掌握大量、繁杂的机器码。因此,从高级语言到机器码,必须进行语言的转换 ,这就是语言处理器。下是语言处理器的结构:

源程序:即源语言所写程序,也就是我们平时用高级语言写的代码,如C、C++、Python等。

预处理器:预处理也叫预编译,主要用于执行预编译命令,以C++为例,预编译的操作包括:

  •  将所有的#define删除,并且展开所有的宏定义;

  • 处理所有的条件预编译指令,如#if、#ifdef;

  •  处理#include预编译指令,将被包含的文件插入到该预编译指令的位置;

  • 过滤所有的注;

  • 添加行号和文件名标识。

编译器:编译器的核心功能是把源代码翻译成目标代码,也是本文介绍的主要内容,主要包括词法分析、语法分析、语义分析、代码优化、目标代码生成、目标代码优化等。

汇编器:汇编器会对由编译器产生的汇编语言处理,生成可重定位的机器码。那什么是可重定位的机器码?写过LLVM IR的都知道IR是通过br指令在basicblock之间跳转来实现逻辑,这种basicblock被称为逻辑地址空间,而程序在运行的时候,真正用到的是物理地址空间,所以这个时候就需要有一种从逻辑地址到物理地址的映射。由于操作系统给进程分配内存的起始位置L并不固定,所以不能在编译的时候就把逻辑地址和物理地址一一对应写死,要不然程序没法跑了。那怎么办呢?如果在编译时,涉及到有关地址的操作,如某个地址对应数据的读取和写入、地址之间的跳转等有一种动态的方式根据起始位置去调整,这样就可以达到我们的预期(起始位置 +相对地址=绝对地址,根据这个规则调整)。而上面的这个根据起始位置动态调整过的代码叫做可重定位代码,它是在加载的时候,也就是系统给进程确定了物理地址时,才生成绝对地址。

链接器/加载器:

链接器,将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。链接分为静态链接和动态链接。

静态链接,是在链接的时候就已经把要调用的函数或者过程链接到了生成的可执行文件中,就算你在去把静态库删除也不会影响可执行程序的执行;生成的静态链接库,Windows下以.lib为后缀,Linux下以.a为后缀。

而动态链接,是在链接的时候没有把调用的函数代码链接进去,而是在执行的过程中,再去找要链接的函数,生成的可执行文件中没有函数代码,只包含函数的重定位信息,所以当你删除动态库时,可执行程序就不能运行。生成的动态链接库,Windows下以.dll为后缀,Linux下以.so为后缀。

加载器,修改可重定位地址,将修改后的指令和数据放到内存中适当的位置。由汇编器生成可重定位的代码后,逻辑地址和物理地址还并没有生成真实的映射关系,待系统给进程分配了物理地址,根据起始位置 +相对地址=绝对地址 才生成绝对地址。

以上就是一个语言处理器的基本构成,下面一起来探讨编译器的结构与过程。

二、编译器的结构

编译器的核心功能是把源代码翻译成目标代码,这里的目标机器代码并不一定是机器码:如果你要将源语言编译成汇编语言,这里的目标语言就是汇编语言;如果你打算直接编译成机器码,也就是跳过汇编器,那这里的目标语言就是机器码。编译器一个很重要的任务就是报告他在编译的过程中发现的源程序中的错误。编译器的主要结构如下:

  • 词法分析器:字符流->单词流

  • 语法分析器:单词流->语法树

  • 语义分析器:

    • 收集标识符的属性信息:

      • 类型(Type)

      • 种属(Kind)

      • 存储位置、长度

      • 作用域

      • 参数和返回值信息

    • 语义检查:

      • 变量或过程未经声明就使用

      • 变量或过程名重复声明

      • 运算分量类型不匹配

      • 操作符与操作数之间的类型不匹配

  • 中间代码生成器:抽象语法树->中间表示(与平台无关的抽象程序):

      • 易于产生

      • 易于翻译成目标程序

      • 三地址码:temp1=c*d;temp2=b+temp1;a=temp2

      • 四元式:(op, arg1, arg2, result);(* , c , d , temp1);(+ , b, temp1 , temp2);(= , temp2 , - , a)

  • 代码优化器:试图改进中间代码,以产生执行速度较快的机器代码:

      • temp1=c*d;temp2=b+temp1;a=temp2

      • change to:temp1=c*d;a=b+temp1

  • 代码生成器:生成可重定位的机器代码或汇编代码:

      • temp1=c*d;a=b+temp1

      • change to:Mov R2,c;Mul R2, d;Mov R1, b;Add R2, R1;Mov a, R2

      • 一个重要任务是为程序中使用的变量合理分配寄存器

  • 符号管理表:

      • 基本功能是记录源程序中使用的标识符,

      • 并收集与每个标识符相关的各种属性信息,

      • 并将它们记载到符号表中。

  • 错误处理器:

      • 处理方式:报告错误,应继续编译

      • 大部分错误在语法分析、语义分析阶段检测出来

      • 词法分析:字符无法构成合法单词

      • 语法分析:单词流违反语法结构规则

      • 语义分析:语法结构正确,但无实际意义

三、程序语言

任何语言实现的基础是语言定义。语言的定义决定了该语言具有什么样的语言功能、 什么样的程序结构、以及具体的使用形式等细节问题。因此,了解语言定义是编译的基础。

  • 词法:是指单词符号的形成规则。(状态转换图、正则表达式和有穷自动机);

  • 语法:是指一组规则,用它可产生一个程序。(下推自动机理论和上下文无关文法是讨论语法分析的理论基础);

  • 文法:文法用来描述语言的语法结构的形式规则。(3型文法--正则文法--词法结构;2型文法--上下文无关文法--语法结构;1型文法--上下文有关文法;0型文法--任意文法)

  • 规则:词法规则 + 语法规则,语法规则和词法规则定义了程序的形式结构;

  • 语义:定义语言的单词符号和语法单位的意义。(描述语义的方法有自然语言描述和形式描述,但自然语言描述具有二义性、隐藏错误和不完整性。);

  • 形式语言:上述的定义是用文字来描述的,当设计编译程序时,就要把它用形式的方式描述出来,就要用到形式语言。

四、词法分析

词法分析(lexical analysis)是编译器的第一个步骤,也叫扫描(scanning),他的主要任务是从左向右逐行扫描源程序的字符,识别出各个单词,确定单词的类型,将识别出的单词转换成统一的机内表示—— 词法单元(token) 形式。

词法分析的主要作用:识别源程序中的单词是否有误;读入源程序字符流、组成词素,输出词法单元序列;过滤空白、换行、制表符、注释等将词素添加到符号表中。

词法分析涉及到三个重要的相关术语——词法单元、模式和词素:

  • 词法单元由词法单元名和可选的属性值组成。词法单元名是一个词法单元的引用(别名),它将作为语法分析器处理的输入符号。当有多个词素的词法单元名相同时,可以附加属性值信息来区别这些词素。词法单元名将影响语法分析过程中的决定,而属性值将影响语法分析之后对这个词法单元的翻译(具体翻译成哪一个词素);

  • 模式描述一个词法单元的词素可能具有的形式;

  • 词素是一个字符序列(串),它和某个词法单元的模式匹配,并被词法分析器识别为该词法单元的一个实例。

4.1 词法单元

词法分析程序输出的单词符号,通常表示为:

token:<种别码,属性值 >
  • 种别码,词法分析器从左向右逐行按字符读取到的token所对应的提前约定好的名字,这种名字叫种别码。如:if (a > b)中,if的种别码是IF,A的种别码为IDN;

  • 属性值:属性值是指向符号表中关于这个词法单元(token)的符号表条目,符号表条目的信息会被语义分析和代码生成步骤使用。

1)第一行是关键字,高级程序中每个关键字都是确定的,if就是if,while就是)while,所以关键字的种别码是一词一码。

2)第二行是标识符,如变量名、数组名、记录名等,对应多词一码种别码。标识符自身的值就是标识符自身的字符串。

3)后面几行就按照前面的理解方式理解,一型一码的意思是一种类型对应一个种别码。

【举个例子】 词法分析器对下面代码分析:

while(value!=100){  num++;}

分析的结果如下图:<种别码,属性值>

4.2 词法单元的描述--正则表达式定义规则

则表达式可以用来描述词素的模式,一个正则表达式可以由较小的正则表达式递归的构建。

若 r, s为正则表达式,表示语言L ( r ) 和 L ( s ) L(r)和L(s)L(r)和L(s),则(从1到4优先级递增):

  • (r)∣(s)是正则表达式,表示语言L(r)∪L(s);

  • ( r ) ( s ) 是 正 则 表 达 式 , 表 示 语 言 L ( r ) L ( s ) (r)(s)是正则表达式,表示语言L(r)L(s)(r)(s)是正则表达式,表示语言L(r)L(s);

  • ( r ) ∗ 是 正 则 表 达 式 , 表 示 语 言 ( L ( r ) ) ∗ (r)*是正则表达式,表示语言(L(r))*(r)∗是正则表达式,表示语言(L(r))∗;

  • ( r ) 是 正 则 表 达 式 , 表 示 语 言 L ( r ) (r) 是正则表达式,表示语言L(r)(r)是正则表达式,表示语言L(r)。

【举例说明】于符号集合∑={a,b},有:

  • 正则表达式a表示语言{a};

  • 正则表达式a|b表示语言{a,b};

  • 正则表达式(a|b)(a|b)表示语言{aa,ab,ba,bb};

  • 正则表达式a*表示语言{ε,a,aa,aaa,…};

  • 正则表达式(a|b)*表示语言{ε,a,b,aa,ab,ba,bb,aaa,…};

  • 正则表达式a|a*b表示语言{a,b,ab,aab,aaab,…}。


4.3 词法单元的识别

上面介绍了如何用正则表达式来表示一个模式,下面我们将介绍如何根据词法单元的模式来识别一个与该模式匹配的词素,为此,我们首先将模式转换成状态转换图。

一个状态转换图由一组表示状态的结点和表示输入字符的边构成,词法分析器在扫描输入字符串的过程中寻找和某个模式匹配的词素,状态转换图中的每个状态代表一个可能在这个过程中出现的情况。一个状态转换图有如下特点:

  • 有一个初始状态,该状态由一条无出发结点的、标号为“start”的边指明。在读入任何输入符号之前,状态转换图总位于它的初始状态;

  • 有某些最终状态,该状态用双层的圈表示。这些状态表明已经找到一个词素;

  • 有某些回退状态,该状态附近有一个“*”标明。在识别“3+4”这个串时,只有当扫描到“+”符号时,才能确定前面的数字符号“3”,此时识别出了词素“3”,并且需要回退一个字符。

下面是词法单元relop的状态转换图,它表示比较运算符<、>、<=、>=、<>和=:

根据这个状态转换图,我们可以十分容易的编写出一段代码来识别这些比较运算符。

五、语法分析

语法分析(syntax analysis)是编译器的第二个步骤,也叫解析(parsing)。语法分析器(parser)从词法分析器输出的token序列中识别出各类短语,从而构造语法分析树(syntax tree),并判断源程序在结构上是否正确。

语法分析的作用:利用语法检查单词流的语法结构;构造语法分析树;语法错误和修正;识别正确语法;报告错误。

5.1 上下文无关文法

上下文无关文法是描述程序语言语法的强有力的数学工具

上下文无关文法G是一个四元组 G = (VT,VN,S,P),其中 

上面是上下文无关文法的产生式形式,上下文无关文法取名为“上下文无关”的原因就是因为字符P 总可以被字串 a(阿尔法) 自由替换,而无需考虑字符 P 出现的上下文。比如根据下面上下文文法规则,<句子>只要出现,就能被<主语><谓语><间接宾语><直接宾语>替换;<主语>只要出现,就可以被<代词>替换;而这些替换不需要考虑句子出现在什么地方,主语出现在什么地方,也就是不考虑文法的上下文。

 举一个例子,定义只含+,*的算术表达式的文法 G=< {i,+,*,(,)},{E},E, P >, 其中,P由下列产生式组成:

5.2 语法分析树

对每一个句型(句型的接受在后面),该句型一定有一个推导过程(可能不唯一),推导过程一定对应一颗语法树。语法分析树其实就是一个树的数据结构,用来描述源程序的语法结构。以一个简单的赋值语句来举例:

position = initial + rate * 60;

赋值语句是计算出等号右边表达式的值,然后再赋值给左边的变量。所以它对应的语法树左子树是个标识符,右子树是个表达式。

加法运算是将加号两边的两个常量或变量相加,所以它对应的语法树左子树和右子树均是标识符或常量。乘法表达式同理。所以上面赋值语句就有了下面的语法树结构:

这条赋值语句s = 2 * 3.14 * r * (h + r);相比于上面要复杂些,它对应的语法分析树如下:

5.3 语法错误与处理

  • 不同层次的错误

      • 词法:拼写错误(then → them)

      • 语法:单词漏掉、顺序错误(花括号不配对)

      • 语义:类型错误(声明void f()和调用aa = f())

      • 逻辑:无限循环/递归调用( == → = )

  • 错误处理目标

      • 清楚、准确地检测、报告错误及其发生位置

      • 快速恢复,继续编译,以便发现后续错误

      • 不能对正确程序的编译速度造成很大影响

LL,LR,可最快速度发现错误:可行前缀特性,viable-prefix property,当一个输入前缀不是语言中任何符号串前缀——发生错误。

  • 错误恢复策略

    • 错误恢复策略

      • 丢弃单词,直到发现 “同步”单词,设计者指定同步单词集,{end, “;”, “}”, …}

      • 缺点:丢弃输入导致遗漏定义,造成更多错误;遗漏错误

      • 优点:简单 → 适合每个语句一个错误的情况

    • 短语层次的恢复

      • 局部修正,继续分析,同样由设计者指定修正方法。e.g. “,” → “;”,删除“,”,插入“;”

      • 与恐慌模式相结合,避免丢弃过多单词

    • 错误产生式

      • 理解、描述错误模式,文法添加生成错误语句的产生式,拓广文法 → 语法分析器程序。( 错误检测信息+自动修正)

      • e.g. 对C语言赋值语句,为“:=”添加规则报告错误,但继续编译

    • 全局纠正

      • 错误程序 → 正确程序,寻找最少修正步骤,插入、删除、替换

      • 过于复杂,时空效率低。

六、语义分析

语义分析器(semantic analyzer)使用语法树和符号表中的信息来检查源程序是否和语言定义的语义一致。他同时也收集标识符的属性信息,并把这些信息存放在语法树中,以便在后面中间代码生成过程中使用。

语义分析的一个重要部分是类型检查(type checking)。编译器检查每个运算符是否具有匹配的运算分量,比如数组的下标要求必须是一个整数,如果用浮点数作为数组下标,编译器就应该报错。

所以语义分析主要有两个任务:收集标识符信息、语义检查。收集标识符信息包括标识符的种属 (Kind),如常量、变量、数组、函数等;标识符的类型(type),如整型、实型、字符型等。语义检查主要检查源程序与语言对应的语义是否相一致,一些常用语言的错误语义如下:

  • 变量或过程未经声明就使用;

  • 变量或过程名重复声明;

  • 运算分量类型不匹配;

  • 操作符与操作数之间的类型不匹配,如:

      • 数组下标不是整数;

      • 对非数组变量使用数组访问操作符;

      • 对非过程名使用过程调用操作符;

      • 过程调用的参数类型或数目不匹配;

      • 函数返回类型有误。

有的编译器是将语法分析和语义分析一起处理的,以下面赋值语句为例解释:

int a = 1;int sum = 0;sum = a + 110;

编译器在解析sum = a + 110的语法的时候,就会判断=两边的类型是否相兼容,如果不兼容要报错。在解析a + 110加法表达式的时候,也会判断+两边的表达式是否相兼容,不兼容就会报错,这里注意表达式也会产生一个类型,常见的语言都是int+int产生int,double+int产生double。

而有的编译器是将语法分析和语义分析分成两个部分分别去处理的。编译器首先进行语法解析,在语法解析的时候不做语义检查,而是等语法解析完成后,再基于语法解析生成的语法分析树做语义检查。这两种做法要根据实际情况去选择。

七、中间代码生成

编译器的主要目的就是将高级语言写的源程序翻译成目标机器对应的汇编语言,再交由汇编器去处理生成可重定位的机器码。一般的过程都是:源程序——语法树——中间代码——目标代码。

在这个翻译的过程中,一个编译器可能构造出一个或多个中间表示,且这些中间表示可以有多种形式。语法分析时产生的语法分析树也算是一种中间表示。一种常见的中间代码是三地址码,可以通过四元式、三元式或间接三元式的方式表示。下面是一些常用的三地址代码指令:

这是上面三地址指令对应的四元式表示:

现在工业界比较流行的一种编译器框架是LLVM,基于LLVM做一些语言的编译器需求也是越来越多。下面举了一个简单的例子:

常见的高级语言代码:

int a = 10;   int b = 11;   return a + b;

编译过去之后的IR代码:

  %a = alloca i32, align 4  %b = alloca i32, align 4  store i32 0, i32* %retval, align 4  store i32 10, i32* %a, align 4  store i32 11, i32* %b, align 4  %0 = load i32, i32* %a, align 4  %1 = load i32, i32* %b, align 4  %add = add nsw i32 %0, %1  ret i32 %add

八、代码优化

可以分两个维度分析,第一个分类维度,是机器无关的优化与机器相关的优化。

机器无关的优化与硬件特征无关,比如把常数值在编译期计算出来(常数折叠)。而机器相关的优化则需要利用某硬件特有的特征,比如 SIMD 指令可以在一条指令里完成多个数据的计算。

第二个分类维度,是优化的范围。本地优化是针对一个基本块中的代码,全局优化是针对整个函数(或过程),过程间优化则能够跨越多个函数(或过程)做优化。

8.1 常量传播

常量传播,就是说在编译期时,能够直接计算出结果(这个结果往往是常量)的变量,将被编译器由直接计算出的结果常量来替换这个变量。

int main(int argc,char **argv){    int x = 1;    std::cout<<x<<std::endl;    return 0;}

上例种,编译器会直接用常量1替换变量x,优化成:

int main(int argc,char **argv){    std::cout<<1<<std::endl;    return 0;}

8.2 常量折叠

常量折叠,就是说在编译期间,如果有可能,多个变量的计算可以最终替换为一个变量的计算,通常是多个变量的多级冗余计算被替换为一个变量的一级计算

例:

int main(int argc,char **argv){    int a = 1;    int b = 2;    int x = a + b;    std::cout<<x<<std::endl;    return 0;}

常量折叠优化后:

int main(int argc,char **argv){    int x = 1 + 2;    std::cout<<x<<std::endl;    return 0;}

当然,可以再进行进一步的常量替换优化:

int main(int argc,char **argv){    std::cout<<3<<std::endl;    return 0;}

8.3 复写传播

复写传播,就是编译器用一个变量替换两个或多个相同的变量。

例:

int main(int argc,char **argv){    int y = 1;    int x = y;    std::cout<<x<<std::endl;    return 0;}

优化后:

int main(int argc,char **argv){    int x = 1;    std::cout<<x<<std::endl;    return 0;}

上例有两个变量y和x,但是其实是两个相同的变量,并且其它地方并未区分它们两个,所以它们是重复的,可称为“复写”,编译器可以将其优化,将x“传播”给y,只剩下一个变量x,当然,反过来优化掉x只剩下一个y也是可以的。

8.4 公共子表式消除

公共子表达式消除是说,如果一个表达式E已经计算过了,并且从先前的计算到现在的E中的变量都没有发生变化,那么E的此次出现就成为了公共子表达式,因此,编译器可判断其不需要再次进行计算浪费性能。

例:

int main(int argc,char **argv){    int a = 1;    int b = 2;    int x = (a+b) * 2 + (b+a) * 6;    std::cout<<x<<std::endl;    return 0;}

优化后:

int main(int argc,char **argv){    int a = 1;    int b = 2;    int E = a + b;    int x = E * 2 + E * 6;    std::cout<<x<<std::endl;    return 0;}

当然,也有可能会直接变成:

int main(int argc,char **argv){    int a = 1;    int b = 2;    int E = a + b;    int x = E * 8;    std::cout<<x<<std::endl;    return 0;}

8.5 无用代码消除

无用代码消除指的是永远不能被执行到的代码或者没有任何意义的代码会被清除掉,比如return之后的语句,变量自己给自己赋值等等。

例:

int main(int argc,char **argv){    int x = 1;    int x = x;    std::cout<<x<<std::endl;    return 0;}

上例中,x变量自我赋值显然是无用代码,将会被优化掉:

int main(int argc,char **argv){    int x = 1;    std::cout<<x<<std::endl;    return 0;}

8.6 数组范围检查消除

如果开发语言是Java这种动态类型安全型的,那在访问数组时比如array[ ]时,Java不会像C/C++那样只是存粹的裸指针访问,而是会在运行时访问数组元素前进行一次是否越界检查,这将会带来许多开销,如果即时编译器能根据数据流分析出变量的取值范围在[0,array.length]之间,那么在循环期间就可以把数组的上下边界检查消除,以减少不必要的性能损耗。

8.7 方法内联

这种优化方法是将比较简短的函数或者方法代码直接粘贴到其调用者中,以减少函数调用时的开销,比较重要且常用,很容易理解,就比如C++的inline关键字一样,只不过inline是开发者的手动方法内联,而编译器在分析代码和数据流之后,也有可能做出自动inline的优化。

8.8 逃逸分析

一个对象如果被其声明的方法之外的一个或多个函数所引用,那就被称为逃逸,可以通俗理解为,该对象逃逸了其原本的命名空间或者作用域,使得声明(或者定义)该对象的方法结束时,该对象不能被销毁。

通常,一个函数里的局部变量其内存空间是在栈上分配的,而对象则是在堆上分配的内存空间,在函数调用结束时,局部变量会随着栈空间销毁而自动销毁,但堆上的空间要么是依赖类似JVM的垃圾内存自动回收机制(GC),要么就得像C/C++那样的依赖开发者本身的记忆力,因此,堆上的内存分配与销毁一般开销会比栈上的大得多。

逃逸分析的基本原理就是分析对象动态作用域。如果确定一个方法不会逃逸出方法之外,那让整个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧而销毁。在一般应用中,不会逃逸的局部对象所占用的比例很大,如果能在编译器优化时,为其在栈上分配内存空间,那大量的对象就会随着方法结束而自动销毁了,不用依赖前面讲的GC或者记忆力,系统的压力将会小很多。

九、代码生成

代码生成可以看作是编译的最后阶段。根据中间表示生成代码,代码生成器的三个任务:

  • 指令选择:选择适当的指令实现IR语句。代码生成器以中间表示作为输入,并将其转换(映射)为目标机器的指令集。一个表示可以有多种方法(指令)来转换它,因此代码生成器有责任明智地选择适当的指令。

  • 寄存器分配和指派:寄存器分配是决定哪些 IR 值将会保存在寄存器中的过程,寄存器指派是决定用哪个寄存器来存放一个给定的 IR 值的过程。目标机器的体系结构可能不允许将所有值保存在CPU内存或寄存器中。代码生成器决定在寄存器中保留哪些值。此外,它还决定要用来保存这些值的寄存器。

  • 指令排序:按照什么顺序安排指令执行,为执行指令创建时间表。

代码生成的输入:

中间表示形式,符号表IR的中间表示形式的选择有很多,四元式,三元式,间接三元式等三地址表示方式.也包括诸如字节代码和堆栈机代码的虚拟机表示方式.后缀表示的线性表示方式;语法树和DAG的图形表示方式;

代码生成目标:

RISC机通常有很多寄存器,三地址指令,简单的寻址方式和一个相对简单的指令集体系结构.CISC机通常有较少寄存器,两地址指令,多种寻址方式,多种类型的寄存器,可变长度指令,具有副作用的指令.输出一个使用绝对地址的机器语言程序的优点是程序可放在内存某个固定位置,立即执行.编译和执行快.输出可重定位机器语言程序可使各个程序能被分别编译.一组可重定位的目标模块可被一个链接加载器链接到一起并加载运行.可重定位需要为链接和加载付出代价.优势是获得灵活性.如目标机没有自动处理重定位,编译器就需向加载器提供明确的重定位信息.

代码生成示例:

十、参考文章

————————————————

https://blog.csdn.net/qq_42570601/article/details/110312075https://blog.csdn.net/jzyhywxz/article/details/78285722https://blog.csdn.net/qq_47888755/article/details/109148720https://blog.csdn.net/penny2002/article/details/115904031https://blog.csdn.net/u013749051/article/details/121043156https://blog.csdn.net/qq_39384184/article/details/86037568https://blog.csdn.net/qq_42570601/article/details/112442594https://blog.csdn.net/liixnhai/article/details/115458170https://zhuanlan.zhihu.com/p/150401437https://blog.csdn.net/weixin_44226857/article/details/104414873https://blog.csdn.net/weixin_42558631/article/details/81266004https://mortal.blog.csdn.net/article/details/86037568https://blog.csdn.net/liixnhai/article/details/115458170https://mortal.blog.csdn.net/article/details/86037568

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值