GO 汇编学习笔记(五) —— 条件、循环语句
GO 汇编学习笔记(一)
GO 汇编学习笔记(五)
程序主要有顺序、分支和循环几种执行流程。本节主要讨论如何将Go语言的控制流比较直观地转译为汇编程序,或者说如何以汇编思维来编写Go语言代码。
-
顺序执行
顺序执行时常见的工作模式,所有不含分支,循环、goto语句,并且没有递归调用的Go函数,一般都是顺序执行的。
func main() { var a = 10 println(a) var b = (a+a)*a println(b) }
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个字节的栈内存空间。
在函数的开始处先将变量初始化为0值,其中
a-8*2(SP)
对应a变量、b-8*1(SP)
对应b变量(因为a变量先定义,因此a变量的地址更小) -
if/goto跳转
Go语言刚刚开源的时候并没有goto语句,后来Go语言虽然增加了goto语句,但是并不推荐在编程中使用。有一个和cgo类似的原则:如果可以不使用goto语句,那么就不要使用goto语句。Go语言中的goto语句是有严格限制的:它无法跨越代码块,并且在被跨越的代码中不能含有变量定义的语句。虽然Go语言不推荐goto语句,但是goto确实每个汇编语言码农的最爱。因为goto近似等价于汇编语言中的无条件跳转指令JMP,配合if条件goto就组成了有条件跳转指令,而有条件跳转指令正是构建整个汇编代码控制流的基石。
为了便于理解,我们用Go语言构造一个模拟三元表达式的If函数:
func If(ok bool, a, b int) int { if ok { return a } else { return b } }
比如求两个数最大值的三元表达式
(a>b)?a:b
用If函数可以这样表达:If(a>b, a, b)
。因为语言的限制,用来模拟三元表达式的If函数不支持泛型(可以将a、b和返回类型改为空接口,不过使用会繁琐一些)。 这个函数虽然看似只有简单的一行,但是包含了if分支语句。在改用汇编实现前,我们还是先用汇编的思维来重新审视If函数。在改写时同样要遵循每个表达式只能有一个运算符的限制,同时if语句的条件部分必须只有一个比较符号组成,if语句的body部分只能是一个goto语句。
用汇编思维改写后的If函数实现如下:
func If(ok int, a, b int) int { if ok == 0 { goto L } return a L: return b }
因为汇编语言中没有bool类型,我们改用int类型代替bool类型(真实的汇编是用byte表示bool类型,可以通过MOVBQZX指令加载byte类型的值,这里做了简化处理)。当ok参数非0时返回变量a,否则返回变量b。我们将ok的逻辑反转下:当ok参数为0时,表示返回b,否则返回变量a。在if语句中,当ok参数为0时goto到L标号指定的语句,也就是返回变量b。如果if条件不满足,也就是ok参数非0,执行后面的语句返回变量a。
上述函数的实现已经非常接近汇编语言,下面是改为汇编实现的代码:
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 // if ok == 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循环
Go语言的for循环有多种用法,我们这里只选择最经典的for结构来讨论。经典的for循环由初始化、结束条件、迭代步长三个部分组成,再配合循环体内部的if条件语言,这种for结构可以模拟其它各种循环类型。
基于经典的for循环结构,我们定义一个LoopAdd函数,可以用于计算任意等差数列的和:
func LoopAdd(cnt, v0, step int) int { result := v0 for i := 0; i < cnt; i++ { result += step } return result }
比如
1+2+...+100
等差数列可以这样计算LoopAdd(100, 1, 1)
,而10+8+...+0
等差数列则可以这样计算LoopAdd(5, 10, -2)
。在用汇编彻底重写之前先采用前面if/goto
类似的技术来改造for循环。 新的LoopAdd函数只有if/goto语句构成:
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 }
TEXT ·If(SB), NOSPLIT, $0-32 MOVQ cnt+8*0(FP), CX // ok MOVQ v0+8*1(FP), AX // a MOVQ i-0(SP), EX // b MOVQ result-8*1(SP), DX // b MOVQ AX, DX LOOP_BEGIN: MOVQ $0, result-8*1(SP) LOOP_IF: CMPQ EX,CX JL LOOP_BODY JGE LOOP_END LOOP_BODY: MOVQ CX ,DX // return b JMP LOOP_IF LOOP_END: MOVQ DX, ret+8*2(FP) // return b 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语言的函数是可以任意深度调用的,永远不用担心爆栈的风险。那么这些近似黑科技的特性是如何通过低级的汇编语言实现的呢?这些都是本节尝试讨论的问题
-
函数调用规范
在Go汇编语言中CALL指令用于调用函数,RET指令用于从调用函数返回。但是CALL和RET指令并没有处理函数调用时输入参数和返回值的问题。CALL指令类似
PUSH IP
和JMP somefunc
两个指令的组合,首先将当前的IP指令寄存器的值压入栈中,然后通过JMP指令将要调用函数的地址写入到IP寄存器实现跳转。而RET指令则是和CALL相反的操作,基本和POP IP
指令等价,也就是将执行CALL指令时保存在SP中的返回地址重新载入到IP寄存器,实现函数的返回。和C语言函数不同,Go语言函数的参数和返回值完全通过栈传递。下面是Go函数调用时栈的布局图:
首先是调用函数前准备的输入参数和返回值空间。然后CALL指令将首先触发返回地址入栈操作。在进入到被调用函数内之后,汇编器自动插入了BP寄存器相关的指令,因此BP寄存器和返回地址是紧挨着的。再下面就是当前函数的局部变量的空间,包含再次调用其它函数需要准备的调用参数空间。被调用的函数执行RET返回指令时,先从栈恢复BP和SP寄存器,接着取出的返回地址跳转到对应的指令执行。
- 高级汇编语言
Go汇编语言其实是一种高级的汇编语言。在这里高级一词并没有任何褒义或贬义的色彩,而是要强调Go汇编代码和最终真实执行的代码并不完全等价。Go汇编语言中一个指令在最终的目标代码中可能会被编译为其它等价的机器指令。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指令表示函数开始,到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
指令跳转到函数的开始位置重新进行栈空间的检测。