这篇文章国内研究 Go 底层的人应该都看过,准备去学习 runtime 的你也应该读一读。html
众所周知,Go 使用了 Unix 老古董(误 们发明的 plan9 汇编。就算你对 x86 汇编有所了解,在 plan9 里仍是有些许区别。说不定你在看代码的时候,偶然发现代码里的 SP 看起来是 SP,但它实际上不是 SP 的时候就抓狂了哈哈哈。linux
本文将对 plan9 汇编进行全面的介绍,同时解答你在接触 plan9 汇编时可能遇到的大部分问题。git
本文所使用的平台是 linux amd64,由于不一样的平台指令集和寄存器都不同,因此没有办法共同探讨。这也是由汇编自己的性质决定的。github
intel 或 AT&T 汇编提供了 push 和 pop 指令族,plan9 中没有 push 和 pop,plan9 中虽然有 push 和 pop 指令,但通常生成的代码中是没有的,咱们看到的栈的调整大可能是经过对硬件 SP 寄存器进行运算来实现的,例如:golang
SUBQ $0x18, SP // 对 SP 作减法,为函数分配函数栈帧 ... // 省略无用代码 ADDQ $0x18, SP // 对 SP 作加法,清除函数栈帧
通用的指令和 X64 平台差很少,下面分节详述。shell
常数在 plan9 汇编用 $num 表示,能够为负数,默认状况下为十进制。能够用 $0x123 的形式来表示十六进制数。数组
MOVB $1, DI // 1 byte MOVW $0x10, BX // 2 bytes MOVD $1, DX // 4 bytes MOVQ $-10, AX // 8 bytes
能够看到,搬运的长度是由 MOV 的后缀决定的,这一点与 intel 汇编稍有不一样,看看相似的 X64 汇编:数据结构
mov rax, 0x1 // 8 bytes mov eax, 0x100 // 4 bytes mov ax, 0x22 // 2 bytes mov ah, 0x33 // 1 byte mov al, 0x44 // 1 byte
plan9 的汇编的操做数的方向是和 intel 汇编相反的,与 AT&T 相似。架构
MOVQ $0x10, AX ===== mov rax, 0x10 | |------------| | |------------------------|
不过凡事总有例外,若是想了解这种意外,能够参见参考资料中的 [1]。app
ADDQ AX, BX // BX += AX SUBQ AX, BX // BX -= AX IMULQ AX, BX // BX *= AX
相似数据搬运指令,一样能够经过修改指令的后缀来对应不一样长度的操做数。例如 ADDQ/ADDW/ADDL/ADDB。
// 无条件跳转 JMP addr // 跳转到地址,地址可为代码中的地址,不过实际上手写不会出现这种东西 JMP label // 跳转到标签,能够跳转到同一函数内的标签位置 JMP 2(PC) // 以当前指令为基础,向前/后跳转 x 行 JMP -2(PC) // 同上 // 有条件跳转 JZ target // 若是 zero flag 被 set 过,则跳转
能够参考源代码的 arch 部分。
额外提一句,Go 1.10 添加了大量的 SIMD 指令支持,因此在该版本以上的话,不像以前写那样痛苦了,也就是不用人肉填 byte 了。
amd64 的通用寄存器:
(lldb) reg read General Purpose Registers: rax = 0x0000000000000005 rbx = 0x000000c420088000 rcx = 0x0000000000000000 rdx = 0x0000000000000000 rdi = 0x000000c420088008 rsi = 0x0000000000000000 rbp = 0x000000c420047f78 rsp = 0x000000c420047ed8 r8 = 0x0000000000000004 r9 = 0x0000000000000000 r10 = 0x000000c420020001 r11 = 0x0000000000000202 r12 = 0x0000000000000000 r13 = 0x00000000000000f1 r14 = 0x0000000000000011 r15 = 0x0000000000000001 rip = 0x000000000108ef85 int`main.main + 213 at int.go:19 rflags = 0x0000000000000212 cs = 0x000000000000002b fs = 0x0000000000000000 gs = 0x0000000000000000
在 plan9 汇编里都是可使用的,应用代码层面会用到的通用寄存器主要是: rax, rbx, rcx, rdx, rdi, rsi, r8~r15 这 14 个寄存器,虽然 rbp 和 rsp 也能够用,不过 bp 和 sp 会被用来管理栈顶和栈底,最好不要拿来进行运算。
plan9 中使用寄存器不须要带 r 或 e 的前缀,例如 rax,只要写 AX 便可:
MOVQ $101, AX = mov rax, 101
下面是通用通用寄存器的名字在 X64 和 plan9 中的对应关系:
X64 | rax | rbx | rcx | rdx | rdi | rsi | rbp | rsp | r8 | r9 | r10 | r11 | r12 | r13 | r14 | rip |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Plan9 | AX | BX | CX | DX | DI | SI | BP | SP | R8 | R9 | R10 | R11 | R12 | R13 | R14 | PC |
Go 的汇编还引入了 4 个伪寄存器,援引官方文档的描述:
FP
: Frame pointer: arguments and locals.PC
: Program counter: jumps and branches.SB
: Static base pointer: global symbols.SP
: Stack pointer: top of stack.
官方的描述稍微有一些问题,咱们对这些说明进行一点扩充:
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 上。具体可参见以后的 栈结构 一章。symbol+offset(SP)
的方式,引用函数的局部变量。offset 的合法取值是 [-framesize, 0),注意是个左闭右开的区间。假如局部变量都是 8 字节,那么第一个局部变量就能够用 localvar0-8(SP)
来表示。这也是一个词不表意的寄存器。与硬件寄存器 SP 是两个不一样的东西,在栈帧 size 为 0 的状况下,伪寄存器 SP 和硬件寄存器 SP 指向同一位置。手写汇编代码时,若是是 symbol+offset(SP)
形式,则表示伪寄存器 SP。若是是 offset(SP)
则表示硬件寄存器 SP。务必注意。对于编译输出(go tool compile -S / go tool objdump)的代码来说,目前全部的 SP 都是硬件寄存器 SP,不管是否带 symbol。咱们这里对容易混淆的几点简单进行说明:
以上说明看不懂也不要紧,在熟悉了函数的栈结构以后再反复回来查看应该就能够明白了。我的意见,这些是 Go 官方挖的坑。。
在汇编里所谓的变量,通常是存储在 .rodata 或者 .data 段中的只读值。对应到应用层的话,就是已初始化过的全局的 const、var、static 变量/常量。
使用 DATA 结合 GLOBL 来定义一个变量。DATA 的用法为:
DATA symbol+offset(SB)/width, value
大多数参数都是字面意思,不过这个 offset 须要稍微注意。其含义是该值相对于符号 symbol 的偏移,而不是相对于全局某个地址的偏移。
使用 GLOBL 指令将变量声明为 global,额外接收两个参数,一个是 flag,另外一个是变量的总大小。
GLOBL divtab(SB), RODATA, $64
GLOBL 必须跟在 DATA 指令以后,下面是一个定义了多个 readonly 的全局变量的完整例子:
DATA age+0x00(SB)/4, $18 // forever 18 GLOBL age(SB), RODATA, $4 DATA pi+0(SB)/8, $3.1415926 GLOBL pi(SB), RODATA, $8 DATA birthYear+0(SB)/4, $1988 GLOBL birthYear(SB), RODATA, $4
正如以前所说,全部符号在声明时,其 offset 通常都是 0。
有时也可能会想在全局变量中定义数组,或字符串,这时候就须要用上非 0 的 offset 了,例如:
DATA bio<>+0(SB)/8, $"oh yes i" DATA bio<>+8(SB)/8, $"am here " GLOBL bio<>(SB), RODATA, $16
大部分都比较好理解,不过这里咱们又引入了新的标记 <>
,这个跟在符号名以后,表示该全局变量只在当前文件中生效,相似于 C 语言中的 static。若是在另外文件中引用该变量的话,会报 relocation target not found
的错误。
本小节中提到的 flag,还能够有其它的取值:
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 at the top of the 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.
当使用这些 flag 的字面量时,须要在汇编文件中 #include "textflag.h"
。
在 .s
文件中是能够直接使用 .go
中定义的全局变量的,看看下面这个简单的例子:
refer.go:
package main var a = 999 func get() int func main() { println(get()) }
refer.s:
#include "textflag.h" TEXT ·get(SB), NOSPLIT, $0-8 MOVQ ·a(SB), AX MOVQ AX, ret+0(FP) RET
·a(SB),表示该符号须要连接器来帮咱们进行重定向(relocation),若是找不到该符号,会输出 relocation target not found
的错误。
例子比较简单,你们能够自行尝试。
咱们来看看一个典型的 plan9 的汇编函数的定义:
// func add(a, b int) int // => 该声明定义在同一个 package 下的任意 .go 文件中 // => 只有函数头,没有实现 TEXT pkgname·add(SB), NOSPLIT, $0-8 MOVQ a+0(FP), AX MOVQ a+8(FP), BX ADDQ AX, BX MOVQ BX, ret+16(FP) RET
为何要叫 TEXT ?若是对程序数据在文件中和内存中的分段稍有了解的同窗应该知道,咱们的代码在二进制文件中,是存储在 .text 段中的,这里也就是一种约定俗成的起名方式。实际上在 plan9 中 TEXT 是一个指令,用来定义一个函数。除了 TEXT 以外还有前面变量声明说到的 DATA/GLOBL。
定义中的 pkgname 部分是能够省略的,非想写也能够写上。不过写上 pkgname 的话,在重命名 package 以后还须要改代码,因此推荐最好仍是不要写。
中点 ·
比较特殊,是一个 unicode 的中点,该点在 mac 下的输入方法是 option+shift+9
。在程序被连接以后,全部的中点·
都会被替换为句号.
,好比你的方法是 runtime·main
,在编译以后的程序里的符号则是 runtime.main
。嗯,看起来很变态。简单总结一下:
参数及返回值大小 | TEXT pkgname·add(SB),NOSPLIT,$32-32 | | | 包名 函数名 栈帧大小(局部变量+可能须要的额外调用函数的参数空间的总大小,但不包括调用其它函数时的 ret address 的大小)
下面是一个典型的函数的栈结构图:
----------------- current func arg0 ----------------- <----------- FP(pseudo FP) caller ret addr +---------------+ | caller BP(*) | ----------------- <----------- SP(pseudo SP,其实是当前栈帧的 BP 位置) | Local Var0 | ----------------- | Local Var1 | ----------------- | Local Var2 | ----------------- - | ........ | ----------------- | Local VarN | ----------------- | | | | | temporarily | | unused space | | | | | ----------------- | call retn | ----------------- | call ret(n-1)| ----------------- | .......... | ----------------- | call ret1 | ----------------- | call argn | ----------------- | ..... | ----------------- | call arg3 | ----------------- | call arg2 | |---------------| | call arg1 | ----------------- <------------ hardware SP 位置 return addr +---------------+
从原理上来说,若是当前函数调用了其它函数,那么 return addr 也是在 caller 的栈上的,不过往栈上插 return addr 的过程是由 CALL 指令完成的,在 RET 时,SP 又会恢复到图上位置。咱们在计算 SP 和参数相对位置时,能够认为硬件 SP 指向的就是图上的位置。
图上的 caller BP,指的是 caller 的 BP 寄存器值,有些人把 caller BP 叫做 caller 的 frame pointer,实际上这个习惯是从 x86 架构沿袭来的。Go 的 asm 文档中把伪寄存器 FP 也称为 frame pointer,可是这两个 frame pointer 根本不是一回事。
此外须要注意的是,caller BP 是在编译期由编译器插入的,用户手写代码时,计算 frame size 时是不包括这个 caller BP 部分的。是否插入 caller BP 的主要判断依据是:
func Framepointer_enabled(goos, goarch string) bool { return framepointer_enabled != 0 && goarch == "amd64" && goos != "nacl" }
若是编译器在最终的汇编结果中没有插入 caller BP(源代码中所称的 frame pointer)的状况下,伪 SP 和伪 FP 之间只有 8 个字节的 caller 的 return address,而插入了 BP 的话,就会多出额外的 8 字节。也就说伪 SP 和伪 FP 的相对位置是不固定的,有多是间隔 8 个字节,也有可能间隔 16 个字节。而且判断依据会根据平台和 Go 的版本有所不一样。
图上能够看到,FP 伪寄存器指向函数的传入参数的开始位置,由于栈是朝低地址方向增加,为了经过寄存器引用参数时方便,因此参数的摆放方向和栈的增加方向是相反的,即:
FP high ----------------------> low argN, ... arg3, arg2, arg1, arg0
假设全部参数均为 8 字节,这样咱们就能够用 symname+0(FP) 访问第一个 参数,symname+8(FP) 访问第二个参数,以此类推。用伪 SP 来引用局部变量,原理上来说差很少,不过由于伪 SP 指向的是局部变量的底部,因此 symname-8(SP) 表示的是第一个局部变量,symname-16(SP)表示第二个,以此类推。固然,这里假设局部变量都占用 8 个字节。
图的最上部的 caller return address 和 current func arg0 都是由 caller 来分配空间的。不算在当前的栈帧内。
由于官方文档自己较模糊,咱们来一个函数调用的全景图,来看一下这些真假 SP/FP/BP 究竟是个什么关系:
caller +------------------+ | | +----------------------> -------------------- | | | | | caller parent BP | | BP(pseudo SP) -------------------- | | | | | Local Var0 | | -------------------- | | | | | ....... | | -------------------- | | | | | Local VarN | -------------------- caller stack frame | | | callee arg2 | | |------------------| | | | | | callee arg1 | | |------------------| | | | | | callee arg0 | | ----------------------------------------------+ FP(virtual register) | | | | | | return addr | parent return address | +----------------------> +------------------+--------------------------- <-------------------------------+ | caller BP | | | (caller frame pointer) | | BP(pseudo SP) ---------------------------- | | | | | Local Var0 | | ---------------------------- | | | | Local Var1 | ---------------------------- callee stack frame | | | ..... | ---------------------------- | | | | | Local VarN | | SP(Real Register) ---------------------------- | | | | | | | | | | | | | | | | +--------------------------+ <-------------------------------+ callee
在函数声明中:
TEXT pkgname·add(SB),NOSPLIT,$16-32
前面已经说过 $16-32 表示 $framesize-argsize。Go 在函数调用时,参数和返回值都须要由 caller 在其栈帧上备好空间。callee 在声明时仍然须要知道这个 argsize。argsize 的计算方法是,参数大小求和+返回值大小求和,例如入参是 3 个 int64 类型,返回值是 1 个 int64 类型,那么这里的 argsize = sizeof(int64) * 4。
不过真实世界永远没有咱们假设的这么美好,函数参数每每混合了多种类型,还须要考虑内存对齐问题。
若是不肯定本身的函数签名须要多大的 argsize,能够经过简单实现一个相同签名的空函数,而后 go tool objdump 来逆向查找应该分配多少空间。
函数的 framesize 就稍微复杂一些了,手写代码的 framesize 不须要考虑由编译器插入的 caller BP,要考虑:
地址运算也是用 lea 指令,英文原意为 Load Effective Address
,amd64 平台地址都是 8 个字节,因此直接就用 LEAQ 就好:
LEAQ (BX)(AX*8), CX // 上面代码中的 8 表明 scale // scale 只能是 0、二、四、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 // 三个寄存器作运算,仍是别想了 // LEAQ DX(BX)(AX*8), CX // ./a.s:13: expected end of operand, found (
使用 LEAQ 的好处也比较明显,能够节省指令数。若是用基本算术指令来实现 LEAQ 的功能,须要两~三条以上的计算指令才能实现 LEAQ 的完整功能。
math.go:
package main import "fmt" func add(a, b int) int // 汇编函数声明 func sub(a, b int) int // 汇编函数声明 func mul(a, b int) int // 汇编函数声明 func main() { fmt.Println(add(10, 11)) fmt.Println(sub(99, 15)) fmt.Println(mul(11, 12)) }
math.s:
#include "textflag.h" // 由于咱们声明函数用到了 NOSPLIT 这样的 flag,因此须要将 textflag.h 包含进来 // func add(a, b int) int TEXT ·add(SB), NOSPLIT, $0-24 MOVQ a+0(FP), AX // 参数 a MOVQ b+8(FP), BX // 参数 b ADDQ BX, AX // AX += BX MOVQ AX, ret+16(FP) // 返回 RET // func sub(a, b int) int TEXT ·sub(SB), NOSPLIT, $0-24 MOVQ a+0(FP), AX MOVQ b+8(FP), BX SUBQ BX, AX // AX -= BX MOVQ AX, ret+16(FP) RET // func mul(a, b int) int TEXT ·mul(SB), NOSPLIT, $0-24 MOVQ a+0(FP), AX MOVQ b+8(FP), BX IMULQ BX, AX // AX *= BX MOVQ AX, ret+16(FP) RET // 最后一行的空行是必须的,不然可能报 unexpected EOF
把这两个文件放在任意目录下,执行 go build
并运行就能够看到效果了。
来写一段简单的代码证实伪 SP、伪 FP 和硬件 SP 的位置关系。
spspfp.s:
#include "textflag.h" // func output(int) (int, int, int) TEXT ·output(SB), $8-48 MOVQ 24(SP), DX // 不带 symbol,这里的 SP 是硬件寄存器 SP MOVQ DX, ret3+24(FP) // 第三个返回值 MOVQ perhapsArg1+16(SP), BX // 当前函数栈大小 > 0,因此 FP 在 SP 的上方 16 字节处 MOVQ BX, ret2+16(FP) // 第二个返回值 MOVQ arg1+0(FP), AX MOVQ AX, ret1+8(FP) // 第一个返回值 RET
spspfp.go:
package main import ( "fmt" ) func output(int) (int, int, int) // 汇编函数声明 func main() { a, b, c := output(987654321) fmt.Println(a, b, c) }
执行上面的代码,能够获得输出:
987654321 987654321 987654321
和代码结合思考,能够知道咱们当前的栈结构是这样的:
------ ret2 (8 bytes) ------ ret1 (8 bytes) ------ ret0 (8 bytes) ------ arg0 (8 bytes) ------ FP ret addr (8 bytes) ------ caller BP (8 bytes) ------ pseudo SP frame content (8 bytes) ------ hardware SP
本小节例子的 framesize 是大于 0 的,读者能够尝试修改 framesize 为 0,而后调整代码中引用伪 SP 和硬件 SP 时的 offset,来研究 framesize 为 0 时,伪 FP,伪 SP 和硬件 SP 三者之间的相对位置。
本小节的例子是为了告诉你们,伪 SP 和伪 FP 的相对位置是会变化的,手写时不该该用伪 SP 和 >0 的 offset 来引用数据,不然结果可能会出乎你的预料。
output.s:
#include "textflag.h" // func output(a,b int) int TEXT ·output(SB), NOSPLIT, $24-24 MOVQ a+0(FP), DX // arg a MOVQ DX, 0(SP) // arg x MOVQ b+8(FP), CX // arg b MOVQ CX, 8(SP) // arg y CALL ·add(SB) // 在调用 add 以前,已经把参数都经过物理寄存器 SP 搬到了函数的栈顶 MOVQ 16(SP), AX // add 函数会把返回值放在这个位置 MOVQ AX, ret+16(FP) // return result RET
output.go:
package main import "fmt" func add(x, y int) int { return x + y } func output(a, b int) int func main() { s := output(10, 13) fmt.Println(s) }
经过 DECQ 和 JZ 结合,能够实现高级语言里的循环逻辑:
sum.s:
#include "textflag.h" // func sum(sl []int64) int64 TEXT ·sum(SB), NOSPLIT, $0-32 MOVQ $0, SI MOVQ sl+0(FP), BX // &sl[0], addr of the first elem MOVQ sl+8(FP), CX // len(sl) INCQ CX // CX++, 由于要循环 len 次 start: DECQ CX // CX-- JZ done ADDQ (BX), SI // SI += *BX ADDQ $8, BX // 指针移动 JMP start done: // 返回地址是 24 是怎么得来的呢? // 能够经过 go tool compile -S math.go 得知 // 在调用 sum 函数时,会传入三个值,分别为: // slice 的首地址、slice 的 len, slice 的 cap // 不过咱们这里的求和只须要 len,但 cap 依然会占用参数的空间 // 就是 16(FP) MOVQ SI, ret+24(FP) RET
sum.go:
package main func sum([]int64) int64 func main() { println(sum([]int64{1, 2, 3, 4, 5})) }
标准库中的数值类型不少:
这些类型在汇编中就是一段存储着数据的连续内存,只是内存长度不同,操做的时候看好数据长度就行。
前面的例子已经说过了,slice 在传递给函数的时候,实际上会展开成三个参数:
在汇编中处理时,只要知道这个原则那就很好办了,按顺序仍是按索引操做随你开心。
package main //go:noinline func stringParam(s string) {} func main() { var x = "abcc" stringParam(x) }
用 go tool compile -S
输出其汇编:
0x001d 00029 (stringParam.go:11) LEAQ go.string."abcc"(SB), AX // 获取 RODATA 段中的字符串地址 0x0024 00036 (stringParam.go:11) MOVQ AX, (SP) // 将获取到的地址放在栈顶,做为第一个参数 0x0028 00040 (stringParam.go:11) MOVQ $4, 8(SP) // 字符串长度做为第二个参数 0x0031 00049 (stringParam.go:11) PCDATA $0, $0 // gc 相关 0x0031 00049 (stringParam.go:11) CALL "".stringParam(SB) // 调用 stringParam 函数
在汇编层面 string 就是地址 + 字符串长度。
struct 在汇编层面实际上就是一段连续内存,在做为参数传给函数时,会将其展开在 caller 的栈上传给对应的 callee:
struct.go
package main type address struct { lng int lat int } type person struct { age int height int addr address } func readStruct(p person) (int, int, int, int) func main() { var p = person{ age: 99, height: 88, addr: address{ lng: 77, lat: 66, }, } a, b, c, d := readStruct(p) println(a, b, c, d) }
struct.s
#include "textflag.h" TEXT ·readStruct(SB), NOSPLIT, $0-64 MOVQ arg0+0(FP), AX MOVQ AX, ret0+32(FP) MOVQ arg1+8(FP), AX MOVQ AX, ret1+40(FP) MOVQ arg2+16(FP), AX MOVQ AX, ret2+48(FP) MOVQ arg3+24(FP), AX MOVQ AX, ret3+56(FP) RET
上述的程序会输出 99, 88, 77, 66,这代表即便是内嵌结构体,在内存分布上依然是连续的。
经过对下述文件进行汇编(go tool compile -S),咱们能够获得一个 map 在对某个 key 赋值时所须要作的操做:
m.go:
package main func main() { var m = map[int]int{} m[43] = 1 var n = map[string]int{} n["abc"] = 1 println(m, n) }
看一看第七行的输出:
0x0085 00133 (m.go:7) LEAQ type.map[int]int(SB), AX 0x008c 00140 (m.go:7) MOVQ AX, (SP) 0x0090 00144 (m.go:7) LEAQ ""..autotmp_2+232(SP), AX 0x0098 00152 (m.go:7) MOVQ AX, 8(SP) 0x009d 00157 (m.go:7) MOVQ $43, 16(SP) 0x00a6 00166 (m.go:7) PCDATA $0, $1 0x00a6 00166 (m.go:7) CALL runtime.mapassign_fast64(SB) 0x00ab 00171 (m.go:7) MOVQ 24(SP), AX 0x00b0 00176 (m.go:7) MOVQ $1, (AX)
前面咱们已经分析过调用函数的过程,这里前几行都是在准备 runtime.mapassign_fast64(SB) 的参数。去 runtime 里看看这个函数的签名:
func mapassign_fast64(t *maptype, h *hmap, key uint64) unsafe.Pointer {
不用看函数的实现咱们也大概能推测出函数输入参数和输出参数的关系了,把入参和汇编指令对应的话:
t *maptype => LEAQ type.map[int]int(SB), AX MOVQ AX, (SP) h *hmap => LEAQ ""..autotmp_2+232(SP), AX MOVQ AX, 8(SP) key uint64 => MOVQ $43, 16(SP)
返回参数就是 key 对应的能够写值的内存地址,拿到该地址后咱们把想要写的值写进去就能够了:
MOVQ 24(SP), AX MOVQ $1, (AX)
整个过程还挺复杂的,咱们手抄一遍倒也能够实现。不过还要考虑,不一样类型的 map,实际上须要执行的 runtime 中的 assign 函数是不一样的,感兴趣的同窗能够汇编本节的示例自行尝试。
总体来说,用汇编来操做 map 并非一个明智的选择。
channel 在 runtime 也是比较复杂的数据结构,若是在汇编层面操做,实际上也是调用 runtime 中 chan.go 中的函数,和 map 比较相似,这里就不展开说了。
Go 的 goroutine 是一个叫 g 的结构体,内部有本身的惟一 id,不过 runtime 没有把这个 id 暴露出来,但不知道为何有不少人就是想把这个 id 获得。因而就有了各类或其 goroutine id 的库。
在 struct 一小节咱们已经提到,结构体自己就是一段连续的内存,咱们知道起始地址和字段的偏移量的话,很容易就能够把这段数据搬运出来:
go_tls.h:
#ifdef GOARCH_arm #define LR R14 #endif #ifdef GOARCH_amd64 #define get_tls(r) MOVQ TLS, r #define g(r) 0(r)(TLS*1) #endif #ifdef GOARCH_amd64p32 #define get_tls(r) MOVL TLS, r #define g(r) 0(r)(TLS*1) #endif #ifdef GOARCH_386 #define get_tls(r) MOVL TLS, r #define g(r) 0(r)(TLS*1) #endif
goid.go:
package goroutineid import "runtime" var offsetDict = map[string]int64{ // ... 省略一些行 "go1.7": 192, "go1.7.1": 192, "go1.7.2": 192, "go1.7.3": 192, "go1.7.4": 192, "go1.7.5": 192, "go1.7.6": 192, // ... 省略一些行 } var offset = offsetDict[runtime.Version()] // GetGoID returns the goroutine id func GetGoID() int64 { return getGoID(offset) } func getGoID(off int64) int64
goid.s:
#include "textflag.h" #include "go_tls.h" // func getGoID() int64 TEXT ·getGoID(SB), NOSPLIT, $0-16 get_tls(CX) MOVQ g(CX), AX MOVQ offset(FP), BX LEAQ 0(AX)(BX*1), DX MOVQ (DX), AX MOVQ AX, ret+8(FP) RET
这样就实现了一个简单的获取 struct g 中的 goid 字段的小 library,做为玩具放在这里:
https://github.com/cch123/gor...
SIMD 是 Single Instruction, Multiple Data 的缩写,在 Intel 平台上的 SIMD 指令集前后为 SSE,AVX,AVX2,AVX512,这些指令集引入了标准之外的指令,和宽度更大的寄存器,例如:
这些寄存器的关系,相似 RAX,EAX,AX 之间的关系。指令方面能够同时对多组数据进行移动或者计算,例如:
上述指令,当咱们将数组做为函数的入参时有很大几率会看到,例如:
arr_par.go:
package main import "fmt" func pr(input [3]int) { fmt.Println(input) } func main() { pr([3]int{1, 2, 3}) }
go compile -S:
0x001d 00029 (arr_par.go:10) MOVQ "".statictmp_0(SB), AX 0x0024 00036 (arr_par.go:10) MOVQ AX, (SP) 0x0028 00040 (arr_par.go:10) MOVUPS "".statictmp_0+8(SB), X0 0x002f 00047 (arr_par.go:10) MOVUPS X0, 8(SP) 0x0034 00052 (arr_par.go:10) CALL "".pr(SB)
可见,编译器在某些状况下已经考虑到了性能问题,帮助咱们使用 SIMD 指令集来对数据搬运进行了优化。
由于 SIMD 这个话题自己比较广,这里就不展开细说了。
研究过程基本碰到不太明白的都去骚扰卓巨巨了,就是这位 https://mzh.io/ 大大。特别感谢他,给了很多线索和提示。
参考资料[4]须要特别注意,在该 slide 中给出的 callee stack frame 中把 caller 的 return address 也包含进去了,我的认为不是很合适。