Go 汇编详解 您所在的位置:网站首页 few原型和最高级 Go 汇编详解

Go 汇编详解

2023-04-23 04:23| 来源: 网络整理| 查看: 265

0 分享至

用微信扫码二维码

分享至好友和朋友圈

前言

我们知道 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() int64TEXT ·Get(SB),NOSPLIT,$0-8MOVQ (TLS), R14MOVQ g_goid(R14), R13MOVQ 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/[email protected]/goid_go1.5_amd64.sgoid_go1.5_amd64.s:28 0x108adc0 654c8b342530000000 MOVQ GS:0x30, R14goid_go1.5_amd64.s:29 0x108adc9 4d8bae98000000 MOVQ 0x98(R14), R13goid_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 |+----------------------> +------------------+--------------------------+ main.S 1.6.2 使用例子

Go 函数调用汇编函数:

// add.gopackage mainimport "fmt"

func add(x, y int64) int64

func main() {fmt.Println(add(2, 3))} // add_amd64.s// add(x,y) -> x+yTEXT ·add(SB),NOSPLIT,$0MOVQ x+0(FP), BXMOVQ y+8(FP), BPADDQ BP, BXMOVQ BX, ret+16(FP)RET

汇编调用 go 语言函数:

package mainimport "fmt"

func add(x, y int) int {return x + y}

func output(a, b int) int

func main() {s := output(10, 13)fmt.Println(s)} #include "textflag.h"

// func output(a,b int) intTEXT ·output(SB), NOSPLIT, $24-24MOVQ a+0(FP), DX // arg aMOVQ DX, 0(SP) // arg xMOVQ b+8(FP), CX // arg bMOVQ CX, 8(SP) // arg yCALL ·add(SB) // 在调用 add 之前,已经把参数都通过物理寄存器 SP 搬到了函数的栈顶MOVQ 16(SP), AX // add 函数会把返回值放在这个位置MOVQ AX, ret+16(FP) // return resultRET 1.6.1 汇编函数中用到的一些特殊命令(伪指令)

GO_RESULTS_INITIALIZED:如果 Go 汇编函数返回值含指针,则该指针信息必须由 Go 源文件中的函数的 Go 原型提供,即使对于未直接从 Go 调用的汇编函数也是如此。如果返回值将在调用指令期间保存实时指针,则该函数中应首先将结果归零, 然后执行伪指令 GO_RESULTS_INITIALIZED。表明该堆栈位置应该执行进行 GC 扫描,避免其指向的内存地址呗 GC 意外回收。

NO_LOCAL_POINTERS: 就是字面意思,表示函数没有指针类型的局部变量。

PCDATA: Go 语言生成的汇编,利用此伪指令表明汇编所在的原始 Go 源码的位置(file&line&func),用于生成 PC 表格。runtime.FuncForPC 函数就是通过 PC 表格得到结果的。一般由编译器自动插入,手动维护并不现实。

FUNCDATA: 和 PCDATA 的格式类似,用于生成 FUNC 表格。FUNC 表格用于记录函数的参数、局部变量的指针信息,GC 依据它来跟踪栈中指针指向内存的生命周期,同时栈扩缩容的时候也是依据它来确认是否需要调整栈指针的值(如果指向的地址在需要扩缩容的栈中,则需要同步修改)。

1.7 条件编译

Go 语言仅支持有限的条件编译规则:

根据文件名编译。

根据 build 注释编译。

根据文件名编译类似 *_test.go,通过添加平台后缀区分,比如: asm_386.s、asm_amd64.s、asm_arm.s、asm_arm64.s、asm_mips64x.s、asm_linux_amd64.s、asm_bsd_arm.s 等.

根据 build 注释编译,就是在源码中加入区分平台和编译器版本的注释。比如:

//go:build (darwin || freebsd || netbsd || openbsd) && gc// +build darwin freebsd netbsd openbsd// +build gc

Go 1.17 之前,我们可以通过在源码文件头部放置 +build 构建约束指示符来实现构建约束,但这种形式十分易错,并且它并不支持&&和||这样的直观的逻辑操作符,而是用逗号、空格替代,下面是原 +build 形式构建约束指示符的用法及含义:

Go 1.17 引入了 //go:build 形式的构建约束指示符,支持&&和||逻辑操作符,如下代码所示:

//go:build linux && (386 || amd64 || arm || arm64 || mips64 || mips64le || ppc64 || ppc64le)//go:build linux && (mips64 || mips64le)//go:build linux && (ppc64 || ppc64le)//go:build linux && !386 && !arm

考虑到兼容性,Go 命令可以识别这两种形式的构建约束指示符,但推荐 Go 1.17 之后都用新引入的这种形式。

gofmt 可以兼容处理两种形式,处理原则是:如果一个源码文件只有 // +build 形式的指示符,gofmt 会将与其等价的 //go:build 行加入。否则,如果一个源文件中同时存在这两种形式的指示符行,那么 //+build 行的信息将被 //go:build 行的信息所覆盖。

