用自己的语言编写编译器
Posted
技术标签:
【中文标题】用自己的语言编写编译器【英文标题】:Writing a compiler in its own language 【发布时间】:2010-09-16 15:39:24 【问题描述】:直观地说,Foo
语言的编译器本身似乎不能用 Foo 编写。更具体地说,语言Foo
的第一个 编译器不能用Foo 编写,但任何后续编译器都可以为Foo
编写。
但这真的是真的吗?我对阅读第一个编译器是用“自身”编写的语言有一些非常模糊的回忆。这可能吗?如果可以,怎么办?
【问题讨论】:
Bootstrapping still requires outside support的可能重复 这是一个非常古老的问题,但假设我为 Java 语言 Foo 编写了一个解释器。然后使用语言 foo,我编写了它自己的解释器。 Foo 仍然需要 JRE 对吗? 您可以在Foo
本身中编写第一个Foo
编译器。您的源代码将是一个带有Foo
指令的Foo
程序,用于在给定Foo
源代码输入的情况下如何生成机器代码(或者,用更现代的术语,一些其他后端代码)。现在,您需要能够充分理解Foo
规范的或某人 来手动追踪该程序的正确输出,并自行运行。然而,据我所知,出于显而易见的原因,我所描述的内容实际上从未用任何语言完成。
【参考方案1】:
这称为“引导”。您必须首先用其他语言(通常是 Java 或 C)为您的语言构建编译器(或解释器)。完成后,您可以使用 Foo 语言编写新版本的编译器。您使用第一个引导编译器来编译编译器,然后使用这个编译后的编译器来编译其他所有内容(包括它自己的未来版本)。
确实,大多数语言都是以这种方式创建的,部分原因是语言设计者喜欢使用他们正在创建的语言,还因为非平凡的编译器通常可以作为衡量语言“完整”程度的有用基准。
Scala 就是一个例子。它的第一个编译器是用 Martin Odersky 的实验语言 Pizza 创建的。从 2.0 版开始,编译器完全用 Scala 重写。从那时起,旧的 Pizza 编译器可能会被完全抛弃,因为新的 Scala 编译器可以用于为未来的迭代进行编译。
【讨论】:
也许是一个愚蠢的问题:如果你想将你的编译器移植到另一个微处理器架构,那么引导应该从该架构的工作编译器重新启动。这是正确的吗?如果这是正确的,这意味着最好保留第一个编译器,因为它可以将您的编译器移植到其他架构(尤其是如果用某种“通用语言”如 C 编写)? @piertoni 通常将编译器后端重新定位到新的微处理器会更容易。 以LLVM作为后端为例【参考方案2】:也许你可以写一个 BNF 来描述 BNF。
【讨论】:
你确实可以(也没有那么难),但它唯一的实际应用是在解析器生成器中。 确实,我正是使用这种方法来生成 LIME 解析器生成器。元语法的受限、简化、表格表示通过一个简单的递归下降解析器。然后,LIME 为语法语言生成一个解析器,然后它使用该解析器来读取某人实际上有兴趣为其生成解析器的语法。这意味着我不必知道如何写我刚刚写的东西。感觉就像魔术一样。 实际上你不能,因为 BNF 无法描述自己。您需要一个变体,例如 yacc 中使用的变体,其中不引用非终端符号。 不能用bnf来定义bnf,因为不能识别。 EBNF 通过引用语言的常量字符串标记来解决这个问题。【参考方案3】:你不能自己编写编译器,因为你没有任何东西可以用来编译你的起始源代码。有两种方法可以解决这个问题。
最不受欢迎的是以下。您在汇编程序(yuck)中为最小的语言集编写了一个最小编译器,然后使用该编译器来实现该语言的额外功能。建立自己的方式,直到您拥有一个具有所有语言功能的编译器。一个痛苦的过程,通常只有在别无选择的情况下才会完成。
首选方法是使用交叉编译器。您更改另一台机器上现有编译器的后端,以创建在目标机器上运行的输出。然后你有一个很好的完整编译器并在目标机器上工作。最流行的是 C 语言,因为有很多现有的编译器具有可替换的可插拔后端。
一个鲜为人知的事实是 GNU C++ 编译器有一个只使用 C 子集的实现。原因是通常很容易为新目标机器找到 C 编译器,然后您可以从中构建完整的 GNU C++ 编译器。您现在已经在目标机器上安装了 C++ 编译器。
【讨论】:
好吧,从技术上讲,您可以手动编译您的起始源代码。您对 C 的理解是否足够好,能够阅读一些 C 源代码并手动完成它并确定它的输出是什么?用foo
编写的foo
编译器只是另一个foo
程序,在这种情况下,它的输出是机器代码或其他后端代码。从理论上讲,您可以开始在 foo
本身中编写您的第一个 foo
编译器,如果您有足够的信心可以从规范中正确推断出输出应该是什么,并有耐心手动跟踪它。跨度>
【参考方案4】:
这是一个转储(实际上很难搜索的主题):
Smalltalk
C
这也是PyPy和Rubinius的思路:
(我认为这可能也适用于Forth,但我对 Forth 一无所知。)
【讨论】:
一篇据称与 Smalltalk 相关的文章的第一个链接当前指向一个没有明显有用和即时信息的页面。【参考方案5】:为之前的答案增加好奇心。
这是来自Linux From Scratch 手册的引述,在从源代码开始构建 GCC 编译器的步骤中。 (Linux From Scratch 是一种安装 Linux 的方法,它与安装发行版完全不同,因为您必须编译目标系统的真正每个单个二进制文件。)
make bootstrap
'bootstrap' 目标不只是编译 GCC,而是多次编译。它使用第一次编译的程序 轮到自己编译第二次,然后再次编译第三次。然后比较这些第二个和第三个 编译以确保它可以完美地复制自己。这也意味着它被正确编译。
“bootstrap”目标的使用是因为用于构建目标系统工具链的编译器可能与目标编译器的版本不同。这样做肯定会在目标系统中获得一个可以自己编译的编译器。
【讨论】:
“你必须编译目标系统的每一个二进制文件”,但你必须从你从某个地方得到的 gcc 二进制文件开始,因为源不能自己编译。我想知道你是否追溯了用于重新编译每个连续 gcc 的每个 gcc 二进制文件的谱系,你会一直回到 K&R 最初的 C 编译器吗? @robru 我不了解 K&R 的流程,我敢肯定它不是这种情况,但理论上,源代码可以从一开始就自行编译开始。只要你有东西或人可以正确执行程序并推断输出应该是什么并写下来,你就可以获得一个可执行的二进制文件。只是:当你有像 Dennis Ritchie 这样在汇编代码方面非常有天赋的人可以在汇编中手动编码然后从那里引导时,为什么还要费心去做呢?【参考方案6】:GNU Ada 编译器 GNAT 需要完全构建 Ada 编译器。将其移植到没有现成可用的 GNAT 二进制文件的平台时,这可能会很痛苦。
【讨论】:
我不明白为什么?没有规定您必须多次引导(例如每个新平台),您也可以与当前平台交叉编译。【参考方案7】:我记得听过Software Engineering Radio podcast,其中 Dick Gabriel 谈到了通过在 在纸上 用 LISP 编写准系统版本并将其手工组装成机器代码来引导原始 LISP 解释器。从那时起,其余的 LISP 功能都是用 LISP 编写和解释的。
【讨论】:
一切都是从一个有很多人手的创世纪晶体管引导而来的【参考方案8】:一般来说,您需要首先对编译器进行工作(如果是原始的)剪辑 - 然后您可以开始考虑使其自托管。这实际上在某些语言中被认为是一个重要的里程碑。
根据我对“mono”的记忆,他们可能需要在反射中添加一些东西才能使其正常工作:mono 团队一直指出,Reflection.Emit
根本无法实现某些事情;当然,MS 团队可能会证明他们错了。
这有几个真正的优点:对于初学者来说,这是一个相当不错的单元测试!而且您只需要担心一种语言(即 C# 专家可能不太了解 C++;但现在您可以修复 C# 编译器)。但我想知道这里的工作是否缺乏职业自豪感:他们只是希望它是自托管的。
不完全是一个编译器,但我最近一直在开发一个自托管的系统;代码生成器用于生成代码生成器......所以如果架构发生变化,我只需在其自身上运行它:新版本。如果有错误,我只是回到早期版本并重试。很方便,也很容易维护。
更新 1
我刚刚在 PDC 观看了 Anders 的 this video,并且(大约一个小时后)他确实给出了一些更有效的理由 - 所有关于编译器即服务的内容。仅作记录。
【讨论】:
【参考方案9】:Mono项目的C#编译器已经“自托管”了很长时间了,也就是说它是用C#自己写的。
我知道编译器是作为纯 C 代码启动的,但是一旦实现了 ECMA 的“基本”功能,他们就开始用 C# 重写编译器。
我不知道用同一种语言编写编译器的好处,但我确信它至少与语言本身可以提供的特性有关(例如,C 不支持对象面向编程)。
您可以找到更多信息here。
【讨论】:
【参考方案10】:实际上,出于上述原因,大多数编译器都是用它们编译的语言编写的。
第一个引导编译器通常用 C、C++ 或汇编语言编写。
【讨论】:
【参考方案11】:当您为 C 编写第一个编译器时,您是用其他语言编写的。现在,你有一个用于 C 的编译器,比如汇编程序。最终,您将来到必须解析字符串的地方,特别是转义序列。您将编写代码将\n
转换为十进制代码为 10 的字符(以及将\r
转换为 13,等等)。
编译器准备好后,您将开始用 C 重新实现它。这个过程称为“bootstrapping”。
字符串解析代码会变成:
...
if (c == 92) // backslash
c = getc();
if (c == 110) // n
return 10;
else if (c == 92) // another backslash
return 92;
else
...
...
当它编译时,你有一个可以理解'\n'的二进制文件。这意味着您可以更改源代码:
...
if (c == '\\')
c = getc();
if (c == 'n')
return '\n';
else if (c == '\\')
return '\\';
else
...
...
那么 '\n' 是 13 的代码的信息在哪里?它在二进制文件中!就像 DNA:用这个二进制文件编译 C 源代码会继承这个信息。如果编译器自己编译,它会将这些知识传递给它的后代。从现在开始,无法仅从源代码中看出编译器会做什么。
如果你想在某个程序的源代码中隐藏病毒,你可以这样做:获取编译器的源代码,找到编译函数的函数并将其替换为这个:
void compileFunction(char * name, char * filename, char * code)
if (strcmp("compileFunction", name) == 0 && strcmp("compile.c", filename) == 0)
code = A;
else if (strcmp("xxx", name) == 0 && strcmp("yyy.c", filename) == 0)
code = B;
... code to compile the function body from the string in "code" ...
有趣的部分是 A 和 B。A 是 compileFunction
的源代码,包括病毒,可能以某种方式加密,因此搜索生成的二进制文件并不明显。这样可以确保自己编译到编译器会保留病毒注入代码。
对于我们想要用我们的病毒替换的功能,B 是相同的。例如,它可能是来自 Linux 内核的源文件“login.c”中的函数“login”。我们可以将其替换为除普通密码外还接受 root 帐户密码“joshua”的版本。
如果你编译它并作为二进制文件传播,就无法通过查看源代码找到病毒。
创意原文出处:https://web.archive.org/web/20070714062657/http://www.acm.org/classics/sep95/
【讨论】:
写病毒感染编译器的后半部分有什么意义? :) @mhvelplund 只是传播引导如何杀死你的知识。【参考方案12】:我自己编写了 SLIC(用于实现编译器的语言系统)。然后手工编译成程序集。 SLIC 有很多功能,因为它是五种子语言的单一编译器:
SYNTAX 解析器编程语言 PPL GENERATOR LISP 2 基于树爬行伪代码生成语言 ISO In Sequence、PSEUDO 代码、优化语言 PSEUDO 宏类似于汇编代码生成语言。 MACHOP 汇编机器指令定义语言。SLIC 的灵感来自 CWIC(用于编写和实现编译器的编译器)。与大多数编译器开发包不同,SLIC 和 CWIC 使用专门的、特定于领域的语言来解决代码生成问题。 SLIC 扩展了 CWIC 代码生成,添加了 ISO、PSEUDO 和 MACHOP 子语言,将目标机器的细节从树爬行生成器语言中分离出来。
LISP 2 树和列表
基于 LISP 2 的生成器语言的动态内存管理系统是一个关键组件。列表用方括号括起来的语言表示,其组成部分用逗号分隔,即三元素 [a,b,c] 列表。
树木:
ADD
/ \
MPY 3
/ \
5 x
由第一个条目是节点对象的列表表示:
[ADD,[MPY,5,x],3]
树通常以节点分开的方式显示在分支之前:
ADD[MPY[5,x],3]
使用基于 LISP 2 的生成器函数进行解析
生成器函数是一组命名的 (unparse)=>action> 对 ...
<NAME>(<unparse>)=><action>;
(<unparse>)=><action>;
...
(<unparse>)=><action>;
Unparse 表达式是匹配树模式和/或对象类型的测试,将它们分开并将这些部分分配给局部变量以通过其程序操作进行处理。有点像采用不同参数类型的重载函数。除了 ()=> ... 测试按编码顺序进行尝试。第一个成功的 unparse 执行其相应的操作。 unparse 表达式是反汇编测试。 ADD[x,y] 匹配两个分支的 ADD 树,将其分支分配给局部变量 x 和 y。动作可能是一个简单的表达式或一个 .BEGIN ... .END 有界代码块。我今天会使用 c style ... 块。树匹配、[]、未解析规则可能会调用生成器,将返回的结果传递给操作:
expr_gen(ADD[expr_gen(x),expr_gen(y)])=> x+y;
具体来说,上面的 expr_gen unparse 匹配一个有两个分支的 ADD 树。在测试模式中,将使用该分支调用放置在树分支中的单个参数生成器。它的参数列表虽然是分配返回对象的局部变量。上面的unparse指定了两个分支是ADD树反汇编,递归压每个分支到expr_gen。左分支返回放入局部变量 x。同样,右分支通过返回对象 y 传递给 expr_gen。以上可能是数字表达式求值器的一部分。上面有称为向量的快捷功能,而不是节点字符串,节点向量可以与相应动作的向量一起使用:
expr_gen(#node[expr_gen(x),expr_gen(y)])=> #action;
node: ADD, SUB, MPY, DIV;
action: x+y, x-y, x*y, x/y;
(NUMBER(x))=> x;
(SYMBOL(x))=> val:(x);
上述更完整的表达式求值器将 expr_gen 左分支的返回值分配给 x,将右分支的返回值分配给 y。返回在 x 和 y 上执行的相应动作向量。最后一个 unparse=>action 对匹配数字和符号对象。
符号和符号属性
符号可能具有命名属性。 val:(x) 访问 x 中包含的符号对象的 val 属性。广义符号表堆栈是 SLIC 的一部分。 SYMBOL 表可以被推送和弹出,为函数提供本地符号。新创建的符号在顶部符号表中进行编目。符号查找首先从顶部表向后向下搜索符号表堆栈。
生成机器无关代码
SLIC 的生成器语言生成 PSEUDO 指令对象,并将它们附加到节代码列表中。 .FLUSH 导致其 PSEUDO 代码列表运行,从列表中删除每个 PSEUDO 指令并调用它。执行后,一个 PSEUDO 对象的内存被释放。 PSEUDO 和 GENERATOR 动作的程序主体除了输出之外基本上是相同的语言。 PSEUDO 旨在充当提供与机器无关的代码序列化的汇编宏。它们提供了从树爬行生成器语言中分离特定目标机器的方法。 PSEUDO 调用 MACHOP 函数来输出机器代码。 MACHOP 用于定义汇编伪操作(如 dc、定义常量等)和机器指令或使用向量入口的类似格式指令系列。它们只是将参数转换为组成指令的位域序列。 MACHOP 调用旨在看起来像汇编,并在汇编显示在编译列表中时提供字段的打印格式。在示例代码中,我使用了可以轻松添加但不是原始语言的 c 样式注释。 MACHOP 正在将代码生成到可寻址的内存中。 SLIC 链接器处理编译器的输出。使用向量入口的 DEC-10 用户模式指令的 MACHOP:
.MACHOP #opnm register,@indirect offset (index): // Instruction's parameters.
.MORG 36, O(18): $/36; // Align to 36 bit boundary print format: 18 bit octal $/36
O(9): #opcd; // Op code 9 bit octal print out
(4): register; // 4 bit register field appended print
(1): indirect; // 1 bit appended print
(4): index; // 4 bit index register appended print
O(18): if (#opcd&&3==1) offset // immediate mode use value else
else offset/36; // memory address divide by 36
// to get word address.
// Vectored entry opcode table:
#opnm := MOVE, MOVEI, MOVEM, MOVES, MOVS, MOVSI, MOVSM, MOVSS,
MOVN, MOVNI, MOVNM, MOVNS, MOVM, MOVMI, MOVMM, MOVMS,
IMUL, IMULI, IMULM, IMULB, MUL, MULI, MULM, MULB,
...
TDO, TSO, TDOE, TSOE, TDOA, TSOA, TDON, TSON;
// corresponding opcode value:
#opcd := 0O200, 0O201, 0O202, 0O203, 0O204, 0O205, 0O206, 0O207,
0O210, 0O211, 0O212, 0O213, 0O214, 0O215, 0O216, 0O217,
0O220, 0O221, 0O222, 0O223, 0O224, 0O225, 0O226, 0O227,
...
0O670, 0O671, 0O672, 0O673, 0O674, 0O675, 0O676, 0O677;
.MORG 36, O(18): $/36;将位置与 36 位边界对齐,以八进制打印 18 位的位置 $/36 字地址。将 9 位 opcd、4 位寄存器、间接位和 4 位索引寄存器组合起来打印,就像单个 18 位字段一样。 18 位地址/36 或立即值以八进制输出和打印。使用 r1 = 1 和 r2=2 打印出 MOVEI 示例:
400020 201082 000005 MOVEI r1,5(r2)
使用编译器汇编选项,您可以在编译列表中获得生成的汇编代码。
将其链接在一起
SLIC 链接器作为处理链接和符号解析的库提供。尽管必须为目标机器编写目标特定的输出加载文件格式并与链接器库链接。
生成器语言能够将树写入文件并读取它们,从而实现多通道编译器。
代码生成和起源的简短总结
我首先检查了代码生成,以确保 SLIC 是真正的编译器编译器。 SLIC 的灵感来自于 1960 年代后期由 Systems Development Corporation 开发的 CWIC(编写和实现编译器的编译器)。 CWIC 只有 SYNTAX 和 GENERATOR 语言从 GENERATOR 语言中产生数字字节码。字节代码被放置或植入(CWIC 文档中使用的术语)到与命名部分相关的内存缓冲区中,并由 .FLUSH 语句写出。 ACM 档案中提供了有关 CWIC 的 ACM 论文。
成功实现一种主要的编程语言
在 1970 年代后期,SLIC 被用于编写 COBOL 交叉编译器。主要由一个程序员在大约 3 个月内完成。我根据需要与程序员一起工作。另一位程序员为目标 TI-990 mini-COMPUTER 编写了运行时库和 MACHOP。该 COBOL 编译器每秒编译的行数比用汇编编写的 DEC-10 原生 COBOL 编译器要多得多。
更多关于编译器然后通常谈论的内容
从头开始编写编译器的很大一部分是运行时库。你需要一个符号表。你需要输入和输出。动态内存管理等。为编译器编写运行时库比编写编译器更容易。但是对于 SLIC,运行时库对于在 SLIC 中开发的所有编译器都是通用的。请注意,有两个运行时库。一种用于语言的(例如 COBOL)目标机器。另一个是编译器编译器运行时库。
我想我已经确定这些不是解析器生成器。所以现在对后端有一点了解,我可以解释解析器编程语言了。
解析器编程语言
解析器是使用以简单方程形式编写的公式编写的。
<name> <formula type operator> <expression> ;
最低级别的语言元素是字符。标记由语言字符的子集形成。字符类用于命名和定义这些字符子集。定义运算符的字符类是冒号 (:) 字符。作为该类成员的字符在定义的右侧编码。可打印字符包含在素数单 ' 字符串中。非打印字符和特殊字符可以用它们的数字序号表示。类成员由替代 | 分隔操作员。类公式以分号结尾。字符类可能包括先前定义的类:
/* Character Class Formula class_mask */
bin: '0'|'1'; // 0b00000010
oct: bin|'2'|'3'|'4'|'5'|'6'|'7'; // 0b00000110
dgt: oct|'8'|'9'; // 0b00001110
hex: dgt|'A'|'B'|'C'|'D'|'E'|'F'|'a'|'b'|'c'|'d'|'e'|'f'; // 0b00011110
upr: 'A'|'B'|'C'|'D'|'E'|'F'|'G'|'H'|'I'|'J'|'K'|'L'|'M'|
'N'|'O'|'P'|'Q'|'R'|'S'|'T'|'U'|'V'|'W'|'X'|'Y'|'Z'; // 0b00100000
lwr: 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'|
'n'|'o'|'p'|'q'|'r'|'s'|'t'|'u'|'v'|'w'|'x'|'y'|'z'; // 0b01000000
alpha: upr|lwr; // 0b01100000
alphanum: alpha|dgt; // 0b01101110
skip_class 0b00000001 是预定义的,但可能会过度定义skip_class。
总而言之:字符类是一个替代列表,只能是字符常量、字符序数或先前定义的字符类。当我实现字符类时:类公式被分配了一个类位掩码。 (如上面的 cmets 所示)任何具有任何字符文字或序数的类公式都会导致分配一个类位。掩码是通过将包含的类的类掩码与分配的位(如果有)一起进行或运算而制成的。从字符类创建一个类表。由字符序号索引的条目包含指示字符的类成员资格的位。类测试是内联完成的。一个带有 eax 中字符序数的 IA-86 代码示例说明了类测试:
test byte ptr [eax+_classmap],dgt
后跟一个:
jne <success>
或
je <failure>
之所以使用 IA-86 指令代码示例,是因为我认为 IA-86 指令在今天更为广为人知。评估到其类掩码的类名与由字符序数(在 eax 中)索引的类表进行非破坏性与运算。非零结果表示类成员资格。 (除了包含字符的 al(EAX 的低 8 位)之外,EAX 为零)。
这些旧编译器中的标记有点不同。关键词没有被解释为记号。它们只是通过解析器语言中的引用字符串常量进行匹配。通常不保留带引号的字符串。可以使用修饰符。 A + 保持字符串匹配。 (即 +'-' 匹配 - 成功时保留字符的字符) , 操作(即,'E')将字符串插入到标记中。空格由令牌公式处理,跳过前导 SKIP_CLASS 字符,直到进行第一次匹配。请注意,明确的 skip_class 字符匹配将停止跳过,从而允许令牌以 skip_class 字符开头。字符串标记公式会跳过与单引号相当dd 字符或双引号字符串匹配的前导skip_class 字符。有趣的是在 " 引用的字符串中匹配 " 字符:
string .. (''' .ANY ''' | '"' $(-"""" .ANY | """""","""") '"') MAKSTR[];
第一个替代匹配任何单引号引号字符。正确的选择匹配一个双引号引用的字符串,该字符串可能包含使用两个 " 字符一起表示单个 " 字符的双引号字符。此公式定义了在其自己的定义中使用的字符串。右内选项 '"' $(-"""" .ANY | """""","""") '"' 匹配双引号引用的字符串。我们可以使用单个 ' 引号字符来匹配双引号 " 字符。但是在双引号 " 字符串中,如果我们希望使用 " 字符,我们必须使用两个 " 字符来获得一个。例如,在左内替代匹配除引号之外的任何字符:
-"""" .ANY
使用负向窥视 -"""" 表示成功时(不匹配 " 字符)然后匹配 .ANY 字符(不能是 " 字符,因为 -"""" 消除了这种可能性)。正确的选择是采用 -"""" 匹配 " 字符并且失败是正确的选择:
"""""",""""
尝试匹配两个 " 字符,将它们替换为单个双 " 使用 ,"""" 以插入单个 " 字符。匹配结束字符串引号字符失败的两个内部替代方案,并调用 MAKSTR[] 以创建字符串对象. $ 序列,成功时循环,运算符用于匹配序列。标记公式跳过前导跳过类字符(空白)。一旦进行第一次匹配,skip_class 跳过被禁用。我们可以使用 [ 调用以其他语言编写的函数]. MAKSTR[]、MAKBIN[]、MAKOCT[]、MAKHEX[]、MAKFLOAT[] 和 MAKINT[] 是提供的库函数,可将匹配的标记字符串转换为类型化对象。下面的数字公式说明了一个相当复杂的标记识别:
number .. "0B" bin $bin MAKBIN[] // binary integer
|"0O" oct $oct MAKOCT[] // octal integer
|("0H"|"0X") hex $hex MAKHEX[] // hexadecimal integer
// look for decimal number determining if integer or floating point.
| ('+'|+'-'|--) // only - matters
dgt $dgt // integer part
( +'.' $dgt // fractional part?
((+'E'|'e','E') // exponent part
('+'|+'-'|--) // Only negative matters
dgt(dgt(dgt|--)|--)|--) // 1 2 or 3 digit exponent
MAKFLOAT[] ) // floating point
MAKINT[]; // decimal integer
上述数字标记公式可识别整数和浮点数。 -- 替代方案总是成功的。数字对象可用于计算。公式成功后,令牌对象被推入解析堆栈。 (+'E'|'e','E') 中的指数前导很有趣。我们希望 MAKEFLOAT[] 总是有一个大写的 E。但是我们允许使用小写的 'e' 替换它,'E'。
您可能已经注意到字符类和标记公式的一致性。解析公式继续添加回溯选项和树构造运算符。回溯和非回溯替代运算符不得在表达式级别内混合。你可能没有(a | b \ c)混合非回溯| withe \回溯替代。 (a\b\c)、(a|b|c) 和 ((a|b)\c) 是有效的。 \ 回溯替代在尝试其左侧替代之前保存解析状态,并且在失败时在尝试正确替代之前恢复解析状态。在一系列备选方案中,第一个成功的备选方案满足该组。没有尝试进一步的替代方案。分解和分组提供了连续推进的解析。回溯替代在尝试其左替代之前创建解析的保存状态。当解析可能进行部分匹配然后失败时,需要回溯:
(a b | c d)\ e
在上面,如果 a 返回失败,则尝试替代 c d。如果然后 c 返回失败,则将尝试回溯替代方案。如果 a 成功而 b 失败,则将回溯并尝试解析。同样,a 失败 c 成功且 b 失败,则回溯解析并采用替代 e。回溯不限于公式内。如果任何解析公式在任何时候进行部分匹配然后失败,则解析将重置为顶部回溯并采用其替代方案。如果代码已输出,则可能会发生编译失败,因为回溯已创建。在开始编译之前设置回溯。返回失败或回溯到它是编译器失败。回溯是堆叠的。我们可以使用负数和正数? peek/look ahead 运算符在不推进解析的情况下进行测试。字符串测试是一个预览,只需要保存和重置输入状态。向前看将是一个解析表达式,它在失败之前进行部分匹配。使用回溯实现前瞻。
解析器语言既不是 LL 也不是 LR 解析器。但是一种用于编写递归体面解析器的编程语言,您可以在其中编程树构造:
:<node name> creates a node object and pushes it onto the node stack.
.. Token formula create token objects and push them onto
the parse stack.
!<number> pops the top node object and top <number> of parstack
entries into a list representation of the tree. The
tree then pushed onto the parse stack.
+[ ... ]+ creates a list of the parse stack entries created
between them:
'(' +[argument $(',' argument]+ ')'
could parse an argument list. into a list.
一个常用的解析例子是算术表达式:
Exp = Term $(('+':ADD|'-':SUB) Term!2);
Term = Factor $(('*':MPY|'/':DIV) Factor!2);
Factor = ( number
| id ( '(' +[Exp $(',' Exp)]+ ')' :FUN!2
| --)
| '(' Exp ')" )
(^' Factor:XPO!2 |--);
Exp 和 Term 使用循环创建左手树。使用右递归的因子创建右手树:
d^(x+5)^3-a+b*c => ADD[SUB[EXP[EXP[d,ADD[x,5]],3],a],MPY[b,c]]
ADD
/ \
SUB MPY
/ \ / \
EXP a b c
/ \
d EXP
/ \
ADD 3
/ \
x 5
这里有一点 cc 编译器,一个带有 c 风格 cmets 的 SLIC 的更新版本。函数类型(语法、标记、字符类、生成器、PSEUDO 或 MACHOP)由其 id 后面的初始语法确定。 使用这些自上而下的解析器,您可以从程序定义公式开始:
program = $((declaration // A program is a sequence of
// declarations terminated by
|.EOF .STOP) // End Of File finish & stop compile
\ // Backtrack: .EOF failed or
// declaration long-failed.
(ERRORX["?Error?"] // report unknown error
// flagging furthest parse point.
$(-';' (.ANY // find a ';'. skiping .ANY
| .STOP)) // character: .ANY fails on end of file
// so .STOP ends the compile.
// (-';') failing breaks loop.
';')); // Match ';' and continue
declaration = "#" directive // Compiler directive.
| comment // skips comment text
| global DECLAR[*1] // Global linkage
|(id // functions starting with an id:
( formula PARSER[*1] // Parsing formula
| sequencer GENERATOR[*1] // Code generator
| optimizer ISO[*1] // Optimizer
| pseudo_op PRODUCTION[*1] // Pseudo instruction
| emitor_op MACHOP[*1] // Machine instruction
) // All the above start with an identifier
\ (ERRORX["Syntax error."]
garbol); // skip over error.
// 请注意在创建树时如何将 id 分解并随后合并。
formula = ("==" syntax :BCKTRAK // backtrack grammar formula
|'=' syntax :SYNTAX // grammar formula.
|':' chclass :CLASS // character class define
|".." token :TOKEN // token formula
)';' !2 // Combine node name with id
// parsed in calling declaration
// formula and tree produced
// by the called syntax, token
// or character class formula.
$(-(.NL |"/*") (.ANY|.STOP)); Comment ; to line separator?
chclass = +[ letter $('|' letter) ]+;// a simple list of character codes
// except
letter = char | number | id; // when including another class
syntax = seq ('|' alt1|'\' alt2 |--);
alt1 = seq:ALT!2 ('|' alt1|--); Non-backtrack alternative sequence.
alt2 = seq:BKTK!2 ('\' alt2|--); backtrack alternative sequence
seq = +[oper $oper]+;
oper = test | action | '(' syntax ')' | comment;
test = string | id ('[' (arg_list| ,NILL) ']':GENCALL!2|.EMPTY);
action = ':' id:NODE!1
| '!' number:MAKTREE!1
| "+[" seq "]+" :MAKLST!1;
// C style comments
comment = "//" $(-.NL .ANY)
| "/*" $(-"*/" .ANY) "*/";
值得注意的是解析器语言如何处理注释和错误恢复。
我想我已经回答了这个问题。已经编写了 SLIC 继任者的很大一部分,这里是 cc 语言本身。目前还没有编译器。但我可以手动将其编译成汇编代码、裸 asm c 或 c++ 函数。
【讨论】:
您有公共代码存储库或一些文档吗?我很想看看。【参考方案13】:是的,您可以为该语言编写一个编译器。不,您不需要第一个编译器来引导该语言。
您需要引导的是语言的实现。它可以是编译器,也可以是解释器。
从历史上看,语言通常被认为是解释型语言或编译型语言。解释器只为前者编写,编译器只为后者编写。因此,通常如果要为一种语言编写编译器,第一个编译器会用其他语言编写以引导它,然后,可以选择为主题语言重新编写编译器。但是用另一种语言编写解释器是一种选择。
这不仅仅是理论上的。我碰巧目前正在自己做这件事。我正在为我自己开发的一种语言 Salmon 开发编译器。我首先用 C 创建了一个 Salmon 编译器,现在我正在用 Salmon 编写编译器,这样我就可以让 Salmon 编译器工作,而无需使用任何其他语言编写的 Salmon 编译器。
【讨论】:
【参考方案14】:请注意,从技术上讲,您可以用一种尚不存在的语言编写编译器。为了做到这一点,你创建了一个解释器,它是原始语言的一种低级语言,它通常是缓慢且无用的,因为它会在执行任何操作之前解释语言的每个语句。
如果您阅读它,它看起来确实与预期的语言完全一样,但它的执行需要经过一些过程,该过程通过多个步骤将其转换为可执行文件。
这个编译器通常非常慢,因为它使用了一些适用于几乎所有现有语言的通用数学过程,但优点是你下次除了在现有代码上使用生成的编译器之外什么都不做。
这次当然不用解释了。
【讨论】:
以上是关于用自己的语言编写编译器的主要内容,如果未能解决你的问题,请参考以下文章