动手点关注

干货不迷路
前言
我们知道 Go 语言的三位领导者中有两位来自 Plan 9 项目,这直接导致了 Go 语言的汇编采用了比较有个性的 Plan 9 风格。不过,我们不能因咽废食而放弃无所不能的汇编。
1、 Go 汇编基础知识
1.1、通用寄存器
不同体系结构的 CPU,其内部寄存器的数量、种类以及名称可能大不相同,这里我们只介绍 AMD64 的寄存器。AMD64 有 20 多个可以直接在汇编代码中使用的寄存器,其中有几个寄存器在操作系统代码中才会见到,而应用层代码一般只会用到如下三类寄存器。

上述这些寄存器除了段寄存器是 16 位的,其它都是 64 位的,也就是 8 个字节,其中的 16 个通用寄存器还可以作为 32/16/8 位寄存器使用,只是使用时需要换一个名字,比如可以用 EAX 这个名字来表示一个 32 位的寄存器,它使用的是 RAX 寄存器的低 32 位。
AMD64 的通用通用寄存器的名字在 plan9 中的对应关系:
| AMD64 | 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 语言中寄存器一般用途:

1.2、伪寄存器
伪寄存器是 plan9 伪汇编中的一个助记符, 也是 Plan9 比较有个性的语法之一。常见伪寄存器如下表所示:

SB:指向全局符号表。相对于寄存器,SB 更像是一个声明标识,用于标识全局变量、函数等。通过 symbol(SB) 方式使用,symbol<>(SB)表示 symbol 只在当前文件可见,跟 C 中的 static 效果类似。此外可以在引用上加偏移量,如 symbol+4(SB) 表示 symbol+4bytes 的地址。
PC:程序计数器(Program Counter),指向下一条要执行的指令的地址,在 AMD64 对应 rip 寄存器。个人觉得,把他归为伪寄存器有点令人费解,可能是因为每个平台对应的物理寄存器名字不一样。
SP:SP 寄存器比较特殊,既可以当做物理寄存器也可以当做伪寄存器使用,不过这两种用法的使用语法不同。其中,伪寄存器使用语法是 symbol+offset(SP),此场景下 SP 指向局部变量的起始位置(高地址处);x-8(SP) 表示函数的第一个本地变量;物理 SP(硬件SP) 的使用语法则是 +offset(SP),此场景下 SP 指向真实栈顶地址(栈帧最低地址处)。
FP:用于标识函数参数、返回值。被调用者(callee)的 FP 实际上是调用者(caller)的栈顶,即 callee.SP(物理SP) == caller.FP;x+0(FP) 表示第一个请求参数(参数返回值从右到左入栈)。
实际上,生成真正可执行代码时,伪 SP、FP 会由物理 SP 寄存器加上偏移量替换。所以执行过程中修改物理 SP,会引起伪 SP、FP 同步变化,比如执行 SUBQ $16, SP 指令后,伪 SP 和伪 FP 都会 -16。而且,反汇编二进制而生成的汇编代码中,只有物理 SP 寄存器。即 go tool objdump/go tool compile -S 输出的汇编代码中,没有伪 SP 和 伪 FP 寄存器,只有物理 SP 寄存器。
另外还有 1 个比较特殊的伪寄存器:TLS:存储当前 goroutine 的 g 结构体的指针。实际上,X86 和 AMD64 下的 TLS 是通过段寄存器 FS 或 GS 实现的线程本地存储基地址,而当前 g 的指针是线程本地存储的第一个变量。
比如 github.com/petermattis/goid.Get 函数的汇编实现如下:
// func Get() int64
TEXT ·Get(SB),NOSPLIT,$0-8
MOVQ (TLS), R14
MOVQ g_goid(R14), R13
MOVQ R13, ret+0(FP)
RET
编译成二进制之后,再通过 go tool objdump 反编译成汇编(Go 1.18),得到如下代码:
TEXT github.com/petermattis/goid.Get.abi0(SB) /Users/bytedance/go/pkg/mod/github.com/petermattis/goid@v0.0.0-20221215004737-a150e88a970d/goid_go1.5_amd64.s
goid_go1.5_amd64.s:28 0x108adc0 654c8b342530000000 MOVQ GS:0x30, R14
goid_go1.5_amd64.s:29 0x108adc9 4d8bae98000000 MOVQ 0x98(R14), R13
goid_go1.5_amd64.s:30 0x108add0 4c896c2408 MOVQ R13, 0x8(SP)
goid_go1.5_amd64.s:31 0x108add5 c3 RET
可以知道 MOVQ (TLS), R14 指令最终编译成了 MOVQ GS:0x30, R14 ,使用了 GS 段寄存器实现相关功能。
操作系统对内存的一般划分如下图所示:
高地址 +------------------+
| |
| 内核空间 |
| |
--------------------
| |
| 栈 |
| |
--------------------
| |
| ....... |
| |
--------------------
| |
| 堆 |
| |
--------------------
| 全局数据 |
|------------------|
| |
| 静态代码 |
| |
|------------------|
| 系统保留 |
低地址 |------------------|
这里提个疑问,我们知道协程分为有栈协程和无栈协程,go 语言是有栈协程。那你知道普通 gorutine 的调用栈是在哪个内存区吗?
1.3、函数调用栈帧
我们先熟悉几个名词。
caller:函数调用者。callee:函数被调用者。比如函数 main 中调用 sum 函数,那么 main 就是 caller,而 sum 函数就是 callee。栈帧:stack frame,即执行中的函数所持有的、独立连续的栈区段。一般用来保存函数参数、返回值、局部变量、返回 PC 值等信息。golang 的 ABI 规定,由 caller 管理函数参数和返回值。
下图是 golang 的调用栈,源于曹春晖老师的 github 文章《汇编 is so easy》 ,做了简单修改:
caller
+------------------+
| |
+----------------------> +------------------+
| | |
| | caller parent BP |
| BP(pseudo SP) +------------------+
| | |
| | Local Var0 |
| +------------------+
| | |
| | ....... |
| +------------------+
| | |
| | Local VarN |
+------------------+
caller stack frame | |
| callee arg2 |
| +------------------+
| | |
| | callee arg1 |
| +------------------+
| | |
| | callee arg0 |
| SP(Real Register) -> +------------------+--------------------------+ FP(virtual register)
| | | |
| | return addr | parent return address |
+----------------------> +------------------+--------------------------+ <-----------------------+
| caller BP | |

探讨Go语言受Plan9影响的汇编风格,解析AMD64寄存器与伪寄存器的使用,特别是TLS与goroutine的关系,以及如何利用RCU算法优化行号获取的性能。文章还涉及内存管理操作如mprotect的汇编实现,和对栈帧、函数调用机制的深入分析。
最低0.47元/天 解锁文章
2万+

被折叠的 条评论
为什么被折叠?



