Go Asm(Who cares?)
Go's Assembler (Who cares?)
单纯只是想解析源码来看看汇编,没想到没忍住。(如果有基础可以直接跳到这里开始,这是直接分析Go的汇编以及从汇编角度说了说for range的坑。)
A Quick Guid to Go's Assembler(等)
注意: 相关汇编查看也需要注意Go语言版本,当前我的版本是1.20.5,从Go的1.17开始,Go的编译器开始使用AX, BX等的寄存器来传递函数参数以及接收函数返回值,这与之前版本使用栈来传递参数有不同。(参数多了还是使用栈),下面是官方release的log,所以记得看看版本信息。🥱
Go 1.17 implements a new way of passing function arguments and results using registers instead of the stack. Benchmarks for a representative set of Go packages and programs show performance improvements of about 5%, and a typical reduction in binary size of about 2%. This is currently enabled for Linux, macOS, and Windows on the 64-bit x86 architecture (the linux/amd64, darwin/amd64, and windows/amd64 ports). This change does not affect the functionality of any safe Go code and is designed to have no impact on most assembly code. It may affect code that violates the unsafe.Pointer rules when accessing function arguments, or that depends on undocumented behavior involving comparing function code pointers. To maintain compatibility with existing assembly functions, the compiler generates adapter functions that convert between the new register-based calling convention and the previous stack-based calling convention. These adapters are typically invisible to users, except that taking the address of a Go function in assembly code or taking the address of an assembly function in Go code using reflect.ValueOf(fn).Pointer() or unsafe.Pointer will now return the address of the adapter. Code that depends on the value of these code pointers may no longer behave as expected. Adapters also may cause a very small performance overhead in two cases: calling an assembly function indirectly from Go via a func value, and calling Go functions from assembly.
之后我会对一个例子的go1.14,go1.17,go1.20 的汇编代码进行比较来说明自1.17以来的变化。
注意:如果文章内没有说明go版本默认为1.20.5(主要这是我现在自己在用的版本)
好了开始先看go的小文章
第一段便说Go gc compiler使用的的assembly有点unusual啊哈哈哈哈,并且文档并不完全,我想这里指的是对这个Assembler并不完全介绍,其使用的是Plan 9风格的Assembler, well, which I don't know what is that. 😅 谁让这是个Quick Guide呢,大致看看。根据第一段的指引,Plan 9 Assemblers的具体细节可以在这个网址查看。
Go'assembler 并不直接代表底层机器的操作,有些会被map成别的操作,因为 This is because the compiler suite (see this description) needs no assembler pass in the usual pipeline. Instead, the compiler operates on a kind of semi-abstract instruction set, and instruction selection occurs partly after code generation。 也就是说,Go编译器不像传统编译过程一样,将高级语言翻译成底层机器代码而是使用一种so called semi-abstract instruction set 半抽象指令集进行操作,在代码生成之后才会选择具体指令。
说实话听着有点怪,但只能说与传统汇编器不同,并且你看到的指令可能与你想的并不是一个东西。
官方给出了查看go汇编代码的两种方式。
GOOS=linux GOARCH=amd64 go tool compile -S "$filename".go
# or
go build -gcflags -S "$filename".go
直接使用官方例子,下面是 x.go 文件的内容以及汇编情况
注意,这里顺带说一下,
go build -gcflags -S "$filename".go这个命令的输出是直接输出到标准错误的,所以如果需要重定向到文件需要将错误重定向到文件,也就是go build -gcflags -S "$filename".go 2>file类似于这样喔。
$ cat x.go # 这个我实际使用的是bat,cat的一个更好的rust实现
File: x.go
package main
func main() {
println(3)
}
$ go build -gcflags -S x.go
main.main STEXT size=66 args=0x0 locals=0x10 funcid=0x0 align=0x0
0x0000 00000 (x.go:3) TEXT main.main(SB), ABIInternal, $16-0
0x0000 00000 (x.go:3) CMPQ SP, 16(R14)
0x0004 00004 (x.go:3) PCDATA $0, $-2
0x0004 00004 (x.go:3) JLS 57
0x0006 00006 (x.go:3) PCDATA $0, $-1
0x0006 00006 (x.go:3) SUBQ $16, SP
0x000a 00010 (x.go:3) MOVQ BP, 8(SP)
0x000f 00015 (x.go:3) LEAQ 8(SP), BP
0x0014 00020 (x.go:3) FUNCDATA $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
0x0014 00020 (x.go:3) FUNCDATA $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
0x0014 00020 (x.go:4) PCDATA $1, $0
0x0014 00020 (x.go:4) CALL runtime.printlock(SB)
0x0019 00025 (x.go:4) MOVL $3, AX
0x001e 00030 (x.go:4) NOP
0x0020 00032 (x.go:4) CALL runtime.printint(SB)
0x0025 00037 (x.go:4) CALL runtime.printnl(SB)
0x002a 00042 (x.go:4) CALL runtime.printunlock(SB)
0x002f 00047 (x.go:5) MOVQ 8(SP), BP
0x0034 00052 (x.go:5) ADDQ $16, SP
0x0038 00056 (x.go:5) RET
0x0039 00057 (x.go:5) NOP
0x0039 00057 (x.go:3) PCDATA $1, $-1
0x0039 00057 (x.go:3) PCDATA $0, $-2
0x0039 00057 (x.go:3) CALL runtime.morestack_noctxt(SB)
0x003e 00062 (x.go:3) PCDATA $0, $-1
0x003e 00062 (x.go:3) NOP
0x0040 00064 (x.go:3) JMP 0
...
...
...
根据文档,FUNCDATA 和 PCDATA 包含垃圾回收的信息,是编译器自动加入的。这里的下一句是说继续查看链接之后使用objdump来查看二进制文件加了啥。所以go build -gcflags -S 是链接之前的汇编咯。
使用下面命令查看链接之后的内容(这个命令的输出又回到标准输出了🤨)
go tool objdump -s main.main x
TEXT main.main(SB) x.go
x.go:3 0x458560 493b6610 CMPQ 0x10(R14), SP
x.go:3 0x458564 7633 JBE 0x458599
x.go:3 0x458566 4883ec10 SUBQ $0x10, SP
x.go:3 0x45856a 48896c2408 MOVQ BP, 0x8(SP)
x.go:3 0x45856f 488d6c2408 LEAQ 0x8(SP), BP
x.go:4 0x458574 e8275efdff CALL runtime.printlock(SB)
x.go:4 0x458579 b803000000 MOVL $0x3, AX
x.go:4 0x45857e 6690 NOPW
x.go:4 0x458580 e81b65fdff CALL runtime.printint(SB)
x.go:4 0x458585 e87660fdff CALL runtime.printnl(SB)
x.go:4 0x45858a e8915efdff CALL runtime.printunlock(SB)
x.go:5 0x45858f 488b6c2408 MOVQ 0x8(SP), BP
x.go:5 0x458594 4883c410 ADDQ $0x10, SP
x.go:5 0x458598 c3 RET
x.go:3 0x458599 e862cdffff CALL runtime.morestack_noctxt.abi0(SB)
x.go:3 0x45859e 6690 NOPW
x.go:3 0x4585a0 ebbe JMP main.main(SB)
Constants (常量)
常量取值,使用的是Go语言操作符的优先级,而不是类似于C语言等的优先级。并且常量总被evaluate为 64-bit的unsigned integers类型。意思是没有负数咯,并且如果右操作数的高位被设置,除法和右移将被拒绝。(防止模棱两可的情况: gpt: [因为无符号整数的除法结果可能会有舍入误差,而在这种情况下,结果是不唯一的, 右移操作可能会根据底层机器的处理方式和规则而导致不同的结果])
Symbols (符号)
直接翻译:xinjiciwayicimohidouci !!! (滚筒洗衣机)
有些符号,如 R1 或 LR,是预定义的,指的是寄存器。具体设置取决于体系结构。 有四个预定义符号指向伪寄存器。这些不是真正的寄存器,而是由工具链维护的虚拟寄存器,例如帧指针。所有架构的伪寄存器集合都是一样的。
- FP: Frame pointer: arguments and locals.
- PC: Program counter: jumps and branches.
- SB: Static base pointer: global symbols.
- SP: Stack pointer: the highest address within the local stack frame.
这里只要稍微学过汇编(这里也说明了)这几个伪寄存器的作用。除了预定义的伪寄存器,所有用户定义的符号都作为偏移量写入伪寄存器FP以及SB。
SB
SB伪寄存器可以被视为起始地址
| expr | meaning |
|---|---|
foo(SB) | foo这个名称的地址 |
foo<>(SB) | foo 只在当前源文件可见 |
foo+4(SB) | 从foo开始偏移4字节 |
FP
FP 伪寄存器是一个虚拟帧指针,用于引用函数参数。编译器会维护一个虚拟帧指针,并将 堆栈上的参数 作为该伪寄存器的 偏移量 。(翻) 因此,0(FP) 是函数的第一个参数,8(FP) 是第二个参数(在 64 位机器上),以此类推。不过,在以这种方式引用函数参数时,有必要在开头加上一个名称,如 first_arg+0(FP) 和 second_arg+8(FP)。此处的offset,指的是与帧指针(FP)的偏移量,SB中的偏移量是与Symbol的偏移量。
-
对于带有Go原型的汇编函数,go vet(检查参数名称和偏移是否匹配[待会继续看看是啥情况])
-
在 32 位系统上,64 位值的低 32 位和高 32 位通过在名称后添加 _lo 或 _hi 后缀来区分,如 arg_lo+0(FP) 或 arg_hi+4(FP)。如果 Go 原型没有为其结果命名,则预期的汇编名称为 ret。
注意: 返回值的地址位于参数之后。在使用偏移量获取参数之后,之后的地址指向返回值。
SP(Important)
SP作为虚拟堆栈的伪寄存器,用于帧本地变量和为函数调用准备的参数。它指向本地堆栈帧内的最高地址,因此引用时应使用 [-framesize, 0) (注意这个负数的范围) 范围内的负偏移量:x-8(SP)、y-4(SP) 等。(这里的x和y就是symbol, eg:symbol+offset(SP))
注意: 有symbol的SP和没symbol的SP不是一个东西,手写的时候有symbol的sp,即
symbol+offset(SP)为伪SP, 而直接使用的spMOVQ offset(SP)为硬件SP。 后面说到栈结构会有图示。但是这是在自己手写汇编时的说法,如果使用go tool compile得到的代码都是硬件SP。
栈结构(!Important)
现在还是一头雾水的话,主要是这三个伪寄存器在栈内的位置还不明确,以及如何使用的,由于执行下面才会说倒,所以具体代码示例放在指令之后,这里先来说明栈结构。下面是栈的结构图。 根据用户事先声明的栈大小(下面会提到$framesize-argusize),如果栈大小大于0,编译器则会插入caller的BP(伪SP)寄存器的值。在caller(调用方)的BP寄存器的地址没有被编译器插入时,栈结构如下。

