一、概述

Go 语言编译的最后一个阶段是根据 SSA 中间代码生成机器码,这里谈的机器码是在目标 CPU 架构上能够运行的二进制代码,中间代码生成一节简单介绍的从抽象语法树到 SSA 中间代码的生成过程,将近 50 个生成中间代码的步骤中有一些过程严格上说是属于机器码生成阶段的。

机器码的生成过程其实是对 SSA 中间代码的降级(lower)过程,在 SSA 中间代码降级的过程中,编译器将一些值重写成了目标 CPU 架构的特定值,降级的过程处理了所有机器特定的重写规则并对代码进行了一定程度的优化;在 SSA 中间代码生成阶段的最后,Go 函数体的代码会被转换成 cmd/compile/internal/obj.Prog 结构。

指令集架构 #

首先需要介绍的就是指令集架构,虽然我们在第一节编译过程概述中曾经讲解过指令集架构,但是在这里还是需要引入更多的指令集架构知识。

                                        图  计算机软硬件之间的桥梁

指令集架构是计算机的抽象模型,在很多时候也被称作架构或者计算机架构,它是计算机软件和硬件之间的接口和桥梁1;一个为特定指令集架构编写的应用程序能够运行在所有支持这种指令集架构的机器上,也就是说如果当前应用程序支持 x86 的指令集,那么就可以运行在所有使用 x86 指令集的机器上,这其实就是抽象层的作用,每一个指令集架构都定义了支持的数据结构、寄存器、管理主内存的硬件支持(例如内存一致、地址模型和虚拟内存)、支持的指令集和 IO 模型,它的引入其实就在软件和硬件之间引入了一个抽象层,让同一个二进制文件能够在不同版本的硬件上运行。

如果一个编程语言想要在所有的机器上运行,它就可以将中间代码转换成使用不同指令集架构的机器码,这可比为不同硬件单独移植要简单的太多了。

                                        图  复杂指令集(CISC)和精简指令集(RISC)

最常见的指令集架构分类方法是根据指令的复杂度将其分为复杂指令集(CISC)和精简指令集(RISC),复杂指令集架构包含了很多特定的指令,但是其中的一些指令很少会被程序使用,而精简指令集只实现了经常被使用的指令,不常用的操作都会通过组合简单指令来实现。

复杂指令集的特点就是指令数目多并且复杂,每条指令的字节长度并不相等,x86 就是常见的复杂指令集处理器,它的指令长度大小范围非常广,从 1 到 15 字节不等,对于长度不固定的指令,计算机必须额外对指令进行判断,这需要付出额外的性能损失2。

而精简指令集对指令的数目和寻址方式做了精简,大大减少指令数量的同时更容易实现,指令集中的每一个指令都使用标准的字节长度、执行时间相比复杂指令集会少很多,处理器在处理指令时也可以流水执行,提高了对并行的支持。作为一种常见的精简指令集处理器,arm 使用 4 个字节作为指令的固定长度,省略了判断指令的性能损失3,精简指令集其实就是利用了我们耳熟能详的 20/80 原则,用 20% 的基础指令和它们的组合来解决问题。

最开始的计算机使用复杂指令集是因为当时计算机的性能和内存比较有限,业界需要尽可能地减少机器需要执行的指令,所以更倾向于高度编码、长度不等以及多操作数的指令。不过随着计算机性能的提升,出现了精简指令集这种牺牲代码密度换取简单实现的设计;除此之外,硬件的飞速提升还带来了更多的寄存器和更高的时钟频率,软件开发人员也不再直接接触汇编代码,而是通过编译器和汇编器生成指令,复杂的机器指令对于编译器来说很难利用,所以精简指令在这种场景下更适合。

复杂指令集和精简指令集的使用是设计上的权衡,经过这么多年的发展,两种指令集也相互借鉴和学习,与最开始刚被设计出来时已经有了较大的差别,对于软件工程师来讲,复杂的硬件设备对于我们来说已经是领域下三层的知识了,其实不太需要掌握太多,但是对指令集架构感兴趣的读者可以找一些资料开拓眼界。

机器码生成 #

机器码的生成在 Go 的编译器中主要由两部分协同工作,其中一部分是负责 SSA 中间代码降级和根据目标架构进行特定处理的 cmd/compile/internal/ssa 包,另一部分是负责生成机器码的 cmd/internal/obj4:

  • cmd/compile/internal/ssa 主要负责对 SSA 中间代码进行降级、执行架构特定的优化和重写并生成 cmd/compile/internal/obj.Prog 指令;
  • cmd/internal/obj 作为汇编器会将这些指令转换成机器码完成这次编译;

