Go语言基础结构 —— func 函数

概要

​ 函数是一段封装了特定功能的可重用代码块,用于执行特定的任务或计算。函数接受输入(参数)并产生输出(返回值),可以带有副作用(修改状态或执行其他操作)。

Go语言中,函数被认为是一等公民(First-class citizens),这意味着函数在语言中具有与其他类型(如整数、字符串等)相同的权利和地位。以下是函数在Go语言中被视为一等公民的原因:

  1. 函数可以作为值进行传递:在Go语言中,函数可以像其他类型的值一样被传递给其他函数或赋值给变量。这意味着可以将函数作为参数传递给其他函数,也可以将函数作为返回值返回。
  2. 函数可以赋值给变量:在Go语言中,可以将函数赋值给变量,然后通过变量来调用函数。这种能力使得函数可以像其他数据类型一样被操作和处理。
  3. 函数可以匿名定义:Go语言支持匿名函数的定义,也称为闭包。这意味着可以在不给函数命名的情况下直接定义和使用函数,更加灵活和便捷。
  4. 函数可以作为数据结构的成员:在Go语言中,函数可以作为结构体的成员,从而使得函数与其他数据一起存储在结构体中。这种特性使得函数能够更好地与数据相关联,实现更复杂的功能。

函数作为一等公民的特性使得Go语言具有很高的灵活性和表达力,可以方便地实现函数式编程的思想,并且支持构建高阶函数和函数组合等高级编程技巧。

函数声明/定义

在Go语言中,使用func关键字来定义函数。函数的基本语法如下:

func functionName(parameter1 type, parameter2 type) returnType {
    // 函数体
}

其中:

  • 函数声明:关键字 func

  • functionName 代表是函数的名称,函数名由字母、数字、下划线组成。但函数名的第一个字母不能是数字。在同一个包内,函数名称不能重名。

  • parameter1parameter2 代表是函数的参数,type 是参数的类型。参数由参数变量和参数变量的类型组成,参数变量可以省略,可以有一个参数,也可以有多个,也可以没有;多个参数之间使用,分隔;多个参数时参数变量要么全写,要么全省略;如果多个相邻参数的类型是一样的,可以只保留同一类型最后一个参数的声明。

    下面列举几种函数参数的不同定义的方式:

    1. 单个参数:

      func functionName(parameterName parameterType) {
          // 函数体
      }
      

      这是函数接受单个参数的基本形式。parameterName是参数的名称,parameterType是参数的类型。

    2. 多个参数:

      func functionName(parameter1Name parameter1Type, parameter2Name parameter2Type) {
          // 函数体
      }
      

      如果函数需要接受多个参数,可以在函数声明中依次列出参数的名称和类型。

    3. 可变参数(Variadic parameters):

      func functionName(parameterName ...parameterType) {
          // 函数体
      }
      

      可变参数允许函数接受不定数量的参数。在参数类型之前使用...来指示可变参数的形式。在函数体内,可变参数被当作切片类型来处理。

    4. 参数命名和类型省略:

      func functionName(parameter1, parameter2 int) {
          // 函数体
      }
      

      在函数定义中,如果多个参数具有相同的类型,可以省略参数类型,并在最后一个参数上指定类型。这种情况下,所有的参数都将具有相同的类型。

    5. 匿名参数:

      func functionName(int, string) {
          // 函数体
      }
      

      在函数定义中,如果不需要使用参数的值,可以将参数名称省略,只保留参数类型。这种形式的参数被称为匿名参数。

  • returnType 是函数的返回值类型。返回值由返回值变量和其变量类型组成,返回值变量可以省略,可以有一个返回值,也可以有多个,也可以没有;多个返回值必须用()包裹,并用,分隔;多个返回值时返回值变量要么全写,要么全省略。

    下面列举返回类型的不同定义方式:、

    1. 单个返回值:

      func functionName() returnType {
          // 函数体
          return value
      }
      

      这是函数返回单个值的基本形式。returnType是返回值的类型,value是要返回的具体值。

    2. 多个返回值:

      func functionName() (returnType1, returnType2) {
          // 函数体
          return value1, value2
      }
      

      如果函数需要返回多个值,可以在函数声明中使用括号将多个返回值类型括起来,并在函数体内使用逗号分隔返回的具体值。

    3. 命名返回值:

      func functionName() (returnValue1 returnType1, returnValue2 returnType2) {
          // 函数体
          returnValue1 = value1
          returnValue2 = value2
          return
      }
      

      可以为返回值命名,通过在函数声明中为返回值指定名称和类型。在函数体内,可以直接为这些命名返回值赋值,并在最后使用return关键字返回结果。

    4. 空返回值:

      func functionName() {
          // 函数体
          return
      }
      
      

      如果函数没有返回值,可以省略返回值的类型和具体值,只使用return关键字。

  • 函数体是函数的具体实现, 指的是指定功能的逻辑。

