Go Plan9 汇编初探

标签:Go语言首次发布:2024-01-25最近修改:2024-04-02

前言

来自 Rob Pike 的 The Design of the Go Assembler(已翻译):

关于 Go 语言的汇编器最重要的一点是,它并不是底层机器的直接表示。其中一些细节确实精确映射到机器上,但有些并不是。这是因为编译器套件在常规的编译流程中并不需要一个汇编器阶段。相反,编译器操作的是一种半抽象的指令集,而指令选择部分是在代码生成之后进行的。汇编器工作在这种半抽象形式上,因此当你看到像 MOV 这样的指令时,工具链实际为该操作生成的可能根本不是移动指令,也许是清除或加载指令。

或者,它可能完全对应于具有该名称的机器指令。通常,机器特定的操作倾向于以它们本身的形式出现,而更一般的概念,如内存移动、子程序调用和返回则更抽象。细节会随着架构的不同而有所变化,对于这种不精确性我们表示歉意;情况并不明确。

汇编程序是解析该半抽象指令集描述,并将其转化为输入给链接器的指令的一种方式。

对于 Go 编译器而言,其输出的结果是一种抽象可移植的汇编代码,这种汇编(Go 的汇编是基于 Plan9 的汇编)并不对应某种真实的硬件架构。Go 的汇编器会使用这种伪汇编,再为目标硬件生成具体的机器指令。伪汇编这一个额外层可以带来很多好处,最主要的一点是方便将 Go 移植到新的架构上。

寄存器

通用寄存器

不同体系结构的 CPU,其内部寄存器的数量、种类以及名称可能大不相同,这里以 AMD64 寄存器为例。AMD64 有 20 多个可以直接在汇编代码中使用的寄存器,其中有几个寄存器在操作系统代码中才会见到,而应用层代码一般只会用到如下三类寄存器。 image-20240120162941436

上述这些寄存器除了段寄存器是 16 位的,其它都是 64 位的,也就是 8 个字节,其中的 16 个通用寄存器还可以作为 32/16/8 位寄存器使用,只是使用时需要换一个名字,比如可以用 EAX 这个名字来表示一个 32 位的寄存器,它使用的是 RAX 寄存器的低 32 位。

AMD64 的通用寄存器的名字在 plan9 中的对应关系如下: image-20240120164127797

Go 语言中寄存器一般用途: image-20240120164311633

伪寄存器

Go 汇编为了简化汇编代码的编写,引入了 PC、FP、SP、SB 四个伪寄存器。四个伪寄存器加其它的通用寄存器就是 Go 汇编语言对 CPU 的重新抽象。

  • PC:程序计数器,指向下一条要执行的指令的地址,在 AMD64 对应 rip 寄存器。个人觉得,由于每个平台对应的物理寄存器名字不一样,所以在这里将其归为伪寄存器。

  • FP:使用形如 symbol+offset(FP) 的方式,引用函数的输入参数。例如 arg0+0(FP),arg1+8(FP),使用 FP 不加 symbol 时,无法通过编译,在汇编层面来讲,symbol 并没有什么用,加 symbol 主要是为了提升代码可读性。另外,官方文档虽然将伪寄存器 FP 称之为 frame pointer,实际上它根本不是 frame pointer,按照传统的 x86 的习惯来讲,frame pointer 是指向整个 stack frame 底部的 BP 寄存器。假如当前的 callee 函数是 add,在 add 的代码中引用 FP,该 FP 指向的位置不在 callee 的 stack frame 之内,而是在 caller 的 stack frame 上。

  • SP:SP 寄存器比较特殊,既可以当做物理寄存器也可以当做伪寄存器使用,不过这两种用法的使用语法不同。其中,伪寄存器使用语法是 symbol+offset(SP),此场景下 SP 指向局部变量的起始位置(高地址处);x-8(SP) 表示函数的第一个本地变量;物理 SP(硬件 SP) 的使用语法则是 +offset(SP),此场景下 SP 指向真实栈顶地址(栈帧最低地址处)。

  • SB:全局静态基指针,一般用来声明函数或全局变量。

当需要区分伪寄存器和真寄存器的时候只需要记住一点:伪寄存器一般需要一个标识符和偏移量为前缀,如果没有标识符前缀则是真寄存器。比如 (SP)、+8(SP) 没有标识符前缀为真 SP 寄存器,而 a(SP)、b+8(SP) 有标识符为前缀表示伪寄存器。

另外还有 1 个比较特殊的伪寄存器:TLS:存储当前 goroutine 的 g 结构体的指针。实际上,X86 和 AMD64 下的 TLS 是通过段寄存器 FS 或 GS 实现的线程本地存储基地址,而当前 g 的指针是线程本地存储的第一个变量。

