万剑归宗从汇编认识GO语言

189 阅读10分钟

一、概述

我们给出几个需要从汇编来认识GO语言的原因:

  1. 优化提升函数功能的性能;
  2. 认识Golang语言内部的实现机制
    当我们需要深入去优化代码结构、系统架构,就不得不去深入了解Golang这门语言,去了解Golang内部实现:比如goroutine调度、io调度、map实现、string实现。Golang内部有go实现,也有汇编实现。 为了做更深入的优化,有时候不得不去写汇编,甚至根据特定汇编指令集来做优化。 据悉某节,为了提升JSON性能,借助汇编做了大量优化,带来了整体性能大幅度的提升。
  3. 实现一些比较超越语言自身束缚的能力 举例来说,GO这种类似于C/C++的语言产物,导致我们难以实现AOP这样的功能,但是我们利用汇编,可以为GO实现AOP功能。

二、plan9汇编简介

    Golang的开发团队和bell实验室(开发了Unix的那个实验室)开发plan9操作系统开发团队是同一批人,之所以用plan9作为汇编,因为他们很熟悉且具有跨架构的抽象性。 可以带来很多好处,最主要的一点是方便将 Go 移植到新的架构上,

     Go 的汇编器最重要的是要知道 Go 的汇编器不是对底层机器的直接表示。概括来说,特定于机器的指令会以他们的本尊出现, 然而对于一些通用的操作,如内存的移动以及子程序的调用以及返回通常都做了抽象。但细节因架构不同而不一样,官方也对这样的不精确性表示歉意,情况并不明确。汇编器程序的工作是对这样半抽象指令集进行解析并将其转变为可以输入到链接器的指令。

     Golang的基础方法中,使用了大量plan9汇编,其中包含了一些如4个伪寄存器等plan9特有的语法。关于 Go 的汇编器最重要的一点是它不是底层机器的直接表示。 一些细节精确映射到机器,但有些则不然。这是因为编译器套件(请参阅 此说明)不需要在通常的管道中传递汇编程序。相反,编译器在一种半抽象的指令集上运行,指令选择部分发生在代码生成之后。汇编器以半抽象的形式工作,所以当你看到这样的指令时MOV 工具链实际为该操作生成的可能根本不是移动指令,可能是清除指令或加载指令。或者它也可能与具有该名称的机器指令完全对应。 一般来说,特定于机器的操作往往表现为它们自己,而更一般的概念,如内存移动和子程序调用和返回则更抽象。细节因架构而异,对于不精确之处,我们深表歉意;情况不明确。

    汇编程序是一种解析该半抽象指令集的描述,并将其转换为要输入到链接器的指令的方法。如果您想查看给定体系结构(例如 amd64)的汇编指令,标准库的源代码中有很多示例,例如 runtime和 math/big. 您还可以检查编译器作为汇编代码发出的内容(实际输出可能与您在此处看到的不同):

IA64RAXRBXRCXRDXRDIRSIRBPRSPR8R9R10R11R12R13R14RIP
Plan9AXBXCXDXDISIBPSPR8R9R10R11R12R13R14PC

    应用代码层面会用到的通用寄存器主要是: AX, BX, CX, DX, DI, SI, R8~R15 这 14 个寄存器,虽然 BP 和 SP 也可以用,不过 BP 和 SP 会被用来管理栈顶和栈底,最好不要拿来进行运算。

    Plan9 汇编的操作数方向和 Intel 汇编相反的,与 AT&T 类似。它的一些特点如下:

  1. 没有 push 和 pop,栈的操作通过SP 寄存器进行运算来实现的
  2. 常数在 plan9 汇编用 $num 表示,可以为负数,默认情况下为十进制。
  3. 操作数方向与intel相反,与AT&T类似
  4. 数据搬运的长度由 MOV 的后缀决定,如下
MOVB $1, DI      // 1 byte
MOVW $0x10, BX   // 2 bytes
MOVD $1, DX      // 4 bytes
MOVQ $-10, AX     // 8 bytes

