Go学习笔记-汇编

前言 本文是笔者学习Go汇编基础知识的笔记。本文的主要内容都是借鉴文章Go汇编语言,笔者在原文基础上扩展了部分写的比较简略的内容,删除了一些笔者自己没有理解的内容。笔者希望读者朋友们阅读本文后,能够:

  • 了解Go汇编出现的背景、原因和作用;
  • 了解汇编Go汇编Go语言三者之间的关系,以及它们出现的背景和原因;
  • 利用所学的Go汇编基本语法知识,看懂golang库中一些汇编代码,同时也能手工写一些简单的Go汇编代码
  • 通过golang代码输出的Go汇编代码来学习golang语言的一些底层实现原理;
不过笔者能力有限,本文中必然存在不少错误或者遗漏之处,欢迎读者朋友们给予批评和指正。
Go汇编简介 Go汇编语言中写道:
Go语言中很多设计思想和工具都是传承自Plan9操作系统,Go汇编语言也是基于Plan9汇编演化而来。根据Rob Pike的介绍,大神Ken Thompson在1986年为Plan9系统编写的C语言编译器输出的汇编伪代码就是Plan9汇编的前身。所谓的Plan9汇编语言只是便于以手工方式书写该C语言编译器输出的汇编伪代码而已。
Go汇编继承自Plan9汇编,其相对于常见的汇编语言一个很大的特点是:可跨平台的,可移植的,与具体的底层操作系统无关的。我们在golang的库中,经常能看到*.s文件,这些都是汇编代码,我们利用Go汇编,可以更加高效的操作计算机CPU和内存,因此这些*.s文件通常都是为了提高代码运行效率。Go汇编语法和常见的汇编语言语法类似,一些地方做了简化,例如Go汇编中设置了一些伪寄存器,方便开发者使用。
Go汇编入门 汇编基本知识
Go汇编语言中写道:
汇编语言其实是一种非常简单的编程语言,因为它面向的计算机模型就是非常简单的。让人觉得汇编语言难学主要有几个原因:不同类型的CPU都有自己的一套指令;即使是相同的CPU,32位和64位的运行模式依然会有差异;不同的汇编工具同样有自己特有的汇编指令;不同的操作系统和高级编程语言和底层汇编的调用规范并不相同。
我们要想学习Go汇编,首先需要了解汇编中很基础的两个知识:
  • 寄存器
  • 内存模型
我们编写汇编代码过程中,始终贯穿着这两个知识点,Go汇编也不例外,所以学习这些知识可以帮助我们更好的理解Go汇编
寄存器 CPU只能计算,不能存储数据,程序计算中需要的数据都存储专用的硬件设备中,包括寄存器,一级缓存,二级缓存,RAM内存,磁盘等;因为CPU计算速度很快,读取/存储数据就会成为计算性能的瓶颈,现代计算机使用很多不同特性的存储设备,加快数据的I/O,尽量提高程序的计算速度,其中寄存器就是一种容量很小,但是数据I/O非常高的专用存储设备,CPU会利用寄存机来存储程序中常用的数据,例如循环中的变量。下图是计算机中的存储设备说明:
Go学习笔记-汇编
文章图片

寄存器不依靠地址区分数据,而依靠名称,每一个寄存器都有自己的名称,我们告诉 CPU 去具体的哪一个寄存器拿数据,这样的速度是最快的。有人比喻寄存器是 CPU 的零级缓存。寄存器是CPU中最重要的资源,每个要处理的内存数据原则上需要先放到寄存器中才能由CPU处理,同时寄存器中处理完的结果需要再存入内存。
Tips: 上面的粗体字标明的原则,我们简称为“寄存器很重要原则”,请记住这条原则,后面我们在分析汇编代码的时候,会反复强调这条原则
内存模型 内存模型是一个比较抽象的概念,笔者并不能很好解释这个概念。笔者自己的理解是寄存器容量很小,程序运行所需的大部分数据还是要存储在其他内存中,因此我们需要了解,程序运行过程中,CPU如何操作各种内存,内存又是如何分配来存储各种数据,特别是在多cpu,多线程的情况下,所以我们人为的制定了一套协议来规范这些行为,笔者把这个协议理解成内存模型。
下面笔者将展示一张X86-64(非常常用的计算机架构,它是AMD公司于1999年设计的x86架构的64位拓展,向后兼容于16位及32位的x86架构,X86-64目前正式名称为AMD64;我们后面的汇编代码都是基于X86-64架构)架构的体系结构图,图中我们将稍微详细讲解下内存模型,以及上文提到的寄存器和一些汇编指令。
Go学习笔记-汇编
文章图片

1.我们先来关注下图中最左边的Memory部分,其表示的就是内存模型
  • 内存从text部分到stack部分是从低地址(low)到高地址(high)增长;
  • text表示代码段,一般用于存储要执行的指令数据,可以理解成存储我们要执行的程序的代码,代码段一般都是只读的;
  • rodata和data都是数据段,一般用于存储全局数据,其中rodata表示只读数据(read only data);
  • heap表示堆,一般用于存储动态数据,堆的空间比较大,可以存储较大的数据,例如go中创建的结构体;很多具有垃圾回收(gc)的语言中,如java,go,堆中的数据都是gc扫描后被自动清理回收的;
  • stack表示栈,一般用于存储函数调用中相关的数据,例如函数中的局部变量,栈的特点就是FIFO,函数调用开始的时候,数据入栈,函数调用结束后,数据出栈,栈空间收回;go中,函数的入参和返回值也是通过栈来存储;
汇编语言入门教程中对于内存模型有比较详细的说明,读者朋友们可以自行查看。
2.然后我们来看下中间Register部分,它是X86提供的寄存器。寄存器是CPU中最重要的资源,每个要处理的内存数据原则上需要先放到寄存器中才能由CPU处理,同时寄存器中处理完的结果需要再存入内存。X86中除了状态寄存器FLAGS和指令寄存器IP两个特殊的寄存器外,还有AXBXCXDXSIDIBPSP几个通用寄存器。在X86-64中又增加了八个以R8-R15方式命名的通用寄存器。提前记住这些寄存器名字,后面的示例汇编代码中会涉及到。
3.最后我们看下右边的Instructions部分,它是X86的指令集。CPU是由指令和寄存器组成,指令是每个CPU内置的算法,指令处理的对象就是全部的寄存器和内存。我们可以将每个指令看作是CPU内置标准库中提供的一个个函数,然后基于这些函数构造更复杂的程序的过程就是用汇编语言编程的过程。指令集指令的具体含义,可以自行百度。
我们参考汇编语言学习中的一个示例来展示一下汇编语言编程。这个例子是一个功能很简单的函数:两数相加,结果赋值给一个变量sum,即sum = 5 + 6
.data; 此为数据区 sum DWORD 0; 定义名为sum的变量 .code; 此为代码区 main PROC mov eax,5; 将数字5送入而eax寄存器 add eax,6; eax寄存器加6 mox sum,eax INVOKE ExitProcess,0; 结束程序 main ENDP

稍微解释一下:
  • mov eax,5eax就是上文提到的通用寄存器AX,X86-64指令集里面我们可以不写前面的r
  • 我们看到代码中.data.code分别表示数据区和代码区,验证了上文提到的X86-64内存模型中的textdata部分;
  • 程序实现的功能是sum = 5 + 6,但是我们不能直接在sum的内存里面做5 + 6的计算,记住上文提到的寄存器的一条重要原则:每个要处理的内存数据原则上需要先放到寄存器中才能由CPU处理,同时寄存器中处理完的结果需要再存入内存。
Go汇编语法
本文不会全面的介绍Go汇编语法,笔者自己也不清楚到底有多少汇编命令;我们会介绍一些常用的命令,帮助我们后面阅读和理解Go汇编代码。
1.我们首先会介绍下Go汇编的伪寄存器。
Go汇编为了简化汇编代码的编写,引入了PC、FP、SP、SB四个伪寄存器。四个伪寄存器加其它的通用寄存器就是Go汇编语言对CPU的重新抽象,该抽象的结构也适用于其它非X86类型的体系结构。
四个伪寄存器和X86/AMD64的内存和寄存器的相互关系如下图:
Go学习笔记-汇编
文章图片

  • FP: Frame pointer:伪FP寄存器对应函数的栈帧指针,一般用来访问函数的参数和返回值;golang语言中,函数的参数和返回值,函数中的局部变量,函数中调用子函数的参数和返回值都是存储在栈中的,我们把这一段栈内存称为栈帧(frame),伪FP寄存器对应栈帧的底部,但是伪FP只包括函数的参数和返回值这部分内存,其他部分由伪SP寄存器表示;注意golang中函数的返回值也是通过栈帧返回的,这也是golang函数可以有多个返回值的原因;
  • PC: Program counter:指令计数器,用于分支和跳转,它是汇编的IP寄存器的别名;
  • SB: Static base pointer:一般用于声明函数或者全局变量,对应代码区(text)内存段底部;
  • SP: Stack pointer:指向当前栈帧的局部变量的开始位置,一般用来引用函数的局部变量,这里需要注意汇编中也有一个SP寄存器,它们的区别是:1.伪SP寄存器指向栈帧(不包括函数参数和返回值部分)的底部,真SP寄存器对应栈的顶部;所以伪SP寄存器一般用于寻址函数局部变量,真SP寄存器一般用于调用子函数时,寻址子函数的参数和返回值(后面会有具体示例演示);2.当需要区分伪寄存器和真寄存器的时候只需要记住一点:伪寄存器一般需要一个标识符和偏移量为前缀,如果没有标识符前缀则是真寄存器。比如(SP)、+8(SP)没有标识符前缀为真SP寄存器,而a(SP)、b+8(SP)有标识符为前缀表示伪寄存器;
