封面图出自 Renee French 遵循 Creative Commons 4.0 Attributions license
寄存器#
Plan9 | amd64 | 通常用途 | |
---|---|---|---|
AX | rax | Accumulator | 存放算数操作数和返回值 |
BX | rbx | Base Register | 存储内存基址 (结构体或数组) 或指针 |
CX | rcx | Count Register | 计数操作如循环计数器 |
DX | rdx | Data Register | 存储数据如乘数 / 除数 |
DI | rdi | Destination Index | 目的操作数的偏移量 |
SI | rsi | Source Index | 源操作数的偏移量 |
BP | rbp | Base Pointer | 保存栈基址 |
SP | rsp | Stack Pointer | 保存栈顶指针 |
PC | rip | Program Counter | 程序计数器 |
R8-R14 | r8-r14 | 通用寄存器 |
伪寄存器#
名称 | 用途 |
---|---|
FP(Frame pointer) | 参数和局部变量的基址 |
PC(Program counter) | 程序计数器 |
SB(Static base pointer) | 全局变量基址 |
SP(Stack pointer) | 栈指针 (当前栈帧的最高地址) |
所有用户程序中定义的本地变量都会被编译成 FP 和 SB 这两个寄存器上的基址加上一定量的偏移量。
伪寄存器 SB 用于引用全局变量如:
- foo (SB) 表示全局变量 foo 的内存地址
- foo<>(SB) 表示全局变量 foo 只在当前文件可见
- foo+4 (SB) 表示 foo 内存基址加上四个字节偏移量
伪寄存器 FP 用于保存虚拟的栈指针,以便引用函数参数,编译器使用 FP 加上偏移量来访问当前函数的参数,在访问时还可以附加一个参数名,尽管没有实际的用途,但是有助于理解和阅读代码,此外汇编器也强制使用 FP 时必须附加参数名如:
- 0 (FP) 或者是 first_arg+0 (FP) 表示当前函数的第一个参数
- 8 (FP) 或者是 second_arg+8 (FP) 表示当前函数的第二个参数 (第一个参数占 8 字节)
注意:无论是否存在硬件 FP 寄存器 FP 都是一个伪寄存器。
伪寄存器 SP 保存虚拟的栈指针,用于访问当前栈帧内的局部变量和函数调用参数,其指向当前栈帧的最高地址,则偏移量只能在 [−framesize, 0) 范围内。如 x-8 (SP) y-4 (SP) 在具备硬件 SP 寄存器的体系结构中,使用带参数名前缀和不带参数名前缀访问 SP 寄存器有以下区别:
- x-8 (SP) 带参数前缀访问时使用的是伪寄存器 SP
- -8 (SP) 不带参数前缀访问时使用的是硬件寄存器 SP
符号定义#
在 Go 的对象文件和二进制文件中,完整的符号名由包路径跟上一个点加上一个符号名组成如 math/rand.Int 在源文件转汇编的过程中,编译器会将其转换成 math∕rand・Int 这里斜杠和点被转换成 U+2215 和 U+00B7 在手写汇编定义符号无需包含完整的包名,在链接过程中,链接器会自动为每个以。开头的符号添加完整的包名,因此只需定义类似・Int 的符号名即可。
汇编器使用编程序指令 directives 将代码或数据绑定到符号,比如:
函数符号定义#
函数 (代码段) 通过 TEXT 指令定义如:
TEXT runtime·profileloop(SB),NOSPLIT,$24-8
- pkgname: 包名,可以省略
- funcname (SB): 函数名,因为函数本身是全局符号,通过 SB 引用
- NOSPLIT: 汇编指令参数,后续会介绍
- $24-8: 分别标识此函数的栈帧和 (参数 + 返回值) 的大小,当使用 NOSPLIT 参数时必须提供栈帧大小
全局数据符号定义#
全局数据通过一组 DATA 指令加上一个 GLOBAL 指令来定义 DATA 指令的格式是
DATA symbol+offset(SB)/width, value
表示在符号 symbol 的指定偏移量 offset 处初始化一个大小为 width 初始值为 value 的内存段,多个 DATA 指令的 offset/width 必须是连续的。GLOBAL 指令用于声明全局符号,需要指定符号名,参数和大小。如果 DATA 指令没有初始化值则 GLOBAL 会将其初始化为 0 如:
DATA divtab<>+0x00(SB)/4, $0xf4f8fcff
DATA divtab<>+0x04(SB)/4, $0xe6eaedf0
...
DATA divtab<>+0x3c(SB)/4, $0x81828384
GLOBL divtab<>(SB), RODATA, $64
GLOBL runtime·tlsoffset(SB), NOPTR, $4
上述代码明和初始化了一个 64byte 的只读全局变量 divtab 和一个 4byte 的全局变量 runtime・tlsoffset 并将它们都初始化为 0 这里声明了 NOPTR 即这些数据都不包含指针
符号定义的参数#
每条汇编指令可以包含一个或两个参数,如果有两个参数,则第一个参数必须是标志位掩码。所有的参数定义需要通过 #include "textflag.h" 引入,参数如下:
- DUPOK: 允许同二进制中有多个相同的符号,链接器会选择其中之一
- NOSPLIT: 用于 TEXT 指令,标记不需要插入栈溢出检查
- RODATA: 用于 DATA 和 GLOBAL 指令,将数据放入只读段
- NOPTR: 用于 DATA 和 GLOBAL 指令,标记数据不包含指针,不需要 GC 扫描
- WRAPPER: 用于 TEXT 指令,标记该函数只是一个 wrap 不要禁用 recover 见源码 src/debug/gosym/pclntab.go
- NEEDCTXT: 用用于 TEXT 指令,标记该函数为闭包需使用传入的上下文寄存器
- TLSBSS: 用于 DATA 和 GLOBAL 指令,标记分配 TLS 存储单元并将其偏移量存储在变量中
- NOFRAME: 用于 TEXT 指令,标记不在函数中插入分配栈帧空间的指令,适用于零栈帧函数
- REFLECTMETHOD: 标记函数可以调用 reflect.Type.Method/reflect.Type.MethodByName
- TOPFRAME: 用用于 TEXT 指令,标记此函数为调用栈的最上层,栈展开应在此停止
- ABIWRAPPER: 用于 TEXT 指令,标记该函数为 ABI 的 wrapper
在汇编中使用 Go 类型和常量#
如果一个包中包含 .s 文件则在进行 build 时编译器会输出一个特殊的头文件 go_asm.h 该头文件包含了很多常量的定义比如:结构体字段偏移量,结构体类型大小以及当前包中定义的常量。汇编中可以通过包含此头文件来使用 Go 类型,在 go_asm.h 文件中,各种类型以以下形式定义:
- 常量: const_name
- 结构体字段偏移量: type_field
- 结构体大小: type__size
const bufSize = 1024
type reader struct {
buf [bufSize]byte
r int
}
以上代码为例,在汇编代码中可以:
- 通过 const_bufSize 使用常量 bufSize
- 通过 reader__size 获取结构体 reader 的大小
- 通过 reader_buf 和 reader_r 获取 buf 和 r 字段的偏移量。如果 R1 包含一个 reader 指针则可以通过 reader_buf (R1) 和 reader_r (R1) 来访问两个字段
运行时#
为确保 GC 运行的正确性,运行时必须了解栈帧和全局变量中包含的所有指针。编译器在编译 go 代码会自动插入这些信息,但在汇编代码中需要显式定义。有 NOPTR 参数的数据符号不包含运行时分配的数据指针;有 RODATA 参数的符号数据在内存只读段中分配,因此也隐含 NOPTR 标记;小于指针大小的类型也自然不可能包含指针。虽然无法在汇编代码中定义包含指针的符号,但是可以在 go 代码中定义并在汇编代码中通过相应的符号来引用。一般最好的的实践方式是:在 go 中定义所有的非只读符号,而不是汇编代码中定义。
每个函数需要标注其参数,返回值和栈帧中的存活指针的位置。如果汇编函数没有指针返回值,没有函数调用和栈帧空间需求,只需要在同一包中定义 go 函数原型 (签名) 即可。对于更复杂的情况,需要包含 funcdata.h 头文件来引用伪汇编指令进行显式标注。没有参数和返回值 (TEXT 指令中标注 $n-0) 的函数可以忽略指针信息。除此之外,所有指针信息必须通过 go 代码中的函数原型 (签名) 提供,即使是不会被 go 函数直接调用的汇编函数。
在函数的开头,可以假设参数已经被初始化,但是返回值未初始化,如果返回值中存在函数调用期间存活的指针,函数应该一开始就将返回值置空并执行 GO_RESULTS_INITIALIZED 伪指令,这个指令记录了返回值已经被初始化,在栈转移 (扩容) 和 GC 期间应该被扫描。大部分情况下可以有意避免在汇编函数中返回指针,至少在标准库中没有汇编函数使用 GO_RESULTS_INITIALIZED
如果函数没有本地栈帧 (即在 TEXT 指令中声明 $n-0) 或函数中不包含 CALL 指令则可以忽略指针信息。除此以外本地栈帧不能包含指针,汇编器会执行伪指令 NO_LOCAL_POINTERS 进行验证。由于栈的扩张和收缩是通过复制移动栈空间实现的,函数调用期间栈指针可能会改变,因此即使是指向栈上数据的指针也不应该保存在局部变量中。
汇编函数应该始终给出 Go 原型,这样既可以为参数和返回值提供指针信息,又可以让 go vet 检查偏移量的使用是否正确。
内存布局#
Go 语言内置的基础类型大小和对齐方式及复合类型 (结构体) 内字段偏移量的计算可以查看 ABI 文档 Memory layout 对于其他类型:
- map/chan/func 类型的内存布局等价于 *T
- 数组类型
[N]T
内存布局由 N 个 T 类型组成的连续内存构成 - string 类型在内存中由两部分组成:标识字符串按字节长度的 int 和指向
[cap]T
的指针 - 切片类型
[]T
在内存中由三部分组成:标识切片有效长度的 int 标识切片容量大小的 int 和指向[cap]T
的指针
结构体类型的内存是由其各个字段的内存连续组成的。如 struct { f1 t1; ...; fM tM }
类型的结构体内存中的顺序为 t1, ..., tM, tP
这里 tP 是一个额外的字节当且仅当在最后一个字段 tM 的大小为零而前面的任意一个字段 ti 的大小不为零时填充。通过实验可以得知,在结构体中对大小为零的字段取址时,总是返回该字段后面第一个非零大小类型字段的地址。所以在最后一个零大小字段后面填充了一个字节以保证取址不会访问到外部的内存上去。
type S struct { // 0xc00034c000
A struct{} // 0xc00034c000
B int // 0xc00034c000
C struct{} // 0xc00034c008
D struct{} // 0xc00034c008
E int // 0xc00034c008
F struct{} // 0xc00034c010
}
空接口 interface {} 类型 runtime.eface 由以下部分组成:
- 指向运行时动态数据类型描述的指针
- 指向运行时动态数据值的 unsafe.Pointer 指针
非空接口类型由以下部分组成:
- 指向 runtime.itab 的指针包含:
- runtime.interfacetype 包含此接口相关的 method 指针
- 指向运行时动态数据类型描述的指针
- 指向运行时动态数据值的 unsafe.Pointer 指针
接口类型可以是直接或者是间接类型:
- 直接类型的接口直接存储了数据
- 间接类型的接口存储了指向数据的指针
- 如果接口内的值只有单个指针组成,那么这个接口类型只能是直接类型
以上就是 Go 所有类型的内存布局结构,但是在手写汇编函数时不应该依赖这些规则,而是引用 go_asm.h 头文件中定义的常量。
函数调用的参数和返回值传递#
函数调用过程中参数 / 返回值通过栈和硬件寄存器传递。每个参数 / 返回值可能会整体存储在寄存器上 (可以用多个寄存器同时存储一个参数 / 返回值), 或存储在栈上。通常情况下,由于访问寄存器比访问内存要快,参数 / 返回值优先存储在寄存器中,然而当剩余的寄存器无法存储完整值或者包含有非定长数组时,参数 / 返回值只能通过栈传递。
每个体系结构都定义了一组整数寄存器和一组浮点数寄存器,从高层次来看,所有参数和返回值类型都可以拆分成基础类型并按顺序存储在寄存器上。参数和返回值可以共用一个寄存器,但是不能共用同一段栈空间。调用者会在调用栈上为存储在寄存器中的参数保留一段溢出空间,但是不会填充这一段空间。具体参数 / 返回值在寄存器或栈上分配的算法比较复杂,参考 Function call argument and result passing
在调用方法之前,需要先在调用者的栈帧中分配一段内存来存储方法接收者、栈上参数、栈上返回值以及寄存器参数溢出空间。然后将对应的参数值存储到寄存器或栈空间中,执行调用操作。在执行调用时,返回值栈空间、溢出空间和返回值寄存器都没有被初始化,被调用者需要在返回之前将返回值存储到按照算法分配的相应寄存器或栈帧空间中。由于不存在 callee-save 寄存器,因此所有没有明确意义的寄存器都可能会被覆盖,包括参数寄存器。
在带有 R0-R9 整数寄存器的 64 位架构下,函数 f 签名及其调用栈空间如下:
func f(a1 uint8, a2 [2]uintptr, a3 uint8) (
r1 struct { x uintptr; y [2]uintptr },
r2 string,
)
// 栈空间布局
// a2 [2]uintptr
// r1.x uintptr
// r1.y [2]uintptr
// a1Spill uint8
// a3Spill uint8
// _ [6]uint8 // alignment padding
由于 a2 和 r1 包含数组,因此它们只能分配到栈上进行赋值,其他的参数和返回值可以分配到寄存器上。r2 被解构成两个可以独立的在寄存器上赋值的部分。在调用时 a1 会赋值给 R0 寄存器 a3 会赋值给 R1 寄存器 a2 会在栈上赋值。在返回时 r2.base 会赋值给 R0 寄存器 r2.len 会赋值给 R1 寄存器 r1.x 和 r1.y 会在栈空间中赋值。
闭包#
函数值如 var f func
相当于一个指向闭包对象的指针,闭包由闭包函数入口地址和一些与闭包环境相关的内存空间组成。闭包的调用规则和静态函数与基本一致,唯一的例外在于:每个体系结构下都设定了一个特殊的闭包上下文寄存器,调用闭包前会将闭包对象指针保存在这个寄存器上。这样即使是闭包函数退出后,还能通过这个特殊的寄存器引用到闭包内的对象。
常用指令#
// TODO
Reference#
官方文档和代码:
其他资料: