Go 函数调用━栈和寄存器视角

本文深入探讨Go语言的函数调用机制,包括调用过程、参数传递方式、返回值处理、匿名函数与闭包等。通过示例代码和汇编分析,揭示Go在栈、寄存器使用上的特点,以及与C语言调用惯例的差异。理解这些概念有助于编写高效Go代码和进行性能优化。
摘要由CSDN通过智能技术生成

函数的调用过程主要要点在于借助寄存器和内存帧栈传递参数和返回值。虽然同为编译型语言,Go 相较 C 对寄存器和栈的使用有一些差别,同时,Go 语言自带协程并引入 defer 等语句,在调用过程上显得更加复杂。 理解Go函数调用在CPU指令层的过程有助于编写高效的代码,在性能优化、Bug排查的时候,能更迅速的确定要点。本文以简短的示例代码和对应的汇编代码演示了Go的调用过程,展示了不同数据类型的参数的实际传递过程,同时分析了匿名函数、闭包作为参数或者返回值传递时,在内存上的实际数据结构。对于协程对栈的使用和实现细节,本文不展开。
  阅读本文需要掌握计算机体系结构基础知识(至少了解程序内存布局、栈、寄存器)、Go 基础语法。参考文档提供了这些主题更详细的知识。
  以下:

术语

  • 栈:每个进程/线程/goroutine有自己的调用栈,参数和返回值传递、函数的局部变量存放通常通过栈进行。和数据结构中的栈一样,内存栈也是后进先出,地址是从高地址向低地址生长。
  • 栈帧:(stack frame)又常被称为帧(frame)。一个栈是由很多帧构成的,它描述了函数之间的调用关系。每一帧就对应了一次尚未返回的函数调用,帧本身也是以栈的形式存放数据的。
  • caller 调用者
  • callee 被调用者,如在 函数 A 里 调用 函数 B,A 是 caller,B 是 callee

寄存器(X86)

  • ESP:栈指针寄存器(extended stack pointer),存放着一个指针,该指针指向栈最上面一个栈帧(即当前执行的函数的栈)的栈顶。注意:
    • ESP指向的是已经存储了内容的内存地址,而不是一个空闲的地址。例如从 0xC0000000 到 0xC00000FF是已经使用的栈空间,ESP指向0xC00000FF
  • EBP:基址指针寄存器(extended base pointer),也叫帧指针,存放着一个指针,该指针指向栈最上面一个栈帧的底部。
  • EIP:寄存器存放下一个CPU指令存放的内存地址,当CPU执行完当前的指令后,从EIP寄存器中读取下一条指令的内存地址,然后继续执行。

注意:16位寄存器没有前缀(SP、BP、IP),32位前缀是E(ESP、EBP、EIP),64位前缀是R(RSP、RBP、RIP)

汇编指令

  • PUSH:进栈指令,PUSH指令执行时会先将ESP减4,接着将内容写入ESP指向的栈内存。
  • POP :出栈指令,POP指令执行时先将ESP指向的栈内存的一个字长的内容读出,接着将ESP加4。注意:
    • 用PUSH指令和POP指令时只能按字访问栈,不能按字节访问栈。
  • CALL:调用函数指令,将返回地址(call指令的下一条指令)压栈,接着跳转到函数入口。
  • RET:返回指令,将栈顶返回地址弹出到EIP,接着根据EIP继续执行。
  • LEAVE:等价于 mov esp,ebp; pop ebp;
  • MOVL:在内存与寄存器、寄存器与寄存器之间转移值
  • LEAL:用来将一个内存地址直接赋给目的操作数

注意:8位指令后缀是B、16位是S、32位是L、64位是Q

调用惯例

调用惯例(calling convention)是指程序里调用函数时关于如何传参如何分配和清理栈等的方案。一个调用惯例的内容包括:

  • 参数是通过寄存器传递还是栈传递或者二者混合

  • 通过栈传递时参数是从左至右压栈还是从右至左压栈

  • 函数结果是通过寄存器传递还是通过栈传递

  • 调用者(caller)还是被调用者(callee)清理栈空间

  • 被调用者应该为调用者保存哪些寄存器

    例如,C 的调用惯例(cdecl, C declaration)是:

  • 函数实参在线程栈上按照从右至左的顺序依次压栈。

  • 函数结果保存在寄存器EAX/AX/AL中

  • 浮点型结果存放在寄存器ST0中

  • 编译后的函数名前缀以一个下划线字符

  • 调用者负责从线程栈中弹出实参(即清栈)

  • 8比特或者16比特长的整形实参提升为32比特长。

  • 受到函数调用影响的寄存器(volatile registers):EAX, ECX, EDX, ST0 - ST7, ES, GS

  • 不受函数调用影响的寄存器: EBX, EBP, ESP, EDI, ESI, CS, DS

  • RET指令从函数被调用者返回到调用者(实质上是读取寄存器EBP所指的线程栈之处保存的函数返回地址并加载到IP寄存器)

