简单的说 编译器 就是语言翻译器,它一般将高级语言翻译成更低级的语言,如 GCC 可将 C/C++ 语言翻译成可执行机器语言,Java 编译器可以将 Java 源代码翻译成 Java 虚拟机可以执行的字节码。

编译器如此神奇,那么它到底是如何工作的呢?本文将简单介绍编译器的原理,并实现一个简单的编译器,使它能编译我们自定义语法格式的源代码。(文中使用的源码都已上传至 GitHub 以方便查看)。

自定义语法

为了简洁易懂,我们的编译器将只支持以下简单功能:

  • 数据类型只支持整型,这样不需要数据类型符;
  • 支持 加(+),减(-),乘(*), 除(/) 运算
  • 支持函数调用
  • 支持 extern(为了调用 printf 打印计算结果)

以下是我们要支持的源码实例 demo.xy:

  1. extern printi(val)
  2. sum(a, b) {
  3. return a + b
  4. }
  5. mult(a, b) {
  6. return a * b
  7. }
  8. printi(mult(4, 5) - sum(4, 5))

编译原理简介

一般编译器有以下工作步骤:

  1. 词法分析(Lexical analysis): 此阶段的任务是从左到右一个字符一个字符地读入源程序,对构成源程序的字符流进行扫描然后根据构词规则识别 单词(Token),完成这个任务的组件是 词法分析器(Lexical analyzer,简称Lexer),也叫 扫描器(Scanner);
  2. 语法分析(Syntactic analysis,也叫 Parsing): 此阶段的主要任务是由 词法分析器 生成的单词构建 抽象语法树(Abstract Syntax Tree ,AST),完成此任务的组件是 语法分析器(Parser);
  3. 目标码生成: 此阶段编译器会遍历上一步生成的抽象语法树,然后为每个节点生成 机器 / 字节码。

编译器完成编译后,由 链接器(Linker) 将生成的目标文件链接成可执行文件,这一步并不是必须的,一些依赖于虚拟机运行的语言(如 Java,Erlang)就不需要链接。

工具简介

对应编译器工作步骤我们将使用以下工具,括号里标明了所使用的版本号:

  • Flex(2.6.0): Flex 是 Lex 开源替代品,他们都是 词法分析器 制作工具,它可以根据我们定义的规则生成 词法分析器 的代码;
  • Bison(3.0.4): Bison 是 语法分析器 的制作工具,同样它可以根据我们定义的规则生成 语法分析器 的代码;
  • LLVM(3.8.0): LLVM 是构架编译器的框架系统,我们会利用他来完成从 抽象语法树 生成目标码的过程。

在 ubuntu 上可以通过以下命令安装这些工具:

  1. sudo apt-get install flex
  2. sudo apt-get install bison
  3. sudo apt-get install llvm-3.8*

介绍完工具,现在我们可以开始实现我们的编译器了。

词法分析器

前面提到 词法分析器 要将源程序分解成 单词,我们的语法格式很简单,只包括:标识符,数字,数学运算符,括号和大括号等,我们将通过 Flex 来生成 词法分析器 的源码,给 Flex 使用的规则文件 lexical.l 如下:

  1. %{
  2. #include <string>
  3. #include "ast.h"
  4. #include "syntactic.hpp"
  5. #define SAVE_TOKEN  yylval.string = new std::string(yytext, yyleng)
  6. #define TOKEN(t)    (yylval.token = t)
  7. %}
  8. %option noyywrap
  9. %%
  10. [ \t\n]                 ;
  11. "extern"                return TOKEN(TEXTERN);
  12. "return"                return TOKEN(TRETURN);
  13. [a-zA-Z_][a-zA-Z0-9_]*  SAVE_TOKEN; return TIDENTIFIER;
  14. [0-9]+                  SAVE_TOKEN; return TINTEGER;
  15. "="                     return TOKEN(TEQUAL);
  16. "=="                    return TOKEN(TCEQ);
  17. "!="                    return TOKEN(TCNE);
  18. "("                     return TOKEN(TLPAREN);
  19. ")"                     return TOKEN(TRPAREN);
  20. "{"                     return TOKEN(TLBRACE);
  21. "}"                     return TOKEN(TRBRACE);
  22. ","                     return TOKEN(TCOMMA);
  23. "+"                     return TOKEN(TPLUS);
  24. "-"                     return TOKEN(TMINUS);
  25. "*"                     return TOKEN(TMUL);
  26. "/"                     return TOKEN(TDIV);
  27. .                       printf("Unknown token!\n"); yyterminate();
  28. %%

