APM_函数调用栈展开

前言

上一篇 读懂 iOS 的线程调用栈,介绍了线程栈、栈帧、以及常用寄存器 PCSPFPLR 在函数调用中的作用,那么当 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 读取 PCSPFPLR,然后直接记录:

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 只给出了调用者的 PCcaller 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-x28d8-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 PCcaller SPcaller 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 复制代码
地址 -> 函数名
地址 -> 文件名和代码行
变量 -> 类型和存储位置
当前寄存器 -> 上一层调用帧

DWARF 5 规范

编译大致流程是:

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 计算出调用者的 PCSPFP。重复这个过程,得到整条 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 地址和大小。

第二步是找到覆盖当前 PCFDE

ksdwarf_findFDE() 中,线性扫描 __eh_freme

  1. 读取一条记录的长度
  2. 判断它是 CIE 还是 FDE
  3. 如果是 FDE,通过其中的反向偏移找到对应的 CIE
  4. 解析 FDE 中的 pcStartpcRange
  5. 判断当前 PC 是否满足:

pcStart <= currentPC < pcStart + pcRange

满足时,就找到了负责当前代码位置的 FDE。

这里不是通过函数名查找,整个过程只比较地址范围。

第三步,把 CFI 指令执行成一张恢复表。

找到 CIE 和 FDE 后,依次:

  1. 执行 CIE 中的初始指令,建立默认规则
  2. 保存这份初始状态
  3. 从 FDE 覆盖范围的起点开始执行 FDE 指令
  4. 只执行到当前 PC 所对应的位置
  5. 得到当前 PCCFI 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 从当前函数逐层恢复调用者的完整栈展开过程。

总结

栈展开的核心,是从线程当前的 PCSPFPLR 出发,恢复调用者的 PCSPFP,再把调用者作为新的当前帧,不断重复这个过程,直到无法继续展开。

KSCrash 的 ARM64 实现中,先记录当前 PC,并处理 LR,然后后续每一层依次尝试:

swift 复制代码
  Compact Unwind
        ↓ 失败
  DWARF
        ↓ 失败
  Frame Pointer
        ↓ 失败
  停止展开

Compact Unwind 和 DWARF 使用编译器留下的展开信息,Frame Pointer 就是按标准帧记录进行兜底。任何一种方式成功,都会得到上一层调用者的状态,然后继续恢复下一层。

所以,调用栈并不是内存中已经有了的一串函数名,而是根据寄存器、栈内存和展开的元数据,逐层去计算出来的一组地址。这组地址再经过符号化,才成为最终在调试器或崩溃报告中看到的函数调用链。

相关推荐
极客猴子5 小时前
会议记录带日历功能的iOS录音软件推荐:按日期查找会议内容
ios
2501_915918417 小时前
Rust 程序抓包解密,rustls 不认系统证书的几种办法
开发语言·后端·网络协议·ios·adb·https·rust
MaoJiu7 小时前
「Landmark-Driven Beauty Rendering:实时磨皮与腮红的算法与 GPU 实现」
ios
冯汉栩1 天前
Swift Control RollingCountdownView(动画倒计时)
开发语言·ios·swift
_瑞1 天前
读懂 iOS 的线程调用栈
ios·编译原理·汇编语言
末代iOS程序员华仔1 天前
iOS 语聊直播/聊天APP4.3条例被拒解决
flutter·ios·swift
2601_965798472 天前
Build a Fast, High-Ranking Restaurant Website with Rolanda Theme
开发语言·ios·swift
极客互动API2 天前
企业微信 iPad 协议消息接口开发:文本 / 图片 / 群发消息的统一封装
人工智能·ios·微信·机器人·企业微信·ipad
我血条子呢2 天前
iOS 软键盘遮挡底部输入框解决报告
ios