cdecl 将函数返回值保存在寄存器中,所以 C 语言不支持多个返回值。另外,cdecl 是调用者负责清栈,因而可以实现可变参数的函数。如果是被调用者负责清理的话,无法实现可变参数的函数,但是编译代码的效率会高一点,因为清理栈的代码不用在每次调用的时候(编译器计算)生成一遍。(x86的ret指令允许一个可选的16位参数说明栈字节数,用来在返回给调用者之前解堆栈。代码类似ret 12这样,如果遇到这样的汇编代码,说明是被调用者清栈。)

注意,虽然 C 语言 里都是借助寄存器传递返回值,但是返回值大小不同时有不同的处理情形。若小于4字节,返回值存入eax寄存器,由函数调用方读取eax。若返回值5到8字节,采用eax和edx联合返回。若大于8个字节,首先在栈上额外开辟一部分空间temp,将temp对象的地址做为隐藏参数入栈。函数返回时将数据拷贝给temp对象,并将temp对象的地址用寄存器eax传出。调用方从eax指向的temp对象拷贝内容。

可以看到,设计一个编程语言的特性时,需要为其选择合适调用惯例才能在底层实现这些特性。(调用惯例是编程语言的编译器选择的,同样的语言不同的编译器可能会选择实现不同的调用惯例)

一次典型的 C 函数调用过程

在caller里

  • 将实参从右至左压栈(X86-64下是:将实参写入寄存器,如果实参超过 6 个,超出的从右至左压栈)
  • 执行 call 指令(会将返回地址压栈,并跳转到 callee 入口)

进入callee里

  • push ebp; mov ebp,esp; 此时EBP和ESP已经分别表示callee的栈底和栈顶了。之后 EBP 的值会保持固定。此后局部变量和临时存储都可以通过基准指针EBP加偏移量找到了。
  • sub xxx, esp; 栈顶下移,为callee分配空间,用于存放局部变量等。分配的内存单元可以通过 EBP - K 或者 ESP + K 得到地址访问。
  • 将某些寄存器的值压栈(可能)
  • callee执行
  • 将某些寄存器值弹出栈(可能)
  • mov esp,ebp; pop ebp; (这两条指令也可以用 leave 指令替代)此时 EBP 和 ESP 回到了进入callee之前的状态,即分别表示caller的栈底和栈顶状态。
  • 执行 ret 指令

回到了caller里的代码

int add(int arg1, int arg2, int arg3, int arg4, int arg5, int arg6, int arg7, int arg8) {
   
    return arg1 + arg2 + arg3 + arg4 + arg5 + arg6 + arg7 + arg8;
}

int main() {
   
    int i = add(1, 2, 3 , 4, 5, 6, 7, 8);
}

x86版汇编

	.section	__TEXT,__text,regular,pure_instructions
	.build_version macos, 10, 14	sdk_version 10, 14
	.globl	_add                    ## -- Begin function add
	.p2align	4, 0x90
_add:                                   ## @add
	.cfi_startproc
## %bb.0:
	pushl	%ebp
	.cfi_def_cfa_offset 8
	.cfi_offset %ebp, -8
	movl	%esp, %ebp
	.cfi_def_cfa_register %ebp
	pushl	%ebx
	pushl	%edi
	pushl	%esi
	subl	$32, %esp
	.cfi_offset %esi, -20
	.cfi_offset %edi, -16
	.cfi_offset %ebx, -12
	movl	36(%ebp), %eax
	movl	32(%ebp), %ecx
	movl	28(%ebp), %edx
	movl	24(%ebp), %esi
	movl	20(%ebp), %edi
	movl	16(%ebp), %ebx
	movl	%eax, -16(%ebp)         ## 4-byte Spill
	movl	12(%ebp), %eax
	movl	%eax, -20(%ebp)         ## 4-byte Spill
	movl	8(%ebp), %eax
	movl	%eax, -24(%ebp)         ## 4-byte Spill
	movl	8(%ebp), %eax
	addl	12(%ebp), %eax
	addl	16(%ebp), %eax
	addl	20(%ebp), %eax
	addl	24(%ebp), %eax
	addl	28(%ebp), %eax
	addl	32(%ebp), %eax
	addl	36(%ebp), %eax
	movl	%ebx, -28(%ebp)         ## 4-byte Spill
	movl	%ecx, -32(%ebp)         ## 4-byte Spill
	movl	%edx, -36(%ebp)         ## 4-byte Spill
	movl	%esi, -40(%ebp)         ## 4-byte Spill
	movl	%edi, -44(%ebp)         ## 4-byte Spill
	addl	$32, %esp
	popl	%esi
	popl	%edi
	popl	%ebx
	popl	%ebp
	retl
	.cfi_endproc
                                        ## -- End function
	.globl	_main                   ## -- Begin function main
	.p2align	4, 0x90