根据函数的功能和需求来定义参数和返回值。以下是几个函数声明的例子:

  1. 基本的函数声明:

    func sayHello() {
        fmt.Println("Hello!")
    }
    

    这个函数没有参数,也没有返回值。它的作用是打印"Hello!"。

  2. 带有参数的函数声明:

    func greet(name string) {
        fmt.Println("Hello, " + name + "!")
    }
    

    这个函数接受一个字符串类型的参数name,并打印"Hello, "加上name的值。

  3. 带有返回值的函数声明:

    func add(a, b int) int {
        return a + b
    }
    

    这个函数接受两个整数类型的参数ab,并返回它们的和。

  4. 带有多个返回值的函数声明:

    func divide(a, b float64) (float64, error) {
        if b == 0 {
            return 0, errors.New("division by zero")
        }
        return a / b, nil
    }
    

    这个函数接受两个float64类型的参数ab,并返回它们的商和一个error类型的错误值(在除法操作时检查除数是否为零)。

  5. 可变参数的函数声明:

    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 中的汇编语言。

目前根据常见的指令集架构和代码风格,汇编分类如下:

按照指令集架构分类:

  1. x86 架构汇编:主要用于 Intel x86 和兼容处理器的汇编语言。x86 汇编是最广泛使用的汇编语言之一,用于编写 PC、服务器和大多数个人计算机的底层代码。
  2. ARM 架构汇编:用于 ARM 架构处理器的汇编语言,广泛应用于移动设备、嵌入式系统和低功耗设备。
  3. MIPS 架构汇编:用于 MIPS 架构处理器的汇编语言,常见于嵌入式系统、网络设备和嵌入式控制器。

按照代码风格分类:

  1. AT&T 语法:AT&T 语法是一种汇编语言代码风格,常用于 UNIX 系统和 GNU 工具链。它以movladdl等指令表示操作符和操作数,并使用%前缀表示寄存器。
  2. Intel 语法:Intel 语法是另一种汇编语言代码风格,主要用于 Intel 架构和微处理器。它以movadd等指令表示操作符和操作数,并不使用前缀表示寄存器。

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 寄存器中
    
  • 计算指令:ADDSUB, 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 做加法,清除函数栈帧
    
  • 跳转指令:JMPJZ, 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个寄存器:

  1. **通用寄存器。**通用寄存器有16个寄存器,CPU对它们的用途没有做特殊规定,可以自定义其用途(其中SPBP这两个寄存器有特殊用途)

    golang中,作为参数存储使用的限制为数量9个,当这9个寄存器装不下了,会继续使用栈来传递:

    image-20230601155424225

    还有两个特殊寄存器,这两个寄存器在栈帧环节会频繁出现:

    • BP寄存器,基址指针寄存器(extended base pointer),也叫帧指针,存放着一个指针,表示函数栈开始的地方;

    • SP寄存器,栈指针寄存器(extended stack pointer),存放着一个指针,存储的是函数栈空间的栈顶,也就是函数栈空间分配结束的地方;

    还有一个比较特殊的参数类型,那就是浮点型参数。由于amd 64架构中,浮点型数据的编码与整形数据编码大不相同,而浮点数的运算会使用专用寄存器和指令。所以浮点数不会使用这9个通用寄存器来传递,而是使用这15个XMM寄存器来传递。这组XMM寄存器是随着多媒体相关的指令集一起引入的, go 语言使用它们来处理浮点数。前15个浮点型参数会依次使用x0x1415个寄存器来传递,如果还有浮点数据就要使用栈来传递了:

    image-20230601162611271

  2. **程序计数寄存器(rip寄存器,也叫PC寄存器、IP寄存器)。**用来存放下一条即将用来执行的指令的地址,它决定程序执行的流程。

  3. **段寄存器(FS、GS寄存器)。**用来实现线程本地存储(TLS),比如ADM64LinuxGo语言和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(编译好的文件名)

至此,知识理论已经完备,可以下面一步分析了。

函数栈帧

为了更加深入的理解函数中的局部变量、参数、返回值、递归和嵌套函数、闭包和其他与函数调用相关知识以及语言的实现和性能,需要对函数栈帧知识有一定的了解。

操作系统把磁盘上的可执行文件加载到内存之前,会做很多工作。最重要的一个环节就是把可执行文件中的代码、数据放在内存中合适的位置上,并分配和初始化程序运行中所必须的堆和栈,当所有操作完成后,操作系统才会调度程序运行起来。

程序运行时在内存中的布局如下:

image-20220520153140923

