在之前的文章中介绍到了 x86-64 下 C 语言函数的调用过程,最近又看到了一篇文章介绍 Go 语言的函数调用,于是便想了解下在不同语言中函数调用中参数传递的方式有什么不同。

C

通过之前的文章,我们知道在 x86-64 下,少于 6 个的参数是通过寄存器来传递的。一旦需要传递的参数大于 6 个,大于 6 个的部分会存储到栈中。下面看一下 csapp 中的一个例子

1
2
3
4
5
6
7
8
9
void proc(long  a1, long    *a1p,
int a2, int *a2p,
short a3, short *a3p,
char a4, char *a4p) {
*a1p += a1;
*a2p += a2;
*a3p += a3;
*a4p += a4;
}

查看程序的汇编代码如下

1
2
3
4
5
6
7
8
9
proc:
.LFB0:
movq 16(%rsp), %rax ; 将 a4 放入 %rax 中
addq %rdi, (%rsi) ; *a1p += a1
addl %edx, (%rcx) ; *a2p += a2
addw %r8w, (%r9) ; *a3p += a3
movl 8(%rsp), %edx ; 将 a4 存放到 %edx 中
addb %dl, (%rax) ; *a4p += a4
ret

可以看到,在 proc 函数中,分别从 %rdi, %si, %rdx, %rcx, %r8, r9 寄存器中取出了前 6 个参数,而剩余的两个参数都是从栈中取出的。因此可以证明,在 x86-64 下,C 语言通过寄存器传递函数的前 6 个参数,超过 6 个的部分会通过栈来传递。

Java

Java 会将 java 文件翻译为字节码,然后由 Java 虚拟机解释执行。这里我们只关心字节码是如何实现参数传递的。我们看下面这个函数

1
2
3
public int add(int a, int b) {
return a + b;
}

这个函数的字节码如下:

1
2
3
4
0 iload_1
1 iload_2
2 iadd
3 ireturn

iload_<n> 表示从局部变量表中加载第 n 个变量。所以上面字节码的意思就是分别从局部变量表中加载第 1,2 个变量。说明 a,b 分别为局部变量表中的第 1,2 个变量。为什么下标从 1 开始呢?因为在普通的方法中,第 0 个变量是用来存放 this 对象的。如果将函数改为静态函数的话,那么 a,b 分别为局部变量表中的第 0,1 个变量。
上面的例子貌似不能很好地说明 Java 是通过栈来传递参数的,因为有可能是在某处将参数从寄存器中放到了局部变量表中。所以我们接着来看看 main 函数的字节码。

1
2
3
4
5
6
7
8
9
10
 0 new #2 <TestArg>
3 dup
4 invokespecial #3 <TestArg.<init> : ()V>
7 astore_1
8 aload_1
9 bipush 123
11 sipush 456
14 invokevirtual #4 <TestArg.add : (II)I>
17 pop
18 return

bipush 表示将一个字节推入栈中,这说明了在 Java 中,是通过栈来传递参数的。

Golang

之前看过一篇文章,是基于 go1.15 的,我直接把例子抄过来。

1
2
3
4
5
6
7
8
9
package main

func myFunction(a, b int) (int, int) {
return a + b, a - b
}

func main() {
myFunction(66, 77)
}

通过 go tool compile -S -N -l main.go 编译得到下面的汇编指令

1
2
3
4
5
6
7
8
9
10
11
12
13
"".main STEXT size=68 args=0x0 locals=0x28
0x0000 00000 (main.go:7) MOVQ (TLS), CX
0x0009 00009 (main.go:7) CMPQ SP, 16(CX)
0x000d 00013 (main.go:7) JLS 61
0x000f 00015 (main.go:7) SUBQ $40, SP // 分配 40 字节栈空间
0x0013 00019 (main.go:7) MOVQ BP, 32(SP) // 将基址指针存储到栈上
0x0018 00024 (main.go:7) LEAQ 32(SP), BP
0x001d 00029 (main.go:8) MOVQ $66, (SP) // 第一个参数
0x0025 00037 (main.go:8) MOVQ $77, 8(SP) // 第二个参数
0x002e 00046 (main.go:8) CALL "".myFunction(SB)
0x0033 00051 (main.go:9) MOVQ 32(SP), BP
0x0038 00056 (main.go:9) ADDQ $40, SP
0x003c 00060 (main.go:9) RET

根据汇编指令可以分析出 Go 1.15 是通过栈来传递参数的。
但是当我在我的机器上实验是,结果却是通过寄存器来传递参数的。在网上搜索了一下发现,Go 1.17 之后的版本才是通过寄存器来传递参数的,而在这个版本之前都是通过栈来传递参数的。下面我们来看一下 Go 1.17。6 是怎么传递参数的。
既然我们已经知道 1.17 之后使用寄存器传递参数了,那么使用的寄存器肯定不可能没有限制,为了知道这个限制,我们必须改写一下例子。

