前言
学习必须包含输出,输出过程能整理知识,查漏补缺,锻炼表达能力。
技术人员的工作日常会使用计算机,使用编程语言,使用各种软件,比如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,它们都有点年头了。另外还有新一点的工具Flex和Bison,它们可以看作是新版的Lex和YACC。
在window实现的话就用Flex和Bison,就是编译的命令有一点点不同而已。
需要分别创建Lex和YACC能编译的文件,编译后会生成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一样。
结束
该篇结束,下一篇开始用YACC和Lex搞编译器。