2.Go汇编常用语法
下面我们重点介绍几个Go汇编常用语法,方便我们后续阅读理解示例Go汇编代码。
  • 常量
    Go汇编语言中常量以$美元符号为前缀。常量的类型有整数常量、浮点数常量、字符常量和字符串常量等几种类型。以下是几种类型常量的例子:
    $1// 十进制 $0xf4f8fcff// 十六进制 $1.5// 浮点数 $'a'// 字符 $"abcd"// 字符串

  • DATA命令用于初始化包变量,DATA命令的语法如下:
    DATA symbol+offset(SB)/width, value
    其中symbol为变量在汇编语言中对应的标识符,offset是符号开始地址的偏移量,width是要初始化内存的宽度大小,value是要初始化的值。其中当前包中Go语言定义的符号symbol,在汇编代码中对应·symbol,其中·中点符号为一个特殊的unicode符号;DATA命令示例如下
    DATA ·Id+0(SB)/1,$0x37 DATA ·Id+1(SB)/1,$0x25

    这两条指令的含义是将全局变量Id赋值为16进制数0x2537,也就是十进制的9527;
    我们也可以合并成一条指令
    DATA ·Id+0(SB)/2,$9527

  • GLOBL命令用于将符号导出,例如将全局变量导出(所谓导出就是把汇编中的全局变量导出到go代码中声明的相同变量上,否则go代码中声明的变量感知不到汇编中变量的值的变化),其语法如下:
    GLOBL symbol(SB), width
    其中symbol对应汇编中符号的名字,width为符号对应内存的大小;GLOBL命令示例如下:
    GLOBL ·Id, $8这条指令的含义是导出一个全局变量Id,其大小是8字节(byte);
    结合DATAGLOBL指令,我们就可以初始化并导出一个全局变量
    GLOBL ·Id, $8 DATA ·Id+0(SB)/1,$0x37 DATA ·Id+1(SB)/1,$0x25 DATA ·Id+2(SB)/1,$0x00 DATA ·Id+3(SB)/1,$0x00 DATA ·Id+4(SB)/1,$0x00 DATA ·Id+5(SB)/1,$0x00 DATA ·Id+6(SB)/1,$0x00 DATA ·Id+7(SB)/1,$0x00

  • TEXT命令是用于定义函数符号,其语法如下
    TEXT symbol(SB), [flags,] $framesize[-argsize]

    函数的定义部分由5个部分组成:TEXT指令、函数名、可选的flags标志、函数帧大小和可选的函数参数大小。
    其中TEXT用于定义函数符号,函数名中当前包的路径可以省略。函数的名字后面是(SB),表示是函数名符号相对于SB伪寄存器的偏移量,二者组合在一起最终是绝对地址。作为全局的标识符的全局变量和全局函数的名字一般都是基于SB伪寄存器的相对地址。标志部分用于指示函数的一些特殊行为,标志在textlags.h文件中定义,常见的NOSPLIT主要用于指示叶子函数不进行栈分裂。framesize部分表示函数的局部变量需要多少栈空间,其中包含调用其它函数时准备调用参数的隐式栈空间。最后是可以省略的参数大小,之所以可以省略是因为编译器可以从Go语言的函数声明中推导出函数参数的大小。
    TEXT命令后续会在讲解函数部分详细说明。
  • 其他常用命令
    SUBQ $0x18, SP// 分配函数栈,操作数 8 个字节 ADDQ $0x18, SP// 清除函数栈,操作数据 8 个字节 MOVB $1, DI// 拷贝 1个字节 MOVW $0x10, BX// 拷贝 2 个字节 MOVD $1, DX// 拷贝 4 个字节 MOVQ $-10, AX// 拷贝 8 个字节 ADDQ AX, BX// BX = BX + AX 存 BX SUBQ AX, BX// BX = BX - AX 存 BX IMULQ AX, BX// BX = BX * AX 存 BX MOVQ AX, BX// BX = AX 将 AX 中的值赋给 BX MOVQ (AX), BX// BX = *AX 加载 AX 中指向内存地址的值给 BX MOVQ 16(AX), BX// BX = *(AX + 16) 偏移 16 个字节后地址中的值

    注意在X86-64指令集中,指令决定操作数尺寸,例如XXXB = 1 XXXW = 2 XXXD = 4 XXXQ = 8
Go汇编编程
本节开始,我们将利用Go汇编编写一些简单的代码(终于开始撸代码了orz),包括:定义变量、定义函数、控制流代码、系统调用。笔者演示的go版本是go1.15.4 darwin/amd64
首先需要说明Go汇编语言并不是一个独立的语言,因为Go汇编程序无法独立使用。Go汇编代码必须以Go包的方式组织,同时包中至少要有一个Go语言文件用于指明当前包名等基本包信息。如果Go汇编代码中定义的变量和函数要被其它Go语言代码引用,还需要通过Go语言代码将汇编中定义的符号声明出来。用于变量的定义和函数的定义Go汇编文件类似于C语言中的.c文件,而用于导出汇编中定义符号的Go源文件类似于C语言的.h文件。
我们在goland中创建一个工程,将go代码和汇编代码放到同一个pkg下面,我们在go代码中声明了3个变量,同时在汇编代码中初始化这3个变量,我们可以直接运行main函数查看变量打印结果。
Go学习笔记-汇编
文章图片

Go学习笔记-汇编
文章图片

定义变量 1.定义基本数据类型变量
#include "textflag.h" // var MyInt int = 1234 GLOBL ·MyInt(SB),NOPTR,$8 DATA ·MyInt+0(SB)/8,$1234// var MyFloat float64 = 56.78 GLOBL ·MyFloat(SB),NOPTR,$8 DATA ·MyFloat+0(SB)/8,$56.78// var MyBool bool = true GLOBL ·MyBool(SB),NOPTR,$1 DATA ·MyBool+0(SB)/1,$1

我们稍微解释一下上面的Go汇编代码:
  • GLOBLDATA命令结合起来,可以初始化一个全局变量并导出;
  • 全局变量都是通过伪SB寄存器寻址的;
  • GLOBL命令中需要加上NOPTR flag,表示这个变量不是一个指针,因为golang自带垃圾回收,当gc扫描到该变量时候,需要明确知道该变量是否包含指针;Go汇编语言中示例代码是没有加上NOPTR flag,理由是go代码中已经声明变量类型,但是我们实际运行中发现不加NOPTR flag会报错:runtime.gcdata: missing Go type information for global symbol main.MyInt: size 8,具体原因笔者也没搞清楚;
  • 汇编文件头需要加上#include "textflag.h",表示引入了runtime/textflag.h文件,该文件预定义了一些flag,其中包括NOPTR flag,具体含义我们后面会说明;
2.定义string
// var MyStr0 string GLOBL NameData<>(SB),NOPTR,$8 DATANameData<>(SB)/8,$"abc"GLOBL ·MyStr0(SB),NOPTR,$16 DATA·MyStr0+0(SB)/8,$NameData<>(SB) DATA·MyStr0+8(SB)/8,$3

  • 我们知道golang中string类型可以通过stringHeader来表示,所以MyStr0对应一个16字节的内存块,注意就像我们之前说的,汇编中是没有类型概念的,我们操作的都是内存和内存地址;
    type stringHeader struct { Data unsafe.Pointer Lenint }

  • ·NameData<>(SB)是临时变量,是用来辅助表示string中字符串内容的,其中<>表示是私有变量;
  • DATA ·MyStr0+0(SB)/8,$·NameData<>(SB)表示把·NameData<>(SB)对应的地址赋值给·MyStr0+0(SB),宽度是8;
    3.定义数组
    // var MyArray [2]int = {12, 34} GLOBL ·MyArray(SB),NOPTR,$16 DATA·MyArray+0(SB)/8,$12 DATA·MyArray+8(SB)/8,$34

    4.定义切片
