《iOS 三问》 -- Block (中) 深入函数栈

上一篇中我们讨论了 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 函数栈

我们先来回顾下程序在内存中的结构:

objc-mm-struct
对内存结构的详细分析,请查看 《这篇》

函数栈,又称 栈区 (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 寄存器

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 入口处开始分析:

  • 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   //bt 命令查看当前栈区情况,其中每个 frame 为一个栈帧,按序号数字越小为栈顶。
* 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

在 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
//------- 补充
//x29 是 FP,栈帧的帧底指针。
//x30 是 LR,存放函数的返回点地址。
// ----------------------------
// 第一部分:开辟栈空间,保护现场
0x100a796fc <+0>: sub sp, sp, #0x20 // SP 指针下移 20 字节,开辟栈空间
0x100a79700 <+4>: stp x29, x30, [sp, #0x10] // 把上个栈帧的帧底与函数返回点入栈 (保护上个栈帧现场)
0x100a79704 <+8>: add x29, sp, #0x10 // 然后把 FP 下移,指向当前栈帧的帧底

// 第二部分:函数逻辑
0x100a79708 <+12>: stur w0, [x29, #-0x4] // 这里在栈中开辟了 4 字节内存,存放函数入参 int a
0x100a7970c <+16>: ldur w0, [x29, #-0x4]
0x100a79710 <+20>: add w0, w0, #0x1
0x100a79714 <+24>: str w0, [sp, #0x8] // 此逻辑为 b = a + 1,sp+8 为局部变量 b 分配空间
0x100a79718 <+28>: ldur w0, [x29, #-0x4]
0x100a7971c <+32>: ldr w1, [sp, #0x8] // 然后读出 a,b 值到 w0,w1 寄存器,可见这两个寄存器是用于传参的!
0x100a79720 <+36>: bl 0x104d956c4 // 这里调用 method2 ,即 c = method2 (a, b)
0x100a79724 <+40>: str w0, [sp, #0x4]
0x100a79728 <+44>: ldr w0, [sp, #0x4]
0x100a7972c <+48>: ldp x29, x30, [sp, #0x10] // 函数返回前把 FP、LR 还原
0x100a79730 <+52>: add sp, sp, #0x20 // 移动栈顶,释放栈帧究竟
0x100a79734 <+56>: ret // 函数返回,返回到 LR 保存的地址继续执行指令
// 即设置 PC=LR

我们继续使用 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 // 了 2 个 int (4 字节内存),存放入参 a, 局部变量 b
fp -> 0x16f388f20: 0x000000016f388f60 // 回溯前面,这就是 FP (onPress)=0x000000016d5a8f70
0x16f388f28: 0x0000000100a79950 //vincent`-[XXController onButtonPress] 函数的返回点,保存的 LR (onPress)
0x16f388f30: 0x000000016f388f80
0x16f388f38: 0x0000000000000000
0x16f388f40: 0x0000000000000000
0x16f388f48: 0x0000000105e04a00
0x16f388f50: 0x00000001c7ffbd47

从对栈帧内存究竟的分析,和反汇编出来的代码是一一对应的:

  1. 保存现场部分:
    我们从 FP 的指向可知,FP+4FP+8 正好对应汇编第一部分中的保存现场部分,将上一个栈帧的 FPLR 入栈!

  2. 局部变量部分:
    从栈区结合反汇编代码可见,入参与局部变量使用 FP + 位移SP + 位移 来取 (看哪个近来提高寻址速度),两者都位于栈帧内存区域中。然后函数的传参是用寄存器 w0,w1… 来传递 (从反汇编代码中看得)。

  3. 函数返回部分:
    函数返回时,先把 FPLR 恢复,再直接把 SP 下移,该栈帧信息就给释放了,可见函数栈操作的效率是比较高的。

  • method2

method2 与 method1 都是非叶子函数 (非叶子函数就是函数内还调用了自定义函数),所以情况是相同的,我们就不分析反汇编了,直接看走进去后的栈帧信息。

分析 method2
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
// 查看栈区状态
> 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

// 这里 method2 的 LR = 0x100a79724,说明什么呢?我们看下 method1 中的反汇编代码:
// > 0x100a79720 <+36>: bl 0x104d956c4 // 调用 method2 ,即 c = method2 (a, b)
// > 0x100a79724 <+40>: str w0, [sp, #0x4]
// 看出来了吧,0x100a79724 就是 bl 调用 method2 之后回到 method1 的下一条语句!

> x/20ag 0x000000016f388ef0
-------- sp --> 0x16f388ef0: 0x0000000281087de0
method2 0x16f388ef8: 0x0000000100000002 // local(method2)
-fp(method2)--> 0x16f388f00: 0x000000016f388f20 // FP(method1)
0x16f388f08: 0x0000000100a79724 // LR(method1)
method1 0x16f388f10: 0x000000016f388f60 // not use
0x16f388f18: 0x0000000100000002 // local(method1)
-fp(method1)--> 0x16f388f20: 0x000000016f388f60 // FP(onPress)
0x16f388f28: 0x0000000100a79950 // LR(onPress)
  • method3

接着让断点走进 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 //v1++ 逻辑
0x100a796b8 <+16>: str w8, [sp, #0xc] // 计算结果存入 v1 分配的空间

// 回收栈究竟,恢复现场
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 // local(method2)
-fp(method2)--> 0x16f388f00: 0x000000016f388f20 // FP(method1)
0x16f388f08: 0x0000000100a79724 // LR(method1)
method1 0x16f388f10: 0x000000016f388f60 // not use
0x16f388f18: 0x0000000100000002 // local(method1)
-fp(method1)--> 0x16f388f20: 0x000000016f388f60 // FP(onPress)
0x16f388f28: 0x0000000100a79950 // LR(onPress)

这里可见,叶子函数没有设 FP,这应该是一个优化,也就是当调用的函数为叶子函数时,不需要使用 FP 栈帧底指针,可以省去 FP 的保存与恢复,提高一些效率。

  • 函数返回 method2

我们在 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 时的栈区情况 2
1
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 // 这里可以看到局部变量 temp 变成了 3,并充当返回值
method2 0x16f388ef8: 0x0000000100000002 // local(method2)
-fp(method2)--> 0x16f388f00: 0x000000016f388f20 // FP(method1)
0x16f388f08: 0x0000000100a79724 // LR(method1)
method1 0x16f388f10: 0x000000016f388f60 // not use
0x16f388f18: 0x0000000100000002 // local(method1)
-fp(method1)--> 0x16f388f20: 0x000000016f388f60 // FP(onPress)
0x16f388f28: 0x0000000100a79950 // LR(onPress)

后面 method1 的返回也是累同,我这里就不多作累叙了。朋友们可以自已实验一下,体会其中乐趣。

1.3 小结

至此,我们知道了函数栈在程序运行过程中是怎样工作的,寄存器与栈区操作配合,完成了函数调用、参数传递及局部变量的存储。我们知道了参数存在什么地方,函数怎样保存入参、怎样保存局部变量。

当然,我的实验为了简单,用的是 int 型基本变量,感兴趣的朋友可以验证下分配对象的话,对象的引用是怎样存储的,就作为本节的小作业吧~

2 小结

本篇为我们深入 Block 源码做好知识储备。

首先我们了解函数栈的实现,其实也是加深了对函数的理解,知道了在程序运行过程中函数的调用过程是怎样的,传参是怎样的,局部变量是怎样存储的。栈的操作思想及数据结构在系统的设计中是非常常见且有用的,其设计逻辑简单、操作效率高效,在很多场景上都有运用,比如我们讲 AutoReleasePool 的设计与实现,也有用到,大家可以细细体会。

对于局部变量的存储我们也有了深刻的认识,在栈上分配的局部变量是直接存放在函数栈中的,准确来说是在栈帧中。结合我们 《OC 的内存管理》 中介绍的,内存分配在堆上的对象。我们后面会介绍分配在栈究竟的 Block,分配在堆内存中的 Block 以及将 Block 从栈上拷贝到堆上,故本文可为后面的叙述打下一个基础。

3 引用

  1. 《iOS-iOS 与 OS X 多线程和内存管理》 我学习本系列的主要参考书,写得非常好,真正的深入浅出。
  2. 《谈 Objective-C block 的实现 - 唐巧》 唐巧一篇早期的文章,写得不错的。
  3. 源码:libclosure-73,objc4-709
  4. 《iOS逆向-从汇编代码理解函数调用栈》 - 傻傻木,分析函数调用栈比较详细的一篇文章