编译[1]开头

54 阅读8分钟

前言

学习必须包含输出,输出过程能整理知识,查漏补缺,锻炼表达能力。

技术人员的工作日常会使用计算机,使用编程语言,使用各种软件,比如vscode,浏览器,数据库等。

底层,在我看来就是往下追问这东西是怎样做出来的/由什么组成/内部做了什么/...。比如问,操作系统做了什么,计算机是如何组成的,软件(机器码)是怎样在硬件上跑起来的,一门语言是如何做出来的,等等。

关于编译

编译包含词法分析,语法分析,语义分析。

编译包含前端,后端。

这些步骤(或组成部分)只不过是前人总结的一些合理组成,有时候要做一件事时可能会直接根据经验直觉想到一些步骤。比如现在我想编译某种语言,可能就自然会想到,类比于理解人类语言,起码要先懂得分词,每句话包含的字符串,肯定都在有意无意中被分割成一个一个词,再由这些词形成一种结构,再对应到这个结构和词所映射到的人类自己约定的含义。

比如下面这句话:

如果我是超人,我就会飞。

当这句话输入大脑,立马会被识别出其中包含的各个词

如果 | 我 | 是 | 超人 | , | 我 | 就会 | 飞 | 。

然后再自动判断结构,这是一个如果语句,大脑遇到'如果'这个敏感词,翻译成如果语句(结构),大脑知道如果语句会包含条件和满足条件时要触发的另一个语句,所以自然要找那个条件表达,我是超人,还有就是满足这个条件时的语句,我就会飞

另外

我 | 是 | 超人

又是另一种结构,那就自然要递归分析,两个名词('我'和'超人')中间隔了一个,立马判断成语句。

点到为止,减少篇幅。

大脑会通过某个词或多个词来判断遇到什么结构,什么语句,这种判断完全是预先定义好的,是生活过程中约定的。

产生式

回到编译某个编程语言这件事,输入的也是字符串,比如输入一个js文件,里面的内容其实就是一条字符串。拿到字符串,先分词,这件事交给词法分析器,还要定义一些结构,比如定义某种语句的结构。

比如:

if_statement: 'if' '(' expr ')' stmts;

这里的表达方式叫产生式,冒号左边是产生式头,右边是产生式体产生式头也叫非终结符,因为它可以展开成别的表示,而不是最终显示的某个字符串。

一个产生式就定义了一种结构,产生式头是这种结构的名字(代表)。这里试图定义一种if语句,由'if'字符串开头,紧跟着一个'('(左括号),然后expr是另一个结构的名字(代表),接着是')'(右括号),最后是一个stmts结构。这里expr表示一种表达式,所以其实还要再写另一个产生式来定义另一种结构,stmts也一样:

expr: expr > num;

这里只是举个例子,至于这个expr产生式里面又用到expr,形成递归,因为我们完全有可能遇到下面这些情况:

10 > 4 // 这是一个expr,而它里面的10也是一个expr

10 + 4 > 6 // 这是一个expr,10 + 4是一个expr,10是一个expr,4是一个expr

其实可以定义更详细的产生式:

rel_expr: add_expr > num;

add_expr: add_expr + num;

add_expr: num;

// 相同的产生式头有多个产生式的话,可以合写在一起,只写一个产生式头,用'|'隔开多个产生式体

add_expr: add_expr + num | num;

同一个产生式头(add_expr)居然定义了两个产生式,这很正常,毕竟有可能是下面的情况

3 + 4 // 这一整个就是一个add_expr
// 1就是一个add_expr,
// 因为add_expr + num依然是一个add_expr
// 那1 + 2也是一个add_expr,
// 同理1 + 2 + 3也是一个add_expr
1 + 2 + 3 + 4 + 5 + 6 + 7 + 8

这里加号'+'就是一个词,num也是一个词,而产生式就是语法结构的定义。至于语义,就是当遇到一个结构时,相应要做什么事情,这也跟人类语言类似,当识别了if语句后,大脑是怎样想的,这个处理就是语义,这也是大脑定义好的处理某个语句的思维。