// 定义三个string临时变量,作为切片元素 GLOBL str0<>(SB),NOPTR,$48 DATAstr0<>(SB)/48,$"Thoughts in the Still of the Night"GLOBL str1<>(SB),NOPTR,$48 DATAstr1<>(SB)/48,$"A pool of moonlight before the bed"GLOBL str2<>(SB),NOPTR,$8 DATAstr2<>(SB)/8,$"libai"// 定义一个[3]string的数组,元素就是上面的三个string变量 GLOBL strarray<>(SB),NOPTR,$48 DATAstrarray<>+0(SB)/8,$str0<>(SB) DATAstrarray<>+8(SB)/8,$34 DATAstrarray<>+16(SB)/8,$str1<>(SB) DATAstrarray<>+24(SB)/8,$34 DATAstrarray<>+32(SB)/8,$str2<>(SB) DATAstrarray<>+40(SB)/8,$5// var MySlice []string GLOBL ·MySlice(SB),NOPTR,$24 // 上面[3]string数组的首地址用来初始化切片的Data字段 DATA·MySlice+0(SB)/8,$strarray<>(SB) DATA·MySlice+8(SB)/8,$3 DATA·MySlice+16(SB)/8,$4// 定义一个[3]*string的数组,元素就是上面三个string变量的地址 GLOBL strptrarray<>(SB),NOPTR,$24 DATAstrptrarray<>+0(SB)/8,$strarray<>+0(SB) DATAstrptrarray<>+8(SB)/8,$strarray<>+16(SB) DATAstrptrarray<>+16(SB)/8,$strarray<>+32(SB)// var MyPtrSlice []*string GLOBL ·MyPtrSlice(SB),NOPTR,$24 // 上面[3]*string数组的首地址用来初始化切片的Data字段 DATA·MyPtrSlice+0(SB)/8,$strptrarray<>(SB) DATA·MyPtrSlice+8(SB)/8,$3 DATA·MyPtrSlice+16(SB)/8,$4

  • golang中切片是用下面的数据结构来表示的
// sliceHeader is a safe version of SliceHeader used within this package. type sliceHeader struct { Data unsafe.Pointer Lenint Capint }

  • 我们构造[]string[]*string切片的思路是同样的,先构造出数组[3]string[3]*string,再把数组的地址赋值给切片的Data字段;
定义函数 函数是golang中的一等公民(first-class),函数在go汇编中比较重要也比较复杂,只有掌握了汇编函数的基本用法,才能真正算是Go汇编语言入门。
接下来我们将会介绍汇编函数的定义、参数和返回值、局部变量和调用子函数。
函数定义 回忆之前的TEXT语法
TEXT symbol(SB), [flags,] $framesize[-argsize]

我们示范一个简单的Swap函数
// func Swap(a, b int) (ret0 int, ret1 int) TEXT ·Swap(SB), NOSPLIT, $0-32 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

我们先忽略函数的具体实现,只专注函数的定义部分:TEXT ·Swap(SB), NOSPLIT, $0-32
1.因为Swap函数没有局部变量,有2个int入参,2个int返回值,所以$framesize[-argsize]需要写成$0-32或者$0,其实可以省略argsize部分,go编译器能根据go文件中的函数定义推断出argsize大小,所以Swap函数可以写成下面两种形式:
// 完整定义,包含argsize TEXT ·Swap(SB), NOSPLIT, $0-32 // 简略定义,不包含argsize TEXT ·Swap(SB), NOSPLIT, $0

下面是2种函数定义的对比图
Go学习笔记-汇编
文章图片

2.我们注意到函数定义中有NOSPLIT flag,目前可能遇到的函数标志有NOSPLITWRAPPERNEEDCTXT几个。其中NOSPLIT不会生成或包含栈分裂代码,这一般用于没有任何其它函数调用的叶子函数,这样可以适当提高性能。WRAPPER标志则表示这个是一个包装函数,在panicruntime.caller等某些处理函数帧的地方不会增加函数帧计数。最后的NEEDCTXT表示需要一个上下文参数,一般用于闭包函数。这些falg都定义在文件textflag.h中。
3.函数定义中也是没有类型的,上面的Swap函数签名可以改写成下面任意一种,只要满足入参加上返回值,占用内存是32字节就可以了
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) // ...

对于汇编函数来说,只要是函数的名字和参数大小一致就可以是相同的函数了。而且在Go汇编语言中,输入参数和返回值参数是没有任何的区别的。
函数参数和返回值 本节我们将讨论一下函数参数和返回值如何在Go汇编中寻址和函数参数和返回值的内存布局。
1.函数参数和返回值,在Go汇编中可以通过伪寄存器FP寻址找到,例如上面Swap函数中,我们通过伪寄存器FP偏移相应的数量,就能找到对应的入参和返回值,注意在栈中,我们是按照ret1,ret0,b,a的顺序入栈,伪寄存器FP指向的是第一参数的地址。
a+0(FP) // 入参a b+8(FP) // 入参b ret0+16(FP) // 返回值ret0 ret1+24(FP) // 返回值ret1

2.Swap函数中的入参和返回值在内存中布局如下:
Go学习笔记-汇编
文章图片

3.如何计算函数的入参和返回值打下
虽然golang可以通过函数定义推断出函数的入参和返回值大小,不过我们自己也应该要知道如何计算。笔者推荐利用golang自带的unsafe包来计算。我们假设计算下面函数的入参和返回值大小
func Foo(a bool, b int16) (c []byte)
我们大体思路如下:
  • 每个参数占用内存大小,除了受参数本身类型影响,还受内存对齐特性影响;
  • 我们构造结构体,通过unsafe包来计算每个参数对齐后占用内存大小(结构体中的字段也会内存对齐);
    具体代码如下:
// func Foo(a bool, b int16) (c []byte) type FooArgs struct { a bool b int16 c []byte }func computeFooArgsSize() { args := FooArgs{ a: false, b: 0, c: nil, } // 参数a的偏移量 aOffset := 0 // 参数b的偏移量 bOffset := unsafe.Offsetof(args.b) // 参数c的偏移量 cOffset := unsafe.Offsetof(args.c) // 参数总大小 size := unsafe.Sizeof(args) fmt.Printf("参数a偏移量:%v byte, 定位:a+%v(FP)\n参数b偏移量:%v byte, 定位:b+%v(FP)\n返回值c偏移量:%v byte, 定位:c+%v(FP)\n参数总大小:%v\n", aOffset, aOffset, bOffset, bOffset, cOffset, cOffset, size) }

运行结果如下:
参数a偏移量:0 byte, 定位:a+0(FP) 参数b偏移量:2 byte, 定位:b+2(FP) 返回值c偏移量:8 byte, 定位:c+8(FP) 参数总大小:32

Foo函数的入参和返回值布局如下:
Go学习笔记-汇编
文章图片

函数局部变量 Go汇编中局部变量是指当前函数栈帧内对应的内存内的变量,不包含函数的入参和返回值(访问方式不一样),注意Go汇编中局部变量不能狭隘的理解成go语法中的局部变量,汇编中局部变量包含更多含义,除了包含go语法中的局部变量,Go汇编中函数调用子函数的入参和返回值也是存在调用者(caller)的栈帧中的。我们首先在看下不调用子函数情况下的局部变量。
我们利用汇编实现一个简单函数
func Foo() (ret0 bool, ret1 int16, ret2 []byte) { var c []byte = []byte("abc") var b int16 = 234 var a bool = true return a, b, c }

函数Foo中有三个局部变量,这三个局部变量也同时作为返回值返回,我们可以利用前面计算Swap函数中的入参和返回值占用内存大小的方法,计算函数Foo局部变量和返回值占用内存的大小,同样都是32字节。我们可以构造Go汇编代码如下
// 构造临时变量{'a','b','c'} GLOBL ·tmp_bytes(SB),NOPTR,$8 DATA ·tmp_bytes+0(SB)/8,$"abc"// func Foo() (bool, int16, []byte) TEXT ·Foo(SB), NOSPLIT, $32-32 MOVQ $1, a-32(SP) // var a bool = true MOVQ $234, b-30(SP) // var b int16 = 234 // var c = []byte("abc") LEAQ ·tmp_bytes(SB),AX MOVQ AX, c_data-8(SP) MOVQ $3, c_len-16(SP) MOVQ $4, c_cap-24(SP)// ret0 = a MOVQ a-32(SP), AX MOVQ AX, ret0+0(FP)// ret1 = b MOVQ b-30(SP), AX MOVQ AX, ret1+2(FP) // ret2 = c MOVQ c_data-24(SP), AX MOVQ AX, ret2_data+24(FP) MOVQ c_len-16(SP), AX MOVQ AX, ret2_len+16(FP) MOVQ c_cap-8(SP), AX MOVQ AX, ret2_cap+8(FP) RET

函数Foo局部变量的内存布局如下图:
Go学习笔记-汇编
文章图片