_main:                                  ## @main
	.cfi_startproc
## %bb.0:
	pushl	%ebp
	.cfi_def_cfa_offset 8
	.cfi_offset %ebp, -8
	movl	%esp, %ebp
	.cfi_def_cfa_register %ebp
	subl	$40, %esp
	movl	$1, (%esp)
	movl	$2, 4(%esp)
	movl	$3, 8(%esp)
	movl	$4, 12(%esp)
	movl	$5, 16(%esp)
	movl	$6, 20(%esp)
	movl	$7, 24(%esp)
	movl	$8, 28(%esp)
	calll	_add
	xorl	%ecx, %ecx
	movl	%eax, -4(%ebp)
	movl	%ecx, %eax
	addl	$40, %esp
	popl	%ebp
	retl
	.cfi_endproc
                                        ## -- End function

.subsections_via_symbols

x86-64版汇编

	.section	__TEXT,__text,regular,pure_instructions
	.build_version macos, 10, 14	sdk_version 10, 14
	.globl	_add                    ## -- Begin function add
	.p2align	4, 0x90
_add:                                   ## @add
	.cfi_startproc
## %bb.0:
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset %rbp, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register %rbp
	movl	24(%rbp), %eax
	movl	16(%rbp), %r10d
	movl	%edi, -4(%rbp)
	movl	%esi, -8(%rbp)
	movl	%edx, -12(%rbp)
	movl	%ecx, -16(%rbp)
	movl	%r8d, -20(%rbp)
	movl	%r9d, -24(%rbp)
	movl	-4(%rbp), %ecx
	addl	-8(%rbp), %ecx
	addl	-12(%rbp), %ecx
	addl	-16(%rbp), %ecx
	addl	-20(%rbp), %ecx
	addl	-24(%rbp), %ecx
	addl	16(%rbp), %ecx
	addl	24(%rbp), %ecx
	movl	%eax, -28(%rbp)         ## 4-byte Spill
	movl	%ecx, %eax
	movl	%r10d, -32(%rbp)        ## 4-byte Spill
	popq	%rbp
	retq
	.cfi_endproc
                                        ## -- End function
	.globl	_main                   ## -- Begin function main
	.p2align	4, 0x90
_main:                                  ## @main
	.cfi_startproc
## %bb.0:
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset %rbp, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register %rbp
	subq	$32, %rsp
	movl	$1, %edi
	movl	$2, %esi
	movl	$3, %edx
	movl	$4, %ecx
	movl	$5, %r8d
	movl	$6, %r9d
	movl	$7, (%rsp)
	movl	$8, 8(%rsp)
	callq	_add
	xorl	%ecx, %ecx
	movl	%eax, -4(%rbp)
	movl	%ecx, %eax
	addq	$32, %rsp
	popq	%rbp
	retq
	.cfi_endproc
                                        ## -- End function

.subsections_via_symbols

可以看到 Clang 编译出的X86目标代码并不使用寄存器传递参数,而X86-64目标代码里,使用寄存器传递前六个参数。

一次典型的Go函数调用过程

Go 选择的调用惯例是:

  • 参数完全通过栈传递,从参数列表的右至左压栈
  • 返回值通过栈传递,返回值的栈空间在参数之前,即返回值在更接近caller栈底的位置
  • caller负责清理栈
package main

func main() {
   
	add(1,2)
}

