浅谈 Golang 编译原理及其应用

Golang Compilation Process - An Overview

Posted by Eric.Y on August 1, 2022

浅谈 Golang 编译原理及其应用

TL;DR;本文简要介绍 Golang 编译的各个阶段干了什么,即从源文件到最终的机器码中间经历的过程。并从汇编代码、AST 入手介绍了相关的应用场景及实现原理。本文第一节深度参考了 解析器眼中的 Go 语言,也非常推荐有时间的读者阅读原文。

编译原理

编译器的前端一般承担着词法分析、语法分析、类型检查和中间代码生成几部分工作.

编译器后端主要负责目标代码的生成和优化,也就是将中间代码翻译成目标机器能够运行的二进制机器码。

词法分析

过程:源代码文件 => 词法分析(lexer) => Token 序列

词法分析:词法分析是将字符序列转换为 Token 序列的过程。源代码在计算机看来其实就是一个由字符组成的、无法被理解的字符串,所有的字符在计算器看来并没有什么区别,为了理解这些字符我们需要做的第一件事情就是将字符串分组,即转换为 Token 序列,这能够降低理解字符串的成本,简化源代码的分析过程,这个转换的过程就是词法分析。

func (s *scanner) next() {
    // ...
    s.stop()
    startLine, startCol := s.pos()for s.ch == ' ' || s.ch == '\t' || s.ch == '\n' && !nlsemi || s.ch == '\r' {
        s.nextch()
    }

    s.line, s.col = s.pos()
    s.blank = s.line > startLine || startCol == colbase
    s.start()
    if isLetter(s.ch) || s.ch >= utf8.RuneSelf && s.atIdentChar(true) {
        s.nextch()
        s.ident()return
    }
    switch s.ch {
    case -1:
        s.tok = _EOF
    case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
        s.number(false)
    // ...
    }
}

上述节选的代码是遍历源文件不断获取最新的字符,将字符通过 cmd/compile/internal/syntax.source.nextch 方法追加到 scanner 持有的缓冲区中,并在 cmd/compile/internal/syntax.scanner.next 中对最新的字符进行词法分析的过程。

语法分析

过程:Token 序列 => 语法分析 => AST

语法分析:语法分析是根据某种特定的文法,对 Token 序列构成的输入文本进行分析并确定其语法结构的过程。语法分析由文法、分析方法构成。文法描述了语法的组成,分析方法则是解析文法的过程。

文法

上下文无关文法是用来形式化、精确描述某种编程语言的工具,我们能够通过文法定义一种语言的语法,它主要包含一系列用于转换字符串的生产规则(Production rule)。

The Go Programming Language Specification(Go 语言说明书)使用 EBNF 范式对 Golang 语法进行描述。

下面是使用 EBNF 范式对 EBNF 本身进行描述:

Production  = production_name "=" [ Expression ] "." .
Expression  = Alternative { "|" Alternative } .
Alternative = Term { Term } .
Term        = production_name | token [ "…" token ] | Group | Option | Repetition .
Group       = "(" Expression ")" .
Option      = "[" Expression "]" .
Repetition  = "{" Expression "}" .

生产规则是由 Term 与下述操作符组成:

|   alternation
()  grouping
[]  option (0 or 1 times)
{}  repetition (0 to n times)
""  string
.   terminator symbol

src/cmd/compile/internal/syntax/parser.go 文件中描述了 Go 语言文法的生产规则:

SourceFile = PackageClause ";" { ImportDecl ";" } { TopLevelDecl ";" } .
PackageClause  = "package" PackageName .
PackageName    = identifier .

ImportDecl       = "import" ( ImportSpec | "(" { ImportSpec ";" } ")" ) .
ImportSpec       = [ "." | PackageName ] ImportPath .
ImportPath       = string_lit .

TopLevelDecl  = Declaration | FunctionDecl | MethodDecl .
Declaration   = ConstDecl | TypeDecl | VarDecl .

从上述 SourceFile 相关的生产规则我们可以看出,每一个文件都包含一个 package 的定义以及可选的 import 声明和其他的顶层声明(TopLevelDecl),每一个 SourceFile 在编译器中都对应一个 cmd/compile/internal/syntax.File 结构体,可以从该定义中轻松找到两者的联系:

type File struct {
    Pragma   Pragma
    PkgName  *Name
    DeclList []Decl
    Lines    uint
    node
}

分析方法

语法分析的分析方法一般分为自顶向下和自底向上两种,这两种方式会使用不同的方式对输入的 Token 序列进行推导:

  • 自顶向下分析:解析器会从开始符号分析,通过新加入的字符判断应该使用什么生产规则展开当前的输入流;
    • LL 使用自顶向下分析方法
  • 自底向上分析:解析器会从输入流开始,维护一个栈用于存储未被归约的符号,当栈中符号满足规约条件,则会规约成对应的生产规则;
    • LR(0)、SLR、LR(1) 和 LALR(1) 都是使用了自底向上的处理方式;
  • Lookahead:在不同生产规则发生冲突时,解析器需要通过预读一些 Token 判断当前应该用什么生产规则对输入流进行展开或者归约,例如在 LALR(1) 文法中,需要预读一个 Token 保证出现冲突的生产规则能够被正确处理。

Go 语言的解析器使用了 LALR(1) 的文法来解析词法分析过程中输出的 Token 序列,得到 AST。

AST

语法分析器最终会使用不同的结构体来构建抽象语法树中的节点,File 是根结点。

type File struct {
    Pragma   Pragma
    PkgName  *Name
    DeclList []Decl
    Lines    uint
    node
}

src/cmd/compile/internal/syntax/nodes.go 文件中也定义了其他节点的结构体,比如函数声明的结构:

type (
    Decl interface {
        Node
        aDecl()
    }

    FuncDecl struct {
        Attr   map[string]bool
        Recv   *Field
        Name   *Name
        Type   *FuncType
        Body   *BlockStmt
        Pragma Pragma
        decl
    }
}

这里草草了解对 AST 的说明,在「应用」一节会举一个有关 AST 的例子,这里顺便推荐一个在线解析 AST 的工具。

另外,非常推荐一本详细讲解 AST 的书籍,有兴趣的读者可以查阅 https://chai2010.cn/go-ast-book/index.html。

类型检查

过程: AST => 类型检查 => 关键字改写的 AST

得到 AST 之后,对象类型、对象值已经出来了,这时候执行类型检查是很方便的事情;如果有任何类型不匹配,则会在该阶段抛出异常,这个过程叫做静态类型检查

与静态类型检查互补的是动态类型检查,例如我们在代码中会将 interface{} 转换成具体类型,如果无法发生转换就会发生程序崩溃,那么这里实际上涉及到动态类型检查。动态检查会依赖编译期间得到的类型信息。

另外,执行类型检查的同时,会对内建函数进行一些替换操作,例如 make => runtime.makeslice 或者 runtime.makechan

中间代码生成

过程:AST => 并发编译所有函数 => SSA 等代码优化 => 中间代码

生成中间代码之前,编译器还需要替换抽象语法树中节点的一些元素,即编程语言给开发者的语法糖。该操作将一些关键字和内建函数转换成函数调用,例如: 将 panicrecover 两个内建函数转换成 runtime.gopanicruntime.gorecover 两个真正运行时函数,而关键字 new 也会被转换成调用 runtime.newobject 函数。

img

经过 walk 系列函数的处理之后,抽象语法树就不会改变了,Go 语言的编译器会将 AST 转换为具备 SSA 特性的中间代码。

我们能在 GOSSAFUNC=func_name go build main.go 命令生成的文件中,看到指定函数 func_name,每一轮处理后的中间代码。

img

中间代码是一种更接近机器语言的表示形式,对中间代码的优化和分析相比直接分析高级编程语言更容易。

机器码生成

SSA 输出结果跟最后生成的汇编代码已经非常相似了,随后调用的 cmd/compile/internal/gc.Progs.Flush 会使用 cmd/internal/obj 包中的汇编器将 SSA 转换成汇编代码。

我们可以使用命令生成汇编代码,GOOS=linux GOARCH=amd64 go tool compile -S main.go。这种方式生产的并非标准的汇编代码,而是上节提到的中间代码。不过进行分析的话也可以使用。