当framesize大于0时,插入bp。

所以可以看出,伪SP偏移量始终是负数,硬件SP偏移非负数来设置callee的参数。地址值大小为由SP向上,从低到高。
因为手写汇编和查看go编译器生成的代码并不是完全一样,framesize在手写时并不包含BP这种编译器自动插入的字节,自己计算的话其实只要不把变量覆盖或者爆栈就行,而go编译器输出的汇编代码内的framesize是编译器计算得出的。
寻址模式

注意:本地变量
指令
在go的汇编中,普通符号 . 以及 / 会被汇编器当作标点符号,所以需要使用Unicode码点为 U+00B7 也就是 · 代替 .,U+2215 也就是 ∕ 代替 /。但是使用 -S 获得的汇编代码会替换回 . 和 /。
大多数手工编写的汇编文件不会在符号名称中包含完整的软件包路径,因为链接器会在任何以句号开头的名称的开头插入当前对象文件的软件包路径:在 math/rand 软件包实现的汇编源文件中,软件包的 Int 函数可以被称为 ·Int。这一约定避免了在源代码中硬性编码软件包的导入路径,使代码从一个位置移动到另一个位置变得更容易。
该文章之后说明了 TEXT 指令用于定义函数, DATA 指令用于定义数据段。在Go包中调用汇编函数,一般将函数名定义为SB寄存器的偏移量,并且在一般情况下,帧大小后面跟一个参数大小,中间用减号隔开。下面是一个例子。
TEXT main.hello(SB), $48-16
这里表示定义main包内的hello函数,本地变量,也就是本地栈帧占用48字节,函数参数占用16字节。
注意:我在文档中看到的是后一个数字代表的是参数,并没有包含返回值,在参考其他文章的时候,都说包含返回值,可是我自己测试是没有的。 官方文档: The frame size $24-8 states that the function has a 24-byte frame and is called with 8 bytes of argument, which live on the caller's frame. 最后发现,这玩意为optional, 但是可能会被go vet检查,不想看咋写以及为啥了,反正看自己吧。
数据搬运
常数可表示为0x123表示16进制。
MOVB $1, DI // 1 byte
MOVW $0x10, BX // 2 bytes
MOVD $1, DX // 4 bytes
MOVQ $-10, AX // 8 bytes
MOVQ (R1), R2 // R2 = *R1
MOVQ 8(R3), R4 // R4 = *(8 + R3)
MOVQ 16(R5)(R6*1), R7 // R7 = *(16 + R5 + R6*1)
MOVQ ·myvar(SB), R8 // R8 = *myvar
MOVQ $8(R1)(R2*1), R3 // R3 = 8 + R1 + R2
MOVQ $·myvar(SB), R8 // R8 = &myvar
计算
ADDQ AX, BX // BX += AX
SUBQ AX, BX // BX -= AX
IMULQ AX, BX // BX *= AX
SUB R3, R4, R5 // R5 = R4 - R3
MUL $7, R6 // R6 *= 7
跳转
// 无条件跳转
JMP addr // 跳转到地址,地址可为代码中的地址,不过实际上手写不会出现这种东西
JMP label // 跳转到标签,可以跳转到同一函数内的标签位置
JMP 2(PC) // 以当前指令为基础,向前/后跳转 x 行
JMP -2(PC) // 同上
// 有条件跳转
JZ target // 如果 zero flag 被 set 过,则跳转
加载有效地址
LEAQ (BX)(AX*8), CX // CX = BX + (AX * 8)
// 上面代码中的 8 代表 scale
// scale 只能是 0、2、4、8
// 如果写成其它值:
// LEAQ (BX)(AX*3), CX
// ./a.s:6: bad scale: 3
// 用 LEAQ 的话,即使是两个寄存器值直接相加,也必须提供 scale
// 下面这样是不行的
// LEAQ (BX)(AX), CX
// asm: asmidx: bad address 0/2064/2067
// 正确的写法是
LEAQ (BX)(AX*1), CX
// 在寄存器运算的基础上,可以加上额外的 offset
LEAQ 16(BX)(AX*1), CX
变量声明
- DATA
DATA symbol+offset(SB)/width, value
offset为相对于symbol的偏移量。
- GLOBL
GLOBL divtab(SB), RODATA, $64
使用 GLOBL 指令将变量声明为 global,额外接收两个参数,一个是 flag,另一个是变量的总大小。此处RODATA表示只读数据(Read Only)
DECQ/INCQ
用于减少或者增加寄存器内的数值,DECQ(Decrease), INCQ(Increase)。
INCQ CX // CX++
DECQ CX // CX--
可以作为循环条件,配合跳转指令形成循环。(eg: 求切片每个元素和)(sum.s)
#include "textflags.h"
TEXT ·Sum(SB), NOSPLIT, $0
MOVQ $0, SI // 初始化sum 也可以使用异或操作 XORQ SI, SI
MOVQ sl+0(FP), BX // 加载slice数据地址
MOVQ sl+8(FP), CX // 加载slice长度
start:
ADDQ 0(BX), SI // 这里要注意,BX内是数据地址值,取数据需要加括号,具体看寻址模式那个图
ADDQ $8, BX
DECQ CX
JZ done
JMP start
done:
MOVQ SI, ret+24(FP) // 因为slice是数据地址,slice长度len,底层数组容量cap三个字节组成的结构体,在64位机器上占据24字节,所以需要跨越三个8字节。
RET
在同一个目录下用一个go文件调用(sum.go)
package main
func Sum([]int64) int64
func main() {
println(Sum([]int64{1, 2, 3, 4, 5}))
}
// 运行结果
// 15
使用go build 编译出二进制文件,即可运行。
Registers
可爱的寄存器们🤗
| 数据寄存器 | 地址寄存器 | 浮点寄存器 |
|---|---|---|
| R0-R7 | A0-A7 | F0-F7 |
其他的一些通用寄存器😳
| AX | BX | CX | DX | DI | SI | BP | SP | PC |
如前面所说,SB 表示程序地址空间的起始,对全局数据和程序的引用都被写作 SB 的偏移也就是 offset。手册内给出的例子是将全局数组地址移入TOS(Top-Of-Stack)
MOVL $array(SB), TOS
TOS 是堆栈顶层寄存器,用于向存储过程推送参数、保存临时值等。此处伪指令 MOVL 表示移动时使用的数据类型为 Long word (32-bit), 如果后面跟Q,也就是 MOVQ 代表 Quad word (64-bit),还可以有O 表示 Oct word (八进制字 128-bit)。
注意: 操作顺序是,左边为源,右边是数据的目的地。
移动数组的第二个(4字节)元素进入TOS
MOVL array+4(SB), TOS
返回两个数之和的程序(procedure)
TEXT sum(SB), $0
MOVL arg1+0(FP), R0
ADDL arg2+4(FP), R0
RTS
由于FP引用函数参数,所以可以使用FP加上偏移量来获取函数参数,第一句定义procedure, 并且内部栈帧占用0字节,下面使用 MOVL 伪指令,将arg1相对于FP偏移0字节的数据写入R0寄存器,后使用ADDL伪指令将arg2相对于FP偏移4字节的数据和R0内的数据相加并且存储在R0寄存器内。
TEXT伪指令的中间,这里也就是 sum(SB) 和 $0 的中间是对加载器的选项,下面是在Go的Guide文章内的可选选项。
NOPROF = 1 (For TEXT items.) Don't profile the marked function. This flag is deprecated. DUPOK = 2 It is legal to have multiple instances of this symbol in a single binary. The linker will choose one of the duplicates to use. NOSPLIT = 4 (For TEXT items.) Don't insert the preamble to check if the stack must be split. The frame for the routine, plus anything it calls, must fit in the spare space remaining in the current stack segment. Used to protect routines such as the stack splitting code itself. RODATA = 8 (For DATA and GLOBL items.) Put this data in a read-only section. NOPTR = 16 (For DATA and GLOBL items.) This data contains no pointers and therefore does not need to be scanned by the garbage collector. WRAPPER = 32 (For TEXT items.) This is a wrapper function and should not count as disabling recover. NEEDCTXT = 64 (For TEXT items.) This function is a closure so it uses its incoming context register. LOCAL = 128 This symbol is local to the dynamic shared object. TLSBSS = 256 (For DATA and GLOBL items.) Put this data in thread local storage. NOFRAME = 512 (For TEXT items.) Do not insert instructions to allocate a stack frame and save/restore the return address, even if this is not a leaf function. Only valid on functions that declare a frame size of 0. TOPFRAME = 2048 (For TEXT items.) Function is the outermost frame of the call stack. Traceback should stop at this function.
Compiler
根据Rob Pike的说法go的compiler比较奇怪,相比于传统的 Compile -> Assemble -> Link 的模式 ,早期Go的编译器,相当于把 Assemble 拆开,一部分结合在compiler内,一部分结合在linker内。
在rerange编译器的结构之后,Go的compiler将Linker内的类似传统 Assemble 的部分功能也集成了进来形成了 assembler(只做文本操作,生成对应的指令的数据结构) ,也就是说go的compiler现在在编译完高级代码之后基本直接输出真实的指令(相较于之前的半抽象指令集而言),当然其中还有SSA(Static Single Assignment)对代码进行优化。之后compiler将指令以一种数据结构的形式传递给后面的 obj 库,obj 库再将指令转换为不同平台的指令。
下面是老的与新的Go编译器结构, 第一行是传统的编译器流程,后两行是Go的流程。
The Old Pieces