先尝试

先实践做个简单编译器,利用工具生成词法分析器和语法分析器。实践后,心里有谱,再看一些编译前端的书籍内容时会有更多上下文,更好理解。

工具生成词法分析器和语法分析器,那它们对我们来说就是一个黑箱,以后的学习就要了解黑箱的内部原理,然后自己实现它们。

不同语言有不同的生成工具,有JavaScript,Java,C等等。

用来实践的例子,我用C语言,纯粹是喜欢而已。对应的工具是词法分析器生成工具Lex和语法分析器生成工具YACC,它们都有点年头了。另外还有新一点的工具FlexBison,它们可以看作是新版的LexYACC。 在window实现的话就用FlexBison,就是编译的命令有一点点不同而已。

需要分别创建LexYACC能编译的文件,编译后会生成C语言文件,生成的文件就是对应分析工具的代码,结合另外实现的逻辑代码,就可以编译出一个可执行编译器工具。

Lex

Lex文件代码例子:

%{
#include <stdio.h>
#include "nl.h"
#include "y.tab.h"

int yywrap(void) {
    return 1;
}
%}
%%
"+" return ADD;
"-" return SUB;
"*" return MUL;
"/" return DIV;
"%" return MOD;
"(" return LP;
")" return RP;
"\n" return CR;
([1-9][0-9]*)|"0" {
    Expression *expression = nl_alloc_expression(INT_EXPRESSION);
    sscanf(yytext, "%d", &expression->u.int_value);
    yylval.expression = expression;
    return INT_LITERAL;
}
[0-9]+\.[0-9]+ {
    Expression *expression = nl_alloc_expression(DOUBLE_EXPRESSION);
    sscanf(yytext, "%lf", &expression->u.double_value);
    yylval.expression = expression;
    return DOUBLE_LITERAL;
}
[ \t] ;
. {
    printf("lexical error with unexpected charactor %s\n", yytext);
    exit(1);
}
%%

其中有两行是双百分号字符串

%%

这两行把文件分成3个部分

第一部分中,用'%{'和'%}'包裹的代码会直接搬到生成的C代码文件上,可以在这里引入需要用到的头文件,也可以定义些函数等。

第二部分定义词法分析规则,用一个字符串或正则表达式声明匹配一个词的规则是什么,匹配上之后要做什么。

比如用一个字符串声明一个词规则

"+" return ADD;

意思是匹配上'+'时就返回一个词,ADD是另外定义的一个常量数字,可以在第一部分里面定义这些常量,比如

%{
......
#define ADD 1
#define SUB 2
......
%}
%%
......
%%

但一般会在YACC那边定义,这里就可以直接用。

还可以用一个正则表达式声明一个词规则

[0-9]+\.[0-9]+ {
    Expression *expression = nl_alloc_expression(DOUBLE_EXPRESSION);
    sscanf(yytext, "%lf", &expression->u.double_value);
    yylval.expression = expression;
    return DOUBLE_LITERAL;
}

这里匹配一个浮点数字,匹配上之后要要执行后面跟着的大括号里的逻辑,这个逻辑会直接搬过去生成的C文件里面。

匹配会以最长能匹配上的字符串为结果,比如定义如下两个规则:

"+" return ADD;
"+=" return ADD_ASSIGN;

当出现'+='时,最终结果就是返回ADD_ASSIGN,因为它更长。

词法分析返回的词其实叫token,就是构成语言的最小单位,token包含了一个类型编号,用来区分不同的token,比如这里匹配'+'时返回的ADD,另外还应该有一个字段记录匹配的字符串是'+',因为你可能匹配的是一个标识符,它的类型是另一个常量IDENTIFIER,而它匹配的字符串告诉我们标识符的名字是什么。

Lex中,匹配一个token后,会把匹配的字符串存放在yytext指针指向的位置。比如上面声明的匹配浮点数的规则,匹配后执行的逻辑中有一行