如果想获得更准确,并且更加标准化的汇编代码,可以使用 go tool objdump -s <interesting_function_> main

usage: go tool objdump [-S] [-gnu] [-s symregexp] binary [start end]
  -S    print Go code alongside assembly
  -gnu  print GNU assembly next to Go assembly (where supported)
  -s string
        only dump symbols matching this regexp

自举

自举的定义是,使用 Golang 编写的程序来构建 Golang 编写的程序。实际上,要构建 x ≥ 5 的 Go 1.x,必须在 $GOROOT_BOOTSTRAP 中已经安装 Go 1.4(或更高版本)。而 Go 1.4 本身是依赖 C 语言的。所以这个自举过程其实也并不神秘。

应用

机器码

Monkey Patch

原理:获取 from 的函数地址 => 将跳转 to 的汇编指令替换 from 的函数体

Monkey Patch 是一个实现函数替换的工具,通常用在本地测试 Mock 数据的场景。这里简单介绍函数替换的实现原理。

举个例子,对于下述程序,我们的目标是实现某个函数replace(a, b),使得调用函数 a 的时候,实际上运行函数 b。

package main

func a() int { return 1 }

func b() int { return 2 }

func main() {
  // replace(a, b)
  print(a())
}

先看其汇编代码:

img

再强调一下,我们的目标是调用函数 a 的时候,实际上调用 b。

可以看到 0x2000-0x2009 是函数 main.a 的函数体。

那么,要达到目标,需要把下述汇编代码替换到 0x2000-0x2009 的内存位置。

mov rdx, main.b.f
jmp [rdx]

因此,将汇编对应的机器码(字节表示,可以使用在线汇编器进行转换)强制拷贝到函数main.a 的内存位置即可。

详细可以查阅原文,这里只是简单介绍一下实现的过程。

https://berryjam.github.io/2018/12/golang%E6%9B%BF%E6%8D%A2%E8%BF%90%E8%A1%8C%E6%97%B6%E5%87%BD%E6%95%B0%E4%BD%93%E5%8F%8A%E5%85%B6%E5%8E%9F%E7%90%86/

性能分析

我们借助 pprof 工具,对性能瓶颈进行分析。找到到更具体的问题代码块,可以再通过汇编分析等方法,定位到影响性能的代码。

下图是嵌套指针导致的指令依赖,而无法充分利用指令并行的一个例子。

img

通过汇编指令分析性能瓶颈,帮助机器更好地优化,例如:

  1. 充分利用指令并行

  2. 避免不可预测的分支

  3. 提高指令缓存命中率

详细可以参阅 CSAPP 的第五章,之前记了相关的一段笔记:第五章 优化程序性能

AST

魔改代码

有时候,你需要批量修改代码。例如,你需要给很多个 Handler 添加一个公有的 BaseCheck 逻辑。

这时候,可选的做法是,加一个 BaseHandler 实现改 BaseCheck 方法,让其他 Handler 嵌套 BaseHandler 从而继承该公有方法。然后在所有的 Handler 的 check 方法中,调用继承过来的 BaseCheck 方法,即新增下述高亮部分的代码。

package handler

import "package_path/biz/base_handler"

type HandlerA struct {
    *base_handler.BaseHandler
}

func (h *HandlerA) check() error {
    // other checks by handler A
    // other checks by handler A
    if err := h.BaseCheck(); err != nil { // inherit from BaseHandler
        return err
    }
}

问题来了,当 handler 非常多(大型项目可能有几十上百个)时,手动给每个 handler 文件添加上述逻辑显然不是很爽。

回顾一下上面提到的 AST,我们可以利用 AST 解析整个 go 文件,得到所有的节点。那么当我知道需要在哪些节点上新增代码,便可以写代码来生成这部分代码,我们把这个工具暂称 generator。

这里面涉及两个步骤:

  1. 识别目标节点

  2. 插入新节点(要新增的代码块)

我们可以先手动对任何一个 handler 加这部分代码,然后观察其 AST,模仿该 AST 来编写 generator。上文提到一个在线解析 AST 的工具,我们把已经写好的 handler 贴进去,找到我们感兴趣的那部分逻辑。