下面列出了常用的几个汇编指令(指令后缀Q 说明是 64 位上的汇编指令)

助记符指令种类用途示例
MOVQ传送数据传送MOVQ 18, AX // 把 18 传送到 AX
JLS转移条件转移指令JLS 0x0181 //左边小于右边,则跳到 0x0181
LEAQ传送地址传送LEAQ AX, BX // 把 AX 有效地址传送到 BX
PUSHQ传送栈压入PUSHQ AX // 将 AX 内容送入栈顶位置
POPQ传送栈弹出POPQ AX // 弹出栈顶数据后修改栈顶指针
ADDQ运算相加并赋值ADDQ BX, AX // 等价于 AX+=BX
SUBQ运算相减并赋值SUBQ BX, AX // 等价于 AX-=BX
CMPQ运算比较大小CMPQ SI CX // 比较 SI 和 CX 的大小
CALL转移调用函数CALL runtime.printnl(SB) // 发起调用
JMP转移无条件转移指令JMP 0x0181 //无条件转至 0x0181 地址处

三、Golang代码的汇编分析

查看方式

go tool compile -S .\main.go
也可 go build -gcflags -S .\main.go

-l 禁止内联 -N 编译时,禁止优化 -S 输出汇编代码

说明:下面仅简要演示一个Golang函数的汇编分析方式.

 
# cat x.go 

package main 

func main() { 
	println(3) 
} 

# go tool compile -S .\main.go 
 
main.main STEXT size=66 args=0x0 locals=0x10 funcid=0x0 align=0x0
        0x0000 00000 (G:\main.go:3)     TEXT    main.main(SB), ABIInternal, $16-0
        0x0000 00000 (G:\main.go:3)     CMPQ    SP, 16(R14)
        0x0004 00004 (G:\main.go:3)     PCDATA  $0, $-2
        0x0004 00004 (G:\main.go:3)     JLS     57
        0x0006 00006 (G:\main.go:3)     PCDATA  $0, $-1
        0x0006 00006 (G:\main.go:3)     SUBQ    $16, SP
        0x000a 00010 (G:\main.go:3)     MOVQ    BP, 8(SP)
        0x000f 00015 (G:\main.go:3)     LEAQ    8(SP), BP
        0x0014 00020 (G:\main.go:3)     FUNCDATA        $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
        0x0014 00020 (G:\main.go:3)     FUNCDATA        $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
        0x0014 00020 (G:\main.go:4)     PCDATA  $1, $0
        0x0014 00020 (G:\main.go:4)     CALL    runtime.printlock(SB)
        0x0019 00025 (G:\main.go:4)     MOVL    $3, AX
        0x001e 00030 (G:\main.go:4)     NOP
        0x0020 00032 (G:\main.go:4)     CALL    runtime.printint(SB)
        0x0025 00037 (G:\main.go:4)     CALL    runtime.printnl(SB)
        0x002a 00042 (G:\main.go:4)     CALL    runtime.printunlock(SB)
        0x002f 00047 (G:\main.go:5)     MOVQ    8(SP), BP
        0x0034 00052 (G:\main.go:5)     ADDQ    $16, SP
        0x0038 00056 (G:\main.go:5)     RET
        0x0039 00057 (G:\main.go:5)     NOP
        0x0039 00057 (G:\main.go:3)     PCDATA  $1, $-1
        0x0039 00057 (G:\main.go:3)     PCDATA  $0, $-2
        0x0039 00057 (G:\main.go:3)     CALL    runtime.morestack_noctxt(SB)
        0x003e 00062 (G:\main.go:3)     PCDATA  $0, $-1
        0x003e 00062 (G:\main.go:3)     NOP
        0x0040 00064 (G:\main.go:3)     JMP     0
        0x0000 49 3b 66 10 76 33 48 83 ec 10 48 89 6c 24 08 48  I;f.v3H...H.l$.H
        0x0010 8d 6c 24 08 e8 00 00 00 00 b8 03 00 00 00 66 90  .l$...........f.
        0x0020 e8 00 00 00 00 e8 00 00 00 00 e8 00 00 00 00 48  ...............H
        0x0030 8b 6c 24 08 48 83 c4 10 c3 e8 00 00 00 00 66 90  .l$.H.........f.
        0x0040 eb be                                            ..
        rel 21+4 t=7 runtime.printlock+0
        rel 33+4 t=7 runtime.printint+0
        rel 38+4 t=7 runtime.printnl+0
        rel 43+4 t=7 runtime.printunlock+0
        rel 58+4 t=7 runtime.morestack_noctxt+0