The New Pieces

输出看看
在有了上面的基础知识(以及一点点汇编,虽然之前学过但是也忘了很多😬)以及编写了一个简单的汇编函数之后,直接来看看go编译之后的汇编是什么样的吧。这里还是要强调一下,有些文章在谈Go语言相关的汇编时没有强调Go语言版本,但 强调版本这是很重要的,这里就是1.17之后Go的编译器发生了变化(当然之前也有大变化)。所以下面对三个版本的Go语言编译器输出的代码进行分析分别是1.14.2、1.17、1.20.5。至于选择每个版本的原因,选择1.14.2的版本的原因是在参考之外的其他我参考的小部分资料使用,所以拿来实验,对于1.17当然是因为这个版本发生了函数传递参数以及结果首先使用寄存器这个变化,最后使用1.20.5是因为我自己机子(linux-amd64)使用的是这个版本(至少在后面程序的输出汇编上1.17和1.20差距并不大)。
下面是Go语言代码。
package main
import "fmt"
func showInt(i int) {
s := []int{1, 2, 3, 4, 5}
for _, v := range s {
fmt.Println(&v)
}
}
func myFunc(usedStr string) int {
sum := 1 + 9
fmt.Println(sum)
fmt.Println(usedStr)
return sum
}
func main() {
i := myFunc("HelloWorld")
showInt(i)
}
这个程序单纯输出字符,以及在函数间传递int之后继续输出。当然写这篇文章的原因是做个记录,在之后的Go runtime源码解读中,如果事先对Plan 9 Assembly以及相关栈的信息有所了解,在解读Go runtime代码时会非常方便。但是这貌似有点怪,因为在理解汇编的同时,需要先去了解Go的部分源代码以及数据类型的底层,看似悖论,但是二者并不冲突,(至少没有某些软件编译出的产品是编译自己的依赖的难受情况。我不说是某个c\c++编译器😩)先去了解先关类型以及部分代码即可。在涉及到的时候我带一句,或者贴一下代码就明白了。
main.main
Go 1.14
先从main函数来看
...
0000 TEXT "".main(SB), ABIInternal, $32-0
0000 MOVQ (TLS), CX
0009 CMPQ SP, 16(CX)
0013 JLS 78
...
...
00078 CALL runtime.morestack_noctxt(SB)
00083 JMP 0
这三行在对栈进行检查,如果栈超出了TLS向上移动16字节的那个值,便会跳转到调用morestack_noctx函数,进行栈的扩充,之后重新跳转到函数开头。那TLS向上移动16字节是什么呢?TLS又是什么?别急,下面查了查。
TLS 是一个由go的compiler维护的伪寄存器,即Thread-local storage, 保存了指向当前g结构体(goroutine 底层是一个g结构体)的指针。下面是保存在 src/cmd/internal/obj/x86/obj6.go 内 progedit函数内的一段注释,并且这个progedit并不是program edit的缩写。而是prologue edit的缩写,go的栈分裂(stack-split)分成两部分如上面代码注释说的那样。
func progedit(ctxt *obj.Link, p *obj.Prog, newprog obj.ProgAlloc) {
// Thread-local storage references use the TLS pseudo-register.
// As a register, TLS refers to the thread-local storage base, and it
// can only be loaded into another register:
//
// MOVQ TLS, AX
//
// An offset from the thread-local storage base is written off(reg)(TLS*1).
// Semantically it is off(reg), but the (TLS*1) annotation marks this as
// indexing from the loaded TLS base. This emits a relocation so that
// if the linker needs to adjust the offset, it can. For example:
//
// MOVQ TLS, AX
// MOVQ 0(AX)(TLS*1), CX // load g into CX
//
// On systems that support direct access to the TLS memory, this
// pair of instructions can be reduced to a direct TLS memory reference:
//
// MOVQ 0(TLS), CX // load g into CX
//
...
}
而g内保存了当前goroutine的状态信息,当然也有栈信息下面是部分g结构体的字段。
// Stack describes a Go execution stack.
// The bounds of the stack are exactly [lo, hi),
// with no implicit data structures on either side.
type stack struct {
lo uintptr
hi uintptr
}
type g struct {
// Stack parameters.
// stack describes the actual stack memory: [stack.lo, stack.hi).
// stackguard0 is the stack pointer compared in the Go stack growth prologue.
// It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
// stackguard1 is the stack pointer compared in the C stack growth prologue.
// It is stack.lo+StackGuard on g0 and gsignal stacks.
// It is ~0 on other goroutine stacks, to trigger a call to morestackc (and crash).
stack stack // offset known to runtime/cgo
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
...
}
由于g结构体内第一个字段大小为而16字节,所以在 MOV (TLS) CX 之后需要用SP与16(CX)进行比较(也就是与g.stackguard0进行比较),如果SP地址比16(CX)小[SP <= 16(CX)] 就调用morestack_noctxt函数进行扩张。
接下来三行是go的compiler帮我们做的,也就是插入BP以及初始化SP位置,在自己手写时不需要写这些。
SUBQ $32, SP
MOVQ BP, 24(SP)
LEAQ 24(SP), BP
下面的图可以更直观一点,可以结合之前的栈结构图来理解.
┌──────────┐
│ 8 bytes│
└──────────┘ ◄────── BP(pseudo SP)
│ │
│ 24 bytes │
│ │
SP ───► └──────────┘
好了,基础操作做完了,下面来看看main函数内部的具体内容

