自己动手制作C 语言编译器(3):词法分析器

本章我们要讲解如何构建词法分析器。

什么是词法分析器

简而言之,词法分析器用于对源码字符串做预处理,以减少语法分析器的复杂程度。

词法分析器以源码字符串为输入,输出为标记流(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 。我们的编译器并不支持宏定义,所以直接跳过它们。

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,              // current parsed ID

    *symbols;                 // symbol table


// fields of identifier

enum {Token, Hash, Name, Type, Class, Value, BType, BClass, BValue, IdSize};


void next() {

        ...


        else if ((token >= 'a' && token <= 'z') || (token >= 'A' && token <= 'Z') || (token == '_')) {


            // parse identifier

            last_pos = src - 1;

            hash = token;


            while ((*src >= 'a' && *src <= 'z') || (*src >= 'A' && *src <= 'Z') || (*src >= '0' && *src <= '9') || (*src == '_')) {

                hash = hash * 147 + *src;

                src++;

            }


            // look for existing identifier, linear search

            current_id = symbols;

            while (current_id[Token]) {

                if (current_id[Hash] == hash && !memcmp((char *)current_id[Name], last_pos, src - last_pos)) {

                    //found one, return

                    token = current_id[Token];

                    return;

                }

                current_id = current_id + IdSize;

            }


            // store new ID

            current_id[Name] = (int)last_pos;

            current_id[Hash] = hash;

            token = current_id[Token] = Id;

            return;

        }

        ...

}

查找已有标识符的方法是线性查找symbols表。

数字

数字中较为复杂的一点是需要支持十进制、十六进制及八进制。逻辑也较为直接,可能唯一不好理解的是获取十六进制的值相关的代码。

token_val = token_val * 16 + (token & 16) + (token >= 'A' ? 9 : 0);

这里要注意的是在ASCII码中,字符a对应的十六进制值是61,A是41,故通过(token & 16)可以得到个位数的值。其它就不多说了,这里这样写的目的是装B(其实是抄 c4 的源代码的)。

void next() {

        ...

        else if (token >= '0' && token <= '9') {

// parse number, three kinds: dec(123) hex(0x123) oct(017)

token_val = token - '0';

if (token_val > 0) {

// dec, starts with [1-9]

while (*src >= '0' && *src <= '9') {

token_val = token_val*10 + *src++ - '0';

}

} else {

// starts with number 0

if (*src == 'x' || *src == 'X') {

//hex

token = *++src;

while ((token >= '0' && token <= '9') || (token >= 'a' && token <= 'f') || (token >= 'A' && token <= 'F')) {

token_val = token_val * 16 + (token & 15) + (token >= 'A' ? 9 : 0);

token = *++src;

}

} else {

// oct

while (*src >= '0' && *src <= '7') {

token_val = token_val*8 + *src++ - '0';

}

}

}


token = Num;

return;

}

        ...

}

字符串

在分析时,如果分析到字符串,我们需要将它存放到前一篇文章中说的data段中。然后返回它在data段中的地址。另一个特殊的地方是我们需要支持转义符。例如用\n表示换行符。由于本编译器的目的是达到自己编译自己,所以代码中并没有支持除\n的转义符,如\t,\r等,但仍支持\a表示字符a的语法,如\"表示"。

在分析时,我们将同时分析单个字符如'a'和字符串如"a string"。若得到的是单个字符,我们以Num的形式返回。相关代码如下:

void next() {

        ...


        else if (token == '"' || token == '\'') {

            // parse string literal, currently, the only supported escape

            // character is '\n', store the string literal into data.

            last_pos = data;

            while (*src != 0 && *src != token) {

                token_val = *src++;

                if (token_val == '\\') {

                    // escape character

                    token_val = *src++;

                    if (token_val == 'n') {

                        token_val = '\n';

                    }

                }


                if (token == '"') {

                    *data++ = token_val;

                }

            }


            src++;

            // if it is a single character, return Num token

            if (token == '"') {

                token_val = (int)last_pos;

            } else {

                token = Num;

            }


            return;

        }

}

注释

在我们的 C 语言中,只支持//类型的注释,不支持/* comments */的注释。

void next() {

        ...


        else if (token == '/') {

            if (*src == '/') {

                // skip comments

                while (*src != 0 && *src != '\n') {

                    ++src;

                }

            } else {

                // divide operator

                token = Div;

                return;

            }

        }


        ...

}

这里我们要额外介绍lookahead的概念,即提前看多个字符。上述代码中我们看到,除了跳过注释,我们还可能返回除号/(Div)标记。

提前看字符的原理是:有一个或多个标记是以同样的字符开头的(如本小节中的注释与除号),因此只凭当前的字符我们并无法确定具体应该解释成哪一个标记,所以只能再向前查看字符,如本例需向前查看一个字符,若是/则说明是注释,反之则是除号。

我们之前说过,词法分析器本质上也是编译器,其实提前看字符的概念也存在于编译器,只是这时就是提前看k个“标记”而不是“字符”了。平时听到的LL(k)中的k就是需要向前看的标记的个数了。

另外,我们用词法分析器将源码转换成标记流,能减小语法分析复杂度,原因之一就是减少了语法分析器需要“向前看”的字符个数。

其它

其它的标记的解析就相对容易一些了,我们直接贴上代码:

void next() {

        ...


        else if (token == '=') {

            // parse '==' and '='

            if (*src == '=') {

                src ++;

                token = Eq;

            } else {

                token = Assign;

            }

            return;

        }

        else if (token == '+') {

            // parse '+' and '++'

            if (*src == '+') {

                src ++;

                token = Inc;

            } else {

                token = Add;

            }

            return;

        }

        else if (token == '-') {

            // parse '-' and '--'

            if (*src == '-') {

                src ++;

                token = Dec;

            } else {

                token = Sub;

            }

            return;

        }

        else if (token == '!') {

            // parse '!='

            if (*src == '=') {

                src++;

                token = Ne;

            }

            return;

        }

        else if (token == '<') {

            // parse '<=', '<<' or '<'

            if (*src == '=') {

                src ++;

                token = Le;

            } else if (*src == '<') {

                src ++;

                token = Shl;

            } else {

                token = Lt;

            }

            return;

        }

        else if (token == '>') {

            // parse '>=', '>>' or '>'

            if (*src == '=') {

                src ++;

                token = Ge;

            } else if (*src == '>') {

                src ++;

                token = Shr;

            } else {

                token = Gt;

            }

            return;

        }

        else if (token == '|') {

            // parse '|' or '||'

            if (*src == '|') {

                src ++;

                token = Lor;

            } else {

                token = Or;

            }

            return;

        }

        else if (token == '&') {

            // parse '&' and '&&'

            if (*src == '&') {

                src ++;

                token = Lan;

            } else {

                token = And;

            }

            return;

        }

        else if (token == '^') {

            token = Xor;

            return;

        }

        else if (token == '%') {

            token = Mod;

            return;

        }

        else if (token == '*') {

            token = Mul;

            return;

        }

        else if (token == '[') {

            token = Brak;

            return;

        }

        else if (token == '?') {

            token = Cond;

            return;

        }

        else if (token == '~' || token == ';' || token == '{' || token == '}' || token == '(' || token == ')' || token == ']' || token == ',' || token == ':') {

            // directly return the character as token;

            return;

        }


        ...

}

代码较多,但主要逻辑就是向前看一个字符来确定真正的标记。

关键字与内置函数

虽然上面写完了词法分析器,但还有一个问题需要考虑,那就是“关键字”,例如if,while,return等。它们不能被作为普通的标识符,因为有特殊的含义。

一般有两种处理方法:

1.词法分析器中直接解析这些关键字。

2.在语法分析前将关键字提前加入符号表。

这里我们就采用第二种方法,将它们加入符号表,并提前为它们赋予必要的信息(还记得前面说的标识符Token字段吗?)。这样当源代码中出现关键字时,它们会被解析成标识符,但由于符号表中已经有了相关的信息,我们就能知道它们是特殊的关键字。

内置函数的行为也和关键字类似,不同的只是赋值的信息,在main函数中进行初始化如下:

// types of variable/function

enum { CHAR, INT, PTR };

int *idmain;                  // the `main` function


void main() {

    ...


    src = "char else enum if int return sizeof while "

          "open read close printf malloc memset memcmp exit void main";


     // add keywords to symbol table

    i = Char;

    while (i <= While) {

        next();

        current_id[Token] = i++;

    }


    // add library to symbol table

    i = OPEN;

    while (i <= EXIT) {

        next();

        current_id[Class] = Sys;

        current_id[Type] = INT;

        current_id[Value] = i++;

    }


    next(); current_id[Token] = Char; // handle void type

    next(); idmain = current_id; // keep track of main


    ...

    program();

}

小结

本章我们为我们的编译器构建了词法分析器,通过本章的学习,我认为有几个要点需要强调:

1.词法分析器的作用是对源码字符串进行预处理,作用是减小语法分析器的复杂程度。

2.词法分析器本身可以认为是一个编译器,输入是源码,输出是标记流。

3.lookahead(k)的概念,即向前看k个字符或标记。

4.词法分析中如何处理标识符与符号表。

下一章中,我们将介绍递归下降的语法分析器。我们下一章见。如果想一起交流的可以加这个群:941636044 ,有什么问题可以群里面交流,群里面也有一些方便学习C语言C++编程的资料可以给你利用哦!

你可能感兴趣的:(自己动手制作C 语言编译器(3):词法分析器)