汇编对sp指针进行修改_从Go走进plan9汇编

本文由李晨毅投稿,原文首发于: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 语法的一些特点

  1. 没有 push 和 pop,栈的调整是通过对硬件 SP 寄存器进行运算来实现的

  2. 常数在 plan9 汇编用 $num 表示,可以为负数,默认情况下为十进制。

  3. 操作数方向与 intel 相反,与 AT&T 类似

SUBQ	$24, SP  // 对 SP 做减法,为函数分配函数栈24字节大小的帧 (因为栈是从高地址向低地址增长的)
...
中间的一堆代码
...
ADDQ $24, SP // 对 SP 做加法,清除函数栈帧
  1. 数据搬运的长度由 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
  1. 为了简化汇编代码的编写,引入了 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
四个伪寄存器

在 AMD64 环境,伪 PC 寄存器其实是 IP 指令计数器寄存器的别名。伪 FP 寄存器对应的是函数的帧指针,用来访问函数的参数和返回值。伪 SP 栈指针对应的是当前函数栈帧的底部(不包括参数和返回值部分),用于定位局部变量。伪 SP 是一个比较特殊的寄存器,因为还存在一个同名的 SP 真寄存器。真 SP 寄存器对应的是栈的顶部,用于定位调用其它函数的参数和返回值。

当需要区分伪寄存器和真寄存器的时候只需要记住一点:伪寄存器需要一个标识符和偏移量为前缀,如果没有标识符前缀则是真寄存器。比如(SP)+8(SP)没有标识符前缀为真 SP 寄存器,而a(SP)b+8(SP)有标识符为前缀表示伪寄存器。

  1. 被调用函数的入参与出参都在调用函数的栈帧中。

    fe750f14b9848ddd2a1f829311b1cfb3.png

栈示意图

在这一点和 c 语言有一点不一样,c 当入参小于 6 个时会使用寄存器,出参也只允许有一个,想要有多返回值要么就是返回一个指针,要么就是把入参当出参用。而 golang 则一律使用栈来传输入参与出参,所以函数调用有「一定的性能损耗」(会比 c 慢一点)。Go 编译器是通过「函数内联」来缓解这个问题的影响

PS:在这里提一嘴,golang 可以通过命令查看 build 过程中究竟干了些什么

go build -n filename.go

build 分为三个阶段,compile, link 以及 buildId。

而在 compile 过程中做了下图这六件事,大致就是

  • 词法分析:根据空格等符号分词
  • 语法分析:生成 AST
  • 语义分析:类型检查+逃逸分析+内联等 (「禁止函数内联就是操作这个步骤」)
  • 中间码生成:替换一些底层函数(如判断使用 makeslice64 或 makeslice)
  • 代码优化:顾名思义,就是搞提升并行,指令优化,利用寄存器等代码优化
  • 机器代码生成:根据 GOARCH,生成 plan9

2779f4b34d10c28149548f9bbb43acd5.png

编译器原理

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]
}

8d59474ec93ea324adc266b22a2abe43.png

slice

至于想用汇编进行逃逸分析的人,个人认为是没必要的。直接 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. 栈扩容

a9aac8c41c8ea214815c4327e29e5c6b.png

栈扩容
  • 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扫码关注 a33b41a0e0f3aa4d39b3f7fb5a6e7230.png更多精彩 94aa80115420d0f1825af897508ee56f.png 94aa80115420d0f1825af897508ee56f.png

你点的每一个在看,我都认真当成了喜欢

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值