上一篇中我们讨论了 Block 的来源,它是 OC 中对闭包的实现。Block 的设计初衷及其特性,主要 3 个特性:
1.可嵌套定义,可在函数及 Block 内定义 Block;
2.Block 内可访问外层变量,这里我们上篇介绍了有 截取变量
及 __block 变量
。
3.方法传递,包括了后面会介绍的 Block 的堆拷贝。
那根据我们的 “三问精神”,这篇开始我们就来深究一下 Block 本身及这些特性是怎样设计并实现的。
这一篇中,我们将为深入 Block 源码做好各种准备,它将为我们后续对 Block 的研究或是学习其它技术提供知识储备与帮助。
1 深入函数栈
我们说 Block 特性的时候,说过Block 内可访问其外层的变量,这个外层的变量,主要有外层函数的局部变量,当然也有在外层定义的分配在堆上的对象。堆对象我们在介绍 《面向对象》 和 《OC 的内存管理》 时介绍过了,这里我们补充下函数中的局部变量。
局部变量
,顾名思义,只在局部起作用(一般就是函数的作用域),它其实存放在 函数栈
当中。
1.1 函数栈 stack 与栈帧 frame
1.1.1 函数栈
我们先来回顾下程序在内存中的结构:
对内存结构的详细分析,请查看 《这篇》
函数栈
,又称 栈区 (stack)
,在内存中从高地址往低分配,与堆区域相对。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 高地址┌──────────────┐ │ │ │ 栈区 │ ├──────────┼───┤ │ │ │ │ ▼ │ │ │ │ │ │ ▲ │ │ │ │ ├──────────┼───┤ │ 堆区 │ │ │ 低地址└──────────────┘
|
1.1.2 栈帧
我们知道函数调用是发生在栈上的,每个函数相关的信息 (局部变量、调用记录等) 都存储在一个栈帧中。每执行一次函数调用,就会成生一个其相关的栈帧,然后将之压入函数栈。而当函数执行结束,则将此函数对应的栈帧出栈并释放掉。我们使用的在函数中定义的局部变量就放在栈帧中,每次函数调用完随栈帧一起释放掉,这样就保证了函数调用的快速高效。
比如下面这个程序示例:
栈帧程序示例1 2 3 4 5 6 7 8 9 10 11 12 13
| #include <stdio.h>
int Add(int x,int y) { int z = 0; z = x + y; return z; }
int main() { int a = 10; int b = 20; int ret = Add(a, b); }
|
程序执行时栈区中栈帧变化:
1 2 3 4 5 6
| 程序执行调用 main main 中调用 add 从 add 函数中返回 从 main 中返回 ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ frame(main)│ │ frame(main) │ │ frame(main) │ │ │ └─────────────┘ ├─────────────┤ ├─────────────┤ ├─────────────┤ │ frame(add) │ │ │ │ │ └─────────────┘ └─────────────┘ └─────────────┘
|
1.2 深入栈帧
接下来,我们深入探索一下栈帧的变化。我们先简单回顾下 ARM 中的寄存器:
1.2.1 寄存器基础
因为现在 iPhone cpu 是都是使用 ARM64 位架构,所以这里就简单回顾下 ARM64 中的寄存器架构。
ARM64 有 34 个寄存器,包括 31 个通用寄存器、SP、PC、CPSR。
寄存器 |
描述 |
x0-x30 |
通用寄存器,如果有需要可以当做 32bit 使用 |
FP (x29) |
保存栈帧地址 (栈底指针) |
LR (x30) |
程序链接寄存器,保存子程序结束后需要执行的下一条指令 |
SP |
栈指针,使用 SP/WSP 来进行对 SP 寄存器的访问 |
PC |
程序计数器,总是指向即将要执行的下一条指令,软件自身是不能直接改写 PC 寄存器的 |
CPSR |
状态寄存器 |
x0-x7: 用于子程序调用时的参数传递,X0 还用于返回值传递
x0-x30 是 31 个通用整形寄存器。每个寄存器可以存取一个 64 位大小的数。 当使用 r0 - r30 访问时,它就是一个 64 位的数。当使用 w0 - w30 访问时,访问的是这些寄存器的低 32 位.
其中我们关注的主要就是几个特殊的寄存器而已。
- 和程序执行有关的 2 个:
-PC程序计数器,指向下一条要执行的指令;
-LR程序连接寄存器,指向函数执行结束的要返回的指令;
- 和栈帧有关的 2 个:
-SP栈顶指针,指向栈区的栈顶;
-FP栈帧指针,指向当前栈帧的栈底。
画了一张图方便理解:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| (栈区) (寄存器) (程序代码区) 高地址┌─────────────┐ ┌────────────┐ │frame (主函数)│ │ │ | │ │ ┌──────┐◀─┐ │ │ │ ├─────────────┤◀─────FP │ │ │ │ │ ▼ │ │ │ 子函数│ │ │ 主函数 │ │frame (子函数)│ ┌── SP │ │ └───│(子函数调用点)│ │ │ │ │ │ ┌──▶│(子函数返回点)│ ├─────────────┤◀─┘ PC ───▶│ │ | │ │ │ │ └──────┘──┤ │ │ │ │ LR ──────────────┘ │ │ │ │ │ │ │ │ │ │ │ │ │ │ 低地址└─────────────┘ └────────────┘
|
1.2.2 要用到的 LLDB 基础
接下来,我们进入实战,用一个简单的函数调用例子来看看真实场景下栈帧是怎样工作的。作为准备工作,我们先看看要使用的 LLDB 中的几个常用的栈帧调试命令:
先罗列下等会我们要用到的调试命令
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| bt 查看当前程序的栈区,会将所有栈帧及相应的函数名列出; --- register read 输入程序当前寄存器状态; --- dis # 反汇编当前运行位置 disassemble -a 地址 # 指定内存地址的反汇编 dis -a & 方法名 # 反汇编指定方法 --- x # 内存查看命令 x/<n/f/u> <addr>
n、f、u 是可选的参数。 - n 是一个正整数,表示需要显示的内存单元的个数。 - f 表示显示的格式,参见下面。 - u 表示从当前地址往后请求的字节数 - <addr> 表示一个内存地址
- f 参数: x 按十六进制格式显示变量。 d 按十进制格式显示变量。 u 按十六进制格式显示无符号整型。 o 按八进制格式显示变量。 t 按二进制格式显示变量。 a 按十六进制格式显示变量。 c 按字符格式显示变量。 f 按浮点数格式显示变量。
- u 参数: u 参数可以用下面的字符来代替,b 表示单字节,h 表示双字节,w 表示四字 节,g 表示八字节
// 示例 x/3xh 0x54320 // 表示,从内存地址 0x54320 读取内容,h 表示以双字节为一个单位,3 表示输出三个单位,u 表示按十六进制显示。
|
1.2.3 深入栈帧实战
我们实验用的示例程序如下,调用顺序为:method1 -> method2 -> method3
:
示例程序 (在键头处打上断点)1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| int method3(int v1) { >> return v1++; }
int method2(int x, int y) { >> int temp = method3(x) + y; >> return temp; }
int method1(int a) { int b = a + 1; >> int c = method2(a, b); >> return c; }
>> method1(1);
|
此程序的调用栈帧应该是这样:
1 2 3 4 5 6 7 8 9 10 11
| ┌─────────┐ │ method3 │ ├─────────┤ │ method2 │ ├─────────┤ │ method1 │ ├─────────┤ │ onPress │ ├─────────┤ │ ... │ └─────────┘
|
我们下面从 method1 入口处开始分析:
我们在 method (1)
调用处断点,输入 dis
查看:
查看 method1 入口汇编1
| 0x100a7994c <+80>: bl 0x100a796fc ; method1 at XXController.m:66
|
bl
是汇编的子程序跳转指令,它会将把下一条指令的地址存储到 LR 寄存器中 (程序返回点),并跳转到给定的地址。因此,0x100a796fc
就是 method1
方法的入口地址 (后面看汇编代码可以看出)。
查看当前栈区与寄存器情况1 2 3 4 5 6 7 8 9 10
| > bt * thread #1, name = 'com.airone.xxxx.vincent', queue = 'com.apple.main-thread', stop reason = breakpoint 1.1 frame #0: 0x0000000100a79948 vincent `-[XXController onButtonPress].. ...
> register read - fp = 0x000000016f388f60 - lr = 0x0000000100a79948 vincent`-[XXController onButtonPress] + 84 at - XXController.m:176:1 - sp = 0x000000016f388f30 - pc = 0x0000000100a79948 vincent`-[XXController onButtonPress] + 84 at XXController.m:176:1
|
记下寄存器信息,接着点 “继续运行”,断点进入 method1 内部。
在 method1 内部断点,我们先来查看下其汇编源码:
disassemble 命令输出反汇编源码1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
0x100a796fc <+0>: sub sp, sp, #0x20 0x100a79700 <+4>: stp x29, x30, [sp, #0x10] 0x100a79704 <+8>: add x29, sp, #0x10
0x100a79708 <+12>: stur w0, [x29, #-0x4] 0x100a7970c <+16>: ldur w0, [x29, #-0x4] 0x100a79710 <+20>: add w0, w0, #0x1 0x100a79714 <+24>: str w0, [sp, #0x8] 0x100a79718 <+28>: ldur w0, [x29, #-0x4] 0x100a7971c <+32>: ldr w1, [sp, #0x8] 0x100a79720 <+36>: bl 0x104d956c4 0x100a79724 <+40>: str w0, [sp, #0x4] 0x100a79728 <+44>: ldr w0, [sp, #0x4] 0x100a7972c <+48>: ldp x29, x30, [sp, #0x10] 0x100a79730 <+52>: add sp, sp, #0x20 0x100a79734 <+56>: ret
|
我们继续使用 LLDB 来看看实际情况是否如我们分析的一样。
我们先查看下当前栈区:
进入 method1 后栈区信息1 2 3 4 5
| > bt * thread #1, name = 'com.airone.xxxx.vincent', queue = 'com.apple.main-thread', stop reason = breakpoint 1.1 frame #0: 0x0000000100a79718 vincent`method1(a=1) at XXController.m:68:21 frame #0: 0x0000000100a79950 vincent `-[XXController onButtonPress]... ... 省去无关栈帧
|
我们看到,执行进入 method1
之后,frame0 变成了 method1
。我们再看看寄存器状态:
进入 method1 后寄存器信息1 2 3 4 5 6
| > register read ------- fp = 0x000000016f388f20 lr = 0x0000000100a79950 vincent`-[XXController onButtonPress] + 84 at XXController.m:176:1 sp = 0x000000016f388f10 pc = 0x0000000100a79718 vincent`method1 + 28 at XXController.m:68:21
|
我们看到,LR 变成了 method1 的入口 -[XXController onButtonPress]
方法,也就是说 method1 执行完后将返回 -[XXController onButtonPress]
方法,这是符合我们所想的。
我们再根据 sp = 0x000000016f388f10
,查看下内存中栈区的信息(因为栈是从高地址到低地址的,所以我们从栈顶打印就可以看到想要的栈帧信息):
执行到 method1 时栈区的内存信息1 2 3 4 5 6 7 8 9 10 11
| > x/20ag 0x000000016f388f10
sp -> 0x16f388f10: 0x000000016f388f60 0x16f388f18: 0x0000000100000002 fp -> 0x16f388f20: 0x000000016f388f60 0x16f388f28: 0x0000000100a79950 0x16f388f30: 0x000000016f388f80 0x16f388f38: 0x0000000000000000 0x16f388f40: 0x0000000000000000 0x16f388f48: 0x0000000105e04a00 0x16f388f50: 0x00000001c7ffbd47
|
从对栈帧内存究竟的分析,和反汇编出来的代码是一一对应的:
保存现场部分:
我们从 FP
的指向可知,FP+4
和 FP+8
正好对应汇编第一部分中的保存现场部分,将上一个栈帧的 FP
与 LR
入栈!
局部变量部分:
从栈区结合反汇编代码可见,入参与局部变量使用 FP + 位移
或 SP + 位移
来取 (看哪个近来提高寻址速度),两者都位于栈帧内存区域中。然后函数的传参是用寄存器 w0,w1… 来传递 (从反汇编代码中看得)。
函数返回部分:
函数返回时,先把 FP
、LR
恢复,再直接把 SP 下移,该栈帧信息就给释放了,可见函数栈操作的效率是比较高的。
method2 与 method1 都是非叶子函数 (非叶子函数就是函数内还调用了自定义函数),所以情况是相同的,我们就不分析反汇编了,直接看走进去后的栈帧信息。
分析 method21 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| > bt -------- * thread #1, name = 'com.airone.xxxx.vincent', queue = 'com.apple.main-thread', stop reason = breakpoint 3.1 * frame #0: 0x0000000100a796d8 vincent`method2(x=1, y=2) at XXController.m:61:24 frame #1: 0x0000000100a79724 vincent`method1(a=1) at XXController.m:68:13 frame #2: 0x0000000100a79950 vincent`[XXController onButtonPress] + 84 at XXController.m:176:1
> register read -------- fp = 0x000000016f388f00 lr = 0x0000000100a79724 sp = 0x000000016f388ef0 pc = 0x0000000100a796d8
> x/20ag 0x000000016f388ef0 -------- sp --> 0x16f388ef0: 0x0000000281087de0 method2 0x16f388ef8: 0x0000000100000002 -fp(method2)--> 0x16f388f00: 0x000000016f388f20 0x16f388f08: 0x0000000100a79724 method1 0x16f388f10: 0x000000016f388f60 0x16f388f18: 0x0000000100000002 -fp(method1)--> 0x16f388f20: 0x000000016f388f60 0x16f388f28: 0x0000000100a79950
|
接着让断点走进 method3
:
method3 反汇编1 2 3 4 5 6 7 8 9 10 11 12
| 0x100a796a8 <+0>: sub sp, sp, #0x10
0x100a796ac <+4>: str w0, [sp, #0xc] 0x100a796b0 <+8>: ldr w0, [sp, #0xc] 0x100a796b4 <+12>: add w8, w0, #0x1 0x100a796b8 <+16>: str w8, [sp, #0xc]
0x100a796bc <+20>: add sp, sp, #0x10 0x100a796c0 <+24>: ret
|
再使用 LLDB 查看一下内存与寄存器信息:
使用 LLDB 查看一下栈区信息1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| > bt * thread #1, name = 'com.airone.xxxx.vincent', queue = 'com.apple.main-thread', stop reason = breakpoint 4.1 * frame #0: 0x0000000100a796b0 vincent`method3(v1=1) at AppOnLaunchService.m:56:14 frame #1: 0x0000000100a796e0 vincent`method2(x=1, y=2) at AppOnLaunchService.m:61:16 frame #2: 0x0000000100a79724 vincent`method1(a=1) at AppOnLaunchService.m:68:13 frame #3: 0x0000000100a79950 vincent`[XXController onButtonPress] + 84 at XXController.m:176:1
> register read fp = 0x000000016f388f00 lr = 0x0000000100a796e0 vincent`method2 + 28 at AppOnLaunchService.m:61:29 sp = 0x000000016f388ee0 pc = 0x0000000100a796b0 vincent`method3 + 8 at AppOnLaunchService.m:56:14
> x/20ag 0x000000016f388ee0 -------- sp --> 0x16f388ee0: 0x000000016f388f00 method3 0x16f388ee8: 0x000000019b21eca0 0x16f388ef0: 0x0000000281087de0 method2 0x16f388ef8: 0x0000000100000002 -fp(method2)--> 0x16f388f00: 0x000000016f388f20 0x16f388f08: 0x0000000100a79724 method1 0x16f388f10: 0x000000016f388f60 0x16f388f18: 0x0000000100000002 -fp(method1)--> 0x16f388f20: 0x000000016f388f60 0x16f388f28: 0x0000000100a79950
|
这里可见,叶子函数没有设 FP
,这应该是一个优化,也就是当调用的函数为叶子函数时,不需要使用 FP 栈帧底指针,可以省去 FP 的保存与恢复,提高一些效率。
我们在 method2 的 return 处也打了一个断点,看看返回到 method2 时的栈区情况:
返回到 method2 时的栈区情况1 2 3 4
| > bt * thread #1, name = 'com.airone.xxxx.vincent', queue = 'com.apple.main-thread', stop reason = breakpoint 6.1 * frame #0: 0x0000000100a796ec vincent`method2(x=1, y=2) at AppOnLaunchService.m:62:12 frame #1: 0x0000000100a79724 vincent`method1(a=1) at AppOnLaunchService.m:68:13
|
由此可见,method3 的栈帧已经回收了
再看看寄存器和栈区信息
返回到 method2 时的栈区情况 21 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| > register read fp = 0x000000016f388f00 lr = 0x0000000100a796e0 vincent`method2 + 28 at AppOnLaunchService.m:61:29 sp = 0x000000016f388ef0 pc = 0x0000000100a796ec vincent`method2 + 40 at AppOnLaunchService.m:62:12
> x/20ag 0x000000016f388ef0 -------- sp --> 0x16f388ef0: 0x0000000381087de0 method2 0x16f388ef8: 0x0000000100000002 -fp(method2)--> 0x16f388f00: 0x000000016f388f20 0x16f388f08: 0x0000000100a79724 method1 0x16f388f10: 0x000000016f388f60 0x16f388f18: 0x0000000100000002 -fp(method1)--> 0x16f388f20: 0x000000016f388f60 0x16f388f28: 0x0000000100a79950
|
后面 method1 的返回也是累同,我这里就不多作累叙了。朋友们可以自已实验一下,体会其中乐趣。
1.3 小结
至此,我们知道了函数栈在程序运行过程中是怎样工作的,寄存器与栈区操作配合,完成了函数调用、参数传递及局部变量的存储。我们知道了参数存在什么地方,函数怎样保存入参、怎样保存局部变量。
当然,我的实验为了简单,用的是 int 型基本变量,感兴趣的朋友可以验证下分配对象的话,对象的引用是怎样存储的,就作为本节的小作业吧~
2 小结
本篇为我们深入 Block 源码做好知识储备。
首先我们了解函数栈的实现,其实也是加深了对函数的理解,知道了在程序运行过程中函数的调用过程是怎样的,传参是怎样的,局部变量是怎样存储的。栈的操作思想及数据结构在系统的设计中是非常常见且有用的,其设计逻辑简单、操作效率高效,在很多场景上都有运用,比如我们讲 AutoReleasePool 的设计与实现,也有用到,大家可以细细体会。
对于局部变量的存储我们也有了深刻的认识,在栈上分配的局部变量是直接存放在函数栈中的,准确来说是在栈帧中。结合我们 《OC 的内存管理》 中介绍的,内存分配在堆上的对象。我们后面会介绍分配在栈究竟的 Block,分配在堆内存中的 Block 以及将 Block 从栈上拷贝到堆上,故本文可为后面的叙述打下一个基础。
3 引用
- 《iOS-iOS 与 OS X 多线程和内存管理》 我学习本系列的主要参考书,写得非常好,真正的深入浅出。
- 《谈 Objective-C block 的实现 - 唐巧》 唐巧一篇早期的文章,写得不错的。
- 源码:libclosure-73,objc4-709
- 《iOS逆向-从汇编代码理解函数调用栈》 - 傻傻木,分析函数调用栈比较详细的一篇文章