//go:noinline
func add(a , b int) int {
   
	c := 3
	d := a + b + c
	return d
}
TEXT main.main(SB) /Users/user/go/src/test/main.go
  main.go:4		0x104ea20		65488b0c2530000000	MOVQ GS:0x30, CX			
  main.go:4		0x104ea29		483b6110		CMPQ 0x10(CX), SP	
  main.go:4		0x104ea2d		762e			JBE 0x104ea5d				
  main.go:4		0x104ea2f		4883ec20		SUBQ $0x20, SP ; 增加 32 bytes 的栈空间(四个 qword,8个bytes 为一个 qword)				
  main.go:4		0x104ea33		48896c2418		MOVQ BP, 0x18(SP) ; 将 BP 的值写入到刚分配的栈空间的第一个qword
  main.go:4		0x104ea38		488d6c2418		LEAQ 0x18(SP), BP ; 将刚分配的栈空间的第一个字的地址赋值给BP(即BP此时指向了刚才存放旧BP值的地址)			
  main.go:5		0x104ea3d		48c7042401000000	MOVQ $0x1, 0(SP); 将给add函数的第一个实参值1 写入到刚分配栈空间的最后一个qword
  main.go:5		0x104ea45		48c744240802000000	MOVQ $0x2, 0x8(SP); 将给add函数的第二个实参值2 写入到刚分配栈空间的第三个qword。第二个 qword 没有用到,实际上是给callee用来存放返回值的。 			
  main.go:5		0x104ea4e		e81d000000		CALL main.add(SB); 调用 add 函数	
  main.go:6		0x104ea53		488b6c2418		MOVQ 0x18(SP), BP; 将从栈里第四个qword将旧的BP值取回赋值到BP
  main.go:6		0x104ea58		4883c420		ADDQ $0x20, SP; 增加SP的值,栈收缩,收回 32 bytes的栈空间 				
  main.go:6		0x104ea5c		c3			RET					
  
  TEXT main.add(SB) /Users/user/go/src/test/main.go
  main.go:11		0x104ea70		4883ec18		SUBQ $0x18, SP; 分配 24 bytes 的栈空间(3 个 qword)。		
  main.go:11		0x104ea74		48896c2410		MOVQ BP, 0x10(SP); 将 BP值 写入第一个qword
  main.go:11		0x104ea79		488d6c2410		LEAQ 0x10(SP), BP; 将刚分配的24 bytes 栈空间的第一个字的地址赋值给BP(即BP此时指向了刚才存放旧BP值的地址)		
  main.go:11		0x104ea7e		48c744243000000000	MOVQ $0x0, 0x30(SP);将存放返回值的地址清零,0x30(SP) 对应的内存位置是上一段 main.main 里分配的栈空间的第二个qword。
  main.go:12		0x104ea87		48c744240803000000	MOVQ $0x3, 0x8(SP); 对应 c := 3 这行代码。局部变量 c 对应的是栈上内存。3 被写入到刚分配的 24 bytes 空间的第二个qword。
  main.go:13		0x104ea90		488b442420		MOVQ 0x20(SP), AX; 将add的实参 1 写入到AX 寄存器。
  main.go:13		0x104ea95		4803442428		ADDQ 0x28(SP), AX; 将add的实参 2 增加到 AX 寄存器。
  main.go:13		0x104ea9a		4883c003		ADDQ $0x3, AX; 将局部变量值 3 增加到 AX 寄存器		
  main.go:13		0x104ea9e		48890424		MOVQ AX, 0(SP); 将 AX 的值(计算结果) 写入到刚分配的 24 bytes 空间的第三个qword。(对应代码 d := a + b + c)
  main.go:14		0x104eaa2		4889442430		MOVQ AX, 0x30(SP); 将 AX 的值写入到main里为返回值留的栈空间(main里分配的32 bytes 中的第二个 qword)
  main.go:14		0x104eaa7		488b6c2410		MOVQ 0x10(SP), BP; 恢复BP的值为函数入口处保存的旧BP的值。
  main.go:14		0x104eaac		4883c418		ADDQ $0x18, SP; 将 SP 增加三个字,收回add入口处分配的栈空间。	
  main.go:14		0x104eab0		c3			RET			

函数调用过程中,栈的变化情况如图:

初始状态:

call add执行前栈状态:

进入add里之后栈状态:

add里ret执行前栈状态:

main里ret执行前栈状态:

可以看到 Go 的调用过程和 C 类似,区别在于 Go 的参数完全通过栈传递,Go 的返回值也是通过栈传递。对于每种数据类型在作为参数传递时的表现,可以测试一下:

不同数据类型作为参数时的传递方式

Go 基础数据类型的参数传递
package main

import (
	"fmt"
	"runtime/debug"
)

func main() {
   

	str := "hello"
	int8 := int8(8)
	int64 := int64(64)

	boolValue := true

	ExampleStr(str)
	ExampleBool(boolValue)
	ExampleInt8(int8)
	
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值