其中第一部分在前面已经说了,现在来看看第二和第三部分,因为这两个都是函数调用,步骤差不多所以一起说了罢。第二部分的第一行即加载 "HelloWorld"这个字符串的地址并且存入AX寄存器。使用go build -gcflags -S 命令的输出会更明显一些。
LEAQ go.string."HelloWorld"(SB), AX
接下来的函数参数的传递就需要一点对于string这个类型的Go语言底层实现有些了解啦。这个文件在src/runtime/string.go文件内,string类型的底层是一个称为 stringStruct 的结构体,内部包含具体字符串的指针,以及该字符串的长度两个字段。
type stringStruct struct {
str unsafe.Pointer
len int
}
也就是说一个字符串类型占的字节是16字节(64位机子上)。所以第二部分的第一行将地址加载入AX(也就是得到了stringStruct的str字段)。之后将ax中的地址放置在了SP偏移0字节指向的位置,这里也就是下一个函数的第一个参数位置(被调用方使用FP来获取此处SP设置的值)。
继续将字符串长度10(十六进制0x0a)加载进AX,继续将长度放在SP偏移8字节的位置。在将参数都放在myFunc函数的栈顶之后,便直接调用了myFunc函数。
CALL main.myFunc(SB)
为了更好说明这个放置参数以及被调用函数使用参数的过程,我画了个图。
(main函数部分栈) (myFunc函数的部分栈结构)
│ │
│ │
│ │
▼ │
┌─────────────┐ │
│ 10 │ │
└─────────────┘ ▼
│ 0x2ee6b(IP) │
└─────────────┌──────────────┐ ◄──── FP
│ return addr │
┌──────────────┐
│ caller bp │
┌──────────────┐ ◄──── BP(pseudo SP)
│ local var1 │
.
.
.
│ local varn │
SP(real SP) ───► └──────────────┘
所以根据上面的图,只要我们在myFunc函数内对FP进行偏移就能获取到参数,也就是使用类似 MOVQ 0(FP), AX 和 MOVQ 8(FP), BX 的操作就可以将参数存到指定寄存器了。在使用go命令输出的汇编里基本是采用硬件SP来进行偏移的。这也一样能达到目的。
由于调用函数会使用FP来设置返回值,也就是在FP所有参数之后。此处myFunc只是返回一个int类型的整数,并且myFunc的参数在栈上实际占用16bytes,所以要获取myFunc的返回值即获取FP偏移16之后的8字节的值。这里就是下面的asm。
MOVQ 0x10(SP), AX
第三部分仍然是函数调用,将myFunc的返回值,设置在showInt函数的栈顶。然后 CALL it。
最后第四部分是复原BP的值,以及将SP移动回原来的位置,这里可以对照第一部分的上半部分来对比。
Go 1.17
再来看看1.17的go版本是否和开头说的一样,使用寄存器来传递函数参数与返回值。

