Go学习笔记-Go编译器简介

1.编译器
Go学习笔记-Go编译器简介
文章图片

1.1 三阶段编译器

  • 编译器前端: 主要用于理解源代码、扫描解析源代码并进行语义表达
  • IR: Intermediate Representation,可能有多个,编译器会使用多个 IR 阶段、多种数据结构表示程序,并在中间阶段对代码进行多次优化
  • 优化器: 主要目的是降低程序资源的消耗,但有理论已经表明某些优化存在着NP难题,所以编译器无法进行最佳优化,通常常用折中方案
  • 编译后端: 主要用于生成特定目标机器上的程序,可能是可执行文件,也可能是需要进一步处理的obj、汇编语言等
1.2 Go语言编译器
  • go compiler: Go语言编译器的主要源代码位于 src/cmd/compile/internal目录下:
    Go学习笔记-Go编译器简介
    文章图片

    Tips: 注意:大写的GC表示垃圾回收
    Go学习笔记-Go编译器简介
    文章图片
2.词法解析
  • Go编译器会扫描源代码,并且将其符号(token)化,例如 “+”、”-“操作符会被转化为_IncOp,赋值符号 ":=" 会被转化为 _Define。
  • token 是用iota声明的整数,定义在 go/src/cmd/compile/internal/syntax/tokens.go 文件中。
    Go学习笔记-Go编译器简介
    文章图片
  • Go语言标准库 go/src/go/scanner、go/src/go/token 中提供了许多接口用于扫描源代码。
  • Go 编译器把文件内容词法扫后,将每个标识符与运算符都被特定的 token 代替。