程在内存中的布局主要分为四个部分:代码区、数据区、堆和栈,如下:

  1. 代码区【Code段】。包括能被CPU执行的机器代码(指令)和只读数据(比如字符串常量),当程序加载完毕后,代码区大小不会再进行变化;

  2. 数据区【Data段】。包括程序的全局变量和静态变量,与代码区一样,当程序加载完毕后,代码区大小不会再进行变化;

  3. 堆【Heap】。程序运行是动态分配的内存都位于堆中,这部分内存由内存分配器进行管理。

  4. 栈【Stack】:由系统的编译器自动的释放,主要用来存放方法中的参数,一些临时的局部变量等,并且方法中的参数一般在操作完后,会由编译器自动的释放掉。栈只有一个口可供进出,先入栈的在底,后入栈的在顶,最后入栈的最早被取出。运行时栈,上面是高地址,向下增长,栈底通常被称为“栈基(BP)”,栈顶被称为“栈指针(SP)”。

程序运行过程,不管是函数的执行还是调用,栈(Stack)都起着非常重要的作用,用途如下:

  1. 函数的局部变量:函数内部声明的局部变量(包括基本类型、结构体、数组、切片、映射等)存储在栈区。这些局部变量的生命周期与函数调用相关,它们在函数返回后自动被释放。
  2. 函数的参数:函数调用时传递的参数也存储在栈区中。参数的值会被复制到函数栈帧中的参数变量中,函数在执行过程中使用这些参数进行计算。
  3. 函数的返回值:函数执行完成后,返回值也存储在栈区中。当函数调用结束后,返回值会从栈帧中复制到调用方的栈帧中。
  4. 函数调用过程中的调用信息:函数调用时,栈区还存储了一些与函数调用过程相关的信息,如函数返回地址、函数调用上下文等。这些信息用于跟踪函数调用的顺序和状态。

( 需要注意的是,Go语言中的动态分配的内存(如使用newmake等方式创建的对象)不存储在栈区,而是存储在堆区。栈区的大小是有限的,而堆区的大小取决于操作系统和可用内存的限制。)

每个函数在执行过程中都需要用栈来保存上述的值,此时分配给函数的栈空间被就称为这个函数的栈帧(stack frame)

Go语言中函数栈帧布局是这样的,先是**调用者(caller) 栈基(BP)地址,然后是函数的局部变量,最后是被调用(callee)**函数的返回值和参数:

image-20220805161551056

上图所展示的就是一个函数栈帧的结构。栈帧有以下部分组成且按先后顺序入栈保存:

  • 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)

针对汇编代码,我们逐行分析下,看下函数栈空间从空间分配到数据存放的完整流程:

  1. main 函数栈帧空间构建(BPSP地址确定)

      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)中;

    image-20230601180043978

  2. 调用main.calc函数

      main.go:13		0xde4			e800000000		CALL 0xde9		[1:5]R_CALL:main.calc	
    

    调用 CALL 指令,CALL 指令主要操作两件事情:

    1. 将下一条指令的地址入栈,被调用函数执行结束后会跳回到这个地址继续执行,这就是函数调用的“返回地址”。入栈操作会导致栈SP位置的变化
    2. 跳转到被调用的函数指令入口处执行

    image-20230601180917174

  3. 构造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 函数中的相关赋值指令。

    image-20230601183638705

  4. 调用main.add函数

    main.go:8		0xd8f			e800000000		CALL 0xd94		[1:5]R_CALL:main.add	
    

    调用 CALL 指令,CALL 指令主要操作两件事情就不重复了,直接上图:

    image-20230601184734501

  5. 构造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)位置

    image-20230601192658141

  6. 执行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作用是:

    1. 如果存在弹出CALL指令压栈的返回地址;
    2. 跳转到CALL指令压栈的返回地址处

    执行完 add 函数,则会释放add

    image-20230602115520446

  7. 执行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

    image-20230602123002404

  8. 当执行完 cacl 函数,就只剩下main函数栈帧了,如下图:

    image-20230602124057265

    main 函数后续指令就不继续分析了。

通过这个例子的函数栈帧信息,我们总结下golang的函数栈帧的一些重要特点:

  • 栈帧按照先后顺序存储数据,首先是栈基地址(BP),接着是局部变量以及临时变量,然后是函数返回值、参数(从右到左压栈)、返回地址。

  • 函数栈帧大小是在编译时确定的,而不是在运行时动态分配或渐进性分配的,对于栈消耗较大的函数,go语言的编译器还会在函数头部插入检测代码,如果发现需要进行“栈增长”,就会另外分配一段足够大的栈空间,并把原来栈上的数据拷过来,原来的这段栈空间就被释放了。

    image-20230602150030960

    函数栈帧大致就这样,下面通过函数栈帧去解开一些问题。