从图中可以看出,第一部分就已经有了不一样,之前是SP与TLS的比较,现在是寄存器内地址偏移0x10(也就是16字节)之后与SP比较,这有什么区别呢?其实大差不差,R14(因为我是amd64所以是R14寄存器)内的地址仍然指向当前goroutine, 而 0x10(R14) 的地址仍然指向g结构体的stackguard0字段,也就是最低地址。
者可以在源码的src/cmd/internal/obj/x86/obj6.go 以及 src/cmd/internal/obj/x86/a.out.go 查找到相关的代码,并且不同版本函数可能不同,在1.17内load goroutine的操作放在了preprocess函数内,1.20的则放在了loadG函数内,而在1.17的preprocess函数内与1.20的loadG函数同功能但是签名不同的函数是load_g,下面分别是两个版本以及两个文件相关的代码。
Go 1.17版本
// src/cmd/internal/obj/x86/obj6.go Go 1.17
func preprocess(ctxt *obj.Link, cursym *obj.LSym, newprog obj.ProgAlloc) {
if cursym.Func().Text == nil || cursym.Func().Text.Link == nil {
return
}
...
...
var regg int16
if !p.From.Sym.NoSplit() || p.From.Sym.Wrapper() {
if ctxt.Arch.Family == sys.AMD64 && buildcfg.Experiment.RegabiG && cursym.ABI() == obj.ABIInternal {
regg = REGG // use the g register directly in ABIInternal
} else {
p = obj.Appendp(p, newprog)
regg = REG_CX
if ctxt.Arch.Family == sys.AMD64 {
// Using this register means that stacksplit works w/ //go:registerparams even when !buildcfg.Experiment.RegabiG
regg = REGG // == REG_R14
}
p = load_g(ctxt, p, newprog, regg) // load g into regg
}
}
...
...
}
// cmd/internal/obj/x86/a.out.go Go 1.17
const (
...
...
REGCTXT = REG_DX
REGENTRYTMP0 = REG_R12 // scratch register available at function entry in ABIInternal
REGENTRYTMP1 = REG_R13 // scratch register available at function entry in ABIInternal
REGG = REG_R14 // g register in ABIInternal
REGEXT = REG_R15 // compiler allocates external registers R15 down
...
...
)
Go 1.20版本
// src/cmd/internal/obj/x86/obj6.go Go 1.20
// loadG ensures the G is loaded into a register (either CX or REGG),
// appending instructions to p if necessary. It returns the new last
// instruction and the G register.
func loadG(ctxt *obj.Link, cursym *obj.LSym, p *obj.Prog, newprog obj.ProgAlloc) (*obj.Prog, int16) {
if ctxt.Arch.Family == sys.AMD64 && cursym.ABI() == obj.ABIInternal {
// Use the G register directly in ABIInternal
return p, REGG
}
var regg int16 = REG_CX
if ctxt.Arch.Family == sys.AMD64 {
regg = REGG // == REG_R14
}
p = obj.Appendp(p, newprog)
...
...
}
// cmd/internal/obj/x86/a.out.go Go 1.20
const (
...
...
REGCTXT = REG_DX
REGENTRYTMP0 = REG_R12 // scratch register available at function entry in ABIInternal
REGENTRYTMP1 = REG_R13 // scratch register available at function entry in ABIInternal
REGG = REG_R14 // g register in ABIInternal
REGEXT = REG_R15 // compiler allocates external registers R15 down
...
...
)
第二部分可以看出和之前不同,这里将字符串地址以及字符串长度放入了AX,和BX寄存器。并没有使用SP将参数放置在myFunc栈顶。这个也确实和1.17的release note相吻合,并且在第三部分在CALL main.myFunc 之后直接CALL main.showInt 并没有将返回值使用SP放置在showInt函数栈顶,结合1.17的release note,这里其实是将myFunc返回值放入AX寄存器,showInt也直接使用AX寄存器的值作为函数参数。第四部分和之前是一样的。
Go 1.20

