在《Swift汇编分析闭包-内存布局》中介绍了闭包表达式和闭包之间的区别,同时也知道了闭包在内存中的布局方式,那么这篇文章是对其的补充,主要是通过汇编来窥探闭包的调用。
废话也不多说了,我们就直接看代码吧。
/**** 闭包 *********/
typealias Fn = (Int) -> Int
func exec() -> Fn {
var a:Int = 10
func plus(_ i: Int) -> Int {
a += i
return a
}
return plus
}
我们知道,闭包是一个函数以及其捕获的外部变量合称为闭包,那么我们在使用时如下,即获取函数对象并调用。
var fn = exec()
var a = fn(1)
但我们平时只是用了,并没有想过当前的fn
到底是什么。
首先我们来看一下当前fn
所占字节大小,可以得到结果是16个字节
。
从代码上看我们调用exec()
此时返回的是plus
函数,那我们是不是可以猜想这16个字节
中是否存放着函数的地址?
print(MemoryLayout.stride(ofValue: fn)) //16
既然怀疑存放着函数的地址,那么我们干脆直接看看函数的大小。
func sum1(v1: Int, v2: Int) -> Int {
return v1 + v2
}
var fn = sum1
print(MemoryLayout.stride(ofValue: fn)) //16
如上代码所示,我们可以通过该方式获取到函数的大小也是16个字节
。那么我们通过汇编分析一下当前的fn
中的数据。
在print(MemoryLayout.stride(ofValue: fn))
处打上断点,同时开启汇编调试,command + r 运行,此时断点触发
可以看到其实汇编这里已经有了明显的提示了,右侧显示了
fn
,这里movq %rcx, 0x581d(%rip)
是将%rcx
中的数据放入到%rip + 0x581d
(0x1000081C0
)中, movq $0x0, 0x581a(%rip)
则是将0x0
放入到%rip + 0x581a
(0x1000081C8
)中,这两个操作分别都是操作了8个字节
,一起也就是16个字节
。再结合前面我们直接打印的fn
是占了16个字节
,那么说明这里就是往fn
所在的内存写入了数据。
我们再看第
7行
的指令leaq 0x134(%rip), %rcx
,将一个地址放入到了%rcx
中,而在第8行有将%rcx
中的数据写入到到fn
的前8个字节。也就是前8
字节中存放的是0x100002AD0
,我们也可以直接打印fn
可以看到一样,这与汇编中的逻辑相互印证。
(lldb) p fn
() -> () $R0 = 0x0000000100002ad0 SwiftStudy`SwiftStudy.sum1(v1: Swift.Int, v2: Swift.Int) -> Swift.Int at main.swift:12
但是这里有一个疑问,为什么会movq
两次分别写入数据,且第二次写入的还是0
,这是因为movq
指令一次只能移动8个字节
,但是fn
是占用了16个字节
的,那么这里就只能通过两次方式写入数据。第二次写入的0
你可以理解为格式化当前的内存。
上面是单独讲函数拿出来分析,那么回到原点此时分析闭包,但我们现在先不去捕获外部变量,此时看看当前的函数返回了什么内容。
同样断点然后进入到汇编部分。
可以看到断点处
callq
了一个方法,前文也提到过函数的返回值是放在rax
中,那么此时可以看到第9
行将rax
内的数据放入到了一个全局变量的内存中,而第10
行则是将rdx
中的内容放入到了另一块内存中。而且从后面的提示也可以知道是与fn
相关。
我们接着看函数调用,此时
si
进入。
可以看到第
4
行是与内部的plus
函数相关,此时也将一个地址写入到了rax
中,那那么其实可以推测这里写入的应该是plus
的地址(0x100002D60
),再看第6
行将ecx
中的数据写入到了edx
,而edx
是rdx
的一部分那么这里可以理解将ecx
数据写入到了rdx
中而ecx
是前面第5
行异或(异为0,同为1)
得到的数据(0
),那么与前面呼应,我们退回(执行finish
指令)到函数调用之时(上文函数调用图片)。
此时我们也看看
rax
内部存放的数据是否与之前内部调用返回的是否一致。
(lldb) register read rax
rax = 0x0000000100002d60 SwiftStudy`plus #1 (Swift.Int) -> Swift.Int in SwiftStudy.exec() -> (Swift.Int) -> Swift.Int at main.swift:100
也可得知存放函数的地址与数据的地址分别是0x100008158
0x100008160
也是连续的。
这种情况是为捕获外部变量的情况,现在我们来探究看下真正的闭包是怎么处理的。
断点在
return plus
处,进入汇编代码。
首先我们可以看到第9
行处在堆空间
分配了地址,此时的返回数据应该是在rax
处,那么我们在这里大哥断点看一下当前返回的rax
中的内容。
(lldb) register read rax
rax = 0x0000000100443a70
也就是此时堆空间的地址是0x0000000100646980
,也可以看做是fn
的前8个字节中的数据。
前面我们也知道返回值是放在rax
与rdx
中的,那么我们看函数返回之前,也就是第21
行往rdx
中写入了数据,再看第15
行可以得知是将rax
中的数据写入到-0x10(%rbp)
中然后再将-0x10(%rbp)
数据写到rdx
中,那么可以推断rdx
中放的就是堆空间的地址,那么我们在22
行处断点看一下rdx
中的数据。
(lldb) register read rdx
rdx = 0x0000000100443a70
(lldb) register read rax
rax = 0x0000000100002ce0 SwiftStudy`partial apply forwarder for plus #1 (Swift.Int) -> Swift.Int in SwiftStudy.exec() -> (Swift.Int) -> Swift.Int at
同时我们也查看当前rax
中存放的数据,可以看到也是一个地址值,后面的描述是与plus
函数相关。
然后我们在plus
函数内打上断点,进入到函数内部。可以看到当前函数的地址与打印出的函数地址并不一致,所以前面说的是与plus
函数相关,并不直接是plus
函数
上面我们已经弄清楚了fn
中总共16
个字节中的前8
个字节存放的是函数地址,后8
个字节存放的是堆空间的地址。那么在fn(1)
处断点,查看其是怎么调用函数的。
前文也提到过函数调用是通过callq
指令来调用的,那么我们这里调用fn
实际上是调用了内部函数的地址,也就是会从fn
中取其前8
个字节调用,那么说明callq
调用的不会是一个固定的地址而应该是一个动态的地址,比如从rax
中取出来之类的地址,那么我们这里直接找callq
指令且其后面并非固定地址的地方,显而易见可以看到第33
行处callq *%rax
,而rax
中的数据则是从-0x40(%rbp)
来的,再看第24
行-0x40(%rbp)
中的内容是从rax
中而来,而在看第21
行可以得知rax
中的数据是取自于fn
的前8
个字节也就是函数地址。同样我们在看fn
的后8
个字节中的数据的存放是经故宫rcx
后最终落到了r13
中,而r13
一般是作为函数参数的寄存器使用(参照前文)
我们上面说了函数的调用以及堆空间的数据传递,但是我们这里调用fn(1)
还会再传另外一个参数,那么这个参数时如何传递的呢,其实我们再看第30
行,这里会将1
放入到edi(rdi)
寄存器内,也就是传参1
到函数内部。
进入到函数调用内部,可以看到说明是apply for plus 与之前看到的描述一致,且其会通过jmp
指令跳转到真正的plus
函数,同时也可以看到这里会将r13
内的数据放入到rsi
中,其他的寄存器中并没有去做修改。
我们现在plus
函数内部是做了加法计算,那么在做加法计算的时候是如何访问到堆空间的数据的呢。
通过前文分析知道通过获取fn
的前8个字节调用了plus
函数,将后8
个字节通过rsi
寄存器传参,外部参数1
则通过rdi
寄存器传递。
进入到plus
函数内部调用。
rsi -> 堆空间地址值
rdi -> 外部参数(1)
11
行,将rsi
中的数据放入到了-0x50(%rbp)
36
行,将-0x50(%rbp)
放入到了rdx
中
37
行,进行了加操作指令,取出0x10(%rdx)
与%rcx
相加
再看第9
行,将%rdi
中的数据放入到了-0x48(%rbp)
中
23
行,将-0x48(%rbp)
放入到rcx
中
至此到36
行rcx
中的数据未曾改变,也就是这个时候其值就是1
那么到37
行也就是rcx
=rcx
+ 0x10(%rdx) 也就是
plus`函数内部的逻辑。
计算完成后计算得到的新值仍然放在了
rcx
中,此时我们看第44
行会将rcx
中的数据放入到rax
的地址中,那么rax
值时在第42
行由-0x70(%rbp)
而来,而-0x70(%rbp)
则是在第31
行从rdx
获取到的,结合第11
行和第25
行可以得知rdx
中存放的就是堆空间的地址,但是因为其前16
个字节分别存放了"类"和引用计数相关的信息,因此在第26
对其地址做了一个偏移操作,直接指向了数据位,所以最终就是将计算结果存放到了数据位中。
至此整个闭包的调用流程基本清晰了。
首先我们通过查看其内存大小知道闭包函数总共是占用了
16
个字节,其中前8
个字节是“函数地址”,后8
个字节是堆空间地址,然后在调用fn
时实际上是调用了其内部的函数
,而这个函数并非真正的plus
函数而是在其内部间接调用了plus
函数,然后在plus
函数内部则调用addq
完成了加法操作,并将最终的结果直接写入到了堆空间存放数据的地址。