简单了解LLVM IR基本语法
可以转载,请注明出处!
以下内容是参照官方文档,对一些代码样例做出的解释,想要彻底掌握IR的语法规则,还是需要仔细熟读IR的官方文档。这里只是对IR的入门介绍,等上道之后,建议再去看官方文档,因为工作原因,后面我可能会按照自己的结构划分对官网上的主要内容做一个介绍,搞一个专栏:
- LLVM IR官方文档地址:http://llvm.org/docs/LangRef.html#br-instruction
- 参考官方文档,我按照自己的理解外加翻译,对常用指令、类型、函数、变量等等做了一个总结。后面我会随着自己对llvm的理解加深,在这个专栏一直持续弥补改正、并更新新的内容。https://blog.csdn.net/qq_42570601/category_10200372.html
- 发现一个翻译不错的中文网站:https://llvm.liuxfe.com/
1.基本语法介绍
用vim编辑一个c程序代码:
#include <stdio.h>int main()
{int a = 10;int b = 11;return a + b;
}
将c的源码转为LLVM IR,代码如下:
; ModuleID = 'test3.c'
source_filename = "test3.c"
target datalayout = "e-m:e-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-unknown-linux-gnu"; Function Attrs: noinline nounwind optnone uwtable
define i32 @main() #0 {entry:%retval = alloca i32, align 4%a = alloca i32, align 4%b = alloca i32, align 4store i32 0, i32* %retval, align 4store i32 10, i32* %a, align 4store 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, %1ret i32 %add
}attributes #0 = { noinline nounwind optnone uwtable "correctly-rounded-divide-sqrt-fp-math"="false" "disable-tail-calls"="false" "less-precise-fpmad"="false" "no-frame-pointer-elim"="true" "no-frame-pointer-elim-non-leaf" "no-infs-fp-math"="false" "no-jump-tables"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+fxsr,+mmx,+sse,+sse2,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" }!llvm.module.flags = !{!0}
!llvm.ident = !{!1}!0 = !{i32 1, !"wchar_size", i32 4}
!1 = !{!"clang version 6.0.0 (tags/RELEASE_600/final)"}
根据官方文档的描述:
注解是以 ; 分隔且直到当前行的结尾,所以; Function Attrs: noinline nounwind optnone uwtable
这一行是注解;
@代表全局标识符(函数,全局变量);
%代表局部标识符(寄存器名称也就是局部变量,类型)。
所以在llvm IR看来,int main这个函数,或者说他的函数返回值是个全局变量,其内部的a 和b是局部变量。
define i32 @main() #0 {entry:
...
ret i32 %add
}
上面IR是定义一个函数main,函数的返回值类型是i32;#0是后面attributes 属性组中的属性,暂时先不用管;每个函数的定义都会包含一个基本块(BasicBlock),entry是基本块的开始,ret i32 %add是返回类型为i32,名称为 %add的变量中存放的函数的值,也就是基本块结束;
i32:32位的整数,对应c中的int类型,i后面跟几,这个整数就会占几位(bit),i32的话就是32位,4个字节;i后面的数字可以随意写,可以得知这其实是在设置整数位的长度。
%retval = alloca i32, align 4%a = alloca i32, align 4%b = alloca i32, align 4
alloca指令的官方解释大致意思是:用于分配内存堆栈给当前执行的函数,当这个函数返回其调用者时自动释放。这里就是给%retval变量分配一个4byte的内存,待变量不再使用后,将内存释放,有点c语言中声明一个变量的意思,也有点像c中malloc。
align 4:在官方文档中没找到align的解释,我对他的理解是“对齐方式”:若一个结构中含有两个int,一个char,则他应该占用4*3=12字节,虽然char本身只占用一个字节的空间,但由于要向4“对齐”所以即便是数据没有4个字节,也要为其分配4个字节。所以这里的对齐方式为4个字节。
store i32 0, i32* %retval, align 4store i32 10, i32* %a, align 4store i32 11, i32* %b, align 4
store指令官方的解释大致是,将数据写入到指定的内存中。所以这里很好理解,将i32类型的整数10存放到变量(寄存器名称)%a对应的内存中,对齐方式是4byte。
%0 = load i32, i32* %a, align 4%1 = load i32, i32* %b, align 4
load指令官方解释大致意思是,读取指定内存中的数据。这里的意思是读取变量%a对应的内存中的数据,将其存放到类型为i32的零时变量%0中,%0的对齐方式为4byte。load后面紧跟的类型是有限制的,必须为frist class type,这个后面熟悉了可以看官方文档。
%add = add nsw i32 %0, %1
add指令是一个二元运算符,返回它对应的两个操作数的和,操作数也是有要求的,必须为整数或整数值向量,且两个操作数的类型必须要相同。有一个fadd指令,也是求两个操作数的和,只不过对操作数的限制是必须为浮点数或者浮点值向量,且操作数类型相同。add加,sub减,mul乘,div除,rem取余,官网对这一块列举的很详细,也很全面,后面对代码熟悉了可以直接看文档。
2.if语句介绍
c程序代码:
#include <stdio.h>int main()
{int a = 10;if(a%2 == 0)return 0;else return 1;
}
LLVM IR代码:
define i32 @main() #0 {entry:%retval = alloca i32, align 4%a = alloca i32, align 4store i32 0, i32* %retval, align 4store i32 10, i32* %a, align 4%0 = load i32, i32* %a, align 4%rem = srem i32 %0, 2%cmp = icmp eq i32 %rem, 0br i1 %cmp, label %if.then, label %if.elseif.then: ; preds = %entrystore i32 0, i32* %retval, align 4br label %returnif.else: ; preds = %entrystore i32 1, i32* %retval, align 4br label %returnreturn: ; preds = %if.else, %if.then%1 = load i32, i32* %retval, align 4ret i32 %1
}
上面代码出现的新的指令主要有三个,icmp、br、label。srem不算新的指令,上一个例子中已经说过了,取余运算。
%cmp = icmp eq i32 %rem, 0
icmp指令,根据比较规则,比较两个操作数,将比较的结果以布尔值或者布尔值向量(vector of boolean values,暂且就这么叫吧,也不知对不对)返回,且对于操作数的限定是操作数为整数或整数值向量、指针或指针向量。在这里,eq是比较规则,%rem和0是操作数,i32是操作数类型,比较%rem与0的值是否相等,将比较的结果存放到%cmp中。
br i1 %cmp, label %if.then, label %if.else
br指令有两种形式,分别对应于条件分支和无条件分支。该指令的条件分支在形式上接受一个“i1”值和两个“label”值,用于将控制流传输到当前函数中的不同基本块,上面这条指令是条件分支,有点像c中的三目条件运算符< expression ?Statement :statement>;无条件分支的话就是不用判断,直接跳转到指定的分支,有点像c中goto,比如说这个就是无条件分支br label %return。上面指令的意思是,i1类型的变量%cmp的值如果为真,执行if.then,否则执行if.else。
官网对label的划分与解释是,label是类型系统(type system)中的第一类类型(first class type),所以label并不是一条指令,First class type应该是IR中最重要的类型了,所有的指令产生的值都是first class type值。我这里把label理解成一个代码标签,作为label %if.then这条分支的入口。
总结一下if条件语句:
- 求出if语句表达式的两个操作数的值(也有可能是一个eg:a > 0);
- icmp指令开始比较,会产生一个布尔结果值;
- br指令的条件分支利用上一步产生的值,跳转到相对应的分支入口;
- 分支执行完再用br指令的无条件分支跳到if的结束分支。
3.While语句介绍
c程序代码:
#include <stdio.h>int main()
{int a = 0, b = 1;while(a < 5){a++;b *= a;}return b;
}
LLVM IR代码:
define i32 @main() #0 {entry:%retval = alloca i32, align 4%a = alloca i32, align 4%b = alloca i32, align 4store i32 0, i32* %retval, align 4store i32 0, i32* %a, align 4store i32 1, i32* %b, align 4br label %while.condwhile.cond: ; preds = %while.body, %entry%0 = load i32, i32* %a, align 4%cmp = icmp slt i32 %0, 5br i1 %cmp, label %while.body, label %while.endwhile.body: ; preds = %while.cond%1 = load i32, i32* %a, align 4%inc = add nsw i32 %1, 1store i32 %inc, i32* %a, align 4%2 = load i32, i32* %a, align 4%3 = load i32, i32* %b, align 4%mul = mul nsw i32 %3, %2store i32 %mul, i32* %b, align 4br label %while.condwhile.end: ; preds = %while.cond%4 = load i32, i32* %b, align 4ret i32 %4
}
对比if语句可以发现,while中几乎没有新的指令出现,所以说所谓的while循环,也就是“跳转+分支”这一结构。
While的运行流程是:首先跳到while.cond: 相关变量得到初始值后判断是否满足继续循环条件,若满足,就转到while.body: 进行循环实际操作,一次实际操作运行完后再次跳到while.cond:进行条件判断,如此循环~;若否,则直接跳到 while.end: 终止循环;
4.switch语句
对应C的伪代码:
int main (){char grade = 'B';int score;switch(grade){case 'A' :score = 4;break;case 'B' :score = 3;break;case 'C' :score = 2;break;case 'D' :score = 1;break;default :score = 0;}printf("your score: %d\n", score );return 0;
}
IR代码:
@.str = private unnamed_addr constant [16 x i8] c"your score: %d\0A\00", align 1define i32 @main(){entry:%grade = alloca i8, align 1%score = alloca i32, align 4store i8 66, i8* %grade, align 1%0 = load i8, i8* %grade, align 1%conv = sext i8 %0 to i32switch i32 %conv, label %sw.default [i32 65, label %sw.ai32 66, label %sw.bi32 67, label %sw.ci32 68, label %sw.d]sw.a: store i32 4, i32* %score, align 4br label %sw.endsw.b: store i32 3, i32* %score, align 4br label %sw.endsw.c: store i32 2, i32* %score, align 4br label %sw.endsw.d:store i32 1, i32* %score, align 4br label %sw.endsw.default: store i32 0, i32* %score, align 4br label %sw.endsw.end:%1 = load i32, i32* %score, align 4%call = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([16 x i8], [16 x i8]* @.str, i32 0, i32 0), i32 %1)ret i32 0
}declare i32 @printf(i8*, ...)
这里新出来了switch 指令,switch指令是一个终端指令,同br、ret一样,用在一个基本快的结束。也没啥难度,就是选一个与%conv值相等的基本快标签跳进去。
5.对指针的操作
对应C的伪代码:
int main(){int i = 10;int* pi = &i;printf("i的值为:%d",i);printf("*pi的值为:%d",*pi);printf("&i的地址值为:",%d);printf("pi的地址值为:",%d);
}
IR代码:
@.str = private unnamed_addr constant [16 x i8] c"i\E7\9A\84\E5\80\BC\E4\B8\BA\EF\BC\9A%d\00", align 1
@.str.1 = private unnamed_addr constant [18 x i8] c"*pi\E7\9A\84\E5\80\BC\E4\B8\BA\EF\BC\9A%d\00", align 1
@.str.2 = private unnamed_addr constant [23 x i8] c"&i\E7\9A\84\E5\9C\B0\E5\9D\80\E5\80\BC\E4\B8\BA\EF\BC\9A%p\00", align 1
@.str.3 = private unnamed_addr constant [23 x i8] c"pi\E7\9A\84\E5\9C\B0\E5\9D\80\E5\80\BC\E4\B8\BA\EF\BC\9A%p\00", align 1define i32 @main(){entry:%i = alloca i32, align 4%pi = alloca i32*, align 8store i32 10, i32* %i, align 4store i32* %i, i32** %pi, align 8%0 = load i32, i32* %i, align 4%call = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([16 x i8], [16 x i8]* @.str, i32 0, i32 0), i32 %0)%1 = load i32, i32* %i, align 4%call1 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([18 x i8], [18 x i8]* @.str.1, i32 0, i32 0), i32 %1)%call2 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([23 x i8], [23 x i8]* @.str.2, i32 0, i32 0), i32* %i)%2 = load i32*, i32** %pi, align 8%call3 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([23 x i8], [23 x i8]* @.str.3, i32 0, i32 0), i32* %2)ret i32 0
}declare i32 @printf(i8*, ...)
对指针的操作就是指针的指针,开辟一块指针类型的内存,里面放个指针%pi = alloca i32*, align 8
,如果你是从头看下来的,相信这里不会有啥问题。需要注意的是alloca
指令产生的是一个指针,所以才有上一句所说的指针的指针。这些内容属于C的知识,如果不理解的话百度一下,很简单的。
6.对数组的操作
对应C的伪代码:
int main(){char str[30];char c;int i;for(c=65,i=0; c<=90; c++,i++){str[i] = c;}printf("%s\n", str);return 0;
}
IR代码:
@.str = private unnamed_addr constant [4 x i8] c"%s\0A\00", align 1define i32 @main() {entry:%str = alloca [30 x i8], align 16%c = alloca i8, align 1%i = alloca i32, align 4store i8 65, i8* %c, align 1store i32 0, i32* %i, align 4br label %for.condfor.cond:%0 = load i8, i8* %c, align 1%sext = sext i8 %0 to i32%cmp = icmp sle i32 %sext, 90br i1 %cmp, label %for.body, label %for.endfor.body:%1 = load i8, i8* %c, align 1%2 = load i32, i32* %i, align 4%array = getelementptr inbounds [30 x i8], [30 x i8]* %str, i32 0, i32 %2store i8 %1, i8* %array, align 1%add = add i8 %1, 1store i8 %add, i8* %c, align 1%3 = load i32, i32* %i, align 4%add2 = add nsw i32 %3, 1store i32 %add2, i32* %i, align 4br label %for.condfor.end:%arraydecay = getelementptr inbounds [30 x i8], [30 x i8]* %str, i32 0, i32 0%call = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([4 x i8], [4 x i8]* @.str, i32 0, i32 0), i8* %arraydecay)ret i32 0
}declare i32 @printf(i8*, ...)
数组新增了两个不同的地方:%str = alloca [30 x i8], align 16
和%arraydecay = getelementptr inbounds [30 x i8], [30 x i8]* %str, i32 0, i32 0
。
第一条分配内存的指令alloca [30 x i8]
同first class类型一样理解,就是分配了30个i8类型的连续空间。
第二条是一个很重要的指令,首次看可能不是特别好理解,直接看指令介绍,的“内存访问和寻址”的“getelementptr指令”介绍。
7.对结构体的操作
对应C的伪代码:
struct Grade{int number;
}struct Stu{char *name;int age;char group;float score;struct Grade grade1;
};int main(){struct Stu stu1;stu1.name = "Tom";stu1.age = 18;stu1.group = 'A';stu1.score = 136.5;stu1.grade1.number = 4;printf("%s,%d,%c,%.1f, %d\n", stu1.name,stu1.age, stu1.group, stu1.score, stu1.grade1.number);return 0;
}
IR代码:
%struct.grade = type{ i32 }
%struct.Stu = type { i8*, i32, i8, float, %struct.grade}@str.name = private unnamed_addr constant [4 x i8] c"Tom\00", align 1
@str.print = private unnamed_addr constant [25 x i8] c"%s\EF\BC\8C%d\EF\BC\8C%c\EF\BC\8C%.1f, %d\0A\00", align 1define i32 @main(){entry:%stu1 = alloca %struct.Stu, align 8%name = getelementptr inbounds %struct.Stu, %struct.Stu* %stu1, i32 0, i32 0store i8* getelementptr inbounds ([4 x i8], [4 x i8]* @str.name, i32 0, i32 0), i8** %name, align 8%age = getelementptr inbounds %struct.Stu, %struct.Stu* %stu1, i32 0, i32 1store i32 18, i32* %age, align 8%group = getelementptr inbounds %struct.Stu, %struct.Stu* %stu1, i32 0, i32 2store i8 65, i8* %group, align 4%score = getelementptr inbounds %struct.Stu, %struct.Stu* %stu1, i32 0, i32 3store float 136.5, float* %score, align 8%grade = getelementptr inbounds %struct.Stu, %struct.Stu* %stu1, i32 0, i32 4, i32 0store i32 4, i32* %grade, align 4%0 = load i8*, i8** %name, align 8%1 = load i32, i32* %age, align 8%2 = load i8, i8* %group, align 4%sext = sext i8 %2 to i32%3 = load float, float* %score, align 8%4 = load i32, i32* %grade, align 4%fpext = fpext float %3 to double%call = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([25 x i8], [25 x i8]* @str.print, i32 0, i32 0), i8* %0, i32 %1, i32 %sext, double %fpext, i32 %4)ret i32 0
}declare i32 @printf(i8*, ...)
IR中对于结构体的使用同C中很相似,都是先声明一个类型,然后在使用这个类型利用alloca指令开辟内存。第1节基本语法介绍中也说过,%代表局部标识符(寄存器名称也就是局部变量,类型),其中类型就是这里的结构体。
%struct.grade = type{ i32 }
%struct.Stu = type { i8*, i32, i8, float, %struct.grade}
结构体这一块需要注意的是这一条指令,总共有三个索引,%grade = getelementptr inbounds %struct.Stu, %struct.Stu* %stu1, i32 0, i32 4, i32 0
,还是直接看指令介绍,的“内存访问和寻址”的“getelementptr指令”介绍,里面都有。
8.引用内置函数
对应C的伪代码:
int main (){printf("值 8.0 ^ 3 = %lf\n", pow(8.0, 3));printf("值 3.05 ^ 1.98 = %lf", llvm.pow.f32(3.05, 1.98));return 0;
}
IR代码:
@.str = private unnamed_addr constant [19 x i8] c"\E5\80\BC 8.0 ^ 3 = %lf\0A\00", align 1
@.str.1 = private unnamed_addr constant [22 x i8] c"\E5\80\BC 3.05 ^ 1.98 = %lf\00", align 1declare i32 @printf(i8*, ...)
declare double @pow(double, double)
declare double @llvm.pow.f32(double, double)define i32 @main(){entry:%pow1 = call double @pow(double 8.0, double 3.0)call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([19 x i8], [19 x i8]* @.str, i32 0, i32 0), double %pow1)%pow2 = call double @llvm.pow.f32(double 3.05, double 1.98)call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([22 x i8], [22 x i8]* @.str.1, i32 0, i32 0), double %pow2)ret i32 0
}
9.引用外部函数
对应C的伪代码:
int func(int a) {a = a*2;return a;
}----------------------------------------#include<stdio.h>extern int func(int a);int main() {int num = 5;num = func(num);printf("number is %d\n", num);return num;
}
IR代码:
define i32 @func(i32 %a){entry:%a.addr = alloca i32, align 4store i32 %a, i32* %a.addr, align 4%0 = load i32, i32* %a.addr, align 4%mul = mul nsw i32 %0, 2store i32 %mul, i32* %a.addr, align 4%1 = load i32, i32* %a.addr, align 4ret i32 %1
}-----------------------------------------------------@.str = private unnamed_addr constant [14 x i8] c"number is %d\0A\00", align 1; Function Attrs: noinline nounwind optnone uwtable
define i32 @main() #0 {entry:%retval = alloca i32, align 4%num = alloca i32, align 4store i32 0, i32* %retval, align 4store i32 5, i32* %num, align 4%0 = load i32, i32* %num, align 4%call = call i32 @func(i32 %0)store i32 %call, i32* %num, align 4%1 = load i32, i32* %num, align 4%call1 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([14 x i8], [14 x i8]* @.str, i32 0, i32 0), i32 %1)%2 = load i32, i32* %num, align 4ret i32 %2
}declare i32 @func(i32)declare i32 @printf(i8*, ...)
链接后的IR:
@.str = private unnamed_addr constant [14 x i8] c"number is %d\0A\00", align 1define i32 @func(i32 %a){entry:%a.addr = alloca i32, align 4store i32 %a, i32* %a.addr, align 4%0 = load i32, i32* %a.addr, align 4%mul = mul nsw i32 %0, 2store i32 %mul, i32* %a.addr, align 4%1 = load i32, i32* %a.addr, align 4ret i32 %1
}define i32 @main(){entry:%num = alloca i32, align 4store i32 5, i32* %num, align 4%0 = load i32, i32* %num, align 4%call = call i32 @func(i32 %0)store i32 %call, i32* %num, align 4%1 = load i32, i32* %num, align 4%call1 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([14 x i8], [14 x i8]* @.str, i32 0, i32 0), i32 %1)%2 = load i32, i32* %num, align 4ret i32 %2
}declare i32 @printf(i8*, ...)
简单了解LLVM IR基本语法相关推荐
- LLVM IR入门指南(4)——类型系统
我们知道,汇编语言是弱类型的,我们操作汇编语言的时候,实际上考虑的是一些二进制串.但是,LLVM IR却是强类型的,在LLVM IR中所有变量都必须有类型.这是因为,我们在使用高级语言编程的时候,往往 ...
- LLVM IR 语法
译者序 目前几乎没有关于LLVM IR(中间语言)的中文资料,因此本人在看英文手册的同时尝试翻译.限于水平和时间,本文只翻译了一小部分英文手册,如果发现理解有冲突之处,请以原文为准. ...
- 手写token解析器、语法解析器、LLVM IR生成器(GO语言)
最近开始尝试用go写点东西,正好在看LLVM的资料,就写了点相关的内容 - 前端解析器+中间代码生成(本地代码的汇编.执行则靠LLVM工具链完成) https://github.com/daibinh ...
- LLVM一些编程语法语义特性
LLVM一些编程语法语义特性 High Level Structure Module Structure LLVM 程序由Module's组成,每个 's 是输入程序的一个翻译单元.每个模块由函数.全 ...
- LLVM IR 理解
LLVM IR 理解 LLVM IR表示 LLVM IR有三个不同的形式: 内存中编译中间语言(IR) 保存在硬盘上的 bitcode(.bc 文件,适合快速被一个 JIT 编译器加载) 一个可读性的 ...
- LLVM极简教程: 第三章 LLVM IR代码生成
第三章 LLVM IR代码生成 原文: Code generation to LLVM IR 本章简介 欢迎进入"用LLVM开发新语言"教程的第三章.本章将介绍如何把第二章中构造的 ...
- LLVM IR格式的基本介绍
LLVM IR以两种格式存储在磁盘上: 1.位码(.bc文件) 2.汇编文本(.ll文件) 以sum.c源代码为例 int sum(int a, int b){return a+b; } 使用Clan ...
- 修改的LLVM IR基本指令
SysY2022语言定义中不包含无符号整数.结构体.移位操作,整数和浮点数均为32位,比赛测试样例不包含错误.鉴于SysY2022语言的特点,为了IR的简洁,对LLVM IR进行筛选和修改得到如下指令 ...
- LLVM框架/LLVM编译流程/Clang前端/LLVM IR/LLVM应用与实践-李明杰-专题视频课程
LLVM框架/LLVM编译流程/Clang前端/LLVM IR/LLVM应用与实践-3人已学习 课程介绍 LLVM并非仅仅是一款编译器这么简单.利用LLVM,我们可以进行各种疯狂的操作 ...
最新文章
- 天命剑之天命的含义--天行有悖,乃命羲和。
- redis界面管理工具phpRedisAdmin 安装
- MVC 学习日志1(上)
- lower_bound upper_bound
- 如何设置Matlab输出到Word中图片的大小
- 雷林鹏分享:C# 匿名方法
- PyTorch 1.0 中文文档:多进程包 - torch.multiprocessing
- 不采用服务器虚拟化的优缺点,为什么要进行虚拟化部署?虚拟化的缺点是什么?...
- 别点进来! Linux 与 Mac 下有趣但毫无用处的命令(转载)
- IDEA常用快捷键总结(附导入其他IDE快捷键)
- 中兴b860修改mac_【原创】猫盘群晖超级简单修改【SN MAC】 工具
- Hadoop生态圈:19个让大象飞起来的工具!
- MYSQL数据库导出和备份----mysqldump
- webpack配置完全指南
- yy神曲url解析php_歪歪神曲解析源码(参考)
- 全球KYC服务商ADVANCE.AI顺利加入深跨协 推动跨境电商行业有序发展
- Redis跳跃表(SkipList)
- 谈谈我的单片机编程思路
- 大规模中文文本处理中的自动切词和标注技术
- 基于LabVIEW编程的气象监测系统