go:cuinfo.producer.<unlinkable> SDWARFCUINFO dupok size=0
        0x0000 72 65 67 61 62 69                                regabi
go:cuinfo.packagename.main SDWARFCUINFO dupok size=0
        0x0000 6d 61 69 6e                                      main
main..inittask SNOPTRDATA size=24
        0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
        0x0010 00 00 00 00 00 00 00 00                          ........
gclocals·g2BeySu+wFnoycgXfElmcg== SRODATA dupok size=8
        0x0000 01 00 00 00 00 00 00 00                          ........

说明: FUNCDATA and PCDATA含垃圾收集器使用的信息;它们由编译器引入。

函数的调用过程

image.png Golang一律使用栈来传输入参与出参,所以函数调用有一定的性能损耗,通过函数内联来缓解这个问题的影响,这和C/CPP有点差异.

Golang的编译过程

  1. 词法分析:根据空格等符号分词
  2. 语法分析:生成AST
  3. 语义分析:类型检查+逃逸分析+内联等 (禁止函数内联就是操作这个步骤)
  4. 中间码生成:替换一些底层函数(如判断使用makeslice64或makeslice)
  5. 代码优化:顾名思义,就是搞提升并行,指令优化,利用寄存器等代码优化
  6. 机器代码生成:根据GOARCH,生成plan9,最终生成本地汇编

plan9的常量

尽管go汇编器的指导来自 Plan 9 汇编器,但它却是一个不同的程序,因此存在一些差异。
汇编器中的常量表达式使用 Go 的运算符优先级进行解析,而不是原始的类 C 优先级。因此3&1<<2是 4,而不是 0——它解析为(3&1)<<2 not 3&(1<<2)。此外,常量始终被评估为 64 位无符号整数。因此-2不是整数值减二,而是具有相同位模式的无符号 64 位整数。区别很少重要,但为了避免歧义,拒绝设置右操作数的高位的除法或右移。

plan9符号

所有用户定义的符号都作为偏移量写入伪寄存器 FP(参数和局部变量)和SB(全局变量)。

Go 汇编引入了 4 个伪寄存器,官方定义如下:

  • FP: Frame pointer: arguments and locals.帧指针,参数和本地变量s
  • PC: Program counter: jumps and branches.程序计数器,用于跳转和分支
  • SB: Static base pointer: global symbols.静态基指针
  • SP: Stack pointer: top of stack.栈指针,指向栈顶

伪寄存器SB 可以认为是内存的本源,所以符号foo(SB) 就是名字foo作为内存中的一个地址。
这种形式用于命名全局函数和数据。添加<>到名称后,如foo<>(SB),使名称仅在当前源文件中可见,就像C 文件中的顶级声明一样。 向名称添加偏移量,是指从符号地址开始的偏移量。

伪寄存器FP是用于引用函数参数的虚拟帧指针。编译器维护一个虚拟帧指针并将堆栈上的参数引用为该伪寄存器的偏移量。因此0(FP)是函数的第一个参数, 8(FP)是第二个(在 64 位机器上),依此类推。

但是,当以这种方式引用函数参数时,有必要在开头放置一个名称,如first_arg+0(FP)and second_arg+8(FP)。(偏移量的含义——相对于帧指针的偏移量——不同于它与 的用法SB,后者是相对于符号的偏移量。)汇编程序强制执行此约定,拒绝普通的0(FP)and 8(FP)。实际名称在语义上无关紧要,但应该用于记录参数名称。值得强调的是FP始终是伪寄存器,而不是硬件寄存器,即使在具有硬件帧指针的体系结构上也是如此。

