这篇文章以实践的方式验证go语言函数之间是如何传递数组类型变量的。
和slice相比,go对于array传参是传递整个array内容的,而不是引用,即把原array内容做一个拷贝,然后把拷贝后的内容值作为参数给被调用者使用。
以如下go语言程序为例子:
package main
const SIZE = 16
func main() {
var ss [SIZE]int64
ss[0] = 0x1111
ss[SIZE-1] = 0x2222
useArray(ss)
}
func useArray(ss [SIZE]int64) {
ss[0] = 0x3333
ss[SIZE-1] = 0x4444
}
逐段分析生成的哦汇编代码,先看数组是如何定义的:
var ss [SIZE]int64
44d682: 48 8d bc 24 80 00 00 lea 0x80(%rsp),%rdi
44d689: 00
44d68a: 0f 57 c0 xorps %xmm0,%xmm0
44d68d: 48 89 6c 24 f0 mov %rbp,-0x10(%rsp)
44d692: 48 8d 6c 24 f0 lea -0x10(%rsp),%rbp
44d697: e8 ee ab ff ff callq 44828a <runtime.duffzero+0x10a>
44d69c: 48 8b 6d 00 mov 0x0(%rbp),%rbp
在main函数里面变量ss被分配在栈中,位置是用%rsp+0x80开始的 16*8字节,所以
&ss[0] = %rsp+0x80
&ss[1] = %rsp+0x88
...
&ss[15] = %rsp+0xf8
因为我们看两条赋值语句的代码:
ss[0]对应的是%rsp + 0x80
ss[15]对应的是%rsp + 0xf8
ss[0] = 0x1111
44d6a0: 48 c7 84 24 80 00 00 movq $0x1111,0x80(%rsp)
44d6a7: 00 11 11 00 00
ss[SIZE-1] = 0x2222
44d6ac: 48 c7 84 24 f8 00 00 movq $0x2222,0xf8(%rsp)
44d6b3: 00 22 22 00 00
同时我们也能看到数组被定义的同时,也进行了初始化操作,调用runtime.duffzero函数,duffzero函数接收参数%rdi,把从%rdi地址开始的内存空间(在这个例子中就是数组ss的首地址)填满%xmm0的值,这个函数的设计很巧妙,后面我们介绍它。
再看函数useArray的调用语句
useArray(ss)
44d6b8: 48 89 e7 mov %rsp,%rdi
44d6bb: 48 8d b4 24 80 00 00 lea 0x80(%rsp),%rsi
44d6c2: 00
44d6c3: 48 89 6c 24 f0 mov %rbp,-0x10(%rsp)
44d6c8: 48 8d 6c 24 f0 lea -0x10(%rsp),%rbp
44d6cd: e8 fe ae ff ff callq 4485d0 <runtime.duffcopy+0x310>
44d6d2: 48 8b 6d 00 mov 0x0(%rbp),%rbp
44d6d6: e8 25 00 00 00 callq 44d700 <main.useArray>
从这段代码可以看到,main函数把ss的内容做了一个完整拷贝,函数runtime.duffcopy用来拷贝内存从%rsi(即ss的首地址)拷贝到%rdi(即当前栈顶),细心的读者会发现这个函数和前面的runtime.duffzero函数一样有一个问题,即没有指定内存的大小,不知道该拷贝填充多大的内存,虽然指定了内存地址的开始地址,但是没有指定结束地址。这其实就是这两个函数设计的巧妙之处,后面我们再介绍。
接着再看函数useArray的实现语句
func useArray(ss [SIZE]int64) {
ss[0] = 0x3333
44d700: 48 c7 44 24 08 33 33 movq $0x3333,0x8(%rsp)
44d707: 00 00
ss[SIZE-1] = 0x4444
44d709: 48 c7 84 24 80 00 00 movq $0x4444,0x80(%rsp)
44d710: 00 44 44 00 00
}
在函数useArray内部参数ss占用的空间是%rsp+0x8到%rsp+0x80的范围,注意此%rsp和main函数的%rsp相差一个8字节位置,因为main函数调用到useArray函数时,%rsp指针发生了移动(保存了%rsp)。
我们看到在调用useArray前后栈的变化
in main values in useArray
------------------+--------------------------|-------------------------
%rsp + 0xf8 --> | ss[15] |
%rsp + 0xf0 --> | ss[14] |
... | |
%rsp + 0x88 --> | ss[1] |
%rsp + 0x80 --> | ss[0] |
%rsp + 0x78 --> | reserved.param.ss[15] | <-- %rsp + 0x80
%rsp + 0x70 --> | reserved.param.ss[14] | <-- %rsp + 0x78
... | |
%rsp + 0x8 --> | reserved.param.ss[1] | <-- %rsp + 0x10
%rsp + 0x0 --> | reserved.param.ss[0] | <-- %rsp + 0x8
| (useArray return addr) | <-- %rsp + 0x0
最后我们来看下runtime.duffzero和runtime.duffcopy
这两个系统函数由go运行环境提供,功能类似于memset和memory,它是由汇编语言直接生成;代码如下
0000000000448180 <runtime.duffzero>:
448180: 0f 11 07 movups %xmm0,(%rdi)
448183: 0f 11 47 10 movups %xmm0,0x10(%rdi)
448187: 0f 11 47 20 movups %xmm0,0x20(%rdi)
44818b: 0f 11 47 30 movups %xmm0,0x30(%rdi)
44818f: 48 83 c7 40 add $0x40,%rdi
448193: 0f 11 07 movups %xmm0,(%rdi)
448196: 0f 11 47 10 movups %xmm0,0x10(%rdi)
44819a: 0f 11 47 20 movups %xmm0,0x20(%rdi)
44819e: 0f 11 47 30 movups %xmm0,0x30(%rdi)
4481a2: 48 83 c7 40 add $0x40,%rdi
...
4482b0: c3 retq
00000000004482c0 <runtime.duffcopy>:
4482c0: 0f 10 06 movups (%rsi),%xmm0
4482c3: 48 83 c6 10 add $0x10,%rsi
4482c7: 0f 11 07 movups %xmm0,(%rdi)
4482ca: 48 83 c7 10 add $0x10,%rdi
4482ce: 0f 10 06 movups (%rsi),%xmm0
4482d1: 48 83 c6 10 add $0x10,%rsi
4482d5: 0f 11 07 movups %xmm0,(%rdi)
4482d8: 48 83 c7 10 add $0x10,%rdi
...
448640: c3 retq
可以看到这两个函数都非常整齐,就是由4条/5条指令组的重复,删除了所有的函数entry/exit的标准模板代码,然后在最后有一个RET指令。
每一条指令组使用%xmm0寄存器来一次拷贝/赋值16字节的内容,每一组由4条指令来拷贝/赋值64字节内容。
前面提到的runtime.duffzero和runtime.duffcopy函数调用缺少参数指定内存大小的问题怎么解决吗?在这里不由callee不负责,而是由caller负责,caller根据要操作内存的大小来决定call到这两个函数的具体哪一条指令,对比通常的函数调用都是跳转到函数的入口地址,而对这个函数是跳转到函数内部的某一条指令,直到运行到RET指令为止,这期间运行了多少条拷贝赋值指令,就是操作了多大内存空间。
44d697: e8 ee ab ff ff callq 44828a <runtime.duffzero+0x10a>
...
44d6cd: e8 fe ae ff ff callq 4485d0 <runtime.duffcopy+0x310>
看前面的代码,通常的函数调用都是
callq 0x...... <functionmame>
也就是直接调用函数的入口指令,而这两个函数都是带一个偏移量的,这也就只有像runtime.duffzero和runtime.duffcopy这种内部逻辑简单的函数可以这么调用。这实际上编译器处理了大量的工作。