Go Asm
文章目录
本文最初是在公司内部小组技术分享
为什么想分享这个知识点
- 经常性在阅读GO源码时,会追溯到底层的汇编实现,如果不理解,就会懵逼了
- 网上大量的大神在分析某个特性或者实现时,常常都会祭出底层汇编,这也说明,要明白GO底层的很多设计,都必须要了解GO汇编
- 性能优化,代码分析所需,虽然这种情况很少
- 了解底层实现,充实自己的武器库
- 具备部分辩伪能力,学习了GO汇编,多少会对语言特性的底层实现有一些嗅觉
- 装逼,装逼是第一生产力
前置知识点
进程内存布局
进程在内存中的布局主要分为4个区域:代码区,数据区,堆和栈
GO ASM
- GO汇编并不对应某种真实的硬件架构,可以理解为是一种伪汇编语言,初衷是想做到架构无关、可移植,但现状是骨感的,源码中随处可见一个函数不同架构的汇编源码实现。
- GO ASM 基于PLAN 9,自定义了几个平台通用的伪寄存器。
X64 | 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 |
GO 汇编自定义的伪寄存器,具体定义见go官网文档asm
FP
: Frame pointer: arguments and locals.PC
: Program counter: jumps and branches.SB
: Static base pointer: global symbols.SP
: Stack pointer: top of stack.BP
: Base Pointer: base of frame Pointer
几个相关的概念
- 栈:进程、线程、goroutine 都有自己的调用栈,先进后出(FILO)
- 栈帧:可以理解是函数调用时,在栈上为函数所分配的内存区域
- 调用者:caller,比如:A 函数调用了 B 函数,那么 A 就是调用者
- 被调者:callee,比如:A 函数调用了 B 函数,那么 B 就是被调者
GO 调用约定
- 由调用者(caller)负责函数参数的准备工作
- call指令负责保存PC地址
- 被调用者(callee)负责BP的保存,SP扩栈,当完成函数操作后,需要恢复BP,SP
- 被调用函数,需要声明自己所需栈空间大小和参数大小
GO routine栈帧布局
Go 汇编使用的是caller-save
模式,被调用函数的入参参数、返回值都由调用者维护、准备。因此,当需要调用一个函数时,需要先将这些工作准备好,才调用下一个函数,另外这些都需要进行内存对齐,对齐的大小是 sizeof(uintptr)。
|
|
常见指令
助记符 | 指令种类 | 用途 | 示例 |
---|---|---|---|
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.println(SB) // 发起调用 |
JMP |
转移 | 无条件转移指令 | JMP 0x0185 //无条件转至 0x0185 地址处 |
JLS |
转移 | 条件转移指令 | JLS 0x0185 //左边小于右边,则跳到 0x0185 |
指令的后缀来对应不同长度的操作数。B(8位),W(16位),D(32位),Q(64位)。
函数声明
TEXT 指代码段,是存储在 .text 段中的,除了 TEXT 之外还有 DATA/GLOBL。中点 ·
比较特殊,是一个 unicode 的中点,该点在 mac 下的输入方法是 option+shift+9
。在程序被链接之后,所有的中点·
都会被替换为句号.
,比如你的方法是 runtime·main
,在编译之后的程序里的符号则是 runtime.main
。
NOSPLIT: 向编译器表明,不应该插入 stack-split 的用来检查栈需要扩张的前导指令
Talk is cheap, Show me code
输出汇编方法
- 方法 1 先使用
go build -gcflags "-N -l" main.go
生成对应的可执行二进制文件 再使用go tool objdump -s "main\." main
反编译获取对应的汇编 - 方法 2 使用
go tool compile -S -N -l main.go
这种方式直接输出汇编 - 方法 3 使用
go build -gcflags="-N -l -S" main.go
直接输出汇编
-l 禁止内联 -N 编译时,禁止优化 -S 输出汇编代码
实例
|
|
|
|
-l 禁止内联 -N 编译时,禁止优化 -S 输出汇编代码
截取主要输出
|
|
- 指令15-24所做的事情: 进入main函数,完成main函数栈空间初始化,主要是依据函数的栈空间大小进行向下扩栈操作,保存caller的BP数据
这里多说一句,这个main父函数
对应的源码在runtime/proc.go:114。GO 程序真正的入口是在runtime/asm_arm64.s:11 (以Mac 系统为例),这里简单摘录几种一部分代码
|
|
runtime·schedinit 的实现
|
|
- 指令29-46:main函数负责将参数准备好
- call指令调用&&addInt函数中0到45行指令
- addInt函数中的50-59指令:
手动写汇编
大致了解了GO汇编的一些语法,我们自己来动手写一个汇编程序 在项目中,随意新建一个文件,后缀以.s结尾,定义一个函数addInt
|
|
在main函数中调用该函数
|
|
执行正常编译,可以看到我们自己动手写汇编有很大不用,首先不使用SP寄存器,而是使用FP寄存器,FP寄存器指向的参数起始地址处,参考上面的布局图。
执行反编译
|
|
-s "main\."
表示只输出与main
有关的汇编,不然输出会淹没有效信息,截取输出如下
|
|
可以得知:
- (FP)伪寄存器,只有在编写 Go 汇编代码时使用。FP 伪寄存器指向 caller 传递给 callee 的第一个参数
- 使用 go tool compile / go tool objdump 得到的汇编中看不到(FP)寄存器的踪影
回过头来看那几个重要寄存器
- FP: 使用如
symbol+offset(FP)
的方式,引用 callee 函数的入参参数。例如arg0+0(FP),arg1+8(FP)
,使用 FP 必须加 symbol ,否则无法通过编译(从汇编层面来看,symbol 没有什么用,加 symbol 主要是为了提升代码可读性)。另外,需要注意的是:往往在编写 go 汇编代码时,要站在 callee 的角度来看(FP),在 callee 看来,(FP)指向的是 caller 调用 callee 时传递的第一个参数的位置。假如当前的 callee 函数是 add,在 add 的代码中引用 FP,该 FP 指向的位置不在 callee 的 stack frame 之内。而是在 caller 的 stack frame 上,指向调用 add 函数时传递的第一个参数的位置,经常在 callee 中用symbol+offset(FP)
来获取入参的参数值。 - SB: 全局静态基指针,一般用在声明函数、全局变量中。
- SP: 该寄存器也是最具有迷惑性的寄存器,因为会有伪 SP 寄存器和硬件 SP 寄存器之分。plan9 的这个伪 SP 寄存器指向当前栈帧第一个局部变量的结束位置(为什么说是结束位置,可以看下面寄存器内存布局图),使用形如 symbol+offset(SP) 的方式,引用函数的局部变量。offset 的合法取值是 [-framesize, 0),注意是个左闭右开的区间。假如局部变量都是 8 字节,那么第一个局部变量就可以用 localvar0-8(SP) 来表示。与硬件寄存器 SP 是两个不同的东西,在栈帧 size 为 0 的情况下,伪寄存器 SP 和硬件寄存器 SP 指向同一位置。手写汇编代码时,如果是 symbol+offset(SP)形式,则表示伪寄存器 SP。如果是 offset(SP)则表示硬件寄存器 SP。
务必注意
:对于编译输出(go tool compile -S / go tool objdump)的代码来讲,所有的 SP 都是硬件 SP 寄存器,无论是否带 symbol(这一点非常具有迷惑性,需要慢慢理解。往往在分析编译输出的汇编时,看到的就是硬件 SP 寄存器)。 - PC: 实际上就是在体系结构的知识中常见的 pc 寄存器,在 x86 平台下对应 ip 寄存器,amd64 上则是 rip。除了个别跳转之外,手写 plan9 汇编代码时,很少用到 PC 寄存器
分享总结
想达到的目的
- 对于GO ASM有初步的了解,如果能引起大家的兴趣,那就更好了
- 能看懂源码中一些简单的汇编,帮助大家学习源码
- 有兴趣的小伙伴,一起研究GO语言分享吧
适度
- GO ASM不宜深究,总体程度达到能看懂大概意思即可,很多东西花了太多时间搞清楚了,反而觉得没啥用
- 总体达到能通过汇编分析一些特性底层实现能力即可