我们来解释一下,这个文件被 2 个 %% 分成 3 部分,第 1 部分用 %{ 与 %} 包括的是一些 C++ 代码,会被原样复制到 Flex 生成的源码文件中,还可以在指定一些选项,如我们使用了 %option noyywrap,也可以在这定义宏供后面使用;第 2 部分用来定义构成单词的规则,可以看到每条规都是一个 正则表达式 和 动作,很直白,就是 词法分析器 发现了匹配的 单词 后执行相应的 动作 代码,大部分只要返回 单词 给调用者就可以了;第 3 部分可以定义一些函数,也会原样复制到生成的源码中去,这里我们留空没有使用。

现在我们可以通过调用 Flex 生成 词法分析器 的源码:

  1. flex -o lexical.cpp lexical.l

生成的 lexical.cpp 里会有一个 yylex() 函数供 语法分析器 调用;你可能发现了,有些宏和变量并没有被定义(如TEXTERN,yylval,yytext 等),其实有些是 Flex 会自动定义的内置变量(如 yytext),有些是后面 语法分析器 生成工具里定义的变量(如 yylval),我们后面会看到。

语法分析器

语法分析器 的作用是构建 抽象语法树,通俗的说 抽象语法树 就是将源码用树状结构来表示,每个节点都代表源码中的一种结构;对于我们要实现的语法,其语法树是很简单的,如下:

现在我们使用 Bison 生成 语法分析器 代码,同样 Bison 需要一个规则文件,我们的规则文件 syntactic.y 如下,限于篇幅,省略了某些部分,可以通过链接查看完整内容:

  1. %{
  2. #include "ast.h"
  3. #include <cstdio>
  4. ...
  5. extern int yylex();
  6. void yyerror(const char *s) { std::printf("Error: %s\n", s);std::exit(1); }
  7. %}
  8. ...
  9. %token <token> TLPAREN TRPAREN TLBRACE TRBRACE TCOMMA
  10. ...
  11. %%
  12. program:
  13. stmts { programBlock = $1; }
  14. ;
  15. ...
  16. func_decl:
  17. ident TLPAREN func_decl_args TRPAREN block { $$ = new NFunctionDeclaration(*$1, *$3, *$5); delete $3; }
  18. ;
  19. ...
  20. %%

是不是发现和 Flex 的规则文件很像呢?确实是这样,它也是分 3 个部分组成,同样,第一部分的 C++ 代码会被复制到生成的源文件中,还可以看到这里通过以下这样的语法定义前面了 Flex 使用的宏:

  1. %token <token> TLPAREN TRPAREN TLBRACE TRBRACE TCOMMA

比较不同的是第 2 部分,不像 Flex 通过 正则表达式 通过定义规则,这里使用的是 巴科斯范式(BNF: Backus-Naur Form) 的形式定义了我们识别的语法结构。如下的语法表示函数:

  1. func_decl:
  2. ident TLPAREN func_decl_args TRPAREN block { $$ = new NFunctionDeclaration(*$1, *$3, *$5); delete $3; }
  3. ;

可以看到后面大括号中间的也是 动作 代码,上例的动作是在 抽象语法树 中生成一个函数的节点,其实这部分的其他规则也是生成相应类型的节点到语法树中。像 NFunctionDeclaration 这是一个我们自己定义的节点类,我们在 ast.h 中定义了我们所要用到的节点,同样的,我们摘取一段代码如下:

  1. ...
  2. class NFunctionDeclaration : public NStatement {
  3. public:
  4. const NIdentifier& id;
  5. VariableList arguments;
  6. NBlock& block;
  7. NFunctionDeclaration(const NIdentifier& id,
  8. const VariableList& arguments, NBlock& block) :
  9. id(id), arguments(arguments), block(block) { }
  10. virtual llvm::Value* codeGen(CodeGenContext& context);
  11. };
  12. ...

可以看到,它有 标识符(id),参数列表(arguments),函数体(block) 这些成员,在语法分析阶段会设置好这些成员的内容供后面的 目标码生成 阶段使用。还可以看到有一个 codeGen() 虚函数,你可能猜到了,后面就是通过调用它来生成相应的目标代码。

我们可以通过以下命令调用 Bison 生成 语法分析器 的源码文件,这里我们使用 -d 使头文件和源文件分开,因为前面 词法分析器的源码使用了这里定义的一些宏,所以需要使用这个头文件,这里将会生成 syntactic.cpp 和 syntactic.hpp:

  1. bison -d -o syntactic.cpp syntactic.y

目标码生成

