本文用于持续记录 Go 语言汇编基础学习笔记。
Go 汇编基础
Plan9 指令集
栈调整
Go 编译器不会生成任何 PUSH/POP 族的指令: 栈的增长和收缩是通过在栈指针寄存器 SP
上分别执行减法和加法指令来实现的。
SUBQ $0x18, SP # 对 SP 做减法,为函数分配函数栈帧
... # 省略无用代码
ADDQ $0x18, SP # 对 SP 做加法,清除函数栈帧
数据搬运
常数在 plan9 汇编用 $num 表示,可以为负数,默认情况下为十进制。可以用 $0x123 的形式来表示十六进制数。搬运的长度是由 MOV 的后缀决定的。
MOVB $1, DI # 1 byte
MOVW $0x10, BX # 2 bytes
MOVL $1, DX # 4 bytes
MOVQ $-10, AX # 8 bytes
跳转指令
# 无条件跳转
JMP addr # 跳转到地址,地址可为代码中的地址,不过实际上手写不会出现这种东西
JMP label # 跳转到标签,可以跳转到同一函数内的标签位置
JMP 2(PC) # 以当前指令为基础,向前/后跳转 x 行
JMP -2(PC) # 同上
// 有条件跳转
JNZ target # 如果 zero flag 被 set 过,则跳转
常见指令列表
助记符 | 指令种类 | 用途 | 示例 |
---|---|---|---|
MOVQ | 传送 | 数据传送 | MOVQ 48, AX // 把 48 传送到 AX |
LEAQ | 传送 | 地址传送 | LEAQ AX, BX // 把 AX 有效地址传送到 BX |
PUSHQ | 传送 | 栈压入 | PUSHQ AX // 将 AX 内容送入栈顶位置 |
POPQ | 传送 | 栈弹出 | POPQ AX // 弹出栈顶数据后修改栈顶指针 |
ADDQ | 运算 | 相加并赋值 | ADDQ BX, AX // 等价于 AX+=BX |
SUBQ | 运算 | 相减并赋值 | SUBQ BX, AX // 等价于 AX-=BX |
CMPQ | 运算 | 比较大小 | CMPQ SI CX // 比较 SI 和 CX 的大小 |
CALL | 转移 | 调用函数 | CALL runtime.printnl(SB) // 发起调用 |
JMP | 转移 | 无条件转移指令 | JMP 0x0185 //无条件转至 0x0185 地址处 |
JLS | 转移 | 条件转移指令 | JLS 0x0185 //左边小于右边,则跳到 0x0185 |
寄存器
Plan9 中使用寄存器不需要带 r 或 e 的前缀,例如 rax,只要写 AX 即可:
X86_64 | 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 |
伪寄存器
- FP:使用形式
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 上。 - SB:全局静态基指针,一般涌来声明函数或全局变量。
- SP:Plan9 的这个 SP 寄存器指向当前栈帧的局部变量的开始位置,使用形式
symbol+offset(SP)
,引用函数的局部变量。假如局部变量都是 8 字节,那么第一个局部变量就可以用localvar0-8(SP)
来表示。手写汇编代码时,如果是symbol+offset(SP)
形式,则表示伪寄存器 SP。如果是offset(SP)
则表示硬件寄存器 SP。务必注意。对于编译输出(go tool compile -S / go tool objdump)的代码来讲,目前所有的 SP 都是硬件寄存器 SP,无论是否带 symbol。 - TLS:
TLS
是一个由 runtime 维护的虚拟寄存器,保存了指向当前g
的指针,这个g
的数据结构会跟踪 goroutine 运行时的所有状态值。
变量声明
TODO 没懂
函数声明
# 该声明定义在同一个 package 下的任意 .go 文件中
# 只有函数头,没有实现
// func add(a, b int) int
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 |
+---------------+
此外需要注意的是,caller BP 是在编译期由编译器插入的,用户手写代码时,计算 frame size 时是不包括这个 caller BP 部分的。是否插入 caller BP 的主要判断依据是:
-
函数的栈帧大小大于 0
-
下述函数返回 true
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 来分配空间的。不算在当前的栈帧内。
argsize 和 framesize 计算规则
argsize
TEXT pkgname·add(SB),NOSPLIT,$16-32
前面已经说过 $16-32
表示 $framesize-argsize。Go 在函数调用时,参数和返回值都需要由 caller 在其栈帧上备好空间。argsize 的计算方法是,参数大小求和+返回值大小求和,例如入参是 3 个 int64 类型,返回值是 1 个 int64 类型,那么这里的 argsize = sizeof(int64) * 4。
不过真实世界永远没有我们假设的这么美好,函数参数往往混合了多种类型,还需要考虑内存对齐问题。[[Go 内存对齐]]
如果不确定自己的函数签名需要多大的 argsize,可以通过简单实现一个相同签名的空函数,然后 go tool objdump 来逆向查找应该分配多少空间。
framesize
函数的 framesize 就稍微复杂一些了,手写代码的 framesize 不需要考虑由编译器插入的 caller BP,要考虑:
- 局部变量,及其每个变量的 size。
- 在函数中是否有对其它函数调用时,如果有的话,调用时需要将 callee 的参数、返回值考虑在内。虽然 return address(rip)的值也是存储在 caller 的 stack frame 上的,但是这个过程是由 CALL 指令和 RET 指令完成 PC 寄存器的保存和恢复的,在手写汇编时,同样也是不需要考虑这个 PC 寄存器在栈上所需占用的 8 个字节的。
- 原则上来说,调用函数时只要不把局部变量覆盖掉就可以了。稍微多分配几个字节的 framesize 也不会死。
- 在确保逻辑没有问题的前提下,你愿意覆盖局部变量也没有问题。只要保证进入和退出汇编函数时的 caller 和 callee 能正确拿到返回值就可以。
示例
FP 的使用
math.go:
package main
import "fmt"
func add(a, b int) int // 汇编函数声明
func main() {
fmt.Println(add(10, 11))
}
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
# 最后一行的空行是必须的,否则可能报 unexpected EOF
伪寄存器 SP 、伪寄存器 FP 和硬件寄存器 SP
来写一段简单的代码证明伪 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 来引用数据,否则结果可能会出乎你的预料。