概要
函数是一段封装了特定功能的可重用代码块,用于执行特定的任务或计算。函数接受输入(参数)并产生输出(返回值),可以带有副作用(修改状态或执行其他操作)。
Go
语言中,函数被认为是一等公民(First-class citizens
),这意味着函数在语言中具有与其他类型(如整数、字符串等)相同的权利和地位。以下是函数在Go
语言中被视为一等公民的原因:
- 函数可以作为值进行传递:在
Go
语言中,函数可以像其他类型的值一样被传递给其他函数或赋值给变量。这意味着可以将函数作为参数传递给其他函数,也可以将函数作为返回值返回。 - 函数可以赋值给变量:在
Go
语言中,可以将函数赋值给变量,然后通过变量来调用函数。这种能力使得函数可以像其他数据类型一样被操作和处理。 - 函数可以匿名定义:
Go
语言支持匿名函数的定义,也称为闭包。这意味着可以在不给函数命名的情况下直接定义和使用函数,更加灵活和便捷。 - 函数可以作为数据结构的成员:在
Go
语言中,函数可以作为结构体的成员,从而使得函数与其他数据一起存储在结构体中。这种特性使得函数能够更好地与数据相关联,实现更复杂的功能。
函数作为一等公民的特性使得Go
语言具有很高的灵活性和表达力,可以方便地实现函数式编程的思想,并且支持构建高阶函数和函数组合等高级编程技巧。
函数声明/定义
在Go语言中,使用func
关键字来定义函数。函数的基本语法如下:
func functionName(parameter1 type, parameter2 type) returnType {
// 函数体
}
其中:
-
函数声明:关键字
func
-
functionName
代表是函数的名称,函数名由字母、数字、下划线组成。但函数名的第一个字母不能是数字。在同一个包内,函数名称不能重名。 -
parameter1
和parameter2
代表是函数的参数,type
是参数的类型。参数由参数变量和参数变量的类型组成,参数变量可以省略,可以有一个参数,也可以有多个,也可以没有;多个参数之间使用,
分隔;多个参数时参数变量要么全写,要么全省略;如果多个相邻参数的类型是一样的,可以只保留同一类型最后一个参数的声明。下面列举几种函数参数的不同定义的方式:
-
单个参数:
func functionName(parameterName parameterType) { // 函数体 }
这是函数接受单个参数的基本形式。
parameterName
是参数的名称,parameterType
是参数的类型。 -
多个参数:
func functionName(parameter1Name parameter1Type, parameter2Name parameter2Type) { // 函数体 }
如果函数需要接受多个参数,可以在函数声明中依次列出参数的名称和类型。
-
可变参数(Variadic parameters):
func functionName(parameterName ...parameterType) { // 函数体 }
可变参数允许函数接受不定数量的参数。在参数类型之前使用
...
来指示可变参数的形式。在函数体内,可变参数被当作切片类型来处理。 -
参数命名和类型省略:
func functionName(parameter1, parameter2 int) { // 函数体 }
在函数定义中,如果多个参数具有相同的类型,可以省略参数类型,并在最后一个参数上指定类型。这种情况下,所有的参数都将具有相同的类型。
-
匿名参数:
func functionName(int, string) { // 函数体 }
在函数定义中,如果不需要使用参数的值,可以将参数名称省略,只保留参数类型。这种形式的参数被称为匿名参数。
-
-
returnType
是函数的返回值类型。返回值由返回值变量和其变量类型组成,返回值变量可以省略,可以有一个返回值,也可以有多个,也可以没有;多个返回值必须用()
包裹,并用,
分隔;多个返回值时返回值变量要么全写,要么全省略。下面列举返回类型的不同定义方式:、
-
单个返回值:
func functionName() returnType { // 函数体 return value }
这是函数返回单个值的基本形式。
returnType
是返回值的类型,value
是要返回的具体值。 -
多个返回值:
func functionName() (returnType1, returnType2) { // 函数体 return value1, value2 }
如果函数需要返回多个值,可以在函数声明中使用括号将多个返回值类型括起来,并在函数体内使用逗号分隔返回的具体值。
-
命名返回值:
func functionName() (returnValue1 returnType1, returnValue2 returnType2) { // 函数体 returnValue1 = value1 returnValue2 = value2 return }
可以为返回值命名,通过在函数声明中为返回值指定名称和类型。在函数体内,可以直接为这些命名返回值赋值,并在最后使用
return
关键字返回结果。 -
空返回值:
func functionName() { // 函数体 return }
如果函数没有返回值,可以省略返回值的类型和具体值,只使用
return
关键字。
-
-
函数体是函数的具体实现, 指的是指定功能的逻辑。
根据函数的功能和需求来定义参数和返回值。以下是几个函数声明的例子:
-
基本的函数声明:
func sayHello() { fmt.Println("Hello!") }
这个函数没有参数,也没有返回值。它的作用是打印"Hello!"。
-
带有参数的函数声明:
func greet(name string) { fmt.Println("Hello, " + name + "!") }
这个函数接受一个字符串类型的参数
name
,并打印"Hello, "加上name
的值。 -
带有返回值的函数声明:
func add(a, b int) int { return a + b }
这个函数接受两个整数类型的参数
a
和b
,并返回它们的和。 -
带有多个返回值的函数声明:
func divide(a, b float64) (float64, error) { if b == 0 { return 0, errors.New("division by zero") } return a / b, nil }
这个函数接受两个
float64
类型的参数a
和b
,并返回它们的商和一个error
类型的错误值(在除法操作时检查除数是否为零)。 -
可变参数的函数声明:
func sum(numbers ...int) int { total := 0 for _, num := range numbers { total += num } return total }
这个函数接受任意数量的整数参数,并返回它们的总和。
这些例子展示了不同类型的函数声明,包括无参数、有参数、无返回值、有返回值和可变参数等。根据实际需求,可以根据这些示例来定义自己的函数。
函数调用
定义了函数之后,可以通过函数名()
的方式调用函数。 调用上面定义的几个函数:
func main() {
sayHello() // Hello!
greet("John") // Hello, John!
ret := add(1, 2)
f1, err := divide(1, 0)
totol := sum(1, 2, 3, 4, 5)
fmt.Println(ret, f1, err, totol) // 3 0 division by zero 15
}
注意,调用有返回值的函数时,可以不接收其返回值。
函数栈帧
汇编基础
在阅读 Golang
源代码时,对一些概念不理解,但参考着汇编源码分析就能知道个大概。所以简要说明下 Golang
中的汇编语言。
目前根据常见的指令集架构和代码风格,汇编分类如下:
按照指令集架构分类:
x86
架构汇编:主要用于Intel x86
和兼容处理器的汇编语言。x86
汇编是最广泛使用的汇编语言之一,用于编写PC
、服务器和大多数个人计算机的底层代码。ARM
架构汇编:用于ARM
架构处理器的汇编语言,广泛应用于移动设备、嵌入式系统和低功耗设备。MIPS
架构汇编:用于MIPS
架构处理器的汇编语言,常见于嵌入式系统、网络设备和嵌入式控制器。
按照代码风格分类:
AT&T
语法:AT&T
语法是一种汇编语言代码风格,常用于UNIX
系统和GNU
工具链。它以movl
、addl
等指令表示操作符和操作数,并使用%
前缀表示寄存器。Intel
语法:Intel
语法是另一种汇编语言代码风格,主要用于Intel
架构和微处理器。它以mov
、add
等指令表示操作符和操作数,并不使用前缀表示寄存器。
Go
用了 plan9
汇编,使用的是 GAS
汇编语法(Gnu ASsembler
),与 AT&T
汇编格式有很多相同之处,但也有不同之处。plan9
作者们是写 unix
操作系统的同一批人,大名鼎鼎的贝尔实验室开发的。
plan9
语法主要的几个点如下:
-
MOV
指令:其后缀表示搬运长度,$NUM
表示具体的数字,如下面例子:MOVB $1, DI // 1 byte, DI=1 MOVW $0x10, BX // 2 bytes, BX=10 MOVD $1, DX // 4 bytes, DX=1 MOVQ $-10, AX // 8 bytes, AX=-10
-
LEA
, 将有效地址加载到指定的地址寄存器中LEAQ ret+24(FP), AX // 把 ret+24(FP) 地址加载到 AX 寄存器中
-
计算指令:
ADD
,SUB
,IMULQ
,示例如下:ADDQ AX, BX // BX += AX SUBQ AX, BX // BX -= AX IMULQ AX, BX // BX *= AX
可以利用计算指令来调整栈空间,我们知道
SP
指向栈顶位置,调整SP
中的值:// 栈空间: 高地址向低地址 SUBQ $0x18, SP // 对 SP 做减法,为函数分配函数栈帧 ADDQ $0x18, SP // 对 SP 做加法,清除函数栈帧
-
跳转指令:
JMP
,JZ
,JLS
…,示例:// 无条件跳转 JMP addr // 跳转到地址,地址可为代码中的地址,不过实际上手写不会出现这种东西 JMP label // 跳转到标签,可以跳转到同一函数内的标签位置 JMP 2(PC) // 以当前指令为基础,向前/后跳转 x 行 JMP -2(PC) // 同上 // 有条件跳转 JZ target // 如果 zero flag 被 set 过,则跳转 JLS num // 如果上一行的比较结果,左边小于右边则执行跳到 num 地址处
-
位运算指针:
AND
,OR
,XOR
-
其他指令:如
CALL
RET
等
plan9
的编程语法在此就不一一详细说明了,具体的可以参考Plan 9
的官方网站:https://9p.io/plan9/ 以及《The Plan 9 Assembly Language》
、《Plan 9 from Bell Labs Programmer's Manual》
等书籍。
下面说下golang
中的寄存器。
寄存器是与 CPU
直接交互的高速存储器,用于存储和操作指令和数据。寄存器是 CPU
的一部分,它们比内存访问更快,因此对于性能关键的代码来说,充分利用寄存器可以提高程序的执行效率。
在go
早期版本中,CPU
进行运算时,需要将数据拷贝到寄存器中进行运算,计算完成后再将计算结果从寄存器拷贝到栈上的返回值空间。但是CPU
处理寄存器的速度与读写内存的速度压根儿不在一个数量级上。要是能将参数和返回值直接通过寄存器来传递,那就能减少内存与寄存器之间的数据拷贝,程序执行的速度自然也就更快了。
正因如此,在go1.17
的调用约定中,就实现了通过寄存器来传参。
在应用层的话,通常只会用到如下三个分类共19
个寄存器:
-
**通用寄存器。**通用寄存器有
16
个寄存器,CPU
对它们的用途没有做特殊规定,可以自定义其用途(其中SP
、BP
这两个寄存器有特殊用途)在
golang
中,作为参数存储使用的限制为数量9
个,当这9
个寄存器装不下了,会继续使用栈来传递:还有两个特殊寄存器,这两个寄存器在栈帧环节会频繁出现:
-
BP寄存器,基址指针寄存器(
extended base pointer
),也叫帧指针,存放着一个指针,表示函数栈开始的地方; -
SP寄存器,栈指针寄存器(
extended stack pointer
),存放着一个指针,存储的是函数栈空间的栈顶,也就是函数栈空间分配结束的地方;
还有一个比较特殊的参数类型,那就是浮点型参数。由于
amd 64
架构中,浮点型数据的编码与整形数据编码大不相同,而浮点数的运算会使用专用寄存器和指令。所以浮点数不会使用这9
个通用寄存器来传递,而是使用这15个XMM
寄存器来传递。这组XMM
寄存器是随着多媒体相关的指令集一起引入的,go
语言使用它们来处理浮点数。前15
个浮点型参数会依次使用x0
到x14
这15
个寄存器来传递,如果还有浮点数据就要使用栈来传递了: -
-
**程序计数寄存器(rip寄存器,也叫PC寄存器、IP寄存器)。**用来存放下一条即将用来执行的指令的地址,它决定程序执行的流程。
-
**段寄存器(FS、GS寄存器)。**用来实现线程本地存储(
TLS
),比如ADM64
、Linux
下Go
语言和pthread
线程库都用fs
存储器来实现线程的TLS
(本地存储)
寄存器基本内容就了解至此,那go语言如何查看语言程序对应的伪汇编代码呢?
用下面的命令查看:
$ go tool compile -N -l -S 文件名 # or: go build -gcflags -N -l -S 文件名
其中go tool compile
命令用于调用Go语言提供的底层命令工具,其中-S
参数表示输出汇编格式。
也可以用下面命令将编译好的代码反编译:
$ go tool objdump main.o(编译好的文件名)
至此,知识理论已经完备,可以下面一步分析了。
函数栈帧
为了更加深入的理解函数中的局部变量、参数、返回值、递归和嵌套函数、闭包和其他与函数调用相关知识以及语言的实现和性能,需要对函数栈帧知识有一定的了解。
操作系统把磁盘上的可执行文件加载到内存之前,会做很多工作。最重要的一个环节就是把可执行文件中的代码、数据放在内存中合适的位置上,并分配和初始化程序运行中所必须的堆和栈,当所有操作完成后,操作系统才会调度程序运行起来。
程序运行时在内存中的布局如下:
程在内存中的布局主要分为四个部分:代码区、数据区、堆和栈,如下:
-
代码区【
Code
段】。包括能被CPU
执行的机器代码(指令)和只读数据(比如字符串常量),当程序加载完毕后,代码区大小不会再进行变化; -
数据区【
Data
段】。包括程序的全局变量和静态变量,与代码区一样,当程序加载完毕后,代码区大小不会再进行变化; -
堆【
Heap
】。程序运行是动态分配的内存都位于堆中,这部分内存由内存分配器进行管理。 -
栈【
Stack
】:由系统的编译器自动的释放,主要用来存放方法中的参数,一些临时的局部变量等,并且方法中的参数一般在操作完后,会由编译器自动的释放掉。栈只有一个口可供进出,先入栈的在底,后入栈的在顶,最后入栈的最早被取出。运行时栈,上面是高地址,向下增长,栈底通常被称为“栈基(BP)”,栈顶被称为“栈指针(SP)”。
程序运行过程,不管是函数的执行还是调用,栈(Stack
)都起着非常重要的作用,用途如下:
- 函数的局部变量:函数内部声明的局部变量(包括基本类型、结构体、数组、切片、映射等)存储在栈区。这些局部变量的生命周期与函数调用相关,它们在函数返回后自动被释放。
- 函数的参数:函数调用时传递的参数也存储在栈区中。参数的值会被复制到函数栈帧中的参数变量中,函数在执行过程中使用这些参数进行计算。
- 函数的返回值:函数执行完成后,返回值也存储在栈区中。当函数调用结束后,返回值会从栈帧中复制到调用方的栈帧中。
- 函数调用过程中的调用信息:函数调用时,栈区还存储了一些与函数调用过程相关的信息,如函数返回地址、函数调用上下文等。这些信息用于跟踪函数调用的顺序和状态。
( 需要注意的是,Go
语言中的动态分配的内存(如使用new
、make
等方式创建的对象)不存储在栈区,而是存储在堆区。栈区的大小是有限的,而堆区的大小取决于操作系统和可用内存的限制。)
每个函数在执行过程中都需要用栈来保存上述的值,此时分配给函数的栈空间被就称为这个函数的栈帧(stack frame)。
Go
语言中函数栈帧布局是这样的,先是**调用者(caller) 栈基(BP
)地址,然后是函数的局部变量,最后是被调用(callee)**函数的返回值和参数:
上图所展示的就是一个函数栈帧的结构。栈帧有以下部分组成且按先后顺序入栈保存:
-
BP
指针,保存调用函数的栈基地址(BP
),用于函数返回后获得调用函数的栈帧基地址,表示一个栈帧的栈底; -
局部变量或者临时变量,保存函数内部本地变量或者函数运行过程中产生的临时变量以及函数返回值临时变量;
-
被调用函数的返回值(
callee's return
); -
被调用函数的参数(
callee's args
),参数的入栈顺序是从右往左,即最后的参数先入栈,最前的参数后入栈; -
返回地址(
return addr
),表示当前被调用函数的下一条指令地址。该地址实际并不是在创建函数栈的时候生成,而是在调用Call
函数时生成,函数RET
时释放; -
SP
指针,该指针表示一个栈帧的栈顶位置,然后通过sp + 偏移量的方式来定位内存位置。
对于上面概念不理解没关系,下面通过具体例子来加深理解。
实例分析
有了上面对函数栈帧的基本了解,接着下面用实际的例子来更具体化的说明函数栈帧的内存分布,示例如下:
func add(a, b int) int {
return a + b
}
func calc(x, y int) int {
c := add(x, y)
return c
}
func main() {
calc(6, 8)
}
我们使用反编译命令:
go tool compile -N -l -S main.go
go tool objdump main.o
可以得到汇编代码:
TEXT main.add(SB) gofile../Users/xjx/workspace/golang/source_code_read/main.go
main.go:3 0xd37 4883ec10 SUBQ $0x10, SP
main.go:3 0xd3b 48896c2408 MOVQ BP, 0x8(SP)
main.go:3 0xd40 488d6c2408 LEAQ 0x8(SP), BP
main.go:3 0xd45 4889442418 MOVQ AX, 0x18(SP)
main.go:3 0xd4a 48895c2420 MOVQ BX, 0x20(SP)
main.go:3 0xd4f 48c7042400000000 MOVQ $0x0, 0(SP)
main.go:4 0xd57 4801d8 ADDQ BX, AX
main.go:4 0xd5a 48890424 MOVQ AX, 0(SP)
main.go:4 0xd5e 488b6c2408 MOVQ 0x8(SP), BP
main.go:4 0xd63 4883c410 ADDQ $0x10, SP
main.go:4 0xd67 c3 RET
TEXT main.calc(SB) gofile../Users/xjx/workspace/golang/source_code_read/main.go
main.go:7 0xd68 493b6610 CMPQ 0x10(R14), SP
main.go:7 0xd6c 763b JBE 0xda9
main.go:7 0xd6e 4883ec28 SUBQ $0x28, SP
main.go:7 0xd72 48896c2420 MOVQ BP, 0x20(SP)
main.go:7 0xd77 488d6c2420 LEAQ 0x20(SP), BP
main.go:7 0xd7c 4889442430 MOVQ AX, 0x30(SP)
main.go:7 0xd81 48895c2438 MOVQ BX, 0x38(SP)
main.go:7 0xd86 48c744241000000000 MOVQ $0x0, 0x10(SP)
main.go:8 0xd8f e800000000 CALL 0xd94 [1:5]R_CALL:main.add
main.go:8 0xd94 4889442418 MOVQ AX, 0x18(SP)
main.go:9 0xd99 4889442410 MOVQ AX, 0x10(SP)
main.go:9 0xd9e 488b6c2420 MOVQ 0x20(SP), BP
main.go:9 0xda3 4883c428 ADDQ $0x28, SP
main.go:9 0xda7 90 NOPL
main.go:9 0xda8 c3 RET
main.go:7 0xda9 4889442408 MOVQ AX, 0x8(SP)
main.go:7 0xdae 48895c2410 MOVQ BX, 0x10(SP)
main.go:7 0xdb3 e800000000 CALL 0xdb8 [1:5]R_CALL:runtime.morestack_noctxt
main.go:7 0xdb8 488b442408 MOVQ 0x8(SP), AX
main.go:7 0xdbd 488b5c2410 MOVQ 0x10(SP), BX
main.go:7 0xdc2 eba4 JMP main.calc(SB)
TEXT main.main(SB) gofile../Users/xjx/workspace/golang/source_code_read/main.go
main.go:12 0xdc4 493b6610 CMPQ 0x10(R14), SP
main.go:12 0xdc8 7629 JBE 0xdf3
main.go:12 0xdca 4883ec18 SUBQ $0x18, SP
main.go:12 0xdce 48896c2410 MOVQ BP, 0x10(SP)
main.go:12 0xdd3 488d6c2410 LEAQ 0x10(SP), BP
main.go:13 0xdd8 b806000000 MOVL $0x6, AX
main.go:13 0xddd bb08000000 MOVL $0x8, BX
main.go:13 0xde2 6690 NOPW
main.go:13 0xde4 e800000000 CALL 0xde9 [1:5]R_CALL:main.calc
main.go:14 0xde9 488b6c2410 MOVQ 0x10(SP), BP
main.go:14 0xdee 4883c418 ADDQ $0x18, SP
main.go:14 0xdf2 c3 RET
main.go:12 0xdf3 e800000000 CALL 0xdf8 [1:5]R_CALL:runtime.morestack_noctxt
main.go:12 0xdf8 ebca JMP main.main(SB)
针对汇编代码,我们逐行分析下,看下函数栈空间从空间分配到数据存放的完整流程:
-
main
函数栈帧空间构建(BP
、SP
地址确定)main.go:12 0xdca 4883ec18 SUBQ $0x18, SP main.go:12 0xdce 48896c2410 MOVQ BP, 0x10(SP) main.go:12 0xdd3 488d6c2410 LEAQ 0x10(SP), BP main.go:13 0xdd8 b806000000 MOVL $0x6, AX main.go:13 0xddd bb08000000 MOVL $0x8, BX
通过
SUBQ $0x18, SP
执行了栈指针(SP
)的调整,通过将栈指针减去0x18
(24字节),为函数栈帧分配了一块大小为24
字节的内存空间;再通过
MOVQ BP, 0x10(SP)
将基址指针(BP
)的值复制到栈帧的相对偏移地址为0x10(SP)
的位置;最后通过
LEAQ 0x10(SP), BP
将栈帧中相对偏移地址为0x10(SP)
的位置的值加载到基址指针(BP
)中; -
调用
main.calc
函数main.go:13 0xde4 e800000000 CALL 0xde9 [1:5]R_CALL:main.calc
调用
CALL
指令,CALL
指令主要操作两件事情:- 将下一条指令的地址入栈,被调用函数执行结束后会跳回到这个地址继续执行,这就是函数调用的“返回地址”。入栈操作会导致栈SP位置的变化。
- 跳转到被调用的函数指令入口处执行
-
构造
calc
函数栈帧并执行calc
函数指令main.go:7 0xd6e 4883ec28 SUBQ $0x28, SP main.go:7 0xd72 48896c2420 MOVQ BP, 0x20(SP) main.go:7 0xd77 488d6c2420 LEAQ 0x20(SP), BP main.go:7 0xd7c 4889442430 MOVQ AX, 0x30(SP) main.go:7 0xd81 48895c2438 MOVQ BX, 0x38(SP) main.go:7 0xd86 48c744241000000000 MOVQ $0x0, 0x10(SP)
通过
SUBQ $0x28, SP
执行了栈指针(SP
)的调整,通过将栈指针减去0x28
(40字节),为calc
函数栈帧分配了一块大小为40
字节的内存空间;再通过
MOVQ BP, 0x20(SP)
将基址指针(BP
)的值复制到栈帧的相对偏移地址为0x20(SP)
的位置;通过LEAQ 0x20(SP), BP
将栈帧中相对偏移地址为0x20(SP)
的位置的值加载到基址指针(BP
)中;最后执行
calc
函数中的相关赋值指令。 -
调用
main.add
函数main.go:8 0xd8f e800000000 CALL 0xd94 [1:5]R_CALL:main.add
调用
CALL
指令,CALL
指令主要操作两件事情就不重复了,直接上图: -
构造
add
函数栈帧并执行add
函数里面的指令main.go:3 0xd37 4883ec10 SUBQ $0x10, SP main.go:3 0xd3b 48896c2408 MOVQ BP, 0x8(SP) main.go:3 0xd40 488d6c2408 LEAQ 0x8(SP), BP main.go:3 0xd45 4889442418 MOVQ AX, 0x18(SP) main.go:3 0xd4a 48895c2420 MOVQ BX, 0x20(SP) main.go:3 0xd4f 48c7042400000000 MOVQ $0x0, 0(SP) main.go:4 0xd57 4801d8 ADDQ BX, AX main.go:4 0xd5a 48890424 MOVQ AX, 0(SP)
通过
SUBQ $0x10, SP
执行了栈指针(SP
)的调整,通过将栈指针减去0x10
(十进制:16),为add
函数栈帧分配了一块大小为16
字节的内存空间;再通过
MOVQ BP, 0x8(SP)
将基址指针(BP
)的值复制到栈帧的相对偏移地址为0x8(SP)
的位置;通过LEAQ 0x8(SP), BP
将栈帧中相对偏移地址为0x8(SP)
的位置的值加载到基址指针(BP
)中;执行赋值指令:
MOVQ AX, 0x18(SP)
、MOVQ BX, 0x20(SP)
、MOVQ $0x0, 0(SP)
分别将相对SP
偏移位置赋值执行加法指令:
ADDQ BX, AX
,将AX
寄存器的值加上BX
寄存器的值后更新进AX
寄存器和0(SP)
位置 -
执行
add
函数栈帧剩余的指令main.go:4 0xd5e 488b6c2408 MOVQ 0x8(SP), BP main.go:4 0xd63 4883c410 ADDQ $0x10, SP main.go:4 0xd67 c3 RET
剩余的指令就是
BP
以及SP
指针回到add
函数栈构造前(即调用者函数栈)中,然后执行RET
指令,RET
指令则负责起了函数的返回,RET
作用是:- 如果存在弹出
CALL
指令压栈的返回地址; - 跳转到
CALL
指令压栈的返回地址处
执行完
add
函数,则会释放add
栈 - 如果存在弹出
-
执行
cacl
函数栈帧剩余的指令main.go:8 0xd94 4889442418 MOVQ AX, 0x18(SP) main.go:9 0xd99 4889442410 MOVQ AX, 0x10(SP) main.go:9 0xd9e 488b6c2420 MOVQ 0x20(SP), BP main.go:9 0xda3 4883c428 ADDQ $0x28, SP main.go:9 0xda8 c3 RET
具体的解析就不说了,跟第6步骤基本一致,执行完
cacl
函数,则会释放cacl
栈 -
当执行完
cacl
函数,就只剩下main
函数栈帧了,如下图:main
函数后续指令就不继续分析了。
通过这个例子的函数栈帧信息,我们总结下golang
的函数栈帧的一些重要特点:
-
栈帧按照先后顺序存储数据,首先是栈基地址(
BP
),接着是局部变量以及临时变量,然后是函数返回值、参数(从右到左压栈)、返回地址。 -
函数栈帧大小是在编译时确定的,而不是在运行时动态分配或渐进性分配的,对于栈消耗较大的函数,
go
语言的编译器还会在函数头部插入检测代码,如果发现需要进行“栈增长”,就会另外分配一段足够大的栈空间,并把原来栈上的数据拷过来,原来的这段栈空间就被释放了。函数栈帧大致就这样,下面通过函数栈帧去解开一些问题。
值传递和引用传递
我们从实际例子出发,看代码:
// 值传递函数
func f1(i int, s string) {
i = i + 2
s = s + "_hello"
}
// 引用传递函数
func f2(x []int) {
x[0] = 55
}
func main() {
num := 1
s := "xjx"
slc := []int{1, 2, 3}
f1(num, s)
f2(slc)
println(num, s, slc) //output: 1 xjx [55 2 3]
}
通过代码结果,可以看出 i
, s
变量经过函数f1
修改值后未改变其原本值,而x
变量经过f2
函数修改值后更改影响了原本的值。
golang
参数传递有两种方式:
- 值传递,是指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数;值传递的数据类型包括:基本数据类型
int
系列,float
系列,bool
,string
,数组和结构体struct
等。 - 引用传递,是指在调用函数时将实际参数的地址传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数;引用类型数据类型包括: 指针,
slice
切片,map
,管道chan
,interface
等。
下面我们分别选取一种类型通过函数栈帧来查看值传递和引用传递的不同。
值传递
直接看示例:
package main
func Change(i int) {
i = 100
}
func main() {
i := 5
Change(i)
}
进行反编译,查看汇编代码跟踪变量s
的值变化情况:
TEXT main.Change(SB) gofile../Users/xjx/workspace/golang/source_code_read/main.go
main.go:3 0x818 4889442408 MOVQ AX, 0x8(SP)
main.go:4 0x81d 48c744240864000000 MOVQ $0x64, 0x8(SP)
main.go:5 0x826 c3 RET
TEXT main.main(SB) gofile../Users/xjx/workspace/golang/source_code_read/main.go
main.go:7 0x827 493b6610 CMPQ 0x10(R14), SP
main.go:7 0x82b 762b JBE 0x858
main.go:7 0x82d 4883ec18 SUBQ $0x18, SP
main.go:7 0x831 48896c2410 MOVQ BP, 0x10(SP)
main.go:7 0x836 488d6c2410 LEAQ 0x10(SP), BP
main.go:8 0x83b 48c744240805000000 MOVQ $0x5, 0x8(SP)
main.go:9 0x844 b805000000 MOVL $0x5, AX
main.go:9 0x849 e800000000 CALL 0x84e [1:5]R_CALL:main.Change
main.go:10 0x84e 488b6c2410 MOVQ 0x10(SP), BP
main.go:10 0x853 4883c418 ADDQ $0x18, SP
main.go:10 0x857 c3 RET
main.go:7 0x858 e800000000 CALL 0x85d [1:5]R_CALL:runtime.morestack_noctxt
main.go:7 0x85d ebc8 JMP main.main(SB)
根据汇编代码,画出栈帧变化图:
当我们将一个值类型的变量(如整数、浮点数、布尔值等)作为参数传递给函数时,函数会在栈上创建该变量的一个副本,并在函数内部使用该副本。对副本的任何修改都不会影响原始变量。这是因为值类型的变量存储的是实际的值,而不是指向该值的地址。
如Change
函数中参数i
值是复制了一份main
函数局部变量i
的值,传递给函数的是变量值的副本,此时对参数i
进行赋值修改,对main
函数的局部变量i
值不会产生任何影响。
像基本数据类型int
系列,float
系列,bool
,string
,数组和结构体struct
等都是这样拷贝的是变量值。更改拷贝值不影响原值。
如果你想通过参数改变上述类型的原值,只需要参数变更为地址指针类型即可,例如上面例子可以改为:
func Change(i *int) {
*i = 100
}
func main() {
i := 5
Change(&i)
}
引用传递
示例:
package main
func Change(s []int) {
s[0] = 100
}
func main() {
s := []int{1, 2, 3}
Change(s)
println(s) // [100 2 3]
}
进行反编译,查看汇编代码跟踪变量s
的值变化情况:
TEXT main.Change(SB) gofile../Users/xjx/workspace/golang/source_code_read/main.go
main.go:3 0xcfe 4883ec18 SUBQ $0x18, SP
main.go:3 0xd02 48896c2410 MOVQ BP, 0x10(SP)
main.go:3 0xd07 488d6c2410 LEAQ 0x10(SP), BP
main.go:3 0xd0c 4889442420 MOVQ AX, 0x20(SP)
main.go:3 0xd11 48895c2428 MOVQ BX, 0x28(SP)
main.go:3 0xd16 48894c2430 MOVQ CX, 0x30(SP)
main.go:3 0xd1b 0f1f00 NOPL 0(AX)
main.go:4 0xd1e 4885db TESTQ BX, BX
main.go:4 0xd21 7702 JA 0xd25
main.go:4 0xd23 eb11 JMP 0xd36
main.go:4 0xd25 48c70064000000 MOVQ $0x64, 0(AX)
main.go:5 0xd2c 488b6c2410 MOVQ 0x10(SP), BP
main.go:5 0xd31 4883c418 ADDQ $0x18, SP
main.go:5 0xd35 c3 RET
main.go:4 0xd36 31c0 XORL AX, AX
main.go:4 0xd38 4889d9 MOVQ BX, CX
main.go:4 0xd3b 0f1f00 NOPL 0(AX)
main.go:4 0xd3e e800000000 CALL 0xd43 [1:5]R_CALL:runtime.panicIndex
main.go:4 0xd43 90 NOPL
TEXT main.main(SB) gofile../Users/xjx/workspace/golang/source_code_read/main.go
main.go:7 0xd44 493b6610 CMPQ 0x10(R14), SP
main.go:7 0xd48 0f869a000000 JBE 0xde8
main.go:7 0xd4e 4883ec58 SUBQ $0x58, SP
main.go:7 0xd52 48896c2450 MOVQ BP, 0x50(SP)
main.go:7 0xd57 488d6c2450 LEAQ 0x50(SP), BP
main.go:8 0xd5c 440f117c2418 MOVUPS X15, 0x18(SP)
main.go:8 0xd62 440f117c2420 MOVUPS X15, 0x20(SP)
main.go:8 0xd68 488d442418 LEAQ 0x18(SP), AX
main.go:8 0xd6d 4889442430 MOVQ AX, 0x30(SP)
main.go:8 0xd72 8400 TESTB AL, 0(AX)
main.go:8 0xd74 48c744241801000000 MOVQ $0x1, 0x18(SP)
main.go:8 0xd7d 8400 TESTB AL, 0(AX)
main.go:8 0xd7f 48c744242002000000 MOVQ $0x2, 0x20(SP)
main.go:8 0xd88 8400 TESTB AL, 0(AX)
main.go:8 0xd8a 48c744242803000000 MOVQ $0x3, 0x28(SP)
main.go:8 0xd93 8400 TESTB AL, 0(AX)
main.go:8 0xd95 eb00 JMP 0xd97
main.go:8 0xd97 4889442438 MOVQ AX, 0x38(SP)
main.go:8 0xd9c 48c744244003000000 MOVQ $0x3, 0x40(SP)
main.go:8 0xda5 48c744244803000000 MOVQ $0x3, 0x48(SP)
main.go:9 0xdae bb03000000 MOVL $0x3, BX
main.go:9 0xdb3 4889d9 MOVQ BX, CX
main.go:9 0xdb6 e800000000 CALL 0xdbb [1:5]R_CALL:main.Change
main.go:10 0xdbb e800000000 CALL 0xdc0 [1:5]R_CALL:runtime.printlock<1>
main.go:10 0xdc0 488b442438 MOVQ 0x38(SP), AX
main.go:10 0xdc5 488b5c2440 MOVQ 0x40(SP), BX
main.go:10 0xdca 488b4c2448 MOVQ 0x48(SP), CX
main.go:10 0xdcf e800000000 CALL 0xdd4 [1:5]R_CALL:runtime.printslice<1>
main.go:10 0xdd4 e800000000 CALL 0xdd9 [1:5]R_CALL:runtime.printnl<1>
main.go:10 0xdd9 e800000000 CALL 0xdde [1:5]R_CALL:runtime.printunlock<1>
main.go:11 0xdde 488b6c2450 MOVQ 0x50(SP), BP
main.go:11 0xde3 4883c458 ADDQ $0x58, SP
main.go:11 0xde7 c3 RET
main.go:7 0xde8 e800000000 CALL 0xded [1:5]R_CALL:runtime.morestack_noctxt
main.go:7 0xded e952ffffff JMP main.main(SB)
根据汇编代码,画出栈帧变化图:
从栈帧变化图上可以看出,跟值传递一样,作为参数传递给函数时,函数会在栈上创建该变量的一个副本,并在函数内部使用该副本。但不同的是,引用传递副本中的指针指向相同的底层数据。这意味着函数内部对该变量进行的修改会反映在原始变量上,因为它们共享相同的数据。
值传递和引用传递区别如下图:
匿名函数
在 Go
语言中,匿名函数是一种没有名称的函数。它是一种函数字面量,可以在代码中直接定义和使用,而无需事先命名。
匿名函数通常用于需要在某个特定位置定义、传递或执行函数的场景,而不需要在其他地方重复使用该函数。它们可以作为变量的值,传递给其他函数,或直接调用。
匿名函数的语法类似于普通函数,但没有函数名。它可以包含参数列表、函数体和返回值。
声明
匿名函数声明格式如下:
func(参数列表)(返回参数列表){
函数体
}
例如:
func(i int) int{
return i * 2
}
使用
在Go语言中,可以使用匿名函数(也称为闭包)来定义没有名字的函数。匿名函数可以作为值赋给变量,也可以作为参数传递给其他函数,或者直接被调用。
下面是一些匿名函数的使用示例:
-
将匿名函数赋值给变量并调用:
add := func(a, b int) int { return a + b } result := add(3, 5) fmt.Println(result) // 输出: 8
-
将匿名函数作为参数传递给其他函数:
func calculate(a, b int, operation func(int, int) int) int { return operation(a, b) } add := func(a, b int) int { return a + b } result := calculate(3, 5, add) fmt.Println(result) // 输出: 8
-
在函数内部定义并立即调用匿名函数:
result := func(a, b int) int { return a + b }(3, 5) fmt.Println(result) // 输出: 8
-
闭包的使用或者作为返回值,匿名函数可以捕获外部函数的变量:
func increment() func() int { count := 0 return func() int { count++ return count } } counter := increment() fmt.Println(counter()) // 输出: 1 fmt.Println(counter()) // 输出: 2
Function Value
函数,在GO
语言中属于头等对象,可以被当作参数传递、也可以作为函数返回值、也可绑定到变量。而如果这个函数像其他类型一样被赋值给变量、作为参数传递给其他函数或从函数返回的,则称为 Function Value
。
换句话说,函数值( Function Value
)是函数作为值的表达形式。
下面是一个简单的示例,演示如何将函数赋值给变量并使用函数值调用函数:
func add(a, b int) int {
return a + b
}
func main() {
var sumFunc func(int, int) int
sumFunc = add
result := sumFunc(2, 3) // 调用函数值
fmt.Println(result) // 输出: 5
}
在上面的示例中,函数 add
被赋值给了名为 sumFunc
的函数变量,然后可以通过 sumFunc
来调用 add
函数。
Function Value
本质上是一个指针,却不直接指向函数指令入口,而是指向runtime.funcval
结构体。
//runtime/runtime2.go
type funcval struct {
fn uintptr
// variable-size, fn-specific data here
}
其中的fn
字段存储的是函数变量的地址,而根据下面的注释能够知道这个结构的大小是不确定的,实际上编译器会把函数捕获外层函数的“捕获列表”追加到fn
字段之后,至于捕获列表中存储的是值还是地址,需要根据实际情况而定。
一个Function Value
如下图所示形式存在的:
这块内容后续会在闭包章节中重点突出,目前只做简单的了解。
Init函数
特性以及加载顺序
在 Go
语言中,init()
函数是一种特殊的函数,用于在程序执行之前自动执行初始化操作。
通过一个例子来了解下 init
函数:
package main
import "fmt"
// 定义全部变量a
var a = v("a")
func v(name string) int {
fmt.Println("var " + name)
return 1
}
func init() {
fmt.Println("init 1")
}
func init() {
fmt.Println("init 2")
}
// 定义全局变量b
var b = v("b")
func main() {
fmt.Println("main")
//init() 调用init函数,报错
}
/**
输出结果:
var a
var b
init 1
init 2
main
*/
通过上述代码的结果可以得到init
以下特征:
init
函数没有输入参数、返回值,也未声明,所以无法被显示的调用,不能被引用(赋值给函数变量),否则会出现编译错误- 一个
go
文件可以拥有多个init
函数,执行顺序按定义顺序执行 - 初始化常量/变量优于
init
函数执行,init
函数先于main
函数自动执行。执行顺序先后为:const
常量 >var
变量 >init
函数 >main
函数
上面是单个文件的执行顺序,如果是多个文件互相加载或者import
包的情况下,加载执行顺序如下图:
以上这张图片很清晰的反应了init函数的加载顺序:包加载优先级排在第一位,先层层递归进行包加载,每个包中加载顺序为:const
> var
> init
,首先进行初始化的是常量,然后是变量,最后才是init
函数。
使用场景
init()
函数通常用于执行一些初始化任务,例如:
- 初始化全局变量或常量。
- 注册/初始化数据库连接。
- 执行配置加载和解析。
- 注册/初始化各种组件或模块。
- 进行一些必要的预处理操作等。
以下是一些常见的使用场景和相应的示例:
-
初始化全局变量或常量:
var globalVar int func init() { globalVar = 10 }
-
注册和初始化数据库连接:
func init() { db.Connect("localhost:3306", "username", "password") }
-
执行配置加载和解析:
func init() { config.Load("config.json") }
-
注册和初始化各种组件或模块:
func init() { cache.Register() logging.Init() }
-
进行一些必要的预处理操作:
func init() { // 检查环境变量或命令行参数 // 设置日志级别 // 等等... }
-
注册自定义的类型、接口实现或者其他初始化任务:
func init() { // 注册自定义的类型 myTypeRegistry.Register(&MyType{}) // 注册接口实现 myInterfaceRegistry.Register(&MyInterfaceImpl{}) }
需要注意的是,init()
函数在包被导入时自动执行,因此可以用于进行一些初始化操作。它们可以包含任何合法的 Go
代码,并且可以有多个 init()
函数,按照定义的顺序依次执行。
在实际开发中,init()
函数通常用于进行一些初始化设置、资源的注册和初始化,以及其他需要在程序启动时执行的任务。
参考资料:
幼麟实验室 https://space.bilibili.com/567195437
Go 语言设计与实现 https://draveness.me/golang
小贺coding https://zhuanlan.zhihu.com/p/452965689
https://www.cnblogs.com/jiujuan/p/16555192.html
luyaran https://blog.csdn.net/luyaran/article/details/120555863?spm=1001.2101.3001.6661.1&utm_medium=distribute.pc_relevant_t0.none-task-blog-2%7Edefault%7ECTRLIST%7Edefault-1-120555863-blog-121065713.pc_relevant_multi_platform_whitelistv3&depth_1-utm_source=distribute.pc_relevant_t0.none-task-blog-2%7Edefault%7ECTRLIST%7Edefault-1-120555863-blog-121065713.pc_relevant_multi_platform_whitelistv3&utm_relevant_index=1