函数中调用子函数 实践中,通过Go汇编实现的函数一般都是叶子函数,也就是被其他函数调用的函数,不会调用其他子函数,原因一是叶子函数逻辑比较简单,便于用汇编编写,二是一般的性能瓶颈都在叶子函数上,用Go汇编来编写正是为了优化性能;但是Go汇编也是可以在函数中调用子函数的,否则Go汇编就不是一个完整的汇编语言。
我们总结了几条调用子函数的规则:
  • 调用函数(caller)负责提供内存空间来存储被调用函数(callee)需要的入参和返回值;
  • 调用函数(caller)将被调用函数(callee)需要的入参和返回值存储在自己的栈帧中;入栈顺序最后的返回值先入栈,第一个入参最后入栈,和被调用函数(callee)用伪寄存器FP寻址顺序一致;调用函数(caller)用伪寄存器SP寻址存储被调用函数(callee)的入参和返回值;
  • 被调用函数(callee)返回后,返回值会存储到对应的调用函数(caller)的栈帧中,调用函数(caller)用伪寄存器SP寻址读取返回值;
    注意Go语言函数的调用参数和返回值均是通过栈传输的,这样做的优点是函数调用栈比较清晰,缺点是函数调用有一定的性能损耗(Go编译器是通过函数内联来缓解这个问题的影响);
    我们构造一个多级调用的函数来展示函数调用情况:
func main() { printsum(1, 2) }func myprint(v int) { println(v) }func printsum(a, b int) { var ret = sum(a, b) myprint(ret) }func sum(a, b int) int { return a+b }

我们用Go汇编来实现函数printsumsum
// func printsum(a, b int) TEXT ·printsum(SB), $24 MOVQ a+0(FP), AX// AX = a MOVQ b+8(FP), BX// BX = b MOVQ AX, ia-24(SP) // sum函数的入参和返回值布局:ret -> b -> a MOVQ BX, ia-16(SP) CALL ·sum(SB) // 调用sum函数 MOVQ ret-8(SP), AX // sum函数返回值在伪寄存器SP的栈底,读取返回值放入栈顶,作为myprint函数的入参 MOVQ AX, ia-24(SP) // 伪寄存器SP的栈顶 CALL ·myprint(SB) // 调用myprint函数 RET// func sum(a, b int) int TEXT ·sum(SB), $0 MOVQ a+0(FP), AX// AX = a MOVQ b+8(FP), BX// BX = b ADDQ AX, BX MOVQ BX, ret0+16(FP) RET

同时,我们看下内存布局:
Go学习笔记-汇编
文章图片

宏函数 宏函数并不是Go汇编语言所定义,而是Go汇编引入的预处理特性自带的特性。
笔者自己对宏函数不是很了解,这里只放一个示例:用宏函数来实现Swap函数。
// 定义宏函数 #define SWAP(x, y, t) MOVQ x, t; MOVQ y, x; MOVQ t, y// 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 = bSWAP(AX, BX, CX)// AX, BX = b, aMOVQ AX, ret0+16(FP) // return MOVQ BX, ret1+24(FP) // RET

函数中的控制流 控制流对于一门编程语言是非常重要和非常基础的,一般程序主要有顺序、分支和循环几种执行流程。本节我们从Go汇编的角度来讨论下程序中控制流的实现。
  1. 顺序执行
    首先,前面的很多例子都是顺序执行流程,我们就不额外举例子了。
  2. 条件跳转
    go语言中通过if/else语句来实现条件跳转,其实go语言也支持goto语句,我们可以通过if/goto来实现条件跳转,但是不推荐大家使用;我们通过汇编来实现条件跳转,思路却是近似if/goto
    我们尝试实现一个If函数:
func If(ok bool, a, b int) int { if ok { return a } else { return b } }

我们先用if/goto的方式来重写一遍:
func If(ok int, a, b int) int { if ok == 0 { goto L } return a L: return b }

这种方式其实也很接近汇编的思路了,我们用Go汇编来实现下
// func If(ok int, a, b int) int TEXT ·If(SB), $0-32 MOVQ ok+0(FP), CX // CX = ok MOVQ a+8(FP), AX // AX = a MOVQ b+16(FP), BX // BX = bCMPQ CX, $0 // 比较CX是否等于0 JZ L // 如果CX等于0,则跳转到L MOVQ AX, ret+24(FP) // return a RET L: MOVQ BX, ret+24(FP) // retrun b RET

  1. for循环
    for循环有多种形式,最经典的for循环由初始化,结束条件和步长迭代三部分组成,再配合循环体内的条件跳转,这种经典for循环形式可以模拟其他各种循环形式。Go汇编中实现这种经典的for循环结构,使用的也是类似if/goto的方式。
    我们基于经典的for循环形式,构造一个LoopAdd函数来计算等差数列的和:
func LoopAdd(cnt, v0, step int) int { result := 0 for i := 0; i < cnt; i++ { result += v0 v0 += step } return result }

我们先用if/goto的方式来改下上面的函数:
func LoopAdd(cnt, v0, step int) int { result := 0 i := 0 LoopIf: if i < cnt { goto LoopBody } goto LoopEndLoopBody: result += v0 v0 += step i++ goto LoopIfLoopEnd: return result }

最后我们用汇编来实现:
// func LoopAdd(cnt, v0, step int) int TEXT ·LoopAdd(SB), $0-32 MOVQ cnt+0(FP), AX // AX = cnt MOVQ v0+8(FP), BX // BX = v0 MOVQ step+16(FP), CX // CX = stepMOVQ $0, DX // i = 0 MOVQ $0, ret+24(FP) // result = 0 LOOPIF: CMPQ DX, AX JL LOOPBODY JMP LOOPENDLOOPBODY: ADDQ BX, ret+24(FP) // result += v0 ADDQ CX, BX // v0 += step ADDQ $1, DX // i++ JMP LOOPIFLOOPEND: RET

几种特殊的函数 我们之前介绍的函数都是比较简单的函数,本节我们将介绍一些复杂的函数实现;首先我们介绍一下函数的调用规范,更加深入的了解一下函数的调用细节;然后我们分别介绍一下:方法函数、递归函数和闭包。
  1. Go函数调用规范
    我们之前讲过Go汇编中函数的调用,只是简单说了下被调函数的入参和返回值如何传递的问题,本节我们更加深入的讲解函数调用的细节。感兴趣的小伙伴可以参考这篇文章:图解函数调用过程,文章里面说的很详细,我们这里就不赘述了。
    我们这里给出一张Go汇编函数调用规范图解说明:
    Go学习笔记-汇编
    文章图片
  • 从图中我们可以看出函数调用过程中,栈都是连续的,例如函数A中调用函数B,那么这两个函数所占用的栈空间是连接在一起的一块连续空间;
  • 图示中展开了CALLRET两个命令,简单来说就是CALL命令约等于PUSH IPJMP callee_func两个命令的组合,其中PUSH IP命令相当于SUBQ $-1, SPMOVQ IP, (SP),即栈帧扩展1个字节,然后把IP寄存器的存储值写入SP寄存器对应的内存地址中,由于IP寄存器中存储的值可以简单理解为CALL命令的下一条指令的地址,这条指令就是CALL命令调用子函数返回后,我们需要执行的命令地址,所以我们也称之为return addressJMP callee_func命令意思是将子函数的地址加入IP寄存器,这样就实现了函数跳转;RET命令刚好想反,把之前栈中存储的return address地址写入IP寄存器并跳转回去继续执行下面的命令;
  • 一般一个函数的栈帧包括局部变量和调用子函数所需的入参和返回值,当执行到一个函数的时候,函数的栈帧对应的栈内存的栈底地址是存储在BP寄存器中的,这个值一般是不变的,所以BP叫做基址指针寄存器,栈帧的栈顶地址是存储在SP寄存器中的,这个值是在不断变化的,因为函数执行过程中经常需要栈扩展来存储局部变量等,所以SP寄存器也叫栈指针寄存器;图中caller's BP保存的就是调用函数当时BP寄存器中的值,也就是调用函数栈帧的栈底地址,我们子函数返回后,需要通过这个值来回复调用函数的栈帧的栈底地址;
  • 注意图中的argsizeframesize,他们都是栈中一部分,不是全部,Go汇编隐藏了一部分调用细节;
  • 上面提到的CALLRET两个命令是不处理被调用函数的入参和返回值的,因为它们是由调用函数来准备的,切记这一点;
    下面通过一个示例来验证一下函数调用规范,我们先构建一个多级函数调用的示例,文件命名为asm_call.go
package mainimport "math/rand"func Fun01(a, b int) int { c := Fun02(a, b) return c }func Fun02(a, b int) int { c := Fun03(a,b) d := Fun04(c, b, a) return d }func Fun03(a, b int) int { r := rand.Intn(1000) if r > 500 { return a + b + r } return a + b }func Fun04(c, b, a int) int { r := rand.Intn(1000) if r > 500 { return a + b + c } return a + b + r }

我们先首先尝试用Go汇编来重写Fun01Fun02两个函数
// func Fun01(a, b int) int TEXT ·Fun01(SB), $24-24 MOVQ a+0(FP), AX// AX = a MOVQ b+8(FP), BX// BX = b MOVQ AX, f2_a-24(SP) // Fun02入参a MOVQ BX, f2_b-16(SP) // Fun02入参b CALL ·Fun02(SB) // 调用Fun02函数 MOVQ f2_ret-8(SP), CX // Fun02函数的返回值赋值给CX MOVQ CX, ret+16(FP) // 返回值 RET// func Fun02(a, b int) int TEXT ·Fun02(SB), $32-24 MOVQ a+0(FP), AX// AX = a MOVQ b+8(FP), BX// BX = b MOVQ AX, f3_a-32(SP) // Fun03入参a MOVQ BX, f3_b-24(SP) // Fun03入参b CALL ·Fun03(SB) // 调用Fun03函数MOVQ f3_ret-16(SP), CX // Fun03函数的返回值赋值给CX MOVQ f3_b-24(SP), BX MOVQ f3_a-32(SP), AX MOVQ CX, f4_c-32(SP) // Fun04入参c MOVQ BX, f4_b-24(SP) // Fun04入参b,这里可以省略 MOVQ AX, f4_a-16(SP) // Fun04入参aCALL ·Fun04(SB) // 调用Fun04函数MOVQ f4_ret-8(SP), AX // 返回值 MOVQ AX, ret+16(FP) // 返回值 RET

我们再通过go tool compile -S ./asm_call.go >./asm_call.txt命令输出最终的目标代码,对比两者的不同
"".Fun01 STEXT size=80 args=0x18 locals=0x20 0x0000 00000 (asm_caller/asm_call.go:15)TEXT"".Fun01(SB), ABIInternal, $32-24 0x0000 00000 (asm_caller/asm_call.go:15)MOVQ(TLS), CX 0x0009 00009 (asm_caller/asm_call.go:15)CMPQSP, 16(CX) 0x000d 00013 (asm_caller/asm_call.go:15)PCDATA$0, $-2 0x000d 00013 (asm_caller/asm_call.go:15)JLS73 0x000f 00015 (asm_caller/asm_call.go:15)PCDATA$0, $-1 0x000f 00015 (asm_caller/asm_call.go:15)SUBQ$32, SP 0x0013 00019 (asm_caller/asm_call.go:15)MOVQBP, 24(SP) 0x0018 00024 (asm_caller/asm_call.go:15)LEAQ24(SP), BP 0x001d 00029 (asm_caller/asm_call.go:15)FUNCDATA$0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d 00029 (asm_caller/asm_call.go:15)FUNCDATA$1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d 00029 (asm_caller/asm_call.go:16)MOVQ"".a+40(SP), AX 0x0022 00034 (asm_caller/asm_call.go:16)MOVQAX, (SP) 0x0026 00038 (asm_caller/asm_call.go:16)MOVQ"".b+48(SP), AX 0x002b 00043 (asm_caller/asm_call.go:16)MOVQAX, 8(SP) 0x0030 00048 (asm_caller/asm_call.go:16)PCDATA$1, $0 0x0030 00048 (asm_caller/asm_call.go:16)CALL"".Fun02(SB) 0x0035 00053 (asm_caller/asm_call.go:16)MOVQ16(SP), AX 0x003a 00058 (asm_caller/asm_call.go:17)MOVQAX, "".~r2+56(SP) 0x003f 00063 (asm_caller/asm_call.go:17)MOVQ24(SP), BP 0x0044 00068 (asm_caller/asm_call.go:17)ADDQ$32, SP 0x0048 00072 (asm_caller/asm_call.go:17)RET 0x0049 00073 (asm_caller/asm_call.go:17)NOP 0x0049 00073 (asm_caller/asm_call.go:15)PCDATA$1, $-1 0x0049 00073 (asm_caller/asm_call.go:15)PCDATA$0, $-2 0x0049 00073 (asm_caller/asm_call.go:15)CALLruntime.morestack_noctxt(SB) 0x004e 00078 (asm_caller/asm_call.go:15)PCDATA$0, $-1 0x004e 00078 (asm_caller/asm_call.go:15)JMP0"".Fun02 STEXT size=114 args=0x18 locals=0x28 0x0000 00000 (asm_caller/asm_call.go:20)TEXT"".Fun02(SB), ABIInternal, $40-24 0x0000 00000 (asm_caller/asm_call.go:20)MOVQ(TLS), CX 0x0009 00009 (asm_caller/asm_call.go:20)CMPQSP, 16(CX) 0x000d 00013 (asm_caller/asm_call.go:20)PCDATA$0, $-2 0x000d 00013 (asm_caller/asm_call.go:20)JLS107 0x000f 00015 (asm_caller/asm_call.go:20)PCDATA$0, $-1 0x000f 00015 (asm_caller/asm_call.go:20)SUBQ$40, SP 0x0013 00019 (asm_caller/asm_call.go:20)MOVQBP, 32(SP) 0x0018 00024 (asm_caller/asm_call.go:20)LEAQ32(SP), BP 0x001d 00029 (asm_caller/asm_call.go:20)FUNCDATA$0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d 00029 (asm_caller/asm_call.go:20)FUNCDATA$1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d 00029 (asm_caller/asm_call.go:21)MOVQ"".a+48(SP), AX 0x0022 00034 (asm_caller/asm_call.go:21)MOVQAX, (SP) 0x0026 00038 (asm_caller/asm_call.go:21)MOVQ"".b+56(SP), CX 0x002b 00043 (asm_caller/asm_call.go:21)MOVQCX, 8(SP) 0x0030 00048 (asm_caller/asm_call.go:21)PCDATA$1, $0 0x0030 00048 (asm_caller/asm_call.go:21)CALL"".Fun03(SB) 0x0035 00053 (asm_caller/asm_call.go:21)MOVQ16(SP), AX 0x003a 00058 (asm_caller/asm_call.go:22)MOVQAX, (SP) 0x003e 00062 (asm_caller/asm_call.go:22)MOVQ"".b+56(SP), AX 0x0043 00067 (asm_caller/asm_call.go:22)MOVQAX, 8(SP) 0x0048 00072 (asm_caller/asm_call.go:22)MOVQ"".a+48(SP), AX 0x004d 00077 (asm_caller/asm_call.go:22)MOVQAX, 16(SP) 0x0052 00082 (asm_caller/asm_call.go:22)CALL"".Fun04(SB) 0x0057 00087 (asm_caller/asm_call.go:22)MOVQ24(SP), AX 0x005c 00092 (asm_caller/asm_call.go:23)MOVQAX, "".~r2+64(SP) 0x0061 00097 (asm_caller/asm_call.go:23)MOVQ32(SP), BP 0x0066 00102 (asm_caller/asm_call.go:23)ADDQ$40, SP 0x006a 00106 (asm_caller/asm_call.go:23)RET 0x006b 00107 (asm_caller/asm_call.go:23)NOP 0x006b 00107 (asm_caller/asm_call.go:20)PCDATA$1, $-1 0x006b 00107 (asm_caller/asm_call.go:20)PCDATA$0, $-2 0x006b 00107 (asm_caller/asm_call.go:20)CALLruntime.morestack_noctxt(SB) 0x0070 00112 (asm_caller/asm_call.go:20)PCDATA$0, $-1 0x0070 00112 (asm_caller/asm_call.go:20)JMP0

这里我们一起梳理下Fun02函数的目标代码
  • 函数声明中,TEXT "".Fun02(SB), ABIInternal, $40-24,frame-size参数目标代码是40,比Go汇编中的32要大8字节,原因是要多一个caller's BP的值要存储;
  • Go编译器会自动加入栈扩容的代码,大意是判断现在的栈大小是否到达了扩容阈值,如果是的话,就调用扩容函数,扩容结束后会继续跳转到函数开头重新执行,这个也是Go语言不会发生爆栈的重要原因,对应的目标代码如下
0x0000 00000 (asm_caller/asm_call.go:20)MOVQ(TLS), CX // 加载Goroutine结构g到CX 0x0009 00009 (asm_caller/asm_call.go:20)CMPQSP, 16(CX) // 比较当前SP中的值和g中的stackguard0 0x000d 00013 (asm_caller/asm_call.go:20)JLS107 // 如果SP中的值超出阈值,跳转到107,执行runtime.morestack_noctxt ... 0x006b 00107 (asm_caller/asm_call.go:20)CALLruntime.morestack_noctxt(SB) // 栈扩容 0x0070 00112 (asm_caller/asm_call.go:20)JMP0 // 跳转回函数开头重新执行

  • 函数调用过程中都会有栈扩展、caller's BP入栈和callee BP初始化过程,对应目标代码如下
0x000f 00015 (asm_caller/asm_call.go:20)SUBQ$40, SP // SP中的栈帧栈顶地址减小40,即栈扩展40字节,就是函数定义的frame-size的大小 0x0013 00019 (asm_caller/asm_call.go:20)MOVQBP, 32(SP) // 将BP的值存储到(SP)+32地址对应的内存,即将caller的栈帧栈底地址存储到callee函数的栈上 0x0018 00024 (asm_caller/asm_call.go:20)LEAQ32(SP), BP // 将此时栈上栈帧栈底地址写入BP寄存器,即callee BP初始化

  • PCDATAFUNCDATA都是Go编译器自动生成的指令,它们都是和函数表格相关,具体可以参考文章:PCDATA和FUNCDATA
  • 目标代码的其他部分和我们Go汇编基本一样,只要注意一下真伪SP寄存器的不同;
本节涉及的知识点其实很多,想要言简意赅的说清楚其实不容易,笔者建议大家看看文章:函数调用规范。
有了上面的基础,下面我们具体来分析几种特殊的函数,我们采用的分析方法是:先构建Go代码示例,然后输出汇编目标代码,查看汇编实现,分析实现逻辑,最后我们采用Go汇编的方式实现一遍。
  1. 方法函数
    我们先来构建一个简单的方法函数
package maintype MyInt intfunc (i MyInt) Add1() int { return int(i) + 1 }func (i *MyInt) Add2() int { return int(*i) + 1 }

我们使用命令go tool compile -S来输出目标代码(只截取了重要部分):
"".MyInt.Add1 STEXT nosplit size=14 args=0x10 locals=0x0 0x0000 00000 (asm_method/asm_method.go:5)TEXT"".MyInt.Add1(SB), NOSPLIT|ABIInternal, $0-16 0x0000 00000 (asm_method/asm_method.go:5)FUNCDATA$0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x0000 00000 (asm_method/asm_method.go:5)FUNCDATA$1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x0000 00000 (asm_method/asm_method.go:6)MOVQ"".i+8(SP), AX 0x0005 00005 (asm_method/asm_method.go:6)INCQAX 0x0008 00008 (asm_method/asm_method.go:6)MOVQAX, "".~r0+16(SP) 0x000d 00013 (asm_method/asm_method.go:6)RET 0x0000 48 8b 44 24 08 48 ff c0 48 89 44 24 10 c3H.D$.H..H.D$.. "".(*MyInt).Add2 STEXT nosplit size=17 args=0x10 locals=0x0 0x0000 00000 (asm_method/asm_method.go:9)TEXT"".(*MyInt).Add2(SB), NOSPLIT|ABIInternal, $0-16 0x0000 00000 (asm_method/asm_method.go:9)FUNCDATA$0, gclocals·1a65e721a2ccc325b382662e7ffee780(SB) 0x0000 00000 (asm_method/asm_method.go:9)FUNCDATA$1, gclocals·69c1753bd5f81501d95132d08af04464(SB) 0x0000 00000 (asm_method/asm_method.go:10)MOVQ"".i+8(SP), AX 0x0005 00005 (asm_method/asm_method.go:10)MOVQ(AX), AX 0x0008 00008 (asm_method/asm_method.go:10)INCQAX 0x000b 00011 (asm_method/asm_method.go:10)MOVQAX, "".~r0+16(SP) 0x0010 00016 (asm_method/asm_method.go:10)RET 0x0000 48 8b 44 24 08 48 8b 00 48 ff c0 48 89 44 24 10H.D$.H..H..H.D$. 0x0010 c3. "".(*MyInt).Add1 STEXT dupok nosplit size=85 args=0x10 locals=0x8 0x0000 00000 (:1)TEXT"".(*MyInt).Add1(SB), DUPOK|NOSPLIT|WRAPPER|ABIInternal, $8-16 0x0000 00000 (:1)MOVQ(TLS), CX 0x0009 00009 (:1)SUBQ$8, SP 0x000d 00013 (:1)MOVQBP, (SP) 0x0011 00017 (:1)LEAQ(SP), BP 0x0015 00021 (:1)MOVQ32(CX), BX 0x0019 00025 (:1)TESTQBX, BX 0x001c 00028 (:1)JNE70 0x001e 00030 (:1)NOP 0x001e 00030 (:1)FUNCDATA$0, gclocals·1a65e721a2ccc325b382662e7ffee780(SB) 0x001e 00030 (:1)FUNCDATA$1, gclocals·69c1753bd5f81501d95132d08af04464(SB) 0x001e 00030 (:1)MOVQ""..this+16(SP), AX 0x0023 00035 (:1)TESTQAX, AX 0x0026 00038 (:1)JEQ60 0x0028 00040 (:1)MOVQ(AX), AX 0x002b 00043 ()NOP 0x002b 00043 (asm_method/asm_method.go:6)INCQAX 0x002e 00046 (:1)MOVQAX, "".~r0+24(SP) 0x0033 00051 (:1)MOVQ(SP), BP 0x0037 00055 (:1)ADDQ$8, SP 0x003b 00059 (:1)RET 0x003c 00060 (:1)PCDATA$1, $1 0x003c 00060 (:1)NOP 0x0040 00064 (:1)CALLruntime.panicwrap(SB) 0x0045 00069 (:1)XCHGLAX, AX 0x0046 00070 (:1)LEAQ16(SP), DI 0x004b 00075 (:1)CMPQ(BX), DI 0x004e 00078 (:1)JNE30 0x0050 00080 (:1)MOVQSP, (BX) 0x0053 00083 (:1)JMP30

目标文件的汇编代码很好理解,方法函数的实现逻辑大概就是:
  • 方法函数在汇编中命名时,需要把类型名作为前缀,例如.MyInt.Add1(SB).(*MyInt).Add2(SB),这也解释了为什么不同的类型可以有同名的方法函数,因为它们在汇编中名称是不一样的;可见方法函数在Go汇编中和普通的全局函数没有区别,只不过命名加上前缀即可;
  • 我们注意到示例中的方法函数的argsize都是16,但其实我们函数定义中argsize应该是8,原因是方法函数默认第一个入参就是对应类型的变量,例如汇编语句MOVQ "".i+8(SP), AX
  • 我们定义了2个方法函数,但其实编译器一共实现了3个方法函数,多出来一个是.(*MyInt).Add1(SB),这也就解释了为什么我们定义的MyInt变量无论是否是指针都可以调用两个方法函数,因为编译器帮我们自动实现了;
  • 理论上应该有4个方法函数:.MyInt.Add1(SB).(*MyInt).Add1(SB).MyInt.Add2(SB).(*MyInt).Add2(SB),现在只有3个,如果我们在Go代码中调用MyInt.Add2(),编译器其实会自动改写成(*MyInt).Add2(SB);
下面我们用Go汇编示例如下:
type MyInt intfunc (v MyInt) Twice01() int { return int(v)*2 }func (v *MyInt) Twice02() int { return int(*v)*2 }

第一个方法函数的汇编实现如下:
// func (v MyInt) Twice() int TEXT ·MyInt·Twice01(SB), NOSPLIT, $0-16 // 函数名前缀为·MyInt MOVQ a+0(FP), AX// 第一个参数默认是v ADDQ AX, AX// AX *= 2 MOVQ AX, ret+8(FP) // return v RET

第二个方法函数包含指针,理论上命名应为·(*MyInt)·Twice02,但是在Go汇编语言中,星号和小括弧都无法用作函数名字,也就是无法用汇编直接实现接收参数是指针类型的方法。这个可能是官方的故意限制。
  1. 递归函数
    递归函数是比较特殊的函数,递归函数通过调用自身并且在栈上保存状态,这可以简化很多问题的处理。Go汇编中递归函数的强大之处是不用担心爆栈问题,因为栈可以根据需要进行扩容和收缩。
    我们通过一个递归求和的函数来示例:
func sum(n int) int { if n > 0 { return n + sum(n-1) } else { return 0 } }

我们使用命令go tool compile -S来输出目标代码(只截取了重要部分):
"".sum STEXT size=106 args=0x10 locals=0x18 0x0000 00000 (asm_method/asm_method.go:10)TEXT"".sum(SB), ABIInternal, $24-16 0x0000 00000 (asm_method/asm_method.go:10)MOVQ(TLS), CX 0x0009 00009 (asm_method/asm_method.go:10)CMPQSP, 16(CX) 0x000d 00013 (asm_method/asm_method.go:10)PCDATA$0, $-2 0x000d 00013 (asm_method/asm_method.go:10)JLS99 0x000f 00015 (asm_method/asm_method.go:10)PCDATA$0, $-1 0x000f 00015 (asm_method/asm_method.go:10)SUBQ$24, SP 0x0013 00019 (asm_method/asm_method.go:10)MOVQBP, 16(SP) 0x0018 00024 (asm_method/asm_method.go:10)LEAQ16(SP), BP 0x001d 00029 (asm_method/asm_method.go:10)FUNCDATA$0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d 00029 (asm_method/asm_method.go:10)FUNCDATA$1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d 00029 (asm_method/asm_method.go:11)MOVQ"".n+32(SP), AX 0x0022 00034 (asm_method/asm_method.go:11)TESTQAX, AX 0x0025 00037 (asm_method/asm_method.go:11)JLE80 0x0027 00039 (asm_method/asm_method.go:12)LEAQ-1(AX), CX 0x002b 00043 (asm_method/asm_method.go:12)MOVQCX, (SP) 0x002f 00047 (asm_method/asm_method.go:12)PCDATA$1, $0 0x002f 00047 (asm_method/asm_method.go:12)CALL"".sum(SB) 0x0034 00052 (asm_method/asm_method.go:12)MOVQ8(SP), AX 0x0039 00057 (asm_method/asm_method.go:12)MOVQ"".n+32(SP), CX 0x003e 00062 (asm_method/asm_method.go:12)ADDQCX, AX 0x0041 00065 (asm_method/asm_method.go:12)MOVQAX, "".~r1+40(SP) 0x0046 00070 (asm_method/asm_method.go:12)MOVQ16(SP), BP 0x004b 00075 (asm_method/asm_method.go:12)ADDQ$24, SP 0x004f 00079 (asm_method/asm_method.go:12)RET 0x0050 00080 (asm_method/asm_method.go:14)MOVQ$0, "".~r1+40(SP) 0x0059 00089 (asm_method/asm_method.go:14)MOVQ16(SP), BP 0x005e 00094 (asm_method/asm_method.go:14)ADDQ$24, SP 0x0062 00098 (asm_method/asm_method.go:14)RET 0x0063 00099 (asm_method/asm_method.go:14)NOP 0x0063 00099 (asm_method/asm_method.go:10)PCDATA$1, $-1 0x0063 00099 (asm_method/asm_method.go:10)PCDATA$0, $-2 0x0063 00099 (asm_method/asm_method.go:10)CALLruntime.morestack_noctxt(SB) 0x0068 00104 (asm_method/asm_method.go:10)PCDATA$0, $-1 0x0068 00104 (asm_method/asm_method.go:10)JMP0

通过目标代码,我们发现由于Go汇编会自动为栈扩容,所以递归函数与简单的函数调用并没有不同;
下面我们用Go汇编来实现一遍,我们首先用if/goto来改写代码,方便我们后续改写成汇编代码
func sum(n int) (result int) { var AX = n var BX intif n > 0 { goto L_STEP_TO_END } goto L_ENDL_STEP_TO_END: AX -= 1 BX = sum(AX)AX = n // 调用函数后, AX重新恢复为n BX += AXreturn BXL_END: return 0 }

最后我们实现汇编代码
// func sum(n int) (result int) TEXT ·sum(SB), $16-16 MOVQ n+0(FP), AX// n MOVQ result+8(FP), BX// resultCMPQ AX, $0// test n - 0 JGL_STEP_TO_END// if > 0: goto L_STEP_TO_END JMPL_END// goto L_STEP_TO_ENDL_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 RETL_END: MOVQ $0, result+8(FP) // return 0 RET

注意这里不能加上NOSPLIT标识,因为递归函数会因为调用层级过大而导致栈空间不足,NOSPLIT标识限制了栈的扩容,显然是Go编译器不允许的。
  1. 闭包函数
    闭包函数是最强大的函数,因为闭包函数可以捕获外层局部作用域的局部变量,因此闭包函数本身就具有了状态。从理论上来说,全局的函数也是闭包函数的子集,只不过全局函数并没有捕获外层变量而已。
    老规矩,我们先来构建一个简单的闭包函数:
func GenAddFuncClosure01(x int) func() int { return func() int { x += 2 return x } } func main() { f1 := GenAddFuncClosure01(1) f1() }

这次我们加上了闭包的调用代码,因为闭包调用也比较特殊。
我们看下目标文件的汇编代码:
"".GenAddFuncClosure01 STEXT size=157 args=0x10 locals=0x20 0x0000 00000 (asm_demo2/asm_demo2.go:8)TEXT"".GenAddFuncClosure01(SB), ABIInternal, $32-16 0x0000 00000 (asm_demo2/asm_demo2.go:8)MOVQ(TLS), CX // 获取TLS中的g结构体 0x0009 00009 (asm_demo2/asm_demo2.go:8)CMPQSP, 16(CX) // 判断函数栈是否需要扩容 0x000d 00013 (asm_demo2/asm_demo2.go:8)PCDATA$0, $-2 0x000d 00013 (asm_demo2/asm_demo2.go:8)JLS147 0x0013 00019 (asm_demo2/asm_demo2.go:8)PCDATA$0, $-1 0x0013 00019 (asm_demo2/asm_demo2.go:8)SUBQ$32, SP // 函数栈扩展32字节 0x0017 00023 (asm_demo2/asm_demo2.go:8)MOVQBP, 24(SP) // 保存BP 0x001c 00028 (asm_demo2/asm_demo2.go:8)LEAQ24(SP), BP 0x0021 00033 (asm_demo2/asm_demo2.go:8)FUNCDATA$0, gclocals·2589ca35330fc0fce83503f4569854a0(SB) 0x0021 00033 (asm_demo2/asm_demo2.go:8)FUNCDATA$1, gclocals·9fb7f0986f647f17cb53dda1484e0f7a(SB) 0x0021 00033 (asm_demo2/asm_demo2.go:8)LEAQtype.int(SB), AX // 将type.int(SB)对应地址写入AX 0x0028 00040 (asm_demo2/asm_demo2.go:8)MOVQAX, (SP) // 将AX写入0+(SP),作为下面将要调用的函数runtime.newobject(SB)入参 0x002c 00044 (asm_demo2/asm_demo2.go:8)PCDATA$1, $0 0x002c 00044 (asm_demo2/asm_demo2.go:8)CALLruntime.newobject(SB) // 调用函数:func newobject(typ *byte) *any 0x0031 00049 (asm_demo2/asm_demo2.go:8)MOVQ8(SP), AX // 函数newobject返回值写入AX,返回值是个int指针 0x0036 00054 (asm_demo2/asm_demo2.go:8)MOVQAX, "".&x+16(SP) // AX值写入16(SP),这里都是真SP寄存器 0x003b 00059 (asm_demo2/asm_demo2.go:8)MOVQ"".x+40(SP), CX // GenAddFuncClosure01函数入参x写入CX 0x0040 00064 (asm_demo2/asm_demo2.go:8)MOVQCX, (AX) // CX值写入AX中存储值所对应的地址中,即上面新创建的int指针赋值为x 0x0043 00067 (asm_demo2/asm_demo2.go:9)LEAQtype.noalg.struct { F uintptr; "".x *int }(SB), CX // 创建了一个struct { F uintptr; "".x *int }的类型变量,类似上面的type.int变量,并将其地址写入CX 0x004a 00074 (asm_demo2/asm_demo2.go:9)MOVQCX, (SP) // 将CX值写入0+(SP),作为下面将要调用的函数runtime.newobject(SB)入参 0x004e 00078 (asm_demo2/asm_demo2.go:9)PCDATA$1, $1 0x004e 00078 (asm_demo2/asm_demo2.go:9)CALLruntime.newobject(SB) // 调用函数:func newobject(typ *byte) *any 0x0053 00083 (asm_demo2/asm_demo2.go:9)MOVQ8(SP), AX // 函数newobject返回值写入AX,返回值是个struct { F uintptr; "".x *int }指针 0x0058 00088 (asm_demo2/asm_demo2.go:9)LEAQ"".GenAddFuncClosure01.func1(SB), CX // 编译器创建了一个.GenAddFuncClosure01.func1函数,将其地址写入CX 0x005f 00095 (asm_demo2/asm_demo2.go:9)MOVQCX, (AX) // 将CX值写入AX存储值所对应的地址,也就是为struct { F uintptr; "".x *int }的F成员变量赋值为.GenAddFuncClosure01.func1函数地址 0x0062 00098 (asm_demo2/asm_demo2.go:9)PCDATA$0, $-2 0x0062 00098 (asm_demo2/asm_demo2.go:9)CMPLruntime.writeBarrier(SB), $0 // gc相关的,先不用管 0x0069 00105 (asm_demo2/asm_demo2.go:9)JNE131 0x006b 00107 (asm_demo2/asm_demo2.go:9)MOVQ"".&x+16(SP), CX // 将之前存储的int指针写入CX 0x0070 00112 (asm_demo2/asm_demo2.go:9)MOVQCX, 8(AX) // 将CX值写入AX存储值的偏移量8字节所对应的地址,为struct { F uintptr; "".x *int }的x成员变量赋值为创建的int指针 0x0074 00116 (asm_demo2/asm_demo2.go:9)PCDATA$0, $-1 0x0074 00116 (asm_demo2/asm_demo2.go:9)MOVQAX, "".~r1+48(SP) // 返回struct { F uintptr; "".x *int }地址 0x0079 00121 (asm_demo2/asm_demo2.go:9)MOVQ24(SP), BP // 恢复BP 0x007e 00126 (asm_demo2/asm_demo2.go:9)ADDQ$32, SP 0x0082 00130 (asm_demo2/asm_demo2.go:9)RET 0x0083 00131 (asm_demo2/asm_demo2.go:9)PCDATA$0, $-2 0x0083 00131 (asm_demo2/asm_demo2.go:9)LEAQ8(AX), DI 0x0087 00135 (asm_demo2/asm_demo2.go:9)MOVQ"".&x+16(SP), CX 0x008c 00140 (asm_demo2/asm_demo2.go:9)CALLruntime.gcWriteBarrierCX(SB) 0x0091 00145 (asm_demo2/asm_demo2.go:9)JMP116 0x0093 00147 (asm_demo2/asm_demo2.go:9)NOP 0x0093 00147 (asm_demo2/asm_demo2.go:8)PCDATA$1, $-1 0x0093 00147 (asm_demo2/asm_demo2.go:8)PCDATA$0, $-2 0x0093 00147 (asm_demo2/asm_demo2.go:8)CALLruntime.morestack_noctxt(SB) 0x0098 00152 (asm_demo2/asm_demo2.go:8)PCDATA$0, $-1 0x0098 00152 (asm_demo2/asm_demo2.go:8)JMP0"".main STEXT size=71 args=0x0 locals=0x18 0x0000 00000 (asm_demo2/asm_demo2.go:21)TEXT"".main(SB), ABIInternal, $24-0 0x0000 00000 (asm_demo2/asm_demo2.go:21)MOVQ(TLS), CX 0x0009 00009 (asm_demo2/asm_demo2.go:21)CMPQSP, 16(CX) 0x000d 00013 (asm_demo2/asm_demo2.go:21)PCDATA$0, $-2 0x000d 00013 (asm_demo2/asm_demo2.go:21)JLS64 0x000f 00015 (asm_demo2/asm_demo2.go:21)PCDATA$0, $-1 0x000f 00015 (asm_demo2/asm_demo2.go:21)SUBQ$24, SP 0x0013 00019 (asm_demo2/asm_demo2.go:21)MOVQBP, 16(SP) 0x0018 00024 (asm_demo2/asm_demo2.go:21)LEAQ16(SP), BP 0x001d 00029 (asm_demo2/asm_demo2.go:21)FUNCDATA$0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d 00029 (asm_demo2/asm_demo2.go:21)FUNCDATA$1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d 00029 (asm_demo2/asm_demo2.go:22)MOVQ$1, (SP) // 函数GenAddFuncClosure01入参是x=1 0x0025 00037 (asm_demo2/asm_demo2.go:22)PCDATA$1, $0 0x0025 00037 (asm_demo2/asm_demo2.go:22)CALL"".GenAddFuncClosure01(SB) // 调用函数GenAddFuncClosure01 0x002a 00042 (asm_demo2/asm_demo2.go:22)MOVQ8(SP), DX // 函数GenAddFuncClosure01返回值是一个地址,写入DX;这里之所以写入DX,是因为GenAddFuncClosure01.func1函数会从DX中获取struct { F uintptr; "".x *int }地址,从而获取x的值 0x002f 00047 (asm_demo2/asm_demo2.go:23)MOVQ(DX), AX // 取返回值对应地址中存储的值写入AX,即struct { F uintptr; "".x *int }中F成员变量的值 0x0032 00050 (asm_demo2/asm_demo2.go:23)CALLAX // 此时AX中存储的其实是GenAddFuncClosure01.func1函数的地址,也就是闭包中真正的执行逻辑,执行该函数 0x0034 00052 (asm_demo2/asm_demo2.go:24)MOVQ16(SP), BP 0x0039 00057 (asm_demo2/asm_demo2.go:24)ADDQ$24, SP 0x003d 00061 (asm_demo2/asm_demo2.go:24)RET 0x003e 00062 (asm_demo2/asm_demo2.go:24)NOP 0x003e 00062 (asm_demo2/asm_demo2.go:21)PCDATA$1, $-1 0x003e 00062 (asm_demo2/asm_demo2.go:21)PCDATA$0, $-2 0x003e 00062 (asm_demo2/asm_demo2.go:21)NOP 0x0040 00064 (asm_demo2/asm_demo2.go:21)CALLruntime.morestack_noctxt(SB) 0x0045 00069 (asm_demo2/asm_demo2.go:21)PCDATA$0, $-1 0x0045 00069 (asm_demo2/asm_demo2.go:21)JMP0"".GenAddFuncClosure01.func1 STEXT nosplit size=20 args=0x8 locals=0x0 0x0000 00000 (asm_demo2/asm_demo2.go:9)TEXT"".GenAddFuncClosure01.func1(SB), NOSPLIT|NEEDCTXT|ABIInternal, $0-8 0x0000 00000 (asm_demo2/asm_demo2.go:9)FUNCDATA$0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x0000 00000 (asm_demo2/asm_demo2.go:9)FUNCDATA$1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x0000 00000 (asm_demo2/asm_demo2.go:9)MOVQ8(DX), AX // 将8(DX)中存储值写入AX,即struct { F uintptr; "".x *int }中x成员变量 0x0004 00004 (asm_demo2/asm_demo2.go:10)MOVQ(AX), CX // AX存储的是int指针,现在去指针对应的int值写入CX 0x0007 00007 (asm_demo2/asm_demo2.go:10)ADDQ$2, CX // x += 2 0x000b 00011 (asm_demo2/asm_demo2.go:10)MOVQCX, (AX) // 计算后的新x值覆盖旧值 0x000e 00014 (asm_demo2/asm_demo2.go:11)MOVQCX, "".~r0+8(SP) // 返回新x值 0x0013 00019 (asm_demo2/asm_demo2.go:11)RET

  • 函数GenAddFuncClosure01大概实现逻辑是:创建一个type.noalg.struct { F uintptr; "".x *int }(SB),将闭包的真正执行函数.GenAddFuncClosure01.func1写入F uintptr,入参x int复制到变量x *int,这样闭包的自身状态就保存在x *int中,不会受到入参x int的影响;最后返回结构体type.noalg.struct { F uintptr; "".x *int }(SB)的地址;
  • 闭包的真正执行函数.GenAddFuncClosure01.func1的逻辑大概是:从DX中获取结构体type.noalg.struct { F uintptr; "".x *int }(SB)的内部变量x,对x进行运算,同时覆盖旧的x值,最后返回新的x值;注意函数.GenAddFuncClosure01.func1增加了NEEDCTXT标识,表示该函数需要上下文,上下文一般都是存储在DX中;
  • 函数main中,我们看到调用函数GenAddFuncClosure01返回后,我们已经知道返回的是结构体type.noalg.struct { F uintptr; "".x *int }(SB)地址,并且闭包真正执行函数.GenAddFuncClosure01.func1需要上下文,所以汇编中把返回地址写入DX,同时取出.GenAddFuncClosure01.func1地址进行调用;这些应该都是汇编中对于闭包函数调用的人为约定;
  • 总结一下,闭包函数的具体实现其实是一个结构体,结构体第一个成员变量是闭包实际执行的函数,其他成员变量都是闭包中会用到的内部变量;并且调用闭包函数的时候,编译器知道取出真正的执行函数,并将结构体地址作为上下文写入DX,这样闭包实际执行函数就可以获取并更新结构体中的内部变量;
下面我们也按照这个思路用Go汇编来实现一下闭包:
Go代码:
func main() { f1 := GenAddFuncClosure02(1) fmt.Println(f1()) // 3 fmt.Println(f1()) // 5 fmt.Println(f1()) // 7} func GenAddFuncClosure02(x int) func() int func GenAddFuncClosure02func1() int

Go汇编代码:
GLOBL ·MyClosureStruct(SB),NOPTR,$16TEXT ·GenAddFuncClosure02(SB), NOSPLIT, $0-16 MOVQ x+0(FP), AX MOVQ AX, ·MyClosureStruct+8(SB) LEAQ ·GenAddFuncClosure02func1(SB), AX MOVQ AX, ·MyClosureStruct(SB) LEAQ ·MyClosureStruct(SB), BX MOVQ BX, ret+8(FP) RETTEXT ·GenAddFuncClosure02func1(SB), NOSPLIT|NEEDCTXT, $0-8 MOVQ 8(DX), AX ADDQ $2, AX MOVQ AX, 8(DX) MOVQ AX, ret+0(FP) RET

文章闭包函数中给出的例子可能更容易理解一点,读者朋友们可以自行阅读;另外笔者发现NEEDCTXT flag不加也不影响调用,这一块有懂行的老铁可以指导指导。
文章汇编语言的威力中,还有关于Go汇编的系统调用,AVX指令和一个获取goroutine ID的示例,感兴趣的小伙伴可以自行阅读。
至此,我们就介绍完Go语言中几种特殊函数的汇编实现了,我们也介绍完了Go汇编的入门知识。
总结 【Go学习笔记-汇编】本篇学习笔记中,我们介绍了汇编的基本知识,介绍了Go汇编基本知识,也通过汇编知识分析了一些Go语言特性的实现方式。笔者觉得最重要的还是理解清楚汇编Go汇编Go语言三者之间的关系和它们各自出现的背景和原因,以及通过一些汇编知识帮助我们深入理解Go语言的实现方式,进一步加深Go语言的运用能力。
参考
  1. Go汇编语言
  2. 汇编语言入门教程
  3. 大白话 golang 教程-29-反汇编和内存结构
  4. Go ASM
  5. 汇编语言学习
  6. 有栈协程与无栈协程
  7. 图解函数调用过程

    推荐阅读