1
2
3
4
5
6
7
8
9
10
11
package main

func myFunction(a, b, c, d, e, f, g, h, i, j int) (int, int) {
a = a + b + c + d + e + f + g + h + i + j
b = a - b - c - d - e - f - g - h - i - j
return a, b
}

func main() {
myFunction(11, 22, 33, 44, 55, 66, 77, 88, 99, 100)
}

查看上述程序的汇编指令如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
"".main STEXT size=103 args=0x0 locals=0x58 funcid=0x0
0x0000 00000 (main.go:9) TEXT "".main(SB), ABIInternal, $88-0
0x0000 00000 (main.go:9) CMPQ SP, 16(R14)
0x0004 00004 (main.go:9) JLS 92
0x0006 00006 (main.go:9) SUBQ $88, SP // 栈指针减去 88,即分配 88 字节栈空间
0x000a 00010 (main.go:9) MOVQ BP, 80(SP) // 将基址指针存储到栈上
0x000f 00015 (main.go:9) LEAQ 80(SP), BP
0x0014 00020 (main.go:10) MOVQ $100, (SP) // 将第 10 个参数 存储到栈上
0x001c 00028 (main.go:10) MOVL $11, AX // 将第一个参数存储到 AX 寄存器 中
0x0021 00033 (main.go:10) MOVL $22, BX // 将第二个参数存储到 BX 寄存器中
0x0026 00038 (main.go:10) MOVL $33, CX // 将第三个参数存储到 CX 寄存器中
0x002b 00043 (main.go:10) MOVL $44, DI // 将第四个参数存储到 DI 寄存器中
0x0030 00048 (main.go:10) MOVL $55, SI // 将第五个参数存储到 SI 寄存器中
0x0035 00053 (main.go:10) MOVL $66, R8 // 将第六个参数存储到 R8 寄存器中
0x003b 00059 (main.go:10) MOVL $77, R9 // 将第七个参数存储到 R9 寄存器中
0x0041 00065 (main.go:10) MOVL $88, R10 // 将第八个参数存储到 R10 寄存器中
0x0047 00071 (main.go:10) MOVL $99, R11 // 将第九个参数存储到 R11 寄存器中
0x004d 00077 (main.go:10) PCDATA $1, $0
0x004d 00077 (main.go:10) CALL "".myFunction(SB)
0x0052 00082 (main.go:11) MOVQ 80(SP), BP
0x0057 00087 (main.go:11) ADDQ $88, SP
0x005b 00091 (main.go:11) RET

可以看到,函数会将参数分别放到 AX, BX, CX, DI, SI, R8, R9, R10, R11 中。但是这里为什么会将栈指针减去 88 呢?难道这里的寄存器不是真的寄存器吗。我们继续查看汇编代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
0000000000455440 <main.main>:
455440: 49 3b 66 10 cmp 0x10(%r14),%rsp
455444: 76 56 jbe 45549c <main.main+0x5c>
455446: 48 83 ec 58 sub $0x58,%rsp
45544a: 48 89 6c 24 50 mov %rbp,0x50(%rsp)
45544f: 48 8d 6c 24 50 lea 0x50(%rsp),%rbp
455454: 48 c7 04 24 64 00 00 movq $0x64,(%rsp)
45545b: 00
45545c: b8 0b 00 00 00 mov $0xb,%eax
455461: bb 16 00 00 00 mov $0x16,%ebx
455466: b9 21 00 00 00 mov $0x21,%ecx
45546b: bf 2c 00 00 00 mov $0x2c,%edi
455470: be 37 00 00 00 mov $0x37,%esi
455475: 41 b8 42 00 00 00 mov $0x42,%r8d
45547b: 41 b9 4d 00 00 00 mov $0x4d,%r9d
455481: 41 ba 58 00 00 00 mov $0x58,%r10d
455487: 41 bb 63 00 00 00 mov $0x63,%r11d
45548d: e8 4e ff ff ff callq 4553e0 <main.myFunction>
455492: 48 8b 6c 24 50 mov 0x50(%rsp),%rbp
455497: 48 83 c4 58 add $0x58,%rsp
45549b: c3 retq
45549c: 0f 1f 40 00 nopl 0x0(%rax)
4554a0: e8 bb cd ff ff callq 452260 <runtime.morestack_noctxt.abi0>
4554a5: eb 99 jmp 455440 <main.main>

通过汇编代码,还是不明白为什么 rsp 要减去 88。通过测试,每增加一个参数,都会预留一个同样大小的栈空间。我的理解是 Go 可以能担心变量可能还会被用到,所以为其预留了空间。如果我理解错了,麻烦纠正我一下。(当然后续版本可能会完善寄存器机制)
但是经过对返回值个数的测试,发现如果返回值个数小于 9 个,会直接放在寄存器中,不会在栈中预留空间。如果超过 9 个,超过 9 个的部分会存储在栈中。

参考资料

Go 语言设计与实现