如何写一个简单的编译器? https://www.zhihu.com/question/36756224
初学编译原理,想写一个简单的编译器。
是时候亮出我的 LL 语言了,全称:Lambda Lite Js。
LL 是一个类似 haskell 的函数式语言,使用 Javascript 实现。在线demo:Lambda-lite-js。项目地址:GitHub - moevis/lambda-lite-js: a tiny FUNCITONAL LANGUAGE implemented by javascript. 一个函数式语言,使用 js 实现。。
大概写了两周多,有了很多很有趣的功能了,比如可以玩 lambda 演算,可以柯里化,可以玩闭包,可以玩模式匹配,可以使用 Point-Free 风格,具有局部 lazy-evaluation 特性等等。
先给几个阶乘的示例代码。
普通阶乘
let fact = \n ->
if n == 1 then 1 else n * (fact n - 1);
print $ fact 5;
利用模式匹配写阶乘:
let fact n@1 = 1;
let fact n@Number = n * (fact n - 1);
print $ fact 5;
不动点组合子阶乘:
let z = \f -> (\x -> f (\y -> x x y)) (\x -> f (\y -> x x y));
let makeFact = \g -> \n -> if n < 2
then 1
else n * (g n - 1);
let fact = z makeFact;
print $ fact 5;
网页版运行截图:
是的,就是和 haskell 这么像。
=====说正事=====
之前我写过一个 js 代码高亮插件,用于高亮 html、js、css 代码,GitHub - moevis/code-lighter: a lightweight code highlighting tool, 代码高亮工具。原理是将代码切分成一系列 tokens, 然后给这些 tokens 添加着色就好了。
至于如何切分 tokens 呢?Parser 就是负责这个事情的。我们先将输入的代码先分成以下几个基本元素:
数字 Number
字面量 Literial
标点 Punctuar
空白符 White
文件结束符 EOF
我们的任务就是要区分它们,这个目标还是蛮容易的,我们使用递归下降法来解析,这就是一个状态机的模型:
首先,我们的初始状态机 State 处于默认状态。
读入待解析代码,读第一个字符 c ,这时候有五种可能:
c 属于 '9' ~ '0',State 转为数字模式。
c 属于 'a' ~ 'z' 或者 'A' ~ 'Z',State 转为字面量模式。
c 属于 '*,;"+'... 的标点,State 转为标点模式。
c 属于 '\t' 或者 '\n' 或者空格,State 转为空白符模式。
c 为空,则到了代码的最后,State 为文件结束模式。
进入另一个模式后,我们会用不同的策略来接收下一个字符。比如,在数字模式假如下一个字符是 '0' ~ '9',那么读入数字,并拼接到之前的数字后;直到接收到一个非数字的字符(这里我们就不考虑小数了)。当退出数字模式,我们就能得到一个数字的 token 啦。同理我们可以得到标点,字面量等等。
关于字面量,我们还需要做一些分类,字面量包括变量名,关键字,所以我们实现要有一个关键字表,当字面量不再关键字表里面,我们视为一个变量或者是函数名,否则就是有语法意义的字符比如 if else 。
初步的 tokens 切分结束后,去除空白 tokens,我们就得到一个 token 数组。
这时候我们要建立抽象语法树 (Abstract syntax tree)了。先设计几个基本语法,比如声明一个函数或者变量时,使用`let`关键字,`if...else...then...`做分支判断,声明匿名函数使用类似'\n -> n + 1'的语法(有且只有一个参数 n ,n + 1 为返回值),表达式采用中缀 `1 + 2 + a`。
这时候我们可以定义几个树节点类型了:
声明节点, defineNode (let x = 5)
匿名函数节点, lambdaNode (\n -> n + 1)
分支节点,conditionNode (if ... then ... else ...)
引用节点,代表某一个已声明的变量值或者函数名 objectNode
数字节点,代表一个数字,numberNode
表达式节点, 代表某一中缀表达式,其本身储存运算符,比如 a + b,节点储存 `+`,expressNode
函数调用节点,表示对函数的调用,比如 print x,callNode
我们的任务就是将 tokens 转为这些节点的相互关系。
首先还是读入第一个 token,判断 token 类型,如果是`let`,那么就按照声明节点来解析; 如果是`\`则是一个匿名函数声明节点,如果是`if`,则解析一个分支节点…… 解析表达式节点会难一点,因为有运算符的优先级,我参照了 llvm 教程中的 kaleidoscope 语言来写(传送门,Kaleidoscope: 实现解析器和抽象语法树)。
总之这是需要你认真理解的一步,这一步完后,就可以根据语法树来得到代码运行结果了。每一种节点的结果计算方式都不同,但是有统一的接口 getValue()。比如声明节点无返回值,匿名函数返回一个函数,分支节点先对 condition 求值,当 condition 为 true,返回第一个分支的 value,否则是第二个……
当这一切结束后,你的第一个语言就完成了。当然,现在我的语言已经比较复杂了,比如加入了模式匹配节点,声明多参数函数的语法糖,改变结合方向等等。
你可以看我的 commit log 来看到我是如何一步一步添加这些功能的。
编译原理这个方向,龙书虎书是经典,若要深入学习,一定要看。不过我也没有时间啃下来,不过有一本书也是简单有趣,叫做《计算的本质》,O‘REILY 出的,值得信赖。
说了那么多,我这个只能算是解释器,并没有真正编译,当然对于初级已经够了。你不满足的话我再给你指条路,llvm 教程中有一个小语言叫做 kaleidoscope,你按照这个教程来,https://github.com/moevis/Kaleidoscope-LLVM-tutorial-zh-cn,这是我当年留下的中文翻译坑,当时翻了一些后就去做实验室任务了。这个从 parser 到 ast 到 ir 到各种优化都很全。
如果是用 Haskell 的话,三篇文章足矣。
prerequisite: 懂 state monad就行了
第一篇,《How to build a monadic interpreter in one day》http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.368.2522
跟着做,可以完成一个简单的backend, 也就是架构基本的AST 并执行的步骤。
然后到frontend, 也就是parser的部分你就会发现你看不懂了, 这个时候看第二篇。(因为该文的 parser 部分其实是 第二篇 的一个浓缩版,所以不看第二篇基本很难看懂这个部分)
第二篇,《Monadic Parser Combinator》 ,http://www.cs.nott.ac.uk/~pszgmh/monparsing.pdf
看了也就能对照第一篇,把 parser 写出来了, 然后就能和之前的backend 组合,完成一个基本的,完全由自己手写的,monadic的解释器(parser 和 backend 分别由一个自定义的 state monad 实现)。顺便加深对 monad 的理解。
看第二篇的时候,回过头对照第一篇看效果会更高,虽然逻辑一样,但第二篇是用 monad comprehension 的形式来写, 第一篇是用 do notation 来写。有的复杂的地方你两种方式对照看一下,会有茅塞顿开的效果。
然后再看第三篇
第三篇,llvm的haskell 教程, Implementing a JIT Compiler with Haskell and LLVM ( Stephen Diehl ) , 把你的backend 换成llvm. (注:事先对 llvm 不熟的话,可以和 hackage 上面 llvm-general, llvm-general-pure 这两个库的 wiki, 以及 LLVM Language Reference Manual对照着看)
至于frontend, 可以换成Parsec之类,但也可以就不断扩充之前自己写的版本。
齐活儿~
------
今天闲着没事儿,撸了一个 swift 的版本,GitHub - aaaron7/swift_monadic_parser: A simple haskell-style monadic combinator-based parser written in Swift. 仅包含最基本的 parser 和 interpreter 的功能,写法完全按照前面两篇文章的思路实现,有兴趣可以看看。
这个题目有点久了,现在才想起来答,主要还是因为暑假才找到空闲的时间,把我的C11编译器写完了。
这个编译器,(GitHub - wgtdkp/wgtcc: a tiny C compiler in C++) 一方面是为了学习 C11, 一方面为了练习C++。
在约11k代码中实现了:
几乎完整的C11语法解析(除去变长数组);
语义与类型检查(仿gcc的错误提示)
预处理器
x86-64汇编代码生成, 多谢
@RednaxelaFX
的回答寄存器分配问题? - 编译器,把我从无休止的手动优化中拯救回来
精简的x86-64 ABI
心形很流行嘛,wgtcc编译的M大(
@Milo Yip
) 在 如何用C语言画一个“心形”? - C(编程语言)回答里的代码:
#include
int main() {
for (float y = 1.5f; y > -1.5f; y -= 0.1f) {
for (float x = -1.5f; x < 1.5f; x += 0.05f) {
float a = x * x + y * y - 1;
putchar(a * a * a - x * x * y * y * y <= 0.0f ? '*' : ' ');
} putchar('\n');
}
}
C11 中有一些非常实用或者好玩的新特性,如 compound literal. 一个典型的用途是当我们想获得一个数据的另一种表示的时候, 我们可能会这么做:
float f = 1.5;
int i = *(int*)&f;
然而gcc 在开 -O2 时会报 break strict-aliasing rules 的warning。 有了 compound literal, 我们可以这么做:
#define CAST(s_t, d_t, sv) \
(union {s_t sv; d_t dv;}){sv}.dv
float f = 1.5;
int i = CAST(float, int, f);
而且这是一个模板呢~
C11 也支持在identifier 中使用unicode字符了,中文编程很exciting:
#define 整型 int
#define 输出 printf
#define 面函数 main
#define 返回 return
#define 定义 typedef
#define 不可变 const
#define 字符 char
#define 指针 *
#define 为
定义 不可变 字符 指针 为 字面值;
整型 面函数() {
字面值 蛤蛤 = "\u82df\u5229\u56fd\u5bb6\u751f\u6b7b\u4ee5\uff0c"
"\u5c82\u56e0\u7978\u798f\u907f\u8d8b\u4e4b";
输出("%s\n", 蛤蛤);
返回 0;
}
这些例子在example/目录下可以找到。
说说写这个小编译器总结的方法吧:
以最快的速度做到能够解析下面这段代码:
int main(int argc, char** argv) {
int i;
return 0;
}
以最快的速度看到hello,world。
开始对照语言标准一个一个实现特性,并同步做单元测试。因为已经看到hello world,这一步虽然工作量有点大,但是因为有了前面的经验,可以很快。
解析声明是最复杂的,所以先写解析声明。
龙书是必需的,一个好的参考也非常重要(如GitHub - rui314/8cc: A Small C Compiler)。
尝试自举(因为我用的C++,所以没法自举)。
写一个编译器的坏处是,以后写一段代码都试图在脑子里面翻译成汇编。。。
// Update_1
// Date: 09/20/2016
匆忙写的回答,感觉仅仅是抛出结果和小结,实在是太不负责任了=-=。没有实现过的同学可能还是不知如何入手,下面就写写我是如何一步一步做的吧(某些内容只限于C编译器)
1. 初始状态,你必须有一本第二版的龙书。其它的答案可能会推荐《编译器实现》或者《编程语言实现模式》。《编译器实现》因为中文翻译的比较生硬,读了几章,发现还是龙书比较好,后来基本没有看《编译器实现》了。如果你是直接读原版,那么还是推荐龙书,毕竟都有决心读原版了,干脆彻底一点读龙书原版好了^_^。其实龙书并没有那么难,公式记不住,不理解,跳过就好了。《编程语言实现模式》其实很好的,各种实现方法都有所涉及。唯一不足的是,作者没有向《代码大全》的作者那样,对我耳提面命 ----- 直接告诉我怎么做就好了(相信这是新手最需要的...)。
2. 你必须有C11 standard。open-std 上面的draft就够了(http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1548.pdf)。(如果是其他语言的编译器,对应之。如果是自己设计,那么应该学着standard那样,将grammar,constraints等写下来)
3. 一个简单的不带优化的编译器,基本只需要3个步骤:词法分析,语法分析,代码生成;对应到代码里面,就是3个class:Scanner, Parser, Generator;
4. 对于C语言编译器,支持预处理器,相当于又支持了一门新的简单语言了。所以一开始不必去支持预处理器。在测试时,只使用不包含预处理器的代码就好了。或者,用gcc进行预处理,将其输出作为你的编译器的输入。
5. 在真的动手开始写Scanner之前,有必要读n1548的5.1节,对C源程序的编译步骤有基本的了解。其实ad-hoc的词法解析一点也不比写一个split()函数更复杂,尤其是开始的时候不必去记录 Source Location(当然后面如果写错误提示,可以再加进来)。实现的时候,也不要吝惜内存了,直接将所有的token一次解析掉存起来就好。因为这对于后面Parser要回溯的时候会方便一点。
6. 已经得到Token List 之后,就可以开始做Parser部分了。暂时不做错误恢复,即遇到错误即exit。这里有一个很实用的设计建议:准备好下面这四个函数:
a. Peek() 返回下一个Token(只测试该Token,不向前移动Token List的offset指针)
b. Next() 消费下一个Token
c. Expect(expectedToken) , 这代替了下面的这段错误处理:
if (Peek() != expectedToken) {
Error("expect %s, but got %s\n", expectedToken, Peek());
}
d. Try(expectedToken), 这代替了下面这段代码:
if (Peek() == expectedToken) {
Next(); // 消费之
}
这很有用,因为Parser里面可能会有上百个这段代码,在我的Parser里面,有84个Expect()调用,81个Peek()(Peek和Test), 39个Next(),62个Try()。相信我,这4个函数会让你的代码干净一倍。
7. C的语言组成,大致可以分为 Expression, Declaration, Statement and Block 三个部分。这里面Statement and Block是最简单的,Declaration难一点。按照我前面的心得体验,应该从简单的入手,但是我们非先做Declaration不可。因为Statements都是在函数内的啊,不搞定Declaration就没法继续了呢~ 其实,Declaration也好做,对着n1548的7.A.2.2节一个一个将grammar翻译为Parser里面的逻辑就好了(是的,除去语义和类型检查,Parser就是这么简单)。做完Declaration,你还没有往AST上添加任何node,是的,仅仅是Declaration,是没有一行代码需要生成的。所有的成就都在符号表里面。这里又有tip:暂时不要做Initializer,它有一点烦人的(C标准把它搞得好繁琐)。
8. struct/union 类型;如果只是支持一个小小的子集,那么大可以跳过这一部分不做。struct会引入一些工作量,一方面,需要为tag(tag 和普通的identifier不在同一个命名空间)另开一个符号表(我使用一个小trick避免了这个麻烦);另一方面,它也是使Initializer变复杂的原因之一,尤其是匿名struct/union。tip:对struct/union的支持步骤是:普通嵌套的结构,匿名无tag的 struct成员,位域,union;这里把union放到最后是因为,它和struct除去存储布局之外,别无二致(所以你甚至不必区分这两个类型);你也可以在任何一步就开始支持union。
9. 数组和函数;除去作为sizeof关键字的操作数,数组总是会被cast成一个指针;你一定写过这样的代码:
typedef void (*func_t)(void);
func_t f = func; f(); // 难道不应该是 (*f)(); ?
其实函数也是被cast成指针了,所以上面的调用方式都对,更微妙的是,任何一个函数调用都会被先cast成指针,再解引用(至少我的实现是这样的);
10. storage 和 linkage;起初只实现所有的对象分配在栈空间;这会大大简化代码生成部分,因此对于“以最快速度看到hello world”是非常重要的;linkage对于前向声明是重要的,如果你没有打算支持,那么也可以跳过,等你看到hello world再回来支持,或者等你的函数和标准库的冲突了。
11. expression;这个最简单,该怎么样就怎么样=-=。tip:联合赋值运算符:
a *= 5;
a = a * 5;
是不是总是被告知效果和下面那行效果相等?那么不要害羞,就这么翻译!(嗯,这么做是会产生bug(如果左值表达式有副作用),但是可以通过额外的检查规避掉;对于带优化的编译器,这根本不是问题,因为它们怎么会对公共子表达式求两遍值呢?)
12. statement;这是我最喜欢的部分,不仅仅是因为它简单,而且让我明白那些控制语句是怎么生成到汇编代码的(对应请看龙书6.6和6.7节);如最简单的while循环的展开:
/*
* while (expression) statement
* 展开后:
* cond:
* if (expression) then
* empty
* else
* goto end
*
statement
*
goto cond
* end:
*/
这里,我将 if 语句保留为基本的翻译单元,因为将其他的控制结构翻译为 if 语句会带来很大的便利。tip:支持顺序:if-else, while/do-while, for, switch-case;
这些基本是一个C语言Parser的动手步骤了,现在你可以parse这段代码了:
int main() {
puts("hello world\n");
return 0;
}
你可以手动将 puts插入到符号表以应付过去(某些builtin的函数还真就是这么干的),也可以在这个hello.c文件中声明puts:
extern int puts(const char* str);
或者你就要实现对struct/union的支持, 不然是没有办法 #include 的了。这里没有使用 printf,因为暂时没有必要实现变参函数。
这样,你与hello world只有一步之遥了:汇编代码生成。
// TODO(wgtdkp): 汇编代码生成
// End of update_1
// Update_2
// Date: 09/21/2016
因为按照"以最快的速度看到hello world"来做的话,语义检查和类型检查可以暂且放一放,或者只是实现parse过程中必不可少的一部分。下面是以x86-64 汇编代码生成为例,说说代码生成。这里又可以跳过中间代码生成,直接由AST生成汇编代码~
1. intel x86-64 手册;显然这是必需的,虽然我拿到这3000多页的手册时,也是虎躯一震的。不过,实实在在地讲,我只看了大概30页的内容;更多的时候,我是对照着gcc生成的汇编代码照虎画猫; tip:对于某些指令,如乘除法,移位,对照gcc进行翻译是非常有效的;但你不应该企图生成像gcc那么高效的代码!(具体方法见下面)
2. System V x64 ABI;你必须至少看chapter 3(chapter 3就够用了, 不过只有30多页,放心吧);至少掌握stack frame的结构和对齐。注意x86-64的调用规约会稍微复杂一点,不过你可以做一些大胆的简化:
a. scalar type (除去struct/union,剩下的都是)按照 ABI里面的就好;
b. struct/union 是比较复杂的,这里可以直接按照stack传参(而不是寄存器传参去做),毕竟又有多少代码会直接传递struct/union 呢?等到你决意要做一个full featured的编译器时,再来考虑它吧。
可以参考这里Introduction to X86-64 Assembly for Compiler Writers
3. visitor 模式;相信这个不必赘述,取决于怎么使用它,可以有很多作用;比如Parser中会需要做常量表达式求值,我们会用到它;获得一个表达式的左值地址时,我们又需要用到它;(考虑你该如何翻译赋值表达式)
4. 数据类型;在代码生成这一步,我们的数据类型只有3种:整型,浮点型,struct/union(集合);我将能够用通用寄存器存储的数据称为整型,包括指针;struct/union的处理比较特殊,对于一个类型为struct/union的表达式,visit该表达式,总是得到此struct/union对象的地址值(存储于%rax寄存器中);只要我们在所有代码中遵守这一规则,那么它确实很管用,即使会有一些冗余的拷贝;
5. 翻译函数定义;一个函数的翻译可以分为下面几个步骤:
保存栈指针;
确定函数参数的位置(在哪个寄存器或者stack上的位置),将它们复制到当前stack frame的上,更新每个参数(object)的offset成员(object的地址)。更新当前stack frame的偏移量;
确定当前作用域内的object的地址, 这只需要扫描当前scope内的所有object,并线性地分配到stack frame上面就好;注意不包括内层scope内定义的object。这是一种优化,能够充分利用栈空间,而且实现更简单。更新当前的stack frame偏移量。
翻译函数体;
在return 语句和函数结尾处,恢复栈指针并退出函数;
6. 翻译函数调用;也可以分为下面几个步骤:
确定函数地址;这可能是函数的名字,也可能是一个寄存器;这里就需要一个能够计算表达式左值地址的 evaluator 了(后面会讲到);
对函数参数求值,暂存之(push到stack上);
确定函数参数的位置,即,应该在哪个寄存器或stack的位置;拷贝参数值到对应位置;
调整栈指针以正确地对齐(这个很重要,不然会有segment fault的,都是泪);
调用之~
7. 翻译赋值表达式;对左值表达式的赋值,需要获得左值表达式的地址,而不是值;因此我们需要一个 LValGenerator 来取得左值表达式的地址,然后将右操作数的值load到该地址中;
8. 翻译表达式;建议采用1-TOSCA方法,不懂的可以看看R大的回答寄存器分配问题? - 编译器;这里的tip:不要被gcc生成的各种高效代码蛊惑了而去做大量的手动优化,那是一个很大的坑,尤其是我们没有生成中间代码,是做不到全局寄存器分配的效果的。
========
第一个 C 语言编译器是怎样编写的 http://www.csdn.net/article/2015-11-27/2826350
摘要:当今几乎所有的实用的编译器/解释器(以下统称编译器)都是用C语言编写的,有一些语言比如Clojure,Jython等是基于JVM或者说是用Java实现的,IronPython等是基于。
当今几乎所有的实用的编译器/解释器(以下统称编译器)都是用C语言编写的,有一些语言比如Clojure,Jython等是基于JVM或者说是用Java实现的,IronPython等是基于.NET实现的,但是Java和C#等本身也要依靠C/C++来实现,等于是间接调用了C。所以衡量某种高级语言的可移植性其实就是在讨论ANSI/ISO C的移植性。
C语言是很低级的语言,很多方面都近似于汇编语言,在《Intel 32位汇编语言程序设计》一书中,甚至介绍了手工把简单的C语言翻译成汇编的方法。对于编译器这种系统软件,用C语言来编写是很自然不过的,即使是像Python这样的高级语言依然在底层依赖于C语言(举Python的例子是因为Intel的黑客正在尝试让Python不需要操作系统就能运行——实际上是免去了BIOS上的一次性C代码)。现在的学生,学过编译原理后,只要有点编程能力的都可以实现一个功能简单的类C语言编译器。
可是问题来了,不知道你有没有想过,大家都用C语言或基于C语言的语言来写编译器,那么世界上第一个C语言编译器又是怎么编写的呢?这不是一个“鸡和蛋”的问题……
还是让我们回顾一下C语言历史:1970年Tomphson和Ritchie在BCPL(一种解释型语言)的基础上开发了B语言,1973年又在B语言的基础上成功开发出了现在的C语言。在C语言被用作系统编程语言之前,Tomphson也用过B语言编写过操作系统。可见在C语言实现以前,B语言已经可以投入实用了。因此第一个C语言编译器的原型完全可能是用B语言或者混合B语言与PDP汇编语言编写的。我们现在都知道,B语言的执行效率比较低,但是如果全部用汇编语言来编写,不仅开发周期长、维护难度大,更可怕的是失去了高级程序设计语言必需的移植性。所以早期的C语言编译器就采取了一个取巧的办法:先用汇编语言编写一个C语言的一个子集的编译器,再通过这个子集去递推完成完整的C语言编译器。详细的过程如下:
先创造一个只有C语言最基本功能的子集,记作C0语言,C0语言已经足够简单了,可以直接用汇编语言编写出C0的编译器。依靠C0已有的功能,设计比C0复杂,但仍然不完整的C语言的又一个子集C1语言,其中C0属于C1,C1属于C,用C0开发出C1语言的编译器。在C1的基础上设计C语言的又一个子集C2语言,C2语言比C1复杂,但是仍然不是完整的C语言,开发出C2语言的编译器……如此直到CN,CN已经足够强大了,这时候就足够开发出完整的C语言编译器的实现了。至于这里的N是多少,这取决于你的目标语言(这里是C语言)的复杂程度和程序员的编程能力——简单地说,如果到了某个子集阶段,可以很方便地利用现有功能实现C语言时,那么你就找到N了。下面的图说明了这个抽象过程:
那么这种大胆的子集简化的方法,是怎么实现的,又有什么理论依据呢?先介绍一个概念,“自编译”Self-Compile,也就是对于某些具有明显自举性质的强类型(所谓强类型就是程序中的每个变量必须声明类型后才能使用,比如C语言,相反有些脚本语言则根本没有类型这一说法)编程语言,可以借助它们的一个有限小子集,通过有限次数的递推来实现对它们自身的表述,这样的语言有C、Pascal、Ada等等,至于为什么可以自编译,可以参见清华大学出版社的《编译原理》,书中实现了一个Pascal的子集的编译器。总之,已经有计算机科学家证明了,C语言理论上是可以通过上面说的CVM的方法实现完整的编译器的,那么实际上是怎样做到简化的呢?这张图是不是有点熟悉?对了就是在讲虚拟机的时候见到过,不过这里是CVM(C Language Virtual Machine),每种语言都是在每个虚拟层上可以独立实现编译的,并且除了C语言外,每一层的输出都将作为下一层的输入(最后一层的输出就是应用程序了),这和滚雪球是一个道理。用手(汇编语言)把一小把雪结合在一起,一点点地滚下去就形成了一个大雪球,这大概就是所谓的0生1,1生C,C生万物吧?
下面是C99的关键字:
仔细看看,其实其中有很多关键字是为了帮助编译器进行优化的,还有一些是用来限定变量、函数的作用域、链接性或者生存周期(函数没有)的,这些在编译器实现的早期根本不必加上,于是可以去掉auto, restrict, extern, volatile, const, sizeof, static, inline, register, typedef,这样就形成了C的子集,C3语言,C3语言的关键字如下:
再想一想,发现C3中其实有很多类型和类型修饰符是没有必要一次性都加上去的,比如三种整型,只要实现int就行了,因此进一步去掉这些关键词,它们是:unsigned, float, short, char(char 是 int), signed, _Bool, _Complex, _Imaginary, long,这样就形成了我们的C2语言,C2语言关键字如下:
继续思考,即使是只有18个关键字的C2语言,依然有很多高级的地方,比如基于基本数据类型的复合数据结构,另外我们的关键字表中是没有写运算符的,在C语言中的复合赋值运算符->、运算符的++、– 等过于灵活的表达方式此时也可以完全删除掉,因此可以去掉的关键字有:enum, struct, union,这样我们可以得到C1语言的关键字:
接近完美了,不过最后一步手笔自然要大一点。这个时候数组和指针也要去掉了,另外C1语言其实仍然有很大的冗杂度,比如控制循环和分支的都有多种表述方法,其实都可简化成一种,具体的来说,循环语句有while循环,do…while循环和for循环,只需要保留while循环就够了;分支语句又有if…{}, if…{}…else, if…{}…else if…, switch,这四种形式,它们都可以通过两个以上的if…{}来实现,因此只需要保留if,…{}就够了。可是再一想,所谓的分支和循环不过是条件跳转语句罢了,函数调用语句也不过是一个压栈和跳转语句罢了,因此只需要goto(未限制的goto)。因此大胆去掉所有结构化关键字,连函数也没有,得到的C0语言关键字如下:
只有5个关键字,已经完全可以用汇编语言快速的实现了。通过逆向分析我们还原了第一个C语言编译器的编写过程,也感受到了前辈科学家们的智慧和勤劳!我们都不过是巨人肩膀上的灰尘罢了!0生1,1生C,C生万物,实在巧妙!
========
学习较底层编程:动手写一个C语言编译器 http://blog.jobbole.com/77305/
动手编写一个编译器,学习一下较为底层的编程方式,是一种学习计算机到底是如何工作的非常有效方法。
编译器通常被看作是十分复杂的工程。事实上,编写一个产品级的编译器也确实是一个庞大的任务。但是写一个小巧可用的编译器却不是这么困难。
秘诀就是首先去找到一个最小的可用工程,然后把你想要的特性添加进去。这个方法也是Abdulaziz Ghuloum在他那篇著名的论文“一种构造编译器的捷径”里所提到的办法。不过这个办法确实可行。你只需要按照这篇论文中的第一步来操作,就可以得到一个真正可用的编译器!当然,它只能编译程序语言中的非常小的子集,但是它确实是一个真实可用的编译器。你可以随意的扩展这个编译器,然后从中学到更多更深的知识。
受到这篇文章的鼓舞,我就写了一个C编译器。从某种意义上来说这比写一个scheme的编译器要困难一些(因为你必须去解析C那复杂的语法),但是在某些方面又很便利(你不需要去处理运行时类型)。要写这样一个编译器,你只需要从你那个可用的最小的编译器开始。
对于我写的编译器来说,我把它叫 babyc,我选了这段代码来作为我需要运行的第一个程序:
C
int main() {
return 2;
}
int main() {
return 2;
}
没有变量,没有函数调用,没有额外的依赖,甚至连if语句,循环语句都没有,一切看起来是那么简单。
我们首先需要解析这段代码。我们将使用 Flex 和 Bison 来做到这点。这里有怎么用的例子可以参考,幸好我们的语法是如此简单,下面就是词法分析器:
"{" { return '{'; }
"}" { return '}'; }
"(" { return '('; }
")" { return ')'; }
";" { return ';'; }
[0-9]+ { return NUMBER; }
"return" { return RETURN; }
"int" { return TYPE; }
"main" { return IDENTIFIER; }
"{" { return '{'; }
"}" { return '}'; }
"(" { return '('; }
")" { return ')'; }
";" { return ';'; }
[0-9]+ { return NUMBER; }
"return" { return RETURN; }
"int" { return TYPE; }
"main" { return IDENTIFIER; }
这里是语法分析器:
function:
TYPE IDENTIFIER '(' ')' '{' expression '}'
;
expression:
RETURN NUMBER ';'
;
function:
TYPE IDENTIFIER '(' ')' '{' expression '}'
;
expression:
RETURN NUMBER ';'
;
最终,我们需要生成一些汇编代码。我们将使用32位的X86汇编,因为它非常的通用而且可以很容易的运行在你的机器上。这里有X86汇编的相关网站。
下面就是我们需要生成的汇编代码:
.text
.global _start # Tell the loader we want to start at _start.
_start:
movl $2,%ebx # The argument to our system call.
movl $1,%eax # The system call number of sys_exit is 1.
int $0x80 # Send an interrupt
.text
.global _start # Tell the loader we want to start at _start.
_start:
movl $2,%ebx # The argument to our system call.
movl $1,%eax # The system call number of sys_exit is 1.
int $0x80 # Send an interrupt
然后加上上面的词法语法分析代码,把这段汇编代码写进一个文件里。恭喜你!你已经是一个编译器的编写者了!
Babyc 就是这样诞生的,你可以在这里看到它最开始的样子。
当然,如果汇编代码没办法运行也是枉然。让我们来用编译器生成我们所希望的真正的汇编代码。
Shell
# Here's the file we want to compile.
$ cat return_two.c
#include
int main() {
return 2;
}
# Run the compiler with this file.
$ ./babyc return_two.c
Written out.s.
# Check the output looks sensible.
$ cat out.s
.text
.global _start
_start:
movl $2, %ebx
movl $1, %eax
int $0x80
# Here's the file we want to compile.
$ cat return_two.c
#include
int main() {
return 2;
}
# Run the compiler with this file.
$ ./babyc return_two.c
Written out.s.
# Check the output looks sensible.
$ cat out.s
.text
.global _start
_start:
movl $2, %ebx
movl $1, %eax
int $0x80
非常棒!接着让我们来真正的运行一下编译之后代码来确保它能得到我们所想的结果。
Shell
# Assemble the file. We explicitly assemble as 32-bit
# to avoid confusion on x86_64 machines.
$ as out.s -o out.o --32
# Link the file, again specifying 32-bit.
$ ld -m elf_i386 -s -o out out.o
# Run it!
$ ./out
# What was the return code?
$ echo $?
2 # Woohoo!
# Assemble the file. We explicitly assemble as 32-bit
# to avoid confusion on x86_64 machines.
$ as out.s -o out.o --32
# Link the file, again specifying 32-bit.
$ ld -m elf_i386 -s -o out out.o
# Run it!
$ ./out
# What was the return code?
$ echo $?
2 # Woohoo!
我们踏出了第一步,接下去怎么做就全看你了。你可以按照那篇文章所指导的全部做一遍,然后制作一个更加复杂的编译器。你需要去写一个更加精巧的语法树来生成汇编代码。接下去的几步分别是:(1)允许返回任意的值(比如,return 3; 一些可执行代码);(2)添加对“非”的支持(比如,return ~1; 一些可执行代码)。每一个额外的特性都可以教你关于C语言的更多知识,编译器到底是怎么执行的,以及世界上其他编写编译器的人是如何想的。
这是构建 babyc 的方法。Babyc 现在已经拥有了if语句,循环,变量以及最基础的数据结构。欢迎你来check out它的代码,但是我希望看完我的文章你能够自己动手写一个。
不要害怕底层的一些事情。这是一个非常奇妙的世界。
========
Yacc 与 Lex 快速入门 https://www.ibm.com/developerworks/cn/linux/sdk/lex/
Lex 与 Yacc 介绍
Lex 和 Yacc 是 UNIX 两个非常重要的、功能强大的工具。事实上,如果你熟练掌握 Lex 和 Yacc 的话,它们的强大功能使创建 FORTRAN 和 C 的编译器如同儿戏。Ashish Bansal 为您详细的讨论了编写自己的语言和编译器所用到的这两种工具,包括常规表达式、声明、匹配模式、变量、Yacc 语法和解析器代码。最后,他解释了怎样把 Lex 和 Yacc 结合起来。
Lex 代表 Lexical Analyzar。Yacc 代表 Yet Another Compiler Compiler。 让我们从 Lex 开始吧。
Lex
Lex 是一种生成扫描器的工具。扫描器是一种识别文本中的词汇模式的程序。 这些词汇模式(或者常规表达式)在一种特殊的句子结构中定义,这个我们一会儿就要讨论。
一种匹配的常规表达式可能会包含相关的动作。这一动作可能还包括返回一个标记。 当 Lex 接收到文件或文本形式的输入时,它试图将文本与常规表达式进行匹配。 它一次读入一个输入字符,直到找到一个匹配的模式。 如果能够找到一个匹配的模式,Lex 就执行相关的动作(可能包括返回一个标记)。 另一方面,如果没有可以匹配的常规表达式,将会停止进一步的处理,Lex 将显示一个错误消息。
Lex 和 C 是强耦合的。一个 .lex 文件(Lex 文件具有 .lex 的扩展名)通过 lex 公用程序来传递,并生成 C 的输出文件。这些文件被编译为词法分析器的可执行版本。
Lex 的常规表达式
常规表达式是一种使用元语言的模式描述。表达式由符号组成。符号一般是字符和数字,但是 Lex 中还有一些具有特殊含义的其他标记。 下面两个表格定义了 Lex 中使用的一些标记并给出了几个典型的例子。
用 Lex 定义常规表达式
字符
含义
A-Z, 0-9, a-z
构成了部分模式的字符和数字。
.
匹配任意字符,除了 \n。
-
用来指定范围。例如:A-Z 指从 A 到 Z 之间的所有字符。
[ ]
一个字符集合。匹配括号内的 任意 字符。如果第一个字符是 ^ 那么它表示否定模式。例如: [abC] 匹配 a, b, 和 C中的任何一个。
*
匹配 0个或者多个上述的模式。
+
匹配 1个或者多个上述模式。
?
匹配 0个或1个上述模式。
$
作为模式的最后一个字符匹配一行的结尾。
{ }
指出一个模式可能出现的次数。 例如: A{1,3} 表示 A 可能出现1次或3次。
\
用来转义元字符。同样用来覆盖字符在此表中定义的特殊意义,只取字符的本意。
^
否定。
|
表达式间的逻辑或。
"<一些符号>"
字符的字面含义。元字符具有。
/
向前匹配。如果在匹配的模版中的“/”后跟有后续表达式,只匹配模版中“/”前 面的部分。如:如果输入 A01,那么在模版 A0/1 中的 A0 是匹配的。
( )
将一系列常规表达式分组。
常规表达式举例
常规表达式
含义
joke[rs]
匹配 jokes 或 joker。
A{1,2}shis+
匹配 AAshis, Ashis, AAshi, Ashi。
(A[b-e])+
匹配在 A 出现位置后跟随的从 b 到 e 的所有字符中的 0 个或 1个。
Lex 中的标记声明类似 C 中的变量名。每个标记都有一个相关的表达式。 (下表中给出了标记和表达式的例子。) 使用这个表中的例子,我们就可以编一个字数统计的程序了。 我们的第一个任务就是说明如何声明标记。
标记声明举例
标记
相关表达式
含义
数字(number)
([0-9])+
1个或多个数字
字符(chars)
[A-Za-z]
任意字符
空格(blank)
" "
一个空格
字(word)
(chars)+
1个或多个 chars
变量(variable)
(字符)+(数字)*(字符)*(数字)*
回页首
Lex 编程
Lex 编程可以分为三步:
以 Lex 可以理解的格式指定模式相关的动作。
在这一文件上运行 Lex,生成扫描器的 C 代码。
编译和链接 C 代码,生成可执行的扫描器。
注意: 如果扫描器是用 Yacc 开发的解析器的一部分,只需要进行第一步和第二步。 关于这一特殊问题的帮助请阅读 Yacc和 将 Lex 和 Yacc 结合起来部分。
现在让我们来看一看 Lex 可以理解的程序格式。一个 Lex 程序分为三个段:第一段是 C 和 Lex 的全局声明,第二段包括模式(C 代码),第三段是补充的 C 函数。 例如, 第三段中一般都有 main() 函数。这些段以%%来分界。 那么,回到字数统计的 Lex 程序,让我们看一下程序不同段的构成。
回页首
C 和 Lex 的全局声明
这一段中我们可以增加 C 变量声明。这里我们将为字数统计程序声明一个整型变量,来保存程序统计出来的字数。 我们还将进行 Lex 的标记声明。
字数统计程序的声明
%{
int wordCount = 0;
%}
chars [A-za-z\_\'\.\"]
numbers ([0-9])+
delim [" "\n\t]
whitespace {delim}+
words {chars}+
%%
两个百分号标记指出了 Lex 程序中这一段的结束和三段中第二段的开始。
回页首
Lex 的模式匹配规则
让我们看一下 Lex 描述我们所要匹配的标记的规则。(我们将使用 C 来定义标记匹配后的动作。) 继续看我们的字数统计程序,下面是标记匹配的规则。
字数统计程序中的 Lex 规则
{words} { wordCount++; /*
increase the word count by one*/ }
{whitespace} { /* do
nothing*/ }
{numbers} { /* one may
want to add some processing here*/ }
%%
C 代码
Lex 编程的第三段,也就是最后一段覆盖了 C 的函数声明(有时是主函数)。注意这一段必须包括 yywrap() 函数。 Lex 有一套可供使用的函数和变量。 其中之一就是 yywrap。 一般来说,yywrap() 的定义如下例。我们将在 高级 Lex 中探讨这一问题。
字数统计程序的 C 代码段
void main()
{
yylex(); /* start the
analysis*/
printf(" No of words:
%d\n", wordCount);
}
int yywrap()
{
return 1;
}
上一节我们讨论了 Lex 编程的基本元素,它将帮助你编写简单的词法分析程序。 在 高级 Lex 这一节中我们将讨论 Lex 提供的函数,这样你就能编写更加复杂的程序了。
将它们全部结合起来
.lex文件是 Lex 的扫描器。它在 Lex 程序中如下表示:
$ lex
这生成了 lex.yy.c 文件,它可以用 C 编译器来进行编译。它还可以用解析器来生成可执行程序,或者在链接步骤中通过选项 �ll 包含 Lex 库。
这里是一些 Lex 的标志:
-c表示 C 动作,它是缺省的。
-t写入 lex.yy.c 程序来代替标准输出。
-v提供一个两行的统计汇总。
-n不打印 -v 的汇总。
高级 Lex
Lex 有几个函数和变量提供了不同的信息,可以用来编译实现复杂函数的程序。 下表中列出了一些变量和函数,以及它们的使用。 详尽的列表请参考 Lex 或 Flex 手册(见后文的 资源)。
Lex 变量
yyin
FILE* 类型。 它指向 lexer 正在解析的当前文件。
yyout
FILE* 类型。 它指向记录 lexer 输出的位置。 缺省情况下,yyin 和 yyout 都指向标准输入和输出。
yytext
匹配模式的文本存储在这一变量中(char*)。
yyleng
给出匹配模式的长度。
yylineno
提供当前的行数信息。 (lexer不一定支持。)
Lex 函数
yylex()
这一函数开始分析。 它由 Lex 自动生成。
yywrap()
这一函数在文件(或输入)的末尾调用。 如果函数的返回值是1,就停止解析。 因此它可以用来解析多个文件。 代码可以写在第三段,这就能够解析多个文件。 方法是使用 yyin 文件指针(见上表)指向不同的文件,直到所有的文件都被解析。 最后,yywrap() 可以返回 1 来表示解析的结束。
yyless(int n)
这一函数可以用来送回除了前�n? 个字符外的所有读出标记。
yymore()
这一函数告诉 Lexer 将下一个标记附加到当前标记后。
对 Lex 的讨论就到这里。下面我们来讨论 Yacc...
Yacc
Yacc 代表 Yet Another Compiler Compiler。 Yacc 的 GNU 版叫做 Bison。它是一种工具,将任何一种编程语言的所有语法翻译成针对此种语言的 Yacc 语 法解析器。它用巴科斯范式(BNF, Backus Naur Form)来书写。按照惯例,Yacc 文件有 .y 后缀。编译行如下调用 Yacc 编译器:
$ yacc
在进一步阐述以前,让我们复习一下什么是语法。在上一节中,我们看到 Lex 从输入序列中识别标记。 如果你在查看标记序列,你可能想在这一序列出现时执行某一动作。 这种情况下有效序列的规范称为语法。Yacc 语法文件包括这一语法规范。 它还包含了序列匹配时你想要做的事。
为了更加说清这一概念,让我们以英语为例。 这一套标记可能是:名词, 动词, 形容词等等。为了使用这些标记造一个语法正确的句子,你的结构必须符合一定的规则。 一个简单的句子可能是名词+动词或者名词+动词+名词。(如 I care. See spot run.)
所以在我们这里,标记本身来自语言(Lex),并且标记序列允许用 Yacc 来指定这些标记(标记序列也叫语法)。
终端和非终端符号
终端符号 : 代表一类在语法结构上等效的标记。 终端符号有三种类型:
命名标记: 这些由 %token 标识符来定义。 按照惯例,它们都是大写。
字符标记 : 字符常量的写法与 C 相同。例如, -- 就是一个字符标记。
字符串标记 : 写法与 C 的字符串常量相同。例如,"<<" 就是一个字符串标记。
lexer 返回命名标记。
非终端符号 : 是一组非终端符号和终端符号组成的符号。 按照惯例,它们都是小写。 在例子中,file 是一个非终端标记而 NAME 是一个终端标记。
用 Yacc 来创建一个编译器包括四个步骤:
通过在语法文件上运行 Yacc 生成一个解析器。
说明语法:
编写一个 .y 的语法文件(同时说明 C 在这里要进行的动作)。
编写一个词法分析器来处理输入并将标记传递给解析器。 这可以使用 Lex 来完成。
编写一个函数,通过调用 yyparse() 来开始解析。
编写错误处理例程(如 yyerror())。
编译 Yacc 生成的代码以及其他相关的源文件。
将目标文件链接到适当的可执行解析器库。
用 Yacc 编写语法
如同 Lex 一样, 一个 Yacc 程序也用双百分号分为三段。 它们是:声明、语法规则和 C 代码。 我们将解析一个格式为 姓名 = 年龄 的文件作为例子,来说明语法规则。 我们假设文件有多个姓名和年龄,它们以空格分隔。 在看 Yacc 程序的每一段时,我们将为我们的例子编写一个语法文件。
回页首
C 与 Yacc 的声明
C 声明可能会定义动作中使用的类型和变量,以及宏。 还可以包含头文件。每个 Yacc 声明段声明了终端符号和非终端符号(标记)的名称,还可能描述操作符优先级和针对不同符号的数据类型。 lexer (Lex) 一般返回这些标记。所有这些标记都必须在 Yacc 声明中进行说明。
在文件解析的例子中我们感兴趣的是这些标记:name, equal sign, 和 age。Name 是一个完全由字符组成的值。 Age 是数字。于是声明段就会像这样:
文件解析例子的声明
%
#typedef char* string; /*
to specify token types as char* */
#define YYSTYPE string /*
a Yacc variable which has the value of returned token */
%}
%token NAME EQ AGE
%%
你可能会觉得 YYSTYPE 有点奇怪。但是类似 Lex, Yacc 也有一套变量和函数可供用户来进行功能扩展。 YYSTYPE 定义了用来将值从 lexer 拷贝到解析器或者 Yacc 的 yylval (另一个 Yacc 变量)的类型。 默认的类型是 int。 由于字符串可以从 lexer 拷贝,类型被重定义为 char*。 关于 Yacc 变量的详细讨论,请参考 Yacc 手册(见 资源)。
Yacc 语法规则
Yacc 语法规则具有以下一般格式:
result: components { /*
action to be taken in C */ }
;
在这个例子中,result 是规则描述的非终端符号。Components 是根据规则放在一起的不同的终端和非终端符号。 如果匹配特定序列的话 Components 后面可以跟随要执行的动作。 考虑如下的例子:
param : NAME EQ NAME {
printf("\tName:%s\tValue(name):%s\n", $1,$3);}
| NAME EQ VALUE{
printf("\tName:%s\tValue(value):%s\n",$1,$3);}
;
如果上例中序列 NAME EQ NAME 被匹配,将执行相应的 { } 括号中的动作。 这里另一个有用的就是 $1 和 $3 的使用, 它们引用了标记 NAME 和 NAME(或者第二行的 VALUE)的值。 lexer 通过 Yacc 的变量 yylval 返回这些值。标记 NAME 的 Lex 代码是这样的:
char [A-Za-z]
name {char}+
%%
{name} { yylval = strdup(yytext);
return NAME; }
文件解析例子的规则段是这样的:
文件解析的语法
file : record file
| record
;
record: NAME EQ AGE {
printf("%s is now %s years old!!!", $1, $3);}
;
%%
附加 C 代码
现在让我们看一下语法文件的最后一段,附加 C 代码。 (这一段是可选的,如果有人想要略过它的话:)一个函数如 main() 调用 yyparse() 函数(Yacc 中 Lex 的 yylex() 等效函数)。 一般来说,Yacc 最好提供 yyerror(char msg) 函数的代码。 当解析器遇到错误时调用 yyerror(char msg)。错误消息作为参数来传递。 一个简单的 yyerror( char* ) 可能是这样的:
int yyerror(char* msg)
{
printf("Error: %s
encountered at line number:%d\n", msg, yylineno);
}
yylineno 提供了行数信息。
这一段还包括文件解析例子的主函数:
附加 C 代码
void main()
{
yyparse();
}
int yyerror(char* msg)
{
printf("Error: %s
encountered \n", msg);
要生成代码,可能用到以下命令:
$ yacc _d
这生成了输出文件 y.tab.h 和 y.tab.c,它们可以用 UNIX 上的任何标准 C 编译器来编译(如 gcc)。
命令行的其他常用选项
'-d' ,'--defines' : 编写额外的输出文件,它们包含这些宏定义:语法中定义的标记类型名称,语义的取值类型 YYSTYPE, 以及一些外部变量声明。如果解析器输出文件名叫 'name.c', 那么 '-d' 文件就叫做 'name.h'。 如果你想将 yylex 定义放到独立的源文件中,你需要 'name.h', 因为 yylex 必须能够引用标记类型代码和 yylval变量。
'-b file-prefix' ,'--file-prefix=prefix' : 指定一个所有Yacc输出文件名都可以使用的前缀。选择一个名字,就如输入文件名叫 'prefix.c'.
'-o outfile' ,'--output-file=outfile' : 指定解析器文件的输出文件名。其他输出文件根据 '-d' 选项描述的输出文件来命名。
Yacc 库通常在编译步骤中自动被包括。但是它也能被显式的包括,以便在编译步骤中指定 �ly选项。这种情况下的编译命令行是:
$ cc names> -ly
将 Lex 与 Yacc 结合起来
到目前为止我们已经分别讨论了 Lex 和 Yacc。现在让我们来看一下他们是怎样结合使用的。
一个程序通常在每次返回一个标记时都要调用 yylex() 函数。只有在文件结束或者出现错误标记时才会终止。
一个由 Yacc 生成的解析器调用 yylex() 函数来获得标记。 yylex() 可以由 Lex 来生成或完全由自己来编写。 对于由 Lex 生成的 lexer 来说,要和 Yacc 结合使用,每当 Lex 中匹配一个模式时都必须返回一个标记。 因此 Lex 中匹配模式时的动作一般格式为:
{pattern} { /* do smthg*/
return TOKEN_NAME; }
于是 Yacc 就会获得返回的标记。当 Yacc 编译一个带有 _d 标记的 .y文件时,会生成一个头文件,它对每个标记都有 #define 的定义。 如果 Lex 和 Yacc 一起使用的话,头文件必须在相应的 Lex 文件 .lex中的 C 声明段中包括。
让我们回到名字和年龄的文件解析例子中,看一看 Lex 和 Yacc 文件的代码。
Name.y - 语法文件
%
typedef char* string;
#define YYSTYPE string
%}
%token NAME EQ AGE
%%
file : record file
| record
;
record : NAME EQ AGE {
printf("%s is %s years old!!!\n", $1, $3); }
;
%%
int main()
{
yyparse();
return 0;
}
int yyerror(char *msg)
{
printf("Error
encountered: %s \n", msg);
}
Name.lex - Lex 的解析器文件
%{
#include "y.tab.h"
#include
#include
extern char* yylval;
%}
char [A-Za-z]
num [0-9]
eq [=]
name {char}+
age {num}+
%%
{name} { yylval = strdup(yytext);
return NAME; }
{eq} { return EQ; }
{age} { yylval = strdup(yytext);
return AGE; }
%%
int yywrap()
{
return 1;
}
作为一个参考,我们列出了 y.tab.h, Yacc 生成的头文件。
y.tab.h - Yacc 生成的头文件
# define NAME 257
# define EQ 258
# define AGE 259
我们对于 Lex 和 Yacc的讨论到此为止。今天你想要编译什么语言呢?
========
利用yacc和lex制作一个小的计算器 http://www.tuicool.com/articles/yqumyyi
由于本人是身处弱校,学校的课程没有编译原理这一门课,所以就想看这两章,了解一下编译原理,增加一下自己的软实力。免得被别人鄙视。
一、安装yacc和lex
我是在Windows下使用这两个软件的。所以使用bison代替yacc,用flex代替lex。两者的下载地址是 http://sourceforge.net/projects/winflexbison/ 我的gcc环境是使用以前用过的mingw。我们吧解压后的flex和bison放到mingw的bin目录下。这一步就完成了。
二、编译代码
先编译代码,看一下结果,然后在分析。在这本书中提供的一个网址有书中代码下载。下载地址 http://avnpc.com/pages/devlang 下载后找到mycalc这个文件夹。然后执行下面进行编译
1 bison --yacc -dv mycalc.y -o y.tab.c
2 flex mycalc.l
3 gcc -o mycalc y.tab.c lex.yy.c
三、yacc/lex是什么
一般编程语言的语法处理,都会有以下的过程。
1.词法分析
将源代码分割成若干个记号的处理。
2.语法分析
即从记号构建分析树的处理。分析树也叫作语法树或抽象语法树。
3.语义分析
经过语法分析生成的分析树,并不包含数据类型等语义信息。因此在语义分析阶段,会检查程序中是否含有语法正确但是存在逻辑问题的错误。
4.生成代码
如果是C语言等生成机器码的编译器或Java这样生成字节码的编译器,在分析树构建完毕后会进入代码生成阶段。
例如有下面源代码
1 if(a==10)
2 {
3 printf("hoge\n");
4 }
5 else
6 {
7 printf("piyo\n");
8 }
执行词法分析后,将被分割为如下的记号(每一块就是一个记号)
对此进行语法分析后构建的分析树,如下图所示
执行词法分析的程序称为词法分析器。lex的工作就是根据词法规则自动生成词法分析器。
执行语法分析的程序则称为解析器。yacc就是根据语法规则自动生成解析器的程序。
四、分析计算器代码
1.mycalc.l源代码
1 %{
2 #include
3 #include "y.tab.h"
4
5 int
6 yywrap(void)
7 {
8 return 1;
9 }
10 %}
11 %%
12 "+" return ADD;
13 "-" return SUB;
14 "*" return MUL;
15 "/" return DIV;
16 "\n" return CR;
17 ([1-9][0-9]*)|0|([0-9]+\.[0-9]*) {
18 double temp;
19 sscanf(yytext, "%lf", &temp);
20 yylval.double_value = temp;
21 return DOUBLE_LITERAL;
22 }
23 [ \t] ;
24 . {
25 fprintf(stderr, "lexical error.\n");
26 exit(1);
27 }
28 %%
第一行到第十行是一个定义区块,lex中用 %{...}%定义,这里面代码将原样输出。
第11行到第28行是一个规则区块。语法大概就是前面一部分是使用正则表达式后面一部分是返回匹配到后这一部分是类型标记。大括号里面是动作。例如 ([1-9][0-9]*)|0|([0-9]+\.[0-9]*)是匹配小数,然后对这个小数进行sscanf处理后返回一个DOUBLE_LITERAL类型。
2.mycalc.y 源代码
1 %{
2 #include
3 #include
4 #define YYDEBUG 1
5 %}
6 %union {
7 int int_value;
8 double double_value;
9 }
10 %token DOUBLE_LITERAL
11 %token ADD SUB MUL DIV CR
12 %type expression term primary_expression
13 %%
14 line_list
15 : line
16 | line_list line
17 ;
18 line
19 : expression CR
20 {
21 printf(">>%lf\n", $1);
22 }
23 expression
24 : term
25 | expression ADD term
26 {
27 $$ = $1 + $3;
28 }
29 | expression SUB term
30 {
31 $$ = $1 - $3;
32 }
33 ;
34 term
35 : primary_expression
36 | term MUL primary_expression
37 {
38 $$ = $1 * $3;
39 }
40 | term DIV primary_expression
41 {
42 $$ = $1 / $3;
43 }
44 ;
45 primary_expression
46 : DOUBLE_LITERAL
47 ;
48 %%
49 int
50 yyerror(char const *str)
51 {
52 extern char *yytext;
53 fprintf(stderr, "parser error near %s\n", yytext);
54 return 0;
55 }
56
57 int main(void)
58 {
59 extern int yyparse(void);
60 extern FILE *yyin;
61
62 yyin = stdin;
63 if (yyparse()) {
64 fprintf(stderr, "Error ! Error ! Error !\n");
65 exit(1);
66 }
67 }
上面第13行到第48行,语法规则简化为下面格式
A
: B C
| D
;
即A的定义是B与C的组合,或者为D。
上面的过程可以用一个游戏的方式解释。就是一个数字是定位为DOUBLE_LITERAL类型,通过第45行的规则可以将DOUBLE_LITERAL升级成primary_expression类型,然后通过34行规则可以升级为term类型。又term类型可以升级为expression类型。所以 “2+4” 符合的规则是数字2升级到expression类型,而当数字4升级到term类型时,此时的状态是 expression ADD term 通过第25行的规则可以得到两者的结合,得到一个term类型。(PS:这个时候让我想起了一个动漫,就是数码宝贝,类型可以进行进化,进化,超进化,究极进化,还可以合体进化。<笑>)上面一个专业的叫法是叫做归约。
由于归约情况比较复杂和不好讲,我就截书本上的原图进行讲解吧。
至于左结合或右结合是由写的词法分析器来决定的。例如给出的代码,为什么是右结合呢,是因为用到了递归,所以会首先和低级的类型进行结合,这就是为什么MUL,DIV是term类型,ADD,SUB是expression类型,就是处理优先级的问题。
对于C或Java有这样的一个问题
a+++++b;
我们可以分析为a++ + ++b 为什么编译器还会报错呢?是因为我们如果定义优先级的话,++优先级大于+。那么在代码中就是实现为尽量使++在一起,而不是+优先,如果是+优先的话,那么每次都不会结合为++。所以代码在词法分析器阶段代码就会被分割成a ++ ++ + b ;这样几段。从而错误的。由于词法分析器和解析器是各自独立的。又因为词法分析器先于语法分析器运行。
上面的过程就是这样进行语法分析的。上面的过程虽然简单,但是如果用代码实现就有点困难了。我们使用yacc生成的执行文件就是对上面模拟的执行代码,使用yacc自动生成的。如果我们要自制编程语言的话,那么这个过程就要自己写了。因为有很多细节问题。不过不多说了,我们先了解这个就行。生成后的代码文件是y.tab.c y.tab.h 。生成的代码有几十K呢,我们要了解这个过程还是比较难的。
五、用代码实现词法分析器
该代码在calc/llparser目录下
lexicalanalyzer.c
1 #include
2 #include
3 #include
4 #include "token.h"
5
6 static char *st_line;
7 static int st_line_pos;
8
9 typedef enum {
10 INITIAL_STATUS,
11 IN_INT_PART_STATUS,
12 DOT_STATUS,
13 IN_FRAC_PART_STATUS
14 } LexerStatus;
15
16 void
17 get_token(Token *token)
18 {
19 int out_pos = 0;
20 LexerStatus status = INITIAL_STATUS;
21 char current_char;
22
23 token->kind = BAD_TOKEN;
24 while (st_line[st_line_pos] != '\0') {
25 current_char = st_line[st_line_pos];
26 if ((status == IN_INT_PART_STATUS || status == IN_FRAC_PART_STATUS)
27 && !isdigit(current_char) && current_char != '.') {
28 token->kind = NUMBER_TOKEN;
29 sscanf(token->str, "%lf", &token->value);
30 return;
31 }
32 if (isspace(current_char)) {
33 if (current_char == '\n') {
34 token->kind = END_OF_LINE_TOKEN;
35 return;
36 }
37 st_line_pos++;
38 continue;
39 }
40
41 if (out_pos >= MAX_TOKEN_SIZE-1) {
42 fprintf(stderr, "token too long.\n");
43 exit(1);
44 }
45 token->str[out_pos] = st_line[st_line_pos];
46 st_line_pos++;
47 out_pos++;
48 token->str[out_pos] = '\0';
49
50 if (current_char == '+') {
51 token->kind = ADD_OPERATOR_TOKEN;
52 return;
53 } else if (current_char == '-') {
54 token->kind = SUB_OPERATOR_TOKEN;
55 return;
56 } else if (current_char == '*') {
57 token->kind = MUL_OPERATOR_TOKEN;
58 return;
59 } else if (current_char == '/') {
60 token->kind = DIV_OPERATOR_TOKEN;
61 return;
62 } else if (isdigit(current_char)) {
63 if (status == INITIAL_STATUS) {
64 status = IN_INT_PART_STATUS;
65 } else if (status == DOT_STATUS) {
66 status = IN_FRAC_PART_STATUS;
67 }
68 } else if (current_char == '.') {
69 if (status == IN_INT_PART_STATUS) {
70 status = DOT_STATUS;
71 } else {
72 fprintf(stderr, "syntax error.\n");
73 exit(1);
74 }
75 } else {
76 fprintf(stderr, "bad character(%c)\n", current_char);
77 exit(1);
78 }
79 }
80 }
81
82 void
83 set_line(char *line)
84 {
85 st_line = line;
86 st_line_pos = 0;
87 }
88
89 #if 1
90 void
91 parse_line(char *buf)
92 {
93 Token token;
94
95 set_line(buf);
96
97 for (;;) {
98 get_token(&token);
99 if (token.kind == END_OF_LINE_TOKEN) {
100 break;
101 } else {
102 printf("kind..%d, str..%s\n", token.kind, token.str);
103 }
104 }
105 }
106
107 int
108 main(int argc, char **argv)
109 {
110 char buf[1024];
111
112 while (fgets(buf, 1024, stdin) != NULL) {
113 parse_line(buf);
114 }
115
116 return 0;
117 }
118 #endif
View Code
token.h
1 #ifndef TOKEN_H_INCLUDED
2 #define TOKEN_H_INCLUDED
3
4 typedef enum {
5 BAD_TOKEN,
6 NUMBER_TOKEN,
7 ADD_OPERATOR_TOKEN,
8 SUB_OPERATOR_TOKEN,
9 MUL_OPERATOR_TOKEN,
10 DIV_OPERATOR_TOKEN,
11 END_OF_LINE_TOKEN
12 } TokenKind;
13
14 #define MAX_TOKEN_SIZE (100)
15
16 typedef struct {
17 TokenKind kind;
18 double value;
19 char str[MAX_TOKEN_SIZE];
20 } Token;
21
22 void set_line(char *line);
23 void get_token(Token *token);
24
25 #endif /* TOKEN_H_INCLUDED */
View Code
上面使用的方法是DFA(确定有限状态自动机)
上面的图有些指向error的箭头没有标出,不过这个图就大概描述了这个过程。可以自己baidu一些状态机的知识。
有了这两章的基础就可以自己写个分析器了(作用:以后写应用程序时,要给程序一个配置文件时,可以自己写个脚本进行解析,方便用户书写配置文件。不过现在都使用xml语法了,都还有解析的库呢。都不知道学了以后还有没有机会用到实际中呢)。不过循环和判断就还不能实现。
========