读懂 iOS 的线程调用栈

0 阅读11分钟

开头

经常崩溃的朋友对调用栈都不陌生,比如,下面是在一个空的 Swift 项目中的 ViewControoler 写入一个崩溃:

override func viewDidLoad() {
	super.viewDidLoad()
	
	let arrayA = [1...10]
	print(arrayA[10])
}

执行到这里时,项目会崩溃,我们会得到这样的调用栈:

stack_1.png

一眼就能看出崩溃是在哪个方法产生的,本地开发的时候可能习以为常,但是线上运行的时候,当你的代码已经被转成一堆二进制,怎么去恢复这个调用栈,从而能够定位到问题,就不再是一件简单的事情。

这篇文章就来探讨这个问题。

什么是调用栈?

这里有几个概念,线程栈、栈帧、调用栈。

线程栈和调用栈,可以理解成 "同一个东西的两个视角"。

线程栈是一段真实的内存地址范围

每个线程都有自己的栈空间,用来管理函数调用产生的栈帧和部分局部数据。

某一次函数调用在当前线程栈中占用的部分,叫做一个栈帧

调用栈,强调当前尚未返回的函数调用顺序。每调用一个函数,就增加一个栈帧;函数返回后,对应的栈帧消失。

stack_2.png

其实就像一辆火车一样,线程栈是这辆火车,栈帧就像一节节车厢,每增加一个函数调用,就加一节车厢。

  主线程的线程栈
  └── main()
      └── viewDidLoad()
          └── startCrashFlow()
              └── loadData()
                  └── triggerCrash()  ← 当前执行位置/栈顶

知道这个结构之后,我们来看函数调用中究竟发生了什么,不过在此之前,需要先知道一些寄存器的概念。

寄存器

寄存器是 CPU 内部的一小组高速存储位置。CPU 执行机器指令时,需要用它们保存:

  • 当前执行到哪条指令
  • 当前线程栈在哪里
  • 函数返回后去哪里
  • 函数参数和返回值
  • 正在参与计算的整数、地址或中间结果
  • 最近一次 CPU 异常的底层信息

每条线程都有自己的一套 "逻辑寄存器状态"。

啥意思呢?

当操作系统把 CPU 从线程 A 切换到线程 B 的时候,会保存 A 的寄存器、恢复 B 的寄存器。这个过程就是所谓的 上下文切换

寄存器可以分两块去看,一块是 basic,一块是 exception

ARM64 下有 31 个 64 位通用寄存器:

  • x0~x7:整数 / 指针参数和返回值;调用者保存
  • x8:间接返回大型结果
  • x9~x15:临时寄存器;调用者保存
  • x16、x17IP0/IP1,连接器跳板和临时计算
  • x18:Apple 平台保留,不应使用
  • x19~x28:被调用函数需要负责恢复的寄存器
  • x29 / fp:Frame Pointer,栈帧指针
  • x30 / lr:Link Register,函数返回地址

另外,还有一些特殊的寄存器:

  • sp:Stack Pointer,线程当前栈顶
  • pc:Program Counter,当前执行指令地址
  • cpsr / nzcv:CPU 状态和条件标志

异常状态下,还可能包含:

  • far:发生内存访问错误的地址
  • esr:异常原因编码

fp

FP 是 Frame Pointer,帧指针,在 ARM 64 中对应 x29

一个函数被调用后,会在当前线程栈上建立自己的栈帧。FP 通常指向当前栈帧中的 frame record,frame record 保存:

  • 上一层函数的 FP
  • 返回上一层函数的地址

所以它可以形成:

当前 FP
-> 上一层 FP
-> 再上一层 FP
-> ...

这是展开调用栈的一条重要路径。

lr

LR 是 Link Register,链接寄存器,对应 x30

ARM64 执行带链接的函数调用指令时,会把返回地址放入 LR

函数 A 调用函数 B

LR 保存 B 执行完后返回 A 的地址

sp

SP 是 Stack Pointer,栈指针。

指向当前线程栈的活动位置。

每条线程都有自己的栈,因此也有自己的 SP

pc

Program Counter,程序计数器。

表示线程当前执行到的对应指令位置。

知道这个之后,再看函数调用。

函数调用

下面这段代码:

@inline(never)
	func add(_ a: Int, _ b: Int) -> Int {
	let result = a + b
	return result
}

let total = add(10, 20)

