自制编译原理自始至终都是非常难学的知识,虽然网上能找到各种各样的教程及文档,但也极少有开发者深入研究。
这儿推荐一个基于.NET5的库,Facc,通过极简语法描述文法,自动生成AST代码。
GitHub:https://github.com/fawdlstty/Facc
Gitee:https://gitee.com/fawdlstty/Facc
下面我从0开始讲解编程语言原理,以及这个库的使用。
第一章:语言基础
编程语言,是一种人与计算机交互的方式。人想让计算机做什么,就通过编程语言的方式,与计算机达成共识。通常计算机无法直接识别一个语言,需要使用一种工具或程序,将编程语言转为二进制代码,计算机就能识别编程语言,从而开始执行。现存编程语言千千万,只要你能让你的语言转为计算机可识别的代码,那么等于你创造了一个编程语言。
第一节:代码和数据
代码或数据通常指代不同的东西,代码指的是让计算机执行的内容,数据指的是不可执行的内容。比如如下C#代码:
Console.WriteLine ("Hello World!");
此代码分为两部分,一部分是 "Hello World!"
这个字符串,这是一串数据,计算机无法执行;一部分是 Console.WriteLine ()
这是一句可执行语句,代表执行这个函数,同时将前面的字符串内容传递给此函数,让此函数去执行。
虽然绝大部分语言的代码和数据区分的明明白白,但依旧有部分语言模糊了两者的关系,比如:
- Lisp没有明显的区分代码和数据
- JavaScript的eval函数能够执行字符串js代码
- 等等……
为了简单,本文档只以主流编程语言来解读,也就是明显区分代码与数据。当所有内容全部悉知后,你也能创建出模糊代码与数据关系的编程语言。
第二节:编译代码
编译,顾名思义,就是将人类可读的代码,翻译为计算机可读的指令。
假如我们设计了一个语言,首先将语言解析为语法树,然后将语法树转为masm汇编代码,最后调用汇编器编译汇编代码,翻译为二进制程序。
这个过程有很多可以省略或者增加步骤,比如C++之父开发的CFront这个编译器能够编译C++代码,最开始的实现是将其编译为C代码,然后调用C编译器完成编译。
当然,以上方式都不太主流了,主流方式有两种,一种是自己写一个虚拟机,然后解释执行虚拟指令(或者jit、AOT编译),比如C#、Java;再或者生成LLVM IR,调用LLVM生成对应环境可执行代码(比如clang、Rust)。
语法树其实也能直接生成CPU可执行的汇编代码,但没必要,因为现在已经有了汇编器工具了,假如全部靠手工从0实现各种轮子,那么只能说工作不饱和。
比如前段时间在公众号上非常火的V语言,编译方式是翻译为C++,然后调用C++编译器编译,然后很多人喷这种编译方式。其实真没必要喷,C++之父都这么搞的呢,何必。
此处就按LLVM IR作为中间语言。编译代码最关键的步骤就是代码转语法树,以及语法树转LLVM IR。实现这两个功能,就能实现出一个完整的编译器了。
第三节:语言前端和语言后端
听起来很像一个写UI一个写实现。其实不是,语言前端指的是,语言代码翻译为AST树,然后将AST树转为中间语言(比如LLVM IR);语言后端是将中间语言翻译为对应平台的可执行指令,比如流行的指令集有:Intel x86、arm、mips、risc-v……
我们一般做的自制编程语言指的是完成语言前端。后端这个就是另一回事了,比如自制了一块CPU,自己设计了指令集,然后想让代码跑在这个CPU上,就需要自己完成编程语言后端了,将LLVM IR翻译为自制的指令集。在此之后,使用LLVM的编译器就能编译自制指令集可执行的代码了。
第二章:编程语言语法
由字符组成的代码
代码的组成,实际就是各种各样的字符组成。比如如下代码:
static void Main (string [] args) {
Console.WriteLine ("Hello World!");
}
首先是 static
这六个字符组成的关键字代表静态,然后是 void
这四个字符组成的关键字代表函数返回类型,后面还有可忽略的换行符、tab缩进符等。我们首先需要明确的概念就是,语言代码就是字符串由各种各样的字符组成。
各种语句的组合
上面的代码我们扩展一下:
static void Main (string [] args) {
Console.WriteLine ("Hello World!");
Console.WriteLine ("Hello World!");
Console.WriteLine ("Hello World!");
}
很简单的代码,是吧?我们再来分析一下,这段代码与上面那段代码的区别,可以明显发现,hello world打印了三遍。我们由此可以大胆给出一个猜测,一个函数里面可以有0行代码,也可以有N行代码(废话)。
我们现在来定义一下函数的结构,首先是函数头,我们定义名称为func_begin,然后是语句,我们定义名称为stmt,再然后是函数尾,我们定义名称为func_end
通过一种特殊的语法来描述这种现象:
func_stmt ::= func_begin stmt* func_end
简单解释一下,这段描述符的含义是,函数结构(func_stmt)由三个部分组成:函数头、函数体(由语句stmt组成,星号代表重复0次到无限次)、函数尾。
这个描述符就能完成匹配上面两段代码了,不管Main函数里面有几串表达式语句。
终结符和非终结符
简而言之,终结符就是代码中的字符或字符串;非终结符就是我们对一个结构的定义。
比如我们想要解析这一段语句:
1+2*3-4/5
这段语句由9个部分组成,刚好一个字符就是一个语句,其中又包含含义相同的元素,我们可以将其理解为
expr ::= num op num op num op num op num
简化一下:
expr ::= num (op num)+
op与num的组合在后面重复了4次,我们就折叠一下,并要求重复次数至少1次以上。
然后我们定义一下num与op:
num ::= [0-9]+
op ::= '+' | '-' | '*' | '/'
发现没,我们定义语言文法时, 由 ::=
符号左右的两个部分组成,左边是对非终结符的定义,右边可以是终结符或者非终结符的组合。
我们可以将一串代码理解为多叉树的根节点,非终结符为树的茎节点,终结符为树的叶子节点。我们定义语法就是规定,一个茎节点允许有哪些子节点类型,比如是否允许有叶子节点等等,当我么定义好之后,拿一串代码,从根节点开始匹配,一直匹配到所有叶子节点,此时这棵多叉树就是我们的AST。
第三章:Facc语法规范
基本语法
Facc 语法描述格式为:非终结符名称、::=
、表达式。
non_terminal ::= expr
非终结符名称可以取数字、大小写字母、下划线_,但不能以数字开头。然后是::=
,由于语言文法的有个老大哥级别的规范,叫EBNF表达式,能做的事情和Facc完全一致,此处沿用EBNF规范。当然,其他很多地方没有沿用规范的原因是,个人觉得这样写更简单,没必要沿用,于是顺手改了。
表达式“与”模式、“或”模式
接下来就是表达式了。表达式允许两种类型,“与”,以及“或”。“与”模式下要求表达式项同时存在。比如我们想定义一个取数组元素的表达式(比如:arr[10]
),需要变量名、方括号、索引同时存在。我们假设定义了id规范及num规范,那么表达式可以定义为:
array_access_expr ::= id '[' num ']'
“或”模式要求表达式中的项只允许存在一个。比如数字间的运算符允许是加减乘除等。表达式可以定义为:
op ::= '+' | '-' | '*' | '/'
终结符
终结符有两种定义方式,字符终结符或字符串终结符。
字符终结符允许匹配代码中的一个字符。比如需要匹配一个标识符:
id ::= [a-zA-Z_] [0-9a-zA-Z_]*
方括号内允许使用单个字符或者字符范围,比如匹配一个标识符的第一个字符,允许使用小写字母(a-z)、大写字母(A-Z)、下划线(_)。需注意,如果需匹配单个字符‘-’,为避免被当做字符范围解析,需放置方括号内的第一个位置。
重复次数
默认只允许重复1次,通过在末尾加入不同符号代表标识取值范围
-
?
0次~1次 -
*
0次~无数次 -
+
1次~无数次
组
通过在表达式中加入括号,组成一个组,可以通过这种方式,控制重复次数。比如要求两种类型符号交替出现可以写为:
expr ::= num (op num)+
注释
注释为对代码描述语句的备注,注释内容将被忽略,不参与解析。
两种注释方式。
“//”为行注释,注释此标识到行结束,不过与C++不同的地方在于不允许续行(C++通过行末“\”续行,能让下一行也变成注释)
“/**/”为块注释,能跨行使用。
Facc 的使用
首先,NuGet上安装Facc。
生成AST:
var _grammar = @" // 语法描述字符串
// 方括号代表匹配其中任一字符
num ::= [0-9]+
// 单引号或双引号代表匹配整个字符串,“|”代表“或”关系,匹配任一串字符串
op2_sign ::= '+' | '-' | '*' | '/'
// 空格连接代表“与”关系,所有元素必须同时存在
op0_expr ::= '(' expr ')'
// 匹配 1+2*3-4 这样的字符串
op2_expr ::= expr (op2_sign expr)+
// 表达式允许纯数字、括号或四则运算字符串
expr ::= num | op0_expr | op2_expr
";
string _path = "D:\\ASTs"; // AST解析文件生成路径
string _namespace = "Facc.Example.ASTs"; // 生成的AST解析文件的命名空间
var _generator = new AstGenerator (_grammar, _path, _namespace);
_generator.ClearPath (); // 清空指定路径下的所有文件
_generator.Generate (); // 生成AST解析文件
执行生成的AST代码,解析文法:
var _root = AstParser.Parse ("3+2*5-4");
_root.PrintTree (0);
解析为语法树之后,再对应生成LLVM IR对接LLVM,或者对应生成其他语言,再或者生成一种自定义字节码,自己写虚拟机执行,一个编程语言就完成啦。