本篇我们要讲解如何构建词法分析器。
什么是词法分析器
简而言之,词法分析器用于对源码字符串做预处理,以减少语法分析器的复杂程度。
词法分析器以源码字符串为输入,输出为标记流(token stream),即一连串的标记,每个标记通常包括:(token, token value)即标记本身和标记的值。例如,源码中若包含一个数字'998',词法分析器将输出(Number, 998),即(数字,998)。再例如:
2 + 3 * (4 - 5)
=>
(Number, 2) Add (Number, 3) Multiply Left-Bracket (Number, 4) Subtract (Number, 5) Right-Bracket
通过词法分析器的预处理,语法分析器的复杂度会大大降低,这点在后面的语法分析器我们就能体会。如果想一起交流的可以加这个群:941636044 ,有什么问题可以群里面交流,群里面也有一些方便学习C语言C++编程的资料可以给你利用。
词法分析器与编译器
要是深入词法分析器,你就会发现,它的本质上也是编译器。我们的编译器是以标记流为输入,输出汇编代码,而词法分析器则是以源码字符串为输入,输出标记流。
+-------+ +--------+
-- source code --> | lexer | --> token stream --> | parser | --> assembly
+-------+ +--------+
在这个前提下,我们可以这样认为:直接从源代码编译成汇编代码是很困难的,因为输入的字符串比较难处理。所以我们先编写一个较为简单的编译器(词法分析器)来将字符串转换成标记流,而标记流对于语法分析器而言就容易处理得多了。
词法分析器的实现
由于词法分析的工作很常见,但又枯燥且容易出错,所以人们已经开发出了许多工具来生成词法分析器,如lex, flex。这些工具允许我们通过正则表达式来识别标记。
这里注意的是,我们并不会一次性地将所有源码全部转换成标记流,原因有二:
1.字符串转换成标记流有时是有状态的,即与代码的上下文是有关系的。
2.保存所有的标记流没有意义且浪费空间。
所以实际的处理方法是提供一个函数(即前几篇中提到的next()),每次调用该函数则返回下一个标记。
支持的标记
在全局中添加如下定义:
// tokens and classes (operators last and in precedence order)
enum {
Num = 128, Fun, Sys, Glo, Loc, Id,
Char, Else, Enum, If, Int, Return, Sizeof, While,
Assign, Cond, Lor, Lan, Or, Xor, And, Eq, Ne, Lt, Gt, Le, Ge, Shl, Shr, Add, Sub, Mul, Div, Mod, Inc, Dec, Brak
};
这些就是我们要支持的标记符。例如,我们会将=解析为Assign;将==解析为Eq;将!=解析为Ne等等。
所以这里我们会有这样的印象,一个标记(token)可能包含多个字符,且多数情况下如此。而词法分析器能减小语法分析复杂度的原因,正是因为它相当于通过一定的编码(更多的标记)来压缩了源码字符串。
当然,上面这些标记是有顺序的,跟它们在 C 语言中的优先级有关,如*(Mul)的优先级就要高于+(Add)。它们的具体使用在后面的语法分析中会提到。
最后要注意的是还有一些字符,它们自己就构成了标记,如右方括号]或波浪号~等。我们不另外处理它们的原因是:
1.它们是单字符的,即并不是多个字符共同构成标记(如==需要两个字符);
2.它们不涉及优先级关系。
词法分析器的框架
即next()函数的主体:
void next() {
char *last_pos;
int hash;
while (token = *src) {
++src;
// parse token here
}
return;
}
这里的一个问题是,为什么要用while循环呢?这就涉及到编译器(记得我们说过词法分析器也是某种意义上的编译器)的一个问题:如何处理错误?
对词法分析器而言,若碰到了一个我们不认识的字符该怎么处理?一般处理的方法有两种:
指出错误发生的位置,并退出整个程序
指出错误发生的位置,跳过当前错误并继续编译
这个while循环的作用就是跳过这些我们不识别的字符,我们同时还用它来处理空白字符。我们知道,C 语言中空格是用来作为分隔用的,并不作为语法的一部分。因此在实现中我们将它作为“不识别”的字符,这个while循环可以用来跳过它。
换行符
换行符和空格类似,但有一点不同,每次遇到换行符,我们需要将当前的行号加一:
// parse token here
...
if (token == '\n') {
++line;
}
...
宏定义
C 语言的宏定义以字符#开头,如# include <stdio.h>。我们的编译器并不支持宏定义,所以直接跳过它们。
else if (token == '#') {
// skip macro, because we will not support it
while (*src != 0 && *src != '\n') {
src++;
}
}
标识符与符号表
标识符(identifier)可以理解为变量名。对于语法分析而言,我们并不关心一个变量具体叫什么名字,而只关心这个变量名代表的唯一标识。例如int a;定义了变量a,而之后的语句a = 10,我们需要知道这两个a指向的是同一个变量。
基于这个理由,词法分析器会把扫描到的标识符全都保存到一张表中,遇到新的标识符就去查这张表,如果标识符已经存在,就返回它的唯一标识。
那么我们怎么表示标识符呢?如下:
struct identifier {
int token;
int hash;
char * name;
int class;
int type;
int value;
int Bclass;
int Btype;
int Bvalue;
}
这里解释一下具体的含义:
1.token:该标识符返回的标记,理论上所有的变量返回的标记都应该是Id,但实际上由于我们还将在符号表中加入关键字如if,while等,它们都有对应的标记。
2.hash:顾名思义,就是这个标识符的哈希值,用于标识符的快速比较。
3.name:存放标识符本身的字符串。
4.class:该标识符的类别,如数字,全局变量或局部变量等。
5.type:标识符的类型,即如果它是个变量,变量是int型、char型还是指针型。
6.value:存放这个标识符的值,如标识符是函数,刚存放函数的地址。
7.BXXXX:C 语言中标识符可以是全局的也可以是局部的,当局部标识符的名字与全局标识符相同时,用作保存全局标识符的信息。
由上可以看出,我们实现的词法分析器与传统意义上的词法分析器不太相同。传统意义上的符号表只需要知道标识符的唯一标识即可,而我们还存放了一些只有语法分析器才会得到的信息,如type。
由于我们的目标是能自举,而我们定义的语法不支持struct,故而使用下列方式。
Symbol table:
----+-----+----+----+----+-----+-----+-----+------+------+----
.. |token|hash|name|type|class|value|btype|bclass|bvalue| ..
----+-----+----+----+----+-----+-----+-----+------+------+----
|<--- one single identifier --->|
即用一个整型数组来保存相关的ID信息。每个ID占用数组中的9个空间,分析标识符的相关代码如下:
int token_val; // value of current token (mainly for number)
int *current_id,