1.20和1.17基本上是一样的。
main.myFunc
上面的分析中main函数把myFunc的参数已经设置好了,我也顺便说了返回值怎么处理。现在来看看myFunc内部是什么情况。
Go 1.14

这个函数内功能其实没啥看头,第一部分分配了0x68(相对于SP偏移)字节栈大小,这个带回在获取参数usedStr时使用到了也就是第四部分,而在第五部分将字面值10直接放在了myFunc的栈顶(参数之后也就是16字节之上)。一开始分配0x68字节,而根据之前的学习,在这之上还有一个return address占用 0x08字节,所以参数usedStr的两个字段(str, 数据指针以及len,长度)在 0x68 + 0x08之上。也就是从0x70开始的16字节分别是地址以及字符串长度,这里可以看到第四部分前四行就是这么做的直接使用SP的偏移量0x70(注意: 这里全部都是16进制,不要和10进制混淆)。
而返回值的设置则是在第五部分第一行。直接将字面值0xa也就是十进制的10放入参数之后的位置,参数的位置是0x70向上16字节之后,也就是0x80之后,这里也正是使用MOVQ $0xa, 0x80(SP) 来设置的。
下面继续画个图来说明一下,其实还是很好理解的。
0x88(SP) ┌──────────────┐
│ sum(ret) │
0x80(SP) ┌──────────────┐
│ len │
0x78(SP) ┌──────────────┐
│ str(pointer) │
0x70(SP) ┌──────────────┐ ◄──── FP
│ return addr │
0x68(SP) ┌──────────────┐
│ caller bp │
0x60(SP) ┌──────────────┐ ◄──── BP(pseudo SP)
│ local var1 │
.
.
.
│ local varn │
SP(real SP) ───► └──────────────┘
Go 1.17