汇编指令

操作指令

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

助记符 指令种类 用途 示例
MOVQ 传送 数据传送 MOVQ 48, AX // 把 48 传送到 AX
LEAQ 传送 地址传送 LEAQ AX, BX // 把 AX 有效地址传送到 BX
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 0x0185 //无条件转至 0x0185 地址处
JLS 转移 条件转移指令 JLS 0x0185 //左边小于右边,则跳到 0x0185

伪指令

  1. TEXT 指令

    TEXT 指令用于定义一个函数的开始。它告诉汇编器函数的名字、可见性、参数大小和栈帧大小。语法:

    text
    TEXT symbol(SB), flags, $frameSize-argSize
    • symbol: 函数名,通常使用双引号包围,后缀为 (SB),表示这个符号是相对于静态基址寄存器(Static Base register,即 SB)的。
    • flags: 一组标志,如 NOSPLIT 表示函数不包含栈分割 prolog。
    • $frameSize: 函数调用所需的栈空间大小。
    • argSize: 函数参数的大小。
  2. GLOBL 指令

    GLOBL 指令用于定义一个全局变量的属性,比如它的可见性(是否能够被其他文件中的代码访问)和它的对齐要求。语法:

    text
    GLOBL symbol(SB), flags, $size
    • symbol: 全局变量的名字,通常使用双引号包围,后缀为 (SB)。
    • flags: 标志来指定变量的一些属性,如 RODATA(只读数据)或 NOPTR(没有指针的数据)等。
    • $size: 变量占用的字节数。
  3. DATA 指令

    DATA 指令用于初始化在数据段中的变量,可以为变量提供初始值。语法:

    text
    DATA symbol+offset(SB)/width, value
    • symbol: 要初始化的变量名,后缀为 (SB)。
    • offset: 变量内的偏移量,用于指定哪个部分的数据被初始化。
    • width: 数据的宽度(字节大小),常见的有 1、2、4、8 等。
    • value: 要设置的值,具体格式依赖于 width。
  4. FUNCDATA 指令

    FUNCDATA指令在 Go 汇编中用来关联一些元数据,这些元数据会在运行时被垃圾回收器(GC)和其他运行时系统组件使用,以执行栈内存管理和垃圾收集。

    text
    FUNCDATA    $index, symbol(SB)
    • $index: 一个立即数参数,通常是一个整数常量,用来表示不同类型的元数据。例如,$0$1 是预定义的索引,分别用于局部变量和传入参数的 GC(垃圾收集)信息。
    • symbol(SB): 这是与$index关联的数据,通常是一个伪指令生成的符号,这个符号实际上代表了一个编码过的位图或其他形式的数据。该符号表示在静态基址(SB)段中的一个位置。

    FUNCDATA 指令不会直接影响程序的执行流程,而是提供了额外的信息。这些信息在运行时被用来辅助栈内存的管理,特别是垃圾收集器在进行栈扫描时,用来确定哪些栈上的值是指针,哪些不是,以及指针指向的对象是否还活跃。

补充

函数运行时栈分裂

函数运行时栈分裂(stack splitting)是一个与协程和用户空间线程(如 Go 语言中的 goroutines)的实现相关的技术,其目的是为了在需要时动态地增加或减少函数调用栈的大小。

在 Go 语言中,每个 goroutine 开始时都只分配了一个很小的栈(通常是几千字节)。随着函数调用的深入,初始栈可能会不够用。为了解决这个问题,Go 运行时会使用栈分裂技术来检测栈空间是否足够。如果当前栈空间不足以支持更深层次的函数调用,它会分配一个更大的栈空间,然后将现有的栈数据复制到新的栈上,并更新栈指针,以便函数可以继续在新的空间上运行。这个过程对于正在运行的代码是透明的,也就是说,代码本身不需要关心栈的调整和分裂。

栈分裂在 Go 汇编代码中通常是通过 NOSPLIT 标志来控制的。如果一个函数使用了这个标志,它就告诉编译器这个函数不会进行栈分裂。例如:

text
TEXT ·myFunc(SB), NOSPLIT, $0

这行代码定义了一个函数 myFunc,并且指定它不会进行栈分裂。这通常用于系统调用或者其他不需要更多栈空间的场景。若没有 NOSPLIT 标志,编译器会生成额外的代码,在函数调用时先检查是否有足够的栈空间,如果没有,就会进行栈分裂操作。这种栈分裂机制的好处是它可以极大地减少每个协程的内存占用,允许程序并发运行成千上万的协程,而不会像传统线程那样因为固定的较大栈空间而受限。相反,栈的大小可以根据程序的实际需要动态调整,这使得协程模型比传统的线程模型更加灵活和高效。

寻址模式

