GO汇编-函数
<h1 id="go汇编-函数">GO汇编-函数</h1><h4 id="终于到函数了因为go汇编语言中可以也建议通过go语言来定义全局变量那么剩下的也就是函数了只有掌握了汇编函数的基本用法才能真正算是go汇编语言入门本章将简单讨论go汇编中函数的定义和用法">终于到函数了!因为Go汇编语言中,可以也建议通过Go语言来定义全局变量,那么剩下的也就是函数了。只有掌握了汇编函数的基本用法,才能真正算是Go汇编语言入门。本章将简单讨论Go汇编中函数的定义和用法。</h4>
<h2 id="基本语法">基本语法</h2>
<h4 id="函数标识符通过text汇编指令定义表示该行开始的指令定义在text内存段text语句后的指令一般对应函数的实现但是对于text指令本身来说并不关心后面是否有指令因此text和label定义的符号是类似的区别只是label是用于跳转标号但是本质上他们都是通过标识符映射一个内存地址">函数标识符通过<code>TEXT</code>汇编指令定义,表示该行开始的指令定义在<code>TEXT</code>内存段。<code>TEXT</code>语句后的指令一般对应函数的实现,但是对于<code>TEXT</code>指令本身来说并不关心后面是否有指令。因此<code>TEXT</code>和<code>LABEL</code>定义的符号是类似的,区别只是<code>LABEL</code>是用于跳转标号,但是本质上他们都是通过标识符映射一个内存地址。</h4>
<h4 id="函数的定义的语法如下">函数的定义的语法如下:</h4>
<pre><code class="language-s">TEXT symbol(SB), $framesize[-argsize]
</code></pre>
<h4 id="函数的定义部分由5个部分组成text指令函数名可选的flags标志函数帧大小和可选的函数参数大小">函数的定义部分由<code>5</code>个部分组成:<code>TEXT指令</code>、<code>函数名</code>、<code>可选的flags标志</code>、<code>函数帧大小</code>和<code>可选的函数参数大小</code>。</h4>
<h4 id="其中text用于定义函数符号函数名中当前包的路径可以省略函数的名字后面是sb表示是函数名符号相对于sb伪寄存器的偏移量二者组合在一起最终是绝对地址作为全局的标识符的全局变量和全局函数的名字一般都是基于sb伪寄存器的相对地址标志部分用于指示函数的一些特殊行为标志在textlagsh文件中定义常见的nosplit主要用于指示叶子函数不进行栈分裂framesize部分表示函数的局部变量需要多少栈空间其中包含调用其它函数时准备调用参数的隐式栈空间最后是可以省略的参数大小之所以可以省略是因为编译器可以从go语言的函数声明中推导出函数参数的大小">其中<code>TEXT</code>用于定义函数符号,函数名中当前包的路径可以省略。函数的名字后面是(SB),表示是函数名符号相对于<code>SB伪寄存器</code>的偏移量,二者组合在一起最终是绝对地址。作为全局的标识符的全局变量和全局函数的名字一般都是基于SB伪寄存器的相对地址。标志部分用于指示函数的一些特殊行为,标志在<code>textlags.h</code>文件中定义,常见的<code>NOSPLIT</code>主要用于指示叶子函数不进行栈分裂。<code>framesize</code>部分表示函数的局部变量需要多少栈空间,其中包含调用其它函数时准备调用参数的隐式栈空间。最后是可以省略的参数大小,之所以可以省略是因为编译器可以从Go语言的函数声明中推导出函数参数的大小。</h4>
<h4 id="我们首先从一个简单的swap函数开始swap函数用于交互输入的两个参数的顺序然后通过返回值返回交换了顺序的结果如果用go语言中声明swap函数大概这样的">我们首先从一个简单的Swap函数开始。Swap函数用于交互输入的两个参数的顺序,然后通过返回值返回交换了顺序的结果。如果用Go语言中声明Swap函数,大概这样的:</h4>
<pre><code class="language-go">package main
//go:nosplit
func Swap(a, b int) (int, int)
</code></pre>
<h4 id="下面是main包中swap函数在汇编中两种定义方式">下面是main包中Swap函数在汇编中两种定义方式:</h4>
<pre><code class="language-s">// func Swap(a, b int) (int, int)
TEXT ·Swap(SB), NOSPLIT, $0-32
// func Swap(a, b int) (int, int)
TEXT ·Swap(SB), NOSPLIT, $0
</code></pre>
<h4 id="下图是swap函数几种不同写法的对比关系图">下图是Swap函数几种不同写法的对比关系图:</h4>
<p><img src="https://img2020.cnblogs.com/blog/1585694/202006/1585694-20200602180521306-1401497722.png"></p>
<h4 id="第一种是最完整的写法函数名部分包含了当前包的路径同时指明了函数的参数大小为32个字节对应参数和返回值的4个int类型第二种写法则比较简洁省略了当前包的路径和参数的大小如果有nosplit标注会禁止汇编器为汇编函数插入栈分裂的代码nosplit对应go语言中的gonosplit注释">第一种是最完整的写法:函数名部分包含了当前包的路径,同时指明了函数的参数大小为32个字节(对应参数和返回值的4个int类型)。第二种写法则比较简洁,省略了当前包的路径和参数的大小。如果有NOSPLIT标注,会禁止汇编器为汇编函数插入栈分裂的代码。NOSPLIT对应Go语言中的//go:nosplit注释。</h4>
<h4 id="目前可能遇到的函数标志有nosplitwrapper和needctxt几个其中nosplit不会生成或包含栈分裂代码这一般用于没有任何其它函数调用的叶子函数这样可以适当提高性能wrapper标志则表示这个是一个包装函数在panic或runtimecaller等某些处理函数帧的地方不会增加函数帧计数最后的needctxt表示需要一个上下文参数一般用于闭包函数">目前可能遇到的函数标志有<code>NOSPLIT</code>、<code>WRAPPER</code>和<code>NEEDCTXT</code>几个。其中<code>NOSPLIT</code>不会生成或包含栈分裂代码,这一般用于没有任何其它函数调用的叶子函数,这样可以适当提高性能。<code>WRAPPER</code>标志则表示这个是一个包装函数,在panic或runtime.caller等某些处理函数帧的地方不会增加函数帧计数。最后的<code>NEEDCTXT</code>表示需要一个上下文参数,一般用于闭包函数。</h4>
<h4 id="需要注意的是函数也没有类型上面定义的swap函数签名可以下面任意一种格式">需要注意的是函数也没有类型,上面定义的Swap函数签名可以下面任意一种格式:</h4>
<pre><code class="language-go">func Swap(a, b, c int) int
func Swap(a, b, c, d int)
func Swap() (a, b, c, d int)
func Swap() (a []int, d int)
// ...
</code></pre>
<h4 id="对于汇编函数来说只要是函数的名字和参数大小一致就可以是相同的函数了而且在go汇编语言中输入参数和返回值参数是没有任何的区别的">对于汇编函数来说,只要是函数的名字和参数大小一致就可以是相同的函数了。而且在Go汇编语言中,输入参数和返回值参数是没有任何的区别的。</h4>
<h2 id="函数参数和返回值">函数参数和返回值</h2>
<h4 id="对于函数来说最重要的是函数对外提供的api约定包含函数的名称参数和返回值当这些都确定之后如何精确计算参数和返回值的大小是第一个需要解决的问题">对于函数来说,最重要的是函数对外提供的API约定,包含函数的名称、参数和返回值。当这些都确定之后,如何精确计算参数和返回值的大小是第一个需要解决的问题。</h4>
<h4 id="比如有一个swap函数的签名如下">比如有一个Swap函数的签名如下:</h4>
<pre><code class="language-go">func Swap(a, b int) (ret0, ret1 int)
</code></pre>
<h4 id="对于这个函数我们可以轻易看出它需要4个int类型的空间参数和返回值的大小也就是32个字节">对于这个函数,我们可以轻易看出它需要4个int类型的空间,参数和返回值的大小也就是32个字节:</h4>
<pre><code class="language-s">TEXT ·Swap(SB), $0-32
</code></pre>
<h4 id="那么如何在汇编中引用这4个参数呢为此go汇编中引入了一个fp伪寄存器表示函数当前帧的地址也就是第一个参数的地址因此我们以通过0fp8fp16fp和24fp来分别引用abret0和ret1四个参数">那么如何在汇编中引用这4个参数呢?为此Go汇编中引入了一个FP伪寄存器,表示函数当前帧的地址,也就是第一个参数的地址。因此我们以通过<code>+0(FP)</code>、<code>+8(FP)</code>、<code>+16(FP)</code>和<code>+24(FP)</code>来分别引用<code>a、b、ret0和ret1</code>四个参数。</h4>
<h4 id="但是在汇编代码中我们并不能直接以0fp的方式来使用参数为了编写易于维护的汇编代码go汇编语言要求任何通过fp伪寄存器访问的变量必和一个临时标识符前缀组合后才能有效一般使用参数对应的变量名作为前缀">但是在汇编代码中,我们并<code>不能直接以+0(FP)</code>的方式来使用参数。为了编写易于维护的汇编代码,Go汇编语言要求,<code>任何通过FP伪寄存器访问的变量必和一个临时标识符前缀组合后才能有效,一般使用参数对应的变量名作为前缀</code>。</h4>
<h4 id="下图是swap函数中参数和返回值在内存中的布局图">下图是Swap函数中参数和返回值在内存中的布局图:</h4>
<p><img src="https://img2020.cnblogs.com/blog/1585694/202006/1585694-20200602181113870-1804343440.png"></p>
<h4 id="下面的代码演示了如何在汇编函数中使用参数和返回值">下面的代码演示了如何在汇编函数中使用参数和返回值:</h4>
<pre><code class="language-s">TEXT ·Swap(SB), $0
MOVQ a+0(FP), AX // AX = a
MOVQ b+8(FP), BX // BX = b
MOVQ BX, ret0+16(FP) // ret0 = BX
MOVQ AX, ret1+24(FP) // ret1 = AX
RET
</code></pre>
<h4 id="从代码可以看出abret0和ret1的内存地址是依次递增的fp伪寄存器是第一个变量的开始地址">从代码可以看出a、b、ret0和ret1的内存地址是<code>依次递增</code>的,F<code>P伪寄存器是第一个变量的开始地址</code>。</h4>
<h2 id="参数和返回值的内存布局">参数和返回值的内存布局</h2>
<h4 id="如果是参数和返回值类型比较复杂的情况该如何处理呢下面我们再尝试一个更复杂的函数参数和返回值的计算比如有以下一个函数">如果是参数和返回值类型比较复杂的情况该如何处理呢?下面我们再尝试一个更复杂的函数参数和返回值的计算。比如有以下一个函数:</h4>
<pre><code class="language-go">func Foo(a bool, b int16) (c []byte)
</code></pre>
<h4 id="函数的参数有不同的类型而且返回值中含有更复杂的切片类型我们该如何计算每个参数的位置和总的大小呢">函数的参数有不同的类型,而且返回值中含有更复杂的切片类型。我们该如何计算每个参数的位置和总的大小呢?</h4>
<h4 id="其实函数参数和返回值的大小以及对齐问题和结构体的大小和成员对齐问题是一致的函数的第一个参数和第一个返回值会分别进行一次地址对齐我们可以用诡代思路将全部的参数和返回值以同样的顺序分别放到两个结构体中将fp伪寄存器作为唯一的一个指针参数而每个成员的地址也就是对应原来参数的地址">其实<code>函数参数和返回值的大小以及对齐问题</code>和<code>结构体的大小和成员对齐问题</code>是<code>一致</code>的,函数的<code>第一个参数</code>和<code>第一个返回值</code>会<code>分别进行一次地址对齐</code>。我们可以用诡代思路将全部的参数和返回值以同样的顺序分别放到两个结构体中,将FP伪寄存器作为唯一的一个指针参数,而每个成员的地址也就是对应原来参数的地址。</h4>
<h4 id="用这样的策略可以很容易计算前面的foo函数的参数和返回值的地址和总大小为了便于描述我们定义一个foo_args_and_returns临时结构体类型用于诡代原始的参数和返回值">用这样的策略可以很容易计算前面的Foo函数的参数和返回值的地址和总大小。为了便于描述我们定义一个<code>Foo_args_and_returns</code>临时结构体类型用于诡代原始的参数和返回值:</h4>
<pre><code class="language-go">type Foo_args struct {
a bool
b int16
c []byte
}
type Foo_returns struct {
c []byte
}
</code></pre>
<h4 id="然后将foo原来的参数替换为结构体形式并且只保留唯一的fp作为参数">然后将Foo原来的参数替换为结构体形式,并且只保留唯一的FP作为参数:</h4>
<pre><code class="language-s">func Foo(FP *SomeFunc_args, FP_ret *SomeFunc_returns) {
// a = FP + offsetof(&args.a)
_ = unsafe.Offsetof(FP.a) + uintptr(FP) // a
// b = FP + offsetof(&args.b)
// argsize = sizeof(args)
argsize = unsafe.Offsetof(FP)
// c = FP + argsize + offsetof(&return.c)
_ = uintptr(FP) + argsize + unsafe.Offsetof(FP_ret.c)
// framesize = sizeof(args) + sizeof(returns)
_ = unsafe.Offsetof(FP) + unsafe.Offsetof(FP_ret)
return
}
</code></pre>
<h4 id="代码完全和foo函数参数的方式类似唯一的差异是每个函数的偏移量通过unsafeoffsetof函数自动计算生成因为go结构体中的每个成员已经满足了对齐要求因此采用通用方式得到每个参数的偏移量也是满足对齐要求的序言注意的是第一个返回值地址需要重新对齐机器字大小的倍数">代码完全和Foo函数参数的方式类似。唯一的差异是每个函数的偏移量,通过<code>unsafe.Offsetof</code>函数自动计算生成。因为Go结构体中的每个成员已经满足了对齐要求,因此采用通用方式得到每个参数的偏移量也是满足对齐要求的。序言注意的是第一个返回值地址需要重新对齐机器字大小的倍数。</h4>
<h4 id="foo函数的参数和返回值的大小和内存布局">Foo函数的参数和返回值的大小和内存布局:</h4>
<p><img src="https://img2020.cnblogs.com/blog/1585694/202006/1585694-20200602182039811-1255803898.png"></p>
<h4 id="下面的代码演示了foo汇编函数参数和返回值的定位">下面的代码演示了Foo汇编函数参数和返回值的定位:</h4>
<pre><code class="language-s">TEXT ·Foo(SB), $0
MOVEQ a+0(FP), AX // a
MOVEQ b+2(FP), BX // b
MOVEQ c_dat+8*1(FP), CX // c.Data
MOVEQ c_len+8*2(FP), DX // c.Len
MOVEQ c_cap+8*3(FP), DI // c.Cap
RET
</code></pre>
<h4 id="其中a和b参数之间出现了一个字节的空洞b和c之间出现了4个字节的空洞出现空洞的原因是要保证每个参数变量地址都要对齐到相应的倍数">其中a和b参数之间出现了一个字节的空洞,b和c之间出现了4个字节的空洞。出现空洞的原因是要保证每个参数变量地址都要对齐到相应的倍数。</h4>
<h4 id="内存对齐-httpswwwbilibilicomvideobv1ja4y1i7affromsearchseid14846072930915436056">内存对齐 https://www.bilibili.com/video/BV1Ja4y1i7AF?from=search&seid=14846072930915436056</h4>
<h2 id="函数中的局部变量">函数中的局部变量</h2>
<h4 id="从go语言函数角度讲局部变量是函数内明确定义的变量同时也包含函数的参数和返回值变量但是从go汇编角度看局部变量是指函数运行时在当前函数栈帧所对应的内存内的变量不包含函数的参数和返回值因为访问方式有差异函数栈帧的空间主要由函数参数和返回值局部变量和被调用其它函数的参数和返回值空间组成为了便于理解我们可以将汇编函数的局部变量类比为go语言函数中显式定义的变量不包含参数和返回值部分">从Go语言函数角度讲,局部变量是函数内明确定义的变量,同时也包含函数的参数和返回值变量。但是从Go汇编角度看,局部变量是指函数运行时,在当前函数栈帧所对应的内存内的变量,不包含函数的参数和返回值(因为访问方式有差异)。函数栈帧的空间主要由函数参数和返回值、局部变量和被调用其它函数的参数和返回值空间组成。为了便于理解,我们可以将汇编函数的局部变量类比为Go语言函数中显式定义的变量,不包含参数和返回值部分。</h4>
<h4 id="为了便于访问局部变量go汇编语言引入了伪sp寄存器对应当前栈帧的底部因为在当前栈帧时栈的底部是固定不变的因此局部变量的相对于伪sp的偏移量也就是固定的这可以简化局部变量的维护工作sp真伪寄存器的区分只有一个原则如果使用sp时有一个临时标识符前缀就是伪sp否则就是真sp寄存器比如asp和b8sp有a和b临时前缀这里都是伪sp而前缀部分一般用于表示局部变量的名字而sp和8sp没有临时标识符作为前缀它们都是真sp寄存器">为了便于访问局部变量,Go汇编语言引入了<code>伪SP寄存器,对应当前栈帧的底部</code>。<code>因为在当前栈帧时栈的底部是固定不变的</code>,因此局部变量的相对于伪SP的偏移量也就是固定的,这可以简化局部变量的维护工作。SP真伪寄存器的区分只有一个原则:<code>如果使用SP时有一个临时标识符前缀就是伪SP,否则就是真SP寄存器</code>。比如a(SP)和b+8(SP)有a和b临时前缀,这里都是伪SP,而前缀部分一般用于表示局部变量的名字。而(SP)和+8(SP)没有临时标识符作为前缀,它们都是真SP寄存器。</h4>
<h4 id="在x86平台函数的调用栈是从高地址向低地址增长的因此伪sp寄存器对应栈帧的底部其实是对应更大的地址当前栈的顶部对应真实存在的sp寄存器对应当前函数栈帧的栈顶对应更小的地址如果整个内存用memory数组表示那么memory0spend-0sp就是对应当前栈帧的切片其中开始位置是真sp寄存器结尾部分是伪sp寄存器真sp寄存器一般用于表示调用其它函数时的参数和返回值真sp寄存器对应内存较低的地址所以被访问变量的偏移量是正数而伪sp寄存器对应高地址对应的局部变量的偏移量都是负数">在X86平台,<code>函数的调用栈是从高地址向低地址增长的,因此伪SP寄存器对应栈帧的底部其实是对应更大的地址</code>。当前栈的顶部对应真实存在的SP寄存器,对应当前函数栈帧的栈顶,对应更小的地址。如果整个内存用Memory数组表示,那么Memory就是对应当前栈帧的切片,其中开始位置是真SP寄存器,结尾部分是伪SP寄存器。真SP寄存器一般用于表示调用其它函数时的参数和返回值,真SP寄存器对应内存较低的地址,所以被访问变量的偏移量是正数;而伪SP寄存器对应高地址,对应的局部变量的偏移量都是负数。</h4>
<h4 id="为了便于对比我们将前面foo函数的参数和返回值变量改成局部变量">为了便于对比,我们将前面Foo函数的参数和返回值变量改成局部变量:</h4>
<pre><code class="language-go">func Foo() {
var c []byte
var b int16
var a bool
}
</code></pre>
<h4 id="然后通过汇编语言重新实现foo函数并通过伪sp来定位局部变量">然后通过汇编语言重新实现Foo函数,并通过伪SP来定位局部变量:</h4>
<pre><code class="language-s">TEXT ·Foo(SB), $32-0
MOVQ a-32(SP), AX // a
MOVQ b-30(SP), BX // b
MOVQ c_data-24(SP), CX // c.Data
MOVQ c_len-16(SP),DX // c.Len
MOVQ c_cap-8(SP), DI // c.Cap
RET
</code></pre>
<h4 id="foo函数有3个局部变量但是没有调用其它的函数因为对齐和填充的问题导致函数的栈帧大小为32个字节因为foo函数没有参数和返回值因此参数和返回值大小为0个字节当然这个部分可以省略不写而局部变量中先定义的变量c离伪sp寄存器对应的地址最近最后定义的变量a离伪sp寄存器最远有两个因素导致出现这种逆序的结果一个从go语言函数角度理解先定义的c变量地址要比后定义的变量的地址更大另一个是伪sp寄存器对应栈帧的底部而x86中栈是从高向低生长的所以最先定义有着更大地址的c变量离栈的底部伪sp更近">Foo函数有3个局部变量,但是没有调用其它的函数,因为对齐和填充的问题导致函数的栈帧大小为32个字节。因为Foo函数没有参数和返回值,因此参数和返回值大小为0个字节,当然这个部分可以省略不写。而局部变量中先定义的变量c离伪SP寄存器对应的地址最近,最后定义的变量a离伪SP寄存器最远。有两个因素导致出现这种逆序的结果:一个从Go语言函数角度理解,先定义的c变量地址要比后定义的变量的地址更大;另一个是伪SP寄存器对应栈帧的底部,而X86中栈是从高向低生长的,所以最先定义有着更大地址的c变量离栈的底部伪SP更近。</h4>
<h4 id="我们同样可以通过结构体来模拟局部变量的布局">我们同样可以通过结构体来模拟局部变量的布局:</h4>
<pre><code class="language-go">func Foo() {
var local struct{
a bool
b int16
c []byte
}
var SP = &local;
_ = -(unsafe.Sizeof(local)-unsafe.Offsetof(local.a)) + uintptr(&SP) // a
_ = -(unsafe.Sizeof(local)-unsafe.Offsetof(local.b)) + uintptr(&SP) // b
_ = -(unsafe.Sizeof(local)-unsafe.Offsetof(local.c)) + uintptr(&SP) // c
}
</code></pre>
<h4 id="我们将之前的三个局部变量挪到一个结构体中然后构造一个sp变量对应伪sp寄存器对应局部变量结构体的顶部然后根据局部变量总大小和每个变量对应成员的偏移量计算相对于伪sp的距离最终偏移量是一个负数">我们将之前的三个局部变量挪到一个结构体中。然后构造一个SP变量对应伪SP寄存器,对应局部变量结构体的顶部。然后根据局部变量总大小和每个变量对应成员的偏移量计算相对于伪SP的距离,最终偏移量是一个负数。</h4>
<h4 id="通过这种方式可以处理复杂的局部变量的偏移同时也能保证每个变量地址的对齐要求当然除了地址对齐外局部变量的布局并没有顺序要求对于汇编比较熟悉同学可以根据自己的习惯组织变量的布局">通过这种方式可以处理复杂的局部变量的偏移,同时也能保证每个变量地址的对齐要求。当然,除了地址对齐外,局部变量的布局并没有顺序要求。对于汇编比较熟悉同学可以根据自己的习惯组织变量的布局。</h4>
<h4 id="下面是foo函数的局部变量的大小和内存布局">下面是Foo函数的局部变量的大小和内存布局:</h4>
<p><img src="https://img2020.cnblogs.com/blog/1585694/202006/1585694-20200602214801597-580080014.png"></p>
<h4 id="从图中可以看出foo函数局部变量和前一个例子中参数和返回值的内存布局是完全一样的这也是我们故意设计的结果但是参数和返回值是通过伪fp寄存器定位的fp寄存器对应第一个参数的开始地址第一个参数地址较低因此每个变量的偏移量是正数而局部变量是通过伪sp寄存器定位的而伪sp寄存器对应的是第一个局部变量的结束地址第一个局部变量地址较大因此每个局部变量的偏移量都是负数">从图中可以看出Foo函数局部变量和前一个例子中参数和返回值的内存布局是完全一样的,这也是我们故意设计的结果。但是参数和返回值是通过<code>伪FP寄存器定位的</code>,<code>FP寄存器对应第一个参数的开始地址(第一个参数地址较低)</code>,因此每个变量的偏移量是正数。而<code>局部变量是通过伪SP寄存器定位的</code>,<code>而伪SP寄存器对应的是第一个局部变量的结束地址(第一个局部变量地址较大)</code>,因此每个局部变量的偏移量都是负数。</h4>
<h2 id="调用其它函数">调用其它函数</h2>
<h4 id="常见的用go汇编实现的函数都是叶子函数也就是被其它函数调用的函数但是很少调用其它函数这主要是因为叶子函数比较简单可以简化汇编函数的编写同时一般性能或特性的瓶颈也处于叶子函数但是能够调用其它函数和能够被其它函数调用同样重要否则go汇编就不是一个完整的汇编语言">常见的用Go汇编实现的函数都是叶子函数,也就是被其它函数调用的函数,但是很少调用其它函数。这主要是因为叶子函数比较简单,可以简化汇编函数的编写;同时一般性能或特性的瓶颈也处于叶子函数。但是能够调用其它函数和能够被其它函数调用同样重要,否则Go汇编就不是一个完整的汇编语言。</h4>
<h4 id="在前文中我们已经学习了一些汇编实现的函数参数和返回值处理的规则那么一个显然的问题是汇编函数的参数是从哪里来的答案同样明显被调用函数的参数是由调用方准备的调用方在栈上设置好空间和数据后调用函数被调用方在返回前将返回值放在对应的位置函数通过ret指令返回调用方函数之后调用方再从返回值对应的栈内存位置取出结果go语言函数的调用参数和返回值均是通过栈传输的这样做的优点是函数调用栈比较清晰缺点是函数调用有一定的性能损耗go编译器是通过函数内联来缓解这个问题的影响">在前文中我们已经学习了一些汇编实现的函数参数和返回值处理的规则。那么一个显然的问题是,汇编函数的参数是从哪里来的?答案同样明显,被调用函数的参数是由调用方准备的:调用方在栈上设置好空间和数据后调用函数,被调用方在返回前将返回值放在对应的位置,函数通过RET指令返回调用方函数之后,调用方再从返回值对应的栈内存位置取出结果。Go语言函数的调用参数和返回值均是通过栈传输的,这样做的优点是函数调用栈比较清晰,缺点是函数调用有一定的性能损耗(Go编译器是通过函数内联来缓解这个问题的影响)。</h4>
<h4 id="为了便于展示我们先使用go语言来构造三个逐级调用的函数">为了便于展示,我们先使用Go语言来构造三个逐级调用的函数:</h4>
<pre><code class="language-go">func main() {
printsum(1, 2)
}
func printsum(a, b int) {
var ret = sum(a, b)
println(ret)
}
func sum(a, b int) int {
return a+b
}
</code></pre>
<h4 id="其中main函数通过字面值常量直接调用printsum函数printsum函数输出两个整数的和而printsum函数内部又通过调用sum函数计算两个数的和并最终调用打印函数进行输出因为printsum既是被调用函数又是调用函数所以它是我们要重点分析的函数">其中main函数通过字面值常量直接调用<code>printsum</code>函数,<code>printsum</code>函数输出两个整数的和。而<code>printsum</code>函数内部又通过调用<code>sum</code>函数计算两个数的和,并最终调用打印函数进行输出。因为<code>printsum</code>既是被调用函数又是调用函数,所以它是我们要重点分析的函数。</h4>
<h4 id="下图展示了三个函数逐级调用时内存中函数参数和返回值的布局">下图展示了三个函数逐级调用时内存中函数参数和返回值的布局:</h4>
<p><img src="https://img2020.cnblogs.com/blog/1585694/202006/1585694-20200602220507454-752118808.png"></p>
<h4 id="为了便于理解我们对真实的内存布局进行了简化要记住的是调用函数时被调用函数的参数和返回值内存空间都必须由调用者提供因此函数的局部变量和为调用其它函数准备的栈空间总和就确定了函数帧的大小调用其它函数前调用方要选择保存相关寄存器到栈中并在调用函数返回后选择要恢复的寄存器进行保存最终通过call指令调用函数的过程和调用我们熟悉的调用println函数输出的过程类似">为了便于理解,我们对真实的内存布局进行了简化。要记住的是调用函数时,被调用函数的参数和返回值内存空间都必须由调用者提供。因此函数的局部变量和为调用其它函数准备的栈空间总和就确定了函数帧的大小。调用其它函数前调用方要选择保存相关寄存器到栈中,并在调用函数返回后选择要恢复的寄存器进行保存。最终通过<code>CALL</code>指令调用函数的过程和调用我们熟悉的调用<code>println</code>函数输出的过程类似。</h4>
<h4 id="go语言中函数调用是一个复杂的问题因为go函数不仅仅要了解函数调用参数的布局还会涉及到栈的跳转栈上局部变量的生命周期管理本节只是简单了解函数调用参数的布局规则在后续的章节中会更详细的讨论函数的细节">Go语言中函数调用是一个复杂的问题,因为Go函数不仅仅要了解函数调用参数的布局,还会涉及到栈的跳转,栈上局部变量的生命周期管理。本节只是简单了解函数调用参数的布局规则,在后续的章节中会更详细的讨论函数的细节。</h4>
<h2 id="宏函数">宏函数</h2>
<h4 id="宏函数并不是go汇编语言所定义而是go汇编引入的预处理特性自带的特性">宏函数并不是Go汇编语言所定义,而是Go汇编引入的预处理特性自带的特性。</h4>
<h4 id="在c语言中我们可以通过带参数的宏定义一个交换2个数的宏函数">在C语言中我们可以通过带参数的宏定义一个交换2个数的宏函数:</h4>
<pre><code class="language-c">#define SWAP(x, y) do{ int t = x; x = y; y = t; }while(0)
</code></pre>
<h4 id="我们可以用类似的方式定义一个交换两个寄存器的宏">我们可以用类似的方式定义一个交换两个寄存器的宏:</h4>
<pre><code class="language-c">#define SWAP(x, y, t) MOVQ x, t; MOVQ y, x; MOVQ t, y
</code></pre>
<h4 id="因为汇编语言中无法定义临时变量我们增加一个参数用于临时寄存器下面是通过swap宏函数交换ax和bx寄存器的值然后返回结果">因为汇编语言中无法定义临时变量,我们增加一个参数用于临时寄存器。下面是通过SWAP宏函数交换AX和BX寄存器的值,然后返回结果:</h4>
<pre><code class="language-s">// func Swap(a, b int) (int, int)
TEXT ·Swap(SB), $0-32
MOVQ a+0(FP), AX // AX = a
MOVQ b+8(FP), BX // BX = b
SWAP(AX, BX, CX) // AX, BX = b, a
MOVQ AX, ret0+16(FP) // return
MOVQ BX, ret1+24(FP) //
RET
</code></pre>
<h4 id="因为预处理器可以通过条件编译针对不同的平台定义宏的实现这样可以简化平台带来的差异">因为预处理器可以通过条件编译针对不同的平台定义宏的实现,这样可以简化平台带来的差异。</h4>
<h1 id="函数进阶">函数进阶</h1>
<h2 id="函数调用规范">函数调用规范</h2>
<h4 id="在go汇编语言中call指令用于调用函数ret指令用于从调用函数返回但是call和ret指令并没有处理函数调用时输入参数和返回值的问题call指令类似push-ip和jmp-somefunc两个指令的组合首先将当前的ip指令寄存器的值压入栈中然后通过jmp指令将要调用函数的地址写入到ip寄存器实现跳转而ret指令则是和call相反的操作基本和pop-ip指令等价也就是将执行call指令时保存在sp中的返回地址重新载入到ip寄存器实现函数的返回">在Go汇编语言中<code>CALL</code>指令用于调用函数,<code>RET</code>指令用于从调用函数返回。但是CALL和RET指令并没有处理函数调用时输入参数和返回值的问题。<code>CALL指令类似PUSH IP和JMP somefunc两个指令的组合</code>,首先将当前的IP指令寄存器的值压入栈中,然后通过JMP指令将要调用函数的地址写入到IP寄存器实现跳转。而<code>RET指令则是和CALL相反的操作,基本和POP IP指令等价</code>,也就是将执行CALL指令时保存在SP中的返回地址重新载入到IP寄存器,实现函数的返回。</h4>
<h4 id="和c语言函数不同go语言函数的参数和返回值完全通过栈传递下面是go函数调用时栈的布局图">和C语言函数不同,Go语言函数的参数和返回值完全通过栈传递。下面是Go函数调用时栈的布局图:</h4>
<p><img src="https://img2020.cnblogs.com/blog/1585694/202006/1585694-20200604114351594-2062454901.png"></p>
<h4 id="首先是调用函数前准备的输入参数和返回值空间然后call指令将首先触发返回地址入栈操作在进入到被调用函数内之后汇编器自动插入了bp寄存器相关的指令因此bp寄存器和返回地址是紧挨着的再下面就是当前函数的局部变量的空间包含再次调用其它函数需要准备的调用参数空间被调用的函数执行ret返回指令时先从栈恢复bp和sp寄存器接着取出的返回地址跳转到对应的指令执行">首先是调用函数前准备的输入参数和返回值空间。然后CALL指令将首先触发返回地址入栈操作。在进入到被调用函数内之后,汇编器自动插入了BP寄存器相关的指令,因此BP寄存器和返回地址是紧挨着的。再下面就是当前函数的局部变量的空间,包含再次调用其它函数需要准备的调用参数空间。被调用的函数执行RET返回指令时,先从栈恢复BP和SP寄存器,接着取出的返回地址跳转到对应的指令执行。</h4>
<h2 id="高级汇编语言">高级汇编语言</h2>
<h4 id="go汇编语言其实是一种高级的汇编语言在这里高级一词并没有任何褒义或贬义的色彩而是要强调go汇编代码和最终真实执行的代码并不完全等价go汇编语言中一个指令在最终的目标代码中可能会被编译为其它等价的机器指令go汇编实现的函数或调用函数的指令在最终代码中也会被插入额外的指令要彻底理解go汇编语言就需要彻底了解汇编器到底插入了哪些指令">Go汇编语言其实是一种高级的汇编语言。在这里高级一词并没有任何褒义或贬义的色彩,而是要强调Go汇编代码和最终真实执行的代码并不完全等价。Go汇编语言中一个指令在最终的目标代码中可能会被编译为其它等价的机器指令。Go汇编实现的函数或调用函数的指令在最终代码中也会被插入额外的指令。要彻底理解Go汇编语言就需要彻底了解汇编器到底插入了哪些指令。</h4>
<h4 id="为了便于分析我们先构造一个禁止栈分裂的printnl函数printnl函数内部都通过调用runtimeprintnl函数输出换行">为了便于分析,我们先构造一个禁止栈分裂的printnl函数。printnl函数内部都通过调用runtime.printnl函数输出换行:</h4>
<pre><code class="language-s">TEXT ·printnl_nosplit(SB), NOSPLIT, $8
CALL runtime·printnl(SB)
RET
</code></pre>
<h4 id="然后通过go-tool-asm--s-main_amd64s指令查看编译后的目标代码">然后通过<code>go tool asm -S main_amd64.s</code>指令查看编译后的目标代码:</h4>
<pre><code class="language-s">"".printnl_nosplit STEXT nosplit size=29 args=0xffffffff80000000 locals=0x10
0x0000 00000 (main_amd64.s:5) TEXT "".printnl_nosplit(SB), NOSPLIT $16
0x0000 00000 (main_amd64.s:5) SUBQ $16, SP
0x0004 00004 (main_amd64.s:5) MOVQ BP, 8(SP)
0x0009 00009 (main_amd64.s:5) LEAQ 8(SP), BP
0x000e 00014 (main_amd64.s:6) CALL runtime.printnl(SB)
0x0013 00019 (main_amd64.s:7) MOVQ 8(SP), BP
0x0018 00024 (main_amd64.s:7) ADDQ $16, SP
0x001c 00028 (main_amd64.s:7) RET
</code></pre>
<h4 id="输出代码中我们删除了非指令的部分为了便于讲述我们将上述代码重新排版并根据缩进表示相关的功能">输出代码中我们删除了非指令的部分。为了便于讲述,我们将上述代码重新排版,并根据缩进表示相关的功能:</h4>
<pre><code class="language-s">TEXT "".printnl(SB), NOSPLIT, $16
SUBQ $16, SP
MOVQ BP, 8(SP)
LEAQ 8(SP), BP
CALL runtime.printnl(SB)
MOVQ 8(SP), BP
ADDQ $16, SP
RET
</code></pre>
<h4 id="第一层是text指令表示函数开始到ret指令表示函数返回第二层是subq-16-sp指令为当前函数帧分配16字节的空间在函数返回前通过addq-16-sp指令回收16字节的栈空间我们谨慎猜测在第二层是为函数多分配了8个字节的空间那么为何要多分配8个字节的空间呢再继续查看第三层的指令开始部分有两个指令movq-bp-8sp和leaq-8sp-bp首先是将bp寄存器保持到多分配的8字节栈空间然后将8sp地址重新保持到了bp寄存器中结束部分是movq-8sp-bp指令则是从栈中恢复之前备份的前bp寄存器的值最里面第四次层才是我们写的代码调用runtimeprintnl函数输出换行">第一层是<code>TEXT</code>指令表示函数开始,到RET指令表示函数返回。第二层是<code>SUBQ $16, SP</code>指令为当前函数帧分配16字节的空间,在函数返回前通过<code>ADDQ $16, SP</code>指令回收16字节的栈空间。我们谨慎猜测在第二层是为函数多分配了8个字节的空间。那么为何要多分配8个字节的空间呢?再继续查看第三层的指令:开始部分有两个指令<code>MOVQ BP, 8(SP)和LEAQ 8(SP), BP,</code>首先是将BP寄存器保持到多分配的8字节栈空间,然后将8(SP)地址重新保持到了BP寄存器中;结束部分是<code>MOVQ 8(SP)</code>, BP指令则是从栈中恢复之前备份的前BP寄存器的值。最里面第四次层才是我们写的代码,调用<code>runtime.printnl</code>函数输出换行。</h4>
<h4 id="如果去掉nospilt标志再重新查看生成的目标代码会发现在函数的开头和结尾的地方又增加了新的指令下面是经过缩进格式化的结果">如果去掉NOSPILT标志,再重新查看生成的目标代码,会发现在函数的开头和结尾的地方又增加了新的指令。下面是经过缩进格式化的结果:</h4>
<pre><code class="language-s">TEXT "".printnl_nosplit(SB), $16
L_BEGIN:
MOVQ (TLS), CX
CMPQ SP, 16(CX)
JLSL_MORE_STK
SUBQ $16, SP
MOVQ BP, 8(SP)
LEAQ 8(SP), BP
CALL runtime.printnl(SB)
MOVQ 8(SP), BP
ADDQ $16, SP
L_MORE_STK:
CALL runtime.morestack_noctxt(SB)
JMPL_BEGIN
RET
</code></pre>
<h4 id="其中开头有三个新指令movq-tls-cx用于加载g结构体指针然后第二个指令cmpq-sp-16cxsp栈指针和g结构体中stackguard0成员比较如果比较的结果小于0则跳转到结尾的l_more_stk部分当获取到更多栈空间之后通过jmp-l_begin指令跳转到函数的开始位置重新进行栈空间的检测">其中开头有三个新指令,<code>MOVQ (TLS), CX</code>用于加载g结构体指针,然后第二个指令<code>CMPQ SP, 16(CX)SP</code>栈指针和g结构体中stackguard0成员比较,如果比较的结果小于0则跳转到结尾的<code>L_MORE_STK</code>部分。当获取到更多栈空间之后,通过<code>JMP L_BEGIN</code>指令跳转到函数的开始位置重新进行栈空间的检测。</h4>
<h4 id="g结构体在gorootsrcruntimeruntime2go文件定义开头的结构成员如下">g结构体在<code>$GOROOT/src/runtime/runtime2.go</code>文件定义,开头的结构成员如下:</h4>
<pre><code class="language-s">type g struct {
// Stack parameters.
stack stack // offset known to runtime/cgo
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
...
}
</code></pre>
<h4 id="第一个成员是stack类型表示当前栈的开始和结束地址stack的定义如下">第一个成员是stack类型,表示当前栈的开始和结束地址。stack的定义如下:</h4>
<pre><code class="language-s">// Stack describes a Go execution stack.
// The bounds of the stack are exactly [lo, hi),
// with no implicit data structures on either side.
type stack struct {
lo uintptr
hi uintptr
}
</code></pre>
<h4 id="在g结构体中的stackguard0成员是出现爆栈前的警戒线stackguard0的偏移量是16个字节因此上述代码中的cmpq-sp-16ax表示将当前的真实sp和爆栈警戒线比较如果超出警戒线则表示需要进行栈扩容也就是跳转到l_more_stk在l_more_stk标号处先调用runtimemorestack_noctxt进行栈扩容然后又跳回到函数的开始位置此时此刻函数的栈已经调整了然后再进行一次栈大小的检测如果依然不足则继续扩容直到栈足够大为止">在g结构体中的stackguard0成员是出现爆栈前的警戒线。stackguard0的偏移量是16个字节,因此上述代码中的<code>CMPQ SP, 16(AX)</code>表示将当前的真实SP和爆栈警戒线比较,如果超出警戒线则表示需要进行栈扩容,也就是跳转到<code>L_MORE_STK</code>。在<code>L_MORE_STK</code>标号处,先调用<code>runtime·morestack_noctxt</code>进行栈扩容,然后又跳回到函数的开始位置,此时此刻函数的栈已经调整了。然后再进行一次栈大小的检测,如果依然不足则继续扩容,直到栈足够大为止。</h4>
<h4 id="以上是栈的扩容但是栈的收缩是在何时处理的呢我们知道go运行时会定期进行垃圾回收操作这其中包含栈的回收工作如果栈使用到比例小于一定到阈值则分配一个较小到栈空间然后将栈上面到数据移动到新的栈中栈移动的过程和栈扩容的过程类似">以上是栈的扩容,但是栈的收缩是在何时处理的呢?我们知道Go运行时会定期进行垃圾回收操作,这其中包含栈的回收工作。如果栈使用到比例小于一定到阈值,则分配一个较小到栈空间,然后将栈上面到数据移动到新的栈中,栈移动的过程和栈扩容的过程类似。</h4>
<h2 id="pcdata和funcdata">PCDATA和FUNCDATA</h2>
<h4 id="go语言中有个runtimecaller函数可以获取当前函数的调用者列表我们可以非常容易在运行时定位每个函数的调用位置以及函数的调用链因此在panic异常或用log输出信息时可以精确定位代码的位置">Go语言中有个<code>runtime.Caller</code>函数可以获取当前函数的调用者列表。我们可以非常容易在运行时定位每个函数的调用位置,以及函数的调用链。因此在panic异常或用log输出信息时,可以精确定位代码的位置。</h4>
<h4 id="比如以下代码可以打印程序的启动流程">比如以下代码可以打印程序的启动流程:</h4>
<pre><code class="language-go">func main() {
for skip := 0; ; skip++ {
pc, file, line, ok := runtime.Caller(skip)
if !ok {
break
}
p := runtime.FuncForPC(pc)
fnfile, fnline := p.FileLine(0)
fmt.Printf("skip = %d, pc = 0x%08X\n", skip, pc)
fmt.Printf("func: file = %s, line = L%03d, name = %s, entry = 0x%08X\n", fnfile, fnline, p.Name(), p.Entry())
fmt.Printf("call: file = %s, line = L%03d\n", file, line)
}
}
</code></pre>
<h4 id="其中runtimecaller先获取当时的pc寄存器值以及文件和行号然后根据pc寄存器表示的指令位置通过runtimefuncforpc函数获取函数的基本信息go语言是如何实现这种特性的呢">其中<code>runtime.Caller</code>先获取当时的PC寄存器值,以及文件和行号。然后根据PC寄存器表示的指令位置,通过````runtime.FuncForPC```函数获取函数的基本信息。Go语言是如何实现这种特性的呢?</h4>
<h4 id="go语言作为一门静态编译型语言在执行时每个函数的地址都是固定的函数的每条指令也是固定的如果针对每个函数和函数的每个指令生成一个地址表格也叫pc表格那么在运行时我们就可以根据pc寄存器的值轻松查询到指令当时对应的函数和位置信息而go语言也是采用类似的策略只不过地址表格经过裁剪舍弃了不必要的信息因为要在运行时获取任意一个地址的位置必然是要有一个函数调用因此我们只需要为函数的开始和结束位置以及每个函数调用位置生成地址表格就可以了同时地址是有大小顺序的在排序后可以通过只记录增量来减少数据的大小在查询时可以通过二分法加快查找的速度">Go语言作为一门静态编译型语言,在执行时每个函数的地址都是固定的,函数的每条指令也是固定的。如果针对每个函数和函数的每个指令生成一个地址表格(也叫PC表格),那么在运行时我们就可以根据PC寄存器的值轻松查询到指令当时对应的函数和位置信息。而Go语言也是采用类似的策略,只不过地址表格经过裁剪,舍弃了不必要的信息。因为要在运行时获取任意一个地址的位置,必然是要有一个函数调用,因此我们只需要为函数的开始和结束位置,以及每个函数调用位置生成地址表格就可以了。同时地址是有大小顺序的,在排序后可以通过只记录增量来减少数据的大小;在查询时可以通过二分法加快查找的速度。</h4>
<h4 id="在汇编中有个pcdata用于生成pc表格pcdata的指令用法为pcdata-tableid-tableoffsetpcdata有个两个参数第一个参数为表格的类型第二个是表格的地址在目前的实现中有pcdata_stackmapindex和pcdata_inltreeindex两种表格类型两种表格的数据是类似的应该包含了代码所在的文件路径行号和函数的信息只不过pcdata_inltreeindex用于内联函数的表格">在汇编中有个PCDATA用于生成PC表格,PCDATA的指令用法为<code>:PCDATA tableid, tableoffset</code>。PCDATA有个两个参数,第一个参数为表格的类型,第二个是表格的地址。在目前的实现中,有<code>PCDATA_StackMapIndex</code>和<code>PCDATA_InlTreeIndex</code>两种表格类型。两种表格的数据是类似的,应该包含了代码所在的文件路径、行号和函数的信息,只不过<code>PCDATA_InlTreeIndex</code>用于内联函数的表格。</h4>
<h4 id="此外对于汇编函数中返回值包含指针的类型在返回值指针被初始化之后需要执行一个go_results_initialized指令">此外对于汇编函数中返回值包含指针的类型,在返回值指针被初始化之后需要执行一个<code>GO_RESULTS_INITIALIZED</code>指令:</h4>
<pre><code class="language-s">#define GO_RESULTS_INITIALIZED PCDATA $PCDATA_StackMapIndex, $1
</code></pre>
<h4 id="go_results_initialized记录的也是pc表格的信息表示pc指针越过某个地址之后返回值才完成被初始化的状态"><code>GO_RESULTS_INITIALIZED</code>记录的也是PC表格的信息,表示PC指针越过某个地址之后返回值才完成被初始化的状态。</h4>
<h4 id="go语言二进制文件中除了有pc表格还有func表格用于记录函数的参数局部变量的指针信息funcdata指令和pcdata的格式类似funcdata-tableid-tableoffset第一个参数为表格的类型第二个是表格的地址目前的实现中定义了三种func表格类型funcdata_argspointermaps表示函数参数的指针信息表funcdata_localspointermaps表示局部指针信息表funcdata_inltree表示被内联展开的指针信息表通过func表格go语言的垃圾回收器可以跟踪全部指针的生命周期同时根据指针指向的地址是否在被移动的栈范围来确定是否要进行指针移动">Go语言二进制文件中除了有PC表格,还有FUNC表格用于记录函数的参数、局部变量的指针信息。FUNCDATA指令和PCDATA的格式类似:<code>FUNCDATA tableid, tableoffset</code>,第一个参数为表格的类型,第二个是表格的地址。目前的实现中定义了三种FUNC表格类型:<code>FUNCDATA_ArgsPointerMaps</code>表示函数参数的指针信息表,<code>FUNCDATA_LocalsPointerMaps</code>表示局部指针信息表,<code>FUNCDATA_InlTree</code>表示被内联展开的指针信息表。通过FUNC表格,Go语言的垃圾回收器可以跟踪全部指针的生命周期,同时根据指针指向的地址是否在被移动的栈范围来确定是否要进行指针移动。</h4>
<h4 id="在前面递归函数的例子中我们遇到一个no_local_pointers宏它的定义如下">在前面递归函数的例子中,我们遇到一个<code>NO_LOCAL_POINTERS</code>宏。它的定义如下:</h4>
<pre><code class="language-s">#define FUNCDATA_ArgsPointerMaps 0 /* garbage collector blocks */
#define FUNCDATA_LocalsPointerMaps 1
#define FUNCDATA_InlTree 2
#define NO_LOCAL_POINTERS FUNCDATA $FUNCDATA_LocalsPointerMaps, runtime·no_pointers_stackmap(SB)
</code></pre>
<h4 id="因此no_local_pointers宏表示的是funcdata_localspointermaps对应的局部指针表格而runtimeno_pointers_stackmap是一个空的指针表格也就是表示函数没有指针类型的局部变量">因此<code>NO_LOCAL_POINTERS</code>宏表示的是<code>FUNCDATA_LocalsPointerMaps</code>对应的局部指针表格,而<code>runtime·no_pointers_stackmap</code>是一个空的指针表格,也就是表示函数没有指针类型的局部变量。</h4>
<h4 id="pcdata和funcdata的数据一般是由编译器自动生成的手工编写并不现实如果函数已经有go语言声明那么编译器可以自动输出参数和返回值的指针表格同时所有的函数调用一般是对应call指令编译器也是可以辅助生成pcdata表格的编译器唯一无法自动生成是函数局部变量的表格因此我们一般要在汇编函数的局部变量中谨慎使用指针类型"><code>PCDATA</code>和<code>FUNCDATA</code>的数据一般是由编译器自动生成的,手工编写并不现实。如果函数已经有Go语言声明,那么编译器可以自动输出参数和返回值的指针表格。同时所有的函数调用一般是对应<code>CALL</code>指令,编译器也是可以辅助生成<code>PCDATA</code>表格的。编译器唯一无法自动生成是函数局部变量的表格,因此我们一般要在汇编函数的局部变量中谨慎使用指针类型。</h4>
<h4 id="对于pcdata和funcdata细节感兴趣的同学可以尝试从debuggosym包入手参考包的实现和测试代码">对于PCDATA和FUNCDATA细节感兴趣的同学可以尝试从debug/gosym包入手,参考包的实现和测试代码。</h4>
<h2 id="方法函数">方法函数</h2>
<h4 id="go语言中方法函数和全局函数非常相似比如有以下的方法">Go语言中方法函数和全局函数非常相似,比如有以下的方法:</h4>
<pre><code class="language-go">package main
type MyInt int
func (v MyInt) Twice() int {
return int(v)*2
}
func MyInt_Twice(v MyInt) int {
return int(v)*2
}
</code></pre>
<h4 id="其中myint类型的twice方法和myint_twice函数的类型是完全一样的只不过twice在目标文件中被修饰为mainmyinttwice名称我们可以用汇编实现该方法函数">其中MyInt类型的Twice方法和MyInt_Twice函数的类型是完全一样的,只不过Twice在目标文件中被修饰为<code>main.MyInt.Twice</code>名称。我们可以用汇编实现该方法函数:</h4>
<pre><code class="language-s">// func (v MyInt) Twice() int
TEXT ·MyInt·Twice(SB), NOSPLIT, $0-16
MOVQ a+0(FP), AX // v
ADDQ AX, AX // AX *= 2
MOVQ AX, ret+8(FP) // return v
RET
</code></pre>
<h4 id="不过这只是接收非指针类型的方法函数现在增加一个接收参数是指针类型的ptr方法函数返回传入的指针">不过这只是接收非指针类型的方法函数。现在增加一个接收参数是指针类型的Ptr方法,函数返回传入的指针:</h4>
<pre><code class="language-go">func (p *MyInt) Ptr() *MyInt {
return p
}
</code></pre>
<h4 id="在目标文件中ptr方法名被修饰为mainmyintptr也就是对应汇编中的myintptr不过在go汇编语言中星号和小括弧都无法用作函数名字也就是无法用汇编直接实现接收参数是指针类型的方法">在目标文件中,Ptr方法名被修饰为<code>main.(*MyInt).Ptr</code>,也就是对应汇编中的<code>·(*MyInt)·Ptr</code>。不过在Go汇编语言中,星号和小括弧都无法用作函数名字,也就是无法用汇编直接实现接收参数是指针类型的方法。</h4>
<h4 id="在最终的目标文件中的标识符名字中还有很多go汇编语言不支持的特殊符号比如typestringhello中的双引号这导致了无法通过手写的汇编代码实现全部的特性或许是go语言官方故意限制了汇编语言的特性">在最终的目标文件中的标识符名字中还有很多Go汇编语言不支持的特殊符号(比如<code>type.string."hello"</code>中的双引号),这导致了无法通过手写的汇编代码实现全部的特性。或许是Go语言官方故意限制了汇编语言的特性。</h4>
<h2 id="递归函数-1到n求和">递归函数: 1到n求和</h2>
<h4 id="递归函数是比较特殊的函数递归函数通过调用自身并且在栈上保存状态这可以简化很多问题的处理go语言中递归函数的强大之处是不用担心爆栈问题因为栈可以根据需要进行扩容和收缩">递归函数是比较特殊的函数,递归函数通过调用自身并且在栈上保存状态,这可以简化很多问题的处理。Go语言中递归函数的强大之处是不用担心爆栈问题,因为栈可以根据需要进行扩容和收缩。</h4>
<h4 id="首先通过go递归函数实现一个1到n的求和函数">首先通过Go递归函数实现一个1到n的求和函数:</h4>
<pre><code class="language-go">// sum = 1+2+...+n
// sum(100) = 5050
func sum(n int) int {
if n > 0 { return n+sum(n-1) } else { return 0 }
}
</code></pre>
<h4 id="然后通过ifgoto重构上面的递归函数以便于转义为汇编版本">然后通过if/goto重构上面的递归函数,以便于转义为汇编版本:</h4>
<pre><code class="language-s">func sum(n int) (result int) {
var AX = n
var BX int
if n > 0 { goto L_STEP_TO_END }
goto L_END
L_STEP_TO_END:
AX -= 1
BX = sum(AX)
AX = n // 调用函数后, AX重新恢复为n
BX += AX
return BX
L_END:
return 0
}
</code></pre>
<h4 id="在改写之后递归调用的参数需要引入局部变量保存中间结果也需要引入局部变量而通过栈来保存中间的调用状态正是递归函数的核心因为输入参数也在栈上所以我们可以通过输入参数来保存少量的状态同时我们模拟定义了ax和bx寄存器寄存器在使用前需要初始化并且在函数调用后也需要重新初始化">在改写之后,递归调用的参数需要引入局部变量,保存中间结果也需要引入局部变量。而通过栈来保存中间的调用状态正是递归函数的核心。因为输入参数也在栈上,所以我们可以通过输入参数来保存少量的状态。同时我们模拟定义了AX和BX寄存器,寄存器在使用前需要初始化,并且在函数调用后也需要重新初始化。</h4>
<h4 id="下面继续改造为汇编语言版本">下面继续改造为汇编语言版本:</h4>
<pre><code class="language-s">// func sum(n int) (result int)
TEXT ·sum(SB), NOSPLIT, $16-16
MOVQ n+0(FP), AX // n
MOVQ result+8(FP), BX// result
CMPQ AX, $0 // test n - 0
JG L_STEP_TO_END // if > 0: goto L_STEP_TO_END
JMPL_END // goto L_STEP_TO_END
L_STEP_TO_END:
SUBQ $1, AX // AX -= 1
MOVQ AX, 0(SP) // arg: n-1
CALL ·sum(SB) // call sum(n-1)
MOVQ 8(SP), BX // BX = sum(n-1)
MOVQ n+0(FP), AX // AX = n
ADDQ AX, BX // BX += AX
MOVQ BX, result+8(FP)// return BX
RET
L_END:
MOVQ $0, result+8(FP) // return 0
RET
</code></pre>
<h4 id="在汇编版本函数中并没有定义局部变量只有用于调用自身的临时栈空间因为函数本身的参数和返回值有16个字节因此栈帧的大小也为16字节l_step_to_end标号部分用于处理递归调用是函数比较复杂的部分l_end用于处理递归终结的部分">在汇编版本函数中并没有定义局部变量,只有用于调用自身的临时栈空间。因为函数本身的参数和返回值有16个字节,因此栈帧的大小也为16字节。L_STEP_TO_END标号部分用于处理递归调用,是函数比较复杂的部分。L_END用于处理递归终结的部分。</h4>
<h4 id="调用sum函数的参数在0sp位置调用结束后的返回值在8sp位置在函数调用之后要需要重新为需要的寄存器注入值因为被调用的函数内部很可能会破坏了寄存器的状态同时调用函数的参数值也是不可信任的输入参数值也可能在被调用函数内部被修改了">调用sum函数的参数在<code>0(SP)</code>位置,调用结束后的返回值在<code>8(SP)</code>位置。在函数调用之后要需要重新为需要的寄存器注入值,因为被调用的函数内部很可能会破坏了寄存器的状态。同时调用函数的参数值也是不可信任的,输入参数值也可能在被调用函数内部被修改了。</h4>
<h4 id="总得来说用汇编实现递归函数和普通函数并没有什么区别当然是在没有考虑爆栈的前提下我们的函数应该可以对较小的n进行求和但是当n大到一定程度也就是栈达到一定的深度必然会出现爆栈的问题爆栈是c语言的特性不应该在哪怕是go汇编语言中出现">总得来说用汇编实现递归函数和普通函数并没有什么区别,当然是在没有考虑爆栈的前提下。我们的函数应该可以对较小的n进行求和,但是当n大到一定程度,也就是栈达到一定的深度,必然会出现爆栈的问题。爆栈是C语言的特性,不应该在哪怕是Go汇编语言中出现。</h4>
<h4 id="go语言的编译器在生成函数的机器代码时会在开头插入一小段代码因为sum函数也需要深度递归调用因此我们删除了nosplit标志让汇编器为我们自动生成一个栈扩容的代码">Go语言的编译器在生成函数的机器代码时,会在开头插入一小段代码。因为sum函数也需要深度递归调用,因此我们删除了<code>NOSPLIT</code>标志,让汇编器为我们自动生成一个栈扩容的代码:</h4>
<pre><code class="language-go">// func sum(n int) int
TEXT ·sum(SB), $16-16
NO_LOCAL_POINTERS
// 原来的代码
</code></pre>
<h4 id="除了去掉了nosplit标志我们还在函数开头增加了一个no_local_pointers语句该语句表示函数没有局部指针变量栈的扩容必然要涉及函数参数和局部编指针的调整如果缺少局部指针信息将导致扩容工作无法进行不仅仅是栈的扩容需要函数的参数和局部指针标记表格在gc进行垃圾回收时也将需要函数的参数和返回值的指针状态可以通过在go语言中的函数声明中获取函数的局部变量则需要手工指定因为手工指定指针表格是一个非常繁琐的工作因此一般要避免在手写汇编中出现局部指针">除了去掉了NOSPLIT标志,我们还在函数开头增加了一个<code>NO_LOCAL_POINTERS</code>语句,该语句表示函数没有局部指针变量。栈的扩容必然要涉及函数参数和局部编指针的调整,如果缺少局部指针信息将导致扩容工作无法进行。不仅仅是栈的扩容需要函数的参数和局部指针标记表格,在GC进行垃圾回收时也将需要。函数的参数和返回值的指针状态可以通过在Go语言中的函数声明中获取,函数的局部变量则需要手工指定。因为手工指定指针表格是一个非常繁琐的工作,因此一般要避免在手写汇编中出现局部指针。</h4>
<h4 id="喜欢深究的读者可能会有一个问题如果进行垃圾回收或栈调整时寄存器中的指针是如何维护的前文说过go语言的函数调用是通过栈进行传递参数的并没有使用寄存器传递参数同时函数调用之后所有的寄存器视为失效因此在调整和维护指针时只需要扫描内存中的指针数据寄存器中的数据在垃圾回收器函数返回后都需要重新加载因此寄存器是不需要扫描的">喜欢深究的读者可能会有一个问题:如果进行垃圾回收或栈调整时,寄存器中的指针是如何维护的?前文说过,Go语言的函数调用是通过栈进行传递参数的,并没有使用寄存器传递参数。同时函数调用之后所有的寄存器视为失效。因此在调整和维护指针时,只需要扫描内存中的指针数据,寄存器中的数据在垃圾回收器函数返回后都需要重新加载,因此寄存器是不需要扫描的。</h4>
<h2 id="闭包函数">闭包函数</h2>
<h4 id="闭包函数是最强大的函数因为闭包函数可以捕获外层局部作用域的局部变量因此闭包函数本身就具有了状态从理论上来说全局的函数也是闭包函数的子集只不过全局函数并没有捕获外层变量而已">闭包函数是最强大的函数,因为闭包函数可以捕获外层局部作用域的局部变量,因此闭包函数本身就具有了状态。从理论上来说,全局的函数也是闭包函数的子集,只不过全局函数并没有捕获外层变量而已。</h4>
<h4 id="为了理解闭包函数如何工作我们先构造如下的例子">为了理解闭包函数如何工作,我们先构造如下的例子:</h4>
<pre><code class="language-go">package main
func NewTwiceFunClosure(x int) func() int {
return func() int {
x *= 2
return x
}
}
func main() {
fnTwice := NewTwiceFunClosure(1)
println(fnTwice()) // 1*2 => 2
println(fnTwice()) // 2*2 => 4
println(fnTwice()) // 4*2 => 8
}
</code></pre>
<h4 id="其中newtwicefunclosure函数返回一个闭包函数对象返回的闭包函数对象捕获了外层的x参数返回的闭包函数对象在执行时每次将捕获的外层变量乘以2之后再返回在main函数中首先以1作为参数调用newtwicefunclosure函数构造一个闭包函数返回的闭包函数保存在fntwice闭包函数类型的变量中然后每次调用fntwice闭包函数将返回翻倍后的结果也就是248">其中<code>NewTwiceFunClosure</code>函数返回一个闭包函数对象,返回的闭包函数对象捕获了外层的x参数。返回的闭包函数对象在执行时,每次将捕获的外层变量乘以2之后再返回。在<code>main</code>函数中,首先以1作为参数调用<code>NewTwiceFunClosure</code>函数构造一个闭包函数,返回的闭包函数保存在<code>fnTwice</code>闭包函数类型的变量中。然后每次调用<code>fnTwice</code>闭包函数将返回翻倍后的结果,也就是:2,4,8。</h4>
<h4 id="上述的代码从go语言层面是非常容易理解的但是闭包函数在汇编语言层面是如何工作的呢下面我们尝试手工构造闭包函数来展示闭包的工作原理首先是构造funtwiceclosure结构体类型用来表示闭包对象">上述的代码,从Go语言层面是非常容易理解的。但是闭包函数在汇编语言层面是如何工作的呢?下面我们尝试手工构造闭包函数来展示闭包的工作原理。首先是构造```FunTwiceClosure````结构体类型,用来表示闭包对象:</h4>
<pre><code class="language-go">type FunTwiceClosure struct {
F uintptr
X int
}
func NewTwiceFunClosure(x int) func() int {
var p = &FunTwiceClosure{
F: asmFunTwiceClosureAddr(),
X: x,
}
return ptrToFunc(unsafe.Pointer(p))
}
</code></pre>
<h4 id="funtwiceclosure结构体包含两个成员第一个成员f表示闭包函数的函数指令的地址第二个成员x表示闭包捕获的外部变量如果闭包函数捕获了多个外部变量那么funtwiceclosure结构体也要做相应的调整然后构造funtwiceclosure结构体对象其实也就是闭包函数对象其中asmfuntwiceclosureaddr函数用于辅助获取闭包函数的函数指令的地址采用汇编语言实现最后通过ptrtofunc辅助函数将结构体指针转为闭包函数对象返回该函数也是通过汇编语言实现"><code>FunTwiceClosure</code>结构体包含两个成员,第一个成员<code>F表示闭包函数的函数指令的地址</code>,第二个成员<code>X表示闭包捕获的外部变量</code>。如果闭包函数捕获了多个外部变量,那么<code>FunTwiceClosure</code>结构体也要做相应的调整。然后构造<code>FunTwiceClosure</code>结构体对象,其实也就是闭包函数对象。其中<code>asmFunTwiceClosureAddr</code>函数用于辅助获取闭包函数的函数指令的地址,采用汇编语言实现。最后通过<code>ptrToFunc</code>辅助函数将结构体指针转为闭包函数对象返回,该函数也是通过汇编语言实现。</h4>
<h4 id="汇编语言实现了以下三个辅助函数">汇编语言实现了以下三个辅助函数:</h4>
<pre><code class="language-go">func ptrToFunc(p unsafe.Pointer) func() int
func asmFunTwiceClosureAddr() uintptr
func asmFunTwiceClosureBody() int
</code></pre>
<h4 id="其中ptrtofunc用于将指针转化为func-int类型的闭包函数asmfuntwiceclosureaddr用于返回闭包函数机器指令的开始地址类似全局函数的地址asmfuntwiceclosurebody是闭包函数对应的全局函数的实现">其中<code>ptrToFunc用于将指针转化为func() int类型的闭包函数</code>,<code>asmFunTwiceClosureAddr用于返回闭包函数机器指令的开始地址(类似全局函数的地址)</code>,<code>asmFunTwiceClosureBody是闭包函数对应的全局函数的实现</code>。</h4>
<h4 id="然后用go汇编语言实现以上三个辅助函数">然后用Go汇编语言实现以上三个辅助函数:</h4>
<pre><code class="language-s">#include "textflag.h"
TEXT ·ptrToFunc(SB), NOSPLIT, $0-16
MOVQ ptr+0(FP), AX // AX = ptr
MOVQ AX, ret+8(FP) // return AX
RET
TEXT ·asmFunTwiceClosureAddr(SB), NOSPLIT, $0-8
LEAQ ·asmFunTwiceClosureBody(SB), AX // AX = ·asmFunTwiceClosureBody(SB)
MOVQ AX, ret+0(FP) // return AX
RET
TEXT ·asmFunTwiceClosureBody(SB), NOSPLIT|NEEDCTXT, $0-8
MOVQ 8(DX), AX
ADDQ AX , AX // AX *= 2
MOVQ AX , 8(DX) // ctx.X = AX
MOVQ AX , ret+0(FP) // return AX
RET
</code></pre>
<h4 id="其中ptrtofunc和asmfuntwiceclosureaddr函数的实现比较简单我们不再详细描述最重要的是asmfuntwiceclosurebody函数的实现它有一个needctxt标志采用needctxt标志定义的汇编函数表示需要一个上下文环境在amd64环境下是通过dx寄存器来传递这个上下文环境指针也就是对应funtwiceclosure结构体的指针函数首先从funtwiceclosure结构体对象取出之前捕获的x将x乘以2之后写回内存最后返回修改之后的x的值">其中<code>·ptrToFunc</code>和<code>·asmFunTwiceClosureAddr</code>函数的实现比较简单,我们不再详细描述。最重要的是<code>·asmFunTwiceClosureBody</code>函数的实现:它有一个<code>NEEDCTXT</code>标志。采用<code>NEEDCTXT</code>标志定义的汇编函数表示需要一个上下文环境,在AMD64环境下是通过<code>DX寄存器</code>来传递这个上下文环境指针,也就是对应<code>FunTwiceClosure</code>结构体的指针。函数首先从<code>FunTwiceClosure</code>结构体对象取出之前捕获的X,将X乘以2之后写回内存,最后返回修改之后的X的值。</h4>
<h4 id="如果是在汇编语言中调用闭包函数也需要遵循同样的流程首先为构造闭包对象其中保存捕获的外层变量在调用闭包函数时首先要拿到闭包对象用闭包对象初始化dx然后从闭包对象中取出函数地址并用通过call指令调用">如果是在汇编语言中调用闭包函数,也需要遵循同样的流程:首先为构造闭包对象,其中保存捕获的外层变量;在调用闭包函数时首先要拿到闭包对象,用闭包对象初始化<code>DX</code>,然后从闭包对象中取出函数地址并用通过<code>CALL</code>指令调用。</h4>
</div>
<div id="MySignature" role="contentinfo">
Songzhibin<br><br>
来源:https://www.cnblogs.com/binHome/p/13034103.html
頁:
[1]