2.1 scanner.go 代码简介 scanner.go 文件位于 go/src/go/scanner 目录下,实现了Go源代码的扫描,采用一个 []byte 作为源,然后可以通过重复调用 Scan 方法对其进行标记。
  • type ErrorHandler func(pos token.Position, msg string): 可以向Scanner.Init 提供 ErrorHandler,如果遇到语法错误并安装了处理程序,则调用处理程序并提供位置和错误消息,该位置指向错误标记的开始。
  • type Scanner struct: 扫描器在处理给定文本时保持扫描器的内部状态。
  • const bom = 0xFEFF: 字节顺序标记,只允许作为第一个字符。
  • next()函数: 将下一个Unicode字符读入 s.ch,S.ch < 0 表示文件结束。
  • peek()函数: Peek返回跟随最近读取字符的字节,而不推进扫描仪,如果扫描器在 EOF, peek 返回 0。
  • type Mode uint: 模式值是一组标志(或 0),它们控制扫描行为。
    const ( ScanCommentsMode = 1 << iota // 返回注释作为注释标记 dontInsertSemis// 不要自动插入分号-仅用于测试 )

  • Init()函数: Init 通过在 src 的开头设置扫描器来让扫描器 s 标记文本src,扫描器使用文件集文件来获取位置信息,并为每一行添加行信息。当重新扫描相同的文件时,可以重复使用相同的文件,因为已经存在的行信息被忽略了。如果文件大小与 src 大小不匹配,Init 会导致 panic。如果遇到语法错误且 err 不是nil,则调用 Scan 将调用错误处理程序 err。此外,对于遇到的每个错误, Scanner 字段 ErrorCount 增加 1,mode 参数决定如何处理注释。
    Tips: 注意,如果文件的第一个字符有错误,Init 可能会调用 err。
  • updateLineInfo()函数: updateLineInfo 将输入的注释文本在 offset off 处解析为一个行指令。如果成功,它将根据 line 指令更新下一个位置的行信息表。
  • isLetter()函数: 判断 rune 字符是否为 a-z、A-Z、_ 或符合 utf8.RuneSelf 定义的字符。
    func isLetter(ch rune) bool { return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || ch == '_' || ch >= utf8.RuneSelf && unicode.IsLetter(ch) }

  • isDigit()函数: 判断 rune 字符是否为 0-9 或符合 utf8.RuneSelf 定义的字符。
    func isDigit(ch rune) bool { return '0' <= ch && ch <= '9' || ch >= utf8.RuneSelf && unicode.IsDigit(ch) }

  • digitVal()函数: 返回 rune 字符对应的整型数。
  • scanEscape()函数: scanEscape 解析转义序列,其中 rune 是接受的转义引号,在语法错误的情况下,它在错误字符处停止(不使用它)并返回 false,否则返回 true。
2.2 token.go 代码代码简介 token.go 文件位于 go/src/go/token 目录下。
Go学习笔记-Go编译器简介
文章图片

  • init()函数: 初始化关键字keywords变量(map[string]Token)的值
map[break:61 case:62 chan:63 const:64 continue:65 default:66 defer:67 else:68 fallthrough:69 for:70 func:71 go:72 goto:73 if:74 import:75 interface:76 map:77 package:78 range:79 return:80 select:81 struct:82 switch:83 type:84 var:85]

  • String()函数: 返回与令牌 tok 对应的字符串,对于操作符、分隔符和关键字,字符串是实际的标记字符序列(例如,对于标记ADD,字符串是“+”),对于所有其他令牌,字符串对应于令牌常量名(例如,对于令牌IDENT,字符串为“IDENT”)
  • Precedence()函数: 返回二进制操作符 op 的操作符优先级,如果op不是二进制操作符,则结果为最低优先级
  • Lookup()函数: 查找标记符对应的token
  • IsLiteral() 函数: 判断token值是否是基本类型范围的值,若是返回true,否则返回false
literal_beg // Identifiers and basic type literals // (these tokens stand for classes of literals) IDENT// main INT// 12345 FLOAT// 123.45 IMAG// 123.45i CHAR// 'a' STRING // "abc" literal_end

  • IsOperator() 函数: 判断token值是否是操作范围的值,若是返回true,否则返回false
operator_beg // Operators and delimiters ADD // + SUB // - MUL // * QUO // / REM // %AND// & OR// | XOR// ^ SHL// << SHR// >> AND_NOT // &^ADD_ASSIGN // += SUB_ASSIGN // -= MUL_ASSIGN // *= QUO_ASSIGN // /= REM_ASSIGN // %=AND_ASSIGN// &= OR_ASSIGN// |= XOR_ASSIGN// ^= SHL_ASSIGN// <<= SHR_ASSIGN// >>= AND_NOT_ASSIGN // &^=LAND// && LOR// || ARROW // <- INC// ++ DEC// --EQL// == LSS// < GTR// > ASSIGN // = NOT// !NEQ// != LEQ// <= GEQ// >= DEFINE// := ELLIPSIS // ...LPAREN // ( LBRACK // [ LBRACE // { COMMA// , PERIOD // .RPAREN// ) RBRACK// ] RBRACE// } SEMICOLON // ; COLON// : operator_end

  • IsKeyword() 函数: 判断token值是否是关键字范围的值,若是返回true,否则返回false
keyword_beg // Keywords BREAK CASE CHAN CONST CONTINUEDEFAULT DEFER ELSE FALLTHROUGH FORFUNC GO GOTO IF IMPORTINTERFACE MAP PACKAGE RANGE RETURNSELECT STRUCT SWITCH TYPE VAR keyword_end

3.语法解析
  • 词法解析完成后,语法解析阶段需要按照 Go 中指定的语法对 token 化后的源代码文件解析。
  • Go 编译器采用了自上而下的递归下降(Top-Down Recursive-Descent)算法,用简单高效的方式,完成了不需要回溯的语法扫描。
  • 核心算法位于 go/src/cmd/compile/internal/syntax/nodes.gogo/src/cmd/compile/internal/syntax/parser.go 中。
  • 源代码中的每一个声明都有对应的语法规则,通过递归下降识别初始的标识符,采用对应的语法进行解析。
  • 每一种声明语法或者表达式都有对应的结构体。
  • 语法声明的结构体拥有对应的层次结构,是构建抽象语法树的基础。
//包导入声明 ImportSpec = [ "." | PackageName ] ImportPath . ImportPath = string_lit . //静态常量 ConstSpec = IdentifierList [ [ Type ] "=" ExpressionList ] . //类型声明 TypeSpec = identifier [ "=" ] Type . //变量声明 VarSpec = IdentifierList ( Type [ "=" ExpressionList ] | "=" ExpressionList ) .

Go学习笔记-Go编译器简介
文章图片

3.1 nodes.go 代码代码简介
  • 函数声明:
    AssignStmt struct { OpOperator // 表示当前的操作符,即":=",0 表示没有操作 Lhs, Rhs Expr// Lhs, Rhs 分别代表左右两个表达式,Rhs == ImplicitOne means Lhs++ (Op == Add) or Lhs-- (Op == Sub) simpleStmt }

  • type Node interface:
    //Pos() 返回与节点相关联的位置,如下所示: // 1)表示终端语法产物(Name,BasicLit等)的节点位置是相应产物在源中的位置。 // 2)表示非终端产品(IndexExpr, IfStmt等)的节点的位置是与该产品唯一关联的令牌的位置; 通常是最左边的一个('['表示IndexExpr, 'if'表示IfStmt,等等)type Node interface { Pos() Pos aNode() }

  • type File struct: 包 PkgName; DeclList[0], DeclList[1], ...。
  • type Group struct: 属于同一组的所有声明指向同一个 group 节点。
3.2 parser.go 代码代码简介
  • importDecl()函数: 包导入声明。
  • constDecl()函数: 静态常量。
  • typeDecl()函数: 类型声明。
  • varDecl()函数: 变量声明。
  • type parser struct:
    type parser struct { file *PosBase errh ErrorHandler mode Mode scannerbase*PosBase // 当前位置的基准 firsterror// 遇到的第一个错误 errcnt int// 遇到的错误数 pragma Pragma// 编译指示标志fnestint// 函数嵌套级别(用于错误处理) xnestint// 表达式嵌套级别(用于解决编译歧义) indent []byte //跟踪支持 }

  • updateBase()函数: updateBase 将当前的位置基设置为 pos 处的新行的基准,该行基准的文件名、行和列值从定位于 (tline, tcol) 的文本中提取(仅在错误消息中需要)。
  • posAt()函数: posAt返回 (line, col) 的 Pos 值和当前位置的基数。
  • errorAt()函数: 错误报告在给定位置的错误。
  • syntaxErrorAt()函数: syntaxErrorAt 在给定位置报告语法错误。
  • tokstring()函数: tokstring 为更可读的错误消息返回所选标点符号的英文单词。
  • const stopset uint64: stopset 包含启动语句的关键字。在语法错误的情况下,它们是很好的同步点,(通常)不应该跳过。
    const stopset uint64 = 1<<_Break | 1<<_Const | 1<<_Continue | 1<<_Defer | 1<<_Fallthrough | 1<<_For | 1<<_Go | 1<<_Goto | 1<<_If | 1<<_Return | 1<<_Select | 1<<_Switch | 1<<_Type | 1<<_Var

  • advance()函数: Advance 消耗令牌,直到找到 stopset 或 followlist 的令牌。只有在函数 (p.fnest > 0) 中才考虑 stopset,如果它是空的,则只使用一个 (非eof) 令牌来确保进度。
  • fileOrNil()函数: 包文件 Parse 方法会根据需要使用匹配的 Go 结果进行注释,注释的目的只是作为引导,因为单个 Go 语法规则可能被多个解析方法覆盖,排除返回切片的方法,名为 xOrNil 的解析方法可能返回 nil,所有其他节点都将返回一个有效的非 nil 节点。
  • list()函数: List 解析一个可能为空的、由 sep 分隔的列表,可选后跟 sep 并由 (and) 或 {and} 括起来。open 是 Lparen 的一个,sep 是 Comma 或 Semi 的一个对于每个列表元素,调用 fmf 返回 true 后,不再接受列表元素,List 返回结束令牌的位置。
3.3 语法解析举例
a := b + c(10) //Tips: c(10) 表示强类型转换。

Go学习笔记-Go编译器简介
文章图片

a := b + c(10)语句被语法解析后转换为对应的 syntax.AssignStmt 结构体之后,最顶层的 Op 操作符为 token.Def(:=)。Lhs 表达式类型为标识符syntax.Name,值为标识符 “a”。Rhs表达式为 syntax.Operator 加法运算,加法运算左边为标识符 “b”,右边为函数调用表达式,类型为 CallExpr,其中,函数名 c 的类型为 syntax.Name,参数为常量类型 syntax.BasicLit,代表数字 10。
4.抽象语法树构建
  • 编译器前端必须构建程序的中间表示形式,以便在编译器中间阶段及后端使用。
  • 抽象语法树(Abstract Syntax Tree,AST)是一种常见的树状结构的中间态。
  • 在Go语言源文件中的任何一种import、type、const、func声明都是一个根节点,在根节点下包含当前声明的子节点。
  • 核心逻辑代码位于 go/src/cmd/compile/internal/gc/noder.go 中。
    Go学习笔记-Go编译器简介
    文章图片
  • 每个节点都包含了当前节点属性的Op字段,定义在 go/src/cmd/compile/internal/gc/syntax.go 中,以O开头。
  • 与词法解析阶段中的token相同的是,Op 字段也是一个整数。不同的是,每个 Op 字段都包含了语义信息,例如,当一个节点的 Op 操作为 OAS 时,该节点代表的语义为 Left:=Right,而当节点的操作为 OAS2 时,代表的语义为x,y,z=a,b,c。
    func (p *noder) decls(decls []syntax.Decl) (l []*Node) { var cs constStatefor _, decl := range decls { p.setlineno(decl) switch decl := decl.(type) { case *syntax.ImportDecl: p.importDecl(decl)case *syntax.VarDecl: l = append(l, p.varDecl(decl)...)case *syntax.ConstDecl: l = append(l, p.constDecl(decl, &cs)...)case *syntax.TypeDecl: l = append(l, p.typeDecl(decl))case *syntax.FuncDecl: l = append(l, p.funcDecl(decl))default: panic("unhandled Decl") } }return }

5.类型检查
  • 完成抽象语法树的初步构建后,就进入类型检查阶段遍历节点树并决定节点的类型。
  • 这其中包括了语法中明确指定的类型,例如 var a int,也包括了需要通过编译器类型推断得到的类型。
  • 在类型检查阶段,会对一些类型做特别的语法或语义检查。例如:引用的结构体字段是否是大写可导出的?数组字面量的访问是否超过了其长度?数组的索引是不是正整数?
  • 在类型检查阶段还会进行其他工作。例如:计算编译时常量、将标识符与声明绑定等。类型检查的核心逻辑位于 go/src/cmd/compile/internal/gc/typecheck.go 中。
6.变量捕获
  • 变量捕获主要是针对闭包场景而言的,由于闭包函数中可能引用闭包外的变量,因此变量捕获需要明确在闭包中通过值引用或地址引用的方式来捕获变量。
  • 类型检查阶段完成后,Go语言编译器将对抽象语法树进行分析及重构,从而完成一系列优化。
  • 变量捕获的核心逻辑位于 go/src/cmd/compile/internal/gc/closure.go 文件的 capturevars 函数中。
    package mainimport ( "fmt" )func main(){ a := "qinshixian" b := make(map[string]int) c := "haoweilai" go func() { fmt.Println(a) fmt.Println(b) fmt.Println(c) }() a = "asddss" }

    使用 go tool compile -m=2 getVar.go| grep capturing 命令可以查看变量捕获信息,如下图:
    Go学习笔记-Go编译器简介
    文章图片

    从上图可以看出变量 a 采用 ref 引用传递方式,变量 b、c 采用 value 值传递的方式。
7.函数内联
  • 函数内联指将较小的函数直接组合进调用者的函数。
  • 函数内联的优势在于,可以减少函数调用带来的开销。
  • 对于 Go 语言来说,函数调用的成本在于参数与返回值栈复制、较小的栈寄存器开销以及函数序言部分的检查栈扩容(Go语言中的栈是可以动态扩容的)。
  • Go语言编译器会计算函数内联花费的成本,只有执行相对简单的函数时才会内联。
  • 函数内联的核心代码位于 go/src/cmd/compile/internal/gc/inl.go 中。
  • 当函数内部有 for、range、go、select 等语句时,该函数不会被内联。
  • 若希望程序中所有的函数都不执行内联操作,那么可以添加编译器选项 “-l”。
  • 函数内联效率提升举例:
    package mainimport "testing"//go:noinline func max(a, b int) int { if a > b { return a } return b }var Result intfunc BenchmarkMax(b *testing.B) { var r int for i := 0; i < b.N; i++ { r = max(-1, i) } Result = r }//Tips://go:noinline 表示当前函数禁止函数内联优化。

    加上注释 //go:noinline 运行 go test leetcode_test.go -bench=. 如下图:
    Go学习笔记-Go编译器简介
    文章图片

没有加上注释 //go:noinline 运行 go test leetcode_test.go -bench=. 如下图:
Go学习笔记-Go编译器简介
文章图片

函数内部有 for、range、go、select 等语句时,如下图:
Go学习笔记-Go编译器简介
文章图片

若想查看函数是否可以使用函数内联,可使用 命令 go tool compile -m=2 leetcode_test.go,如下图所示:
Go学习笔记-Go编译器简介
文章图片

8.逃逸分析
  • 逃逸分析也是 Go 编译阶段中的优化,用于标识变量内存应该被分配在栈区还是堆区。
  • 若函数返回了一个栈上的对象指针,函数执行完成后,栈被销毁,访问被销毁栈上的对象指针就会出现问题,逃逸分析能识别这种问题,将该变量放置到堆区,并借助 Go 运行时的垃圾回收机制自动释放内存。
  • 编译器会尽可能地将变量放置到栈中,因为栈中的对象随着函数调用结束会被自动销毁,减轻运行时分配和垃圾回收的负担。
  • 逃逸分析核心代码位置在 go/src/cmd/compile/internal/gc/escape .go 文件中。
  • 不管是字符串、数组字面量,还是通过 new、make 标识符创建的对象,都既可能被分配到栈中,也可能被分配到堆中。分配时,遵循以下两个原则:
    (1)原则1:指向栈上对象的指针不能被存储到堆中 (2)原则2:指向栈上对象的指针不能超过该栈对象的生命周期

  • 逃逸现象举例:
    package mainvar n *intfunc escape(){ a := 100 n = &a }func main(){ escape() }

    【Go学习笔记-Go编译器简介】使用命令 go tool compile -m getVar.go,如下图所示:
    Go学习笔记-Go编译器简介
    文章图片

    其中变量 n 是一个 int 型指针,若 a 被分配到栈中,变量 n 超出了 a 的生命周期范围,违背了上述原则2,所以 a 需要被分配到堆中。

    推荐阅读