值传递和引用传递

我们从实际例子出发,看代码:

// 值传递函数
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]
}

通过代码结果,可以看出 is变量经过函数f1修改值后未改变其原本值,而x变量经过f2函数修改值后更改影响了原本的值。

golang参数传递有两种方式:

  • 值传递,是指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数;值传递的数据类型包括:基本数据类型int系列,float系列,boolstring,数组和结构体struct等。
  • 引用传递,是指在调用函数时将实际参数的地址传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数;引用类型数据类型包括: 指针,slice切片,map,管道chaninterface等。

下面我们分别选取一种类型通过函数栈帧来查看值传递和引用传递的不同。

值传递

直接看示例:

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)		

根据汇编代码,画出栈帧变化图:
image-20230605171759887

当我们将一个值类型的变量(如整数、浮点数、布尔值等)作为参数传递给函数时,函数会在栈上创建该变量的一个副本,并在函数内部使用该副本。对副本的任何修改都不会影响原始变量。这是因为值类型的变量存储的是实际的值,而不是指向该值的地址。

Change函数中参数i值是复制了一份main函数局部变量i的值,传递给函数的是变量值的副本,此时对参数i进行赋值修改,对main函数的局部变量i值不会产生任何影响。

像基本数据类型int系列,float系列,boolstring,数组和结构体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)	

根据汇编代码,画出栈帧变化图:

image-20230605160600768

从栈帧变化图上可以看出,跟值传递一样,作为参数传递给函数时,函数会在栈上创建该变量的一个副本,并在函数内部使用该副本。但不同的是,引用传递副本中的指针指向相同的底层数据。这意味着函数内部对该变量进行的修改会反映在原始变量上,因为它们共享相同的数据。

值传递和引用传递区别如下图:

image-20230605165522868

匿名函数

Go 语言中,匿名函数是一种没有名称的函数。它是一种函数字面量,可以在代码中直接定义和使用,而无需事先命名。

匿名函数通常用于需要在某个特定位置定义、传递或执行函数的场景,而不需要在其他地方重复使用该函数。它们可以作为变量的值,传递给其他函数,或直接调用。

匿名函数的语法类似于普通函数,但没有函数名。它可以包含参数列表、函数体和返回值。

声明

匿名函数声明格式如下:

func(参数列表)(返回参数列表){
    函数体
}

例如:

	func(i int) int{
		return i * 2
	}

使用

在Go语言中,可以使用匿名函数(也称为闭包)来定义没有名字的函数。匿名函数可以作为值赋给变量,也可以作为参数传递给其他函数,或者直接被调用。

下面是一些匿名函数的使用示例:

  1. 将匿名函数赋值给变量并调用:

    add := func(a, b int) int {
        return a + b
    }
    
    result := add(3, 5)
    fmt.Println(result) // 输出: 8
    
  2. 将匿名函数作为参数传递给其他函数:

    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
    
  3. 在函数内部定义并立即调用匿名函数:

    result := func(a, b int) int {
        return a + b
    }(3, 5)
    
    fmt.Println(result) // 输出: 8
    
  4. 闭包的使用或者作为返回值,匿名函数可以捕获外部函数的变量:

    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如下图所示形式存在的:

image-20230606154040176

这块内容后续会在闭包章节中重点突出,目前只做简单的了解。

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包的情况下,加载执行顺序如下图:

image-20230609162150426

以上这张图片很清晰的反应了init函数的加载顺序:包加载优先级排在第一位,先层层递归进行包加载,每个包中加载顺序为:const > var > init,首先进行初始化的是常量,然后是变量,最后才是init函数。

使用场景

init() 函数通常用于执行一些初始化任务,例如:

  • 初始化全局变量或常量。
  • 注册/初始化数据库连接。
  • 执行配置加载和解析。
  • 注册/初始化各种组件或模块。
  • 进行一些必要的预处理操作等。

以下是一些常见的使用场景和相应的示例:

  1. 初始化全局变量或常量:

    var globalVar int
    
    func init() {
    	globalVar = 10
    }
    
  2. 注册和初始化数据库连接:

    func init() {
    	db.Connect("localhost:3306", "username", "password")
    }
    
    
  3. 执行配置加载和解析:

    func init() {
    	config.Load("config.json")
    }
    
  4. 注册和初始化各种组件或模块:

    func init() {
    	cache.Register()
    	logging.Init()
    }
    
  5. 进行一些必要的预处理操作:

    func init() {
    	// 检查环境变量或命令行参数
    	// 设置日志级别
    	// 等等...
    }
    
  6. 注册自定义的类型、接口实现或者其他初始化任务:

    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

  • 20
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值