2、 go 语言 ABI

参考文档:

Go internal ABI specification

https://go.googlesource.com/go/+/refs/heads/dev.regabi/src/cmd/compile/internal-abi.md

Proposal: Create an undefined internal calling convention

https://go.googlesource.com/proposal/+/master/design/27539-internal-abi.md

名词解释:ABI: application binary interface, 应用程序二进制接口,规定了程序在机器层面的操作规范和调用规约。调用规约: calling convention, 所谓“调用规约”是调用方和被调用方对于函数调用的一个明确的约定,包括:函数参数与返回值的传递方式、传递顺序。只有双方都遵守同样的约定,函数才能被正确地调用和执行。如果不遵守这个约定,函数将无法正确执行。

Go 从1.17.1版本开始支持多 ABI:1. 为了兼容性各平台保持通用性,保留历史版本 ABI,并更名为 ABI0。2. 为了更好的性能,增加新版本 ABI 取名 ABIInternal。ABI0 遵循平台通用的函数调用约定,实现简单,不用担心底层cpu架构寄存器的差异;ABIInternal 可以指定特定的函数调用规范,可以针对特定性能瓶颈进行优化,在多个 Go 版本之间可以迭代,灵活性强,支持寄存器传参提升性能。Go 汇编为了兼容已存在的汇编代码,保持使用旧的 ABI0。

Go 为什么在有了 ABI0 之后,还要引入 ABIInternal?当然是为了性能!据官方测试,寄存器传参可以带来 5% 的性能提升。

我们看一个例子:

package mainimport _ "fmt"

func Print(delta string)

func main() {Print("hello")} #include "textflag.h"TEXT ·Print(SB), NOSPLIT, $8CALL fmt·Println(SB)RET

运行上面代码会报错:main.Print: relocation target fmt.Println not defined for ABI0 (but is defined for ABIInternal)

原因是,fmt·Println 函数默认使用的 ABI 标准是 ABIInternal,而 Go 语言手写的汇编使用的 ABI 格式是 ABI0,二者标准不一样不能直接调用。不过 Go 语言可以通过 //go:linkname 的方式为 ABIInternal 生成 ABI0 包装。

package mainimport ("fmt")//go:linkname Println fmt.Printlnfunc Println(a ...any) (n int, err error)

func Print(delta interface{})func main() {Print("hello")} #include "textflag.h"TEXT ·Print(SB), NOSPLIT, $48-16LEAQ strp+0(FP),AXMOVQ AX, 0(SP) // []interface{} slice 的 pointerMOVQ $1, BXMOVQ BX, 8(SP) // slice 的 lenMOVQ BX, 16(SP) // slice 的 capCALL fmt·Println(SB) // //go:linkname 为 fmt.Println 生成一个 ABI0 包装后,汇编可以直接调用RET

简单说明:函数 fmt.Println 是一个变参函数,变参(a ...any)实际上是 (a []any)的语法糖。参数中,slice 占 24Byte,int 占 8Byte,error 是 interface 类型,占 16Byte,加起来是 48 Byte。所以,调用此函数时,caller 需要再栈上准备 24Byte 空间。而 Print 的入参刚好是一个 interface{} 类型,和 any 一致,所以只要把 Print 函数的入参的地址赋给 a 的指针,并把 a 的 len 和 cap 设置为 1,就可以调用 fmt·Println 函数了。如以上代码所示。

3、内存管理和 GC 对汇编的影响 3.1 调用栈扩缩容对汇编的影响

为了减少对内存的占用,goroutine 启动时 runtime 只给它分配了很少的栈内存。所有函数(标记 go:nosplit 的除外)的序言部分(启动指令)会插入分段检查,当发现栈溢出(栈空间不足)时,就会调用 runtime.morestack,执行栈拓展逻辑:

旧版本的 Go 编译器采用了分段栈机制实现栈拓展,当一个 goroutine 的执行栈溢出时,就增加一个栈内存作为调用栈的补充,新旧栈彼此没有连续。这种设计的缺陷很容易破坏缓存的局部性原理,从而降低程序的运行时性能。

Go 1.3 版本开始,引入了连续栈(拷贝栈)机制,并把 goroutine 的初始栈大小由 8KB 降低到了 2KB。当一个执行栈发生溢出时,新建一个两倍于原栈大小的新栈,并将原栈整个拷贝到新栈上,保证整个栈是连续的。

栈的拷贝有些副作用:

如果栈上存在指向当前被拷贝栈的指针,当栈拷贝执行完成后,这个指针还是指向原栈,需要更新。

goroutine 的 g 结构体上的 gobuf 成员也还是指向旧的栈,也需要更新。

除了正在拷贝的栈中可能存在指向自己的的指针外,还有没有其他存活中的内存有指向即将失效的栈空间的指针呢?答案在 go 逃逸分析源码 中,代码如下:

// Escape analysis.//// Here we analyze functions to determine which Go variables// (including implicit allocations such as calls to "new" or "make",// composite literals, etc.) can be allocated on the stack. The two// key invariants we have to ensure are: (1) pointers to stack objects// cannot be stored in the heap, and (2) pointers to a stack object// cannot outlive that object (e.g., because the declaring function// returned and destroyed the object's stack frame, or its space is// reused across loop iterations for logically distinct variables).//

其中 “(1) pointers to stack objects cannot be stored in the heap” 表明指向栈对象的指针不能存储在堆中。

拷贝栈理论上没有上限,但是一般都设置了上限。当新的栈大小超过了 maxstacksize 就会抛出”stack overflow“的异常。maxstacksize 是在 runtime.main 中设置的。64 位系统下栈的最大值 1GB、32 位系统是 250MB。参考代码:

if newsize > maxstacksize || newsize > maxstackceiling {if maxstacksize < maxstackceiling {print("runtime: goroutine stack exceeds ", maxstacksize, "-byte limit\n")} else {print("runtime: goroutine stack exceeds ", maxstackceiling, "-byte limit\n")}print("runtime: sp=", hex(sp), " stack=[", hex(gp.stack.lo), ", ", hex(gp.stack.hi), "]\n")throw("stack overflow")}

由拷贝栈的原理可知,拷贝栈对 Go 汇编是透明的。

3.2 GC 对汇编的影响

由于 GC 会动态回收没有被引用的堆内存,而 goroutine 的调用栈在堆空间,所以如果调用栈中存了堆内存的指针,就需要告诉 GC 栈中含指针。上文中说到的伪指令 FUNCDATA、GO_RESULTS_INITIALIZED、NO_LOCAL_POINTERS 就是干这个事的。由于 FUNCDATA 伪指令几乎只能由编译器维护,所以在手写的汇编函数本地内存栈中保存指向动态内存的指针几乎是一种奢望。

4、 函数内联和汇编

参考文档:

Go: Inlining Strategy & Limitation

https://medium.com/a-journey-with-go/go-inlining-strategy-limitation-6b6d7fc3b1be

4.1 查看内联情况

可以通过执行以下命令,输出被内联的函数:

go build -gcflags="-m" main.go# 输出结果:# ./op.go:3:6: can inline add# ./op.go:7:6: can inline sub# ./main.go:16:11: inlining call to sub# ./main.go:14:11: inlining call to add# ./main.go:7:12: inlining call to fmt.Printf

或者使用参数 -gflags="-m -m" 运行,查看编译器的详细优化策略:

go build -gcflags="-m -m" main.go

输出很详细:

# command-line-arguments./main.go:10:6: cannot inline main: function too complex: cost 106 exceeds budget 80./main.go:20:12: inlining call to fmt.Printf./main.go:23:6: can inline toEface with cost 0 as: func() { }./main.go:18:2: shlx escapes to heap:./main.go:18:2: flow: i = &{storage for shlx}:./main.go:18:2: from shlx (spill) at ./main.go:18:2./main.go:18:2: from i = shlx (assign) at ./main.go:18:4./main.go:18:2: flow: {storage for ... argument} = i:./main.go:18:2: from ... argument (slice-literal-element) at ./main.go:20:12./main.go:18:2: flow: fmt.a = &{storage for ... argument}:./main.go:18:2: from ... argument (spill) at ./main.go:20:12./main.go:18:2: from fmt.format, fmt.a := "%+v", ... argument (assign-pair) at ./main.go:20:12./main.go:18:2: flow: {heap} = *fmt.a:./main.go:18:2: from fmt.Fprintf(io.Writer(os.Stdout), fmt.format, fmt.a...) (call parameter) at ./main.go:20:12./main.go:17:2: x escapes to heap:./main.go:17:2: flow: i = &{storage for x}:./main.go:17:2: from x (spill) at ./main.go:17:2./main.go:17:2: from i = x (assign) at ./main.go:17:4./main.go:17:2: flow: {storage for ... argument} = i:./main.go:17:2: from ... argument (slice-literal-element) at ./main.go:20:12./main.go:17:2: flow: fmt.a = &{storage for ... argument}:./main.go:17:2: from ... argument (spill) at ./main.go:20:12./main.go:17:2: from fmt.format, fmt.a := "%+v", ... argument (assign-pair) at ./main.go:20:12./main.go:17:2: flow: {heap} = *fmt.a:./main.go:17:2: from fmt.Fprintf(io.Writer(os.Stdout), fmt.format, fmt.a...) (call parameter) at ./main.go:20:12./main.go:17:2: x escapes to heap./main.go:18:2: shlx escapes to heap./main.go:20:12: ... argument does not escape

Go 编译器默认将进行内联优化,可以通过 -gcflags="-l" 选项全局禁用内联,与一个-l禁用内联相反,如果传递两个或两个以上的-l则会打开内联,并启用更激进的内联策略。例如以下代码:

// 3.1: var closure = NewClosure()func main() {// 3.2: var closure func() intvar closure = NewClosure()closure()// 3.3: closure = NewClosure()closure()}

func NewClosure() func() int {i := 0return func() int {i++return i}}

命令 go build -gcflags="-m" main.go 和 go build -gcflags="-m -l -l" main.go 都是输出:

./main.go:19:6: can inline NewClosure./main.go:21:9: can inline NewClosure.func1./main.go:13:26: inlining call to NewClosure./main.go:21:9: can inline main.func1./main.go:14:9: inlining call to main.func1./main.go:16:9: inlining call to main.func1./main.go:13:26: func literal does not escape./main.go:20:2: moved to heap: i./main.go:21:9: func literal escapes to heap

命令 go build -gcflags="-m" main.go 输出:

./main.go:20:2: moved to heap: i./main.go:21:9: func literal escapes to heap 4.2 内联前后性能对比

首先,看一下函数内联与非内联的性能差异。内联可以避免函数调用过程中的一些开销:创建栈帧,读写寄存器。不过,对函数体进行拷贝也会增大二进制文件的大小。据 Go 官方宣传,内联大概会有 5~6% 的性能提升。

//go:noinlinefunc maxNoinline(a, b int) int {if a < b {return b}return a}

func maxInline(a, b int) int {if a < b {return b}return a}

func BenchmarkInline(b *testing.B) {x, y := 1, 2b.Run("BenchmarkNoInline", func(b *testing.B) {b.ResetTimer()for i := 0; i < b.N; i++ {maxNoinline(x, y)}})b.Run("BenchmarkInline", func(b *testing.B) {b.ResetTimer()for i := 0; i < b.N; i++ {maxInline(x, y)}})}

在程序代码中,想要禁止编译器内联优化很简单,在函数定义前一行添加 //go:noinline 即可。以下是性能对比结果:

BenchmarkInline/BenchmarkNoInline-12 886137398 1.248 ns/op 0 B/op 0 allocs/opBenchmarkInline/BenchmarkInline-12 1000000000 0.2506 ns/op 0 B/op 0 allocs/op

因为函数体内部的执行逻辑非常简单,此时内联与否的性能差异主要体现在函数调用的固定开销上。显而易见,该差异是非常大的。

4.3 内联条件

Go 语言代码函数内联的策略每个编译器版本都有细微差别,比如新版已支持含 for 和 闭包 的函数内联。1.18 版本的部分无法内联的规则如下:

函数标注 "go:noinline" 注释。

函数标注 "go:norace" 注释,且使用 "-gcflags=-d checkptr" 参数编译。

函数标注 "go:cgo_unsafe_args" 注释。

函数标注 "go:uintptrescapes" 注释。

函数只有声明而没有函数体:比如函数实体在汇编文件 xxx.s 中。

超过小代码量边界的函数:内联的小代码量边界是 80 个节点(抽象语法树AST的节点)。

函数中含某些关键字的函数:比如 select、defer、go、recover 等。

一些特殊的内部函数:比如 runtime.getcallerpc、runtime.getcallersp (这俩太特殊了)。

函数内部使用 type 关键字重定义了类型:比如 "type Int int" 或 "type Int = int"。

作为尾递归调用时。

此外,还有一些编译器觉得内联成本很低,所以必然内联的函数:

"runtime" package 下的 "heapBits.nextArena" 和 "builtin" package 下的 "append"。

"encoding/binary" package 下的:"littleEndian.Uint64", "littleEndian.Uint32", "littleEndian.Uint16","bigEndian.Uint64", "bigEndian.Uint32", "bigEndian.Uint16","littleEndian.PutUint64", "littleEndian.PutUint32", "littleEndian.PutUint16","bigEndian.PutUint64", "bigEndian.PutUint32", "bigEndian.PutUint16", "append"。

由规则 5 可知,Go 语言汇编是无法内联的。

此外,关于闭包内联是一个比较复杂的话题,据笔者测试,1.18 有如上规则:

满足条件的闭包可以内联。

闭包通用部分在内联统计的时候,占用函数的 15 个 AST 节点。

变量保存的闭包,如果是局部变量且没有重新赋值过,则可以被内联。

关于闭包内联的第 3 条规则,有如下例子:

// 3.1: var closure = NewClosure()func main() {// 3.2: var closure func() intvar closure = NewClosure()closure()// 3.3: closure = NewClosure()closure()}

func NewClosure() func() int {i := 0return func() int {i++return i}}

执行 go build -gcflags="-m" ./ 输出如下

./main.go:19:6: can inline NewClosure./main.go:21:9: can inline NewClosure.func1./main.go:13:26: inlining call to NewClosure./main.go:21:9: can inline main.func1./main.go:14:9: inlining call to main.func1./main.go:16:9: inlining call to main.func1./main.go:13:26: func literal does not escape./main.go:20:2: moved to heap: i./main.go:21:9: func literal escapes to heap

表明闭包 closure 可以内联。如果把 3.1 或 3.2 或 3.3 的注释打开,则将会输出:

./main.go:19:6: can inline NewClosure./main.go:21:9: can inline NewClosure.func1./main.go:13:22: inlining call to NewClosure./main.go:13:22: func literal does not escape./main.go:20:2: moved to heap: i./main.go:21:9: func literal escapes to heap

表明闭包 closure 无法内联。

此外,如果想禁用闭包内联,可以使用 -gcflags="-d=inlfuncswithclosures=0" 或-gcflags="-d inlfuncswithclosures=0" 参数编译。

go build -gcflags="-d=inlfuncswithclosures=0" main.gogo build -gcflags="-d inlfuncswithclosures=0" main.go

如果想了解 go 1.18 的内联检查逻辑,可以看这个源码:inline.CanInline 和 (*inline.hairyVisitor).doNode。其调用顺序是:inline.CanInline --> inline.hairyVisitor.tooHairy --> inline.hairyVisitor.doNode。

// CanInline determines whether fn is inlineable.// If so, CanInline saves copies of fn.Body and fn.Dcl in fn.Inl.// fn and fn.Body will already have been typechecked.func CanInline(fn *ir.Func) {...// If marked "go:noinline", don't inlineif fn.Pragma&ir.Noinline != 0 {reason = "marked go:noinline"return}

// If marked "go:norace" and -race compilation, don't inline.if base.Flag.Race && fn.Pragma&ir.Norace != 0 {reason = "marked go:norace with -race compilation"return}

// If marked "go:nocheckptr" and -d checkptr compilation, don't inline.if base.Debug.Checkptr != 0 && fn.Pragma&ir.NoCheckPtr != 0 {reason = "marked go:nocheckptr"return}

// If marked "go:cgo_unsafe_args", don't inline, since the// function makes assumptions about its argument frame layout.if fn.Pragma&ir.CgoUnsafeArgs != 0 {reason = "marked go:cgo_unsafe_args"return}

// If marked as "go:uintptrescapes", don't inline, since the// escape information is lost during inlining.if fn.Pragma&ir.UintptrEscapes != 0 {reason = "marked as having an escaping uintptr argument"return}

// The nowritebarrierrec checker currently works at function// granularity, so inlining yeswritebarrierrec functions can// confuse it (#22342). As a workaround, disallow inlining// them for now.if fn.Pragma&ir.Yeswritebarrierrec != 0 {reason = "marked go:yeswritebarrierrec"return}

// If fn has no body (is defined outside of Go), cannot inline it.if len(fn.Body) == 0 {reason = "no function body"return}...visitor := hairyVisitor{budget: inlineMaxBudget, // inlineMaxBudget == 80extraCallCost: cc,}if visitor.tooHairy(fn) {reason = visitor.reasonreturn}...}