其他部分就不看了,主要看差异的第四部分,图中高亮的绿色部分。第一绿色部分直接将AX,BX内容移入对应位置,这是因为调用方将字符串的两个字段放在了AX与BX内。之后第二部分绿色又将对应位置的内容取出,放在AX,BX内方便调用convTstring时将二者作为参数。其他是基本差不多的。最后返回值也是直接放入AX寄存器,而不是和1.14那样偏移SP来实现。
Go 1.20
Go 1.20基本与这个1.17相同,所以这里我就不放图片了。
main.showInt
终于到稍微好玩一点的函数了,其中涉及到一个for ... range 的一个知识点。刚好从汇编层面解释了该知识点。下面慢慢来看,直接上图。
Go 1.14

直接从函数内部开始,也就是第二部分(第一部分都是一样的,初始化,检查栈等)。可以看到第二部分s切片被直接使用MOVQ将字面量移入对应位置。来重点看看第三部分,这个 range 循环以及变量 v 。
for range 的坑
第三部分的 LEAQ 0xde37, AX 将一个表示Int类型的类型指针放入AX,继而将AX的内容放入runtime.newobject() 函数第一个参数的位置(实际这个函数也只有一个参数类型为 *_type,返回值为unsafe.Pointer)。可以知道的是,之后MOVQ 0x8(SP), AX 将创建好的int地址给到了AX,并且将地址放在了0x70(SP)的位置。这个位置注意看第二部分的话,刚好在数字5这个字面量的后面。随后XORL CX, CX 使用异或将CX清为0用作后面循环的计数。
下面就是直接转跳,并且判断CX内的值与5的关系(因为切片的len为5)。如果小于则直接跳回第四部分上面的那部分紫色代码开始执行,(图上还有箭头,看得清楚)否则就重置SP,BP等。
再回来看转跳回去的部分,首先将计数器放在数字1之前的地址位置也就是0x40(SP)。然后获取SP偏移对应切片位置的数值。也就是 0x48(SP)(CX*8) 这里含义为 *(SP + 0x48 + CX * 8)。因为CX为0,之后会自增到1,2,3...所以用来遍历切片,并且结果存储在DX内。后面把DX的值放入0(AX)。
这里注意,AX寄存器内保存的是一个由newobject申请好内存,并指向该内存的指针,因此 0(AX) 是将AX内指针指向的那块内存的值改为DX中的内容,并没有修改指针。第四第五部分就不多说了,毕竟是打印部分。在打印完成之后,将原先保存在0x40(SP) 的用于计数的CX的值,给到AX,便于下面增加1。
增加1可以使用INCQ指令,这里使用LEAQ 指令达到了一样的效果,因为LEAQ 指令只是将AX内的数据偏移(这里是加1)相应的数而已,例如LEAQ 8(SP), SI 这里将SP内的指针加8并给到SI寄存器,具体对比上面LEAQ以及MOVQ两个指令的用法。
ok, 计数器CX加一了,之前存储在0x70(SP) 的由runtime.newobject() 函数返回的指针又被放进了AX寄存器,然后比较,如此往复直到CX等于5。所以:for k, v := range 循环的值这里的v,实际上一直是同一个地址,只不过该地址指向的内存中的值在不断改变。
如果在for range中得到值的地址,并且在该循环之后,该地址只能获取最后一个值。有兴趣可以试试下面的代码。结果全部为old_slice最后一个值。
old_slice := []int{1, 2, 3, 4, 5}
new_slice_p := []*int{}
for _, v := range old_slice {
new_slice_p = append(new_slice_p, &v)
}
for _, v := range new_slice_p {
fmt.Println(*v)
}
// 5 5 5 5 5
Go 1.17