SSA 降级 #

SSA 降级是在中间代码生成的过程中完成的,其中将近 50 轮处理的过程中,lower 以及后面的阶段都属于 SSA 降级这一过程,这么多轮的处理会将 SSA 转换成机器特定的操作:

var passes = [...]pass{...{name: "lower", fn: lower, required: true},{name: "lowered deadcode for cse", fn: deadcode}, // deadcode immediately before CSE avoids CSE making dead values live again{name: "lowered cse", fn: cse},...{name: "trim", fn: trim}, // remove empty blocks
}

SSA 降级执行的第一个阶段就是 lower,该阶段的入口方法是 cmd/compile/internal/ssa.lower 函数,它会将 SSA 的中间代码转换成机器特定的指令:

func lower(f *Func) {applyRewrite(f, f.Config.lowerBlock, f.Config.lowerValue)
}

向 cmd/compile/internal/ssa.applyRewrite 传入的两个函数 lowerBlock 和 lowerValue 是在中间代码生成阶段初始化 SSA 配置时确定的,这两个函数会分别转换函数中的代码块和代码块中的值。

假设目标机器使用 x86 的架构,最终会调用 cmd/compile/internal/ssa.rewriteBlock386 和 cmd/compile/internal/ssa.rewriteValue386 两个函数,这两个函数是两个巨大的 switch 语句,前者总共有 2000 多行,后者将近 700 行,用于处理 x86 架构重写的函数总共有将近 30000 行代码,你能在 cmd/compile/internal/ssa/rewrite386.go 这里找到文件的全部内容,我们只节选其中的一段展示一下:

func rewriteValue386(v *Value) bool {switch v.Op {case Op386ADCL:return rewriteValue386_Op386ADCL_0(v)case Op386ADDL:return rewriteValue386_Op386ADDL_0(v) || rewriteValue386_Op386ADDL_10(v) || rewriteValue386_Op386ADDL_20(v)...}
}func rewriteValue386_Op386ADCL_0(v *Value) bool {// match: (ADCL x (MOVLconst [c]) f)// cond:// result: (ADCLconst [c] x f)for {_ = v.Args[2]x := v.Args[0]v_1 := v.Args[1]if v_1.Op != Op386MOVLconst {break}c := v_1.AuxIntf := v.Args[2]v.reset(Op386ADCLconst)v.AuxInt = cv.AddArg(x)v.AddArg(f)return true}...
}

重写的过程会将通用的 SSA 中间代码转换成目标架构特定的指令,上述的 rewriteValue386_Op386ADCL_0 函数会使用 ADCLconst 替换 ADCL 和 MOVLconst 两条指令,它能通过对指令的压缩和优化减少在目标硬件上执行所需要的时间和资源。

我们在上一节中间代码生成中已经介绍过 cmd/compile/internal/gc.compileSSA 中调用 cmd/compile/internal/gc.buildssa 的执行过程,我们在这里继续介绍 cmd/compile/internal/gc.buildssa 函数返回后的逻辑:

func compileSSA(fn *Node, worker int) {f := buildssa(fn, worker)pp := newProgs(fn, worker)defer pp.Free()genssa(f, pp)pp.Flush()
}

cmd/compile/internal/gc.genssa 函数会创建一个新的 cmd/compile/internal/gc.Progs 结构并将生成的 SSA 中间代码都存入新建的结构体中,我们在上一节得到的 ssa.html 文件就包含最后生成的中间代码:

                                                 图 genssa 的执行结果

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

func (pp *Progs) Flush() {plist := &obj.Plist{Firstpc: pp.Text, Curfn: pp.curfn}obj.Flushplist(Ctxt, plist, pp.NewProg, myimportpath)
}

cmd/compile/internal/gc.buildssa 中的 lower 和随后的多个阶段会对 SSA 进行转换、检查和优化,生成机器特定的中间代码,接下来通过 cmd/compile/internal/gc.genssa 将代码输出到 cmd/compile/internal/gc.Progs 对象中,这也是代码进入汇编器前的最后一个步骤。

汇编器 #

汇编器是将汇编语言翻译为机器语言的程序,Go 语言的汇编器是基于 Plan 9 汇编器的输入类型设计的,Go 语言对于汇编语言 Plan 9 和汇编器的资料十分缺乏,网上能够找到的资料也大多都含糊不清,官方对汇编器在不同处理器架构上的实现细节也没有明确定义:

The details vary with architecture, and we apologize for the imprecision; the situation is not well-defined.5

