自己动手实现编译器实战篇(一) 词法分析器

618 阅读7分钟

前言

千呼万唤始出来,铺垫了那么久,现在咱们正式进入实战环节。本篇重点是实现一个词法分析器,在理论篇 (一),我们知道词法分析器可以将一个字符串序列转换为编程语言自定义的token序列。对于不符合词法的情况,词法分析器需要提供必要的错误信息,至少要包括错误定位功能。所以,此工具初步设计的功能如下:

  • 对工具的具体描述,帮助使用信息
  • 词法文件生成功能
  • 支持多文件批量生成
  • 必要的错误信息提示

准备工作

实现语言选择

这里我们选择函数式编程语言OCaml,在函数式编程语言中,函数是一等公民,这里并没有数据和函数的区别,函数即数据。在函数式编程的世界中,所有数据都是不可变地,这就为逻辑推导、理论证明提供了支持,所以函数式编程语言常用于公式证明、理论证明等领域。从学习难度上讲,函数式语言逻辑贴近正常思维逻辑,我们设计出算法流程,实现是顺水渠成的事。还有一点,强大的数据抽象能力,这为我们设计抽象数据结构提供了强大地支持。

语言设计

我们这门语言的词法和语法介于C和GO之间,从以下几个方面介绍这门语言:

image.png

基础数据结构

image.png

  • 整型interger
  • 布尔型boolean
  • 数组array
  • 字符串string
  • 字符char

操作符

image.png

  • 双目运算 + - * / == != && || =
  • 单目运算 ~ !

逻辑控制结构

image.png

  • 条件控制 if condition-expr then block-expr else block-expr
  • 循环控制 while expr condition-expr block-expr
  • 赋值语句 LET-optional ID COMMA DATA_TYPE = expr IN-optional

正则匹配规则

image.png

关于这块内容,后面我们会详细说明。

依赖库

OCaml词法工具介绍

官方文档里详细说明了工具的用法,我们这里简要说明下,感兴趣的小伙伴自行了解。

文件格式

OCaml文件以ml后缀结尾,词法文件以mll后缀结尾,语法文件以mly后缀结尾,这里其实分别加了lex和yacc的首字母。

文件结构

  • 文件头
  • 声明定义
  • 正则匹配规则

文件头是一些OCaml代码,这些信息会毫无保留地转换为ml文件代码,我们在文件头可以写helper code。

声明定义部分主要定义了正则表达式

匹配规则部分定义了扫描匹配的具体执行逻辑

项目结构

./
├── ast.ml                   //语法树数据结构定义
├── dune                     
├── lexer.mll                //词法文件
├── main.ml                  //主文件
├── parser.mly               //语法文件
└── print_parser_tokens.ml   //帮助函数

词法文件

文件头

{
  open Lexing
  open Parser

  exception Error of string
  exception SyntaxError of string

  let next_line lexbuf =
    let pos = lexbuf.lex_curr_p in
    lexbuf.lex_curr_p <-
    { pos with pos_bol = lexbuf.lex_curr_pos;
               pos_lnum = pos.pos_lnum + 1
    }
  let ascii_hex_char lexbuf =
    let s = Lexing.lexeme lexbuf in
    let hex_s = "0x" ^ ((String.split_on_char 'x' s) |> List.tl |> List.hd) in
    hex_s |> int_of_string |> Char.chr

  let ascii_oct_char lexbuf =
    let s = Lexing.lexeme lexbuf in
    let oct_s = (String.split_on_char '\\' s) |> List.tl |> List.hd in
    oct_s |> int_of_string |> Char.chr
}

这里我们定义了三个函数 next_lineascii_hex_charascii_oct_char

正则表达式定义

let int = '-'? ['0'-'9'] ['0'-'9']*
let digit = ['0'-'9']
let frac = '.' digit*
let exp = ['e' 'E'] ['-' '+']? digit+
let float = digit* frac? exp?
let white = [' ' '\t']+
let newline = '\r' | '\n' | "\r\n"
let id = ['a'-'z' 'A'-'Z' '_'] ['a'-'z' 'A'-'Z' '0'-'9' '_']*
let keyword = "use" | '{' | '}' | '(' | ')' | '['|']'|'+'|'-' | '*'|'/'|'='|"=="|"!="|'<'|'>'|','|':'|';'
let char = ['a'-'z' 'A'-'Z']
let ascii_hex = "\\x" ['0'-'7'] ['0'-'9' 'A'-'F' 'a'-'z']
let ascii_oct = '\\' (('0'? ['0'-'9']? ['0'-'9']) | ('1' ['0'-'1'] ['0'-'9']))