这是最后一步了,这一步的主角是前面提到 LLVM,LLVM 是一个构建编译器的框架系统,我们使用他遍历 语法分析 阶段生成的 抽象语法树,然后为每个节点生成相应的 目标码。当然,无法避免的是我们需要使用 LLVM 提供的函数来编写生成目标码的源码,就是实现前面提到的虚函数 codeGen(),是不是有点拗口?不过确实是这样。我们在 gen.cpp 中编写了不同节点的生成代码,我们摘取一段看一下:

  1. ...
  2. Value *NMethodCall::codeGen(CodeGenContext &context) {
  3. Function *function = context.module->getFunction(id.name.c_str());
  4. if (function == NULL) {
  5. std::cerr << "no such function " << id.name << endl;
  6. }
  7. std::vector<Value *> args;
  8. ExpressionList::const_iterator it;
  9. for (it = arguments.begin(); it != arguments.end(); it++) {
  10. args.push_back((**it).codeGen(context));
  11. }
  12. CallInst *call = CallInst::Create(function, makeArrayRef(args), "", context.currentBlock());
  13. std::cout << "Creating method call: " << id.name << endl;
  14. return call;
  15. }
  16. ...

看起来有点复杂,简单来说就是通过 LLVM 提供的接口来生成 目标码,需要了解更多的话可以去 LLVM 的官网学习一下。

至此,我们所有的工作基本都做完了。简单回顾一下:我们先通过 Flex 生成 词法分析器 源码文件 lexical.cpp,然后通过 Bison 生成 语法分析器 源码文件 syntactic.cpp 和头文件 syntactic.hpp,我们自己编写了 抽象语法树 节点定义文件 ast.h 和 目标码生成文件 ast.cpp,还有一个 gen.h 包含一点 LLVM 环境相关的代码,为了输出我们程序的结果,还在 printi.cpp 里简单的通过调用 C 语言库函数实现了输出一个整数。

对了,我们还需要一个 main 函数作为编译器的入口函数,它在 main.cpp 里:

  1. ...
  2. int main(int argc, char **argv) {
  3. yyparse();
  4. InitializeNativeTarget();
  5. InitializeNativeTargetAsmPrinter();
  6. InitializeNativeTargetAsmParser();
  7. CodeGenContext context;
  8. context.generateCode(*programBlock);
  9. context.runCode();
  10. return 0;
  11. }

我们可以看到其调用了 yyparse() 做 语法分析,(yyparse() 内部会先调用 yylex() 做 词法分析);然后是一系列的 LLVM 初始化代码,context.generateCode(*programBlock) 是开始生成 目标码;最后是 context.runCode() 来运行代码,这里使用了 LLVM 的 JIT(Just In Time) 来直接运行代码,没有链接的过程。

现在我们可以用这些文件生成我们的编译器了,需要说明一下,因为 词法分析器 的源码使用了一些 语法分析器 头文件中的宏,所以正确的生成顺序是这样的:

  1. bison -d -o syntactic.cpp syntactic.y
  2. flex -o lexical.cpp lexical.l syntactic.hpp
  3. g++ -c `llvm-config --cppflags` -std=c++11 syntactic.cpp gen.cpp lexical.cpp printi.cpp main.cpp
  4. g++ -o xy-complier syntactic.o gen.o main.o lexical.o printi.o `llvm-config --libs` `llvm-config --ldflags` -lpthread -ldl -lz -lncurses -rdynamic

如果你下载了 GitHub 的源码,那么直接:

  1. cd src
  2. make

就可以完成以上过程了,正常会生成一个二进制文件 xy-complier,它就是我们的编译器了。

编译测试

我们使用之前提到实例 demo.xy 来测试,将其内容传给 xy-complier 的标准输入就可以看到运行结果了:

  1. cat demo.xy | ./xy-complier

也可以直接通过

  1. make test

来测试,输出如下:

  1. ...
  2. define internal i64 @mult(i64 %a1, i64 %b2) {
  3. entry:
  4. %a = alloca i64
  5. %0 = load i64, i64* %a
  6. store i64 %a1, i64* %a
  7. %b = alloca i64
  8. %1 = load i64, i64* %b
  9. store i64 %b2, i64* %b
  10. %2 = load i64, i64* %b
  11. %3 = load i64, i64* %a
  12. %4 = mul i64 %3, %2
  13. ret i64 %4
  14. }
  15. Running code:
  16. 11
  17. Exiting...

可以看到最后正确输出了期望的结果,至此我们简单的编译器就完成了。

作者:Yunba云巴

来源:51CTO

