【关注公众号】「syd3600520」 回复002 获取Go相关学习资料
从计算机诞生到现在,编程语言的发展大致分为了三个阶段
- 从打孔程序的机器语言
- 一系列指令、寄存器代码的汇编语言
- 再到我们日常使用的高级语言
机器语言一堆的0/1代码确实反人类,汇编语言指令繁杂 不同机器设备还有较大差异。比如x86架构的汇编指令一般有两种格式:
-
Intel汇编
- DOS、Windows包括我们之前了解的8086处理器
- Windows派系:VC编译器
-
AT&T汇编
- Linux、Unix、Mac OS
- Unix派系:GCC编译器
而Go使用的汇编叫做plan9汇编
这些东西的确我们现在使用的高级语言的编译器都帮助我们屏蔽掉了,但是今天我们要来学学Go的plan9汇编
,要是硬扛为什么?没错 我是为了炫技!!!
对于一只老鸟来说,我觉得搞搞Plan9汇编
还是有不少益处的:
- 可以搞懂一段代码底层到底是如何运行的 性能极致追求的优化
- 基础数据结构如何运行 比如hashmap、channel
- 反编译对二进制包进行分析
- 绕过go系统限制 访问私有方法
- ......
常用指令
汇编
其实跟Go
Java
这些语言类似无非是变量、方法等。的确汇编存在比较多的指令、寄存器代码。我对待汇编语言就像是对待学习的日语一样,虽然不少晦涩难记的单词 但是先掌握好五十音行 再搞懂语法,单词的问题可以回头查阅,常用的也就那么多
常数定义
plan9汇编中使用0x123的形式表示十六进制
操作方向
plan9汇编操作数方向 与intel汇编方向相反
//plan9 汇编
MOVQ $123, AX
//intel汇编
mov rax, 123
栈扩大、缩小
plan9中栈操作并没有push
pop
,而是采用sub
和add SP
SUBQ $0x18, SP //对SP做减法 为函数分配函数栈帧
ADDQ $0x18, SP //对SP做加法 清楚函数栈帧
数据copy
MOVB $1, DI // 1 byte
MOVW $0x10, BX // 2bytes
MOVD $1, DX // 4 bytes
MOVQ $-10, AX // 8 bytes
计算指令
ADDQ AX, BX // BX += AX
SUBQ AX, BX // BX -= AX
IMULQ AX, BX // BX *= AX
跳转
//无条件跳转
JMP addr // 跳转到地址,地址可为代码中的地址 不过实际上手写不会出现这种东西
JMP label // 跳转到标签 可以跳转到同一函数内的标签位置
JMP 2(PC) // 以当前置顶为基础,向前/后跳转x行
JMP -2(PC) //同上
//有条件跳转
JNZ target // 如果zero flag被set过,则跳转
变量声明
汇编中的变量一般是存储在.rodata
或者.data
段中的只读值。对应到应用层就是已经初始化的全局的const、var变量/常量
DATA symbol+offset(SB)/width,value
上面的语句初始化symbol+offset(SB)的数据中width bytes,赋值为value,相对于栈操作,SB的操作都是增地址,栈时减地址
GLOBL runtime·tlsoffset(SB), NOPTR, $4
// 声明一个全局变量tlsoffset,4byte,没有DATA部分,因其值为0。
// NOPTR 表示这个变量数据中不存在指针,GC不需要扫描。
(使用DATA结合GLOBL来定义一个变量,GLOBL必须跟在DATA指令之后)当时我尝试了下发现GLOBL不放在DATA之后 也没啥问题,如果知道的小伙伴可以分享一下。
举个栗子:
pkg.go
package pkg
var Id int
var Name string
pkg_amd64.s
GLOBL ·Id(SB),$8
DATA ·Id+0(SB)/1,$0x37
DATA ·Id+1(SB)/1,$0x25
DATA ·Id+2(SB)/1,$0x00
DATA ·Id+3(SB)/1,$0x00
DATA ·Id+4(SB)/1,$0x00
DATA ·Id+5(SB)/1,$0x00
DATA ·Id+6(SB)/1,$0x00
DATA ·Id+7(SB)/1,$0x00
GLOBL ·Name(SB),$24
DATA ·Name+0(SB)/8,$·Name+16(SB)
DATA ·Name+8(SB)/8,$6
DATA ·Name+16(SB)/8,$"gopher"
函数声明
举个栗子:
fun.go
package fun
//go:noinline
func Swap(a, b int) (int, int)
fun_amd64.s
#include "textflag.h"
// func Swap(a,b int) (int,int)
告诉汇编器该数据放到TEXT区
| 告诉汇编器这是基于静态地址的数据(static base)
| |
TEXT fun·Swap(SB),NOSPLIT,$0-32
MOVQ a+0(FP), AX // FP(Frame pointer)栈帧指针 这里指向栈帧最低位
MOVQ b+8(FP), BX
MOVQ BX ,ret0+16(FP)
MOVQ AX ,ret1+24(FP)
RET
上述代码存储在TEXT段中。pkgname可以省略,比如你的方法是fun·Swap
(这里的·
是个unicode的中点 mac下的输入方式为 option+shift+9
),在编译后的程序里的符号则是fun.Swap,总结起来如下:
stack frame size
栈帧大小(局部变量+可能需要的额外调用函数的参数空间的总大小,但不不包含调用其他函数时的ret address的大小)
arguments size
参数及返回值大小
若不指定NOSPLIT
,arguments size
必须指定。
测试代码
func main() {
println(pkg.Id)
println(pkg.Name)
a, b := 1, 2
a, b = fun.Swap(a, b)
fmt.Println(a, b)
}
寄存器
Go汇编引入了4个伪寄存器,这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
所有用户空间的数据都可以通过FP/SP(局部数据、输入参数、返回值)和SB(全局数据)访问。通常情况下,不会对SB/FP寄存器进行运算操作,通常情况会以SB/FP/SP作为基准地址,进行偏移、解引用等操作
其中
- SP是栈指针,用来指向局部变量和函数调用的参数,通过symbol+offset(SP)的方式使用。SP指向local stack frame的栈顶,所以使用时需要使用负偏移量,取之范围为[-framesize,0)。foo-8(SP)表示foo的栈第8byte。SP有伪SP和硬件SP的区分,如果硬件支持SP寄存器,那么不加name的时候就是访问硬件寄存器,因此
x-8(SP)
和-8(SP)
访问的会是不同的内存空间。对SP和PC的访问都应该带上name,若要访问对应的硬件寄存器可以使用RSP。
- 伪SP:本地变量最高起始地址
- 硬件SP:函数栈真实栈顶地址
他们的关系为:
- 若没有本地变量: 伪SP=硬件SP+8
- 若有本地变量:伪SP=硬件SP+16+本地变量空间大小
- FP伪寄存器
FP伪寄存器:用来标识函数参数、返回值,编译器维护了基于FP偏移的栈上参数指针,0(FP)表示function的第一个参数,8(FP)表示第二个参数(64位系统上)后台加上偏移量就可以访问更多的参数。要访问具体function的参数,编译器强制要求必须使用name来访问FP,比如 foo+0(FP)获取foo的第一个参数,foo+8(FP)获取第二个参数。
与伪SP寄存器的关系是:
- 若本地变量或者栈调用存严格split关系(无NOSPLIT),伪FP=伪SP+16
- 否则 伪FP=伪SP+8
- FP是访问入参、出参的基址,一般用正向偏移来寻址,SP是访问本地变量的起始基址,一般用负向偏移来寻址
- 修改硬件SP,会引起伪SP、FP同步变化
SUBQ $16, SP // 这里golang解引用时,伪SP/FP都会-16
- SB伪寄存器可以理解为原始内存,foo(SB)的意思是用foo来代表内存中的一个地址。foo(SB)可以用来定义全局的function和数据,foo<>(SB)表示foo只在当前文件可见,跟C中的static效果类似。此外可以在引用上加偏移量,如foo+4(SB)表示foo+4bytes的地址
- 参数/本地变量访问
通过symbol+/-offset(FP/SP)的方式进行使用,例如arg0+0(FP)表示函数第一个参数的位置,arg1+8(FP)表示函数参数偏移8byte的另一个参数。arg0/arg1用于助记,但是必须存在,否则无法通过编译(golang会识别并做处理)。
其中对于SP来说,还有一种访问方式: +/-offset(FP) 这里SP前面没有symbol修饰,代表这是硬件SP???
- PC寄存器
实际上就是在体系结构的知识中常见的PC寄存器,在x86平台下对应ip寄存器,amd64上则是rip。除了个别跳转之外,手写代码与PC寄存器打交道的情况较少。
- BP寄存器
还有BP寄存器,表示已给调用栈的起始栈底(栈的方向从大到小,SP表示栈顶);一般用的不多,若需要做手动维护调用栈关系,需要用到BP寄存器,手动split调用栈。
- 通用寄存器
在plan9汇编里还可以直接使用amd64的通用寄存器,应用代码层面会用到的通用寄存器主要是:
rax,rbx,rcx,rdx,rdi,rsi,r8~r15这14个寄存器。plan9中使用寄存器不需要带r或e的前缀,例如rax,只要写AX即可:
MOVQ $101, AX
示例:
func Add(a ,b int) (c int){
sum := 0
return a + b + sum
}
各变量通用寄存器解引用如下:(伪FP=伪SP+16=硬件SP+24)
- a: a+0(SP)或者a+16(SP)
- b: b+8(SP)或者a+24(SP)
- c: c+16(SP)或者a+32(SP)
- sum:sum-8(SP)或者a-24(FP)
- TLS伪寄存器
该寄存器存储当前goroutine g结构地址
Go程序如何转换为plan9?
//方法一
go build -gcflags="-S" hello.go
//方法二
go tool compile -N -l -S hello.go //禁止优化
//方法三
go build -gcflags="-N -l -m" -o xx xx.go
go tool objdump <binary>
go tool objdump -s <method name> <binary> //反汇编指定函数
//方法一、二生成的过程中的汇编
//方法三 生成的事最终机器码的汇编
栈结构
函数调用栈关系
X86平台上BP
寄存器,通常用来指示函数栈的起始位置,仅仅起一个指示作用,现代编译器生成的代码通常不会用到BP寄存器,但是可能某些debug工具会用到该寄存器来寻找函数参数、局部变量等。因此我们写汇编代码时,也最好将栈起始位置存储在BP寄存器中。因此amd64平台上,会在函数返回值之后插入8byte来放置CALLER BP
寄存器。
此外需要注意的是,CALLER BP
是在编译期由编译器插入的,用户手写汇编代码时,计算framesize
时是不包括这个CALLER BP
部分的,但是要计算函数返回值的8byte。是否插入CALLER BP
的主要判断依据是:
- 函数的栈帧大小大于0
- 下述函数返回true
func Framepointer_enabled(goos, goarch string) bool {
return framepointer_enabled != 0 && goarch == "amd64" && goos != "nacl"
}
此外需要注意,go编译器会将函数栈空间自动加8,用于存储BP寄存器,跳过这8字节后才是函数栈上局部变量的内存。逻辑上的FP/SP位置就是我们在写汇编代码时,计算便宜量时,FP/SP的基准位置,因此局部变量的内存在逻辑SP的低地址侧,因此我们访问时,需要向负方向偏移。
实际上,在该函数被调用后,编译器会添加SUBQ/LEAQ
代码修改物理SP指向的位置。我们在反汇编的代码中能看到这部分操作,因此我们需要注意物理SP与伪SP指向位置的差别。
举个栗子:
func zzz(a, b, c int) [3]int{
var d [3]int
d[0], d[1], d[2] = a, b, c
return d
}
总结
助记符 | 名字 | 用途 |
---|---|---|
AX | 累加寄存器(AccumulatorRegister) | 用于存放数据,包括算术、操作数、结果和临时存放地址 |
BX | 基址寄存器(BaseRegister) | 用于存放访问存储器时的地址 |
CX | 计数寄存器(CountRegister) | 用于保存计算值,用作计数器 |
DX | 数据寄存器(DataRegister) | 用于数据传递,在寄存器间接寻址中的I/O指令中存放I/O端口的地址 |
SP | 堆栈顶指针(StackPointer) | 如果是symbol+offset(SP) 的形式表示go汇编的伪寄存器;如果是offset(SP) 的形式表示硬件寄存器 |
BP | 堆栈基指针(BasePointer) | 保存在进入函数前的栈顶基址 |
SB | 静态基指针(StaticBasePointer) | go汇编的伪寄存器。foo(SB) 用于表示变量在内存中的地址,foo+4(SB) 表示foo起始地址往后偏移四字节。一般用来声明函数或全局变量 |
FP | 栈帧指针(FramePointer) | go汇编的伪寄存器。引用函数的输入参数,形式是symbol+offset(FP) ,例如arg0+0(FP)
|
SI | 源变址寄存器(SourceIndex) | 用于存放源操作数的偏移地址 |
DI | 目的寄存器(DestinationIndex) | 用于存放目的操作数的偏移地址 |
操作指令
用于指导汇编如何进行。以下指令后缀<mark>Q</mark>说明是64位上的汇编指令。
助记符 | 指令种类 | 用途 | 示例 |
---|---|---|---|
MOVQ | 传送 | 数据传送 |
MOVQ 48, AX 表示把48传送AX中 |
LEAQ | 传送 | 地址传送 |
LEAQ AX, BX 表示把AX有效地址传送到BX中 |
PUSHQ AX 表示先修改栈顶指针,将AX内容送入新的栈顶位置SUBQ 代替 |
|||
POPQ AX 表示先弹出栈顶的数据,然后修改栈顶指针ADDQ 代替 |
|||
ADDQ | 运算 | 相加并赋值 |
ADDQ BX, AX 表示BX和AX的值相加并赋值给AX |
SUBQ | 运算 | 相减并赋值 | 略,同上 |
IMULQ | 运算 | 无符号乘法 | 略,同上 |
IDIVQ | 运算 | 无符号除法 |
IDIVQ CX 除数是CX,被除数是AX,结果存储到AX中 |
CMPQ | 运算 | 对两数相减,比较大小 |
CMPQ SI CX 表示比较SI和CX的大小。与SUBQ类似,只是不返回相减的结果 |
CALL | 转移 | 调用函数 |
CALL runtime.printnl(SB) 表示通过<mark>println</mark>函数的内存地址发起调用 |
JMP | 转移 | 无条件转移指令 |
JMP 389 无条件转至0x0185 地址处(十进制389转换成十六进制0x0185) |
JLS | 转移 | 条件转移指令 |
JLS 389 上一行的比较结果,左边小于右边则执行跳到0x0185 地址处(十进制389转换成十六进制0x0185) |
可以看到,表中的PUSHQ
和POPQ
被去掉了,这是因为在go汇编中,对栈的操作并不是出栈入栈,而是通过对SP进行运算来实现的。
标志位
助记符 | 名字 | 用途 |
---|---|---|
OF | 溢出 | 0为无溢出 1为溢出 |
CF | 进位 | 0为最高位无进位或错位 1为有 |
PF | 奇偶 | 0表示数据最低8位中1的个数为奇数,1则表示1的个数为偶数 |
AF | 辅助进位 | |
ZF | 零 | 0表示结果不为0 1表示结果为0 |
SF | 符号 | 0表示最高位为0 1表示最高位为1 |
如有错误恳请指正。
参考文档:
有疑问加站长微信联系(非本文作者)