对于带有 Go 原型的汇编函数,go vet将检查参数名称和偏移量是否匹配。在 32 位系统上,通过在名称中添加_loor _hi 后缀来区分, 如果 Go 原型没有命名其结果,则预期的程序集名称为. govet_lo_hiarg_lo+0(FP)arg_hi+4(FP)ret

伪寄存器SP,是一个虚拟的堆栈指针,用于引用帧局部变量和参数,为函数调用做好准备.它指向本地栈帧的最高地址. 所以,引用需要使用负偏移在范围上,如 [−framesize, 0): x-8(SP)y-4(SP)

在具有硬件寄存器SP的架构中,name前缀来区分指向虚拟堆栈指针亦或指向硬件架构的真寄存器,eg:symbol-offset(SP) 则表示伪寄存器 SP。eg:offset(SP) 则表示硬件 SP。

在计算机上SP并且是 传统上是物理编号寄存器的别名, 在 Go 汇编器中的名称,并且仍然被特殊对待; 例如,引用需要符号, 很像. 要访问实际的硬件寄存器,请使用真实名称。 例如,在 ARM 体系结构上,硬件和可作为 和 进行访问。PC``SP``PC``SP``FP``R``SP``PC``R13``R15

分支和直接跳转始终作为偏移量写入 PC,或写入 跳转到标签:

label:
	MOVW $0, R1
	JMP label

每个标签仅在定义它的函数中可见。 因此,允许文件中的多个函数定义 并使用相同的标签名称。 直接跳转和调用指令可以针对文本符号, 例如 ,但不是符号的偏移量, 如.name(SB)name+4(SB)

指令、寄存器和汇编指令始终为大写,以提醒您 汇编编程是一项令人担忧的工作。 (例外:g在 ARM 上注册重命名。)

四、plan9函数的编写

// 函数声明
// 该声明一般写在任意一个 .go 文件中,例如:add.go
func add(a, b int) int

// 函数实现
// 该实现一般写在与声明同名的 _{Arch}.s 文件中,例如:add_amd64.s
TEXT pkgname·add(SB), NOSPLIT, $0-24
    MOVQ a+0(FP), AX //读取栈帧的第一个参数到AX
    MOVQ a+8(FP), BX //读取栈帧的第二个参数到BX
    ADDQ AX, BX //BX=AX+BX
    MOVQ BX, ret+16(FP) //将BX的值,放到栈帧的返回地址上
    RET

plan9 中 TEXT 是一个指令,用来定义一个函数。 pkgname 是可以省略的,(非想写也可以写上,不过写上 pkgname 的话,在重命名 package 之后还需要改代码,默认为"") 编译器会在链接期自动加上所属的包名称 可以参考 go 的源码, 另外 add 前的 · 不是 .

                   参数及返回值大小
                                 | 
 TEXT pkgname·add(SB),NOSPLIT,$0-16
         |     |               |
        包名  函数名    栈帧大小(局部变量+可能需要的额外调用函数的参数空间的总大小,
                               但不包括调用其它函数时的 ret address 的大小)
  • (SB): SB 是一个虚拟寄存器,保存了静态基地址(static-base) 指针,即程序地址空间的开始地址。"".add(SB) 表明符号位于某个固定的相对地址空间起始处的偏移位置 。换句话来讲,它有一个直接的绝对地址: 是一个全局的函数符号。

  • NOSPLIT: 向编译器表明,不应该插入 stack-split 的用来检查栈需要扩张的前导指令。在我们 add 函数的这种情况下,编译器自己帮我们插入了这个标记: 它足够聪明地意识到,add 不会超出当前的栈,因此没必要调用函数时在这里执行栈检查。

五、总结

本文简介了Golang的汇编的寄存器知识,汇编分析方式,堆栈知识,后续文章的中将会继续出现.