C4编译器源码解析
skaiuijing
前言
当年在github上有一个非常火的项目,名字叫做c4,有上万star。它是一个c语言的编译器,只用了五百行代码,就实现了编译器自举,可以说是非常厉害。
C4编译器实际上是一个非常小型的编译器,是一个学习编译原理的好素材,它实现了一个简单的 C 语言子集,包含前端和后端,最让人惊艳的就是它能够自举。
其实c4或许称之为解释器更合适。不过无所谓,打个比方,如果说编译器是做一大桌菜然后开吃,那么解释器就是边做菜边吃,这么一看,反正都是吃!(><)
好吧,开个玩笑而已。
还是让笔者简单介绍编译器与解释器的概念,然后再介绍一下c4的整体设计吧。
编译器与解释器
编译器是通过翻译的方式阅读程序并且生成对应平台上的程序,对应的平台根据生成的程序执行对应的操作。
解释器是通过解析源程序的每一个输入并转换为对应平台的指令来执行对应的操作。
不过两种方法并没有高低之分,编译器生成的程序的效率更高,解释器生成的程序更容易排查错误。
像java语言,就是结合了两者的处理。一个java程序先被编译为字节码,再由虚拟机逐个执行。
编译执行流程
词法分析:读入组成源程序的的字符流,例如temp = b +c,我们会将temp映射成词法单词<id,1>,b和c同样这么干,变换后如下:
<id,1> <=><id,2> <+> <id,3>
语法分析:通过创建树形结构(语法树)表示语句,使用递归下降等方式进行解析
语义分析:符号表、类型检查都发生在这一步,会附加对应的语义动作。
中间代码生成:构造中间形式,例如三地址代码
以上就是前端内容了。
机器无关优化:主要通过数据流分析消除冗余。
寄存器分配:将变量分配到机器的物理寄存器中,以减少对内存的访问
机器相关优化:这是考虑具体架构做出的优化,像《Computer Architecture》中就介绍了很多这种技术,笔者印象比较深刻的就是寄存器重命名和机器周期优化了,主要是流水线化算法
代码生成:最终生成目标机器代码或汇编代码,并写入目标文件
编译器的设计思想从整体来看,就是函数式的思想,笔者猜测可能是与编译理论直接源于可计算理论有关,同时,编译器领域是最接近计算机原理的领域,像正则表达式和自动机这些东西,很难想象它们的发源居然来自神经网络理论。
以上就是编译执行流程了,现在让我们来看看C4的代码。
C4源码
MAIN
C4只用了四个函数: next( ), expr( ), stmt( ), main( )
next( ): 词法分析器,解析标识符(由字母、数字和下划线组成),解析数字等并转化为标记。
expr( ): 语法分析器中解析表达式的部分,根据不同的文法,解析并生成表达式的中间代码。主要是运算符的解析为主,例如解引用,+,-,/,*,在这一步,被解析的程序将会被构造为中间形式,C4的中间形式是虚拟机可识别的指令。
stmt( ):解析和执行不同类型的语句,例如if、while等语句。
main( ): C4的编码风格比较简陋,排版不是非常美观,main函数不仅完成分配符号表、文本区、数据区和栈区的内存,虚拟机也被放在了main函数中,语法分析的一部分也被放在了main函数,对每个声明,确定基本类型、处理指针、检测重复定义,并添加到符号表,最后运行虚拟机。
框架如下:
1 | graph LR |
词法分析
各个词法单元:
1 | // tokens and classes (operators last and in precedence order) |
词法分析器的返回是tk,它是一个全局变量,那么也很容易想象语法分析器的构造过程了,先判断tk的值,获取类型后生成对应中间形式。
词法分析器入口如下,第一个解析的标识符是”\n”,while(le < e)是判断文本指针位置打印,之后是解析’#’,当然,由于C4不支持宏定义,因此也跳过,当获取词素与支持的类型没有匹配时,词法分析器会跳过,例如空格符:
1 | void next() |
当词法分析器运行到这里,意味着能够与标识符匹配的词素出现了,于是计算并保存哈希值,然后把数组地址往后偏移一个单独符号表的大小,这样就可以继续存储新的词素:
1 | else if ((tk >= 'a' && tk <= 'z') || (tk >= 'A' && tk <= 'Z') || tk == '_') { |
对这些数字、字符串和注释的解析如下,(tk & 15)这种写法,其实就是%16,也就是解析十六进制,这种写法很常见:
1 | else if (tk >= '0' && tk <= '9') { |
解析单目运算符和标点符号·:
1 | else if (tk == '=') { if (*p == '=') { ++p; tk = Eq; } else tk = Assign; return; } |
语法分析
C4的语法分析分为声明(declaration)、表达式(expr)和语句(stmt)的解析。
常见的解析手段有通用、自顶向下和自底向上三种,C4采用的是自顶向下解析。
语法分析在C4中的框架如下:
1 | graph LR |
还有一个重要的部分,那就是符号表:
1 | graph LR |
解析声明
词法分析已经帮助我们捕捉到了词素的类型,现在该语法分析登场了,首先是解析全局声明,这些类型有:int、char、enum、函数
C4采用自顶向下解析,但是本身并不支持递归,所以不能使用递归下降进行解析。
自顶向下解析:
假设我们有文法如下:
1 | E->TE' |
自顶向下解析如下:
1 | graph TD |
其实就是树的遍历展开:
1 | graph TD |
推完左边再推右边就行了,就不多展示了。
文法
int和char的文法不需要过多讲述,重点讲一讲enum和函数的文法:
enum文法:
1 | <EnumDecl> ::= "enum" <EnumName> "{" <EnumList> "}" |
把C4中的文法写成一行,这样看起来就简洁多了:
1 | enum的文法:enum_decl ::= 'enum' [identifier] '{' identifier ['=' number] {',' identifier ['=' number] '}' |
函数文法:
1 | <FuncDecl> ::= <ReturnType> <FuncName> "(" <ParamList> ")" "{" <FuncBody> "}" |
以下就是C4中对应的文法解析:
1 | // parse declarations |
声明解析完,接下来就是解析表达式(expr)和语句(stmt),顺便一提,C4的声明解析是放在main函数中进行的,也就是说,C4的声明必须在文法解析之前,像在函数语句后面定义变量的行为,C4是不支持的。
解析表达式
我们从词法分析那里得到了词素的类型,当判断它是表达式后,我们就可以开始解析了,把它转换为虚拟机可识别的指令流,表达式的解析就是一元运算符、二元运算符和三元与算法的解析,例如加减乘除、与或非这些,这些东西的文法就不详细将了,详细地把每种运算符的解析文法都表示出来有点难受,毕竟笔者的分析只是以框架为主。
从框架中我们可以得知,语法分析就是在往内存模型中输出,之后就是虚拟机了,所以解析结果重点是虚拟机指令。
1 |
|
解析语句
C4解析的语句,包括 if 语句、while 语句、return 语句、块语句和表达式语句。
解析文法如下:
1 | <stmt> ::= 'if' '(' <expr> ')' <stmt> ['else' <stmt>] |
对应程序如下:
1 |
|
虚拟机
虚拟机是C4运行的核心, “程序的80%的运行时间花在20%的代码上”,理解了虚拟机,就理解了C4的框架。
现在让我们先看看虚拟机的实现,从虚拟机中我们可以看见C4工作原理,不过再此之前笔者要讲一讲内存模型。
1 | graph LR |
内存模型
c常见的elf文件模型如下:
1 | +-----------------------+ |
这是编译器编译后的文件,可以看出不同的内存段存储不同信息。
同理,C4也实现了类似的内存模型,让我们看看main函数:
C4的内存模型并不是严格的elf文件格式,只有text段、数据段、栈。
1 | int main(int argc, char **argv) |
虚拟机的执行
虚拟机会从text段开始,一个个指令读,然后实现对应的操作,
C4的虚拟机实现具体如下:
1 | // setup stack |
举个例子,我们要计算1+1,要怎么写指令呢?虚拟机会怎么执行呢?
1 |
|
如上所示,虚拟机执行步骤如下:
加载立即数 1并压入栈:
1 | text[i++] = IMM; text[i++] = 1; |
再加载:
1 | text[i++] = IMM; text[i++] = 1; |
栈顶两个数相加:
1 | text[i++] = ADD; |
将结果存储到位置 0:
1 | text[i++] = SI; text[i++] = 0; |
从位置 0 加载值到栈顶:
1 | text[i++] = LI; text[i++] = 0; |
打印栈顶值:
1 | text[i++] = PRTF; text[i++] = 0; |
退出程序:
1 | text[i++] = EXIT; |
虚拟机的x86版本
1 | while (pc <= e) { |
赋值给je的十六进制是x86架构下的操作码,这样C4就能解析x86下的部分c语言文件了。
让我们再回头看一下C4框架:
1 | graph LR |
词法分析会捕捉每一个词法单元,也就是词素,然后它会返回对应的结果,语法分析根据返回的结果,可以确定是哪一个类型的词素并执行对应的操作,解析的结果会被放在内存模型中,当解析完后,内存模型也就完成了编译的写入,然后虚拟机开始干活了,它根据内存模型内的信息,从text段开始执行,text段全部都是指令,当然,肯定有读者好奇,怎么从数据段访问那些变量?这个不必担心,全局和静态变量在编译时已经分配了特定的内存地址。在程序执行时,虚拟机通过这些地址访问变量。