func (v *hairyVisitor) tooHairy(fn *ir.Func) bool {v.do = v.doNode // cache closureif ir.DoChildren(fn, v.do) {return true}...}

func (v *hairyVisitor) doNode(n ir.Node) bool {...case ir.OSELECT,ir.OGO,ir.ODEFER,ir.ODCLTYPE, // can't print yetir.OTAILCALL:v.reason = "unhandled op " + n.Op().String()return true...}

5、 有哪些有意思的使用场景 5.1、 获取 goid

goid 即 goroutine id,最常用三方库应该就是 petermattis/goid, 里通过汇编获取 goid 的代码关键逻辑如下:

runtime_go1.9.go 代码:

//go:build gc && go1.9// +build gc,go1.9package goid

type stack struct {lo uintptrhi uintptr}

type gobuf struct {sp uintptrpc uintptrg uintptrctxt uintptrret uintptrlr uintptrbp uintptr}

type g struct {stack stackstackguard0 uintptrstackguard1 uintptr

_panic uintptr_defer uintptrm uintptrsched gobufsyscallsp uintptrsyscallpc uintptrstktopsp uintptrparam uintptratomicstatus uint32stackLock uint32goid int64 // Here it is!}

goid_go1.5_amd64.go 代码:

//go:build (amd64 || amd64p32) && gc && go1.5// +build amd64 amd64p32// +build gc// +build go1.5package goid

func Get() int64

goid_go1.5_amd64.s 代码:

//go:build (amd64 || amd64p32) && gc && go1.5// +build amd64 amd64p32// +build gc// +build go1.5

#include "go_asm.h"#include "textflag.h"

// func Get() int64TEXT ·Get(SB),NOSPLIT,$0-8MOVQ (TLS), R14MOVQ g_goid(R14), R13MOVQ R13, ret+0(FP)RET

不过这样获取 goid 有一个局限性,就是如果当前处于 g0 调用栈(系统调用或CGO函数中)时,拿到的不是当前 g 的 goid,而是 是 g0 的 goid。在这种情况下 g.m.curg.goid 才是当前 g 的 goid。参考Go1.18 标准库下go/src/runtime/HACKING.md 文件里的说明:

getg() and getg().m.curg

To get the current user g, use getg().m.curg.

getg() alone returns the current g, but when executing on the system or signal stacks, this will return the current M's "g0" or "gsignal", respectively. This is usually not what you want.

To determine if you're running on the user stack or the system stack, use getg() == getg().m.curg.

除了 goid,pid也可以用汇编获取:choleraehyq/pid 是一个 fork petermattis/goid 的仓库,里面增加了获取 pid 的实现,实现代码如下:

p_m_go1.19.go 代码:

//go:build gc && go1.19 && !go1.21// +build gc,go1.19,!go1.21package goid

type p struct {id int32 // Here is pid}

type m struct {g0 uintptr // goroutine with scheduling stackmorebuf gobuf // gobuf arg to morestackdivmod uint32 // div/mod denominator for arm - known to liblink_ uint32// Fields not known to debuggers.procid uint64 // for debuggers, but offset not hard-codedgsignal uintptr // signal-handling ggoSigStack gsignalStack // Go-allocated signal handling stacksigmask sigset // storage for saved signal masktls [6]uintptr // thread-local storage (for x86 extern register)mstartfn func()curg uintptr // current running goroutinecaughtsig uintptr // goroutine running during fatal signalp *p // attached p for executing go code (nil if not executing go code)}

pid_go1.5.go 代码:

//go:build (amd64 || amd64p32 || arm64) && !windows && gc && go1.5// +build amd64 amd64p32 arm64// +build !windows// +build gc// +build go1.5package goid

//go:nosplitfunc getPid() uintptr//go:nosplitfunc GetPid() int {return int(getPid())}

pid_go1.5_amd64.s 代码:

// +build amd64 amd64p32// +build gc,go1.5

#include "go_asm.h"#include "textflag.h"

// func getPid() int64TEXT ·getPid(SB),NOSPLIT,$0-8MOVQ (TLS), R14MOVQ g_m(R14), R13MOVQ m_p(R13), R14MOVL p_id(R14), R13MOVQ R13, ret+0(FP)RET

不过,通过这种方式获取的 pid 也有一个局限性:在持有 pid 之后的时间里,可能当前 goroutine 已经被调度到其他 P 上了,也就是在使用 pid 的时候当前 pid 已经改变了。如果想要持有在持有 pid 的过程中持续帮当当前 P,可以使用一下方式:

import "unsafe"var _ = unsafe.Sizeof(0)

//go:linkname procPin runtime.procPin//go:nosplitfunc procPin() int

//go:linkname procUnpin runtime.procUnpin//go:nosplitfunc procUnpin()

runtime.procPin 和 runtime.procUnpin的实现代码在Go 标准库下的 src/runtime/proc.go 文件中:

//go:nosplitfunc procPin() int {_g_ := getg()mp := _g_.m

mp.locks++ // 锁定 P 的调度return int(mp.p.ptr().id)}

//go:nosplitfunc procUnpin() {_g_ := getg()_g_.m.locks--}

通过 procPin 函数锁定 P 的调度后再使用 pid,然后通过 procUnpin 释放 P。不过这里也需要谨慎使用,使用不当会对性能产生严重影响。

以上获取 goid 的方式还有一个比较大的缺点,就是如果 Go 编译器修改了 g 的结构体,就需要重新适配。

《Go语言高级编程》第三章第8节 的实现可以避免这个问题。其原理是,通过汇编构建一个 g 类型的 interface{},然后通过反射获取 goid 成员的偏移量。根据原理,可以如下实现:

func Getg() int64func getgi() interface{}var g_goid_offset uintptr = func() uintptr {g := getgi()if f, ok := reflect.TypeOf(g).FieldByName("goid"); ok {return f.Offset}panic("can not find g.goid field")}() TEXT ·Getg(SB), NOSPLIT, $0-8MOVQ (TLS), AXADDQ ·g_goid_offset(SB),AXMOVQ (AX), BXMOVQ BX, ret+0(FP)RET

// func getgi() interface{}TEXT ·getgi(SB), NOSPLIT, $32-16NO_LOCAL_POINTERS

MOVQ $0, ret_type+0(FP)MOVQ $0, ret_data+8(FP)GO_RESULTS_INITIALIZED

// get runtime.g// MOVQ (TLS), AXMOVQ $0, AX

// get runtime.g typeMOVQ $type·runtime·g(SB), BX

// MOVQ BX, ·runtime_g_type(SB)

// return interface{}MOVQ BX, ret_type+0(FP)MOVQ AX, ret_data+8(FP)RET

实际上还可以继续简化:

var runtime_g_type uint64 // go 源码中声明var gGoidOffset uintptr = func() uintptr { //nolintvar iface interface{}type eface struct {_type uint64data unsafe.Pointer}// 结构 iface 后,修改他的类型为 g(*eface)(unsafe.Pointer(&iface))._type = runtime_g_typeif f, ok := reflect.TypeOf(iface).FieldByName("goid"); ok {return f.Offset}panic("can not find g.goid field")}() GLOBL ·runtime_g_type(SB),NOPTR,$8DATA ·runtime_g_type+0(SB)/8,$type·runtime·g(SB) // 汇编中初始化。汇编中可以访问 package 的私有变量 5.2、Monkey Patch

Go 语言实现猴子打点的 package 不一定需要使用汇编,比如 bouk/monkey 和 go-kiss/monkey。不过字节开源的 monkey 和 内部的 mockito 都使用了汇编。他们有一个同源的依赖库,分别在 mockey/internal/monkey 目录和 mockito/monkey 目录下。

其 Patch() 的调用路径如下:Build() -> Patch() -> PatchValue() -> WriteWithSTW() -> Write() -> do_replace_code() 其中 do_replace_code() 是汇编实现的,作用是使用 mprotect 系统调用来修改内存权限(mprotect系统调用是修改内存页属性的)。原因是:可执行代码区是只读的,需要修改为可读写后才能修改,修改为可执行后才能执行(有想用 Go 写病毒的,可以参考一下)。

func (builder *MockBuilder) Build() *Mocker {mocker := Mocker{target: reflect.ValueOf(builder.target), builder: builder}mocker.buildHook(builder)mocker.Patch()return &mocker}func (mocker *Mocker) Patch() *Mocker {mocker.lock.Lock()defer mocker.lock.Unlock()if mocker.isPatched {return mocker}mocker.patch = monkey.PatchValue(mocker.target, mocker.hook, reflect.ValueOf(mocker.proxy), mocker.builder.unsafe)mocker.isPatched = trueaddToGlobal(mocker)

mocker.outerCaller = tool.OuterCaller()return mocker}

// PatchValue replace the target function with a hook function, and stores the target function in the proxy function// for future restore. Target and hook are values of function. Proxy is a value of proxy function pointer.func PatchValue(target, hook, proxy reflect.Value, unsafe bool) *Patch {tool.Assert(hook.Kind() == reflect.Func, "'%s' is not a function", hook.Kind())tool.Assert(proxy.Kind() == reflect.Ptr, "'%v' is not a function pointer", proxy.Kind())tool.Assert(hook.Type() == target.Type(), "'%v' and '%s' mismatch", hook.Type(), target.Type())tool.Assert(proxy.Elem().Type() == target.Type(), "'*%v' and '%s' mismatch", proxy.Elem().Type(), target.Type())

targetAddr := target.Pointer()// The first few bytes of the target function codeconst bufSize = 64targetCodeBuf := common.BytesOf(targetAddr, bufSize)// construct the branch instruction, i.e. jump to the hook functionhookCode := inst.BranchInto(common.PtrAt(hook))// search the cutting point of the target code, i.e. the minimum length of full instructions that is longer than the hookCodecuttingIdx := inst.Disassemble(targetCodeBuf, len(hookCode), !unsafe)

// construct the proxy codeproxyCode := common.AllocatePage()// save the original code before the cutting pointcopy(proxyCode, targetCodeBuf[:cuttingIdx])// construct the branch instruction, i.e. jump to the cutting pointcopy(proxyCode[cuttingIdx:], inst.BranchTo(targetAddr+uintptr(cuttingIdx)))// inject the proxy code to the proxy functionfn.InjectInto(proxy, proxyCode)

tool.DebugPrintf("PatchValue: hook code len(%v), cuttingIdx(%v)\n", len(hookCode), cuttingIdx)

// replace target function codes before the cutting pointmem.WriteWithSTW(targetAddr, hookCode)

return &Patch{base: targetAddr, code: proxyCode, size: cuttingIdx}}

// WriteWithSTW copies data bytes to the target address and replaces the original bytes, during which it will stop the// world (only the current goroutine's P is running).func WriteWithSTW(target uintptr, data []byte) {common.StopTheWorld()defer common.StartTheWorld()err := Write(target, data)tool.Assert(err == nil, err)}

而 Write 函数的实现在 github.com/bytedance/mockey/internal/monkey/mem/write_linux.go,其代码如下:

package mem

import ("syscall""github.com/bytedance/mockey/internal/monkey/common")

func Write(target uintptr, data []byte) error {do_replace_code(target, common.PtrOf(data), uint64(len(data)), syscall.SYS_MPROTECT,syscall.PROT_READ|syscall.PROT_WRITE, syscall.PROT_READ|syscall.PROT_EXEC))return nil}

func do_replace_code(_ uintptr, // void *addr_ uintptr, // void *data_ uint64, // size_t size_ uint64, // int mprotect_ uint64, // int prot_rw_ uint64, // int prot_rx)

do_replace_code 函数的汇编实现在 github.com/bytedance/mockey/internal/monkey/mem/write_linux_amd64.s,代码如下:

#include "textflag.h"

#define NOP8 BYTE $0x90; BYTE $0x90; BYTE $0x90; BYTE $0x90; BYTE $0x90; BYTE $0x90; BYTE $0x90; BYTE $0x90;#define NOP64 NOP8; NOP8; NOP8; NOP8; NOP8; NOP8; NOP8; NOP8;#define NOP512 NOP64; NOP64; NOP64; NOP64; NOP64; NOP64; NOP64; NOP64;#define NOP4096 NOP512; NOP512; NOP512; NOP512; NOP512; NOP512; NOP512; NOP512;

#define addr arg + 0x00(FP)#define data arg + 0x08(FP)#define size arg + 0x10(FP)#define mprotect arg + 0x18(FP)#define prot_rw arg + 0x20(FP)#define prot_rx arg + 0x28(FP)

#define CMOVNEQ_AX_CX \BYTE $0x48 \BYTE $0x0f \BYTE $0x45 \BYTE $0xc8

TEXT ·do_replace_code(SB), NOSPLIT, $0x30 - 0JMP STARTNOP4096START:MOVQ addr, DIMOVQ size, SIMOVQ DI, AXANDQ $0x0fff, AXANDQ $~0x0fff, DIADDQ AX, SIMOVQ SI, CXANDQ $0x0fff, CXMOVQ $0x1000, AXSUBQ CX, AXTESTQ CX, CXCMOVNEQ_AX_CXADDQ CX, SIMOVQ DI, R8MOVQ SI, R9MOVQ mprotect , AXMOVQ prot_rw , DXSYSCALLMOVQ addr, DIMOVQ data, SIMOVQ size, CXREPMOVSBMOVQ R8, DIMOVQ R9, SIMOVQ mprotect , AXMOVQ prot_rx , DXSYSCALLJMP RETURNNOP4096RETURN:RET 5.3、 优化获取行号性能

笔者另一篇掘金文章 《golang文件行号探索》 中有详细说明,代码如下:

//stack_amd64.gotype Line uintptrfunc NewLine() Line

var rcuCache unsafe.Pointer = func() unsafe.Pointer {m := make(map[Line]string)return unsafe.Pointer(&m)}()

func (l Line) LineNO() (line string) {mPCs := *(*map[Line]string)(atomic.LoadPointer(&rcuCache))line, ok := mPCs[l]if !ok {file, n := runtime.FuncForPC(uintptr(l)).FileLine(uintptr(l))line = file + ":" + strconv.Itoa(n)mPCs2 := make(map[Line]string, len(mPCs)+10)mPCs2[l] = linefor {p := atomic.LoadPointer(&rcuCache)mPCs = *(*map[Line]string)(p)for k, v := range mPCs {mPCs2[k] = v}swapped := atomic.CompareAndSwapPointer(&rcuCache p, unsafe.Pointer(&mPCs2))if swapped {break}}}return} # stack_amd64.sTEXT ·NewLine(SB), NOSPLIT, $0-8MOVQ retpc-8(FP), AXSUBQ $1, AX // 注意,这里要 -1MOVQ AX, ret+0(FP)RET

该代码除了使用汇编获取行号外,还是用了无锁的 RCU(Read-copy update) 算法提升并发查询速度。还有一点要注意的,retpc-8(FP) 是函数返回地址,也就是调用指令 CALL 的下一行指令, 所以需要 -1 才能得到 CALL 指令的 pc,参考Go 源码 src/runtime/traceback.g 的这段注释:

// file/line information using pc-1, because that is the pc of the// call instruction (more precisely, the last byte of the call instruction).// Callers expect the pc buffer to contain return addresses and do the// same -1 themselves, so we keep pc unchanged.// When the pc is from a signal (e.g. profiler or segv) then we want// to look up file/line information using pc, and we store pc+1 in the// pc buffer so callers can unconditionally subtract 1 before looking up.// See issue 34123.// The pc can be at function entry when the frame is initialized without// actually running code, like runtime.mstart. 5.4、 优化获取调用栈性能

笔者另一篇掘金文章 《关于 golang 错误处理的一些优化想法》 中有详细说明。stack_amd64.go 代码:

//go:build amd64// +build amd64package errors

import (_ "unsafe")

func buildStack(s []uintptr) int

stack_amd64.s 代码:

//go:build amd64 || amd64p32 || arm64// +build amd64 amd64p32 arm64

#include "go_asm.h"#include "textflag.h"#include "funcdata.h"

// func buildStack(s []uintptr) intTEXT ·buildStack(SB), NOSPLIT, $24-8NO_LOCAL_POINTERSMOVQ cap+16(FP), DX // s.capMOVQ p+0(FP), AX // s.ptrMOVQ $0, CX // loop.iloop:MOVQ +8(BP), BX // last pc -> BXSUBQ $1, BXMOVQ BX, 0(AX)(CX*8) // s[i] = BX

ADDQ $1, CX // CX++ / i++CMPQ CX, DX // if s.len >= s.cap { return }JAE return // 无符号大于等于就跳转

MOVQ +0(BP), BP // last BP; 展开调用栈至上一层CMPQ BP, $0 // if (BP) len(s):return -1case n /阅读下一篇/ 返回网易首页 下载网易新闻客户端



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有