五一假期在家没事逛论坛的时候,发现了一个宝藏网站,传送门 这个网站可以在线生成多种语言的汇编代码,有这个好东西,那必须拿go实验一番。
很久之前我写过一篇go通过go汇编看多返回值实现的文章传送门。当时写的时候比较早,后来 go 1.17 对函数调用时,传递参数做了修改,简单说就是go1.17之前,函数参数是通过栈空间来传递的,在go1.17时做出了改变,在一些平台上(AMD64)可以像C,C++那样使用寄存器传递参数和函数返回值了。为什么做出这个改变呢,原因就是寄存器更快。虽然内存已经很快了,但是还是没法和寄存器相比。之前为啥不用寄存器,用栈空间,原因是实现简单,不用考虑不同平台,不用架构的区别。
简单总结一下两种方式
- 栈空间:
- 优点:实现简单,不用区分不同的平台,通用性强
- 缺点:效率低
- 寄存器:
- 优点:速度快
- 缺点:通用性差,不同的平台需要单独处理
当然,这里说的通用性差是对于编译器来说的
go汇编基础知识
再来总结一次go汇编的基础知识吧,现在回头看之前总结的还是不全面的
go使用的 plan9 汇编,这个和 AT&T 的汇编差别还是有点大的,我个人感觉plan9汇编比较重要的就是四个寄存器,只要理解了这四个寄存器,汇编就理解了一半了
汇编中个几个术语:
- 栈:进程、线程、goroutine 都有自己的调用栈,先进后出(FILO)
- 栈帧:可以理解是函数调用时,在栈上为函数所分配的内存区域
- 调用者:caller,比如:A 函数调用了 B 函数,那么 A 就是调用者
- 被调者:callee,比如:A 函数调用了 B 函数,那么 B 就是被调者
寄存器 | 说明 |
---|---|
SB(Static base pointer) | global symbols 全局静态指针 |
FP(Frame pointer) | arguments and locals 指向栈帧的开始 |
SP(Stack pointer) | top of stack 指向栈顶 |
PC(Program counter) | jumps and branches 简单说程序计数器 |
简单展开说一下几个寄存器吧
- SB:全局静态指针,即程序地址空间的开始地址。一般用在声明函数、全局变量中。
- FP:指向的是 caller 调用 callee 时传递的第一个参数的位置,可以看作是指向两个函数栈的分割位置;但是FP指向的位置不在 callee 的 stack frame 之内。而是在 caller 的 stack frame 上,指向调用 add 函数时传递的第一个参数的位置;可以在 callee 中用
symbol+offset(FP)
来获取入参的参数值,比如a+8(FP)
。虽然symbol
没有什么具体意义,但是不加编译器会报错。 - SP:这个是最常用的寄存器了,同样也是最复杂的寄存器了。不同的引用方式,代表不同的位置。SP寄存器 分为伪 SP 寄存器和硬件 SP 寄存器。
symbol+offset(SP)
形式,则表示伪寄存器 SP (这个也简称为 BP)。如果是offset(SP)
则表示硬件寄存器 SP。伪 SP 寄存器指向当前栈帧第一个局部变量的结束位置;硬件SP指向的是整个函数栈结束的位置。有个比较坑的地方:对于编译输出(go tool compile -S / go tool objdump)的代码来讲,所有的 SP 都是硬件 SP 寄存器,无论是否带 symbol(这一点非常具有迷惑性,需要慢慢理解。往往在分析编译输出的汇编时,看到的就是硬件 SP 寄存器)。 - PC:这个就是计算机常见的 pc 寄存器,在 x86 平台下对应 ip 寄存器,amd64 上则是 rip。这个很少有用到。
通过一个栈帧的图来理解一下这几个寄存器
大体的栈帧就是图中的这样,图中标注的寄存器都是以 callee 函数为基准的
通过图中可知,如果callee函数中没有局部变量的话,SP硬寄存器和SP伪寄存器指向的是同一个地方
伪 FP 寄存器对应的是 caller 函数的帧指针,一般用来访问 callee 函数的入参参数和返回值。伪 SP 栈指针对应的是当前 callee 函数栈帧的底部(不包括参数和返回值部分),一般用于定位局部变量。硬件 SP 是一个比较特殊的寄存器,因为还存在一个同名的 SP 真寄存器,硬件 SP 寄存器对应的是栈的顶部。
在编写 Go 汇编时,当需要区分伪寄存器和真寄存器的时候只需要记住一点:伪寄存器一般需要一个标识符和偏移量为前缀,如果没有标识符前缀则是真寄存器。比如(SP)、+8(SP)没有标识符前缀为真 SP 寄存器,而 a(SP)、b+8(SP)有标识符为前缀表示伪寄存器。
还有一点
如果callee的栈空间大小是0的话, caller BP 是不会被压入栈中的,此时的SP硬件寄存器和伪FP寄存器指向的是同一个位置。
在汇编中,函数的定义
TEXT "".add(SB), NOSPLIT|ABIInternal, $0-24
TEXT
是一个特殊的指令,定义一个函数
.add
是函数的名
NOSPLIT
向编译器表明不应该插入 stack-split 的用来检查栈需要扩张的前导指令
$0-24
这两个参数,0是声明这个函数需要的栈空间的大小,一般来说就是局部变量需要的空间,单位是位。
24是声明函数传入参数和返回值需要的栈空间的大小,单位也是位。
生成汇编
了解了这些基本概念后,上一段代码,通过汇编看一下不同版本go是如何处理函数传递参数的
先通过一段简单的代码看一下区别
package main
func main(){
add(10,20)
}
//go:noinline
func add(a,b int) int{
return a+b
}
//go:noinline
这个是告诉编译器不要对这个函数内联,这个东西叫go的编译指令,go还有你很多别的指令,这里就不展开了。想想go也挺有意思的,C++是通过 inline
显式的指定要内联,go是告诉编译器不要内联。
先看一下在1.16下的汇编代码
main_pc0:
.file 1 "
代码有很多,只关注下面图中标注的
通 MOVQ "".b+16(SP), AX
和 MOVQ "".a+8(SP), CX
可以得知,函数是通过SP寄存器偏移完成传递参数的。
这里要注意,.b+16(SP)
这种写法看着像是使用的是伪SP寄存器,实际上用的是硬件SP寄存器
同样的,在函数调用之前,也会把数值放到栈的指定位置
MOVQ $10, (SP)
, MOVQ $20, 8(SP)
把编译器换成最新的 1.18看一下
main_pc0:
.file 1 "
在1.18的汇编代码中就没有通过栈空间来传递参数了,而是直接通过寄存器完成操作,
ADDQ BX, AX
,并且返回值直接放到寄存器中。
寄存器数量是有上限的,如果传递的参数个数超过了寄存器的上限,又会怎样处理呢
package main
func main(){
add(1,2,3,4,5,6,7,8,9,10,11,12)
}
//go:noinline
func add(a,b,c,d,e,f,g,h,i,j,k,l int) int{
return a+b+c+d+e+f+g+h+i+j+k+l
}
对应的汇编
main_pc0:
.file 1 "
通过汇编可以看到,会先使用寄存器,当寄存器不够时,会使用栈空间。
手写汇编
通过手写一段汇编代码,验证一下各个寄存器的位置,我用的go版本是 1.14.13,所以传参数用的是栈空间。
在 main.go 文件中
package main
func add(int, int) int
func main() {
print(add(10, 20))
}
定义一个 main 函数作为整个程序的入口,声明一个 add(int, int) int
函数,add 函数的具体实现是用汇编写的,
在 main.go 同级目录下创建一个 add_amd64.s 的文件。
使用硬BP寄存器
TEXT ·add(SB), $0-24
MOVQ 8(SP), AX
MOVQ 16(SP), BX
ADDQ BX, AX
MOVQ AX, 24(SP)
RET
使用 go run . 命令,看一下程序执行的结果。
这时候的栈帧如图所示
因为add函数栈空间是0,所以伪SP寄存器没有被压入栈中,伪SP寄存器和硬件SP寄存器指向的是同一个位置。
使用伪SP寄存器
TEXT ·add(SB), $16-24
MOVQ a+16(SP), AX
MOVQ b+24(SP), BX
ADDQ BX, AX
MOVQ AX, ret+32(SP)
RET
为了区分对比,这时候把add函数的栈空间设置为16,然后使用伪SP寄存器来获取值。
执行结果如下:
这时候的栈空间如图
使用FP寄存器
代码如下:
TEXT ·add(SB), $16-24
MOVQ a+0(FP), AX
MOVQ b+8(FP), BX
ADDQ BX, AX
MOVQ AX, ret+16(FP)
RET
执行结果:
说明还是正确的,此时的栈空间没有变化,和上面是一样的。
到这,应该能理解各个寄存器的相对位置了吧:
在callee栈空间不为0的时候,
FP = 硬件SP + framsize + 16
SP = 硬件SP + framsize
在callee栈空间为0的时候,
FP = 硬件SP + 8
SP = 硬件SP
汇编简单分析
通过上面的代码会发现,手写汇编的代码和反汇编的代码有些不同。反汇编得到的代码,在函数前面会有一段
CALL runtime.morestack_noctxt(SB)
其实这个是编译器自动插入的一段函数,这段指令会调用一次 runtime.morestack_noctxt
这个函数具体的作用是挺复杂的,主要有 检查是否需要扩张栈,go的栈空间是可以动态扩充的,所以在调用函数前会检查当前的栈空间是否需要扩充。还有一个功能就是检查当前协程需要抢占。go在1.14之前goroutine的抢占是协作式抢占模式,怎么判断一个协程是否需要抢占呢?后台协程会定时扫描当前运行中的协程,如果发现一个协程运行比较久,会将其标记为抢占状态。这个扫描的时间点就是函数调用期间完成的。
不同类型参数传递
传结构体
package main
type One struct {
a int
b int
}
func main(){
o := One {
a:10,
b.20,
}
f1(o)
}
//go:noinline
func f1(o One) int {
return o.a + o.b
}
只贴 关键的汇编代码吧
MOVQ $10, (SP)
MOVQ $0, 8(SP)
PCDATA $1, $0
CALL "".f1(SB)
..........................
TEXT "".f1(SB), NOSPLIT|ABIInternal, $0-24
MOVQ "".o+16(SP), AX
MOVQ "".o+8(SP), CX
ADDQ CX, AX
MOVQ AX, "".~r1+24(SP)
RET
在传结构体的时候,只把结构体的内容传进去了。
传结构体指针
package main
type One struct {
a int
b int
}
func main(){
o := &One{
a:10,
b:20,
}
f1(o)
}
//go:noinline
func f1(o *One) int {
return o.a + o.b
}
汇编
LEAQ ""..autotmp_2+16(SP), AX
MOVQ AX, (SP)
PCDATA $1, $0
CALL "".f1(SB)
.......................
TEXT "".f1(SB), NOSPLIT|ABIInternal, $0-16
MOVQ "".o+8(SP), AX
MOVQ (AX), CX
ADDQ 8(AX), CX
MOVQ CX, "".~r1+16(SP)
RET
可以看到,传递指针的时候,只是把结构体第一个元素的地址传递进去了。
如果是传一个空的结构体
package main
type One struct {
}
func main(){
o := One{}
f1(o,10,20)
}
//go:noinline
func f1(o One, a,b int) int {
return a + b
}
汇编
TEXT "".f1(SB), NOSPLIT|ABIInternal, $0-24
.loc 1 15 0
MOVQ "".b+16(SP), AX
MOVQ "".a+8(SP), CX
ADDQ CX, AX
MOVQ AX, "".~r3+24(SP)
RET
可以看到,当传递一个空结构体时,相当于没有传递,因为空结构体的空间大小是0,所以编译器就给忽略了。
最后
暂时就想到这么多,先写这些吧,后面想起别的再补充吧