例如你要给每个 Handler 的声明添加 *base_handler.BaseHandler 这个 field,我们来看对应的代码以及 AST:

type Handler struct {
        *base_handler.BaseHandler
        reqCtx *request.RequestContext
}
     0  *ast.File {
     3  .  Name: *ast.Ident {
     5  .  .  Name: "xx_handler"
     7  .  }
     8  .  Decls: []ast.Decl (len = 5) {
     9  .  .  0: *ast.GenDecl {}
    95  .  .  1: *ast.GenDecl {
    96  .  .  .  Doc: nil
    98  .  .  .  Tok: type
   100  .  .  .  Specs: []ast.Spec (len = 1) {
   101  .  .  .  .  0: *ast.TypeSpec {
   102  .  .  .  .  .  Doc: nil
   103  .  .  .  .  .  Name: *ast.Ident {
   105  .  .  .  .  .  .  Name: "Handler"
   106  .  .  .  .  .  .  Obj: *ast.Object {
   107  .  .  .  .  .  .  .  Kind: type
   108  .  .  .  .  .  .  .  Name: "Handler"
   112  .  .  .  .  .  .  }
   113  .  .  .  .  .  }
   115  .  .  .  .  .  Type: *ast.StructType {
   117  .  .  .  .  .  .  Fields: *ast.FieldList {
   119  .  .  .  .  .  .  .  List: []*ast.Field (len = 2) {
   120  .  .  .  .  .  .  .  .  0: *ast.Field {
   121  .  .  .  .  .  .  .  .  .  Doc: nil
   122  .  .  .  .  .  .  .  .  .  Names: nil
   123  .  .  .  .  .  .  .  .  .  Type: *ast.StarExpr {
   124  .  .  .  .  .  .  .  .  .  .  Star: foo:15:2
   125  .  .  .  .  .  .  .  .  .  .  X: *ast.SelectorExpr {
   126  .  .  .  .  .  .  .  .  .  .  .  X: *ast.Ident {
   127  .  .  .  .  .  .  .  .  .  .  .  .  NamePos: foo:15:3
   128  .  .  .  .  .  .  .  .  .  .  .  .  Name: "base_handler"
   129  .  .  .  .  .  .  .  .  .  .  .  .  Obj: nil
   130  .  .  .  .  .  .  .  .  .  .  .  }
   131  .  .  .  .  .  .  .  .  .  .  .  Sel: *ast.Ident {
   132  .  .  .  .  .  .  .  .  .  .  .  .  NamePos: foo:15:16
   133  .  .  .  .  .  .  .  .  .  .  .  .  Name: "BaseHandler"
   134  .  .  .  .  .  .  .  .  .  .  .  .  Obj: nil
   135  .  .  .  .  .  .  .  .  .  .  .  }
   136  .  .  .  .  .  .  .  .  .  .  }
   137  .  .  .  .  .  .  .  .  .  }
   138  .  .  .  .  .  .  .  .  .  Tag: nil
   139  .  .  .  .  .  .  .  .  .  Comment: nil
   140  .  .  .  .  .  .  .  .  }
   141  .  .  .  .  .  .  .  .  1: *ast.Field {
   142  .  .  .  .  .  .  .  .  // ...
   183  .  .  }

更改目标节点

观察上述 AST,可以发现 *ast.File 这个源文件节点下面,声明部分 Decls 就有 Handler 的声明语句。这个声明中有一个 Fields 节点,就是 Handler 的字段定义。

根据该 AST 可以很方便写出目标节点的识别代码,本文使用了 “golang.org/x/tools/go/ast/astutil” 这个库,相比于 “go/ast” 库,astutil 支持获取节点的父节点等功能。

func main() {
    fset := token.NewFileSet()
    file, err := parser.ParseFile(fset, "input.go", nil, parser.ParseComments)
    if err != nil {
        log.Fatal(err)
        return
    }

    astutil.Apply(file, nil, func(c *astutil.Cursor) bool {
        n := c.Node()
        
        switch x := n.(type) {
        
        case *ast.File:
        
        for _, decl := range x.Decls {
            if genDecl, ok := decl.(*ast.GenDecl); ok {
                for _, spec := range genDecl.Specs {
                    switch dx := spec.(type) {
                    case *ast.TypeSpec:
                        // 下面判断是 type struct 声明,并且名称以 Handler 结尾
                        if dx.Name != nil && strings.HasSuffix(dx.Name.Name, "Handler") {
                            if stype, ok := dx.Type.(*ast.StructType); ok {
                                // checkIfAddBaseHandlerDone 判断是否已经插入过该 field
                                if !checkIfAddBaseHandlerDone(stype.Fields) {
                                    // 在 fields.List 中插入 baseHandler 的 field
                                    stype.Fields.List = append([]*ast.Field{newAddBaseHandlerField()}, stype.Fields.List...)
                                }
                            }
                        }
                    }
                }
            }
        }
    }

    printer.Fprint(os.Stdout, fset, file)
}

func newAddBaseHandlerField() *ast.Field {
    return &ast.Field{
        Names: nil,
        Type: &ast.StarExpr{
            X: &ast.SelectorExpr{
                X: &ast.Ident{
                    Name: "base_handler",
                },
                Sel: &ast.Ident{
                    Name: "BaseHandler",
                },
            },
        },
    }
}

expr

expr 自己实现了一套语法,因此也有自己的一整套编译过程。只不过得到了 AST 之后,转换为命令+参数的形式用栈来模拟程序的执行。

  1. 首先是 source code => token,使用状态机自底向上进行分析,expr/parser/lexer/lexer.go,下面是状态机的部分代码,当匹配到期望的规则时,则使用对应的生产规则跳转到下一个状态,因此是自顶向下分析,输出预设的 token 列表。
func root(l *lexer) stateFn {
        switch r := l.next(); {
        case r == eof:
                l.emitEOF()
                return nil
        case IsSpace(r):
                l.ignore()
                return root
        case r == '\'' || r == '"':
                l.scanString(r)
                str, err := unescape(l.word())
                if err != nil {
                        l.error("%v", err)
                }
                l.emitValue(String, str)
        case '0' <= r && r <= '9':
                l.backup()
                return number
        default:
                return l.error("unrecognized character: %#U", r)
        }
        return root
}
  1. 其次,token => AST,当 token 确定之后,可以很方便构建一棵树。个人觉得这部分代码写得有点挫,详细可以看:expr/parser/parser.go

  2. 下面是 AST => 程序命令的例子,三目的条件运算符的解析过程:expr/compile/compiler.go

func (c *compiler) ConditionalNode(node *ast.ConditionalNode) {
        c.compile(node.Cond)
        otherwise := c.emit(OpJumpIfFalse, c.placeholder()...)

        c.emit(OpPop)
        c.compile(node.Exp1)
        end := c.emit(OpJump, c.placeholder()...)

        c.patchJump(otherwise)
        c.emit(OpPop)
        c.compile(node.Exp2)

        c.patchJump(end)
}
  1. 下面是执行函数的相关代码,expr/vm/vm.go,模拟了计算机取值、执行的过程。会将存在 bytecode 中的指令取出,并从 constant 中取出指令对应的参数进行执行。由于指令都是预设的,因此 constant 只需要根据预设进行出栈入栈即可。
op := vm.bytecode[vm.pp]
switch op {
case OpCall:
        call := vm.constant().(Call)
        in := make([]reflect.Value, call.Size)
        for i := call.Size - 1; i >= 0; i-- {
                param := vm.pop()
                if param == nil && reflect.TypeOf(param) == nil {
                        // In case of nil value and nil type use this hack,
                        // otherwise reflect.Call will panic on zero value.
                        in[i] = reflect.ValueOf(&param).Elem()
                } else {
                        in[i] = reflect.ValueOf(param)
                }
        }
        out := FetchFn(env, call.Name).Call(in)
        if len(out) == 2 && out[1].Type() == errorType && !out[1].IsNil() {
                return nil, out[1].Interface().(error)
        }
        vm.push(out[0].Interface())
}

gocover

go test coverage 采用的方法是在生成的 AST 上直接进行编辑,注入统计逻辑,然后根据编辑后的 AST 反向生成代码。

Reference