Plan 9 汇编(有时也称为 Go 汇编,因为它在 Go 语言编译器中的使用)采用了一种特殊的寻址模式,它不同于传统的 x86 或 ARM 汇编寻址。寻址模式用于指定指令操作数的位置,可以是寄存器、内存地址或者二者的组合。以下是 Plan 9 汇编中常见的寻址模式:

  1. 寄存器寻址

    直接使用寄存器名称进行寻址。

    text
    MOVQ AX, BX  // 将 AX 寄存器的值移动到 BX 寄存器
  2. 立即数寻址

    操作数是一个直接的常量值。

    text
    MOVQ $5, AX  // 将常量 5 移动到 AX 寄存器
  3. 直接寻址(或绝对寻址)

    操作数是内存中的一个绝对地址。

    text
    MOVQ var(SB), AX  // 将全局变量 var 的值移动到 AX 寄存器

    这里,var(SB) 表示 var 相对于静态基址寄存器 SB 的位置。

  4. 间接寻址

    通过寄存器间接引用内存位置。

    text
    MOVQ (AX), BX  // 将 AX 寄存器指向的内存中的值移动到 BX 寄存器
  5. 基址寻址

    通过基址寄存器和常量偏移量来寻址。

    text
    MOVQ 8(AX), BX  // 将 AX 寄存器指向的内存地址加 8 后的位置中的值移动到 BX 寄存器
  6. 变址寻址

    通过基址寄存器和变址寄存器相结合的方式来寻址。

    text
    MOVQ (AX)(BX*8), CX  // 将 AX 寄存器加上 BX 寄存器乘以 8 后指向的内存中的值移动到 CX 寄存器
  7. 变址寻址与偏移量

    变址寻址加上一个常量偏移量。

    text
    MOVQ 4(AX)(BX*8), CX  // 将 AX 寄存器加上 BX 寄存器乘以 8 加上偏移量 4 后指向的内存中的值移动到 C

函数调用原理

Go 语言汇编中,函数声明格式如下:

text
告诉汇编器该数据放到TEXT区  ^                        静态基地址指针(告诉汇编器这是基于静态地址的数据)  |                                ^  |                                |   标签   函数入参+返回值占用空间大小  |                                |    ^      ^  |                                |    |      |TEXT pkgname·funcname<ABIInternal>(SB),TAG,$16-24     ^         ^        ^                   ^     |         |        |                   |函数所属包名  函数名  表示ABI类型           函数栈帧大小(本地变量占用空间大小)

一些说明

  • 栈帧大小包括局部变量和可能需要的额外调用函数的参数空间的总大小,但不包含调用其他函数时的 ret address 的大小。

  • 汇编文件中,函数名以 ‘·’ 开头或连接 pkgname 是固定格式。

  • go 函数采用的是 caller-save 模式,被调用者的参数、返回值、栈位置都由调用者维护。

下面是函数调用的栈帧示意图: image-20240121181516968

函数调用汇编分析

测试代码如下:

go
package mainfunc add(a, b int) int {	return a + b}func main() {	add(3, 5)}

使用go tool compile -S -N -l main.go这种方式直接输出汇编(-l 表示禁止内联;-N 编译表示禁止优化;-S 表示输出汇编代码)。生成的核心汇编代码如下:

text
main.sum STEXT nosplit size=56 args=0x10 locals=0x10 funcid=0x0 align=0x0        0x0000 00000 (main.go:3)        TEXT    main.sum(SB), NOSPLIT|ABIInternal, $16-16        // 栈相关操作        0x0000 00000 (main.go:3)        SUBQ    $16, SP        0x0004 00004 (main.go:3)        MOVQ    BP, 8(SP)        0x0009 00009 (main.go:3)        LEAQ    8(SP), BP        // gc垃圾处理        0x000e 00014 (main.go:3)        FUNCDATA        $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)        0x000e 00014 (main.go:3)        FUNCDATA        $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)        0x000e 00014 (main.go:3)        FUNCDATA        $5, main.sum.arginfo1(SB)        // 参数传递        0x000e 00014 (main.go:3)        MOVQ    AX, main.a+24(SP)        0x0013 00019 (main.go:3)        MOVQ    BX, main.b+32(SP)        // 初始化返回值        0x0018 00024 (main.go:3)        MOVQ    $0, main.~r0(SP)        // 执行函数体        0x0020 00032 (main.go:4)        MOVQ    main.a+24(SP), AX        0x0025 00037 (main.go:4)        ADDQ    main.b+32(SP), AX        0x002a 00042 (main.go:4)        MOVQ    AX, main.~r0(SP)        // 恢复BP寄存器的值和栈空间回收        0x002e 00046 (main.go:4)        MOVQ    8(SP), BP        0x0033 00051 (main.go:4)        ADDQ    $16, SP        0x0037 00055 (main.go:4)        RETmain.main STEXT size=54 args=0x0 locals=0x18 funcid=0x0 align=0x0        0x0000 00000 (main.go:7)        TEXT    main.main(SB), ABIInternal, $24-0        // 栈溢出检查。CMPQ比较栈指针SP和R14寄存器值(经过偏移)的内容。        // R14通常用来存储栈的边界。如果SP低于这个边界,则跳转到地址47        0x0000 00000 (main.go:7)        CMPQ    SP, 16(R14)        0x0004 00004 (main.go:7)        PCDATA  $0, $-2        0x0004 00004 (main.go:7)        JLS     47        0x0006 00006 (main.go:7)        PCDATA  $0, $-1        // 栈相关操        0x0006 00006 (main.go:7)        SUBQ    $24, SP        0x000a 00010 (main.go:7)        MOVQ    BP, 16(SP)        0x000f 00015 (main.go:7)        LEAQ    16(SP), BP        // gc垃圾处理        0x0014 00020 (main.go:7)        FUNCDATA        $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)        0x0014 00020 (main.go:7)        FUNCDATA        $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)        // 执行函数体        0x0014 00020 (main.go:8)        MOVL    $3, AX        0x0019 00025 (main.go:8)        MOVL    $5, BX        0x001e 00030 (main.go:8)        PCDATA  $1, $0        0x001e 00030 (main.go:8)        NOP        0x0020 00032 (main.go:8)        CALL    main.sum(SB)        // 恢复BP寄存器的值和栈空间回收        0x0025 00037 (main.go:9)        MOVQ    16(SP), BP        0x002a 00042 (main.go:9)        ADDQ    $24, SP        // 返回        0x002e 00046 (main.go:9)        RET        0x002f 00047 (main.go:9)        NOP        0x002f 00047 (main.go:7)        PCDATA  $1, $-1        0x002f 00047 (main.go:7)        PCDATA  $0, $-2        0x002f 00047 (main.go:7)        CALL    runtime.morestack_noctxt(SB)        0x0034 00052 (main.go:7)        PCDATA  $0, $-1        0x0034 00052 (main.go:7)        JMP     0

在汇编语言中,特别是在涉及栈操作的上下文中,理解寄存器、栈指针(SP)、基址指针(BP)和它们如何相互作用是非常重要的。所以先看相关的栈操作部分。

text
0x0006 00006 (main.go:7)        SUBQ    $24, SP    0x000a 00010 (main.go:7)        MOVQ    BP, 16(SP)    0x000f 00015 (main.go:7)        LEAQ    16(SP), BP    						······    0x0025 00037 (main.go:9)        MOVQ    16(SP), BP0x002a 00042 (main.go:9)        ADDQ    $24, SP
  • SUBQ $24, SP:就是给分配 24 字节给栈空间。
  • MOVQ BP, 16(SP):将 BP 寄存器中的值移动到栈指针 SP 指向的地址向上偏移 16 个字节的位置。
  • LEAQ 16(SP), BP:将栈指针 SP 指向的位置再向上偏移 16 个字节的地址赋值给 BP 寄存器。
  • MOVQ 16(SP), BP:将栈指针 SP 指向的地址向上偏移 16 个字节那个位置的值复制到 BP 寄存器中。
  • ADDQ $24, SP:栈空间回收,回收 24 字节。

看懂上面 MOVQ 和 LEAQ 指令的所表达的意思后,接下来就是要理解为什么这样做?也就是为什要把 BP 的值移动到 SP 指向的地址向上偏移 16 个字节的位置?为什么要把 SP 指向的位置再向上偏移 16 个字节的地址赋值给 BP?

其实只需要理解函数调用的步骤即可,每当调用一个函数就是建立一个新的栈帧,调用完成后要销毁这个新栈帧。也即:函数调用的本质就是栈帧的创建和销毁。再思考,既然要创建一个新的栈帧,那么原来的栈帧的 BP 寄存器的值是不是要保存?如果不保存的话,在函数调用完成后,BP 寄存器的值就会被覆盖,导致无法确认父函数(调用者)的栈帧。继续思考,创建一个栈帧的标志是什么?或者说不同的栈帧的本质区别是什么?其实只要 BP 寄存器保存的地址不同,栈帧就不同。

现在结合上面的汇编代码,问题就迎刃而解了。

  • SUBQ 和 ADDQ 的作用就是栈空间的分配和回收。
  • MOVQ 就是用来保存或恢复 BP 寄存器的值,这个 BP 寄存器里面存储的值是一个地址,是调用者的栈空间的基地址。
  • LEAQ 的作用就是赋予 BP 寄存器一个新地址,新地址的存储就代表着新栈帧的建立。