当前位置: 首页 > 科技观察

Go结构函数调用底层实现

时间:2023-03-12 19:24:09 科技观察

《Go 语言嵌入和多态机制对比》文章中,我们了解了Go语言的类型系统。接下来,我们来看看Go语言是如何实现类型系统特性的。我们将深入到Go语言运行时和最终机器码层面,了解Go语言的结构和函数调用。上面说了,Go语言的结构不是Java和C++中类的概念。我们来看看结构变量声明和相关函数调用在机器码或汇编级别的表现。我们以下面的代码为例进行分析。func(uUser)addAgeVal(aint32)int32{n:=u.Age+areturnn}func(u*User)addAgePtr(aint32)int32{n:=u.Age+areturnn}funcmain(){u:=User{ID:1,Name:"Tom",Age:23}s1:=u.addAgeVal(1)s2:=u.addAgePtr(2)println(s1==s2)}使用以下命令将上述代码编译成机器码,其中GOOS指定目标操作系统,GOARCH指定CPU架构,-S表示打印机器码,-N表示禁止编译器优化,-l表示禁止内联,原生Go版本为go1.16.4。GOOS=linuxGOARCH=amd64gotoolcompile-S-N-lmain.go变量声明和初始化我们先看看main函数中u变量的声明和初始化过程。汇编代码比较大,下面只展示了部分内容,如下图。从上面可以看出,该结构体实际上是基本类型变量的集合,并没有加载额外的信息。对于User类型的u变量的声明和初始化语句,首先清空对应的栈空间,然后处理三个Initialize参数值加载到对应的栈空间位置,完成初始化过程。其中ID和Age由于是基本类型,所以比较简单,而Name字段涉及到string类型,略有不同。String类型的运行时表达式如下。type**StringHeaderstruct{DatauintptrLenint}可以看出,在上面的编译中,首先将Tom的字面值地址加载到栈空间中,将Tom的字面值存放在内存的数据段中,并且给Data变量赋值,然后将字面值的长度3加载到相应位置的Len变量赋值,如下图所示。SP表示栈顶指针,“.u+64(SP)”表示相对于栈顶偏移64字节的位置,u是引用地址的别名,也就是变量u。如图所示,在栈空间中,并没有结构体User,而是一个由基本类型值和指针组成的空间,代表结构体User。从栈顶到栈底,8字节常量值1代表User.ID,16字节字符串Tom值地址代表User.Name,8字节常量代表User.Age23,其中字符串Tom由8字节数据指针和8字节Len组成。上面代码中,变量u没有逃逸,所以分配在栈上。如果变量被声明为指针类型并且符合转义规则,则该结构将分配在堆上。funcmakeUser()*User{u:=&User{ID:1,Name:"Tom",Age:23}returnu}上面指针变量声明和初始化过程的编译如下所示。可以看出,汇编代码会先将Cat结构体的类型指针作为参数加载到栈顶;然后调用newObject函数根据Cat结构类型在堆上分配相应的空间,并返回该空间的起始地址;最后用起始地址设置结构体的变量。上图右侧显示了在堆上分配的结构图。我们可以看到,结构体在栈上分配时,其内部成员变量会按照顺序排列,占据自己固定的空间;而在堆上分配结构体时,栈上只会有一个指向堆地址的指针,该指针指向结构体在堆上的起始位置。值接收函数我们来看看结构体是如何作为函数接收者进行函数调用的,包括如何传递参数和返回值,如何转换值接收者和指针接收者等。上面涉及函数调用的片段例子如下:Go的调用协议要求函数参数和返回值通过栈传递,这部分空间由调用者在其栈帧上提供。函数接收者是隐式的第一个函数参数,所以上面代码片段的第一步是将变量u复制到对应的栈空间,这也对应了值接收者的复制机制;那么第二步就是声明一个值为1的int32类型的参数a,并赋值到指定位置;然后使用CALL指令调用User的addAgeVal函数,CALL指令会将函数的返回值地址压入栈顶,也就是存放+40的栈(SP)位置;最后将其值加载到+60(SP)中,即函数返回值赋值给变量s1。接下来我们看一下被调用函数addAgeVal函数的相关机器码表达式。addAgeVal函数大致分为四步:使用SUBQ指令将SP减16,即栈增加16字节,因为栈帧是往低位增长的,其中8字节用来存放当前栈帧指针,并使用LEAQ计算将新的栈帧指针存入BP;初始化函数的返回值,因为它的类型是int32,所以设置为对应的零值,栈空间的地址为+64(SP);从+48(SP)位置加载函数接收者User的变量Age到AX寄存器,然后将它和函数参数a累加,它的位置+56(SP)将两者之和赋值给变量n,并将两者之和存入返回值栈空间,即+64(SP);从8(SP)中取出旧的栈帧指针,将栈帧缩小16字节,调用RET指令返回。综上所述,main函数调用User的addAgeVal函数的过程如下图所示。如上图所示,我们看到main函数在执行call指令之前,为调用函数addAgeVal的参数和返回值准备了一个空间,然后将函数接收者u和对应的参数a复制到按顺序空格,然后为函数调用的返回值保留+40(SP)。正是因为复制了值接收者和函数参数,所以函数中的修改不会影响原来的值。当调用call指令时,会将指令的返回地址压入栈顶,然后执行addAgeVal函数的指令,将栈顶增加16字节,从而导致改变相对于SP的函数接收者、参数和返回值的地址。增加了16个字节,所以你会发现addAgeVal函数中指令操作的相对地址变了。在指针接收函数下面,我们来看一下调用指针接收函数addAgePtr相关的具体说明,感受一下它和值接收函数的区别。可以看出,调用addAgePtr时,并不会复制接收者u,只是将u的起始栈地址加载到栈顶,实际上相当于传递了一个指向u的指针。然后设置参数a的值,最后使用CALL指令调用addAgePtr函数。addAgePtr函数的指令与addAgeVal类似,唯一的区别是使用了一个指针来获取接收者u的Age变量的值,如下所示。从对应的栈空间中得到接收者u的指针,即它的起始地址,从起始地址开始偏移24字节就是接收者u的Age变量所在的位置。整个过程如下图所示。如上图所示,可以看到在调用指针接收者的函数时,只需要将其地址作为默认参数传入即可,所以函数中对接收者的修改是直接在原值上修改的。另外调用addAgePtr的场景是调用value变量上的指针接收函数。我们看到编译器取值的地址作为接收参数传递,而如果指针变量调用值接收函数,它会先将指针取地址,然后复制指针指向的值数据.综上所述,我们了解了Go语言在机器层面的结构体和结构体函数的底层实现。在后续文章中,我们将继续了解Go语言相关特性的底层实现。