衡水做网站技术,怎样使自己做的网站上线,深圳建站网络公司,网站建设与网页设计论文更好的观看体验#xff0c;请点击——函数调用 | YinKais Blog 本文将从函数的调用惯例和参数传递方法两个方面分别介绍函数执行的过程。
1、调用惯例
对于不同的编程语言#xff0c; 它们在调用函数的时候往往都使用相同的语法#xff1a;
somefunction(arg0, arg1)
虽…更好的观看体验请点击——函数调用 | YinKais Blog 本文将从函数的调用惯例和参数传递方法两个方面分别介绍函数执行的过程。
1、调用惯例
对于不同的编程语言 它们在调用函数的时候往往都使用相同的语法
somefunction(arg0, arg1)
虽然它们调用函数的语法相似但它们的调用习惯可能大不相同。调用管理是调用方和被调用方对于参数和返回值传递的约定下面会对 Go 语言和 C 语言的调用惯例进行讲解。
C 语言
假设有以下 C 语言代码包含一个主函数 main 和一个自定义函数 my_functionint my_function(int arg1, int arg2) {return arg1 arg2;
}
int main() {int i my_function(1, 2);
}
编译成汇编代码如下
main:pushq %rbp ; 保存主函数的栈帧movq %rsp, %rbp ; 设置主函数的栈帧subq $16, %rsp ; 为局部变量分配 16 字节的栈空间movl $2, %esi ; 设置第二个参数 (esi 2)movl $1, %edi ; 设置第一个参数 (edi 1)call my_function ; 调用 my_functionmovl %eax, -4(%rbp) ; 将 my_function 的返回值保存在主函数的局部变量中; 继续执行主函数的其它部分
my_function:pushq %rbp ; 保存 my_function 的栈帧movq %rsp, %rbp ; 设置 my_function 的栈帧movl %edi, -4(%rbp) ; 将第一个参数从寄存器 edi 放入 my_function 的栈帧中movl %esi, -8(%rbp) ; 将第二个参数从寄存器 esi 放入 my_function 的栈帧中movl -8(%rbp), %eax ; 将第二个参数esi加载到寄存器 eax (eax 1)movl -4(%rbp), %edx ; 将第一个参数edi加载到寄存器 edx (edx 2)addl %edx, %eax ; 计算 eax eax edx (eax 1 2 3)popq %rbp ; 恢复 my_function 的栈帧ret ; 返回 my_function 的调用
我们按照调用前、调用时以及调用后的顺序分析上述调用过程 在 my_function 调用前调用方 main 函数将 my_function 的两个参数分别存到 edi 和 esi 寄存器中 在 my_function 调用时它会将寄存器 edi 和 esi 中的数据存储到 eax 和 edx 两个寄存器中随后通过汇编指令 addl 计算两个入参之和 在 my_function 调用后使用寄存器 eax 传递返回值main 函数将 my_function 的返回值存储到栈上的 i 变量中
int my_function(int arg1, int arg2, int ... arg8) {return arg1 arg2 ... arg8;
}
如上述代码所示当 my_function 函数的入参增加至八个时重新编译当前程序可以会得到不同的汇编代码
main:pushq %rbpmovq %rsp, %rbpsubq $16, %rsp // 为参数传递申请 16 字节的栈空间movl $8, 8(%rsp) // 传递第 8 个参数movl $7, (%rsp) // 传递第 7 个参数movl $6, %r9dmovl $5, %r8dmovl $4, %ecxmovl $3, %edxmovl $2, %esimovl $1, %edicall my_function
main 函数调用 my_function 时前六个参数会使用 edi、esi、edx、ecx、r8d 和 r9d 六个寄存器传递。寄存器的使用顺序也是调用惯例的一部分函数的第一个参数一定会使用 edi 寄存去第二个参数使用 esi 寄存器以此推类。
最后两个参数与前面完全不同调用方 main函数通过栈传递这两个参数下图展示了 main 函数在调用 my_function 前的栈信息 上图中 rbp 寄存器会存储函数调用栈的基址指针即属于 main 函数的栈空间的起始位置而另一个寄存器 rsp 存储的是 main 函数调用栈结束的位置这两个寄存器共同表示了函数的栈空间。
在调用 my_function 之前main 函数通过 subq $16, %rsp 指令分配了 16 个字节的栈地址随后将第六个以上的参数按照从右到左的顺序存入栈中即第八个和第七个余下的六个参数会通过寄存器传递接下来运行的 call my_function 指令会调用 my_function 函数
my_function:pushq %rbpmovq %rsp, %rbpmovl %edi, -4(%rbp) // rbp-4 edi 1movl %esi, -8(%rbp) // rbp-8 esi 2...movl -8(%rbp), %eax // eax 2movl -4(%rbp), %edx // edx 1addl %eax, %edx // edx eax edx 3...movl 16(%rbp), %eax // eax 7addl %eax, %edx // edx eax edx 28movl 24(%rbp), %eax // eax 8addl %edx, %eax // edx eax edx 36popq %rbp
my_function 会先将寄存器中的全部数据转移到栈上然后利用 eax 寄存器计算所有入参的和并返回结果。
总结一下的话就是 六个以及六个以下的参数会按照顺序分别使用 edi、esi、edx、ecx、r8d、r9d 这六个寄存器传递 六个以上的参数会使用栈传递函数的参数会以从右到左的顺序依次存入栈中
而函数的返回值是通过 eax 寄存器进行传递的由于只使用一个寄存器存储返回值所以 C 语言的函数不能同时返回多个值。
Go 语言
同样我们以一个简单的代码片段来分析 Go 语言函数的调用惯例
package main
func myFunction(a, b int) (int, int) {return a b, a - b
}
func main() {myFunction(66, 77)
}
上述的 myFunction 函数接受两个整数并返回两个整数main 函数在调用 myFunction 时将 66 和 77 两个参数传递到当前函数中使用 go tool compile -S -N -l main.go 编译上述代码可以得到如下所示的汇编指令 如果编译时不使用 -N -l 参数编译器会对汇编代码进行优化编译结果会有较大差别。 .main STEXT size68 args0x0 locals0x280x0000 00000 (main.go:7) MOVQ (TLS), CX ; 将TLS线程本地存储中的指针加载到寄存器CX中0x0009 00009 (main.go:7) CMPQ SP, 16(CX) ; 比较栈指针SP和16(CX)中的值0x000d 00013 (main.go:7) JLS 61 ; 如果SP小于等于16(CX)则跳转到偏移地址610x000f 00015 (main.go:7) SUBQ $40, SP ; 为局部变量分配40字节的栈空间0x0013 00019 (main.go:7) MOVQ BP, 32(SP) ; 将基址指针BP存储到32(SP)中0x0018 00024 (main.go:7) LEAQ 32(SP), BP ; 设置BP为32(SP)0x001d 00029 (main.go:8) MOVQ $66, (SP) ; 将值66存储到栈上的位置(SP)0x0025 00037 (main.go:8) MOVQ $77, 8(SP) ; 将值77存储到栈上的位置8(SP)0x002e 00046 (main.go:8) CALL .myFunction(SB) ; 调用函数myFunction
0x0033 00051 (main.go:9) MOVQ 32(SP), BP ; 恢复基址指针BP0x0038 00056 (main.go:9) ADDQ $40, SP ; 恢复栈指针SP0x003c 00060 (main.go:9) RET ; 返回
根据 main 函数生成的汇编指令我们可以分析出 main 函数调用 myFunction 之前的栈 main 函数通过 SUBQ $40, SP 指令一共在栈上分配了 40 字节的内存空间
空间大小作用SP32 ~ BP8 字节main 函数的栈基址指针SP16 ~ SP3216 字节函数 myFunction 的两个返回值SP ~ SP1616 字节函数 myFunction 的两个参数
myFunction 入参的压栈顺序和 C 语言一样也是从右到左即第一个参数 66 在栈顶的 SP ~ SP8第二个参数存储在 SP8 ~ SP16 的空间中。
当我们准备好函数的入参之后会调用汇编指令 CALL .myFunction(SB)这个指令首先会将 main 的返回地址存入栈中然后改变当前的栈指针 SP 并执行 myFunction 的汇编指令
.myFunction STEXT nosplit size49 args0x20 locals0x00x0000 00000 (main.go:3) MOVQ $0, .~r224(SP) // 初始化第一个返回值0x0009 00009 (main.go:3) MOVQ $0, .~r332(SP) // 初始化第二个返回值0x0012 00018 (main.go:4) MOVQ .a8(SP), AX // AX 660x0017 00023 (main.go:4) ADDQ .b16(SP), AX // AX AX 77 1430x001c 00028 (main.go:4) MOVQ AX, .~r224(SP) // (24)SP AX 1430x0021 00033 (main.go:4) MOVQ .a8(SP), AX // AX 660x0026 00038 (main.go:4) SUBQ .b16(SP), AX // AX AX - 77 -110x002b 00043 (main.go:4) MOVQ AX, .~r332(SP) // (32)SP AX -110x0030 00048 (main.go:4) RET
从上述的汇编代码中我们可以看出当前函数在执行时首先会将 main 函数中预留的两个返回值地址置成 int 类型的默认值 0然后根据栈的相对位置获取参数并进行加减操作并将值存回栈中在 myFunction 函数返回之间栈中的数据如下图所示 在 myFunction 返回后main 函数会通过以下的指令来恢复栈基址指针并销毁已经失去作用的 40 字节栈内存 0x0033 00051 (main.go:9) MOVQ 32(SP), BP0x0038 00056 (main.go:9) ADDQ $40, SP0x003c 00060 (main.go:9) RET
通过分析 Go 语言编译后的汇编指令我们发现 Go 语言使用栈传递参数和接收返回值所以它只需要在栈上多分配一些内存就可以返回多个值。
对比
Go 语言和 C 语言在设计函数的调用惯例时选择了不同实现方法。C 语言同时使用寄存器和栈传递参数使用 eax 寄存器传递返回值而 Go 语言使用栈传递参数和返回值。这两种设计的优缺点如下 C 语言的方式能够大幅度减少函数调用时的额外开销但也增加了实现的复杂度 CPU 访问栈的开销比访问寄存器高几十倍 需要单独处理函数参数过多的情况。 Go 语言实现的方式能够降低实现的复杂度并支持多返回值但是牺牲了函数调用的性能 不需要考虑超过寄存器数量的参数应该如何传递 不需要考虑不同架构上寄存器差异 函数入参和出参的内存空间需要在栈上进行分配
Go 语言使用栈作为参数的返回值传递的方法是综合考虑后的设计这样意味着编译器会更加简单、更容易维护
2、参数传递
除了函数的调用惯例之外我们还需要关心的另一个问题就是Go 语言在参数传递时时传值还是传引用不同的方式会影响我们的函数中修改入参时是否会影响我们的原数据。 传值函数调用时会对参数进行拷贝被调用方和调用方两者持有不相关的两份数据 传引用函数调用时会传递参数的指针被调用方和调用方两者持有相同数据任意一方做出修改都会影响到另一方。
在 Go 语言中参数传递的方式是传值也就是说不论是传递基本类型、结构体还是指针都会对传递的参数进行拷贝。
整型和数组
如下示例我们在 myFunction 内和 main 函数内分别打印参数的地址
func myFunction(i int, arr [2]int) {fmt.Printf(in my_funciton - i(%d, %p) arr(%v, %p)\n, i, i, arr, arr)
}func main() {i : 30arr : [2]int{66, 77}fmt.Printf(before calling - i(%d, %p) arr(%v, %p)\n, i, i, arr, arr)myFunction(i, arr)fmt.Printf(after calling - i(%d, %p) arr(%v, %p)\n, i, i, arr, arr)
}$ go run main.go
before calling - i(30, 0xc00009a000) arr([66 77], 0xc00009a010)
in my_funciton - i(30, 0xc00009a008) arr([66 77], 0xc00009a020)
after calling - i(30, 0xc00009a000) arr([66 77], 0xc00009a010)
会发现main 函数和被调用者 myFunction 中参数的地址是完全不同的。
不过从 main 函数的角度来看在调用 myFunction 前后整数 i 和数组 arr 两个参数的地址都没有变化。
然后我们试着在 myFunction 函数中对参数进行修改
func myFunction(i int, arr [2]int) {i 29arr[1] 88fmt.Printf(in my_funciton - i(%d, %p) arr(%v, %p)\n, i, i, arr, arr)
}$ go run main.go
before calling - i(30, 0xc000072008) arr([66 77], 0xc000072010)
in my_funciton - i(29, 0xc000072028) arr([66 88], 0xc000072040)
after calling - i(30, 0xc000072008) arr([66 77], 0xc000072010)
发现 myFunction 中对参数的修改也就仅仅影响了当前函数并没有影响调用方 main 函数中的值。所以Go 语言中对于基本类型和数组都是值传递的即调用函数时会对参数进行拷贝。所以我们在传参的时候如果参数所占空间特别大这张传值的方式会特别影响性能。
结构体和指针
然后再可靠另外两种结构体和指针
type MyStruct struct {i int
}func myFunction(a MyStruct, b *MyStruct) {a.i 31b.i 41fmt.Printf(in my_function - a(%d, %p) b(%v, %p)\n, a, a, b, b)
}func main() {a : MyStruct{i: 30}b : MyStruct{i: 40}fmt.Printf(before calling - a(%d, %p) b(%v, %p)\n, a, a, b, b)myFunction(a, b)fmt.Printf(after calling - a(%d, %p) b(%v, %p)\n, a, a, b, b)
}$ go run main.go
before calling - a({30}, 0xc000018178) b({40}, 0xc00000c028)
in my_function - a({31}, 0xc000018198) b({41}, 0xc00000c038)
after calling - a({30}, 0xc000018178) b({41}, 0xc00000c028)
从结果可以得出结果 传递结构体时会拷贝结构体中的全部内容 传递结构体指针时会拷贝结构体指针
修改结构体指针指向的内容相当于改变了指针指向的结构体所以在函数内部对结构体的修改是可以被 main 函数看到的。
我们简单修改上述代码分析一下 Go 语言结构体在内存中的布局
type MyStruct struct {i intj int
}func myFunction(ms *MyStruct) {ptr : unsafe.Pointer(ms)for i : 0; i 2; i {c : (*int)(unsafe.Pointer((uintptr(ptr) uintptr(8*i))))*c i 1fmt.Printf([%p] %d\n, c, *c)}
}func main() {a : MyStruct{i: 40, j: 50}myFunction(a)fmt.Printf([%p] %v\n, a, a)
}$ go run main.go
[0xc000018180] 41
[0xc000018188] 52
[0xc000018180] {41 52}
从打印的地址可以看出结构体在内存中是一片连续的内存空间指向结构体的指针也就指向结构体的首地址。我们可以通过 通用指针类型unsafe.Pointer 和 指针运算类型uintptr 将普通指针进行转化和计算可以通过偏移指针来访问对应的结构体的元素。
如果我们将上述代码简化成如下所示的代码片段并使用 go tool compile 进行编译会得到如下的结果
type MyStruct struct {i intj int
}func myFunction(ms *MyStruct) *MyStruct {return ms
}$ go tool compile -S -N -l main.go
.myFunction STEXT nosplit size20 args0x10 locals0x00x0000 00000 (main.go:8) MOVQ $0, .~r116(SP) // 初始化返回值0x0009 00009 (main.go:9) MOVQ .ms8(SP), AX // 复制引用0x000e 00014 (main.go:9) MOVQ AX, .~r116(SP) // 返回引用0x0013 00019 (main.go:9) RET
在这段汇编语言中我们发现当参数是指针时也会使用 MOVQ .ms8(SP), AX 指令复制引用然后将复制后的指针作为返回值传递回调用方。 所以指针作为参数传入某个函数时函数内部会复制指针也就是会同时出现两个指针指向原有的内存空间。所以 Go 语言中传指针也是传值。
传值
当我们验证了 Go 语言中大多数常见的数据结构之后其实能够推测出 Go 语言在传递参数时使用了传值的方式接收方收到参数时会对这些参数进行复制了解到这一点之后在传递数组或者内存占用非常大的结构体时我们应该尽量使用指针作为参数类型来避免发生数据拷贝进而影响性能。
3、小结
本文讲述了 Go 语言函数的调用惯例是使用栈传递参数和返回值的在调用函数之前会在栈上为返回值分配合适的内存空间随后将入参从右到左按顺序压栈并拷贝参数返回值会被存储到调用方预先留好的存储空间上。
关于 Go 语言函数调用可以总结以下几点 通过堆栈传递参数入栈的顺序是从右到左而参数的计算是从左到右 函数返回值通过堆栈传递并由调用者预先分配内存空间 调用函数时都是传值接收方会对入参进行复制再计算