编译技术(1)

128 阅读8分钟

编译技术(1)

本人之前有写编译器前端的经验,手写过词法分析和语法分析以及构建语法树,希望通过学习编译的框架来增加技术深度。


1. 从Flex和Bison开始

1.1 flex文件的构成

flex用于生成词法分析器。这个所谓的被生成的词法分析器,如果在Java语境中,我们很容易理解他应该就是一个包含了一些公共方法的class。但是在C语境下,没有对象的概念,这个词法分析器其实就是一些C函数。我们接下来可以写一段flex示例程序,然后用flex工具编译成C文件。

参考 Flex & Bison 这本书,我写出了我的第一个.l文件,没想到第一个就踩了巨坑。

给你看下我写的文件

%{
  // 这部分会被完整的copy到生成的C文件的开头
  int chars = 0;
  int words = 0;
  int lines = 0;
%}
​
%%
  // 第二部分,这部分会定义我们生成的词法生成器的行为
  // 这部分可能需要一点正则表达式基础,多写就背下来了
​
  // words和chars是我们前面定义的, yytext是匹配这个规则的char*
  [a-zA-Z]+   { ++words; chars += strlen(yytext); }
  \n    { ++lines; ++chars;}
  .   { ++chars; }
%%
​
// 我们可以在这里写main函数
int main(int argc, char *args[])
{
  yylex();
  printf("%8d%8d%8d\n", lines, words, chars);
}

看起来是不是非常合理,完全符合lex文件的规范!...吗?

让我们编译他试试。

➜  demo flex test.l
➜  demo ls
lex.yy.c test.l

目前看起来没啥问题。

然后我们用clang编译这个文件,看看能不能得到正常的a.out

➜  demo gcc lex.yy.c -ll
test.l:14:2: error: expected expression
   14 |         [a-zA-Z]+       { ++words; chars += strlen(yytext); }
      |         ^
1 error generated.

我们发现非常诡异的报错了, 他竟然说在test.l里面有错误?

我的第一反应是,clang直接读了我的test.l文件?你还懂这个?而且我哪里给你输入了test.l?我给你输入的是lex.yy.c好不好!

然后打开lex.yy.c文件发现这个.c文件竟然直接把这一行写进去了

 666 #line 10 "test.l"
 667         // 第二部分,这部分会定义我们生成的词法生成器的行为
 668         // 这部分可能需要一点正则表达式基础,多写就背下来了
 669
 670         // words和chars是我们前面定义的, yytext是匹配这个规则的char*
 671         [a-zA-Z]+       { ++words; chars += strlen(yytext); }
 672         \n              { ++lines; ++chars;}
 673         .               { ++chars; }
 674 #line 674 "lex.yy.c"

看到flex奇妙的生成逻辑,我愣住了几秒,这种东西是可以直接copy到C文件里的吗,怪不得报错。

经过查阅资料,我知道这里是一段对错误报错方法的重定位,所以我才会看到test.l的某一行的错误。

我玩了一下这个东西,发现有点意思,他就是单纯为了错误重定位的,甚至他根本不关心你定位到的文件是否真的存在,给你看一段实例代码你就懂了。

#include <stdio.h>
​
​
int main() {
  #line 10 "notexist.c"
  yes, error!
}

报错信息如下:

➜  /tmp gcc err.c
notexist.c:10:2: error: use of undeclared identifier 'yes'
   10 |         yes, error!
      |         ^
notexist.c:10:7: error: use of undeclared identifier 'error'
   10 |         yes, error!
      |              ^
2 errors generated.

有点意思!没想到刚开始学习flex就学到这种黑科技。

扯远了扯远了回到我们刚才的问题。查阅资料之后知道Flex文件的缩进不能乱写。

这个文件分成了三个部份(参考上面的注释)

中间的部分是flex需要读取的,并且生成代码的,所以要严格控制格式。必须直接写在行开头,不能缩进。

上下部分是直接copy到开头和最后的,满足C语言格式要求就行了。

那么问题来了, 既然问题出在中间,我的格式不对,flex干嘛不提醒我,还有模有样的生成了一个.c文件。

因为flex的报错检测和信息是真的简陋,你们可以试一下不写 %% 或者别的结构,他也会报错,但是信息类似这样。

➜  demo flex test.l
test.l:18: unrecognized rule
test.l:18: unrecognized rule
test.l:18: unrecognized rule
test.l:21: unrecognized rule
test.l:21: unrecognized rule
test.l:21: unrecognized rule
test.l:22: unrecognized rule

