读懂 iOS 的线程调用栈

开头

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

swift 复制代码
override func viewDidLoad() {
	super.viewDidLoad()
	
	let arrayA = [1...10]
	print(arrayA[10])
}

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

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

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

什么是调用栈?

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

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

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

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

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

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

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

scss 复制代码
  主线程的线程栈
  └── 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
  • 返回上一层函数的地址

所以它可以形成:

swift 复制代码
当前 FP
-> 上一层 FP
-> 再上一层 FP
-> ...

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

lr

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

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

swift 复制代码
函数 A 调用函数 B
↓
LR 保存 B 执行完后返回 A 的地址

sp

SP 是 Stack Pointer,栈指针。

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

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

pc

Program Counter,程序计数器。

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

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

函数调用

下面这段代码:

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

let total = add(10, 20)

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

swift 复制代码
  _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 方法:

swift 复制代码
  _$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

立即数

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

swift 复制代码
  #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 状态标志
str Store Register,把寄存器写入内存
ldr Load Register,从内存读取到寄存器
stp Store Pair,一次保存两个寄存器
ldp Load Pair,一次恢复两个寄存器
bl Branch with Link,调用函数
ret 返回调用者
adrp 取得某个符号所在内存页的基地址
cset 根据 CPU 状态设置寄存器为 0 或 1
tbnz 检查某一位,不为零就跳转
b 无条件跳转
brk 触发处理器断点异常

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

swift 复制代码
bl
ret
sub sp
add sp

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

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

_main

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

swift 复制代码
  _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. 建立栈帧

swift 复制代码
sub sp, sp, #64

新 sp = 旧 sp - 64

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

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

因为 main 需要保存:

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

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

接下来:

swift 复制代码
stp x29, x30, [sp, #48]

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

swift 复制代码
  sp + 48 = S0 - 16
  sp + 56 = S0 - 8

得到:

swift 复制代码
  S0 - 16:旧 x29
  S0 - 8 :旧 x30

因为:

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

然后:

swift 复制代码
add x29, sp, #48

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

此时:

swift 复制代码
  [x29]     = 调用者的 x29
  [x29 + 8] = main 的返回地址

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

swift 复制代码
  x29
   │
   ▼
  ┌──────────────────────────┐
  │ 调用者的 x29             │ ← [x29]
  ├──────────────────────────┤
  │ 当前函数的返回地址 x30   │ ← [x29 + 8]
  └──────────────────────────┘

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

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

swift 复制代码
  当前 x29
  → [当前 x29]
  → 调用者 x29
  → [调用者 x29]
  → 更上一层 x29

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

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

swift 复制代码
  [x29]     → 调用者的 frame record
  [x29 + 8] → 当前函数的返回地址ß

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

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

这里偏移为什么是 48

swift 复制代码
整个栈帧: 64 字节
frame record: 16 字节
64 - 16 = 48

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

2. 准备参数

swift 复制代码
mov w8, #10
mov x0, x8

最终得到 x0 = 10

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

它对应着 a = 10

然后:

swift 复制代码
mov w8, #20
mov x1, x8

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

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

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

swift 复制代码
x0 = 10
x1 = 20

3. 调用 add

swift 复制代码
bl _$s4main3addyS2i_SitF

这个符号还原后是:

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

bl 做两件事:

swift 复制代码
  x30 = bl 后面那条指令的地址
  pc  = add 的入口地址

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

这里需要注意的是:

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

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

4. 接收并保存返回值

add 返回后:x0 = 30

然后:

swift 复制代码
adrp x8, _$s4main5totalSivp@PAGE

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

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

接着:

swift 复制代码
str x0, [x8, _$s4main5totalSivp@PAGEOFF]

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

5. main 恢复并返回

swift 复制代码
ldp x29, x30, [sp, #48]

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

swift 复制代码
  x29 = 调用者的帧指针
  x30 = main 的返回地址

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

然后:

swift 复制代码
add sp, sp, #64

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

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

最后:

swift 复制代码
ret

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

add

swift 复制代码
  _$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. 分配栈空间

swift 复制代码
sub sp, sp, #32

add 分配 32 字节栈空间。

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

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

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

2. 初始化栈槽位

swift 复制代码
str xzr, [sp, #24]
str xzr, [sp, #16]
str xzr, [sp, #8]

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

3. 保存参数

swift 复制代码
str x0, [sp, #24]
str x1, [sp, #16]

进入函数时:

swift 复制代码
x0 = 10
x1 = 20

所以执行后:

swift 复制代码
[sp + 24] = 10
[sp + 16] = 20

4. 计算加法

swift 复制代码
adds x8, x0, x1

计算:x8 = 10 + 20 = 30

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

然后:

swift 复制代码
str x8, [sp]

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

5. 检查溢出

swift 复制代码
cset w8, vs

vs 表示 Overflow Set:

swift 复制代码
发生溢出:w8 = 1
没有溢出:w8 = 0

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

然后:

swift 复制代码
tbnz w8, #0, LBB1_2

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

接着:

swift 复制代码
b LBB1_1

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

6. 准备返回值

正常路径:

swift 复制代码
  LBB1_1:
      ldr x0, [sp]

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

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

然后:

swift 复制代码
str x0, [sp, #8]

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

7. 释放栈帧并返回

swift 复制代码
add sp, sp, #32

恢复进入 add 前的栈顶。

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

然后:

swift 复制代码
ret

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

8. 溢出路径

swift 复制代码
  LBB1_2:
      brk #0x1

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

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

叶子函数

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

add 中:

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

所以:

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

此时 x29 仍然指向 mainframe record

## 栈溢出

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

swift 复制代码
sub sp, sp, #32

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

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

函数调用小结

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

swift 复制代码
0 add
1 main

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

真正存在的是:

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

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

swift 复制代码
add 
main

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

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

结尾

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

相关推荐
末代iOS程序员华仔5 小时前
iOS 语聊直播/聊天APP4.3条例被拒解决
flutter·ios·swift
2601_965798471 天前
Build a Fast, High-Ranking Restaurant Website with Rolanda Theme
开发语言·ios·swift
极客互动API1 天前
企业微信 iPad 协议消息接口开发:文本 / 图片 / 群发消息的统一封装
人工智能·ios·微信·机器人·企业微信·ipad
我血条子呢1 天前
iOS 软键盘遮挡底部输入框解决报告
ios
思盛iOS签名上架1 天前
为什么 iOS 需要签名?未签名 App 无法安装的底层逻辑
ios
Data_Journal2 天前
Golang 中解析 HTML 的指南
ios·iphone
jike_20262 天前
销售拜访记录APP推荐:客户沟通内容怎么快速整理?
ios·语音识别·iphone
00后程序员张2 天前
iOS 开发全流程需要哪些工具?编码、调试、构建、发布阶段选择
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程