前言
上一篇 读懂 iOS 的线程调用栈,介绍了线程栈、栈帧、以及常用寄存器 PC、SP、FP、LR 在函数调用中的作用,那么当 App 上线运行后,发生了一个崩溃,究竟是怎样从这些寄存器和栈内存中,还原出我们在本地开发的时候,在 Xcode 中看到的那条调用链。
主要是两个过程:
- 栈展开
- 符号化
先区分几个概念:
- 线程栈(栈内存):线程的一块内存区域,存放局部变量、保存的寄存器、返回地址等
- 调用栈(调用关系):例如
start -> main -> add -> add1 -> add2 -> ... - Backtrace:把当前仍未返回的调用关系记录成一组指令地址
在栈内存中,其实并没有方法名的这些字符串:
swift
add
main
start
需要经过栈展开后,拿到一堆地址:
swift
0x0000000100003f20
0x0000000100004088
0x0000000187a12340
也就是还没有符号化的 backtrace。
之后才通过 Binary Images 和符号信息,也就是符号化,将它们转成类似:
swift
add(a:b:) Add.c:2
main main.c:11
这样的人能看懂的函数名、源文件和代码行号。
这是两个过程:
swift
栈展开
线程寄存器和栈内存
↓
得到一组 PC 地址
↓
符号化
地址 + Binary Image + dSYM
↓
函数名、文件名、行号
也就是说:
- 栈展开回答:调用栈中有哪些地址?
- 符号化回答:这些地址分别是哪一个函数、哪一行代码?
所以所谓的栈展开,就是:
从线程当前的寄存器状态开始,逐层推算出调用者是谁,最终还原整个调用链。
而符号化,就是:
将这个调用链上的地址,转成人能看懂的函数名、源文件和代码行号。
这篇文章单说栈展开。
栈展开
KSCrash 是一个崩溃采集的一个库,下面以它获取调用栈的处理为例,分析这个过程。
它的一个完整的流程是:
swift
找到线程
↓
暂停线程,冻结现场
↓
读取 PC / SP / FP / LR 等寄存器
↓
重置恢复,得到地址数组
1. 找到目标线程
主要三种情况:
- 当前线程:不用找,直接调
backtrace() - 其他线程:
thread_get_state() - 所有线程:
task_threads()
这三个都是系统提供的方法。
backtrace() 会得到一组地址,这串地址已经是 "调用栈" 了:
swift
0x100003F20
0x100004088
0x187A12340
符号化之后:
swift
add
main
start
如果是其他线程,那 KSCrash 就需要负责这个 backtrace() 的工作,通过寄存器等一些信息,逐层去计算返回地址,最终生成一串类似 backtrace() 的一组地址。
2. 暂停线程
如果目标是另一个正在运行的线程,需要先暂停:
thread_suspend(thread);
因为采集的这个过程,可能会导致被采集线程的寄存器、栈内存发生一些变化,如果不暂停就采集的话,拿到的数据就会是错的,不是真正的崩溃现场。
另外如果不是致命的崩溃,比如是卡顿的一些检测,那么在采集完成之后,还需要恢复线程的运行:thread_resume()。
记得负责采集的线程不要把自己给暂停了,不然就没法采集了。
如果是采集所有线程,先通过 task_threads() 拿到所有线程,然后遍历去单个采集。
3. 读取寄存器
暂停线程后,通过:
swift
thread_get_state(
thread,
ARM_THREAD_STATE64,
&state,
&stateCount
);
获得 ARM64 的 arm_thread_state64,这里面有以下寄存器的信息:
| 寄存器 | 含义 | 对于调用栈的作用 |
|---|---|---|
PC:Program Counter |
当前正在执行的指令地址 | 确定第 0 层,也就是线程现在在哪里 |
SP:Stack Pointer |
当前栈顶地址 | 确定当前栈内存的位置 |
FP / x29:Frame Pointer |
帧指针 | 定位保存的上一层 FP 和返回地址 |
LR / x30:Link Register |
链接寄存器 | 通常保存当前函数返回到调用者的位置 |
4. 栈展开
然后正式开始做栈展开这一步。
因为有些函数可能没有自己的 FP 栈帧,比如 demo 中的 add 是个叶子函数,它就没有创建 Frame Record。另外,编译器还会做一些优化,像这种简单的 add 函数,在编译器优化之后,可能都不会存在这个函数。
KSCrash 在 ARM64 上,栈展开的流程是:
swift
取得初始 PC、SP、FP、LR
↓
第 0 层直接记录 PC
↓
初始 LR 不为 0?
├─ 是:先把 LR 记录为第 1 层
│ 同时恢复调用者的 SP、FP
└─ 否:直接进入通用展开
↓
后续每恢复一层,都按顺序尝试:
Compact Unwind
↓ 失败
DWARF
↓ 失败
Frame Pointer
↓ 失败
停止展开
↓
某个方法成功,得到 caller PC/SP/FP
↓
记录 caller PC
↓
把 caller 状态作为新的 current
↓
继续下一轮
1. PC 作为第 0 层
KSCrash 读取 PC、SP、FP、LR,然后直接记录:
frame[0] = PC。
因为 PC 就是线程当前停在的位置,比如:
PC -> add 中的 adds x0, x0, x1
那么第一层(从 0 开始)就确定为:
frame[0] = add
2. 先处理一次 LR
执行代码 bl add 时,CPU 把下一条指令地址保存到 LR:
swift
LR = main 中 bl add 后面的地址
我们 demo 中的 add 是没有 Frame Record 的,所以:
swift
PC -> add
LR -> main
KSCrash 会先记录:
frame[1] = LR
所以得到:
swift
frame[0] = add
frame[1] = main
但 LR 只给出了调用者的 PC,caller PC,为了继续展开,还必须恢复:
swift
caller SP
caller FP
这一步,KSCrash 用的是同一套方法:
Compact Unwind -> DRARF -> Frame Pointer
它们的任务就是把:
add 的 SP、FP -> main 的 SP、FP(这个例子中)
3. Compact Unwind
概念
Compact Unwind(紧凑栈型展开信息)是 Mach-O 中的一种元数据。
它并不是一个独立的文件,而是存放在 Mach-O 中的一段数据,存放的位置在 Mach-O 中的 __TEXT,__unwind_info 中。
Apple SDK 将 Compact Unwind 的核心编码定义为 uint32_t,32 位编码,用于描述 "恢复哪些寄存器、从哪里恢复以及如何离开当前函数"。
在 ARM64 中,它主要有三种模式:
-
FRAME- 标准
x29栈帧 FR/LR保存在栈上- 还可以记录保存了哪些
x19-x28、d8-d15寄存器
- 标准
-
FRAMELESS- 没有标准
x29栈帧 - 编码中记录栈空间大小
- 返回地址仍在
LR
- 没有标准
-
DWARF- 这个函数太复杂,Compact Unwind 表达不了
- 转而指向
__eh_frame中的DWARF FDE
展开过程大致是:
swift
当前 PC
│
▼
在 __unwind_info 中找到当前函数
│
▼
读取 32 位 Compact Unwind 编码
│
├── FRAME ─────► 根据 FP/LR 和保存的寄存器恢复上一帧
│
├── FRAMELESS ─► 根据 SP、栈大小和 LR 恢复上一帧
│
└── DWARF ─────► 跳到 __eh_frame,解释 CIE/FDE
编译和链接的过程大致是:
swift
.o 目标文件
__LD,__compact_unwind
│ 链接器整理、压缩并建立索引
▼
最终 App 的 Mach-O(Framework / dylib)
__TEXT,__unwind_info
.o 中的 __compact_unwind 会被链接器移除并转换成最终的 __unwind_info。
最简单的,可以将其理解为常见栈帧的 "快捷说明书"。
FRAME
如果编码是 FRAME,说明当前函数创建了标准 Frame Record:
swift
[FP] = 保存的 previous FP
[FP + 8] = 保存的 LR
Compact Unwind 就计算:
swift
caller PC = [FP + 8]
caller FP = [FP]
caller SP = FP + 16
然后返回:
caller PC、caller SP、caller FP
FRAMELESS
说明当前函数没有创建 FP Frame Record。
标准 ARM64 Frameless 叶子函数的编码会提供栈空间大小,返回地址保留在 LR:
swift
caller PC = LR
caller SP = SP + 编码中的 stackSize
caller FP = FP
比如 demo 中的 add 就是一个叶子函数:
swift
sub sp, sp, #32
...
add sp, sp, #32
ret
对应的恢复规则就是:
swift
caller PC = LR
caller SP = SP + 32
caller FP = FP
这样就把 add -> main。
DWARF
说明这个函数的布局无法用 Compact Unwind 的固定模式表达。
Compact Unwind 会返回失败,让 KSCrash 进入 DWARF 的尝试。
4. DWARF
概念
DWARF 是一种数据格式规范,不是特指某一个文件。
官方将它定义为供编译器和调试器使用的 "调试信息格式"。
| 概念 | 说明 |
|---|---|
DWARF |
调试元数据的格式规范 |
DWARF 数据 |
按该规范编码的二进制数据 |
| Mach-O | 可以容纳代码和 DWARF 数据的文件容器 |
| dSYM | Apple 用来单独保存 DWARF 调试信息的 Bundle |
_eh_frame |
Mach-O 中保存 DWARF 栈展开信息的 section |
DWARF 里面不是一整块数据,而是由很多 section 组成的:
.debug_info:函数、变量、类型、作用域等信息.debug_abbrev:.debug_info使用的结构定义.debug_line:机器地址 -> 源文件和行号.debug_str:字符串.debug_frame:标准DWARF调用栈帧展开信息eh_frame:运行时使用的DWARF CFI变体
这些内容共同服务于:
swift
地址 -> 函数名
地址 -> 文件名和代码行
变量 -> 类型和存储位置
当前寄存器 -> 上一层调用帧
编译大致流程是:
swift
Swift / Objective-C / C 源码
│ 编译
▼
.o 目标文件
├── 机器代码
└── DWARF 调试信息
│ 链接
├──────────────► App 中的 Mach-O
│ ├── __text
│ ├── __unwind_info
│ └── __eh_frame
│
└──────────────► App.dSYM
└── 完整的 DWARF 调试信息
开启 dSYM 时,dsymutil 会收集各个 .o 文件中的 DWARF,链接后放入 .dSYM Bundle。
所以:
.dSYM是文件夹形式的容器,不等于DWARF本身DWARF数据可以存放于.o、Mach-O、dSYM等不同载体中- App 中的
__eh_frame主要用于运行时栈展开 - dSYM 中的
.debug_info、.debug_line等主要用于符号化和调试
所以:
DWARF是 "如何描述程序调试和栈展开信息" 的格式规范;Mach-O 和 dSYM 是承载这些的容器。
展开的原理
KSCrash 展开 DWARF 的源码主要在 KSDwarfUnwind.c 中。
主要是读取 Mach-O 编译器中留下的 "寄存器恢复规则",针对当前
PC计算出调用者的PC、SP、FP。重复这个过程,得到整条 Backtrace。
一次 DWARF 展开解决的是:
swift
当前帧:PC、SP、FP、LR
↓
调用者帧:返回地址、SP、FP
DWARF 展开需要依赖几个对象。
__eh_frame
它是已加载 Mach-O 中的一个数据段,保存用于异常处理和栈展开的 DWARF CFI 数据。
KSCrash 会从每个 Binary Image 的 __TEXT,__eh_frame 中取得这段数据的地址和大小。
CFI
Call Frame Information,可以理解成:
编译器为每段机器指令留下的 "如何恢复上一层调用状态" 的说明书。
它描述的是:
- 当前代码执行到这里时,
SP被移动了多少 - 返回地址现在在寄存器里,还是已经保存到了栈上
- 调用者的
SP保存在哪里 - 应当怎样计算一个稳定的栈基准地址
CIE 和 FDE
__eh_frame 主要由两类记录组成:
-
CIE,Common Information Entry- 多段代码共用的基础规则,例如地址增量和栈偏移怎么缩放、默认寄存器规则等
-
FDE,Frame Description Entry- 覆盖一段具体
PC地址范围,记录这段代码具体怎么展开
- 覆盖一段具体
可以把它们理解成:
- CIE:这一批代码共用的基础配置
- FDE:
0x1000~0x1100这段代码具体怎么展开
完整过程
swift
当前 PC/SP/FP/LR
↓
确定 PC 属于哪个 Mach-O
↓
取得该 Mach-O 的 __eh_frame
↓
找到覆盖当前 PC 的 FDE,以及它引用的 CIE
↓
执行 CIE 和 FDE 中的 CFI 指令
↓
生成当前 PC 对应的 CFI Row
↓
计算 CFA
↓
恢复返回地址和调用者 FP
↓
调用者 SP = CFA
↓
得到调用者的 PC/SP/FP
首先,找到当前 PC 所在的 Mach-O。
KSCrash 的 DWARF 入口是内部 C 函数 tryDwarfUnwindForPC(),这个不是 Apple 的 API,而是 KSCrash 自己的封装。
先根据当前 PC 查询 Binary Image Cache,获得这个地址对应的 Mach-O,以及已经缓存的 __eh_frame 地址和大小。
第二步是找到覆盖当前 PC 的 FDE。
ksdwarf_findFDE() 中,线性扫描 __eh_freme:
- 读取一条记录的长度
- 判断它是 CIE 还是 FDE
- 如果是 FDE,通过其中的反向偏移找到对应的 CIE
- 解析 FDE 中的
pcStart和pcRange - 判断当前
PC是否满足:
pcStart <= currentPC < pcStart + pcRange
满足时,就找到了负责当前代码位置的 FDE。
这里不是通过函数名查找,整个过程只比较地址范围。
第三步,把 CFI 指令执行成一张恢复表。
找到 CIE 和 FDE 后,依次:
- 执行 CIE 中的初始指令,建立默认规则
- 保存这份初始状态
- 从 FDE 覆盖范围的起点开始执行 FDE 指令
- 只执行到当前
PC所对应的位置 - 得到当前
PC的CFI Row
CFI Row 可以理解成当前机器指令位置在的一张恢复表,例如:
swift
CFA = SP + 64
返回地址 x30 = 内存[CFA - 8]
调用者 FP x29 = 内存[CFA - 16]
之所以必须精确到当前 PC,是因为同一个函数里的栈布局也会变化。例如函数序言可能依次执行:
- 先移动
SP - 再保存
FP - 再保存
LR
崩溃发生在这些步骤之前、之中或之后,返回地址所在位置可能不同。
CFA
CFA,Canonical Frame Address,是展开规则使用的一个稳定参考地址。
当前函数执行过程中,SP 可能会不断的变化:
swift
刚进入函数:SP = 0x1040
分配局部变量后:SP = 0x1000
所以 CFI 先定义:
CFA = 当前某个寄存器的值 + 偏移
可以理解为一个基地址的概念。
例如:
swift
CFA = SP + 64
KSCrash 计算出 CFA 后,会把它直接作为调用者的 SP:
swift
callerSP = CFA
然后其他寄存器都相对于 CFA 恢复。
示例
假设当前帧是:
swift
PC = 0x400120
SP = 0x1000
FP = 0x1080
KSCrash 找到覆盖 0x400120 的 FDE,执行 CFI 后得到:
swift
CFA = SP + 0x40
返回地址 x30 = Offset(-8)
调用者 FP x29 = Offset(-16)
当前栈内存中保存着:
swift
memory[0x1038] = 0x300500
memory[0x1030] = 0x1100
KSCrash 依次计算:
swift
CFA = 0x1000 + 0x40
= 0x1040
返回地址位置 = CFA - 8
= 0x1038
callerPC = memory[0x1038]
= 0x300500
调用者 FP 位置 = CFA - 16
= 0x1030
callerFP = memory[0x1030]
= 0x1100
callerSP = CFA
= 0x1040
这次 DWARF 展开的最终结果是:
swift
当前帧:
PC = 0x400120
SP = 0x1000
FP = 0x1080
调用者帧:
PC = 0x300500
SP = 0x1040
FP = 0x1100
然后把这组调用者状态放回 cursor,下一轮再根据 0x300500 - 1 找对应的 FDE。不断重复,最终得到一串返回地址。
5. Frame Pointer
Frame Pointer 是在前面两个方法都失败之后的兜底。
它直接假设当前 FP 指向标准 Frame Record:
swift
FP
↓
┌───────────────────┐
│ previous FP │ [FP]
├───────────────────┤
│ saved LR │ [FP + 8]
└───────────────────┘
然后计算:
swift
caller PC = [FP + 8]
caller FP = [FP]
caller SP = FP + 16
成功后返回调用者状态。
也就是在没有函数对应的展开说明时,直接尝试按照标准 FP 链格式恢复调用者。
6. 实例
断点断到 add 方法中,此时:
swift
PC = add 中当前地址
SP = add 的 SP
FP = main 的 FP
LR = main 中 bl add 后面的地址
第一次:
swift
记录 PC
-> frame[0] = add
第二次,ARM64 特殊处理:
swift
记录 LR
-> frame[1] = main
同时根据 add 展开规则恢复:
swift
main SP
main FP
现在内部状态已经切换成 main:
swift
current PC = main 中的返回位置
current SP = main 的 SP
current FP = main 的 SP
下一轮,尝试 Compact Unwind,main 是标准 FRAME 模式:
swift
caller PC = [main FP + 8]
caller FP = [main FP]
caller SP = main FP + 16
得到:
swift
frame[2] = runtime 中的返回位置
然后把 runtime 状态作为新的 current,继续:
swift
Compact
失败 → DWARF
失败 → Frame Pointer
失败 → 停止
最后得到地址序列:
swift
frame[0] = add 地址
frame[1] = main 地址
frame[2] = runtime 地址
...
这就是 KSCrash 从当前函数逐层恢复调用者的完整栈展开过程。
总结
栈展开的核心,是从线程当前的 PC、SP、FP、LR 出发,恢复调用者的 PC、SP、FP,再把调用者作为新的当前帧,不断重复这个过程,直到无法继续展开。
KSCrash 的 ARM64 实现中,先记录当前 PC,并处理 LR,然后后续每一层依次尝试:
swift
Compact Unwind
↓ 失败
DWARF
↓ 失败
Frame Pointer
↓ 失败
停止展开
Compact Unwind 和 DWARF 使用编译器留下的展开信息,Frame Pointer 就是按标准帧记录进行兜底。任何一种方式成功,都会得到上一层调用者的状态,然后继续恢复下一层。
所以,调用栈并不是内存中已经有了的一串函数名,而是根据寄存器、栈内存和展开的元数据,逐层去计算出来的一组地址。这组地址再经过符号化,才成为最终在调试器或崩溃报告中看到的函数调用链。