本文由李晨毅
投稿,原文首发于:https://blog.csdn.net/weixin_40486544/article/details/108392947
前言:
问:什么是 plan9?
答:plan9 是一个很强的操作系统,但我们只需要学习它的汇编语法。
问:为什么说 golang 开发者需要学习 plan9 汇编?
答:因为 golang 的开发团队和 bell 实验室(开发了 Unix 的那个实验室)开发 plan9 操作系统的钢铁糙汉子开发团队是同一批人,他们非要用,咱也没办法。
问:反编译之后玩 Intel 和 AT&T 不香吗?
答:确实可以跳过 plan9 汇编(比如直接拿机器码反编译出 intel 汇编来看),但是会让阅读变得非常困难。并且在 golang 的基础方法中,使用了大量 plan9 汇编,其中包含了一些如 4 个伪寄存器等 plan9 特有的语法,能让人更容易读懂代码,少绕弯路。(再说汇编都大同小异,秒学完好伐!)
问:学 plan9 汇编有什么好处?
答:学习 plan9 能让你在 golang 开发者中脱颖而出,随时随地掏出大汇编对 bug 进行降维打击,成为同事们心中的偶像,「获得 plmm 以及 sqgg 的芳心」,从此走上人生巅峰。
1. plan9 简介
1.Plan-9 是一款神奇的新版 Unix,几乎是由 70 年代当初开发 Unix 系统的同一个团队开发的。
2.目的就是要最终解决 Unix 最初的诺言:一切皆为文件(先进的 9P 虚拟文件系统协议最终让所有东西都成为了文件。目录变成了“命名空间”,资源被映射成了文件。)
❝(你可以通过对/proc 目录(现在应该成其为一个命名空间)里的一个文件使用“cat”命令来查看进程的情况。同样,打开一个网络连接的方式变成了打开/net/tcp 目录里的一个文件。”iotcl”系统调用在这个系统里完全被根除了,因为基于操作系统上的现代文件形式中的这种怪胎已经不再需要了。)
❞
3.Plan-9 实际上没有解决任何问题,并且开发者们不屑于商业化,暂时不打算与 Unix 兼容。这也是为什么 plan9 操作系统按理说比 Unix 强但是却没有推广起来的原因。
2. plan9 语法的一些特点
没有 push 和 pop,栈的调整是通过对硬件 SP 寄存器进行运算来实现的
常数在 plan9 汇编用 $num 表示,可以为负数,默认情况下为十进制。
操作数方向与 intel 相反,与 AT&T 类似
SUBQ $24, SP // 对 SP 做减法,为函数分配函数栈24字节大小的帧 (因为栈是从高地址向低地址增长的)
...
中间的一堆代码
...
ADDQ $24, SP // 对 SP 做加法,清除函数栈帧
- 数据搬运的长度由 MOV 的后缀决定
// plan9
MOVB $1, DI // 1 byte
MOVW $0x10, BX // 2 bytes
MOVD $1, DX // 4 bytes
MOVQ $-10, AX // 8 bytes
// intel
mov rax, 0x1 // 8 bytes
mov eax, 0x100 // 4 bytes
mov ax, 0x22 // 2 bytes
mov ah, 0x33 // 1 byte
mov al, 0x44 // 1 byte
- 为了简化汇编代码的编写,引入了 4 个伪寄存器。(其实就是 Go 汇编语言对 CPU 的重新抽象)
FP
: Frame pointer: arguments and locals.PC
: Program counter: jumps and branches.SB
: Static base pointer: global symbols.SP
: Stack pointer: top of stack.
四个伪寄存器和 X86/AMD64 的内存和寄存器的相互关系如下图:
![2f7cf23031e6223c53ce9a368a387979.png](https://i-blog.csdnimg.cn/blog_migrate/f1983c63dfe50d9a7aa4597204783354.png)
在 AMD64 环境,伪 PC 寄存器其实是 IP 指令计数器寄存器的别名。伪 FP 寄存器对应的是函数的帧指针,用来访问函数的参数和返回值。伪 SP 栈指针对应的是当前函数栈帧的底部(不包括参数和返回值部分),用于定位局部变量。伪 SP 是一个比较特殊的寄存器,因为还存在一个同名的 SP 真寄存器。真 SP 寄存器对应的是栈的顶部,用于定位调用其它函数的参数和返回值。
当需要区分伪寄存器和真寄存器的时候只需要记住一点:伪寄存器需要一个标识符和偏移量为前缀,如果没有标识符前缀则是真寄存器。比如(SP)
、+8(SP)
没有标识符前缀为真 SP 寄存器,而a(SP)
、b+8(SP)
有标识符为前缀表示伪寄存器。
- 被调用函数的入参与出参都在调用函数的栈帧中。
在这一点和 c 语言有一点不一样,c 当入参小于 6 个时会使用寄存器,出参也只允许有一个,想要有多返回值要么就是返回一个指针,要么就是把入参当出参用。而 golang 则一律使用栈来传输入参与出参,所以函数调用有「一定的性能损耗」(会比 c 慢一点)。Go 编译器是通过「函数内联」来缓解这个问题的影响
PS:在这里提一嘴,golang 可以通过命令查看 build 过程中究竟干了些什么
go build -n filename.go
build 分为三个阶段,compile, link 以及 buildId。
而在 compile 过程中做了下图这六件事,大致就是
- 词法分析:根据空格等符号分词
- 语法分析:生成 AST
- 语义分析:类型检查+逃逸分析+内联等 (「禁止函数内联就是操作这个步骤」)
- 中间码生成:替换一些底层函数(如判断使用 makeslice64 或 makeslice)
- 代码优化:顾名思义,就是搞提升并行,指令优化,利用寄存器等代码优化
- 机器代码生成:根据 GOARCH,生成 plan9
3. plan9 的函数声明
// func add(a, b int) int
// => 该声明定义在同一个 package 下的任意 .go 文件中
// => 只有函数头,没有实现
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 pkgname·add(SB),NOSPLIT,$32-32
| | |
包名 函数名 栈帧大小(局部变量+可能需要的额外调用函数的参数空间的总大小,但不包括调用其它函数时的 ret address 的大小)
PS: golang 会自动为每个函数加入一段栈扩容检测的代码,而对于小函数会进行优化,不加入栈扩容检测。而 NOSPLIT 也能强制定义取消栈扩容检查,好处则是速度可以一定程度变快,缺点则也很明显,空间不足就 GG 了。
找个例子试一下:
sync/atomic/doc.go
中定义了 CompareAndSwapInt32
方法 同级目录下有 asm.s
,可以看到对应方法的汇编代码。
TEXT ·CompareAndSwapUint32(SB),NOSPLIT,$0
JMP runtime∕internal∕atomic·Cas(SB)
然后可以跟踪到 runtime/internal/asm_amd64.s
中
// bool Cas(int32 *val, int32 old, int32 new)
// Atomically:
// if(*val == old){
// *val = new;
// return 1;
// } else
// return 0;
TEXT runtime∕internal∕atomic·Cas(SB),NOSPLIT,$0-17
MOVQ ptr+0(FP), BX ; 第一个参数命名为addr,放入BP(MOVQ,完成8个字节的复制)
MOVL old+8(FP), AX ; 第二个参数命名为old,放入AX
MOVL new+12(FP), CX ; 第三个参数命名为new,放入CX
LOCK ; 锁内存总线操作,防止其它CPU干扰
CMPXCHGL CX, 0(BX) ; CMPXCHGL,该指令会把AX中的内容和第二个操作数中的内容比较,如果相等,那么把第一个操作数内容赋值给第二个操作数,换言之则是将old与addr中的内容做比较,如果相等,则新值覆盖旧值。
SETEQ ret+16(FP)
RET
通过追溯源代码可以进一步确认 golang 中 atomic 包中的方法是通过单指令防止因 cpu 调度等原因被中断,从而解决临界区问题。所以至此可以断言 atomic 方法没有使用信号量,因此也没有内核态向用户态的转变这一消耗,是高性能的实现并发安全的方式
4. 解决实际问题
除了直接阅读源代码中的汇编代码之外,还可以将 go 代码进行编译,从而得到编译后的代码(这时候就不含伪寄存器了)
命令:
-l:
禁止内联
-N:
禁止优化
-S:
输出到标准输出
go tool compile -S -N -l main.go
main.go
package main
func main() {
_ = add(3,5)
}
func add(a, b int) int {
return a+b
}
编译后:
"".main STEXT size=68 args=0x0 locals=0x20
0x0000 00000 (main.go:3) TEXT "".main(SB), ABIInternal, $32-0 ; BP 8个字节。2个入参+1个出参 24个字节,所以32个字节
0x0000 00000 (main.go:3) MOVQ (TLS), CX ; 加载g结构体指针,可以查看runtime的getg()方法获取的*g结构
0x0009 00009 (main.go:3) CMPQ SP, 16(CX) ;SP栈指针和g结构体中stackguard0成员比较 判断是否扩容
0x000d 00013 (main.go:3) JLS 61 ; 需要扩容就跳过去 (以上部分在nosplit模式以及小函数中没有)
0x000f 00015 (main.go:3) SUBQ $32, SP ; 栈扩容32个字节
0x0013 00019 (main.go:3) MOVQ BP, 24(SP) ; 将bp寄存器中的值存入(物理寄存器)SP偏移24字节的8个字节位
0x0018 00024 (main.go:3) LEAQ 24(SP), BP ; 将24(SP)的地址置入BP(其实就是为了交给子函数,用来找回它的父函数)
0x001d 00029 (main.go:3) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d 00029 (main.go:3) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d 00029 (main.go:3) FUNCDATA $3, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d 00029 (main.go:4) PCDATA $2, $0
0x001d 00029 (main.go:4) PCDATA $0, $0 ; funcdata与pcdata与GC有关,可以忽略
0x001d 00029 (main.go:4) MOVQ $3, (SP) ; 赋值3
0x0025 00037 (main.go:4) MOVQ $5, 8(SP) ; 赋值5
0x002e 00046 (main.go:4) CALL "".add(SB) ; 调用add函数,这时候16(SP)的位置已经空出来用于放返回值
0x0033 00051 (main.go:5) MOVQ 24(SP), BP
0x0038 00056 (main.go:5) ADDQ $32, SP ; 缩栈
0x003c 00060 (main.go:5) RET ; 结束
0x003d 00061 (main.go:5) NOP
0x003d 00061 (main.go:3) PCDATA $0, $-1
0x003d 00061 (main.go:3) PCDATA $2, $-1
0x003d 00061 (main.go:3) CALL runtime.morestack_noctxt(SB)
0x0042 00066 (main.go:3) JMP 0
0x0000 65 48 8b 0c 25 00 00 00 00 48 3b 61 10 76 2e 48 eH..%....H;a.v.H
0x0010 83 ec 20 48 89 6c 24 18 48 8d 6c 24 18 48 c7 04 .. H.l$.H.l$.H..
0x0020 24 03 00 00 00 48 c7 44 24 08 05 00 00 00 e8 00 $....H.D$.......
0x0030 00 00 00 48 8b 6c 24 18 48 83 c4 20 c3 e8 00 00 ...H.l$.H.. ....
0x0040 00 00 eb bc ....
rel 5+4 t=16 TLS+0
rel 47+4 t=8 "".add+0
rel 62+4 t=8 runtime.morestack_noctxt+0
"".add STEXT nosplit size=25 args=0x18 locals=0x0
0x0000 00000 (main.go:7) TEXT "".add(SB), NOSPLIT|ABIInternal, $0-24 ; 因为入参与出参由调用函数提供,所以栈桢为0,出入参总和24个字节
0x0000 00000 (main.go:7) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (main.go:7) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (main.go:7) FUNCDATA $3, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (main.go:7) PCDATA $2, $0
0x0000 00000 (main.go:7) PCDATA $0, $0
0x0000 00000 (main.go:7) MOVQ $0, "".~r2+24(SP)
0x0009 00009 (main.go:8) MOVQ "".a+8(SP), AX
0x000e 00014 (main.go:8) ADDQ "".b+16(SP), AX
0x0013 00019 (main.go:8) MOVQ AX, "".~r2+24(SP)
0x0018 00024 (main.go:8) RET
0x0000 48 c7 44 24 18 00 00 00 00 48 8b 44 24 08 48 03 H.D$.....H.D$.H.
0x0010 44 24 10 48 89 44 24 18 c3 D$.H.D$..
查看 slice 作为参数的情况,可以发现把一个 slice 作为参数实际上是传了 3 个参数,地址指针+len+cap,这一点可以通过代码中 sliceHeader 结构体得到进一步证实。
ps:golang 中字符串有 16 个字节,也是地址指针+len,结构体为 stringHeader。所以无法进行字符串修改。有一个比较有意思的设计点在于因为 stringHeader 前两个部分与 sliceHeader 相同,因为 plan9 汇编是没有类型的,大家都是一块内存,所以可以直接由 slice 转化为 string。
package main
func main() {
s := make([]int, 3, 10)
_ = f(s)
}
func f(s []int) int {
return s[1]
}
至于想用汇编进行逃逸分析的人,个人认为是没必要的。直接 gcflags 即可。
以下代码可供玩一下,一个是不逃逸,一个是逃逸的。逃逸到堆上一般会造成 GC 压力,但是另一方面也节省了栈的空间。
package main
import ()
func foo() *int {
var x int
return &x
}
func bar() int {
x := new(int)
*x = 1
return *x
}
func main() {}
可以直接
go run -gcflags '-m -l' main.go
5. 栈扩容
- stack.lo: 栈空间的低地址
- stack.hi: 栈空间的高地址
- stackguard0: stack.lo + StackGuard, 用于 stack overlow 的检测
- StackGuard: 保护区大小,常量 Linux 上为 880 字节
- StackSmall: 常量大小为 128 字节,用于小函数调用的优化
栈扩容检测有时候也会引入一定的问题,比如某厂在大量全双工 PUSH 中使用 GPRC 的时候导致所有栈的大小翻一倍,以至于出现线上事故。也是值得警惕的。
6. Go 语言的编译指示
在编写 go 函数时也可以 diy 一些编译行为,个人认为只有//go:nosplit
以及//go:noinline
有点用,其他都没啥实际作用。编译指示详解:https://segmentfault.com/a/1190000016743220
![3f98f0185aabce6cf1ba837447a2485c.png](https://i-blog.csdnimg.cn/blog_migrate/d6b181b086abc58640776b0d2d9862dc.png)
![a33b41a0e0f3aa4d39b3f7fb5a6e7230.png](https://i-blog.csdnimg.cn/blog_migrate/7c9f4e8db6ce4e4168c4911ee37c2874.jpeg)
![94aa80115420d0f1825af897508ee56f.png](https://i-blog.csdnimg.cn/blog_migrate/c442c2c48bb4162f6eaf3b5048aa0ef7.png)
![94aa80115420d0f1825af897508ee56f.png](https://i-blog.csdnimg.cn/blog_migrate/c442c2c48bb4162f6eaf3b5048aa0ef7.png)
你点的每一个在看,我都认真当成了喜欢