对于写业务的同学来说,学习汇编可能没必要,仅仅关注业务逻辑即可。
但是当你要深入去优化代码结构、系统架构,就不得不去深入了解golang这门语言,去了解golang内核实现:比如goroutine调度、io调度、map实现、string实现。当然,golang内核有go实现,也有汇编实现。
为了做更深入的优化,我们需要了解plan9汇编,有时候不得不去写汇编,甚至根据特定汇编指令集来做优化。(主要以x86/64架构)
比如我们去看strings.Index实现,其中有一段代码是汇编所写:
我们不禁会想,golang为何要用汇编来实现这种简单的Index函数?该如何理解?
plan9汇编简介
主要包括:文件命名、指令集、寄存器、函数声明、全局变量声明、跳转、栈分布、调用栈、编译/反编译工具
文件命名
由于不同的平台架构支持的汇编指令集不一样,需要针对不同的架构写不同的汇编实现。
通常文件命名格式:功能名_arch.s
比如:indexbyte_386.s, indexbyte_arm64.s, indexbyte_s390x.s
使用go build编译的时候,会自动根据当前arch平台使用对应的arch文件(或者使用+build tag)
指令集
这里自行去查找不同的架构指令集即可(cat /proc/cpuinfo | grep flags | head -1)
寄存器
有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作为基准地址,进行偏移解引用 等操作。
其中
1: SP有伪SP和硬件SP的区分:
伪SP: 本地变量最高起始地址
硬件SP: 函数栈真实栈顶地址
他们的关系是:
- 如果没有本地变量:伪SP=硬件SP+8
- 如果有本地变量:伪SP=硬件SP+16+本地变量空间大小
2: FP伪寄存器
FP伪寄存器: 用来标识函数参数、返回值
和伪SP寄存器的关系是:
- 如果有本地变量或者栈调用存严格split在关系(无NOSPLIT),伪FP=伪SP+16
- 否则,伪FP=伪SP+8
- FP是访问入参、出参的基址,一般用正向偏移来寻址,SP是访问本地变量的起始基址,一般用负向偏移来寻址
- 修改硬件SP,会引起伪SP、FP同步变化
3: 参数/本地变量访问
通过symbol+/-offset(FP/SP)的方式进行使用
例如arg0+0(FP)表示第函数第一个参数其实的位置,arg1+8(FP)表示函数参数偏移8byte的另一个参数。arg0/arg1用于助记,但是必须存在,否则无法通过编译(golang会识别并做处理)。
其中对于SP来说,还有一种访问方式:
+/-offset(FP)
这里SP前面没有symbol修饰,代表这硬件SP
4: PC寄存器
实际上就是在体系结构的知识中常见的pc寄存器,在x86平台下对应ip寄存器,amd64上则是rip。除了个别跳转 之外,手写代码与PC寄存器打交道的情况较少。
5: SB寄存器
SP是栈指针寄存器,指向当前函数栈的栈顶,通过symbol+offset(SP)的方式使用。offset 的合法取值是 [-framesize, 0),注意是个左闭右开的区间。假如局部变量都是8字节,那么第一个局部变量就可以用localvar0-8(SP) 来表示。
6: BP寄存器
还有BP寄存器,是表示已给调用栈的起始栈底(栈的方向从大到小,SP表示栈顶);一般用的不多,如果需要做手动维护调用栈关系,需要用到BP寄存器,手动split调用栈
7: 通用寄存器
在plan9汇编里还可以直接使用的amd64的通用寄存器,应用代码层面会用到的通用寄存器主要是: rax, rbx, rcx, rdx, rdi, rsi, r8~r15这14个寄存器。plan9中使用寄存器不需要带r或e的前缀,例如rax,只要写AX即可:
代码示例:
各个变量通过寄存器解引用如下:(伪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)
函数声明
此处声明了一个函数sqrt,函数的声明以 TEXT 标识开头,以 {package}·{function} 为函数名。 如何函数属于本package时,通常可以不写{package},只留·{function}即可。· 在mac上可以用shift+option+9 打出。$0表示该函数栈大小为0byte,计算栈大小时,需要考虑局部变量和本函数内调用其他函数时,需要传参的空间,不含函数返回地址和CALLER BP。 $16表示该函数入参和返回值一共有16byte。当有NOSPLIT标识时,可以不写输入参数、返回值占用的大小(这时候会强行插入CALLER BP)。
为了在golang代码里能引用这个函数我们需要做如下申明:
全局变量声明
全局变量的数据部分采用DATA symbol+offset(SB)/width, value格式进行声明。<>表示该变量只在该文件内全局可见。
跳转
跳转分为section跳转或者函数调用跳转
- section跳转
- 类似JNE,JBE,JE,JGE等;其中sp/bp不会变化;栈空间不变,不存在参数传递需求
- 函数调用跳转
- JMQ sp/bp不会变化;栈空间不变。通常需要调用者和被调用者协商好使用那些寄存传递参数,调用者将参数写入这些寄存器
- CALL 栈空间会发生响应的变化,传递参数时,我们需要输入参数、返回值按之前将的栈布局安排在调用者的栈顶(低地址段),然后再调用CALL命令来调用其函数,调用CALL命令后,SP寄存器会下移一个WORD(x86_64上是8byte),然后进入新函数的栈空间运行
栈分布
如果没有本地变量,栈分布如下
如果有本地变量,栈分布如下 ```javascript func zzz(a, b, c int) [3]int{ var d [3]int d[0], d[1], d[2] = a, b, c return d } ```
调用栈
这里以一个函数调用过程A->B->C为例了来解释调用栈过程
编译/反编译工具
实践出真知,很多时候我们无法确定一块代码是如何执行的,需要通过生成汇编、反汇编来研究golang。这里给一些工具来帮助我们了解golang
汇编实战
纸上得来终觉浅,绝知此事要躬行。只有亲自写汇编代码才能帮助我们更好的了解汇编。
我们分别从2部分实践:
- 了解SP、FP的关系,输入、输出
- 做一个简单的函数,实现手动管理栈
练习一:分别获取当前伪SP、硬件SP、伪FP地址
思路:使用LEA把各个寄存器的地址传出来即可
输出如下: 824634359232, 824634359232, 824634359240
可以看到没有本地变量的情况下:伪SP=硬件SP=FP-8
思考:如果把中间的NOSPLIT去掉结果如何?如果把$0-24改成$8-24结果如下?
练习二:使用汇编实现斐波那契
思路:使用递归调用;扩容栈16个字节用来存储递归调用所需的输入和输出
整个调用栈管理如下,这里用16字节是为了节省栈空间(2次调用输入输出参数共用同一块地址)
总结
golang汇编难点在于掌握几个寄存器的关系以及栈分布以及调用栈的过程(无关今紧要的没讲)。想要深入理解,需要多实践。最终目的不一定要熟练写汇编代码,更多的是懂得golang底层机制以及学习人家优化思想。