我调整了test.l的格式,把%%块里的内容删了,发现生成的C文件可以正常编译了。

我们来diff看下,删不删缩进会影响什么。

➜  demo diff test-error.yy.c test-nice.yy.c
...(不重要内容)
733a731,746
> #line 14 "test.l"
> { ++words; chars += strlen(yytext); }
>   YY_BREAK
> case 2:
> /* rule 2 can match eol */
> YY_RULE_SETUP
> #line 15 "test.l"
> { ++lines; ++chars;}
>   YY_BREAK
> case 3:
> YY_RULE_SETUP
> #line 16 "test.l"
> { ++chars; }
>   YY_BREAK
> case 4:
> YY_RULE_SETUP
​
...(不重要内容)

我们发现,格式正常之后,就多了这些合法的C语句。而另一边则直接将我们带有缩进的内容放到了C文本中,怪不得报错。

再次测试发现没啥问题,可以正常统计了。

好,现在提一个新问题。我能不能在我自己的C代码里使用生成的C代码,因为我不想把我的主函数逻辑写进去,答案是完全可以做到。

(留作课后习题)

接下来我们要做一个简单的计算器程序。我们先来写一个Flex来获取需要的token。

作为一个计算器,我们的token就包括数字和符号,开写!

%{
%}

%%
[0-9]+	{ printf("NUMBER_TOKEN -> %s\n", yytext); }
"+"	{ printf("PLUS\n"); }
"-"	{ printf("SUB\n"); }
"*"	{ printf("MUL\n"); }
"/"	{ printf("DIV\n"); }

" "	{ }
\n	{ }
.	{ printf("Unresolved character -> %s\n", yytext); }

%%

int main(int argc, char *args[])
{
	yylex();
}

调试看看:

➜  calculator ./a.out
1 + 3
NUMBER_TOKEN -> 1
PLUS
NUMBER_TOKEN -> 3
12 + 23123
NUMBER_TOKEN -> 12
PLUS
NUMBER_TOKEN -> 23123
dwdwfw
Unresolved character -> d
Unresolved character -> w
Unresolved character -> d
Unresolved character -> w
Unresolved character -> f
Unresolved character -> w

效果还可以。我们可以正常解析计算器里的token了,下一步就是利用这解析到的token,实际计算结果。因此我们要用到Bison。第一阶段的flex的学习结束啦!

1.2 bison文件的构成

在正式开始写bison文件之前, 我们可以看下一下我们刚才写的flex文件。 我们发现了一个问题, 我们只是每次碰到我们需要的token就打印了出来,没有真的把信息收集下来,也就无法做后续的语法分析。所以我们要对本来的flex文件进行改良。

于是我写出了这样的代码:

%{
enum TokenType {
	NUM = 259,
	ADD = 260,
	SUB = 261,
	MUL = 262,
	DIV = 263,
	UNRESOLVED = 264
};

int num = 0;
%}

%%
[0-9]+	{ num = atoi(yytext); return NUM; }
"+"	{ return ADD; }
"-"	{ return SUB; }
"*"	{ return MUL; }
"/"	{ return DIV; }

" "	{ /* do nothing */ }
\n	{ /* do nothing */ }
.	{ return UNRESOLVED; }

%%

int main(int argc, char *args[])
{
	int token = 0;
	do {
		if (token == NUM) {
			printf("number = %d\n", num);
		} else if (token == UNRESOLVED) {
			printf("unresolved symbol -> %s\n", yytext);
		}
	} while(token = yylex());
}

实际上我们可以不用bison,直接在main函数里去写计算的逻辑。但是复杂任务就必须用bison来简化逻辑。

接下来聊聊bison文件的写法以及flex和bison联合编译。

bison文件分成了四个部分, 相比flex文件多了一个bison声明区。还是看一段示例代码来学习:

%{
// 这里用来定义头文件或者全局变量之类的内容
%}

// 这里定义token的类型,bison会自动生成enum
// 不做任何配置的话就是int类型
%token NUMBER
%token ADD SUB MUL DIV
%token EOL

%%

// Bison的语法分析规则
// 出现在这里的左端的是语法单元, 如果不做特殊配置,默认是int类型。
calc_list: /* nothing */
	| calc_list exp EOL { printf("= %d\n", $2); }

