计算机结构
在使用汇编语言之前必须要了解对应的CPU体系结构。下面是X86/AMD架构图:
左边是内存部分是常见的内存布局。其中text一般对应代码段,用于存储要执行指令数据,代码段一般是只读的。然后是rodata和data数据段,数据段一般用于存放全局的数据,其中rodata是只读的数据段。而heap段则用于管理动态的数据,stack段用于管理每个函数调用时相关的数据。在汇编语言中一般重点关注text代码段和data数据段,因此Go汇编语言中专门提供了对应TEXT和DATA命令用于定义代码和数据。
中间是X86提供的寄存器。寄存器是CPU中最重要的资源,每个要处理的内存数据原则上需要先放到寄存器中才能由CPU处理,同时寄存器中处理完的结果需要再存入内存。X86中除了状态寄存器FLAGS和指令寄存器IP两个特殊的寄存器外,还有AX、BX、CX、DX、SI、DI、BP、SP几个通用寄存器。在X86-64中又增加了八个以R8-R15方式命名的通用寄存器。因为历史的原因R0-R7并不是通用寄存。在通用寄存器中BP和SP是两个比较特殊的寄存器:其中BP用于记录当前函数帧的开始位置,和函数调用相关的指令会隐式地影响BP的值;SP则对应当前栈指针的位置,和栈相关的指令会隐式地影响SP的值。
右边是X86的指令集。CPU是由指令和寄存器组成,指令是每个CPU内置的算法,指令处理的对象就是全部的寄存器和内存。我们可以将每个指令看作是CPU内置标准库中提供的一个个函数,然后基于这些函数构造更复杂的程序的过程就是用汇编语言编程的过程。
Go汇编为了简化汇编代码的编写,引入了PC、FP、SP、SB四个伪寄存器。四个伪寄存器加其它的通用寄存器就是Go汇编语言对CPU的重新抽象,该抽象的结构也适用于其它非X86类型的体系结构。
四个伪寄存器和X86/AMD64的内存和寄存器的相互关系如下图:
在AMD64环境,伪PC寄存器其实是IP指令计数器寄存器的别名。伪FP寄存器对应的是函数的帧指针,一般用来访问函数的参数和返回值。伪SP栈指针对应的是当前函数栈帧的底部(不包括参数和返回值部分),一般用于定位局部变量。伪SP是一个比较特殊的寄存器,因为还存在一个同名的SP真寄存器。真SP寄存器对应的是栈的顶部,一般用于定位调用其它函数的参数和返回值。当需要区分伪寄存器和真寄存器的时候只需要记住一点:伪寄存器一般需要一个标识符和偏移量为前缀,如果没有标识符前缀则是真寄存器。比如 (SP)、 +8(SP)没有标识符前缀为真SP寄存器,而 a(SP)、b+8(SP)有标识符为前缀表示伪寄存器。
指令集
MOV指令可以用于将字面值移动到寄存器、字面值移到内存、寄存器之间的数据传输、寄存器和内存之间的数据传输:
基础的逻辑运算指令有AND、OR和NOT等几个指令,对应逻辑与、或和取反等几个指令:
控制流指令有CMP、JMP-if-x、JMP、CALL、RET等指令。CMP指令用于两个操作数做减法,根据比较结果设置状态寄存器的符号位和零位,可以用于有条件跳转的跳转条件。JMP-if-x是一组有条件跳转指令,常用的有JL、JLZ、JE、JNE、JG、JGE等指令,对应小于、小于等于、等于、不等于、大于和大于等于等条件时跳转。JMP指令则对应无条件跳转,将要跳转的地址设置到IP指令寄存器就实现了跳转。而CALL和RET指令分别为调用函数和函数返回指令:
其它比较重要的指令有LEA、PUSH、POP等几个。其中LEA指令将标准参数格式中的内存地址加载到寄存器(而不是加载内存位置的内容)。PUSH和POP分别是压栈和出栈指令,通用寄存器中的SP为栈指针,栈是向低地址方向增长的:
当需要通过间接索引的方式访问数组或结构体等某些成员对应的内存时,可以用LEA指令先对目前内存取地址,然后在操作对应内存的数据。
变量的内存分布
Go汇编语言提供了DATA命令用于初始化包变量,DATA命令的语法如下:
DATA •symbol+offset(SB)/width,value
其中symbol为变量在汇编语言中对应的标识符,offset是符号开始地址的偏移量,width是要初始化内存的宽度大小,value是要初始化的值。其中当前包中Go语言定义的符号symbol,在汇编代码中对应 ·symbol,其中“·”中点符号为一个特殊的unicode符号。
我们采用以下命令可以给Id变量初始化为十六进制的0x2537,对应十进制的9527(常量需要以美元符号$开头表示):
DATA •Id+0(SB)/1,$0x37
DATA •Id+1(SB)/1,$0x25
变量定义好之后需要导出以供其它代码引用。Go汇编语言提供了GLOBL命令用于将符号导出:
GLOBL symbol(SB), width
其中symbol对应汇编中符号的名字,width为符号对应内存的大小。用以下命令将汇编中的·Id变量导出:
GLOBL •Id, $8
至此初步完成了用汇编定义一个整数变量的工作。
用汇编定义一个 [2]int类型的数组变量num:
var num [2]int
然后在汇编中定义一个对应16字节大小的变量,并用零值进行初始化:
GLOBL •num(SB),$16
DATA •num+0(SB)/8,$0
DATA •num+8(SB)/8,$
下图是Go语句和汇编语句定义变量时的对应关系:
汇编代码中并不需要NOPTR标志,因为Go编译器会从Go语言语句声明的 [2]int 类型中推导出该变量内部没有指针数据。
var num [2]int数组的内存布局:
变量在data段分配空间,数组的元素地址依次从低向高排列。
函数
函数标识符通过TEXT汇编指令定义,表示该行开始的指令定义在TEXT内存段。TEXT语句后的指令一般对应函数的实现函数的定义的语法如下:
TEXT symbol(SB), [flags,] $framesize[-argsize]
函数的定义部分由5个部分组成:TEXT指令、函数名、可选的flags标志、函数帧大小和可选的函数参数大小。
其中TEXT用于定义函数符号,函数名中当前包的路径可以省略。函数的名字后面是 (SB) ,表示是函数名符号相对于SB伪寄存器的偏移量,二者组合在一起最终是绝对地址。标志部分用于指示函数的一些特殊行为,常见的 NOSPLIT主要用于指示叶子函数不进行栈分裂。framesize部分表示函数的局部变量需要多少栈空间,其中包含调用其它函数时准备调用参数的隐式栈空间。最后是可以省略的参数大小,之所以可以省略是因为编译器可以从Go语言的函数声明中推导出函数参数的大小。
参数和返回值
我们首先从一个简单的Swap函数开始。Swap函数用于交互输入的两个参数的顺序,然后通过返回值返回交换了顺序的结果。如果用Go语言中声明Swap函数,大概这样的:
package main
//go:nosplit
func Swap(a, b int) (int,int)
下面是main包中Swap函数在汇编中两种定义方式:
//func Swap(a, b int) (int,int)
TEXT •Swap(SB), NOSPLIT, $0-32
//func Swap(a, b int) (int,int)
TEXT •Swap(SB), NOSPLIT, $0
对于这个函数,我们可以轻易看出它需要4个int类型的空间,参数和返回值的大小也就是32个字节。
那么如何在汇编中引用这4个参数呢?为此Go汇编中引入了一个FP伪寄存器,表示函数当前帧的地址,也就是第一个参数的地址。因此我们以通过 +0(FP)、+8(FP)、+16(FP) 和 +24(FP)来分别引用a、b、ret0和ret1四个参数。任何通过FP伪寄存器访问的变量必和一个临时标识符前缀组合后才能有效,一般使用参数对应的变量名作为前缀.
下图是Swap函数中参数和返回值在内存中的布局图:
TEXT •Swap(SB), $0
MOVQ a+0(FP), AX //AX =a
MOVQ b+8(FP), BX //BX =b
MOVQ BX, ret0+16(FP) //ret0 =BX
MOVQ AX, ret1+24(FP) //ret1 =AX
RET
从代码可以看出a、b、ret0和ret1的内存地址是依次递增的,FP伪寄存器是第一个变量的开始地址。
函数的局部变量
从Go汇编角度看,局部变量是指函数运行时,在当前函数栈帧所对应的内存内的变量,不包含函数的参数和返回值(因为访问方式有差异)。函数栈帧的空间主要由函数参数和返回值、局部变量和被调用其它函数的参数和返回值空间组成。
为了便于访问局部变量,Go汇编语言引入了伪SP寄存器,对应当前栈帧的底部。因为在当前栈帧时栈的底部是固定不变的,因此局部变量的相对于伪SP的偏移量也就是固定的,这可以简化局部变量的维护工作。SP真伪寄存器的区分只有一个原则:如果使用SP时有一个临时标识符前缀就是伪SP,否则就是真SP寄存器。比如a(SP)和b+8(SP)有a和b临时前缀,这里都是伪SP,而前缀部分一般用于表示局部变量的名字。而(SP)和+8(SP)没有临时标识符作为前缀,它们都是真SP寄存器。
在X86平台,函数的调用栈是从高地址向低地址增长的,因此伪SP寄存器对应栈帧的底部其实是对应更大的地址。当前栈的顶部对应真实存在的SP寄存器,对应当前函数栈帧的栈顶,对应更小的地址。如果整个内存用Memory数组表示,那么 Memory[0(SP):end-0(SP)]就是对应当前栈帧的切片,其中开始位置是真SP寄存器,结尾部分是伪SP寄存器。真SP寄存器一般用于表示调用其它函数时的参数和返回值,真SP寄存器对应内存较低的地址,所以被访问变量的偏移量是正数;而伪SP寄存器对应高地址,对应的局部变量的偏移量都是负数。
func Foo() {
var c []byte
var b int16
var a bool
}
然后通过汇编语言重新实现Foo函数,并通过伪SP来定位局部变量:
TEXT •Foo(SB), $32-0
MOVQ a-32(SP), AX // a
MOVQ b-30(SP), BX // b
MOVQ c_data-24(SP), CX // c.Data
MOVQ c_len-16(SP), DX // c.Len
MOVQ c_cap-8(SP), DI // c.Cap
RET
Foo函数有3个局部变量,但是没有调用其它的函数,因为对齐和填充的问题导致函数的栈帧大小为32个字节。因为Foo函数没有参数和返回值,因此参数和返回值大小为0个字节,当然这个部分可以省略不写。而局部变量中先定义的变量c离伪SP寄存器对应的地址最远,最后定义的变量a离伪SP寄存器最近。有两个因素导致出现这种逆序的结果:一个从Go语言函数角度理解,先定义的c变量地址要比后定义的变量的地址更小;另一个是伪SP寄存器对应栈帧的底部,而X86中栈是从高向地生长的,所以最先定义有着更小地址的c变量离栈的底部伪SP更远。
下面是Foo函数的局部变量的大小和内存布局:
是参数和返回值是通过伪FP寄存器定位的,FP寄存器对应第一个参数的开始地址
(第一个参数地址较低),因此每个变量的偏移量是正数。而局部变量是通过伪SP寄存器定位的,而伪SP寄存器对应的是第一个局部变量的结束地址(第一个局部变量地址较大),因此每个局部变量的偏移量都是负数。
函数调用
在前文中我们已经学习了一些汇编实现的函数参数和返回值处理的规则。那么一个显然的问题是,汇编函数的参数是从哪里来的?答案同样明显,被调用函数的参数是由调用方准备的:调用方在栈上设置好空间和数据后调用函数,被调用方在返回前将返回值放在对应的位置,函数通过RET指令返回调用方函数之后,调用方再从返回值对应的栈内存位置取出结果。Go语言函数的调用参数和返回值均是通过栈传输的,这样做的优点是函数调用栈比较清晰,缺点是函数调用有一定的性能损耗。为了便于展示,我们先使用Go语言来构造三个逐级调用的函数:
func main(){
printsum(1,2)
}
func printsum(a,b int){
var ret=sum(a,b)
println(ret)
}
func sum(a,b int)int{
return a+b
}
下图展示了三个函数逐级调用时内存中函数参数和返回值的布局:
要记住的是调用函数时,被调用函数的参数和返回值内存空间都必须由调用者提供。因此函数的局部变量和为调用其它函数准备的栈空间总和就确定了函数帧的大小。调用其它函数前调用方要选择保存相关寄存器到栈中,并在调用函数返回后选择要恢复的寄存器进行保存。最终通过CALL指令调用函数的过程和调用我们熟悉的调用println函数输出的过程类似。
和C语言函数不同,Go语言函数的参数和返回值完全通过栈传递。下面是Go函数调用时栈的布局图:
首先是调用函数前准备的输入参数和返回值空间。然后CALL指令将首先触发返回地址入栈操作。在进入到被调用函数内之后,汇编器自动插入了BP寄存器相关的指令,因此BP寄存器和返回地址是紧挨着的。再下面就是当前函数的局部变量的空间,包含再次调用其它函数需要准备的调用参数空间。被调用的函数执行RET返回指令时,先从栈恢复BP和SP寄存器,接着取出的返回地址跳转到对应的指令执行。
函数控制流
程序主要有顺序、分支和循环几种执行流程。
顺序:
func main() {
var a,b int
a=10
runtime.printint(a)
runtime.printnl()
b=a
b+=b
b*=a
runtime.printint(b)
runtime.printnl()
}
汇编函数:
TEXT •main(SB), $24-0
MOVQ $0, a-8*2(SP) //a= 0
MOVQ $0, b-8*1(SP) //b=0
//将新的值写入a对应内存
MOVQ $10, AX //AX =10
MOVQ AX, a-8*2(SP) //a =AX
//以a为参数调用函数
MOVQ AX, 0(SP)
CALL runtime•printint(SB)
CALL runtime•printnl(SB)
//函数调用后, AX/BX 寄存器可能被污染, 需要重新加载
MOVQ a-8*2(SP), AX //AX=a
MOVQ b-8*1(SP), BX //BX=b
//计算b值,并写入内存
MOVQ AX, BX //BX=AX //b=a
ADDQ BX, BX //BX+=BX //b+=a
IMULQ AX, BX //BX*=AX //b*=a
MOVQ BX, b-8*1(SP) //b=BX
// 以b为参数调用函数
MOVQ BX, 0(SP)
CALL runtime•printint(SB)
CALL runtime•printnl(SB)
RET
汇编实现main函数的第一步是要计算函数栈帧的大小。因为函数内有a、b两个int类型变量,同时调用
的runtime·printint函数参数是一个int类型并且没有返回值,因此main函数的栈帧是3个int类型组成的24个字节的栈内存空间。
给a变量分配一个AX寄存器,并且通过AX寄存器将a变量对应的内存设置为10,AX也是10。为了输出a变量,需要将AX寄存器的值放到 0(SP) 位置,这个位置的变量将在调用runtime·printint函数时作为它的参数被打印。因为我们之前已经将AX的值保存到a变量内存中了,因此在调用函数前并不需要再进行寄存器的备份工作。
在调用函数返回之后,全部的寄存器将被视为可能被调用的函数修改,因此我们需要从a、b对应的内存中重新恢复寄存器AX和BX。然后参考上面Go语言中b变量的计算方式更新BX对应的值,计算完成后同样将BX的值写入到b对应的内存。
需要说明的是,上面的代码中IMULQ AX, BX使用了IMULQ指令来计算乘法。没有使用 MULQ指令的原因是MULQ指令默认使用AX保存结果。读者可以自己尝试用 MULQ指令改写上述代码。最后以b变量作为参数再次调用runtime·printint函数进行输出工作。所有的寄存器同样可能被污染,不过main函数马上就返回了,因此不再需要恢复AX、BX等寄存器了。
经过用汇编的思维改写过后,上述的Go函数虽然看着繁琐了一点,但是还是比较容易理解的。下面我们进一步尝试将改写后的函数继续转译为汇编函数:
TEXT •main(SB), $24-0
MOVQ $0, a-8*2(SP) //a=0
MOVQ $0, b-8*1(SP) //b=0
//将新的值写入a对应内存
MOVQ $10, AX //AX=10
MOVQ AX, a-8*2(SP) //a=AX
//以a为参数调用函数
MOVQ AX, 0(SP)
CALL runtime•printint(SB)
CALL runtime•printnl(SB)
//函数调用后, AX/BX 寄存器可能被污染, 需要重新加载
MOVQ a-8*2(SP), AX //AX =a
MOVQ b-8*1(SP), BX //BX =b
//计算b值, 并写入内存
MOVQ AX, BX //BX=AX //b=a
ADDQ BX, BX //BX +=BX //b+=a
IMULQ AX, BX //BX*=AX //b*=a
MOVQ BX, b-8*1(SP) //b =BX
//以b为参数调用函数
MOVQ BX, 0(SP)
CALL runtime•printint(SB)
CALL runtime•printnl(SB)
RET
If/goto跳转:
func If(ok bool,a,b int)int{
if ok {
return a
}else{
return b
}
}
用汇编思维改写后的If函数实现如下:
func If(ok int,a,b int)int {
if ok == 0{ goto L}
return a
L:
return b
}
汇编实现:
TEXT •If(SB), NOSPLIT, $0-32
MOVQ ok+8*0(FP), CX // ok
MOVQ a+8*1(FP), AX //a
MOVQ b+8*2(FP), BX //b
CMPQ CX, $0 //test ok
JZ L //ifok== 0,goto L
MOVQ AX, ret+24(FP) //return a
RET
L:
MOVQ BX, ret+24(FP) //return b
RET
首先是将三个参数加载到寄存器中,ok参数对应CX寄存器,a、b分别对应AX、BX寄存器。然后使用CMPQ比较指令将CX寄存器和常数0进行比较。如果比较的结果为0,那么下一条JZ为0时跳转指令将跳转到L标号对应的语句,也就是返回变量b的值。如果比较的结果不为0,那么JZ指令将没有效果,继续执行后面的指令,也就是返回变量a的值。
在跳转指令中,跳转的目标一般是通过一个标号表示。不过在有些通过宏实现的函数中,更希望通过相对位置跳转,这时候可以通过PC寄存器的偏移量来计算临近跳转的位置。
for循环:
func LoopAdd(cnt, v0, step int) int{
result :=v0
for i := 0;i < cnt; i++{
result += step
}
return result
}
用汇编思维改写:
func LoopAdd(cnt, v0, step int) int {
var i=0
var result= 0
LOOP_BEGIN:
result=v0
LOOP_IF:
if i < cnt { goto LOOP_BODY }
goto LOOP_END
LOOP_BODY
i = i+1
result = result+step
goto LOOP_IF
LOOP_END:
return result
}
汇编语言实现:
//func LoopAdd(cnt,v0, step int)int
TEXT •LoopAdd(SB), NOSPLIT, $0-32
MOVQ cnt+0(FP), AX //cnt
MOVQ v0+8(FP), BX //v0/result
MOVQ step+16(FP), CX //step
LOOP_BEGIN:
MOVQ $0, DX //i
LOOP_IF:
CMPQ DX, AX //comparei,cnt
JL LOOP_BODY //if i< cnt:goto LOOP_BODY
JMP LOOP_END
LOOP_BODY:
ADDQ $1, DX //i++
ADDQ CX, BX //result+=step
JMP LOOP_IF
LOOP_END:
MOVQ BX, ret+24(FP) //return result
RET
其中v0和result变量复用了一个BX寄存器。在LOOP_BEGIN标号对应的指令部分,用MOVQ将DX寄存器初始化为0,DX对应变量i,循环的迭代变量。在LOOP_IF标号对应的指令部分,使用CMPQ指令比较DX和AX,如果循环没有结束则跳转到LOOP_BODY部分,否则跳转到LOOP_END部分结束循环。在LOOP_BODY部分,更新迭代变量并且执行循环体中的累加语句,然后直接跳转到LOOP_IF部分进入下一轮循环条件判断。LOOP_END标号之后就是返回累加结果的语句。
循环是最复杂的控制流,循环中隐含了分支和跳转语句。掌握了循环的写法基本也就掌握了汇编语言的基础写法。
进阶
Go汇编实现的函数或调用函数的指令在最终代码中也会被插入额外的指令。要彻底理解Go汇编语言就需要彻底了解汇编器到底插入了哪些指令。为了便于分析,我们先构造一个禁止栈分裂的printnl函数。printnl函数内部都通过调用runtime.printnl函数输出换行:
TEXT •printnl_nosplit(SB), NOSPLIT, $8
CALL runtime•printnl(SB)
RET
然后通过 go tool asm -S main_amd64.s 指令查看编译后的目标代码:
"".printnl_nosplit STEXT nosplit size=29 args=0xffffffff80000000 locals=0x10
0x0000 00000 (main_amd64.s:5) TEXT "".printnl_nosplit(SB), NOSPLIT $16
0x0000 00000 (main_amd64.s:5) SUBQ $16, SP
0x0004 00004 (main_amd64.s:5) MOVQ BP, 8(SP)
0x0009 00009 (main_amd64.s:5) LEAQ 8(SP), BP
0x000e 00014 (main_amd64.s:6) CALL runtime.printnl(SB)
0x0013 00019 (main_amd64.s:7) MOVQ 8(SP), BP
0x0018 00024 (main_amd64.s:7) ADDQ $16, SP
0x001c 00028 (main_amd64.s:7) RET
输出代码中我们删除了非指令的部分。为了便于讲述,我们将上述代码重新排版,并根据缩进表示相关的功能:
TEXT "".printnl(SB), NOSPLIT, $16
SUBQ $16, SP
MOVQ BP, 8(SP)
LEAQ 8(SP), BP
CALL runtime.printnl(SB)
MOVQ 8(SP), BP
ADDQ $16, SP
RET
第一层是TEXT指令表示函数开始,到RET指令表示函数返回。第二层是SUBQ $16, SP指令为当前函数帧分配16字节的空间,在函数返回前通过ADDQ $16, SP指令回收16字节的栈空间。我们谨慎猜测在第二层是为函数多分配了8个字节的空间。那么为何要多分配8个字节的空间呢?再继续查看第三层的指令:开始部分有两个指令MOVQ BP, 8(SP)和LEAQ 8(SP), BP,首先是将BP寄存器保持到多分配的8字节栈空间,然后将 8(SP)地址重新保持到了BP寄存器中;结束部分是 MOVQ 8(SP),BP指令则是从栈中恢复之前备份的前BP寄存器的值。最里面第四次层才是我们写的代码,调用runtime.printnl函数输出换行。
如果去掉NOSPILT标志,再重新查看生成的目标代码,会发现在函数的开头和结尾的地方又增加了新的指令。下面是经过缩进格式化的结果:
TEXT "".printnl_nosplit(SB), $16
L_BEGIN:
MOVQ (TLS), CX
CMPQ SP, 16(CX)
JLS L_MORE_STK
SUBQ $16, SP
MOVQ BP, 8(SP)
LEAQ 8(SP), BP
CALL runtime.printnl(SB)
MOVQ 8(SP), BP
ADDQ $16, SP
L_MORE_STK:
CALL runtime.morestack_noctxt(SB)
JMP L_BEGIN
RET
其中开头有三个新指令, MOVQ (TLS), CX 用于加载g结构体指针,然后第二个指令 CMPQ SP,16(CX) SP栈指针和g结构体中stackguard0成员比较,如果比较的结果小于0则跳转到结尾的L_MORE_STK部分。当获取到更多栈空间之后,通过 JMP L_BEGIN指令跳转到函数的开始位置重新进行栈空间的检测。
g结构体在 $GOROOT/src/runtime/runtime2.go 文件定义,开头的结构成员如下:
type g struct {
// Stack parameters.
stack stack
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
}
第一个成员是stack类型,表示当前栈的开始和结束地址。stack的定义如下:
type stack struct {
lo uintptr
hi uintptr
}
在g结构体中的stackguard0成员是出现爆栈前的警戒线。stackguard0的偏移量是16个字节,因此上述代码中的 CMPQ SP, 16(AX) 表示将当前的真实SP和爆栈警戒线比较,如果超出警戒线则表示需要进行栈扩容,也就是跳转到L_MORE_STK。在L_MORE_STK标号处,先调用runtime·morestack_noctxt进行栈扩容,然后又跳回到函数的开始位置,此时此刻函数的栈已经调整了。然后再进行一次栈大小的检测,如果依然不足则继续扩容,直到栈足够大为止。
之前在goroutine中也提到过到依靠栈扩容来实现抢占。