实现一个简单的编译器相关推荐

  1. java简单编译器源代码_25行代码实现一个简单的编译器

    起因 <25行JavaScript语句实现一个简单的编译器>实现的是一个简单到不能再简单的玩具的玩具,他的魔法是函数式编程简化了js代码.java 8提供了函数式编程的支持,昨晚脑子抽风突 ...

  2. Qt实现一个简单的编译器(软件生成器)

    Qt实现一个简单的编译器(软件生成器) 本文章只记录如何用Qt实现一个简单编译器,即点击本软件中的按钮便可在另一目录中生成一个新的软件(与本软件不冲突). 文章目录 Qt实现一个简单的编译器(软件生成 ...

  3. java实现编译器_实现一个简单的编译器

    简单的说 编译器 就是语言翻译器,它一般将高级语言翻译成更低级的语言,如 GCC 可将 C/C++ 语言翻译成可执行机器语言,Java 编译器可以将 Java 源代码翻译成 Java 虚拟机可以执行的 ...

  4. 使用JavaScript实现一个简单的编译器

    本文同步在个人博客shymean.com上,欢迎关注 在前端开发中也会或多或少接触到一些与编译相关的内容,常见的有 将ES6.7代码编译成ES5的代码 将SCSS.LESS代码转换成浏览器支持的CSS ...

  5. (3) 用java编译器实现一个简单的编译器-语法分析

    转载地址:http://blog.csdn.net/tyler_download/article/details/50708807 视频地址:http://study.163.com/course/c ...

  6. 用vb思设计Java编译器_一个简单的VB-VC编译器 - 程序设计(Programming)版 - 北大未名BBS...

    发信人: phoenix (凤凰), 信区: VisualBasic 标  题: 一个简单的VB-VC编译器 发信站: PKU BBS (Thu Jan  6 14:05:52 2000), 转信 V ...

  7. 一步一步解读神经网络编译器TVM(一)——一个简单的例子

    @TOC 前言 这是一个TVM教程系列,计划从TVM的使用说明,再到TVM的内部源码?为大家大致解析一下TVM的基本工作原理.因为TVM的中文资料比较少,也希望贡献一下自己的力量,如有描述方面的错误, ...

  8. 一个简单的PHP模板引擎

    PHP早期开发中通常是PHP代码和HTML代码混写,这也使代码中充斥着数据库操作,逻辑处理等.当项目不大时,这样的代码还可以接受,但是随着项目不断扩大,我们就会发现同一个文件中同时存在前端逻辑和后端处 ...

  9. [译]使用 Rust 开发一个简单的 Web 应用,第 4 部分 —— CLI 选项解析

    原文地址:A Simple Web App in Rust, Part 4 -- CLI Option Parsing 原文作者:Joel's Journal 译文出自:掘金翻译计划 本文永久链接:g ...

最新文章

  1. 如何将一个列表当作元组的一个元素
  2. python基础练习(六)
  3. 做毕设时遇到的一些问题,以及一些小技巧
  4. 格雷编码Python解法
  5. 用node-webkit(NW.js)创建桌面程序
  6. Android笔记 对话框demo大全
  7. 【Kafka】Kafka Leader:none ISR 为空 消费超时
  8. webkit的编译(r76498)
  9. AutoResetEvent 与 ManualResetEvent
  10. android 图片3d旋转动画效果,图片UI特效-3D罗盘旋转
  11. 【Leetcode刷题Python】739. 每日温度
  12. Qt 中信号和槽机制
  13. Field userDao ....service.impl...'com.lzj.springcloud.dao.UserDao' that could not be found
  14. 我的2013 Q.E.D
  15. 计算机只报数字怎么调成音乐,电脑怎么设置一锁屏音乐就停?
  16. Spring框架基础概念(面试概念解答)
  17. 像5D动感影院这种新兴的熊十一观影场所你都了解吗?
  18. php 判断是否是域名,用PHP判断顶级域名
  19. 解决IOError: [Errno 2] No such file or directory xxx .xxx
  20. 微型计算机用什么评价判断,关于简单项目的评价等级的判断方法

热门文章

  1. 热议:大脑功能磁共振数据不可靠?杜克大学教授对自己15年的工作提出质疑...
  2. 这个“大脑”收获一份大奖!
  3. 【工业互联网】全球工业互联网十大最具成长性技术展望(2019-2020年)
  4. 终于,Geoffrey Hinton那篇备受关注的Capsule论文公开了
  5. 惊艳了!升级版的 APDrawing,秒让人脸照变线条肖像画
  6. 那个放弃谷歌回老家二本教书的清华姚班生,现在怎么样了?
  7. 每天只睡4小时!大佬们都这么拼吗?
  8. JavaScript递归
  9. 十步图解CSS的position
  10. Pandas/networkx图分析简单入门