// $$	指代的是左侧的语法单元, $n是右侧的第n个语法单元
// noting: 这里全部都是int类型,所以可以自由加减乘除,但是$n不一定是int
exp: factor { $$ = $1; }
	| exp ADD factor { $$ = $1 + $3; }
	| exp SUB factor { $$ = $1 - $3; }

factor: term {$$ = $1; }
	| factor MUL term { $$ = $1 * $3; }
	| factor DIV term { $$ = $1 / $3; }

term: NUMBER { $$ = $1; }

%%

我们可以看一下这一段bison代码,生成的C代码的。

注意bison会生成两个C文件,一个是头文件,一个是实现文件。我们这里先看头文件。

/* A Bison parser, made by GNU Bison 2.3.  */

/* Skeleton interface for Bison's Yacc-like parsers in C

   Copyright (C) 1984, 1989, 1990, 2000, 2001, 2002, 2003, 2004, 2005, 2006
   Free Software Foundation, Inc.

   This program is free software; you can redistribute it and/or modify
   it under the terms of the GNU General Public License as published by
   the Free Software Foundation; either version 2, or (at your option)
   any later version.

   This program is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   GNU General Public License for more details.

   You should have received a copy of the GNU General Public License
   along with this program; if not, write to the Free Software
   Foundation, Inc., 51 Franklin Street, Fifth Floor,
   Boston, MA 02110-1301, USA.  */

/* As a special exception, you may create a larger work that contains
   part or all of the Bison parser skeleton and distribute that work
   under terms of your choice, so long as that work isn't itself a
   parser generator using the skeleton or a modified version thereof
   as a parser skeleton.  Alternatively, if you modify or redistribute
   the parser skeleton itself, you may (at your option) remove this
   special exception, which will cause the skeleton and the resulting
   Bison output files to be licensed under the GNU General Public
   License without this special exception.

   This special exception was added by the Free Software Foundation in
   version 2.2 of Bison.  */

/* Tokens.  */
#ifndef YYTOKENTYPE
# define YYTOKENTYPE
   /* Put the tokens into the symbol table, so that GDB and other debuggers
      know about them.  */
	 // 可以看到这里根据我们刚才写的规则生成了enum
	 // 但是C条件下,我们其实不会查找这里
   enum yytokentype {
     NUMBER = 258,
     ADD = 259,
     SUB = 260,
     MUL = 261,
     DIV = 262,
     EOL = 263
   };
#endif
/* Tokens.  */
// 这里还有一份,兼容C代码的
// 举个简单例子如果我们在C代码里写了token == NUMBER
// 拿起是相当于token == 258(这里定义的)
// 并不会经过enum查找
#define NUMBER 258
#define ADD 259
#define SUB 260
#define MUL 261
#define DIV 262
#define EOL 263




#if ! defined YYSTYPE && ! defined YYSTYPE_IS_DECLARED
typedef int YYSTYPE;
# define yystype YYSTYPE /* obsolescent; will be withdrawn */
# define YYSTYPE_IS_DECLARED 1
# define YYSTYPE_IS_TRIVIAL 1
#endif

extern YYSTYPE yylval;

到此为止, 我们拥有了语法分析器。

刚才有提到我们除了生成一个头文件,还有一个具体的实现文件,那么他是否可以直接编译使用呢?

这个时候就有同学要说了: “他肯定没办法使用呀,因为你没写main文件。”

能想到这一点说明C基础不错,而且已经理解了bison在干的事情的本质了。

➜  calculator gcc calc.tab.c
calc.tab.c:1234:16: error: call to undeclared function 'yylex'; ISO C99 and later do not support implicit function declarations [-Wimplicit-function-declaration]
 1234 |       yychar = YYLEX;
      |                ^
calc.tab.c:590:16: note: expanded from macro 'YYLEX'
  590 | # define YYLEX yylex ()
      |                ^
calc.y:12:7: error: call to undeclared library function 'printf' with type 'int (const char *, ...)'; ISO C99 and later do not support implicit function declarations [-Wimplicit-function-declaration]
   12 |     { printf("= %d\n", (yyvsp[(2) - (3)])); ;}
      |       ^
calc.y:12:7: note: include the header <stdio.h> or explicitly provide a declaration for 'printf'
calc.tab.c:1392:7: error: call to undeclared function 'yyerror'; ISO C99 and later do not support implicit function declarations [-Wimplicit-function-declaration]
 1392 |       yyerror (YY_("syntax error"));
      |       ^