我们在研究汇编器和汇编语言时不应该陷入细节,只需要理解汇编语言的执行逻辑就能够帮助我们快速读懂汇编代码。当我们将如下的代码编译成汇编指令时,会得到如下的内容:

$ cat hello.go
package hellofunc hello(a int) int {c := a + 2return c
}
$ GOOS=linux GOARCH=amd64 go tool compile -S hello.go
"".hello STEXT nosplit size=15 args=0x10 locals=0x00x0000 00000 (main.go:3)    TEXT    "".hello(SB), NOSPLIT, $0-160x0000 00000 (main.go:3)  FUNCDATA    $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)0x0000 00000 (main.go:3)   FUNCDATA    $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)0x0000 00000 (main.go:3)   FUNCDATA    $3, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)0x0000 00000 (main.go:4)   PCDATA  $2, $00x0000 00000 (main.go:4)  PCDATA  $0, $00x0000 00000 (main.go:4)  MOVQ    "".a+8(SP), AX0x0005 00005 (main.go:4)   ADDQ    $2, AX0x0009 00009 (main.go:5)  MOVQ    AX, "".~r1+16(SP)0x000e 00014 (main.go:5)    RET0x0000 48 8b 44 24 08 48 83 c0 02 48 89 44 24 10 c3     H.D$.H...H.D$..
...

上述汇编代码都是由 cmd/internal/obj.Flushplist 这个函数生成的,该函数会调用架构特定的 Preprocess 和 Assemble 方法:

func Flushplist(ctxt *Link, plist *Plist, newprog ProgAlloc, myimportpath string) {...for _, s := range text {mkfwd(s)linkpatch(ctxt, s, newprog)ctxt.Arch.Preprocess(ctxt, s, newprog)ctxt.Arch.Assemble(ctxt, s, newprog)linkpcln(ctxt, s)ctxt.populateDWARF(plist.Curfn, s, myimportpath)}
}

Go 编译器会在最外层的主函数确定调用的 Preprocess 和 Assemble 方法,编译器在 2.1.4 中提到的 cmd/compile.archInits 中根据目标硬件初始化当前架构使用的配置。

如果目标机器的架构是 x86,那么这两个函数最终会使用 cmd/internal/obj/x86.preprocess 和 cmd/internal/obj/x86.span6,作者在这里就不展开介绍这两个特别复杂的底层函数了,有兴趣的读者可以通过链接找到目标函数的位置了解预处理和汇编的处理过程,机器码的生成也都是由这两个函数组合完成的。

小结 #

机器码生成作为 Go 语言编译的最后一步,其实已经到了硬件和机器指令这一层,其中对于内存、寄存器的处理非常复杂并且难以阅读,想要真正掌握这里的处理的步骤和原理还是需要耗费很多精力。

作为软件工程师,如果不是 Go 语言编译器的开发者或者需要经常处理汇编语言和机器指令,掌握这些知识的投资回报率实在太低,我们只需要对这个过程有所了解,补全知识上的盲点,在遇到问题时能够快速定位即可。

延伸阅读 #

  • A Manual for the Plan 9 assembler

  1. Instruction set architecture https://en.wikipedia.org/wiki/Instruction_set_architecture ↩︎

  2. 复杂指令集 Complex instruction set computer https://en.wikipedia.org/wiki/Complex_instruction_set_computer ↩︎

  3. 精简指令集 Reduced instruction set computer https://en.wikipedia.org/wiki/Reduced_instruction_set_computer ↩︎

  4. Introduction to the Go compiler go/README.md at master · golang/go · GitHub ↩︎

  5. A Quick Guide to Go’s Assembler https://golang.org/doc/asm ↩︎

二、词法和语法分析

当使用通用编程语言1进行编写代码时,我们一定要认识到代码首先是写给人看的,只是恰好可以被机器编译和执行,而很难被人理解和维护的代码是非常糟糕。代码其实是按照约定格式编写的字符串,经过训练的软件工程师能对本来无意义的字符串进行分组和分析,按照约定的语法来理解源代码,并在脑内编译并运行程序。

既然工程师能够按照一定的方式理解和编译 Go 语言的源代码,那么我们如何模拟人理解源代码的方式构建一个能够分析编程语言代码的程序呢。我们在这一节中将介绍词法分析和语法分析这两个重要的编译过程,这两个过程能将原本机器看来无序意义的源文件转换成更容易理解、分析并且结构化的抽象语法树,接下来我们就看一看解析器眼中的 Go 语言是什么样的。

2.2.1 词法分析 #

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

make(chan int)