词法匹配逻辑规则

这里规则的格式如下:

rule <rule_name> = parse
| <regex>  {  TOKEN_NAME } (* output a token *)
| <regex>  { ... } (* or execute other code *)

and <another_rule> = parse
  | ...

{...}可以是执行语句,也可以是数据,注意规则是可以递归执行地。比如说我们想过滤掉空格,换行符,制表符等无意义字符,规则可以这么写:

rule read_token =
  parse
  | white    { read_token lexbuf }
  | newline  { next_line lexbuf; read_token lexbuf }

lexbuf是OCaml维护的一个字符串输入流数据结构,从以上代码可以看出,只要递归地调用定义的规则,就实现了跳过字符功能,当遇到换行符时,调用next_line使得当前行号自增1,行号信息储存在lexbuf数据结构中。下面我们来想一下如何来解析字符呢?单字符的特点是以'开头,以'结尾,中间是字母,所以我们在扫描的过程中,遇到'就要向后检查一步,看后续是否包含单字母及',定义规则如下:

and read_char buf =
  parse
  | '\'' { CHAR (Buffer.contents buf) }
  | char { Buffer.add_string buf (Lexing.lexeme lexbuf); read_char buf lexbuf}
  | _ { raise (SyntaxError ("Illegal character: " ^ Lexing.lexeme lexbuf)) }
  | eof { raise (SyntaxError ("char is not terminated")) }

read_char接收一个buf参数,如果当前字符是',我们就把buf中的字符读取出来,写入CHAR这个Token中;如果当前字符命中char正则表达式,将当前字符读取到buf中,递归调用read_char,检查是否以'结尾;对于其他情况,不符合字符定义,所以抛出异常信息。在read_token规则中,这么调用read_char:

rule read_token =
  parse
  ...
  | '\'' { read_char (Buffer.create 17) lexbuf }
  ...

同理,我们还可以定义字符串的解析规则:

and read_string buf =
  parse
  | '"'       { STRING (Buffer.contents buf) }
  | '\\' '/'  { Buffer.add_char buf '/'; read_string buf lexbuf }
  | '\\' '\\' { Buffer.add_char buf '\\'; read_string buf lexbuf }
  | '\\' 'b'  { Buffer.add_char buf '\b'; read_string buf lexbuf }
  | '\\' 'f'  { Buffer.add_char buf '\012'; read_string buf lexbuf }
  | '\\' 'n'  { Buffer.add_char buf '\n'; read_string buf lexbuf }
  | '\\' 'r'  { Buffer.add_char buf '\r'; read_string buf lexbuf }
  | '\\' 't'  { Buffer.add_char buf '\t'; read_string buf lexbuf }
  | ascii_hex { Buffer.add_char buf (ascii_hex_char lexbuf); read_string buf lexbuf }
  | ascii_oct { Buffer.add_char buf (ascii_oct_char lexbuf); read_string buf lexbuf }
  | [^ '"' '\\']+
    { Buffer.add_string buf (Lexing.lexeme lexbuf);
      read_string buf lexbuf
    }
  | _ { raise (SyntaxError ("Illegal string character: " ^ Lexing.lexeme lexbuf)) }
  | eof { raise (SyntaxError ("String is not terminated")) }

需要注意地是ascii_hexascii_oct函数,主要实现了字符串中支持ascii码的16进制和10进制功能。例如print("hello worl\x64!\n"),最终解析的token是String hello world!\nread_token完整定义如下:

rule read_token =
  parse
  | white    { read_token lexbuf }
  | "//" { read_single_line_comment lexbuf }
  |"/*" { read_multi_line_comment lexbuf }
  | newline  { next_line lexbuf; read_token lexbuf }
  | int      { INT (int_of_string (Lexing.lexeme lexbuf)) }
  | "int" { TYPE_INT}
  | "bool" { TYPE_BOOL }
  | "void" { TYPE_VOID }
  | "true"   { TRUE }
  | "false"  { FALSE }
  | '"'      { read_string (Buffer.create 17) lexbuf }
  | '\'' { read_char (Buffer.create 17) lexbuf }
  | keyword  { KEYWORD (Lexing.lexeme lexbuf) }
  | id       { ID  (Lexing.lexeme lexbuf)}
  | "if" { IF }
  | "else" { ELSE }
  | "while" { WHILE }
  | _ { raise (SyntaxError ("Illegal string character: " ^ Lexing.lexeme lexbuf ^ Ast.string_of_loc lexbuf.lex_start_p)) }
  | eof      { EOF }

语法文件

文件结构

和词法文件类似,分为文件头、声明、解析规则定义

%{
  header
%}
  declarations
%%
  rules

文件头部分是OCaml代码、declarations是定义的token声明,也是lexing部分输出所引用的数据结构。token定义如下:

%token  <int> INT
%token  <string> ID
%token  <string> STRING
%token  <string> KEYWORD
%token  <string> CHAR
%token   TYPE_INT
...
%nonassoc IN
%nonassoc ELSE
%left PLUS MINUS        /* lowest precedence */
%left TIMES DIV         /* medium precedence */
%start main             /* the entry point */

为了避免解析歧义,对于运算符解析顺序进行了定义。解析规则部分本篇略过,在实现解析器篇会详细介绍。

主文件

在介绍主文件之前,我们先来看下程序执行流程:首先程序接受控制台传入的参数,校验参数是否合法;其次对原始文件进行处理,调用词法解析器,不断地在输入字符流上扫描,输出生成的token信息,将信息保存到指定文件中。我们先定义两个helper函数:

let rec generated_lexer ppf lexbuf =
  let token = Lexer.read_token lexbuf in
  match token with
  | EOF -> Print_parser_tokens.pprint_tokens ppf lexbuf.lex_start_p EOF
  | _ -> Print_parser_tokens.pprint_tokens ppf lexbuf.lex_start_p token;generated_lexer ppf lexbuf
  
let attempt1 filename  =
  let fn = Filename.basename filename in

  let fname = Sys.getcwd() ^ "/" ^ List.hd (String.split_on_char '.' fn) ^ ".lexed" in
  let oc = open_out fname in
  let oc_fmter = Format.formatter_of_out_channel oc in

  (* Read the file; allocate and initialize a lexing buffer. *)
  let _, lexbuf = L.read filename in
  Printf.printf "output file:%s\n" fname;
  generated_lexer oc_fmter lexbuf

generated_lexer对输入字符流进行扫描,当扫描到文件结尾EOF时结束扫描,否则递归调用自身,不断扫描lexbuf。attempt1接收一个文件字符串,首先判断文件的合法性,接着读取文件,将其转换为lexbuf,最后调用generated_lexer,生成token序列,保存到指定文件中。

程序演示

帮助信息

./_build/default/bin/main.exe --help

image.png

词法文件生成功能

原始文件:

use io
int main(args: int[][]) {
    use;
    print("Hello, Worl\x7A!\n")
    c3po: int = 'x' + 47;
    r2d2: int = c3po // No Han Solo
}
x:bool = 4all
x = 'a'
this = does not matter

执行命令:

./_build/default/bin/main.exe source.xi

生成词法文件source.lexed

1:1 use
1:5 id io
2:1 id main
2:5 (
2:6 id args
2:10    :
2:12    int
2:15    [
2:16    ]
2:17    [
2:18    ]
2:19    )
2:21    {
3:2 use
3:5 ;
4:2 id print
4:7 (
4:27    string Hello, Worlz!\n
4:28    )
5:2 id c3po
5:6 :
5:8 int
5:12    =
5:16    character x
5:18    +
5:20    integer 47
5:22    ;
6:2 id r2d2
6:6 :
6:8 int
6:12    =
6:14    id c3po
7:1 }
8:1 id x
8:2 :
8:3 bool
8:8 =
8:10    integer 4
8:11    id all
9:1 id x
9:3 =
9:6 character a
10:1    id this
10:6    =
10:8    id does
10:13   id not
10:17   id matter
11:1    EOF