calc.tab.c:1538:3: error: call to undeclared function 'yyerror'; ISO C99 and later do not support implicit function declarations [-Wimplicit-function-declaration]
 1538 |   yyerror (YY_("memory exhausted"));
      |   ^
4 errors generated.

但报错并不是这里,因为就算我们写了main函数也会报错。

这里报了很多undeclared function的问题。

其中包括 yylex yyerror printf

printf 是stdio里的函数,所以我们在bison里加入#include <stdio.h>就可以了。

那么另外两个函数没有怎么办呢?

第一个办法,既然他需要这两个函数,那我们自己写就可以了。

但看到这个熟悉的名字,我们当然更应该使用flex生成。

所以bison一般情况下是要配合flex使用的,flex可以单独使用。

除非你想自己把bison使用到的flex的接口实现一遍。

2.3 联合编译

现在我们基本弄清楚了flex和bison的文件结构,以及这两个工具分别干的事情。接下来我就来实现我们的计算器项目,利用bison和flex联合编译。

首先看我们的bison文件:

// calc.y
%{
// 在bison的语法分析模块使用了printf
// macos使用的是clang16,所以不能隐式声明, 必须要加上这一句
#include <stdio.h>

// bison生成的C文件里会使用这个,要提前声明(虽然我们会实现)
void yyerror(const char *);

// bison生成的C文件里会使用这个,这个函数是flex自动生成的
int yylex();
%}

// 每个语法单元或最小单元的值
// 可以通过token <XX>指ding
// 生成的C文件的union名为yylval, 所以在flex侧可以通过yylval.val赋值
%union {
	int val;
}
// 定义最小单元的返回值
%token <val> NUMBER
%token <val> ADD SUB MUL DIV
%token EOL

// 定义语法单元的返回值
%type <val> exp factor term
%%

// 这里EOL存在的意义在于能完整的识别出来一个exp并且进行printf
// 这个EOL的目的在于告诉parser: “我已经输入了一个完整的表达式”
// 一般我们通过输入回车来做到这件事,所以在flex侧我们需要将回车的结果设置为EOL
calc_list: exp EOL { printf("= %d\n", $1); return 0; }

exp: factor { $$ = $1; }
	| exp ADD factor { $$ = $1 + $3; }
	| exp SUB factor { $$ = $1 - $3; }

factor: term {$$ = $1; }
	| factor MUL term { $$ = $1 * $3; }
	| factor DIV term { $$ = $1 / $3; }

term: NUMBER { $$ = $1; }

%%

// 必须手动实现的方法
void yyerror(const char *str)
{
	fprintf(stderr, "Error: %s\n", str);
	return ;
}

// 测试程序的入口
int main(int argc, char *args[])
{
	yyparse();
}

然后我们看看与他配合的flex文件:

// clac.l
%{
// 我们需要先编译bison文件, 这样我们下面才能正常返回token
// 因为这些token是在bison里定义的
#include "calc.tab.h"

%}

%%
[0-9]+	{ yylval.val = atoi(yytext); return NUMBER; }
"+"	{ return ADD; }
"-"	{ return SUB; }
"*"	{ return MUL; }
"/"	{ return DIV; }
\n	{ return EOL; }
.	{ /* do nothing */ }

%%

效果:


# 执行make命令
➜  calculator make
bison -d calc.y
flex calc.l
gcc calc.tab.c lex.yy.c -ll -o calc
ld: warning: object file (/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/lib/libl.a[arm64][3](libyywrap.o)) was built for newer 'macOS' version (15.4) than being linked (15.0)


# 使用我们的计算器
➜  calculator ./calc
# 这是我的输入
1 + 2
# 这是回车之后呈现的结果
= 3

(这里的Makefile就自己想办法吧,非常简单)

2. 一些想法 & 参考资料

有了flex和bison之后,我们可以非常快速地构建出具备词法分析和语法分析能力的前端处理器,从而正式进入编译原理的实践阶段。

但与此同时,我们也观察到,哪怕是为一个简单的文法生成分析器,flex和bison所生成的C代码量都相对庞大且结构复杂。这说明它们不仅仅是“代码模板工具”,而是包含完整自动机构造与语法表驱动器的生成框架。

若想深入研究编译技术,尤其是构建自定义语言工具链,我们必须关注它们的底层实现机制,主要包括两个方面:

  1. flex与bison所生成代码的框架结构
  2. flex与bison的分析器生成机制

以及一个更深的思考,我们如何实现一个简易的flex和bison?

参考书目:

Flex & Bison