哪怕是不懂编程的人看到上述文本的第一反应也应该会将上述字符串分成几个部分 - makechanint 和括号,这个凭直觉分解文本的过程就是词法分析,词法分析是将字符序列转换为标记(token)序列的过程2。

lex #

lex3 是用于生成词法分析器的工具,lex 生成的代码能够将一个文件中的字符分解成 Token 序列,很多语言在设计早期都会使用它快速设计出原型。词法分析作为具有固定模式的任务,出现这种更抽象的工具必然的,lex 作为一个代码生成器,使用了类似 C 语言的语法,我们将 lex 理解为正则匹配的生成器,它会使用正则匹配扫描输入的字符流,下面是一个 lex 文件的示例:

%{
#include <stdio.h>
%}%%
package      printf("PACKAGE ");
import       printf("IMPORT ");
\.           printf("DOT ");
\{           printf("LBRACE ");
\}           printf("RBRACE ");
\(           printf("LPAREN ");
\)           printf("RPAREN ");
\"           printf("QUOTE ");
\n           printf("\n");
[0-9]+       printf("NUMBER ");
[a-zA-Z_]+   printf("IDENT ");
%%

这个定义好的文件能够解析 package 和 import 关键字、常见的特殊字符、数字以及标识符,虽然这里的规则可能有一些简陋和不完善,但是用来解析下面的这一段代码还是比较轻松的:

package mainimport ("fmt"
)func main() {fmt.Println("Hello")
}

.l 结尾的 lex 代码并不能直接运行,我们首先需要通过 lex 命令将上面的 simplego.l 展开成 C 语言代码,这里可以直接执行如下所示的命令编译并打印文件中的内容:

$ lex simplego.l
$ cat lex.yy.c
...
int yylex (void) {...while ( 1 ) {...
yy_match:do {register YY_CHAR yy_c = yy_ec[YY_SC_TO_UI(*yy_cp)];if ( yy_accept[yy_current_state] ) {(yy_last_accepting_state) = yy_current_state;(yy_last_accepting_cpos) = yy_cp;}while ( yy_chk[yy_base[yy_current_state] + yy_c] != yy_current_state ) {yy_current_state = (int) yy_def[yy_current_state];if ( yy_current_state >= 30 )yy_c = yy_meta[(unsigned int) yy_c];}yy_current_state = yy_nxt[yy_base[yy_current_state] + (unsigned int) yy_c];++yy_cp;} while ( yy_base[yy_current_state] != 37 );...do_action:switch ( yy_act )case 0:...case 1:YY_RULE_SETUPprintf("PACKAGE ");YY_BREAK...
}

lex.yy.c4 的前 600 行基本都是宏和函数的声明和定义,后面生成的代码大都是为 yylex 这个函数服务的,这个函数使用有限自动机(Deterministic Finite Automaton、DFA)5的程序结构来分析输入的字符流,上述代码中 while 循环就是这个有限自动机的主体,你如果仔细看这个文件生成的代码会发现当前的文件中并不存在 main 函数,main 函数是在 liblex 库中定义的,所以在编译时其实需要添加额外的 -ll 选项:

$ cc lex.yy.c -o simplego -ll
$ cat main.go | ./simplego

当我们将 C 语言代码通过 gcc 编译成二进制代码之后,就可以使用管道将上面提到的 Go 语言代码作为输入传递到生成的词法分析器中,这个词法分析器会打印出如下的内容:

PACKAGE  IDENTIMPORT  LPARENQUOTE IDENT QUOTE
RPARENIDENT  IDENT LPAREN RPAREN  LBRACEIDENT DOT IDENT LPAREN QUOTE IDENT QUOTE RPAREN
RBRACE

从上面的输出我们能够看到 Go 源代码的影子,lex 生成的词法分析器 lexer 通过正则匹配的方式将机器原本很难理解的字符串进行分解成很多的 Token,有利于后面的处理。

图  从 .l 文件到二进制

到这里我们已经为各位读者展示了从定义 .l 文件、使用 lex 将 .l 文件编译成 C 语言代码以及二进制的全过程,而最后生成的词法分析器也能够将简单的 Go 语言代码进行转换成 Token 序列。lex 的使用还是比较简单的,我们可以使用它快速实现词法分析器,相信各位读者对它也有了一定的了解。

Go #

Go 语言的词法解析是通过 src/cmd/compile/internal/syntax/scanner.go6 文件中的 cmd/compile/internal/syntax.scanner 结构体实现的,这个结构体会持有当前扫描的数据源文件、启用的模式和当前被扫描到的 Token:

type scanner struct {sourcemode   uintnlsemi bool// current token, valid after calling next()line, col uintblank     bool // line is blank up to coltok       tokenlit       string   // valid if tok is _Name, _Literal, or _Semi ("semicolon", "newline", or "EOF"); may be malformed if bad is truebad       bool     // valid if tok is _Literal, true if a syntax error occurred, lit may be malformedkind      LitKind  // valid if tok is _Literalop        Operator // valid if tok is _Operator, _AssignOp, or _IncOpprec      int      // valid if tok is _Operator, _AssignOp, or _IncOp
}

src/cmd/compile/internal/syntax/tokens.go7 文件中定义了 Go 语言中支持的全部 Token 类型,所有的 token 类型都是正整数,你可以在这个文件中找到一些常见 Token 的定义,例如:操作符、括号和关键字等:

const (_    token = iota_EOF       // EOF// operators and operations_Operator // op...// delimiters_Lparen    // (_Lbrack    // [...// keywords_Break       // break..._Type        // type_Var         // vartokenCount //
)

从 Go 语言中定义的 Token 类型,我们可以将语言中的元素分成几个不同的类别,分别是名称和字面量、操作符、分隔符和关键字。词法分析主要是由 cmd/compile/internal/syntax.scanner 这个结构体中的 cmd/compile/internal/syntax.scanner.next 方法驱动,这个 250 行函数的主体是一个 switch/case 结构:

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 == colbases.start()if isLetter(s.ch) || s.ch >= utf8.RuneSelf && s.atIdentChar(true) {s.nextch()s.ident()return}switch s.ch {case -1:s.tok = _EOFcase '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':s.number(false)...}
}