使用编译器生成汇编代码,会得到(省略了一部分):

  _main:
      sub     sp, sp, #64
      stp     x29, x30, [sp, #48]
      add     x29, sp, #48

      mov     w8, #10
      mov     x0, x8

      mov     w8, #20
      mov     x1, x8

      bl      _$s4main3addyS2i_SitF

      adrp    x8, _$s4main5totalSivp@PAGE
      str     x0, [x8, _$s4main5totalSivp@PAGEOFF]

      ; print(total) 对应的汇编不属于本节,省略

      ldp     x29, x30, [sp, #48]
      add     sp, sp, #64
      ret

然后是 add 方法:

  _$s4main3addyS2i_SitF:  // add
      sub     sp, sp, #32

      str     xzr, [sp, #24]
      str     xzr, [sp, #16]
      str     xzr, [sp, #8]

      str     x0, [sp, #24]
      str     x1, [sp, #16]

      adds    x8, x0, x1
      str     x8, [sp]

      cset    w8, vs
      tbnz    w8, #0, LBB1_2
      b       LBB1_1

  LBB1_1:
      ldr     x0, [sp]
      str     x0, [sp, #8]

      add     sp, sp, #32
      ret

  LBB1_2:
      brk     #0x1

汇编中的一些概念

首先是寄存器,前面已经说过了,但是这里还出现了一个 xzr 寄存器,它其实是 Zero Regsister,读取它永远得到零。

还有 w8 寄存器,其实它是 x8 的低 32 位,因为 ARM64 的寄存器是 64 位的,x8 是完整的 64 位。

执行:

mov w8, #10

会把 10 写入 w8,并把 x8 的高 32 位清空,所以最终:

x8 = 10

立即数

汇编中的 # 表示直接写在指令里的常量:

  #64
  #32
  #10
  #20

例如:

sub sp, sp, #64

表示:

sp = sp - 64

内存地址写法

[sp, #24]

表示 sp 指向的地址,再向高地址偏移 24 字节。

例如:

str x0, [sp, #24]

表示把 x0 的值写入:内存地址 sp + 24

指令

一些常用指令的含义:

指令含义
mov把一个值复制到寄存器
sub做减法
add做加法,不更新状态标志
adds做加法,同时更新 CPU 状态标志
strStore Register,把寄存器写入内存
ldrLoad Register,从内存读取到寄存器
stpStore Pair,一次保存两个寄存器
ldpLoad Pair,一次恢复两个寄存器
blBranch with Link,调用函数
ret返回调用者
adrp取得某个符号所在内存页的基地址
cset根据 CPU 状态设置寄存器为 0 或 1
tbnz检查某一位,不为零就跳转
b无条件跳转
brk触发处理器断点异常

在这个例子中,最重要的是:

bl
ret
sub sp
add sp

调用函数、返回到调用者、做加法、做减法,这个过程就体现了函数的调用和返回。

下面逐句分析,先从 _main 开始。

_main

Swift 顶层代码被编译成了 Mach-O 的 _main 函数。

  _main:
      sub     sp, sp, #64
      stp     x29, x30, [sp, #48]
      add     x29, sp, #48

      mov     w8, #10
      mov     x0, x8

      mov     w8, #20
      mov     x1, x8

      bl      _$s4main3addyS2i_SitF

      adrp    x8, _$s4main5totalSivp@PAGE
      str     x0, [x8, _$s4main5totalSivp@PAGEOFF]

      ; print(total) 对应的汇编不属于本节,省略

      ldp     x29, x30, [sp, #48]
      add     sp, sp, #64
      ret

可以把 _main 分成几个部分:

  1. 建立栈帧
  2. 准备第一个参数
  3. 准备第二个参数
  4. 调用 add
  5. 接收返回值

1. 建立栈帧

sub sp, sp, #64

新 sp = 旧 sp - 64

sp 减少 64,为 main 分配 64 字节栈空间,也就是当前 main 使用的栈帧空间。

为什么需要分配栈空间呢?

因为 main 需要保存:

  • 原来的 x29 / fp
  • 原来的 x30 / lr
  • 局部变量
  • 调用其他函数时使用的临时数据

x29 就是 fp,帧指针,x30lr,这个是 main 最终应该返回的地址。

接下来:

stp x29, x30, [sp, #48]

把进入 main 时的旧 x29x30 保存到:

  sp + 48 = S0 - 16
  sp + 56 = S0 - 8

得到:

  S0 - 16:旧 x29
  S0 - 8 :旧 x30

因为:

  • x29 指向调用者的 frame record,后面要靠它回到上一层
  • x30main 自己的返回地址
  • main 后面执行新的 bl 的话就会覆盖 x30,所以要先提前保存

然后:

add x29, sp, #48

执行后:x29 = S0 - 16,也就是让 x29 指向广告保存旧 x29 的位置。

此时:

  [x29]     = 调用者的 x29
  [x29 + 8] = main 的返回地址

这两个连续的 64 位值就叫做 frame record

  x29
   
   
  ┌──────────────────────────┐
   调用者的 x29               [x29]
  ├──────────────────────────┤
   当前函数的返回地址 x30     [x29 + 8]
  └──────────────────────────┘

这里不能指向 sp,因为 sp 在函数执行过程中可能继续改变,而 x29 建立后一般不动。

然后,每个 frame record 的第一个值,都是调用者的 x29

  当前 x29
   [当前 x29]
   调用者 x29
   [调用者 x29]
   更上一层 x29

这样就形成一条可以向上追溯的链表。

然后,只要知道 x29,就可以通过固定位置得到:

  [x29]      调用者的 frame record
  [x29 + 8]  当前函数的返回地址ß

这就是调试器还原调用链的一种基础方式。

ARM64 约定要求 frame record 包含 "前一个 FP" 和 "进入当前函数时的 LR",当前 FP 指向这条 frame record,但是 frame record 位于整个栈帧的哪个位置,由编译器决定。

这里偏移为什么是 48

整个栈帧: 64 字节
frame record: 16 字节
64 - 16 = 48

在这里编译器把 16 字节的 frame record 放在栈帧高地址一端,低地址的 48 字节留给其他数据。

2. 准备参数

mov w8, #10
mov x0, x8

最终得到 x0 = 10

调用约定规定,第一个普通整数参数使用 x0,所以这里使用的是 x0

它对应着 a = 10

然后:

mov w8, #20
mov x1, x8

得到:x1 = 20,对应的是 b = 20

这里先经过 w8/x8 中转,我用的编译模式是 -Onone,然后这个是编译器生成代码时选择了这个方式,并不是要求必须中转。

这样处理完后,调用前的状态就是:

x0 = 10
x1 = 20

3. 调用 add

bl _$s4main3addyS2i_SitF

这个符号还原后是:

main.add(Swift.Int, Swift.Int) -> Swift.Int

bl 做两件事:

  x30 = bl 后面那条指令的地址
  pc  = add 的入口地址

add 执行结束后,需要知道应该回到 main 的哪里继续执行,所以这里需要把下一条指令写入 x30

这里需要注意的是:

ARM64 的 bl 不会自动建立完整栈帧,也不会自动把所有东西压进栈中。

它只负责跳转并记录返回地址。函数需要多少栈空间,由函数自己的汇编决定。

4. 接收并保存返回值

add 返回后:x0 = 30

然后:

adrp x8, _$s4main5totalSivp@PAGE

取得顶层变量 total 所在内存页的基地址。

机器指令最终需要的是内存地址,编译器必须先找到 total 的地址,才能写入。

接着:

str x0, [x8, _$s4main5totalSivp@PAGEOFF]

x0 中的 30 写进 total,此时 total = 30

5. main 恢复并返回

ldp x29, x30, [sp, #48]

从进入 main 时建立的 frame record 中恢复:

  x29 = 调用者的帧指针
  x30 = main 的返回地址

这一步和前面的 stp x29, x30, [sp, #48] 相对应。

然后:

add sp, sp, #64

恢复进入 main 前的栈顶:sp = S0

所谓的释放栈帧,并不是清空那 64 字节,而是移动 sp,宣布那片空间不再属于当前函数。

最后:

ret

x30 中的返回地址送回 pc,回到调用 main 的运行时。

add

  _$s4main3addyS2i_SitF:  
      sub     sp, sp, #32

      str     xzr, [sp, #24]
      str     xzr, [sp, #16]
      str     xzr, [sp, #8]

      str     x0, [sp, #24]
      str     x1, [sp, #16]

      adds    x8, x0, x1
      str     x8, [sp]

      cset    w8, vs
      tbnz    w8, #0, LBB1_2
      b       LBB1_1

  LBB1_1:
      ldr     x0, [sp]
      str     x0, [sp, #8]

      add     sp, sp, #32
      ret

  LBB1_2:
      brk     #0x1

1. 分配栈空间

sub sp, sp, #32

add 分配 32 字节栈空间。

当前编译结果中的布局是:

  sp + 24:参数 a
  sp + 16:参数 b
  sp + 8 :局部变量 result
  sp     :临时计算结果

这个布局是编译器决定的。

2. 初始化栈槽位

str xzr, [sp, #24]
str xzr, [sp, #16]
str xzr, [sp, #8]

xzr 永远表示零,所以这三条相关栈槽位初始化为零。

3. 保存参数

str x0, [sp, #24]
str x1, [sp, #16]

进入函数时:

x0 = 10
x1 = 20

所以执行后:

[sp + 24] = 10
[sp + 16] = 20

4. 计算加法

adds x8, x0, x1

计算:x8 = 10 + 20 = 30

这里使用的是 adds,Swift 普通 Int 加法需要检查是否有符号整数溢出,adds 在计算结果之外,还会设置 CPU 的状态标志,包括溢出标志 V

然后:

str x8, [sp]

把结果 30 暂存到栈中,因为接下来的溢出检查会重新使用 w8,编译器先把计算结果保存下来,避免被后面的指令覆盖。

5. 检查溢出

cset w8, vs

vs 表示 Overflow Set:

发生溢出:w8 = 1
没有溢出:w8 = 0

这个例子中没有溢出,所以:w8 = 0

然后:

tbnz w8, #0, LBB1_2

检查 w0 的第 0 位,如果不为 0,跳转到溢出处理 LBB1_2

接着:

b LBB1_1

没有进入溢出分支,就跳到正常返回路径。

6. 准备返回值

正常路径:

  LBB1_1:
      ldr x0, [sp]

从栈中取回刚才保存的计算结果:x0 = 30

调用约定从 x0 获取简单整数返回值。

然后:

str x0, [sp, #8]

30 保存到源码局部变量 result 对应的栈槽位。

7. 释放栈帧并返回

add sp, sp, #32

恢复进入 add 前的栈顶。

必须要先恢复 sp,因为调用者 main 期望看到调用前的栈状态。如果 add 不恢复,main 的栈位置就全部错位。

然后:

ret

跳转到 x30 保存的地址,也就是 mainbl add 后面的位置。

8. 溢出路径

  LBB1_2:
      brk #0x1

如果发生整数溢出,brk 触发处理器异常,不再正常返回。

这条路径不会按照正常函数返回流程继续执行,而是进入了异常处理,这里的 sp 没有恢复。

叶子函数

刚刚 add 函数中,没有设置新的 x29,因为 add 没有继续调用其他函数,它是 叶子函数

add 中:

  • 没有新的 bl 覆盖 x30
  • 返回地址可以一直留在 x30
  • 编译器不必再把 x30 保存到栈上
  • 也可以不建立新的 x29 frame record

所以:

分配了栈空间,不代表一定会建立新的帧指针记录。

此时 x29 仍然指向 mainframe record

## 栈溢出

每个线程的栈空间是有限的。每调用一次函数,通常都需要使用一些栈空间,比如刚刚的 Demo 中调用 add

sub sp, sp, #32

表示 add 需要使用 32 字节栈空间。

假设一个函数不断递归调用自己,那么调用关系就会不断增长,直到超出该线程可以使用的栈空间。此时当代码再访问越界后的栈地址时,就会发生内存访问失败,然后崩溃,这就是 栈溢出

函数调用小结

add 函数中断个点,调试器会显示:

0 add
1 main

但内存里没有直接保存 addmain 这两个函数名称。

真正存在的是:

  • pc:当前正在执行 add
  • spadd 当前使用的栈位置
  • x30add 返回 main 的地址
  • x29mainframe record
  • [x29]main 调用者的 frame record
  • [x29 + 8]main 的返回地址
  • CFI:如何恢复上一层寄存器和栈位置

调试器把这些地址映射回 Mach-O 中的函数和符号,才最终显示:

add 
main

关键就在于下面这些东西,把它们给串起来:

bl 负责记录 "返回到哪里"
sp 负责管理 "当前函数使用哪片栈空间"
x29/frame record 负责连接上一个栈帧
x0 负责传递参数和返回值
ret 负责回到调用者
CFI 告诉栈展开器怎样恢复上一层状态

结尾

本着短小精悍的态度,这篇文章先不展开如何获取线程调用栈和符号化的这个过程。