《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逆向-从汇编代码理解函数调用栈》 - 傻傻木,分析函数调用栈比较详细的一篇文章
坚持原创技术分享,您的支持将鼓励我继续创作!