cmd/compile/internal/syntax.scanner 每次都会通过 cmd/compile/internal/syntax.source.nextch 函数获取文件中最近的未被解析的字符,然后根据当前字符的不同执行不同的 case,如果遇到了空格和换行符这些空白字符会直接跳过,如果当前字符是 0 就会执行 cmd/compile/internal/syntax.scanner.number 方法尝试匹配一个数字。

func (s *scanner) number(seenPoint bool) {kind := IntLitbase := 10        // number basedigsep := 0invalid := -1     // index of invalid digit in literal, or < 0s.kind = IntLitif !seenPoint {digsep |= s.digits(base, &invalid)}s.setLit(kind, ok)
}func (s *scanner) digits(base int, invalid *int) (digsep int) {max := rune('0' + base)for isDecimal(s.ch) || s.ch == '_' {ds := 1if s.ch == '_' {ds = 2} else if s.ch >= max && *invalid < 0 {_, col := s.pos()*invalid = int(col - s.col) // record invalid rune index}digsep |= dss.nextch()}return
}

上述的 cmd/compile/internal/syntax.scanner.number 方法省略了很多的代码,包括如何匹配浮点数、指数和复数,我们只是简单看一下词法分析匹配整数的逻辑:在 for 循环中不断获取最新的字符,将字符通过 cmd/compile/internal/syntax.source.nextch 方法追加到 cmd/compile/internal/syntax.scanner 持有的缓冲区中;

当前包中的词法分析器 cmd/compile/internal/syntax.scanner 也只是为上层提供了 cmd/compile/internal/syntax.scanner.next 方法,词法解析的过程都是惰性的,只有在上层的解析器需要时才会调用 cmd/compile/internal/syntax.scanner.next 获取最新的 Token。

Go 语言的词法元素相对来说还是比较简单,使用这种巨大的 switch/case 进行词法解析也比较方便和顺手,早期的 Go 语言虽然使用 lex 这种工具来生成词法解析器,但是最后还是使用 Go 来实现词法分析器,用自己写的词法分析器来解析自己8。

语法分析 #

语法分析是根据某种特定的形式文法(Grammar)对 Token 序列构成的输入文本进行分析并确定其语法结构的过程9。从上面的定义来看,词法分析器输出的结果 — Token 序列是语法分析器的输入。

语法分析的过程会使用自顶向下或者自底向上的方式进行推导,在介绍 Go 语言语法分析之前,我们会先来介绍语法分析中的文法和分析方法。

文法 #

上下文无关文法是用来形式化、精确描述某种编程语言的工具,我们能够通过文法定义一种语言的语法,它主要包含一系列用于转换字符串的生产规则(Production rule)10。上下文无关文法中的每一个生产规则都会将规则左侧的非终结符转换成右侧的字符串,文法都由以下的四个部分组成:

终结符是文法中无法再被展开的符号,而非终结符与之相反,还可以通过生产规则进行展开,例如 “id”、“123” 等标识或者字面量11。