1.17的代码1,2部分也不必看了,都一样,其他部分也大差不差,第三部分将int类型地址放入AX作为runtime.newobject()参数,并且runtime.newobject()函数返回值也放在AX内部,之后流程基本与1.14版本没有区别了。
Go 1.20
同上,二者并没有太大区别,就不展示啦。🤗
总结
总算搞完了,这为后续Go源码解读做了个铺垫,(主要是怕我失忆,看过给忘了)虽然以前也看过,但是忘记了。之后许多Go语言代码会涉及到Go的汇编,也算是从另一个视角来学习Go语言了。如果有朋友希望了解Go语言汇编的,希望这篇文章能给你一些参考。最后说说,版本真的很重要啊!😩
-- 来自一位Linux爱好者。
Reference
- https://go.dev/doc/asm
- https://9p.io/sys/doc/asm.html
- https://github.com/cch123/golang-notes/blob/master/assembly.md
- https://www.doxsey.net/blog/go-and-assembly/
- https://github.com/golang/go/files/447163/GoFunctionsInAssembly.pdf
- https://go.dev/doc/go1.17#compiler
- https://www.youtube.com/watch?v=9jpnFmJr2PE&t=993s
- https://www.youtube.com/watch?v=KINIAgRpkDA&t=1282s
- https://go.dev/talks/2016/asm.slide#1
- https://stackoverflow.com/questions/72217460/some-confusion-about-golang-assembly
评论 (...)
加载中...