sscanf(yytext, "%lf", &expression->u.double_value);

就是要把yytext的字符串内容转换成浮点数再赋值给另一个变量。

Lex文件的第三部分这里没有写任何内容,这里可以定义些C的函数方法,内容会直接搬到生成的文件上。

YACC

YACC文件代码例子

%{
#include <stdio.h>
#include <stdlib.h>
#include "nl.h"
int yylex();
int yyerror(char const *str);
%}
%union {
    Expression *expression;
}
%token ADD SUB MUL DIV LP RP MOD CR
%token <expression> INT_LITERAL DOUBLE_LITERAL
%type <expression> primary_expression mult_expression add_expression expression
%%
expression_list
    : expression
    | expression_list expression
    ;
expression
    : add_expression CR
    {
        NL_Value v = nl_eval_expression($1);
        nl_print_value(&v);
    }
    | add_expression
    ;
add_expression
    : mult_expression
    | add_expression ADD mult_expression
    {
        $$ = nl_create_binary_expression(ADD_EXPRESSION, $1, $3);
    }
    | add_expression SUB mult_expression
    {
        $$ = nl_create_binary_expression(SUB_EXPRESSION, $1, $3);
    }
    ;
mult_expression
    : primary_expression
    | mult_expression MUL primary_expression
    {
        $$ = nl_create_binary_expression(MUL_EXPRESSION, $1, $3);
    }
    | mult_expression DIV primary_expression
    {
        $$ = nl_create_binary_expression(DIV_EXPRESSION, $1, $3);
    }
    | mult_expression MOD primary_expression
    {
        $$ = nl_create_binary_expression(MOD_EXPRESSION, $1, $3);
    }
    ;
primary_expression
    : SUB primary_expression
    {
        $$ = nl_create_minus_expression($2);
    }
    | LP expression RP
    {
        $$ = $2;
    }
    | INT_LITERAL
    | DOUBLE_LITERAL
    ;
%%
int yyerror(char const *str) {
    extern char *yytext;
    fprintf(stderr, "parse error near %s\n", yytext);
    return 0;
}

同样有两行是双百分号字符串把代码分成3部分

%%

第一部分'%{'和'%}'括住的代码同样是会原样搬到生成的C文件上,YACC还会在第一部分定义类型和token类型。

%union定义一些类型

%union {
    Expression *expression;
}

Expression类型定义在nl.h文件,所以上面也有引入这个文件

#include "nl.h"

%token定义一些token

%token ADD SUB MUL DIV LP RP MOD CR
%token <expression> INT_LITERAL DOUBLE_LITERAL

这里定义的token在生成文件都会定义他们对应的常量数字,用来表示对应类型。而这里出现的<expression>表示该token返回的值是一个expression类型,而这个expression就是在%union里面定义的。

%type声明非终结符(产生式头),并说明对应类型

%type <expression> primary_expression mult_expression add_expression expression

YACC代码第二部分定义语法规则,用产生式来表达,前面介绍过产生式了,同一个产生式头的多个产生式可以合在一起写,多个产生式体用'|'隔开:

比如

add_expression
    : mult_expression
    | add_expression ADD mult_expression
    {
        $$ = nl_create_binary_expression(ADD_EXPRESSION, $1, $3);
    }
    | add_expression SUB mult_expression
    {
        $$ = nl_create_binary_expression(SUB_EXPRESSION, $1, $3);
    }
    ;

每个产生式体里面如果包含别的非终结符,那该非终结符会定义在别的产生式中,产生式体后面跟的大括号就是匹配命中之后要执行的逻辑,而执行逻辑中对$$的赋值就是这次命中匹配要返回的值,而$1对应产生式体中第1个(从1开始数)非终结符或token返回的值,同理,$3对应产生式体中第3个非终结符或token返回的值,他们返回的值的类型就定义在第一部分的%token和%type声明时尖括号中的类型。

YACC文件第3部分的含义跟Lex一样。

结束

该篇结束,下一篇开始用YACCLex搞编译器。

进入下一篇