1
0
mirror of https://github.com/chai2010/advanced-go-programming-book.git synced 2025-05-24 12:32:21 +00:00

ch3-04: 重构内容

This commit is contained in:
chai2010 2018-07-21 19:00:19 +08:00
parent 3b90a38a81
commit fb1083dec5

View File

@ -4,10 +4,7 @@
## 基本语法
函数标识符通过TEXT汇编指令定义表示该行开始的指令定义在TEXT内存段。TEXT语句后的指令一般对应函数的实现但是对于TEXT指令本身来说并不关心后面是否有指令。我个人觉得TEXT和LABEL定义的符号是类似的区别只是LABEL是用于跳转标号但是本质上他们都是通过标识符映射一个内存地址。
![](../images/ch3-func-decl-01.ditaa.png)
函数标识符通过TEXT汇编指令定义表示该行开始的指令定义在TEXT内存段。TEXT语句后的指令一般对应函数的实现但是对于TEXT指令本身来说并不关心后面是否有指令。因此TEXT和LABEL定义的符号是类似的区别只是LABEL是用于跳转标号但是本质上他们都是通过标识符映射一个内存地址。
函数的定义的语法如下:
@ -19,27 +16,41 @@ TEXT symbol(SB), [flags,] $framesize[-argsize]
其中TEXT用于定义函数符号函数名中当前包的路径可以省略。函数的名字后面是`(SB)`表示是函数名符号相对于SB伪寄存器的偏移量二者组合在一起最终是绝对地址。作为全局的标识符的全局变量和全局函数的名字一般都是基于SB伪寄存器的相对地址。标志部分用于指示函数的一些特殊行为常见的NOSPLIT主要用于指示叶子函数不进行栈分裂。framesize部分表示函数的局部变量需要多少栈空间其中包含调用其它函数时准备调用参数的隐式栈空间。最后是可以省略的参数大小之所以可以省略是因为编译器可以从Go语言的函数声明中推导出函数参数的大小。
下面是在main包中Add在汇编中两种定义方式
```
// func Add(a, b int) int
TEXT main·Add(SB), NOSPLIT, $0-24
我们首先从一个简单的Swap函数开始。Swap函数用于交互输入的两个参数的顺序然后通过返回值返回交换了顺序的结果。如果用Go语言中声明Swap函数大概这样的
// func Add(a, b int) int
TEXT ·Add(SB), $0
```go
package main
//go:nosplit
func Swap(a, b int) (int, int)
```
第一种是最完整的写法函数名部分包含了当前包的路径同时指明了函数的参数大小为24个字节对应参数和返回值的3个int类型。第二种写法则比较简洁省略了当前包的路径和参数的大小。需要注意的是标志参数中的NOSPLIT如果在Go语言函数声明中通过注释指明了标志应该也是可以省略的需要确认下
下面是main包中Swap函数在汇编中两种定义方式
```
// func Swap(a, b int) (int, int)
TEXT ·Swap(SB), NOSPLIT, $0-32
// func Swap(a, b int) (int, int)
TEXT ·Swap(SB), $0
```
下图是Swap函数几种不同写法的对比关系图
![](../images/ch3-func-decl-01.ditaa.png)
第一种是最完整的写法函数名部分包含了当前包的路径同时指明了函数的参数大小为32个字节对应参数和返回值的4个int类型。第二种写法则比较简洁省略了当前包的路径和参数的大小。需要注意的是标志参数中的NOSPLIT如果在Go语言函数声明中通过注释指明了标志也是可以省略的。如果没有NOSPLIT标注Go汇编器和汇编器会分别为Go函数和汇编函数插入栈分裂的代码。
目前可能遇到的函数标志有NOSPLIT、WRAPPER和NEEDCTXT几个。其中NOSPLIT不会生成或包含栈分裂代码这一般用于没有任何其它函数调用的叶子函数这样可以适当提高性能。WRAPPER标志则表示这个是一个包装函数在panic或runtime.caller等某些处理函数帧的地方不会增加函数帧计数。最后的NEEDCTXT表示需要一个上下文参数一般用于闭包函数。
需要注意的是函数也没有类型上面定义的Add函数签名可以下面任意一种格式
需要注意的是函数也没有类型,上面定义的Swap函数签名可以下面任意一种格式:
```
func Add(a, b int) int
func Add(a, b, c int)
func Add() (a, b, c int)
func Add() (a []int) // reflect.SliceHeader 切片头刚好也是 3 个 int 成员
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)
// ...
```
@ -49,81 +60,71 @@ func Add() (a []int) // reflect.SliceHeader 切片头刚好也是 3 个 int 成
对于函数来说最重要的是函数对外提供的API约定包含函数的名称、参数和返回值。当这些都确定之后如何精确计算参数和返回值的大小是第一个需要解决的问题。
![](../images/ch3-func-decl-02.ditaa.png)
比如有一个Foo函数的签名如下
比如有一个Swap函数的签名如下
```go
func Foo(a, b int) (c int)
func Swap(a, b int) (ret0, ret1 int)
```
对于这个函数,我们可以轻易看出它需要3个int类型的空间参数和返回值的大小也就是24个字节:
对于这个函数,我们可以轻易看出它需要4个int类型的空间参数和返回值的大小也就是32个字节
```
TEXT ·Foo(SB), $0-24
TEXT ·Swap(SB), $0-32
```
那么如何在汇编中引用这3个参数呢为此Go汇编中引入了一个FP伪寄存器表示函数当前帧的地址也就是第一个参数的地址。因此我们以通过`+0(FP)``+8(FP)``+16(FP)`来分别引用a、b、c三个参数。
那么如何在汇编中引用这4个参数呢为此Go汇编中引入了一个FP伪寄存器表示函数当前帧的地址也就是第一个参数的地址。因此我们以通过`+0(FP)``+8(FP)``+16(FP)``+24(FP)`来分别引用a、b、ret0和ret1四个参数。
但是在汇编代码中,我们并不能直接以`+0(FP)`的方式来使用参数。为了编写易于维护的汇编代码Go汇编语言要求任何通过FP伪寄存器访问的变量必和一个临时标识符前缀组合后才能有效一般使用参数对应的变量名作为前缀。
下图是Swap函数中参数和返回值在内存中的布局图
![](../images/ch3-func-decl-02.ditaa.png)
但是在汇编代码中,我们并不能直接使用`+0(FP)`来使用参数。为了编写易于维护的汇编代码Go汇编语言要求任何通过FP寄存器访问的变量必和一个临时标识符前缀组合后才能有效一般使用参数对应的变量名作为前缀。
下面的代码演示了如何在汇编函数中使用参数和返回值:
```
TEXT ·Foo(SB), $0
TEXT ·Swap(SB), $0
MOVEQ a+0(FP), AX // a
MOVEQ b+8(FP), BX // b
MOVEQ c+16(FP), CX // c
MOVEQ ret0+16(FP), CX // ret0
MOVEQ ret1+24(FP), DX // ret1
RET
```
从代码可以看出a、b、ret0和ret1的内存地址是依次递增的FP伪寄存器是第一个变量的开始地址。
## 参数和返回值的内存布局
如果是参数和返回值类型比较复杂的情况该如何处理呢?下面我们再尝试一个更复杂的函数参数和返回值的计算。比如有以下一个函数:
```go
func SomeFunc(a, b int, c bool) (d float64, err error)
func Foo(a bool, b int16) (c []byte)
```
函数的参数有不同的类型,同时含有多个返回值,而且返回值中含有更复杂的接口类型。我们该如何计算每个参数的位置和总的大小呢?
其实函数参数和返回值的大小以及对齐问题和结构体的大小和成员对齐问题是一致的。我们先看看如何用Go语言函数来模拟Foo函数中参数和返回值的地址
其实函数参数和返回值的大小以及对齐问题和结构体的大小和成员对齐问题是一致的。我们可以用诡代思路将全部的参数和返回值以同样的顺序放到一个结构体中将FP伪寄存器作为唯一的一个指针参数而每个成员的地址也就是对应原来参数的地址。
用这样的策略可以很容易计算前面的Foo函数的参数和返回值的地址和总大小。为了便于描述我们定义一个`Foo_args_and_returns`临时结构体类型用于诡代原始的参数和返回值:
```go
func Foo(FP *struct{a, b, c int}) {
_ = unsafe.Offsetof(FP.a) + uintptr(FP) // a
_ = unsafe.Offsetof(FP.b) + uintptr(FP) // b
_ = unsafe.Offsetof(FP.c) + uintptr(FP) // c
_ = unsafe.Sizeof(*FP) // argsize
return
type Foo_args_and_returns struct {
a bool
b int16
c []byte
}
```
我们尝试将全部的参数和返回值以同样的顺序放到一个结构体中将FP伪寄存器作为唯一的一个指针参数而每个成员的地址也就是对应原来参数的地址。
用同样的策略可以很容易计算前面的SomeFunc函数的参数和返回值的地址和总大小。
因为SomeFunc函数的参数比较多我们临时定一个`SomeFunc_args_and_returns`结构体用于对应参数和返回值:
然后将Foo原来的参数替换为结构体形式并且只保留唯一的FP作为参数
```go
type SomeFunc_args_and_returns struct {
a int
b int
c bool
d float64
e error
}
```
然后将SomeFunc原来的参数替换为结构体形式并且只保留唯一的FP作为参数
```go
func SomeFunc(FP *SomeFunc_args_and_returns) {
func Foo(FP *SomeFunc_args_and_returns) {
_ = unsafe.Offsetof(FP.a) + uintptr(FP) // a
_ = unsafe.Offsetof(FP.b) + uintptr(FP) // b
_ = unsafe.Offsetof(FP.c) + uintptr(FP) // c
_ = unsafe.Offsetof(FP.d) + uintptr(FP) // d
_ = unsafe.Offsetof(FP.e) + uintptr(FP) // e
_ = unsafe.Sizeof(*FP) // argsize
@ -133,45 +134,66 @@ func SomeFunc(FP *SomeFunc_args_and_returns) {
代码完全和Foo函数参数的方式类似。唯一的差异是每个函数的偏移量这有`unsafe.Offsetof`函数自动计算生成。因为Go结构体中的每个成员已经满足了对齐要求因此采用通用方式得到每个参数的偏移量也是满足对齐要求的。
Foo函数的参数和返回值的大小和内存布局
![](../images/ch3-func-arg-01.ditaa.png)
<!-- TODO: 内容 -->
下面的代码演示了Foo汇编函数参数和返回值的定位
```
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
```
其中a和b参数之间出现了一个字节的空洞b和c之间出现了4个字节的空洞。出现空洞的原因是要包装每个参数变量地址都要对齐到相应的倍数。
## 函数中的局部变量
从Go语言函数角度讲局部变量是函数内明确定义的变量同时也包含函数的参数和返回值变量。但是从Go汇编角度看局部变量是指函数运行时在当前函数栈帧所对应的内存内的变量不包含函数的参数和返回值因为访问方式有差异。函数栈帧的空间主要由函数参数和返回值、局部变量和被调用其它函数的参数和返回值空间组成。为了便于理解我们可以将汇编函数的局部变量类比为Go语言函数中显式定义的变量不包含参数和返回值部分。
![](../images/ch3-func-local-var-01.ditaa.png)
为了便于访问局部变量Go汇编语言引入了伪SP寄存器对应当前栈帧的底部。因为在当前栈帧时栈的底部是固定不变的因此局部变量的相对于伪SP的偏移量也就是固定的这可以简化局部变量的维护工作。SP真伪寄存器的区分只有一个原则如果使用SP时有一个临时标识符前缀就是伪SP否则就是真SP寄存器。比如`a(SP)``b+8(SP)`有a和b临时前缀这里都是伪SP而前缀部分一般用于表示局部变量的名字。而`(SP)``+8(SP)`没有临时标识符作为前缀它们都是真SP寄存器。
在X86平台函数的调用栈是从高地址向低地址增长的因此伪SP寄存器对应栈帧的底部其实是对应更大的地址。当前栈的顶部对应真实存在的SP寄存器对应当前函数栈帧的栈顶对应更小的地址。如果整个内存用Memory数组表示那么`Memory[0(SP):end-0(SP)]`就是对应当前栈帧的切片其中开始位置是真SP寄存器结尾部分是伪SP寄存器。真SP寄存器一般用于表示调用其它函数时的参数和返回值真SP寄存器对应内存较低的地址所以被访问变量的偏移量是正数而伪SP寄存器对应高地址对应的局部变量的偏移量都是负数。
为了便于访问局部变量Go汇编语言引入了伪SP寄存器对应当前栈帧的底部。因为在当前栈帧时栈的底部是固定不变的因此局部变量的相对于伪SP的偏移量也就是固定的这可以简化局部变量的维护工作。SP真伪区分只有一个原则如果使用SP时有一个临时标识符前缀就是伪SP否则就是真SP寄存器。比如`a(SP)``b+8(SP)`有a和b临时前缀这里都是伪SP而前缀部分一般用于表示局部变量的名字。而`(SP)``+8(SP)`没有临时标识符作为前缀它们都是真SP寄存器。
在X86平台函数的调用栈是从高地址向低地址增长的因此伪SP寄存器对应栈帧的底部其实是对应更大的地址。当前栈的顶部对应真实存在的SP寄存器对应当前函数栈帧的栈顶对应更小的地址。如果整个内存用Memory数组表示那么`Memory[0(SP):end-0(SP)]`就是对应当前栈帧的切片其中开始位置是真SP结尾部分是伪SP。真SP一般用于表示调用其它函数时的参数和返回值真SP对应内存较低的地址所以被访问变量的偏移量是正数而伪SP对应高地址对应的局部变量的偏移量都是负数。
我们现在用Go语言定义一个Foo函数并在函数内部定义几个局部变量
为了便于对比我们将前面Foo函数的参数和返回值变量改成局部变量
```go
func Foo() { var a, b, c int }
func Foo() {
var c []byte
var b int16
var a bool
}
```
然后通过汇编语言重新实现Foo函数并通过伪SP来定位局部变量
```
TEXT ·Foo(SB), $24-0
MOVQ a-8*3(SP), AX // a
MOVQ b-8*2(SP), BX // b
MOVQ c-8*1(SP), CX // c
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
```
Foo函数有3个int类型的局部变量,但是没有调用其它的函数,所以函数的栈帧大小为24个字节。因为Foo函数没有参数和返回值因此参数和返回值大小为0个字节当然这个部分可以省略不写。而局部变量中先定义的变量a离伪SP对应的地址最远最后定义的变量c离伪SP最近。有两个原因导致出现这种逆序的结果一个从Go语言函数角度理解先定义的a变量地址要比后定义的变量的地址更小另一个是伪SP对应栈帧的底部而栈是从高向地生长的所以有着更小地址的a变量离栈的底部伪SP更远。
Foo函数有3个局部变量但是没有调用其它的函数因为对齐和填充的问题导致函数的栈帧大小为32个字节。因为Foo函数没有参数和返回值因此参数和返回值大小为0个字节当然这个部分可以省略不写。而局部变量中先定义的变量c离伪SP寄存器对应的地址最远最后定义的变量a离伪SP寄存器最近。有两个因素导致出现这种逆序的结果一个从Go语言函数角度理解先定义的a变量地址要比后定义的变量的地址更小另一个是伪SP寄存器对应栈帧的底部而X86中栈是从高向地生长的所以最先定义有着更小地址的c变量离栈的底部伪SP更远。
我们同样可以通过结构体来模拟局部变量的布局:
```go
func Foo() {
var local [1]struct{a, b, c int};
var local [1]struct{
a bool
b int16
c []byte
}
var SP = &local[1];
_ = -(unsafe.Sizeof(local)-unsafe.Offsetof(local.a)) + uintptr(&SP) // a
@ -184,50 +206,44 @@ func Foo() {
通过这种方式可以处理复杂的局部变量的偏移,同时也能保证每个变量地址的对齐要求。当然,除了地址对齐外,局部变量的布局并没有顺序要求。对于汇编比较熟悉同学可以根据自己的习惯组织变量的布局。
下面是Foo函数的局部变量的大小和内存布局
![](../images/ch3-func-local-var-01.ditaa.png)
从图中可以看出Foo函数局部变量和前一个例子中参数和返回值的内存布局是完全一样的这也是我们故意设计的结果。但是参数和返回值是通过伪FP寄存器定位的FP寄存器对应第一个参数的开始地址第一个参数地址较低因此每个变量的偏移量是正数。而局部变量是通过伪SP寄存器定位的而伪SP寄存器对应的是第一个局部变量的结束地址第一个局部变量地址较大因此每个局部变量的便宜量都是负数。
## 调用其它函数
常见的用Go汇编实现的函数都是叶子函数也就是被其它函数调用但是很少调用其它函数。这主要是因为叶子函数比较简单可以简化汇编函数的编写同时一般性能或特性的瓶颈也处于叶子函数。但是能够调用其它函数和能够被其它函数调用同样重要否则Go汇编就不是一个完整的汇编语言。
常见的用Go汇编实现的函数都是叶子函数也就是被其它函数调用的函数但是很少调用其它函数。这主要是因为叶子函数比较简单可以简化汇编函数的编写同时一般性能或特性的瓶颈也处于叶子函数。但是能够调用其它函数和能够被其它函数调用同样重要否则Go汇编就不是一个完整的汇编语言。
在前文中我们已经学习过一些汇编实现的函数参数和返回值处理的规则。那么一个显然的问题是汇编函数的参数是从哪里来的答案同样明显被调用函数的参数是由调用方准备的调用方在栈上设置好空间和数据后调用函数被调用方在返回前将返回值放在对应的位置函数通过RET指令返回调用方函数之后调用方从返回值对应的栈内存位置取出结果。Go语言函数的调用参数和返回值均是通过栈传输的这样做的优点是函数调用栈比较清晰缺点是函数调用有一定的性能损耗Go编译器是通过函数内联来缓解这个问题的影响
在前文中我们已经学习了一些汇编实现的函数参数和返回值处理的规则。那么一个显然的问题是汇编函数的参数是从哪里来的答案同样明显被调用函数的参数是由调用方准备的调用方在栈上设置好空间和数据后调用函数被调用方在返回前将返回值放在对应的位置函数通过RET指令返回调用方函数之后调用方再从返回值对应的栈内存位置取出结果。Go语言函数的调用参数和返回值均是通过栈传输的这样做的优点是函数调用栈比较清晰缺点是函数调用有一定的性能损耗Go编译器是通过函数内联来缓解这个问题的影响
为了便于展示我们先使用Go语言来构造三个逐级调用的函数
```go
func main() {
printsum(1, 2)
}
func printsum(a, b int) {
var ret = sum(a, b)
println(sum)
}
func sum(a, b int) int {
return a+b
}
```
其中main函数通过字面值常量直接调用printsum函数printsum函数输出两个整数的和。而printsum函数内部又通过调用sum函数计算两个数的和并最终调用打印函数进行输出。因为printsum既是被调用函数又是调用函数所以它是我们要重点分析的函数。
下图展示了三个函数逐级调用时内存中函数参数和返回值的布局:
![](../images/ch3-func-call-frame-01.ditaa.png)
为了便于演示我们先用Go语言构造foo和bar两个函数其中foo函数内部调用bar函数
为了便于理解我们对真实的内存布局进行了简化。要记住的是调用函数时被调用函数的参数和返回值内存空间都必须由调用者提供。因此函数的局部变量和为调用其它函数准备的栈空间总和就确定了函数帧的大小。调用其它函数前调用方要选择保存相关寄存器到栈中并在调用函数返回后选择要恢复的寄存器进行保存。最终通过CALL指令调用函数的过程和调用我们熟悉的调用println函数输出的过程类似。
```go
func foo() {
var a, b int
bar(b)
}
func bar(a int) int {
return a
}
```
然后用汇编重新实现类似的函数:
```
TEXT ·foo(SB), $32-0
MOVQ a-8*2(SP), AX // a
MOVQ b-8*1(SP), BX // b
MOVQ BX, +0(SP) // bar(BX)
CALL ·bar(SB) //
MOVQ +8(SP), CX // CX = bar(a)
RET
TEXT ·bar(SB), $0-16
MOVQ a-0(FP), AX // a
MOVQ AX, ret+8(FP) // return a
RET
```
首先分析foo函数的栈帧的大小foo函数内部有a、b两个局部变量占用16个字节然后要需给要调用的bar函数的参数和返回值准备16字节的空间因此总共有32字节的栈帧大小。在调用bar函数前我们已经计算好了栈帧的大小Go汇编语言环境已将真实的SP寄存器调整到合适的大小在调用函数时并不需要再手动调整SP寄存器。在调用函数bar前真SP对应向下增长的栈顶部因此顶部的16个字节和bar函数的参数和返回值是对应的相同的内存空间。我们将保存了b的BX寄存器内容放入`+0(SP)`位置也就是准备bar函数的第一个参数。然后通过CALL指令进行函数调用。在bar函数内首先从第一个参数对应的`+0(FP)`位置去取参数值存入AX寄存器然后再将AX内容放入返回值对应的`ret+8(FP)`内存位置最后调用RET返回。在foo函数中调用bar函数返回后从bar函数返回值对应的`+8(SP)`位置取出结果放到CX寄存从而完成函数调用。
调用其它函数前调用方要选择保存相关寄存器到栈中并在调用函数返回后选择要恢复的寄存器进行保存。Go语言中函数调用是一个复杂的问题因为Go函数不仅仅要了解函数调用参数的布局还会涉及到栈的跳转栈上局部变量的生命周期管理。本节只是简单了解函数调用参数的布局规则在后续的章节中会更详细的讨论函数的细节。
Go语言中函数调用是一个复杂的问题因为Go函数不仅仅要了解函数调用参数的布局还会涉及到栈的跳转栈上局部变量的生命周期管理。本节只是简单了解函数调用参数的布局规则在后续的章节中会更详细的讨论函数的细节。
## 宏函数